Instruction file imported from markkr125/ollama-agents (
.github/instructions/agent-tools.instructions.md). Copyright stays with the author.
Agent Tools & Execution
Agent Executor Architecture ā Decomposed Structure
The agent execution logic lives in src/services/agent/ and follows a strict single-responsibility decomposition. The executor was decomposed from a monolithic 800-line class into focused sub-handlers. This structure is intentional ā do NOT merge files back together or add new responsibilities to the orchestrator.
File Map & Responsibilities
src/services/agent/
āāā agentChatExecutor.ts # ORCHESTRATOR ONLY ā wires sub-handlers, runs main loop
āāā agentStreamProcessor.ts # LLM streaming ā chunk accumulation, throttled UI emission
āāā agentToolRunner.ts # Tool batch execution ā routing, UI events, diff stats
āāā agentSummaryBuilder.ts # Post-loop ā summary generation, final message, filesChanged
āāā approvalManager.ts # Approval promise lifecycle ā waitForApproval / handleResponse
āāā agentTerminalHandler.ts # Terminal commands ā safety check, approval, execution
āāā agentFileEditHandler.ts # File edits ā sensitivity check, approval, diff preview
āāā checkpointManager.ts # Checkpoints ā snapshotting, keep/undo, diff computation
Ownership Rules ā Where Does New Code Go?
| If you need to... | Put it in... | NOT in... |
|---|---|---|
| Change LLM streaming, chunk throttling, thinking accumulation | agentStreamProcessor.ts |
agentChatExecutor.ts |
| Change per-tool execution, tool UI events, inline diff stats | agentToolRunner.ts |
agentChatExecutor.ts |
| Change post-loop summary, fallback LLM call, final message | agentSummaryBuilder.ts |
agentChatExecutor.ts |
| Change terminal command safety/approval/execution | agentTerminalHandler.ts |
agentToolRunner.ts |
| Change file edit sensitivity/approval/diff preview | agentFileEditHandler.ts |
agentToolRunner.ts |
| Change checkpoint snapshotting, keep/undo, diff computation | checkpointManager.ts |
agentChatExecutor.ts |
| Change approval promise lifecycle (wait/resolve) | approvalManager.ts |
handler files |
| Change loop flow, iteration logic, conversation history | agentChatExecutor.ts |
sub-handler files |
AgentChatExecutor ā The Orchestrator
This class MUST stay thin. It owns:
- Constructor wiring of sub-handlers
- The main
execute()while-loop (iteration orchestration) persistUiEvent()ā the shared persist-to-DB helperpersistGitBranchAction()ā git branch UI event sequencebuildAgentSystemPrompt()ā XML fallback prompt generationparseToolCalls()ā native vs XML extraction dispatchlogIterationResponse()ā debug output channel logging- Pass-through delegates to
checkpointManagerandapprovalManager
It does NOT own streaming, tool execution, diff stats, summary generation, terminal safety, or file sensitivity. Those are in the sub-handlers.
AgentStreamProcessor ā LLM Streaming
Owns the for await (chunk of stream) loop. Takes a chat request and returns:
interface StreamResult {
response: string; // Full accumulated response text
thinkingContent: string; // Full accumulated thinking/CoT text
nativeToolCalls: OllamaToolCall[]; // Native tool calls from API
firstChunkReceived: boolean; // Whether any text was sent to UI
lastThinkingTimestamp: number; // Timestamp (ms) of last thinking token
thinkingCollapsed: boolean; // Whether collapseThinking was already sent
}
Handles: thinking token accumulation, native tool_call accumulation, text content accumulation with 32ms throttled UI emission, first-chunk gate (ā„8 word chars), [TASK_COMPLETE] partial-prefix stripping, partial tool call detection (XML fallback freezing).
Early Thinking Collapse on Native Tool Calls
When native tool_calls arrive during streaming, the stream processor immediately collapses the thinking group rather than waiting for the stream to end:
- Computes accurate
durationSecondsfromlastThinkingTimestamp - thinkingStartTime(excludes Ollama's tool_call buffering time) - Sends
collapseThinkingwithdurationSecondsto the webview ā thinking header changes from "Thinking..." ā "Thought for 8s" instantly - Extracts filenames from write_file/create_file tool_call arguments and shows "Writing filename.ts..." in the bottom spinner
- Sets
thinkingCollapsed = trueso the executor skips sending a duplicatecollapseThinking
Why: Ollama buffers native tool_call content internally (10ā80s for large files). Without early collapse, the thinking group header shows "Thinking..." for the entire buffering duration, making it appear the model is still thinking when it's actually generating file content.
thinkingStartTime parameter: The executor passes thinkingStartTime (captured before streamIteration()) to the stream processor so it can compute accurate duration without depending on executor state.
AgentToolRunner ā Tool Batch Execution
Executes all tool calls in a single iteration as a batch. Routes to terminal handler, file-edit handler, or generic ToolRegistry.execute(). Returns:
interface ToolBatchResult {
nativeResults: Array<{ role: 'tool'; content: string; tool_name: string }>;
xmlResults: string[];
wroteFiles: boolean; // Whether any file write succeeded (not skipped)
}
Handles: per-tool "running"ā"success"/"error" UI events, persistUiEvent for each action, inline diff stats computation (+N -N badges), incremental filesChanged emission, tool result persistence to DB, skipped-action detection.
Chunked read_file Interception
All read_file calls are intercepted before the normal tool execution path and routed through executeChunkedRead(). This prevents loading entire files into memory.
Flow:
isReadFilecheck at top of loop āexecuteChunkedRead()ācontinue- Resolve path via
resolveWorkspacePath() - Count total lines via streaming (
countFileLines()) - Loop in
CHUNK_SIZE(100) line chunks:- Emit "running" UI action:
Reading ${fileName}/lines ${start}ā${end} - Stream just that range via
readFileChunk() - Emit "success" UI action:
Read ${fileName}/lines ${start}ā${end} - Persist the success event
- Emit "running" UI action:
- Concatenate all chunks, persist a single combined tool message to DB
- Return combined content to LLM
Key design decisions:
readFile.tsschema exposes onlypath/fileā nostartLine/endLineā so the LLM cannot bypass chunking- Each chunk gets its own UI action with
filePathandstartLinefor click-to-open navigation - Chunk actions have
filePathbut nocheckpointIdā this is critical forProgressGroup.vue'sisCompletedFileGroupguard (only file edits with checkpointId render flat)
AgentSummaryBuilder ā Post-Loop Finalization
Called once after the while-loop exits. Handles:
- Fallback LLM summary generation (when no accumulated explanation text)
- Tool summary line building (from last 6 tool results)
- Final assistant message persistence to DB
finalMessageemission to webviewfilesChangedfinal emission with checkpoint- Has its own
persistUiEvent(does not share the executor's instance)
Shared Types Location
All core agent types (Tool, ToolContext, ExecutorConfig, PersistUiEventFn) live in src/types/agent.ts. Both toolRegistry.ts and agentTerminalHandler.ts re-export them for backward compatibility, but new code should import from types/agent directly.
Sub-Handler Dependency Pattern
Sub-handlers receive their dependencies via constructor injection (not by holding a reference to the executor). This prevents circular dependencies:
// PersistUiEventFn type ā defined in src/types/agent.ts (shared location)
export type PersistUiEventFn = (
sessionId: string | undefined,
eventType: string,
payload: Record<string, any>
) => Promise<void>;
// Executor binds its own method and passes it down
const persistFn = this.persistUiEvent.bind(this);
this.terminalHandler = new AgentTerminalHandler(..., persistFn, ...);
ā ļø Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Do This Instead |
|---|---|---|
Adding streaming logic to agentChatExecutor.ts |
Executor becomes a god class again | Add to agentStreamProcessor.ts |
Adding per-tool execution logic to agentChatExecutor.ts |
Same ā executor must stay thin | Add to agentToolRunner.ts |
Importing AgentChatExecutor from a sub-handler |
Creates circular dependency | Use PersistUiEventFn callback type instead |
Making AgentStreamProcessor aware of tool execution |
Violates streaming ā execution boundary | Stream processor returns StreamResult, executor decides what to do with it. Exception: the stream processor MAY emit collapseThinking and transient showThinking spinners on tool_call detection (UI-only, no persistence) |
| Putting DB persistence logic in stream processor | Stream processor should only handle UI emission | Persistence belongs in executor or tool runner |
Agent Execution Flow
When user sends a message in Agent mode:
1. handleAgentMode()
āā Create agent session
āā Create git branch (if enabled)
āā agentChatExecutor.execute() ā AgentChatExecutor.execute() method
āā Detect tool calling mode:
ā āā Native: model has 'tools' capability ā uses Ollama tools API
ā āā XML fallback: no capability ā parses <tool_call> from text
āā Create checkpoint (SQLite) ā currentCheckpointId
āā LOOP (max iterations):
ā
āā [AgentStreamProcessor.streamIteration()]
ā āā Build chat request (with tools[] + think:true if native)
ā āā Stream LLM response via OllamaClient.chat()
ā ā āā Accumulate thinking tokens (chunk.message.thinking)
ā ā āā Accumulate native tool_calls (chunk.message.tool_calls)
ā ā ā āā On first tool_call: collapseThinking + "Writing file..." spinner
ā ā āā Accumulate text content (chunk.message.content)
ā ā āā Throttled streamChunk to UI (32ms, first-chunk gate ā„8 word chars)
ā āā Return StreamResult { response, thinkingContent, nativeToolCalls, thinkingCollapsed }
ā
āā [Back in agentChatExecutor.execute()]
ā āā De-duplicate thinking echo in response
ā āā Persist thinking block (if any) ā skip collapseThinking if stream already sent it
ā āā Process per-iteration delta text
ā āā Check [TASK_COMPLETE] ā validate writes ā break
ā āā parseToolCalls() ā native or XML extraction
ā
āā If tools found:
ā āā Persist + post 'startProgressGroup'
ā āā Push assistant message to history (with thinking + tool_calls)
ā ā
ā āā [AgentToolRunner.executeBatch()]
ā ā āā For each tool:
ā ā ā āā [write_file] ā CheckpointManager.snapshotFileBeforeEdit()
ā ā ā ā ā AgentFileEditHandler.execute()
ā ā ā ā ā fileSensitivity ā approval flow
ā ā ā āā [Terminal cmd] ā AgentTerminalHandler.execute()
ā ā ā ā ā commandSafety ā approval flow
ā ā ā āā [Other tool] ā ToolRegistry.execute()
ā ā ā āā Persist tool result to DB
ā ā ā āā Persist + post 'showToolAction' (success/error)
ā ā ā āā Compute inline diff stats (+N -N badge)
ā ā āā Return ToolBatchResult { nativeResults, xmlResults, wroteFiles }
ā ā
ā āā Persist + post 'finishProgressGroup'
ā āā Feed tool results back into conversation history
ā
āā Continue to next iteration
ā
āā [AgentSummaryBuilder.finalize()]
ā āā Generate fallback LLM summary (if no accumulated text)
ā āā Persist final assistant message to DB
ā āā Post 'finalMessage' to webview
ā āā Persist + post 'filesChanged' with checkpointId (if files modified)
ā āā Return { summary, assistantMessage }
ā
āā Return { summary, assistantMessage, checkpointId }
ā Back in handleAgentMode():
ā reviewService.startReviewForCheckpoint(checkpointId)
ā Post 'generationStopped'
Native Tool Calling vs XML Fallback
The executor supports two tool calling paths, selected based on model capabilities:
| Path | When Used | Request | Response | History Format |
|---|---|---|---|---|
| Native | Model has tools capability |
chatRequest.tools = [ToolDefinition...] |
chunk.message.tool_calls: [{function:{name, arguments}}] |
{role:'tool', content, tool_name} per tool |
| XML fallback | Model lacks tools capability |
Tool descriptions in system prompt | <tool_call>{"name":"...", "arguments":{...}}</tool_call> in text |
Accumulated {role:'user', content} message |
Native tool calling conversation structure (matches Ollama docs):
[system] You are a coding agent...
[user] Create a hello world file
[assistant, thinking: "...", tool_calls: [{function:{name:"write_file", arguments:{...}}}]]
[tool, tool_name: "write_file"] File written successfully
[assistant, thinking: "..."] Done! [TASK_COMPLETE]
Key rules:
- Assistant messages MUST include
thinking,content, ANDtool_callsā omittingthinkingcauses the model to lose chain-of-thought context across iterations - Tool result messages MUST include
tool_nameā without it, the model can't match results to calls in multi-tool responses - Do NOT deduplicate tool calls ā Ollama sends each as a complete object in its own chunk; dedup would drop legitimate repeated calls
ToolCalltype has optionalid,type, andfunction.indexfields ā Ollama returnstype: 'function'andfunction.indexbut NOTid
ToolRegistry (src/agent/toolRegistry.ts)
Manages tool registration, lookup, and execution. The registry itself is a slim class (~110 LOC) ā individual tool implementations live in src/agent/tools/, one file per tool.
Tool File Structure
src/agent/tools/
āāā index.ts # Barrel export ā builtInTools[] array
āāā pathUtils.ts # resolveWorkspacePath() shared utility
āāā readFile.ts # read_file tool
āāā writeFile.ts # write_file tool
āāā searchWorkspace.ts # search_workspace tool
āāā listFiles.ts # list_files tool
āāā runTerminalCommand.ts # run_terminal_command tool
āāā getDiagnostics.ts # get_diagnostics tool
Shared Types (src/types/agent.ts)
All core agent types are centralised in src/types/agent.ts:
Toolā tool definition (name, description, schema, execute)ToolContextā runtime context for tool executionExecutorConfigā agent loop configuration (maxIterations, toolTimeout, temperature)PersistUiEventFnā callback type for DB persistence
toolRegistry.ts and agentTerminalHandler.ts re-export these types for backward compatibility.
Built-in Tools (6 total):
| Tool | Description |
|---|---|
read_file |
Read file contents (streaming, chunked in 100-line blocks via countFileLines + readFileChunk; see src/agent/tools/readFile.ts) |
write_file |
Write/create file (handles both) |
list_files |
List directory contents (output includes basePath for click handling) |
search_workspace |
Search for text in files |
run_terminal_command |
Execute shell commands |
get_diagnostics |
Get file errors/warnings |
Tool Call Format (in LLM responses):
<tool_call>{"name": "read_file", "arguments": {"path": "src/file.ts"}}</tool_call>
Tool Call Parser (src/utils/toolCallParser.ts)
Parses tool calls from LLM responses. This is critical for agent functionality and must handle various LLM output quirks robustly.
Key Functions:
| Function | Purpose |
|---|---|
extractToolCalls(response) |
Parse all tool calls from response text |
detectPartialToolCall(response) |
Detect in-progress tool call during streaming |
removeToolCalls(response) |
Strip tool call markup for display |
Robustness Features
The parser handles various LLM quirks that smaller models (like devstral-small) may produce:
-
Balanced JSON Extraction - Uses brace counting instead of regex to properly extract nested JSON:
// WRONG: /<tool_call>\s*(\{[\s\S]*?\})\s*<\/tool_call>/ (stops at first }) // RIGHT: extractBalancedJson() counts { and } to find matching close -
Multiple Argument Field Names - Accepts
arguments,args,params, orparameters:{"name": "read_file", "args": {"path": "file.ts"}} // works {"name": "read_file", "arguments": {"path": "file.ts"}} // works -
Top-Level Arguments - Accepts args at root level instead of nested:
{"name": "read_file", "path": "file.ts"} // works (path extracted from top level) -
Multiple Tool Name Fields - Accepts
name,tool, orfunction:{"tool": "read_file", "arguments": {"path": "file.ts"}} // works -
Incomplete Tool Calls - Handles LLM getting cut off mid-response:
<tool_call>{"name": "write_file", "arguments": {"path": "x.ts", "content": "...The parser attempts to repair by adding missing closing braces.
Tool Argument Flexibility
Tools in toolRegistry.ts also accept multiple argument names for the file path:
path,file, orfilePathare all valid forread_file,write_file,get_diagnostics
Streaming Behavior
First-Chunk Gate
The executor uses a 32ms throttle for streaming text to the UI. The first chunk requires ā„8 word characters before the spinner is replaced with text. This prevents partial markdown fragments like **What from flashing on screen. After the first chunk, any content with ā„1 word character is shown.
[TASK_COMPLETE] Stripping
The control signal [TASK_COMPLETE] is stripped from all displayed content:
- Full match: regex
/\[TASK_COMPLETE\]/gi - Partial prefix: a reverse scan strips any trailing prefix of
[TASK_COMPLETE](e.g.[TASK,[TAS) since tokens arrive incrementally - Applied to: streamed text, thinking content, and persisted thinking blocks
Terminal Command CWD Resolution
executeTerminalCommand() resolves the cwd argument relative to the workspace root. Absolute paths that fall outside the workspace are clamped to the workspace root. If no cwd is provided, commands run in the workspace root.
Write Validation
The agent executor tracks whether a task requires file writes (based on keywords like "rename", "modify", "create", etc.) and validates that write_file was actually called before accepting [TASK_COMPLETE]. This prevents the LLM from hallucinating task completion.
Terminal Command Execution
Shell Integration Requirement
Terminal commands execute via TerminalManager (src/services/terminalManager.ts), which requires VS Code Terminal Shell Integration (VS Code 1.93+). The manager waits up to 5 seconds for shell integration to appear; if unavailable, it throws a hard error.
Session-Keyed Terminals
Terminals are keyed by session ID ā one terminal per agent session, reused across all commands in that session. Terminals are auto-cleaned when VS Code closes them.
Output Handling
- Output is truncated to 100 lines (15 head + 85 tail) with a
[N lines truncated]marker - ANSI escape sequences and VS Code
]633;shell integration markers are stripped - Caveat:
waitForCommandEnd()relies on theonDidEndTerminalShellExecutionevent. If the event never fires (shell integration bug), the promise never resolves ā there is no timeout
Command Safety & Approval Flow
Severity Tiers (src/utils/commandSafety.ts)
analyzeDangerousCommand() returns a severity from highest-match in a static regex pattern array:
| Severity | Examples | Behavior |
|---|---|---|
critical |
rm -rf /, fork bombs, mkfs, dd if= |
Always requires approval ā ignores auto-approve |
high |
sudo, chmod 777, kill -9, npm publish |
Requires approval unless auto-approved |
medium |
npm install, pip install, docker run |
Requires approval unless auto-approved |
none |
ls, cat, echo |
Auto-approved if toggle enabled |
Approval Decision (src/utils/terminalApproval.ts)
computeTerminalApprovalDecision() returns the final decision:
- Critical severity ā always requires approval (regardless of
auto_approve_commands) - Auto-approve enabled ā approve and persist result with
autoApproved: true - Otherwise ā show approval card in UI and wait for user response
File Edit Approval (src/utils/fileSensitivity.ts)
File edits go through a separate sensitivity check:
- Evaluate file path against
sensitiveFilePatterns(last-match-wins pattern order) - If file is sensitive and
auto_approve_sensitive_editsisfalseā show approval card with diff - Non-sensitive files are written directly without approval
ā ļø INVERTED BOOLEAN ā READ CAREFULLY
In
sensitiveFilePatterns,truemeans auto-approve (file is NOT sensitive).falsemeans require approval (file IS sensitive).The boolean answers "is this file safe to auto-approve?" ā NOT "is this file sensitive?".
// ā CORRECT: .env requires approval ā set to false { pattern: '**/.env', value: false } // ā WRONG: Don't set .env to true thinking "yes it's sensitive" { pattern: '**/.env', value: true } // This DISABLES approval!
UI Flow for Approvals
Both terminal and file edit approvals follow the persist+post sequence defined in CRITICAL RULE #1 of copilot-instructions.md. The full event ordering table is there. Here is the approval-specific flow:
1. persistUiEvent + postMessage ā 'showToolAction' (status: 'pending')
2. persistUiEvent + postMessage ā 'requestToolApproval' | 'requestFileEditApproval'
āā wait for user response āā
3. persistUiEvent + postMessage ā 'toolApprovalResult' | 'fileEditApprovalResult'
4. [execute command / apply edit]
5. persistUiEvent + postMessage ā 'showToolAction' (status: 'success' | 'error')
Key rule: Every postMessage MUST have a matching persistUiEvent in the same order. See CRITICAL RULE #1 for the full event table and debugging guide.
Adding a New Tool
Full step-by-step guide: See the
add-agent-toolskill (.github/skills/add-agent-tool/SKILL.md).
Each tool lives in its own file under src/agent/tools/. Quick summary:
- Create tool file ā
src/agent/tools/myTool.tsexporting aToolobject ({ name, description, schema, execute }). - Register in barrel ā Add to
builtInTools[]insrc/agent/tools/index.ts. - Add UI mapping ā Add a
caseingetToolActionInfo()insrc/views/toolUIFormatter.ts. - Add to Settings UI (if toggleable) ā
src/webview/components/settings/components/ToolsSection.vue. - Write tests ā
tests/extension/suite/agent/toolRegistry.test.ts.
Execution routing: agentToolRunner.ts calls ToolRegistry.execute() for standard tools. Terminal commands and file edits have dedicated sub-handlers (agentTerminalHandler.ts, agentFileEditHandler.ts).