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) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-25 12:43:34 -06:00
parent d524454059
commit a0ee03d331
5 changed files with 59 additions and 18 deletions

View file

@ -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,

View file

@ -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<any>) => boolean;
}
/**
@ -144,6 +151,7 @@ export class Agent {
private _maxRetryDelayMs?: number;
private _beforeToolCall?: AgentLoopConfig["beforeToolCall"];
private _afterToolCall?: AgentLoopConfig["afterToolCall"];
private _externalToolExecution?: (model: Model<any>) => 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;

View file

@ -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<AfterToolCallResult | undefined>;
/**
* 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;
}
/**

View file

@ -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.

View file

@ -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