feat(ai): upgrade agent loop tool calling to true SSE streaming

Move tool calling from non-streaming (complete) API calls to real
streaming SSE, so text, reasoning, and tool call arguments all arrive
incrementally — users see the AI think and act in real time.

- Extract ~370 lines of non-streaming tool-call functions from
  agent_loop.rs (call_with_tools, call_openai_with_tools,
  call_claude_with_tools, call_gemini_with_tools) and replace with
  a single ai::stream_with_tools() dispatch call.

- Add streaming-with-tools infrastructure to ai.rs (~550 lines):
  - StreamToolEvent enum: Chunk, ToolCallStart, ToolCallDelta,
    ToolCallComplete — provider-agnostic, feeds into the
    StreamingToolCallAccumulator
  - stream_claude_with_tools(): parse SSE content_block_start/delta/
    stop events, incremental input_json_delta for tool arguments
  - stream_openai_with_tools(): parse SSE delta.tool_calls with
    incremental function.arguments
  - stream_gemini_with_tools(): parse SSE functionCall (Gemini
    sends complete objects, not deltas — emit as one chunk)
  - Cancellation support via tokio::select! in every streaming loop
  - Extended timeout for reasoning models (600s vs 120s)

- StreamingToolCallAccumulator: collect partial tool call fragments
  from streaming deltas, then deserialize into complete ToolCall
  objects in index order once the stream ends.
This commit is contained in:
runstone committed 2026-06-12 11:43:09 +08:00
1 parent 9939e3191f
commit 2d75667b9a
5 files changed
+565 -339

No files matched your search

@@ -253,8 +253,10 @@ function agentStepClass(tone: AiAgentStepTone): string {
}
function agentStepTitle(step: AiAgentStepItem): string {
if (!step.titleKey) return t(step.labelKey);
return t(step.titleKey, step.titleParams || {});
if (step.titleKey) return t(step.titleKey, step.titleParams || {});
const tool = step.titleParams?.tool;
if (tool) return `${t(step.labelKey)}: ${tool}`;
return t(step.labelKey);
}
function toggleReasoning(index: number) {
@@ -513,6 +515,22 @@ async function send() {
if (event.type === "reasoning_delta" && event.delta) {
appendAssistantReasoning(assistantIdx, event.delta);
}
// Real-time agent step rendering
if (event.type === "tool_call_start" || event.type === "tool_call_end") {
const msg = messages.value[assistantIdx];
if (msg) {
const steps = agentEvents
.filter((e) => e.type === "tool_call_start" || e.type === "tool_call_end")
.map((e) => ({
key: `${e.tool_call_id || ""}-${e.type}`,
labelKey: e.type === "tool_call_start" ? "ai.agentSteps.callingTool" : e.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone",
tone: (e.type === "tool_call_start" ? "active" : e.is_error ? "danger" : "success") as AiAgentStepTone,
titleKey: undefined,
titleParams: { tool: e.tool_name || "" },
}));
msg.agentSteps = steps;
}
}
scrollToBottom();
},
sessionId,