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:
commit
91c1547856
5 changed files with 77 additions and 17 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue