lynkr 9.5.0 → 9.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/agents/decomposition/dispatcher.js +185 -0
- package/src/agents/decomposition/gate.js +136 -0
- package/src/agents/decomposition/index.js +183 -0
- package/src/agents/decomposition/model-call.js +75 -0
- package/src/agents/decomposition/planner.js +223 -0
- package/src/agents/decomposition/synthesizer.js +89 -0
- package/src/agents/decomposition/telemetry.js +55 -0
- package/src/clients/databricks.js +135 -1
- package/src/clients/openrouter-utils.js +6 -29
- package/src/clients/prompt-cache-injection.js +49 -0
- package/src/clients/responses-format.js +7 -0
- package/src/clients/tool-call-repair.js +130 -0
- package/src/config/index.js +23 -0
- package/src/context/output-format-guard.js +99 -0
- package/src/routing/index.js +8 -0
- package/src/routing/tier-fallback.js +91 -0
- package/src/server.js +2 -0
- package/src/tools/decompose.js +91 -0
- package/src/tools/lazy-loader.js +8 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decomposition planner (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Turns a complex task into a small subtask DAG using a single model call
|
|
5
|
+
* (plan-and-solve style — plan generated in one shot). Output is validated:
|
|
6
|
+
* ids unique, dependencies reference real ids, no cycles, subtask count capped.
|
|
7
|
+
* If anything fails to parse/validate the planner returns null and the caller
|
|
8
|
+
* falls back to a monolithic solve.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { callModel, extractText } = require("./model-call");
|
|
12
|
+
const logger = require("../../logger");
|
|
13
|
+
|
|
14
|
+
// Agent types the dispatcher knows how to spawn. Planner is steered toward
|
|
15
|
+
// these; unknown types are coerced to general-purpose at dispatch time.
|
|
16
|
+
const KNOWN_AGENT_TYPES = [
|
|
17
|
+
"Explore",
|
|
18
|
+
"Plan",
|
|
19
|
+
"general-purpose",
|
|
20
|
+
"Test",
|
|
21
|
+
"Debug",
|
|
22
|
+
"Fix",
|
|
23
|
+
"Refactor",
|
|
24
|
+
"Documentation",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function buildPlannerPrompt(task, maxSubtasks) {
|
|
28
|
+
return `You are a task-decomposition planner. Break the task below into the SMALLEST set of focused subtasks that can be solved with isolated context. Fewer is better — do NOT over-split. If the task is not genuinely divisible, return a single subtask.
|
|
29
|
+
|
|
30
|
+
Rules:
|
|
31
|
+
- At most ${maxSubtasks} subtasks.
|
|
32
|
+
- Each subtask must be independently solvable given only its prompt plus the results of the subtasks it depends on.
|
|
33
|
+
- Mark dependencies via "dependsOn" (array of subtask ids). Independent subtasks (empty dependsOn) will run in parallel.
|
|
34
|
+
- Prefer assigning each subtask an agent type from: ${KNOWN_AGENT_TYPES.join(", ")}.
|
|
35
|
+
- Keep each subtask prompt self-contained and specific.
|
|
36
|
+
|
|
37
|
+
Respond with ONLY a JSON object, no prose, in exactly this shape:
|
|
38
|
+
{
|
|
39
|
+
"strategy": "one short sentence on how you split it",
|
|
40
|
+
"subtasks": [
|
|
41
|
+
{ "id": "s1", "agentType": "Explore", "prompt": "...", "dependsOn": [] }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
TASK:
|
|
46
|
+
${task}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract the first balanced JSON object from a string (handles models that
|
|
51
|
+
* wrap JSON in prose or ```json fences).
|
|
52
|
+
*/
|
|
53
|
+
function extractJsonObject(text) {
|
|
54
|
+
if (!text) return null;
|
|
55
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
56
|
+
const candidate = fenced ? fenced[1] : text;
|
|
57
|
+
|
|
58
|
+
const start = candidate.indexOf("{");
|
|
59
|
+
if (start === -1) return null;
|
|
60
|
+
|
|
61
|
+
let depth = 0;
|
|
62
|
+
let inString = false;
|
|
63
|
+
let escape = false;
|
|
64
|
+
for (let i = start; i < candidate.length; i++) {
|
|
65
|
+
const ch = candidate[i];
|
|
66
|
+
if (escape) {
|
|
67
|
+
escape = false;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ch === "\\") {
|
|
71
|
+
escape = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (ch === '"') {
|
|
75
|
+
inString = !inString;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (inString) continue;
|
|
79
|
+
if (ch === "{") depth++;
|
|
80
|
+
else if (ch === "}") {
|
|
81
|
+
depth--;
|
|
82
|
+
if (depth === 0) {
|
|
83
|
+
const slice = candidate.slice(start, i + 1);
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(slice);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate and normalise a parsed plan. Returns a clean plan or null.
|
|
97
|
+
*/
|
|
98
|
+
function validatePlan(parsed, maxSubtasks) {
|
|
99
|
+
if (!parsed || !Array.isArray(parsed.subtasks) || parsed.subtasks.length === 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const subtasks = [];
|
|
104
|
+
const seenIds = new Set();
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < parsed.subtasks.length && subtasks.length < maxSubtasks; i++) {
|
|
107
|
+
const st = parsed.subtasks[i];
|
|
108
|
+
if (!st || typeof st.prompt !== "string" || st.prompt.trim().length === 0) {
|
|
109
|
+
return null; // malformed subtask → reject whole plan, fall back
|
|
110
|
+
}
|
|
111
|
+
const id = typeof st.id === "string" && st.id.trim() ? st.id.trim() : `s${i + 1}`;
|
|
112
|
+
if (seenIds.has(id)) return null; // duplicate ids
|
|
113
|
+
seenIds.add(id);
|
|
114
|
+
|
|
115
|
+
const agentType = KNOWN_AGENT_TYPES.includes(st.agentType)
|
|
116
|
+
? st.agentType
|
|
117
|
+
: "general-purpose";
|
|
118
|
+
|
|
119
|
+
const dependsOn = Array.isArray(st.dependsOn)
|
|
120
|
+
? st.dependsOn.filter((d) => typeof d === "string")
|
|
121
|
+
: [];
|
|
122
|
+
|
|
123
|
+
subtasks.push({ id, agentType, prompt: st.prompt.trim(), dependsOn });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Dependencies must reference real ids and contain no cycles.
|
|
127
|
+
for (const st of subtasks) {
|
|
128
|
+
for (const dep of st.dependsOn) {
|
|
129
|
+
if (!seenIds.has(dep)) return null; // dangling dependency
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (hasCycle(subtasks)) return null;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
strategy: typeof parsed.strategy === "string" ? parsed.strategy : "",
|
|
136
|
+
subtasks,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Cycle detection via DFS colouring.
|
|
142
|
+
*/
|
|
143
|
+
function hasCycle(subtasks) {
|
|
144
|
+
const byId = new Map(subtasks.map((s) => [s.id, s]));
|
|
145
|
+
const state = new Map(); // id → 0 unvisited, 1 visiting, 2 done
|
|
146
|
+
|
|
147
|
+
function visit(id) {
|
|
148
|
+
const cur = state.get(id) || 0;
|
|
149
|
+
if (cur === 1) return true; // back-edge → cycle
|
|
150
|
+
if (cur === 2) return false;
|
|
151
|
+
state.set(id, 1);
|
|
152
|
+
const node = byId.get(id);
|
|
153
|
+
for (const dep of node?.dependsOn || []) {
|
|
154
|
+
if (visit(dep)) return true;
|
|
155
|
+
}
|
|
156
|
+
state.set(id, 2);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const s of subtasks) {
|
|
161
|
+
if (visit(s.id)) return true;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Generate a validated plan for a task.
|
|
168
|
+
* @param {Object} params
|
|
169
|
+
* @param {string} params.task
|
|
170
|
+
* @param {string} [params.model="sonnet"] - planning needs reasoning; use a capable model
|
|
171
|
+
* @param {number} [params.maxSubtasks=6]
|
|
172
|
+
* @param {Function} [params.invoke] - injectable model invoker (for tests)
|
|
173
|
+
* @returns {Promise<{strategy:string, subtasks:Array, usage:Object}|null>}
|
|
174
|
+
*/
|
|
175
|
+
async function generatePlan({ task, model = "sonnet", maxSubtasks = 6, invoke } = {}) {
|
|
176
|
+
if (!task || typeof task !== "string") return null;
|
|
177
|
+
|
|
178
|
+
let responseJson;
|
|
179
|
+
try {
|
|
180
|
+
responseJson = await callModel({
|
|
181
|
+
messages: [{ role: "user", content: buildPlannerPrompt(task, maxSubtasks) }],
|
|
182
|
+
model,
|
|
183
|
+
maxTokens: 1500,
|
|
184
|
+
temperature: 0.1,
|
|
185
|
+
invoke,
|
|
186
|
+
});
|
|
187
|
+
} catch (err) {
|
|
188
|
+
logger.warn({ err: err.message }, "[Decomposition] Planner model call failed");
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const text = extractText(responseJson);
|
|
193
|
+
const parsed = extractJsonObject(text);
|
|
194
|
+
const plan = validatePlan(parsed, maxSubtasks);
|
|
195
|
+
|
|
196
|
+
if (!plan) {
|
|
197
|
+
logger.warn(
|
|
198
|
+
{ preview: text.slice(0, 200) },
|
|
199
|
+
"[Decomposition] Plan failed validation — will fall back to monolithic"
|
|
200
|
+
);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
plan.usage = {
|
|
205
|
+
inputTokens: responseJson?.usage?.input_tokens || 0,
|
|
206
|
+
outputTokens: responseJson?.usage?.output_tokens || 0,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
logger.info(
|
|
210
|
+
{ subtasks: plan.subtasks.length, strategy: plan.strategy },
|
|
211
|
+
"[Decomposition] Plan generated"
|
|
212
|
+
);
|
|
213
|
+
return plan;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
generatePlan,
|
|
218
|
+
validatePlan,
|
|
219
|
+
extractJsonObject,
|
|
220
|
+
hasCycle,
|
|
221
|
+
buildPlannerPrompt,
|
|
222
|
+
KNOWN_AGENT_TYPES,
|
|
223
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result synthesizer (Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Combines the (compressed) results of all subtasks into one coherent final
|
|
5
|
+
* answer to the original task. Single model call. If synthesis fails, the
|
|
6
|
+
* caller falls back to concatenating the subtask results.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { callModel, extractText } = require("./model-call");
|
|
10
|
+
const logger = require("../../logger");
|
|
11
|
+
|
|
12
|
+
const MAX_RESULT_CHARS = 4000;
|
|
13
|
+
|
|
14
|
+
function buildSynthesisPrompt(task, subtaskResults) {
|
|
15
|
+
const blocks = subtaskResults
|
|
16
|
+
.map((r) => {
|
|
17
|
+
const status = r.success ? "OK" : `FAILED (${r.error})`;
|
|
18
|
+
const body = r.success
|
|
19
|
+
? truncate(r.result, MAX_RESULT_CHARS)
|
|
20
|
+
: "(no result)";
|
|
21
|
+
return `### Subtask ${r.id} [${r.agentType}] — ${status}\n${body}`;
|
|
22
|
+
})
|
|
23
|
+
.join("\n\n");
|
|
24
|
+
|
|
25
|
+
return `You are synthesizing the results of several subtasks into one final answer for the original request. Integrate the findings into a single coherent response. Resolve overlaps, note any subtask that failed, and do not invent results that no subtask produced.
|
|
26
|
+
|
|
27
|
+
ORIGINAL TASK:
|
|
28
|
+
${task}
|
|
29
|
+
|
|
30
|
+
SUBTASK RESULTS:
|
|
31
|
+
${blocks}
|
|
32
|
+
|
|
33
|
+
Write the final answer now.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncate(text, max) {
|
|
37
|
+
if (typeof text !== "string") return String(text ?? "");
|
|
38
|
+
return text.length <= max ? text : text.slice(0, max) + "\n…[truncated]";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Concatenation fallback used when the synthesis model call fails.
|
|
43
|
+
*/
|
|
44
|
+
function concatFallback(subtaskResults) {
|
|
45
|
+
return subtaskResults
|
|
46
|
+
.filter((r) => r.success && r.result)
|
|
47
|
+
.map((r) => `## ${r.id} (${r.agentType})\n${r.result}`)
|
|
48
|
+
.join("\n\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {Object} params
|
|
53
|
+
* @param {string} params.task
|
|
54
|
+
* @param {Array} params.subtaskResults - from dispatcher
|
|
55
|
+
* @param {string} [params.model="sonnet"]
|
|
56
|
+
* @param {Function} [params.invoke]
|
|
57
|
+
* @returns {Promise<{text:string, fallback:boolean, usage:Object}>}
|
|
58
|
+
*/
|
|
59
|
+
async function synthesize({ task, subtaskResults, model = "sonnet", invoke } = {}) {
|
|
60
|
+
const anySuccess = subtaskResults.some((r) => r.success);
|
|
61
|
+
if (!anySuccess) {
|
|
62
|
+
return { text: "All subtasks failed; no result could be produced.", fallback: true, usage: {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const responseJson = await callModel({
|
|
67
|
+
messages: [{ role: "user", content: buildSynthesisPrompt(task, subtaskResults) }],
|
|
68
|
+
model,
|
|
69
|
+
maxTokens: 4096,
|
|
70
|
+
temperature: 0.3,
|
|
71
|
+
invoke,
|
|
72
|
+
});
|
|
73
|
+
const text = extractText(responseJson);
|
|
74
|
+
if (!text) throw new Error("Empty synthesis");
|
|
75
|
+
return {
|
|
76
|
+
text,
|
|
77
|
+
fallback: false,
|
|
78
|
+
usage: {
|
|
79
|
+
inputTokens: responseJson?.usage?.input_tokens || 0,
|
|
80
|
+
outputTokens: responseJson?.usage?.output_tokens || 0,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
} catch (err) {
|
|
84
|
+
logger.warn({ err: err.message }, "[Decomposition] Synthesis failed — concatenating results");
|
|
85
|
+
return { text: concatFallback(subtaskResults), fallback: true, usage: {} };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { synthesize, buildSynthesisPrompt, concatFallback };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decomposition telemetry + shadow mode (Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* Appends one JSON line per decomposition decision to
|
|
5
|
+
* data/decomposition-decisions.jsonl so the net token effect can be audited.
|
|
6
|
+
* Because the research is clear that decomposition can COST more than it saves,
|
|
7
|
+
* a shadow mode (TASK_DECOMPOSITION_SHADOW=true) runs the gate + records what it
|
|
8
|
+
* WOULD have done without actually decomposing — so savings can be validated on
|
|
9
|
+
* real traffic before enabling for real.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const logger = require("../../logger");
|
|
15
|
+
|
|
16
|
+
const LOG_PATH = path.join(__dirname, "../../../data/decomposition-decisions.jsonl");
|
|
17
|
+
|
|
18
|
+
function estimateTokens(text) {
|
|
19
|
+
if (typeof text !== "string") return 0;
|
|
20
|
+
return Math.ceil(text.length / 4);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rough net-savings estimate.
|
|
25
|
+
* monolithic ≈ what one big context would have cost (estimated input tokens).
|
|
26
|
+
* decomposed ≈ planning + Σ(subagent in+out) + synthesis.
|
|
27
|
+
* Positive `savedTokens` = decomposition was cheaper.
|
|
28
|
+
*/
|
|
29
|
+
function estimateSavings({ monolithicTokens, planUsage, dispatchStats, synthUsage }) {
|
|
30
|
+
const decomposed =
|
|
31
|
+
(planUsage?.inputTokens || 0) +
|
|
32
|
+
(planUsage?.outputTokens || 0) +
|
|
33
|
+
(dispatchStats?.inputTokens || 0) +
|
|
34
|
+
(dispatchStats?.outputTokens || 0) +
|
|
35
|
+
(synthUsage?.inputTokens || 0) +
|
|
36
|
+
(synthUsage?.outputTokens || 0);
|
|
37
|
+
return {
|
|
38
|
+
monolithicTokens: monolithicTokens || 0,
|
|
39
|
+
decomposedTokens: decomposed,
|
|
40
|
+
savedTokens: (monolithicTokens || 0) - decomposed,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function record(entry) {
|
|
45
|
+
const line = { timestamp: Date.now(), ...entry };
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
48
|
+
fs.appendFileSync(LOG_PATH, JSON.stringify(line) + "\n");
|
|
49
|
+
} catch (err) {
|
|
50
|
+
logger.debug({ err: err.message }, "[Decomposition] Telemetry append failed");
|
|
51
|
+
}
|
|
52
|
+
return line;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { record, estimateSavings, estimateTokens, LOG_PATH };
|
|
@@ -1104,6 +1104,64 @@ async function invokeLMStudio(body) {
|
|
|
1104
1104
|
return performJsonRequest(endpoint, { headers, body: lmstudioBody }, "LM Studio");
|
|
1105
1105
|
}
|
|
1106
1106
|
|
|
1107
|
+
/**
|
|
1108
|
+
* Flatten an Anthropic-style content value into a plain string for the
|
|
1109
|
+
* Bedrock Converse API.
|
|
1110
|
+
*
|
|
1111
|
+
* Prompt-cache injection (injectPromptCaching) rewrites string `system`
|
|
1112
|
+
* fields and message `content` into arrays of `{ type, text, cache_control }`
|
|
1113
|
+
* blocks. The Converse API has no `cache_control` concept and expects
|
|
1114
|
+
* `system: [{ text: "<string>" }]` and message content blocks shaped as
|
|
1115
|
+
* `{ text: "<string>" }`. Passing the injected array through unchanged would
|
|
1116
|
+
* either drop the cache markers silently or nest an array under `text`,
|
|
1117
|
+
* producing a ValidationException.
|
|
1118
|
+
*
|
|
1119
|
+
* @param {string|Array|undefined} value - String or array of content blocks
|
|
1120
|
+
* @returns {string} Concatenated plain text
|
|
1121
|
+
*/
|
|
1122
|
+
function flattenContentToText(value) {
|
|
1123
|
+
if (value == null) return "";
|
|
1124
|
+
if (typeof value === "string") return value;
|
|
1125
|
+
if (Array.isArray(value)) {
|
|
1126
|
+
return value
|
|
1127
|
+
.map(block => {
|
|
1128
|
+
if (typeof block === "string") return block;
|
|
1129
|
+
if (block && typeof block === "object") return block.text || block.content || "";
|
|
1130
|
+
return "";
|
|
1131
|
+
})
|
|
1132
|
+
.join("");
|
|
1133
|
+
}
|
|
1134
|
+
return String(value);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Normalize a request body for the Bedrock Converse API.
|
|
1139
|
+
*
|
|
1140
|
+
* Strips `cache_control` markers and flattens any array-shaped `system` /
|
|
1141
|
+
* message `content` (left behind by prompt-cache injection) back into the
|
|
1142
|
+
* plain strings the Converse API expects. Returns a shallow copy with a
|
|
1143
|
+
* normalized `messages` array; the original body is not mutated.
|
|
1144
|
+
*
|
|
1145
|
+
* @param {Object} body - Anthropic-format request body
|
|
1146
|
+
* @returns {Object} Body safe for Converse request construction
|
|
1147
|
+
*/
|
|
1148
|
+
function normalizeBodyForConverse(body) {
|
|
1149
|
+
const normalized = { ...body };
|
|
1150
|
+
|
|
1151
|
+
if (normalized.system !== undefined) {
|
|
1152
|
+
normalized.system = flattenContentToText(normalized.system);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (Array.isArray(normalized.messages)) {
|
|
1156
|
+
normalized.messages = normalized.messages.map(msg => ({
|
|
1157
|
+
...msg,
|
|
1158
|
+
content: flattenContentToText(msg.content),
|
|
1159
|
+
}));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return normalized;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1107
1165
|
async function invokeBedrock(body) {
|
|
1108
1166
|
// 1. Validate Bearer token
|
|
1109
1167
|
if (!config.bedrock?.apiKey) {
|
|
@@ -1130,7 +1188,10 @@ async function invokeBedrock(body) {
|
|
|
1130
1188
|
}, "=== INJECTING STANDARD TOOLS (Bedrock) ===");
|
|
1131
1189
|
}
|
|
1132
1190
|
|
|
1133
|
-
|
|
1191
|
+
// Normalize away cache_control / array shapes that prompt-cache injection
|
|
1192
|
+
// may have applied: the Converse API expects plain-string system and
|
|
1193
|
+
// message content, not Anthropic cache_control blocks.
|
|
1194
|
+
const bedrockBody = { ...normalizeBodyForConverse(body), tools: toolsToSend };
|
|
1134
1195
|
|
|
1135
1196
|
// 4. Detect model family and convert format
|
|
1136
1197
|
const modelId = body._tierModel || config.bedrock.modelId;
|
|
@@ -2124,6 +2185,13 @@ async function invokeModel(body, options = {}) {
|
|
|
2124
2185
|
const { injectPromptCaching } = require('./prompt-cache-injection');
|
|
2125
2186
|
injectPromptCaching(body, initialProvider);
|
|
2126
2187
|
|
|
2188
|
+
// Always-on markdown formatting guard. Stops formatting-weak backends
|
|
2189
|
+
// (Moonshot/Kimi, Ollama, etc.) from emitting mangled ASCII box-drawing
|
|
2190
|
+
// "diagrams". Keyed off the routing-resolved provider/model; skipped for
|
|
2191
|
+
// Claude-family backends which already format cleanly.
|
|
2192
|
+
const { injectFormatGuard } = require('../context/output-format-guard');
|
|
2193
|
+
injectFormatGuard(body, { provider: initialProvider, model: tierSelectedModel });
|
|
2194
|
+
|
|
2127
2195
|
// Build routing decision object for response headers
|
|
2128
2196
|
const routingDecision = {
|
|
2129
2197
|
provider: initialProvider,
|
|
@@ -2323,6 +2391,71 @@ async function invokeModel(body, options = {}) {
|
|
|
2323
2391
|
healthTracker.recordFailure(initialProvider, err, err.status);
|
|
2324
2392
|
getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
|
|
2325
2393
|
|
|
2394
|
+
// Tier-aware escalate-then-demote fallback (TIER_FALLBACK_ENABLED).
|
|
2395
|
+
// On failure, try a MORE capable tier first (climb toward REASONING); only
|
|
2396
|
+
// if every higher tier is unavailable do we fall downward to SIMPLE/local.
|
|
2397
|
+
// Runs before the flat global fallback below and is never silent.
|
|
2398
|
+
if (config.tierFallback?.enabled && !options._tierFallbackInner && routingDecision.tier) {
|
|
2399
|
+
const { getFallbackChain } = require("../routing/tier-fallback");
|
|
2400
|
+
const chain = getFallbackChain(routingDecision.tier);
|
|
2401
|
+
for (const cand of chain) {
|
|
2402
|
+
try {
|
|
2403
|
+
logger.warn({
|
|
2404
|
+
fromTier: routingDecision.tier,
|
|
2405
|
+
fromProvider: initialProvider,
|
|
2406
|
+
toTier: cand.tier,
|
|
2407
|
+
toProvider: cand.provider,
|
|
2408
|
+
toModel: cand.model,
|
|
2409
|
+
direction: cand.direction,
|
|
2410
|
+
}, "[TierFallback] Primary tier failed — attempting tier fallback");
|
|
2411
|
+
|
|
2412
|
+
const attempt = await invokeModel(
|
|
2413
|
+
{ ...body, _tierModel: cand.model },
|
|
2414
|
+
{
|
|
2415
|
+
forceProvider: cand.provider,
|
|
2416
|
+
_tierFallbackInner: true,
|
|
2417
|
+
disableFallback: true,
|
|
2418
|
+
_cascadeInner: true,
|
|
2419
|
+
workspace,
|
|
2420
|
+
tenantPolicy,
|
|
2421
|
+
}
|
|
2422
|
+
);
|
|
2423
|
+
|
|
2424
|
+
metricsCollector.recordFallbackAttempt(initialProvider, cand.provider, "tier_fallback");
|
|
2425
|
+
logger.warn({
|
|
2426
|
+
servedTier: cand.tier,
|
|
2427
|
+
servedProvider: cand.provider,
|
|
2428
|
+
fromTier: routingDecision.tier,
|
|
2429
|
+
direction: cand.direction,
|
|
2430
|
+
}, "[TierFallback] Served by tier fallback");
|
|
2431
|
+
|
|
2432
|
+
return {
|
|
2433
|
+
...attempt,
|
|
2434
|
+
actualProvider: cand.provider,
|
|
2435
|
+
routingDecision: {
|
|
2436
|
+
...routingDecision,
|
|
2437
|
+
provider: cand.provider,
|
|
2438
|
+
model: cand.model,
|
|
2439
|
+
servedTier: cand.tier,
|
|
2440
|
+
fromTier: routingDecision.tier,
|
|
2441
|
+
fallback: true,
|
|
2442
|
+
fallbackDirection: cand.direction,
|
|
2443
|
+
method: "tier_fallback",
|
|
2444
|
+
},
|
|
2445
|
+
};
|
|
2446
|
+
} catch (innerErr) {
|
|
2447
|
+
logger.warn(
|
|
2448
|
+
{ toProvider: cand.provider, error: innerErr.message },
|
|
2449
|
+
"[TierFallback] Candidate failed, trying next"
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
logger.warn(
|
|
2454
|
+
{ fromTier: routingDecision.tier },
|
|
2455
|
+
"[TierFallback] All tier candidates exhausted — falling through"
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2326
2459
|
// Check if we should fallback (any provider can fall back, not just ollama)
|
|
2327
2460
|
const shouldFallback =
|
|
2328
2461
|
isFallbackEnabled() &&
|
|
@@ -2579,4 +2712,5 @@ function destroyHttpAgents() {
|
|
|
2579
2712
|
module.exports = {
|
|
2580
2713
|
invokeModel,
|
|
2581
2714
|
destroyHttpAgents,
|
|
2715
|
+
normalizeBodyForConverse,
|
|
2582
2716
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const logger = require("../logger");
|
|
2
|
+
const { repairToolCallIds } = require("./tool-call-repair");
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Convert Anthropic tool format to OpenAI/OpenRouter format
|
|
@@ -146,35 +147,11 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
const msg = converted[i];
|
|
155
|
-
if (msg.role !== 'tool') continue;
|
|
156
|
-
|
|
157
|
-
// Find the nearest preceding assistant with tool_calls
|
|
158
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
159
|
-
const prev = converted[j];
|
|
160
|
-
if (prev.role === 'user') break;
|
|
161
|
-
if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) {
|
|
162
|
-
if (!prev.tool_calls.some(tc => tc.id === msg.tool_call_id)) {
|
|
163
|
-
// Mismatch — pick the first unmatched tool_call id
|
|
164
|
-
const usedIds = new Set();
|
|
165
|
-
for (let k = j + 1; k < converted.length; k++) {
|
|
166
|
-
if (converted[k].role === 'tool' && k !== i) usedIds.add(converted[k].tool_call_id);
|
|
167
|
-
}
|
|
168
|
-
const available = prev.tool_calls.find(tc => !usedIds.has(tc.id));
|
|
169
|
-
if (available) {
|
|
170
|
-
logger.info({ from: msg.tool_call_id, to: available.id }, "Fixed tool_call_id mismatch");
|
|
171
|
-
msg.tool_call_id = available.id;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
150
|
+
// Repair tool_call_id linkage before handing to OpenAI-compatible providers:
|
|
151
|
+
// backfill blank assistant tool_call ids, re-link drifted/blank tool_call_ids
|
|
152
|
+
// to the nearest preceding assistant tool_call, and drop orphan tool results.
|
|
153
|
+
// Moonshot/Kimi (and others) hard-400 on any empty or unmatched tool_call_id.
|
|
154
|
+
repairToolCallIds(converted);
|
|
178
155
|
|
|
179
156
|
// Kimi/Moonshot (and some OpenAI-compatible APIs) reject a message whose
|
|
180
157
|
// content is an empty string with "Invalid request: tokenization failed".
|
|
@@ -119,6 +119,51 @@ function needsCacheInjection(provider) {
|
|
|
119
119
|
return EXPLICIT_CACHE_PROVIDERS.has(provider);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Model families that do NOT support cache_control breakpoints. cache_control
|
|
123
|
+
// is an Anthropic construct; on aggregating providers (Bedrock, OpenRouter) it
|
|
124
|
+
// only applies to models that natively understand it (Claude, and Gemini via
|
|
125
|
+
// proxy). Injecting markers onto these families produces request shapes the
|
|
126
|
+
// upstream model rejects or silently ignores.
|
|
127
|
+
const NON_CACHE_MODEL_PATTERNS = [
|
|
128
|
+
/(^|[./-])titan/i,
|
|
129
|
+
/(^|[./-])nova/i,
|
|
130
|
+
/(^|[./-])llama/i,
|
|
131
|
+
/(^|[./-])mistral/i,
|
|
132
|
+
/(^|[./-])mixtral/i,
|
|
133
|
+
/(^|[./-])cohere/i,
|
|
134
|
+
/(^|[./-])command/i, // cohere command-*
|
|
135
|
+
/(^|[./-])j2/i, // ai21 jurassic
|
|
136
|
+
/(^|[./-])jamba/i,
|
|
137
|
+
/(^|[./-])deepseek/i,
|
|
138
|
+
/(^|[./-])qwen/i,
|
|
139
|
+
/(^|[./-])gpt/i,
|
|
140
|
+
/(^|[./-])openai/i,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Determine whether the model targeted by this request supports cache_control.
|
|
145
|
+
*
|
|
146
|
+
* Some providers in EXPLICIT_CACHE_PROVIDERS (notably bedrock and openrouter)
|
|
147
|
+
* route to many model families, only some of which understand cache_control.
|
|
148
|
+
* This guard inspects the resolved model id and blocks injection for families
|
|
149
|
+
* that are known not to support it. When the model id is absent or
|
|
150
|
+
* unrecognized, injection is allowed (fail-open) — Claude/Gemini-style ids and
|
|
151
|
+
* Anthropic-only providers fall through to true.
|
|
152
|
+
*
|
|
153
|
+
* @param {Object} body - Request body (may carry the resolved model id)
|
|
154
|
+
* @param {string} provider - Provider name
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
function modelSupportsCacheControl(body, provider) {
|
|
158
|
+
// Providers that only ever route to Anthropic models always support it.
|
|
159
|
+
if (provider === 'azure-anthropic' || provider === 'databricks') return true;
|
|
160
|
+
|
|
161
|
+
const modelId = body && (body._tierModel || body.model);
|
|
162
|
+
if (!modelId || typeof modelId !== 'string') return true; // unknown → fail open
|
|
163
|
+
|
|
164
|
+
return !NON_CACHE_MODEL_PATTERNS.some(re => re.test(modelId));
|
|
165
|
+
}
|
|
166
|
+
|
|
122
167
|
/**
|
|
123
168
|
* Inject provider-side prompt caching into the request body.
|
|
124
169
|
* Call this before sending to the provider.
|
|
@@ -129,6 +174,9 @@ function needsCacheInjection(provider) {
|
|
|
129
174
|
*/
|
|
130
175
|
function injectPromptCaching(body, provider) {
|
|
131
176
|
if (!needsCacheInjection(provider)) return 0;
|
|
177
|
+
// Gate on model capability: a provider may support cache_control in general
|
|
178
|
+
// while the specific routed model does not.
|
|
179
|
+
if (!modelSupportsCacheControl(body, provider)) return 0;
|
|
132
180
|
return injectAnthropicCacheBreakpoints(body);
|
|
133
181
|
}
|
|
134
182
|
|
|
@@ -137,4 +185,5 @@ module.exports = {
|
|
|
137
185
|
injectAnthropicCacheBreakpoints,
|
|
138
186
|
injectGeminiCacheBreakpoints,
|
|
139
187
|
needsCacheInjection,
|
|
188
|
+
modelSupportsCacheControl,
|
|
140
189
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const logger = require("../logger");
|
|
11
|
+
const { repairToolCallIds } = require("./tool-call-repair");
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Map client tool names back to Lynkr tool names
|
|
@@ -203,6 +204,12 @@ function convertResponsesToChat(responsesRequest) {
|
|
|
203
204
|
return cleaned;
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
// Repair tool_call_id linkage now, before anything downstream consumes the
|
|
208
|
+
// converted array. Codex bundled-plugin (Browser/Computer-use) calls and
|
|
209
|
+
// synthetic tool outputs can arrive without a usable call_id, which
|
|
210
|
+
// otherwise flattens to a blank tool_call_id and 400s at the provider.
|
|
211
|
+
repairToolCallIds(messages);
|
|
212
|
+
|
|
206
213
|
logger.info({
|
|
207
214
|
originalCount: input.length,
|
|
208
215
|
filteredCount: messages.length,
|