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