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:
parent
d524454059
commit
a0ee03d331
5 changed files with 59 additions and 18 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue