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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tool-call id repair for OpenAI-format message arrays.
3
+ *
4
+ * OpenAI-compatible providers (Moonshot/Kimi, OpenAI, OpenRouter, …) reject a
5
+ * request with `Invalid request: tool_call_id <x> is not found` whenever a
6
+ * `tool` message references an id that has no matching entry in a preceding
7
+ * assistant `tool_calls` array. This happens in practice when:
8
+ *
9
+ * 1. A `tool` message's tool_call_id is empty/missing — e.g. Codex Desktop's
10
+ * bundled-plugin (Browser/Computer-use) function_call_output items, and
11
+ * synthetic "unsupported call: shell" outputs, arrive without a usable
12
+ * `call_id`, so both the assistant tool_call id and the tool_call_id
13
+ * flatten to "". (The error shows a blank id: "tool_call_id is not found".)
14
+ * 2. An assistant tool_calls entry has an empty/missing id.
15
+ * 3. Ids drift across multiple conversion layers (Responses↔Chat↔Anthropic),
16
+ * leaving a `tool` message pointing at an id no assistant ever issued.
17
+ *
18
+ * This helper repairs all three in place: it backfills synthetic ids onto
19
+ * assistant tool_calls that lack one, re-links each `tool` message to an unused
20
+ * tool_call id on the nearest preceding assistant, and drops any `tool` message
21
+ * that has no assistant tool_call to attach to (a dangling result is a hard
22
+ * 400 at the provider, so dropping it is strictly safer than forwarding it).
23
+ *
24
+ * @module clients/tool-call-repair
25
+ */
26
+
27
+ const logger = require("../logger");
28
+
29
+ function isBlankId(id) {
30
+ return !id || String(id).trim() === "";
31
+ }
32
+
33
+ /**
34
+ * Repair tool_call_id linkage in an OpenAI chat-format message array, in place.
35
+ *
36
+ * @param {Array} messages - OpenAI chat-format messages (role/content, with
37
+ * assistant `tool_calls` and `tool` `tool_call_id`). Mutated in place.
38
+ * @returns {Array} the same array reference, with orphan tool messages removed.
39
+ */
40
+ function repairToolCallIds(messages) {
41
+ if (!Array.isArray(messages) || messages.length === 0) return messages;
42
+
43
+ let synthCounter = 0;
44
+ const nextSyntheticId = () => `call_auto_${synthCounter++}`;
45
+
46
+ // Pass 1 — guarantee every assistant tool_call has a non-empty id.
47
+ for (const msg of messages) {
48
+ if (msg && msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
49
+ for (const tc of msg.tool_calls) {
50
+ if (tc && isBlankId(tc.id)) {
51
+ tc.id = nextSyntheticId();
52
+ logger.info({ assignedId: tc.id }, "Backfilled missing assistant tool_call id");
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ // Pass 2 — relink (or drop) every tool message.
59
+ const repaired = [];
60
+ let dropped = 0;
61
+ for (let i = 0; i < messages.length; i++) {
62
+ const msg = messages[i];
63
+ if (!msg || msg.role !== "tool") {
64
+ repaired.push(msg);
65
+ continue;
66
+ }
67
+
68
+ // Nearest preceding assistant that carries tool_calls (stop at a user turn).
69
+ let assistant = null;
70
+ for (let j = i - 1; j >= 0; j--) {
71
+ const prev = messages[j];
72
+ if (!prev) continue;
73
+ if (prev.role === "user") break;
74
+ if (prev.role === "assistant" && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) {
75
+ assistant = prev;
76
+ break;
77
+ }
78
+ }
79
+
80
+ const matches =
81
+ assistant &&
82
+ !isBlankId(msg.tool_call_id) &&
83
+ assistant.tool_calls.some((tc) => tc.id === msg.tool_call_id);
84
+
85
+ if (matches) {
86
+ repaired.push(msg);
87
+ continue;
88
+ }
89
+
90
+ if (assistant) {
91
+ // Pick the first tool_call id not already consumed by an earlier result.
92
+ const usedIds = new Set(
93
+ repaired.filter((r) => r && r.role === "tool" && r.tool_call_id).map((r) => r.tool_call_id)
94
+ );
95
+ const available = assistant.tool_calls.find((tc) => !usedIds.has(tc.id));
96
+ if (available) {
97
+ logger.info(
98
+ { from: isBlankId(msg.tool_call_id) ? "(blank)" : msg.tool_call_id, to: available.id },
99
+ "Repaired tool_call_id linkage"
100
+ );
101
+ msg.tool_call_id = available.id;
102
+ repaired.push(msg);
103
+ continue;
104
+ }
105
+ }
106
+
107
+ // No assistant tool_call to attach to — drop the orphan rather than let it
108
+ // 400 the whole request at the provider.
109
+ dropped++;
110
+ logger.warn(
111
+ {
112
+ tool_call_id: isBlankId(msg.tool_call_id) ? "(blank)" : msg.tool_call_id,
113
+ contentPreview: typeof msg.content === "string" ? msg.content.slice(0, 80) : "",
114
+ },
115
+ "Dropped orphan tool message with no matching tool_call"
116
+ );
117
+ }
118
+
119
+ if (dropped > 0) {
120
+ logger.info({ dropped, before: messages.length, after: repaired.length }, "Removed orphan tool messages");
121
+ }
122
+
123
+ // Rewrite the array contents in place so callers holding this reference see
124
+ // the repaired result.
125
+ messages.length = 0;
126
+ for (const m of repaired) messages.push(m);
127
+ return messages;
128
+ }
129
+
130
+ module.exports = { repairToolCallIds, isBlankId };
@@ -518,6 +518,12 @@ const agentsDefaultModel = process.env.AGENTS_DEFAULT_MODEL ?? "haiku";
518
518
  const agentsMaxSteps = Number.parseInt(process.env.AGENTS_MAX_STEPS ?? "15", 10);
519
519
  const agentsTimeout = Number.parseInt(process.env.AGENTS_TIMEOUT ?? "120000", 10);
520
520
 
521
+ // Task decomposition configuration (opt-in; builds on the agents subsystem).
522
+ // Single env toggle; everything else is hardcoded below.
523
+ const taskDecompositionEnabled = process.env.TASK_DECOMPOSITION_ENABLED === "true";
524
+
525
+ // Tier-aware fallback (escalate-then-demote) is always on (hardcoded).
526
+
521
527
  // LLM Audit logging configuration
522
528
  const auditEnabled = process.env.LLM_AUDIT_ENABLED === "true"; // default false
523
529
  const auditLogFile = process.env.LLM_AUDIT_LOG_FILE ?? path.join(process.cwd(), "logs", "llm-audit.log");
@@ -775,6 +781,23 @@ var config = {
775
781
  maxSteps: Number.isNaN(agentsMaxSteps) ? 15 : agentsMaxSteps,
776
782
  timeout: Number.isNaN(agentsTimeout) ? 120000 : agentsTimeout,
777
783
  },
784
+ taskDecomposition: {
785
+ enabled: taskDecompositionEnabled, // only env-driven knob (TASK_DECOMPOSITION_ENABLED)
786
+ // Hardcoded defaults — tune here if needed.
787
+ shadow: false,
788
+ planModel: "sonnet",
789
+ synthModel: "sonnet",
790
+ minConfidence: 0.5,
791
+ gate: {
792
+ minComplexity: 60,
793
+ minTokens: 3000,
794
+ maxSubtasks: 6,
795
+ minIndependentUnits: 2,
796
+ },
797
+ },
798
+ tierFallback: {
799
+ enabled: true, // always on (hardcoded): escalate-then-demote on provider failure; floor = SIMPLE
800
+ },
778
801
  tests: {
779
802
  defaultCommand: testDefaultCommand ? testDefaultCommand.trim() : null,
780
803
  defaultArgs: testDefaultArgs,
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Output Format Guard
3
+ *
4
+ * Appends a short formatting instruction to the system prompt so weaker
5
+ * backends (Moonshot/Kimi, Ollama, etc.) stop emitting mangled ASCII/Unicode
6
+ * box-drawing "diagrams" that render as garbage in clients. The actual backend
7
+ * is decided by tier routing — so even when the client asks for "claude-opus",
8
+ * the request may be served by a model that formats poorly. This normalizes the
9
+ * presentation without changing which model serves the request.
10
+ *
11
+ * Always-on (no env flag). It is skipped only for Claude-family backends, which
12
+ * already produce clean GitHub-flavored markdown — injecting there would just
13
+ * waste tokens. The skip biases toward injecting: if we can't tell, we inject
14
+ * (a false-inject is harmless ~50 tokens; a false-skip leaves the garble).
15
+ *
16
+ * Keyed off the ROUTING-RESOLVED provider/model, never the client's requested
17
+ * body.model (which is just a label once tier routing is on).
18
+ *
19
+ * @module context/output-format-guard
20
+ */
21
+
22
+ const logger = require("../logger");
23
+
24
+ const MARKER = "[fmt-guard]";
25
+
26
+ // Model names that already produce clean markdown.
27
+ const CLEAN_FORMATTER_RE = /\b(claude|sonnet|opus|haiku)\b/i;
28
+ // Providers that are always Claude-backed.
29
+ const CLEAN_PROVIDERS = new Set(["azure-anthropic"]);
30
+
31
+ const GUARD_TEXT =
32
+ `${MARKER} Formatting rules for your response: use plain GitHub-flavored markdown only. ` +
33
+ "Do NOT draw diagrams or boxes with ASCII or Unicode line-drawing characters " +
34
+ "(such as ┌ ─ │ └ ├ ┤ ┬ ┴ ╔ ═ ║), and do NOT wrap headings or code in decorative borders. " +
35
+ "Represent structure and relationships with normal markdown headings, nested bullet lists, " +
36
+ "numbered lists, or tables. Use standard triple-backtick fenced code blocks for code. " +
37
+ "Keep code, file paths, commands, and URLs exact.";
38
+
39
+ /**
40
+ * Whether the resolved backend already formats cleanly (→ skip injection).
41
+ * @param {string} provider - routing-resolved provider
42
+ * @param {string} model - routing-resolved model (NOT the client's requested model)
43
+ * @returns {boolean}
44
+ */
45
+ function producesCleanMarkdown(provider, model) {
46
+ if (CLEAN_PROVIDERS.has(String(provider || "").toLowerCase())) return true;
47
+ if (model && CLEAN_FORMATTER_RE.test(String(model))) return true;
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Append the guard text to a system prompt that may be a string or an array of
53
+ * Anthropic content blocks. Idempotent via MARKER. Pure — returns the new value.
54
+ */
55
+ function appendToSystem(system, text) {
56
+ // String (or empty) system prompt.
57
+ if (system == null || typeof system === "string") {
58
+ const base = system || "";
59
+ if (base.includes(MARKER)) return base;
60
+ return base ? `${base}\n\n${text}` : text;
61
+ }
62
+ // Anthropic array-of-blocks system prompt.
63
+ if (Array.isArray(system)) {
64
+ const already = system.some(
65
+ (b) => typeof b?.text === "string" && b.text.includes(MARKER)
66
+ );
67
+ if (already) return system;
68
+ return [...system, { type: "text", text }];
69
+ }
70
+ return system;
71
+ }
72
+
73
+ /**
74
+ * Inject the formatting guard into body.system in place, unless the resolved
75
+ * backend already formats cleanly. Always-on; idempotent.
76
+ *
77
+ * @param {object} body - request body (mutated in place)
78
+ * @param {object} [opts]
79
+ * @param {string} [opts.provider] - routing-resolved provider
80
+ * @param {string} [opts.model] - routing-resolved model
81
+ * @returns {object} body
82
+ */
83
+ function injectFormatGuard(body, opts = {}) {
84
+ if (!body) return body;
85
+ const { provider, model } = opts;
86
+ if (producesCleanMarkdown(provider, model)) return body;
87
+
88
+ body.system = appendToSystem(body.system, GUARD_TEXT);
89
+ logger.debug({ provider, model }, "[FormatGuard] Injected markdown formatting guard");
90
+ return body;
91
+ }
92
+
93
+ module.exports = {
94
+ injectFormatGuard,
95
+ producesCleanMarkdown,
96
+ appendToSystem,
97
+ MARKER,
98
+ GUARD_TEXT,
99
+ };
@@ -697,6 +697,14 @@ function getRoutingHeaders(decision) {
697
697
  headers['X-Lynkr-Cost-Optimized'] = 'true';
698
698
  }
699
699
 
700
+ // Tier-aware fallback surfacing (never silent).
701
+ if (decision.fallback) {
702
+ headers['X-Lynkr-Fallback'] = 'true';
703
+ if (decision.fromTier) headers['X-Lynkr-Fallback-From-Tier'] = decision.fromTier;
704
+ if (decision.servedTier) headers['X-Lynkr-Served-Tier'] = decision.servedTier;
705
+ if (decision.fallbackDirection) headers['X-Lynkr-Fallback-Direction'] = decision.fallbackDirection;
706
+ }
707
+
700
708
  if (decision.risk?.level) {
701
709
  headers['X-Lynkr-Risk'] = decision.risk.level;
702
710
  const hits = Array.from(new Set([
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tier-aware fallback chain (escalate-then-demote).
3
+ *
4
+ * When a tier's provider fails, prefer a MORE capable tier first (climb toward
5
+ * REASONING), and only if every higher tier is also unavailable do we fall
6
+ * downward — all the way to the local SIMPLE tier as a last resort. This biases
7
+ * for correctness/availability over cost, matching a conservative routing policy.
8
+ *
9
+ * Example ladder: SIMPLE → MEDIUM → COMPLEX → REASONING
10
+ * - COMPLEX fails → [REASONING, MEDIUM, SIMPLE]
11
+ * - REASONING fails → [COMPLEX, MEDIUM, SIMPLE]
12
+ * - MEDIUM fails → [COMPLEX, REASONING, SIMPLE]
13
+ *
14
+ * Pure and dependency-injectable so it can be unit-tested without real providers.
15
+ */
16
+
17
+ const logger = require("../logger");
18
+
19
+ const TIER_ORDER = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
20
+
21
+ /** Default availability check: a provider is unavailable if its circuit is OPEN. */
22
+ function defaultIsProviderAvailable(provider) {
23
+ try {
24
+ const { getCircuitBreakerRegistry } = require("../clients/circuit-breaker");
25
+ const registry = getCircuitBreakerRegistry();
26
+ const all = typeof registry.getAll === "function" ? registry.getAll() : [];
27
+ const entry = Array.isArray(all) ? all.find((b) => b.name === provider) : null;
28
+ return !(entry && entry.state === "OPEN");
29
+ } catch {
30
+ return true; // fail open — never block fallback on a health-check error
31
+ }
32
+ }
33
+
34
+ /** Resolve a tier name to { tier, provider, model }, or null if not configured. */
35
+ function resolveTier(tier, selector) {
36
+ try {
37
+ const sel = selector || require("./model-tiers").getModelTierSelector();
38
+ const r = sel.selectModel(tier);
39
+ if (!r || !r.provider || !r.model) return null;
40
+ return { tier, provider: r.provider, model: r.model };
41
+ } catch {
42
+ return null; // tier not configured (TIER_<X> unset) — skip it
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Build the escalate-then-demote fallback chain for a failed tier.
48
+ *
49
+ * @param {string} currentTier - the tier whose provider just failed
50
+ * @param {Object} [opts]
51
+ * @param {Object} [opts.selector] - model-tiers selector (injected for tests)
52
+ * @param {Function} [opts.isProviderAvailable] - (provider) => boolean (injected for tests)
53
+ * @returns {Array<{tier,provider,model,demotedFrom,direction}>} ordered candidates
54
+ */
55
+ function getFallbackChain(currentTier, opts = {}) {
56
+ const isAvailable = opts.isProviderAvailable || defaultIsProviderAvailable;
57
+ const idx = TIER_ORDER.indexOf(currentTier);
58
+ if (idx === -1) return [];
59
+
60
+ const higher = TIER_ORDER.slice(idx + 1); // ascending toward REASONING
61
+ const lower = TIER_ORDER.slice(0, idx).reverse(); // descending toward SIMPLE
62
+ const ordered = [...higher, ...lower];
63
+
64
+ const seen = new Set();
65
+ // Never re-attempt the exact provider:model that just failed.
66
+ const current = resolveTier(currentTier, opts.selector);
67
+ if (current) seen.add(`${current.provider}:${current.model}`);
68
+
69
+ const chain = [];
70
+ for (const tier of ordered) {
71
+ const resolved = resolveTier(tier, opts.selector);
72
+ if (!resolved) continue;
73
+ const key = `${resolved.provider}:${resolved.model}`;
74
+ if (seen.has(key)) continue;
75
+ seen.add(key);
76
+ if (!isAvailable(resolved.provider)) continue;
77
+ chain.push({
78
+ ...resolved,
79
+ demotedFrom: currentTier,
80
+ direction: TIER_ORDER.indexOf(tier) > idx ? "up" : "down",
81
+ });
82
+ }
83
+
84
+ logger.debug(
85
+ { currentTier, chain: chain.map((c) => `${c.tier}:${c.provider}`) },
86
+ "[TierFallback] Built fallback chain"
87
+ );
88
+ return chain;
89
+ }
90
+
91
+ module.exports = { getFallbackChain, resolveTier, TIER_ORDER };
package/src/server.js CHANGED
@@ -29,6 +29,7 @@ const { registerTaskTools } = require("./tools/tasks");
29
29
  const { registerTestTools } = require("./tools/tests");
30
30
  const { registerMcpTools } = require("./tools/mcp");
31
31
  const { registerAgentTaskTool } = require("./tools/agent-task");
32
+ const { registerDecomposeTool } = require("./tools/decompose");
32
33
  const { initConfigWatcher, getConfigWatcher } = require("./config/watcher");
33
34
  const { initializeHeadroom, shutdownHeadroom, getHeadroomManager } = require("./headroom");
34
35
  const { getWorkerPool, isWorkerPoolReady } = require("./workers/pool");
@@ -62,6 +63,7 @@ if (LAZY_TOOLS_ENABLED) {
62
63
  registerTestTools();
63
64
  registerMcpTools();
64
65
  registerAgentTaskTool();
66
+ registerDecomposeTool();
65
67
  logger.info({ mode: "eager" }, "All tools loaded at startup");
66
68
  }
67
69
 
@@ -0,0 +1,91 @@
1
+ const { registerTool } = require(".");
2
+ const { runDecomposedTask } = require("../agents/decomposition");
3
+ const logger = require("../logger");
4
+
5
+ /**
6
+ * DecomposeTask tool — breaks a complex task into focused subtasks with isolated
7
+ * context, runs them (parallel where independent), and synthesizes the result.
8
+ *
9
+ * Opt-in: requires TASK_DECOMPOSITION_ENABLED=true and AGENTS_ENABLED=true.
10
+ * Degrades gracefully — if the gate decides decomposition isn't worth it (or
11
+ * planning fails), it returns ok:true with decomposed:false and a reason so the
12
+ * caller can solve the task monolithically.
13
+ */
14
+ function registerDecomposeTool() {
15
+ registerTool(
16
+ "DecomposeTask",
17
+ async ({ args = {} }, context = {}) => {
18
+ const task = args.task || args.prompt || args.description;
19
+
20
+ if (!task || typeof task !== "string") {
21
+ return {
22
+ ok: false,
23
+ status: 400,
24
+ content: JSON.stringify({ error: "task is required" }, null, 2),
25
+ };
26
+ }
27
+
28
+ logger.info(
29
+ { task: task.slice(0, 100), sessionId: context.sessionId },
30
+ "DecomposeTask: evaluating task for decomposition"
31
+ );
32
+
33
+ try {
34
+ const result = await runDecomposedTask(task, {
35
+ sessionId: context.sessionId,
36
+ cwd: context.cwd,
37
+ riskLevel: args.riskLevel || context.riskLevel,
38
+ });
39
+
40
+ if (result.decomposed) {
41
+ return {
42
+ ok: true,
43
+ status: 200,
44
+ content: result.result,
45
+ metadata: {
46
+ decomposed: true,
47
+ subtasks: result.plan?.subtasks?.length,
48
+ levels: result.stats?.levels,
49
+ strategy: result.plan?.strategy,
50
+ confidence: result.quality?.confidence,
51
+ recommendFallback: result.recommendFallback,
52
+ savedTokens: result.savings?.savedTokens,
53
+ },
54
+ };
55
+ }
56
+
57
+ // Not decomposed — signal the caller to solve monolithically.
58
+ return {
59
+ ok: true,
60
+ status: 200,
61
+ content: JSON.stringify(
62
+ {
63
+ decomposed: false,
64
+ reason: result.reason,
65
+ guidance: "Solve this task directly without decomposition.",
66
+ },
67
+ null,
68
+ 2
69
+ ),
70
+ metadata: { decomposed: false, reason: result.reason },
71
+ };
72
+ } catch (error) {
73
+ logger.error({ error: error.message }, "DecomposeTask: error");
74
+ return {
75
+ ok: false,
76
+ status: 500,
77
+ content: JSON.stringify(
78
+ { error: "Decomposition error", message: error.message },
79
+ null,
80
+ 2
81
+ ),
82
+ };
83
+ }
84
+ },
85
+ { category: "decompose" }
86
+ );
87
+
88
+ logger.info("DecomposeTask tool registered");
89
+ }
90
+
91
+ module.exports = { registerDecomposeTool };
@@ -77,6 +77,11 @@ const TOOL_CATEGORIES = {
77
77
  loader: () => require('./agent-task').registerAgentTaskTool,
78
78
  priority: 2,
79
79
  },
80
+ decompose: {
81
+ keywords: ['decompose', 'subtask', 'break down', 'break into', 'split task', 'plan and execute'],
82
+ loader: () => require('./decompose').registerDecomposeTool,
83
+ priority: 2,
84
+ },
80
85
  'code-mode': {
81
86
  keywords: ['mcp', 'execute', 'server', 'tool', 'code mode'],
82
87
  loader: () => require('./code-mode').registerCodeModeTools,
@@ -296,6 +301,9 @@ function loadCategoryForTool(toolName) {
296
301
  // TinyFish (web agent)
297
302
  'web_agent': 'tinyfish',
298
303
  'agent_task': 'agentTask',
304
+
305
+ // Task decomposition
306
+ 'decomposetask': 'decompose',
299
307
  };
300
308
 
301
309
  // Direct mapping