diff --git a/PIPELINE_TODO.md b/PIPELINE_TODO.md index f4f79a1..dcd0fec 100644 --- a/PIPELINE_TODO.md +++ b/PIPELINE_TODO.md @@ -74,7 +74,7 @@ - [x] Убрать дублирующий ручной `tool-rank-audit.ts`, если stage полностью заменит его. - [x] Сохранить status UX: `🧩 Выбираю подходящие инструменты...`. - [x] Гарантировать `clearStatus()` после ranker success/failure. -- [ ] Добавить fallback через `PipelineFallbackExecutor`: main model, all tools, no tools. +- [x] Добавить fallback через `PipelineFallbackExecutor`: main model, all tools, no tools. - [x] Добавить tests на fallback ranker policy. ## 6. Сделать model_call и tool_loop физически отдельными stages diff --git a/src/ai/tool-ranker-fallback.ts b/src/ai/tool-ranker-fallback.ts index 696f971..a85d7ba 100644 --- a/src/ai/tool-ranker-fallback.ts +++ b/src/ai/tool-ranker-fallback.ts @@ -1,23 +1,56 @@ import {ToolRankerFallbackPolicy} from "../common/policies.js"; +import {decidePipelineFallback, type PipelineFallbackDecision} from "./user-request-pipeline/fallback-executor.js"; export type ToolRankerFallbackSelection = { toolNames: string[]; usedRanker: boolean; }; +export type ToolRankerFallbackDecision = PipelineFallbackDecision & ToolRankerFallbackSelection; + +function fallbackActionForPolicy(policy: ToolRankerFallbackPolicy) { + return policy === ToolRankerFallbackPolicy.MAIN_MODEL + ? "use_alternate_target" + : "continue_without_stage"; +} + +export function decideToolRankerFallback(params: { + fallbackPolicy: ToolRankerFallbackPolicy; + availableToolNames: readonly string[]; + reason: "unavailable" | "failed"; +}): ToolRankerFallbackDecision { + const action = fallbackActionForPolicy(params.fallbackPolicy); + const decision = decidePipelineFallback({ + stage: "tool_rank", + reason: params.reason, + policies: [{ + stage: "tool_rank", + onUnavailable: action, + onFailed: action, + }], + }); + + return { + ...decision, + toolNames: params.fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS + ? [] + : [...params.availableToolNames], + usedRanker: false, + }; +} + export function resolveToolRankerFallbackSelection(params: { fallbackPolicy: ToolRankerFallbackPolicy; availableToolNames: readonly string[]; }): ToolRankerFallbackSelection { - if (params.fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) { - return { - toolNames: [], - usedRanker: false, - }; - } + const decision = decideToolRankerFallback({ + fallbackPolicy: params.fallbackPolicy, + availableToolNames: params.availableToolNames, + reason: "failed", + }); return { - toolNames: [...params.availableToolNames], - usedRanker: false, + toolNames: decision.toolNames, + usedRanker: decision.usedRanker, }; } diff --git a/test/tool-ranker-fallback.test.mjs b/test/tool-ranker-fallback.test.mjs index 2662c6c..15883bf 100644 --- a/test/tool-ranker-fallback.test.mjs +++ b/test/tool-ranker-fallback.test.mjs @@ -2,7 +2,10 @@ import test from "node:test"; import assert from "node:assert/strict"; const {ToolRankerFallbackPolicy} = await import("../dist/common/policies.js"); -const {resolveToolRankerFallbackSelection} = await import("../dist/ai/tool-ranker-fallback.js"); +const { + decideToolRankerFallback, + resolveToolRankerFallbackSelection, +} = await import("../dist/ai/tool-ranker-fallback.js"); const availableToolNames = ["read_file", "search_files"]; @@ -32,6 +35,26 @@ test("tool ranker fallback returns all tools when policy is ALL_TOOLS", () => { ); }); +test("tool ranker fallback decision uses executor semantics", () => { + assert.deepEqual( + decideToolRankerFallback({ + fallbackPolicy: ToolRankerFallbackPolicy.MAIN_MODEL, + availableToolNames, + reason: "failed", + }), + { + stage: "tool_rank", + reason: "failed", + action: "use_alternate_target", + shouldContinue: true, + shouldNotifyUser: false, + shouldFailRequest: false, + toolNames: ["read_file", "search_files"], + usedRanker: false, + }, + ); +}); + test("tool ranker fallback keeps all tools when policy is MAIN_MODEL", () => { assert.deepEqual( resolveToolRankerFallbackSelection({