-
Notifications
You must be signed in to change notification settings - Fork 0
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.
-
ChatRole = 'user' | 'assistant' | 'system' | 'tool' | 'note'—noteexists for local-only UI output such as/cost(see §5). -
ChatToolCall—{ id, name, argsJson, result?, isError?, status: 'running' | 'done' | 'error' }.argsJsonis 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,donewithreason: 'end_turn' | 'stopped' | 'error' | 'max_iterations'. -
ChatError— carriesprovider,message, optional HTTPstatus.
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.
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.
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):
-
Stream —
driver.stream({ model, messages: working, tools, signal }). -
Forward — every event goes through
hooks.onEvent(ev)insidetry/catch; "a broken consumer must never kill the loop." -
Accumulate —
delta/thinkingtext is appended to a lazily created assistant message (ensureAssistant),usagesetsusage/model,toolCallpushes{ ...ev.call }onto the assistant message and into thependinglist,done{reason:'stopped'}recordsstopped. -
Stop check — aborted turn:
current.stopped = true, returnend_turn. No pending calls: returnend_turn. -
Execute — for each pending call, sequentially:
- Unknown name → result
unknown tool: <name>withisError: true(the loop continues; it does not abort). -
needsApproval→await hooks.requestApproval(call), with a thrown hook treated asdeny. Adenyappendsdenied by userand returns immediately withstopReason: 'denied'— later calls in the same batch never run. -
argsJsonempty →{}; otherwiseJSON.parse(parse failure is caught like any run error).def.run(args)is awaited; a rejection becomestool error: <message>withisError: true.
- Unknown name → result
-
Re-enter —
appendToolResultmirrors the outcome onto the requesting assistant message (searching backwards for the matching call id) and pushes a newrole: 'tool'message carrying the finished call. Then the loop explicitly emitstoolResultthroughhooks.onEventso 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)
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.
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) —classifyFileshort-circuitsimage/binarywith a human-readable refusal;readProjectFilefailures becomeread failed: …; non-text results produce<kind> file (…) — no text content; text is returned throughcap(). -
list_dir(no approval,pathoptional, defaults to'.') —listProjectDir(s.path, '.')is deliberately called with the scoped path as its own root so traversal stays confined; entries render asd name/f namelines, 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_fileandlist_dironly. 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 inproject-tools.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 inruntime.ts) — one in-flight run per node.sendrejects a second concurrent run withchat already running for this node;stop(nodeId)aborts the controller;isBusy(nodeId)isactiveRuns.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
AbortSignalis threaded intodriver.stream, so abort cuts the stream; the loop then returnsend_turnwithstoppedset 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 --> [*]
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.
Tool results and tool calls are part of the persisted transcript, so the conversation model matters here:
-
appendMessage,appendDelta,appendThinking,setUsage,markStopped, andensureMessage(which lazily creates anassistantmessage when a stream delta arrives for an unknown id). -
appendNotewritesrole: 'note'— persisted with the transcript but never sent to providers; the wire mappers skip the role (audit B4: asystem-role note silently vanished from Anthropic's context). -
detectSlashCommandparses/clear,/model <id>,/system <text>,/costcase-insensitively. -
serializeConversation/capConversationMessagescap the serialized shape at 200 KiB by dropping oldestuser+assistantpairs while keeping a leadingsystemmessage.capConversationMessagesis exported as a pure helper so node-data persistence can apply the same cap without round-tripping throughserializeConversation(audit B5 —project.jsonmust stay small). -
deserializeConversationtolerates unknown fields on messages but throwsTypeErroron 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.
Boundary conditions:
- Denial ends the whole turn (
stopReason: 'denied'); remaining calls in the batch produce no results. -
requestApprovalthrowing is normalized todeny;onEventthrowing is swallowed. - Unknown tools and argument parse failures are recoverable — they become error results and the model gets another iteration.
-
ChatEvent.doneoffers four reasons, butChatLoopResult.stopReasonexposes onlyend_turn | max_iterations | denied;erroris 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 plusresolveFileScopeconfinement in theWorkspaceStore.
Extension points:
- Add a tool by constructing a
ChatToolDef— either insideprojectChatTools(same-store, project-scoped) or viadeps.toolsFor(req)on the runtime; setneedsApproval: truefor 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;toolsForreturning[]fully disables tools (used by tests to avoid the filesystem). - The
schema: unknownfield keeps core provider-agnostic — new provider adapters only need to satisfyChatDriver.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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance