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,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 };
|
package/src/config/index.js
CHANGED
|
@@ -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
|
+
};
|
package/src/routing/index.js
CHANGED
|
@@ -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 };
|
package/src/tools/lazy-loader.js
CHANGED
|
@@ -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
|