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..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,25 +281,28 @@ async function pumpSdkMessages( case "result": { const result = msg as SDKResultMessage; - // Build final message with text/thinking only (strip tool calls) + // 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"] = []; - // Use builder's accumulated content if available, falling back to captured text - if (builder) { + // 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) { 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 (block.type === "text" || block.type === "thinking") { + finalContent.push(block); } } - } - - if (lastThinkingContent) { - finalContent.push({ type: "thinking", thinking: lastThinkingContent }); - } - if (lastTextContent) { - finalContent.push({ type: "text", text: lastTextContent }); + } else { + 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