From a0ee03d33105e0cbcba7f313239569bf370a72ab Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 25 Mar 2026 12:43:34 -0600 Subject: [PATCH 1/2] feat(agent-core): add externalToolExecution mode for external providers Adds `externalToolExecution` flag to AgentLoopConfig. When true, the agent loop emits tool_execution_start/end events for TUI rendering but skips local tool dispatch. Used by providers that handle tool execution internally (e.g., Claude Code CLI via Agent SDK). The flag is dynamically evaluated per-loop via a callback on AgentOptions, so model switches mid-session are handled correctly. Providers with authMode "externalCli" automatically use this mode. Also updates the Claude Code CLI stream adapter to preserve tool call blocks in the final message instead of stripping them. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/pi-agent-core/src/agent-loop.ts | 26 +++++++++++++++- packages/pi-agent-core/src/agent.ts | 10 +++++++ packages/pi-agent-core/src/types.ts | 10 +++++++ packages/pi-coding-agent/src/core/sdk.ts | 1 + .../claude-code-cli/stream-adapter.ts | 30 ++++++++----------- 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/packages/pi-agent-core/src/agent-loop.ts b/packages/pi-agent-core/src/agent-loop.ts index 436f7b291..ff2bab0f9 100644 --- a/packages/pi-agent-core/src/agent-loop.ts +++ b/packages/pi-agent-core/src/agent-loop.ts @@ -233,7 +233,31 @@ async function runLoop( hasMoreToolCalls = toolCalls.length > 0; const toolResults: ToolResultMessage[] = []; - if (hasMoreToolCalls) { + if (hasMoreToolCalls && config.externalToolExecution) { + // External execution mode: tools were handled by the provider (e.g., Claude Code SDK). + // Emit synthetic tool events for TUI rendering but skip local dispatch. + for (const tc of toolCalls as AgentToolCall[]) { + stream.push({ + type: "tool_execution_start", + toolCallId: tc.id, + toolName: tc.name, + args: tc.arguments, + }); + stream.push({ + type: "tool_execution_end", + toolCallId: tc.id, + toolName: tc.name, + result: { + content: [{ type: "text", text: "(executed by Claude Code)" }], + details: {}, + }, + isError: false, + }); + } + // Don't add tool results to context or loop back — the streamSimple + // call already ran the full multi-turn agentic loop. + hasMoreToolCalls = false; + } else if (hasMoreToolCalls) { const toolExecution = await executeToolCalls( currentContext, message, diff --git a/packages/pi-agent-core/src/agent.ts b/packages/pi-agent-core/src/agent.ts index 6de0be97b..e65ae7a35 100644 --- a/packages/pi-agent-core/src/agent.ts +++ b/packages/pi-agent-core/src/agent.ts @@ -101,6 +101,13 @@ export interface AgentOptions { * Default: 60000 (60 seconds). Set to 0 to disable the cap. */ maxRetryDelayMs?: number; + + /** + * Determines whether a model uses external tool execution (tools handled + * by the provider, not dispatched locally). Evaluated per-loop so model + * switches mid-session are handled correctly. + */ + externalToolExecution?: (model: Model) => boolean; } /** @@ -144,6 +151,7 @@ export class Agent { private _maxRetryDelayMs?: number; private _beforeToolCall?: AgentLoopConfig["beforeToolCall"]; private _afterToolCall?: AgentLoopConfig["afterToolCall"]; + private _externalToolExecution?: (model: Model) => boolean; constructor(opts: AgentOptions = {}) { this._state = { ...this._state, ...opts.initialState }; @@ -158,6 +166,7 @@ export class Agent { this._thinkingBudgets = opts.thinkingBudgets; this._transport = opts.transport ?? "sse"; this._maxRetryDelayMs = opts.maxRetryDelayMs; + this._externalToolExecution = opts.externalToolExecution; } /** @@ -499,6 +508,7 @@ export class Agent { getFollowUpMessages: async () => this.dequeueFollowUpMessages(), beforeToolCall: this._beforeToolCall, afterToolCall: this._afterToolCall, + externalToolExecution: this._externalToolExecution?.(model) ?? false, }; let partial: AgentMessage | null = null; diff --git a/packages/pi-agent-core/src/types.ts b/packages/pi-agent-core/src/types.ts index 3d231da6b..846764edd 100644 --- a/packages/pi-agent-core/src/types.ts +++ b/packages/pi-agent-core/src/types.ts @@ -193,6 +193,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * The hook receives the agent abort signal and is responsible for honoring it. */ afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; + + /** + * When true, tool calls in assistant messages are rendered in the TUI + * but NOT executed locally. Used for providers that handle tool execution + * internally (e.g., Claude Code CLI via Agent SDK). + * + * The agent loop emits tool_execution_start/end events for TUI rendering + * but skips tool.execute() and does not add tool results to context. + */ + externalToolExecution?: boolean; } /** diff --git a/packages/pi-coding-agent/src/core/sdk.ts b/packages/pi-coding-agent/src/core/sdk.ts index f9da7c022..55e80dfc8 100644 --- a/packages/pi-coding-agent/src/core/sdk.ts +++ b/packages/pi-coding-agent/src/core/sdk.ts @@ -326,6 +326,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} transport: settingsManager.getTransport(), thinkingBudgets: settingsManager.getThinkingBudgets(), maxRetryDelayMs: settingsManager.getRetrySettings().maxDelayMs, + externalToolExecution: (m) => modelRegistry.getProviderAuthMode(m.provider) === "externalCli", getApiKey: async (provider) => { // Use the provider argument from the in-flight request; // agent.state.model may already be switched mid-turn. diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index 8a916b1ac..d07aacd75 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -269,25 +269,21 @@ async function pumpSdkMessages( case "result": { const result = msg as SDKResultMessage; - // Build final message with text/thinking only (strip tool calls) - const finalContent: AssistantMessage["content"] = []; + // Build final message with all content from the last assistant turn. + // Tool calls are preserved — the agent loop's externalToolExecution + // mode handles them without local dispatch. + let finalContent: AssistantMessage["content"] = []; - // Use builder's accumulated content if available, falling back to captured text - if (builder) { - for (const block of builder.message.content) { - if (block.type === "text" && block.text) { - lastTextContent = block.text; - } else if (block.type === "thinking" && block.thinking) { - lastThinkingContent = block.thinking; - } + if (builder && builder.message.content.length > 0) { + finalContent = [...builder.message.content]; + } else { + // Fall back to captured text from complete assistant messages + if (lastThinkingContent) { + finalContent.push({ type: "thinking", thinking: lastThinkingContent }); + } + if (lastTextContent) { + finalContent.push({ type: "text", text: lastTextContent }); } - } - - if (lastThinkingContent) { - finalContent.push({ type: "thinking", thinking: lastThinkingContent }); - } - if (lastTextContent) { - finalContent.push({ type: "text", text: lastTextContent }); } // Fallback: use the SDK's result text if we have no content From bbea8460b5659d2bd15cfdd38a203ad912c1d3e5 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 25 Mar 2026 12:49:48 -0600 Subject: [PATCH 2/2] fix(claude-code-cli): render tool calls above text response - Filter toolcall_start/delta/end events from streaming to prevent out-of-order rendering in the TUI's accumulated message content - Collect tool calls from intermediate SDK turns and include them BEFORE text content in the final AssistantMessage - The agent loop's externalToolExecution path emits proper tool_execution_start/end events for each intermediate tool call - Result: tool activity renders above the text response, not below Co-Authored-By: Claude Opus 4.6 (1M context) --- .../claude-code-cli/stream-adapter.ts | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index d07aacd75..ab106b1dc 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -147,6 +147,8 @@ async function pumpSdkMessages( /** Track the last text content seen across all assistant turns for the final message. */ let lastTextContent = ""; let lastThinkingContent = ""; + /** Collect tool calls from intermediate SDK turns for tool_execution events. */ + const intermediateToolCalls: AssistantMessage["content"] = []; try { // Dynamic import — the SDK is an optional dependency. @@ -225,7 +227,14 @@ async function pumpSdkMessages( const assistantEvent = builder.handleEvent(event); if (assistantEvent) { - stream.push(assistantEvent); + // Skip toolcall events — the agent loop's externalToolExecution + // path emits tool_execution_start/end events after streamSimple + // returns. Streaming toolcall events would render tool calls + // out of order in the TUI's accumulated message content. + const t = assistantEvent.type; + if (t !== "toolcall_start" && t !== "toolcall_delta" && t !== "toolcall_end") { + stream.push(assistantEvent); + } } break; } @@ -251,13 +260,16 @@ async function pumpSdkMessages( const userMsg = msg as SDKUserMessage; if (userMsg.parent_tool_use_id !== null) break; - // Capture accumulated text from the builder before resetting + // Capture content from the completed turn before resetting if (builder) { for (const block of builder.message.content) { if (block.type === "text" && block.text) { lastTextContent = block.text; } else if (block.type === "thinking" && block.thinking) { lastThinkingContent = block.thinking; + } else if (block.type === "toolCall") { + // Collect tool calls for externalToolExecution rendering + intermediateToolCalls.push(block); } } } @@ -269,15 +281,22 @@ async function pumpSdkMessages( case "result": { const result = msg as SDKResultMessage; - // Build final message with all content from the last assistant turn. - // Tool calls are preserved — the agent loop's externalToolExecution - // mode handles them without local dispatch. - let finalContent: AssistantMessage["content"] = []; + // Build final message. Include intermediate tool calls so the + // agent loop's externalToolExecution path emits tool_execution + // events for proper TUI rendering, followed by the text response. + const finalContent: AssistantMessage["content"] = []; + // Add tool calls from intermediate turns first (renders above text) + finalContent.push(...intermediateToolCalls); + + // Add text/thinking from the last turn if (builder && builder.message.content.length > 0) { - finalContent = [...builder.message.content]; + for (const block of builder.message.content) { + if (block.type === "text" || block.type === "thinking") { + finalContent.push(block); + } + } } else { - // Fall back to captured text from complete assistant messages if (lastThinkingContent) { finalContent.push({ type: "thinking", thinking: lastThinkingContent }); }