rojects/workspace/outline/rojo-chat-tools.html

390 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rojo Chat — LLM Tools — Roject</title>
<link rel="stylesheet" href="../_assets_/styles.css">
<link rel="stylesheet" href="../_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<p class="date">Plan — To Do</p>
<h1>Rojo Chat — LLM Tools</h1>
<p class="subtitle">
Extend the Rojo Chat agent with a built-in tool set — file system read/write operations
the model can call during a conversation. Generic across any OpenAI-compatible provider
(Claude, Gemini, Mistral, local models).
</p>
</header>
<section>
<h2>What it is</h2>
<div class="card">
<p>
LLM tool use (also called function calling) lets a model request that the server
run a named function and return the result, then continue generating text using
that result as context. The model never executes code directly — it emits a
structured tool call, the server runs it, and the result is fed back as a message.
</p>
<p style="margin-top:0.75rem">
For Rojo Chat this unlocks agentic workflows: a user can ask the Rojo to
"create a new page at docs/intro and add a heading and some intro text" and the
model will call <code>create_directory</code>, then <code>write_text_content</code>,
then reply with a summary — all in a single conversation turn.
</p>
<p style="margin-top:0.75rem">
The tool format used is the OpenAI function-calling standard, which is supported
by all major providers at their OpenAI-compatible endpoints:
Claude (<code>api.anthropic.com/v1</code>),
Gemini (<code>generativelanguage.googleapis.com/v1beta/openai/</code>),
Mistral (<code>api.mistral.ai/v1</code>), and local models via Ollama or LM Studio.
No provider-specific branching is needed in the implementation.
</p>
</div>
</section>
<section>
<h2>Built-in Tools — First Set</h2>
<div class="card">
<p>
All tools operate on the current project's file system, scoped to
<code>storage/&lt;projectId&gt;/root/</code>. Paths are always relative to
the project root. Access checks already in the chat route ensure only authorised
users can trigger tool execution.
</p>
</div>
<div class="card">
<h3>read_path_meta</h3>
<p>Get metadata for a path — whether it is a file or directory, size in bytes, and last-modified time.</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">{
"name": "read_path_meta",
"description": "Get metadata for a file or directory: type (file/directory), size in bytes, and last-modified time.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." }
},
"required": ["path"]
}
}</pre>
<p>Returns: <code>{ type: "file"|"directory", size: number, modifiedAt: string }</code></p>
<p style="margin-top:0.5rem">Server implementation: <code>fs.stat()</code></p>
</div>
<div class="card">
<h3>get_text_content</h3>
<p>Read and return the text content of a file.</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">{
"name": "get_text_content",
"description": "Read and return the text content of a file.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." }
},
"required": ["path"]
}
}</pre>
<p>Returns: file content as a string.</p>
<p style="margin-top:0.5rem">Server implementation: <code>readProjectFile()</code> — already in the storage layer.</p>
</div>
<div class="card">
<h3>write_text_content</h3>
<p>Write text to a file, creating it if it does not exist or overwriting it if it does.</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">{
"name": "write_text_content",
"description": "Write text content to a file, creating or overwriting it.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." },
"text": { "type": "string", "description": "The text content to write." }
},
"required": ["path", "text"]
}
}</pre>
<p>Returns: <code>{ ok: true }</code> on success.</p>
<p style="margin-top:0.5rem">Server implementation: <code>writeProjectFile()</code> — already in the storage layer.</p>
</div>
<div class="card">
<h3>list_files</h3>
<p>List the entries (files and subdirectories) inside a directory.</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">{
"name": "list_files",
"description": "List the files and subdirectories inside a directory.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." }
},
"required": ["path"]
}
}</pre>
<p>Returns: <code>{ name: string, type: "file"|"directory" }[]</code></p>
<p style="margin-top:0.5rem">Server implementation: <code>fs.readdir(dir, { withFileTypes: true })</code></p>
</div>
<div class="card">
<h3>create_directory</h3>
<p>Create a directory (and any missing parent directories) at the given path.</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">{
"name": "create_directory",
"description": "Create a directory and any missing parent directories at the given path.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." }
},
"required": ["path"]
}
}</pre>
<p>Returns: <code>{ ok: true }</code> on success.</p>
<p style="margin-top:0.5rem">Server implementation: <code>fs.mkdir(path, { recursive: true })</code></p>
</div>
</section>
<section>
<h2>The Tool Execution Loop</h2>
<div class="card">
<p>
The current <code>RojosAgent.ts</code> calls <code>model.stream(messages)</code>
once and streams text back. With tools, the agent must loop until the model
produces a final text reply:
</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">loop:
stream from model.bindTools(tools)
collect full response
if response contains tool calls:
for each tool call:
→ execute tool on server
→ emit TOOL_CALL event to client
→ emit TOOL_RESULT event to client
add AIMessage (tool calls) + ToolMessages (results) to history
continue loop
if response is text:
stream text chunks to client as CHAT events
add AIMessage to history
break</pre>
<p style="margin-top:0.75rem">
<code>getAgentStream</code> in <code>RojosAgent.ts</code> becomes an
<code>async function*</code> generator that yields typed events instead of
raw LangChain chunks. The route handler in <code>rojos.ts</code> iterates
the generator and serialises each event to the wire protocol.
</p>
</div>
<div class="card">
<h3>Event types emitted by the generator</h3>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">{ type: "text", text: string }
{ type: "tool_call", name: string, args: Record&lt;string, unknown&gt; }
{ type: "tool_result", name: string, result: string }</pre>
<p style="margin-top:0.75rem">
These map to the existing NDJSON wire protocol in <code>rojos.ts</code>:
</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">{ "type": "CHAT", "text": "..." } // unchanged
{ "type": "TOOL_CALL", "name": "...", "args": {...} } // new
{ "type": "TOOL_RESULT","name": "...", "result": "..." } // new
{ "type": "DONE" } // unchanged</pre>
</div>
<div class="card">
<h3>Tool context</h3>
<p>
Every tool execution receives a <code>RojoToolContext</code> carrying the
<code>projectId</code> and the resolved <code>projectRoot</code> path
(<code>storage/&lt;projectId&gt;/root/</code>). Tools use this to scope
all file system operations — no tool ever receives an absolute path from
the model.
</p>
</div>
</section>
<section>
<h2>File Structure</h2>
<div class="card">
<p>
Tools live in their own directory. Each tool is a self-contained class so
it is easy to review, test, and extend independently. A registry class owns
the full list and dispatches execution by name.
</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">source/server/rojos/
RojosAgent.ts — modified: generator loop, tool binding
RojosConfig.ts — unchanged
tools/
RojoTool.ts — abstract base class + RojoToolContext interface
RojoToolRegistry.ts — holds all tool instances, dispatches execute()
ReadPathMetaTool.ts
GetTextContentTool.ts
WriteTextContentTool.ts
ListFilesTool.ts
CreateDirectoryTool.ts</pre>
</div>
<div class="card">
<h3>RojoTool base class</h3>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">export interface RojoToolContext
{
projectId: string;
projectRoot: string;
}
export abstract class RojoTool
{
abstract Definition: object;
abstract execute( args: Record&lt;string, unknown&gt;, ctx: RojoToolContext ): Promise&lt;unknown&gt;;
}</pre>
</div>
<div class="card">
<h3>RojoToolRegistry</h3>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">export class RojoToolRegistry
{
static readonly All: RojoTool[] = [
new ReadPathMetaTool(),
new GetTextContentTool(),
new WriteTextContentTool(),
new ListFilesTool(),
new CreateDirectoryTool(),
];
static definitions(): object[]
{
return RojoToolRegistry.All.map( t => t.Definition );
}
static execute( name: string, args: Record&lt;string, unknown&gt;, ctx: RojoToolContext )
{
const tool = RojoToolRegistry.All.find( t => ( t.Definition as any ).function?.name === name );
if ( !tool ) return Promise.resolve( { error: `Unknown tool: ${ name }` } );
return tool.execute( args, ctx );
}
}</pre>
</div>
</section>
<section>
<h2>Changes to Existing Files</h2>
<div class="card">
<h3>source/server/rojos/RojosAgent.ts</h3>
<ul style="line-height:1.9;margin-top:0.5rem">
<li><code>AgentConfig</code> gets an optional <code>tools: object[]</code> field.</li>
<li><code>getAgentStream</code> becomes an <code>async function*</code> generator
yielding typed events instead of raw LangChain chunks.</li>
<li>When <code>config.tools</code> is non-empty, the model is created with
<code>model.bindTools(config.tools)</code> and the loop handles tool call chunks.</li>
<li><code>updateAgentConversation</code> receives the full collected
<code>AIMessage</code> (including tool calls) and the subsequent
<code>ToolMessage</code> array so history stays consistent.</li>
</ul>
</div>
<div class="card">
<h3>source/server/routes/rojos.ts — POST /chat</h3>
<ul style="line-height:1.9;margin-top:0.5rem">
<li>Passes <code>RojoToolRegistry.definitions()</code> into <code>AgentConfig.tools</code>
when a <code>projectId</code> is present (so the tool context can be resolved).</li>
<li>Passes a <code>RojoToolContext</code> into the generator so tools can be executed.</li>
<li>Serialises the new <code>tool_call</code> and <code>tool_result</code> event types
to the existing NDJSON wire format.</li>
</ul>
</div>
<div class="card">
<h3>source/components/rojo-chat-panel/rojo-chat-panel.ts</h3>
<ul style="line-height:1.9;margin-top:0.5rem">
<li>Handles <code>TOOL_CALL</code> messages: renders a small dim activity line
showing the tool name and arguments (collapsed by default).</li>
<li>Handles <code>TOOL_RESULT</code> messages: appends the result status (ok / error)
to the same activity line.</li>
<li>Visual style: compact, clearly distinct from user and assistant bubbles —
not the focus, just informational.</li>
</ul>
</div>
</section>
<section>
<h2>Verification Test</h2>
<div class="card">
<p>
After implementation, the following single-message conversation should work
end-to-end with any capable model via an external or tunnel endpoint:
</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">User: "Create a directory at docs/intro and write an index.page
file there with a page header and a short intro paragraph."
Expected sequence:
⚙ create_directory("docs/intro") → ok
⚙ write_text_content("docs/intro/index.page", "...") → ok
Assistant: "Done. I created docs/intro and wrote index.page with ..."</pre>
<p style="margin-top:0.75rem">
This test exercises two sequential tool calls in one agent loop iteration,
confirming that history is correctly updated between calls and that the model
can compose multiple operations from a single natural-language instruction.
</p>
</div>
</section>
<section>
<h2>Phased Implementation</h2>
<div class="card" style="border-left: 3px solid #ffb300;">
<h3>Phase 1 — Backend tools + loop</h3>
<ul style="line-height:1.9">
<li>Create <code>source/server/rojos/tools/</code> directory</li>
<li>Write <code>RojoTool.ts</code> (abstract base + context interface)</li>
<li>Write one class per tool: <code>ReadPathMetaTool</code>, <code>GetTextContentTool</code>,
<code>WriteTextContentTool</code>, <code>ListFilesTool</code>, <code>CreateDirectoryTool</code></li>
<li>Write <code>RojoToolRegistry.ts</code></li>
<li>Rewrite <code>getAgentStream</code> as an async generator with the tool execution loop</li>
<li>Wire tools into the <code>POST /chat</code> route</li>
</ul>
</div>
<div class="card" style="border-left: 3px solid #ffb300;">
<h3>Phase 2 — Frontend display</h3>
<ul style="line-height:1.9">
<li>Handle <code>TOOL_CALL</code> and <code>TOOL_RESULT</code> message types in
<code>rojo-chat-panel.ts</code></li>
<li>Render compact activity lines for each tool call / result</li>
</ul>
</div>
<div class="card">
<h3>Phase 3 — Future extensions</h3>
<ul style="line-height:1.9">
<li>Per-rojo tool configuration: enable/disable specific tools per rojo</li>
<li>Additional tools: rename, delete, move files</li>
<li>User-defined tools: name + description + server-side handler registration</li>
</ul>
</div>
</section>
<footer>
Roject &mdash; Rojo Chat LLM tools plan
</footer>
</div>
<script>var NAV_ROOT = '../';</script>
<script src="../_assets_/nav-data.js"></script>
<script src="../_assets_/nav.js"></script>
</body>
</html>