From a0ee03d33105e0cbcba7f313239569bf370a72ab Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 25 Mar 2026 12:43:34 -0600 Subject: [PATCH] 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