Merge pull request #2532 from gsd-build/feat/external-tool-execution

feat(agent-core): external tool execution mode for Claude Code CLI
This commit is contained in:
TÂCHES 2026-03-25 13:09:21 -06:00 committed by GitHub
commit 91c1547856
5 changed files with 77 additions and 17 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

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