Skip to content

Chat Tool Calling & Project Tools

dazeb edited this page Sep 17, 2026 · 2 revisions

Chat Tool Calling & Project Tools

src/core/chat/ is the Electron-free core of the chat driver v2. It is deliberately split so that the policy of tool calling (schemas, approval, loop guard) never depends on the Electron process, while the platform concerns (provider keys, renderer broadcast, project store) are injected. Four files plus one shared contract file carry the tool story:

File Responsibility
types.ts Shared vocabulary: roles, messages, tool-call records, streaming events, provider error.
tools.ts Provider-neutral tool loop: schema definition (ChatToolDef), dispatch, permission gate, result re-entry, iteration guard.
project-tools.ts The built-in, project-scoped tool set (read_file, list_dir) built on file-service + project-scope.
runtime.ts Per-node orchestration: provider resolution, one active run per node, pending approvals, event broadcast, stop/approve.
conversation.ts The persisted message list, slash commands, and the byte-capped history window tool results land in.

openai.ts / anthropic.ts implement the driver side of the contract (see Model Provider Adapters & Streaming), cost.ts accounts tokens, and sse.ts is the shared stream parser.

1. Shared chat contracts (types.ts)

  • ChatRole = 'user' | 'assistant' | 'system' | 'tool' | 'note' — note exists for local-only UI output such as /cost (see §5).
  • ChatToolCall — { id, name, argsJson, result?, isError?, status: 'running' | 'done' | 'error' }. argsJson is a JSON string accumulated from provider stream fragments, so parsing only happens at execution time.
  • ChatMessage — { id, role, content, thinking?, toolCalls?, usage?, model?, stopped?, ts }. Tool calls live on the assistant message that requested them.
  • ChatEvent — the stream contract both adapters speak: delta, thinking, usage, toolCall, toolResult, context-added, done with reason: 'end_turn' | 'stopped' | 'error' | 'max_iterations'.
  • ChatError — carries provider, message, optional HTTP status.

The decisive design rule is documented on the toolResult variant: "Emitted when a tool call EXECUTES (approved + run), with its outcome — the stream itself never carries results." Results travel out-of-band via a hook, which is why runtime.ts broadcasts events rather than relying on the provider stream for outcomes.

2. Tool schema and driver contract (tools.ts)

export interface ChatToolDef {
  name: string
  description: string
  schema: unknown          // JSON-schema-shaped, untranslated in core
  needsApproval: boolean
  run(args: unknown): Promise<string>
}

ChatDriver.stream({ model, messages, tools?, signal? }) is the minimal surface both SSE adapters satisfy; ChatToolDef[] is passed straight through to the provider adapter, so the translation of schema into a provider-specific tool format belongs to the adapters, not the core. needsApproval is the only policy bit the loop needs.

3. Dispatch: runChatLoop

runChatLoop(driver, model, messages, tools, hooks) drives one chat turn to completion. It never mutates the caller's array: it works on cloneMessages(messages) and returns { messages, stopReason: 'end_turn' | 'max_iterations' | 'denied' }.

Per iteration (bounded by hooks.maxIterations ?? 10, floored at 1):

  1. Stream — driver.stream({ model, messages: working, tools, signal }).
  2. Forward — every event goes through hooks.onEvent(ev) inside try/catch; "a broken consumer must never kill the loop."
  3. Accumulate — delta/thinking text is appended to a lazily created assistant message (ensureAssistant), usage sets usage/model, toolCall pushes { ...ev.call } onto the assistant message and into the pending list, done{reason:'stopped'} records stopped.
  4. Stop check — aborted turn: current.stopped = true, return end_turn. No pending calls: return end_turn.
  5. Execute — for each pending call, sequentially:
    • Unknown name → result unknown tool: <name> with isError: true (the loop continues; it does not abort).
    • needsApproval → await hooks.requestApproval(call), with a thrown hook treated as deny. A deny appends denied by user and returns immediately with stopReason: 'denied' — later calls in the same batch never run.
    • argsJson empty → {}; otherwise JSON.parse (parse failure is caught like any run error). def.run(args) is awaited; a rejection becomes tool error: <message> with isError: true.
  6. Re-enter — appendToolResult mirrors the outcome onto the requesting assistant message (searching backwards for the matching call id) and pushes a new role: 'tool' message carrying the finished call. Then the loop explicitly emits toolResult through hooks.onEvent so approval cards resolve and transcripts stay coherent. The provider sees the results on the next iteration's message list.

When the iteration budget is exhausted, a system message stopped: tool loop iteration limit reached is appended and max_iterations is returned.

sequenceDiagram
    participant R as Renderer (chat node)
    participant RT as ChatRuntime (runtime.ts)
    participant L as runChatLoop (tools.ts)
    participant D as ChatDriver (openai/anthropic)
    participant T as ChatToolDef.run (project-tools.ts)

    R->>RT: send({ nodeId, messages, model, provider })
    RT->>RT: resolveProvider; activeRuns.set(nodeId, controller)
    RT->>L: runChatLoop(driver, model, messages, tools, hooks)
    L->>D: stream({ model, messages, tools, signal })
    D-->>L: delta / thinking / usage / toolCall / done
    L-->>R: hooks.onEvent -> broadcast (all kinds except done)
    L->>R: requestApproval(call) -> broadcasts toolCall card
    R->>RT: approve(nodeId, callId, 'approve' | 'deny')
    RT-->>L: resolves pending promise
    L->>T: def.run(parsed argsJson)
    T-->>L: string result
    L->>L: appendToolResult (mirror on assistant + role:'tool' message)
    L-->>R: toolResult event
    L->>D: stream again with tool results in messages
    D-->>L: done(end_turn)
    L-->>RT: ChatLoopResult { messages, stopReason }
    RT-->>R: terminal 'done' (deferred; excerpt ends before this line)
Loading

Key nodes: activeRuns gates concurrency before the loop starts; the toolCall card reaches the renderer twice — once from the generic stream forwarding and once from the approval hook — which is why the runtime comment tells the renderer to dedupe by id. The loop, not the runtime, owns result appending; the runtime only relays the toolResult event.

4. Project-aware tool set (project-tools.ts)

projectChatTools(store, scope) returns the built-in ChatToolDef[] for a chat anchored at { cwd?, projectId? }. Confinement has exactly one chokepoint, the local scoped(rawPath) helper: it rejects non-string/empty paths, resolves relative arguments against scope.cwd (isAbsolute/resolvePath), rejects relative paths when there is no project folder, and then defers to resolveFileScope(store, abs, scope) — "a prompt-injected path argument can never escape the project the chat node lives in."

  • read_file (no approval) — classifyFile short-circuits image/binary with a human-readable refusal; readProjectFile failures become read failed: …; non-text results produce <kind> file (…) — no text content; text is returned through cap().
  • list_dir (no approval, path optional, defaults to '.') — listProjectDir(s.path, '.') is deliberately called with the scoped path as its own root so traversal stays confined; entries render as d name / f name lines, empty → (empty), output capped.

MAX_RESULT_BYTES = 12 * 1024 with a [truncated N bytes] marker: tool results ride in the transcript and would otherwise blow the conversation history cap immediately.

Evidence boundary: as of this revision the built-in set is read-only — read_file and list_dir only. The source comment states that "write/execute tools come later and will" need approval, so write/search tools are an intended extension point, not present in project-tools.ts.

5. Runtime orchestration and key state (runtime.ts)

createChatRuntime(deps) returns { send, stop, approve, isBusy }. Dependencies are all injectable: resolveProvider, broadcast, toolsFor?, driverFor?, log?. driverFor(cfg) selects Anthropic when cfg.api === 'anthropic' and otherwise the OpenAI-compatible adapter (covering OpenAI/Groq/OpenRouter/LM Studio/llama.cpp/Ollama). Provider/key resolution follows the env wins over settings rule, but that logic lives in resolveProvider so the core stays platform-agnostic; both Electron main and Server Edition construct a runtime with their own deps.

State:

  • activeRuns: Map<nodeId, AbortController> (module scope in runtime.ts) — one in-flight run per node. send rejects a second concurrent run with chat already running for this node; stop(nodeId) aborts the controller; isBusy(nodeId) is activeRuns.has(nodeId).
  • pendingApprovals: Map<${nodeId}:${callId}, (d) => void> per runtime instance. approve() deletes and resolves; an unknown key is a silent no-op.
  • The run's AbortSignal is threaded into driver.stream, so abort cuts the stream; the loop then returns end_turn with stopped set on the message.

send sequence: reject if busy → resolveProvider (null ⇒ no chat provider configured (Settings → Connections → API providers)) → register controller → build driver and tools (deps.toolsFor?.(req) ?? []) → runChatLoop with onEvent broadcasting everything except done (the terminal event is emitted after the loop; the shown excerpt ends inside this tail), and requestApproval parking a resolver while broadcasting the card. The declared result is ChatSendResult { ok, error?, stopReason? }.

stateDiagram-v2
    [*] --> running: toolCall accumulated into assistant message
    running --> done: def.run resolved; result mirrored
    running --> error: unknown tool / invalid argsJson / run threw
    running --> error: approval denied ("denied by user")
    done --> [*]
    error --> [*]
Loading

status is the renderer-visible lifecycle of a ChatToolCall: it starts running when the stream announces the call, and every terminal path — success, unknown tool, JSON/run failure, or denial — rewrites it to done/error both on the role: 'tool' message and on the originating assistant message.

6. Conversation persistence interplay (conversation.ts)

Tool results and tool calls are part of the persisted transcript, so the conversation model matters here:

  • appendMessage, appendDelta, appendThinking, setUsage, markStopped, and ensureMessage (which lazily creates an assistant message when a stream delta arrives for an unknown id).
  • appendNote writes role: 'note' — persisted with the transcript but never sent to providers; the wire mappers skip the role (audit B4: a system-role note silently vanished from Anthropic's context).
  • detectSlashCommand parses /clear, /model <id>, /system <text>, /cost case-insensitively.
  • serializeConversation / capConversationMessages cap the serialized shape at 200 KiB by dropping oldest user+assistant pairs while keeping a leading system message. capConversationMessages is exported as a pure helper so node-data persistence can apply the same cap without round-tripping through serializeConversation (audit B5 — project.json must stay small).
  • deserializeConversation tolerates unknown fields on messages but throws TypeError on garbage.

Boundary worth knowing: trimming only recognizes adjacent user→assistant pairs; any other role advances the window start without dropping, so a transcript dominated by tool/note messages can stop shrinking before it reaches the byte target.

7. Boundary conditions and extension points

Boundary conditions:

  • Denial ends the whole turn (stopReason: 'denied'); remaining calls in the batch produce no results.
  • requestApproval throwing is normalized to deny; onEvent throwing is swallowed.
  • Unknown tools and argument parse failures are recoverable — they become error results and the model gets another iteration.
  • ChatEvent.done offers four reasons, but ChatLoopResult.stopReason exposes only end_turn | max_iterations | denied; error is a stream-side signal.
  • One concurrent run per node; per-result cap 12 KiB; per-conversation serialization cap 200 KiB.
  • All filesystem access is double-checked: scoped() validation plus resolveFileScope confinement in the WorkspaceStore.

Extension points:

  • Add a tool by constructing a ChatToolDef — either inside projectChatTools (same-store, project-scoped) or via deps.toolsFor(req) on the runtime; set needsApproval: true for anything that writes or executes.
  • Approval transport is already generic: any UI that calls approve(nodeId, callId, decision) can resolve a pending card.
  • Swap providers or fake the driver with deps.driverFor; toolsFor returning [] fully disables tools (used by tests to avoid the filesystem).
  • The schema: unknown field keeps core provider-agnostic — new provider adapters only need to satisfy ChatDriver.stream.

Tests backing this behavior live alongside the code: tools.test.ts, project-tools.test.ts, runtime.test.ts, and conversation.test.ts.

Sources: src/core/chat/types.ts, src/core/chat/tools.ts, src/core/chat/project-tools.ts, src/core/chat/runtime.ts, src/core/chat/conversation.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally