From 9352ade19f9cff6aac677029a01a537d3cd6b299 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Mon, 18 May 2026 19:05:13 +0300 Subject: [PATCH] Summarize tool loop output --- PIPELINE_TODO.md | 4 +- src/ai/tool-loop-summary.ts | 56 ++++++++++++++++++++++++++ src/ai/unified-ai-response-pipeline.ts | 38 ++++------------- test/tool-loop-summary.test.mjs | 47 +++++++++++++++++++++ 4 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 src/ai/tool-loop-summary.ts create mode 100644 test/tool-loop-summary.test.mjs diff --git a/PIPELINE_TODO.md b/PIPELINE_TODO.md index 75e945b..dfd89e8 100644 --- a/PIPELINE_TODO.md +++ b/PIPELINE_TODO.md @@ -81,12 +81,12 @@ - [ ] Stage `model_call` должен делать только один model request. - [x] Stage `model_call` должен возвращать normalized model output. -- [ ] Stage `tool_loop` должен решать, есть ли tool calls. +- [x] Stage `tool_loop` должен решать, есть ли tool calls. - [ ] Stage `tool_loop` должен выполнять tools через общий `executeToolBatch`. - [ ] Stage `tool_loop` должен добавлять tool results в provider adapter. - [ ] Stage `tool_loop` должен управлять max rounds. - [ ] Stage `tool_loop` должен сохранять tool result artifacts. -- [ ] Stage `tool_loop` должен уметь завершаться без tools как `skipped`. +- [x] Stage `tool_loop` должен уметь завершаться без tools как `skipped`. - [ ] Убрать tool loop из `runOpenAi`. - [ ] Убрать tool loop из `runMistral`. - [ ] Убрать tool loop из `runOllama`. diff --git a/src/ai/tool-loop-summary.ts b/src/ai/tool-loop-summary.ts new file mode 100644 index 0000000..979cba6 --- /dev/null +++ b/src/ai/tool-loop-summary.ts @@ -0,0 +1,56 @@ +import type {PipelineArtifact} from "./user-request-pipeline/types.js"; +import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js"; +import {summarizeModelOutput} from "./response-model-output.js"; + +export type ToolLoopSummary = { + status: "succeeded" | "skipped"; + fallbackAction?: "continue_without_stage"; + details: { + modelOutput: ReturnType; + count: number; + tools: Array<{ + toolName: string; + callId: string; + resultChars: number; + }>; + }; + artifacts?: PipelineArtifact[]; +}; + +export function summarizeToolLoop(params: { + text: string; + executions: readonly TelegramToolExecutionRecord[]; + outputAttachments: readonly TelegramOutputAttachmentRecord[]; +}): ToolLoopSummary { + const count = params.executions.length; + const tools = params.executions.map(execution => ({ + toolName: execution.toolName, + callId: execution.callId, + resultChars: execution.resultChars, + })); + + return { + status: count ? "succeeded" : "skipped", + fallbackAction: count ? undefined : "continue_without_stage", + details: { + modelOutput: summarizeModelOutput({ + text: params.text, + toolExecutions: params.executions, + outputAttachments: params.outputAttachments, + }), + count, + tools, + }, + artifacts: count ? [{ + kind: "tool_result", + stage: "tool_loop", + createdAt: new Date().toISOString(), + toolName: "summary", + callId: "tool_loop_summary", + resultText: JSON.stringify({ + count, + tools, + }), + }] : undefined, + }; +} diff --git a/src/ai/unified-ai-response-pipeline.ts b/src/ai/unified-ai-response-pipeline.ts index cba1363..4e96e90 100644 --- a/src/ai/unified-ai-response-pipeline.ts +++ b/src/ai/unified-ai-response-pipeline.ts @@ -22,6 +22,7 @@ import {runOpenAi} from "./unified-ai-runner.openai"; import {runOllama} from "./unified-ai-runner.ollama"; import {runMistral} from "./unified-ai-runner.mistral"; import {summarizeModelOutput} from "./response-model-output"; +import {summarizeToolLoop} from "./tool-loop-summary"; import { resolveTextToSpeechProviderForUser, sendSynthesizedSpeech, @@ -269,38 +270,15 @@ export async function runUnifiedAiResponsePipeline(params: { name: "tool_loop", async run() { const executions = streamMessage.getToolExecutions(); + const summary = summarizeToolLoop({ + text: streamMessage.getText(), + executions, + outputAttachments: streamMessage.getOutputAttachments(), + }); + return { stage: "tool_loop", - status: executions.length ? "succeeded" : "skipped", - fallbackAction: executions.length ? undefined : "continue_without_stage", - details: { - modelOutput: summarizeModelOutput({ - text: streamMessage.getText(), - toolExecutions: executions, - outputAttachments: streamMessage.getOutputAttachments(), - }), - count: executions.length, - tools: executions.map(execution => ({ - toolName: execution.toolName, - callId: execution.callId, - resultChars: execution.resultChars, - })), - }, - artifacts: executions.length ? [{ - kind: "tool_result", - stage: "tool_loop", - createdAt: new Date().toISOString(), - toolName: "summary", - callId: "tool_loop_summary", - resultText: JSON.stringify({ - count: executions.length, - tools: executions.map(execution => ({ - toolName: execution.toolName, - callId: execution.callId, - resultChars: execution.resultChars, - })), - }), - }] : undefined, + ...summary, }; }, }, diff --git a/test/tool-loop-summary.test.mjs b/test/tool-loop-summary.test.mjs new file mode 100644 index 0000000..89cd3bb --- /dev/null +++ b/test/tool-loop-summary.test.mjs @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const {summarizeToolLoop} = await import("../dist/ai/tool-loop-summary.js"); + +test("tool loop summary skips empty tool execution batches", () => { + const summary = summarizeToolLoop({ + text: "answer", + executions: [], + outputAttachments: [], + }); + + assert.equal(summary.status, "skipped"); + assert.equal(summary.fallbackAction, "continue_without_stage"); + assert.equal(summary.details.count, 0); + assert.deepEqual(summary.details.tools, []); + assert.deepEqual(summary.details.modelOutput, { + text: "answer", + toolExecutions: [], + outputAttachments: [], + }); + assert.equal(summary.artifacts, undefined); +}); + +test("tool loop summary reports executions and summary artifact", () => { + const summary = summarizeToolLoop({ + text: "answer", + executions: [{ + toolName: "read_file", + callId: "call-1", + argumentsText: "{}", + resultChars: 12, + }], + outputAttachments: [], + }); + + assert.equal(summary.status, "succeeded"); + assert.equal(summary.fallbackAction, undefined); + assert.equal(summary.details.count, 1); + assert.deepEqual(summary.details.tools, [{ + toolName: "read_file", + callId: "call-1", + resultChars: 12, + }]); + assert.equal(summary.artifacts?.[0]?.kind, "tool_result"); + assert.equal(summary.artifacts?.[0]?.stage, "tool_loop"); +});