lynkr 9.9.1 → 9.10.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 +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +4 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +2 -2
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +275 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/difficulty-classifier.js +52 -10
- package/src/routing/index.js +35 -3
- package/src/routing/intent-score.js +20 -2
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +0 -46
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Decomposition gate (Phase 1).
|
|
3
|
-
*
|
|
4
|
-
* Decides whether breaking a task into isolated-context subtasks is actually
|
|
5
|
-
* worth it. This is the make-or-break of the feature: naive "decompose
|
|
6
|
-
* everything" loses money, because every subagent carries fixed overhead
|
|
7
|
-
* (planning + per-agent handoff/summarisation). Decomposition only pays off
|
|
8
|
-
* when the task is (a) genuinely complex, (b) large enough to amortise that
|
|
9
|
-
* overhead, and (c) divisible into reasonably independent units.
|
|
10
|
-
*
|
|
11
|
-
* Pure and synchronous so it can be unit-tested without a model. The caller
|
|
12
|
-
* supplies a pre-computed complexity `analysis` (from routing/complexity-analyzer)
|
|
13
|
-
* and the raw payload.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const DEFAULTS = {
|
|
17
|
-
minComplexity: 60, // 0-100; only decompose genuinely complex work
|
|
18
|
-
minTokens: 3000, // estimated monolithic tokens; below this the overhead wins
|
|
19
|
-
minIndependentUnits: 2, // need at least 2 separable pieces to bother
|
|
20
|
-
maxSubtasks: 6,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const ENUMERATION_RE = /^\s*(?:[-*+]|\d+[.)]|step\s+\d+\b)/gim;
|
|
24
|
-
const CONJUNCTION_RE = /\b(?:and then|then|also|additionally|as well as|after that|finally|next,)\b/gi;
|
|
25
|
-
const IMPERATIVE_RE = /\b(?:add|create|build|implement|write|refactor|update|fix|remove|delete|migrate|test|document|configure|set up|wire|integrate|generate)\b/gi;
|
|
26
|
-
const FILE_PATH_RE = /\b[\w./-]+\.(?:js|ts|tsx|jsx|py|go|rs|java|rb|c|cpp|h|json|yaml|yml|md|sql|sh|css|html)\b/gi;
|
|
27
|
-
|
|
28
|
-
function _uniqueMatches(text, re) {
|
|
29
|
-
const set = new Set();
|
|
30
|
-
const matches = text.match(re) || [];
|
|
31
|
-
for (const m of matches) set.add(m.toLowerCase().trim());
|
|
32
|
-
return set;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Heuristically estimate how many independent units a task contains.
|
|
37
|
-
* Conservative: takes the strongest of several weak signals rather than summing
|
|
38
|
-
* them, so a single rambling sentence doesn't look like five subtasks.
|
|
39
|
-
* @param {string} text
|
|
40
|
-
* @returns {number}
|
|
41
|
-
*/
|
|
42
|
-
function estimateIndependentUnits(text) {
|
|
43
|
-
if (!text || typeof text !== "string") return 1;
|
|
44
|
-
|
|
45
|
-
const enumerated = (text.match(ENUMERATION_RE) || []).length;
|
|
46
|
-
const conjunctions = (text.match(CONJUNCTION_RE) || []).length;
|
|
47
|
-
const imperatives = _uniqueMatches(text, IMPERATIVE_RE).size;
|
|
48
|
-
const files = _uniqueMatches(text, FILE_PATH_RE).size;
|
|
49
|
-
|
|
50
|
-
// Each signal is an independent lower-bound estimate of separable units.
|
|
51
|
-
const signals = [
|
|
52
|
-
enumerated, // explicit list items
|
|
53
|
-
conjunctions + 1, // "do A and then B" → 2 units
|
|
54
|
-
imperatives, // distinct action verbs
|
|
55
|
-
files, // distinct files usually map to distinct work
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
const estimate = Math.max(...signals, 1);
|
|
59
|
-
return estimate;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Decide whether to decompose.
|
|
64
|
-
* @param {Object} analysis - result of analyzeComplexity(payload)
|
|
65
|
-
* @param {Object} payload - the request payload
|
|
66
|
-
* @param {Object} [options]
|
|
67
|
-
* @param {Object} [options.config] - threshold overrides (see DEFAULTS)
|
|
68
|
-
* @param {string} [options.riskLevel] - 'low'|'medium'|'high'; 'high' disables decomposition
|
|
69
|
-
* @param {string} [options.taskText] - explicit task text (else derived from analysis/payload)
|
|
70
|
-
* @returns {{ decompose: boolean, reason: string, signals: Object }}
|
|
71
|
-
*/
|
|
72
|
-
function shouldDecompose(analysis, payload = {}, options = {}) {
|
|
73
|
-
const cfg = { ...DEFAULTS, ...(options.config || {}) };
|
|
74
|
-
|
|
75
|
-
const score = Number(analysis?.score ?? 0);
|
|
76
|
-
const estimatedTokens = Number(
|
|
77
|
-
analysis?.breakdown?.tokens?.estimated ?? options.estimatedTokens ?? 0
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
const taskText =
|
|
81
|
-
options.taskText ||
|
|
82
|
-
analysis?.content ||
|
|
83
|
-
_firstUserText(payload) ||
|
|
84
|
-
"";
|
|
85
|
-
|
|
86
|
-
const independentUnits = estimateIndependentUnits(taskText);
|
|
87
|
-
|
|
88
|
-
const signals = {
|
|
89
|
-
score,
|
|
90
|
-
estimatedTokens,
|
|
91
|
-
independentUnits,
|
|
92
|
-
riskLevel: options.riskLevel || "low",
|
|
93
|
-
thresholds: cfg,
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
// Never decompose high-risk work — keep it in one capable context where the
|
|
97
|
-
// exempt-from-laziness concerns (validation/security) stay coherent.
|
|
98
|
-
if (options.riskLevel === "high") {
|
|
99
|
-
return { decompose: false, reason: "high_risk_skip", signals };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (score < cfg.minComplexity) {
|
|
103
|
-
return { decompose: false, reason: "below_complexity_threshold", signals };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (estimatedTokens < cfg.minTokens) {
|
|
107
|
-
return { decompose: false, reason: "too_small_to_amortise_overhead", signals };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (independentUnits < cfg.minIndependentUnits) {
|
|
111
|
-
return { decompose: false, reason: "not_divisible", signals };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return { decompose: true, reason: "decompose_worthwhile", signals };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function _firstUserText(payload) {
|
|
118
|
-
const messages = payload?.messages;
|
|
119
|
-
if (!Array.isArray(messages)) return "";
|
|
120
|
-
const user = [...messages].reverse().find((m) => m.role === "user");
|
|
121
|
-
if (!user) return "";
|
|
122
|
-
if (typeof user.content === "string") return user.content;
|
|
123
|
-
if (Array.isArray(user.content)) {
|
|
124
|
-
return user.content
|
|
125
|
-
.filter((b) => b?.type === "text" || typeof b?.text === "string")
|
|
126
|
-
.map((b) => b.text || "")
|
|
127
|
-
.join("\n");
|
|
128
|
-
}
|
|
129
|
-
return "";
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
module.exports = {
|
|
133
|
-
shouldDecompose,
|
|
134
|
-
estimateIndependentUnits,
|
|
135
|
-
DEFAULTS,
|
|
136
|
-
};
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Task decomposition — orchestration entry point.
|
|
3
|
-
*
|
|
4
|
-
* Ties the phases together:
|
|
5
|
-
* 1. gate — decide if decomposing is worth it (cost-aware)
|
|
6
|
-
* 2. planner — produce a validated subtask DAG (one model call)
|
|
7
|
-
* 3. dispatcher — run subtasks (parallel within dependency levels, isolated context)
|
|
8
|
-
* 4. synthesizer — combine results into the final answer (one model call)
|
|
9
|
-
* 5. quality — confidence-score the synthesis; flag low-confidence output
|
|
10
|
-
* 6. telemetry — record decision + estimated net token savings; shadow mode
|
|
11
|
-
*
|
|
12
|
-
* Opt-in via TASK_DECOMPOSITION_ENABLED=true. Requires AGENTS_ENABLED=true
|
|
13
|
-
* (it builds on the subagent machinery). Any failure degrades gracefully to a
|
|
14
|
-
* non-decomposed result so the caller can solve monolithically.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const config = require("../../config");
|
|
18
|
-
const logger = require("../../logger");
|
|
19
|
-
const { shouldDecompose } = require("./gate");
|
|
20
|
-
const { generatePlan } = require("./planner");
|
|
21
|
-
const { dispatchPlan } = require("./dispatcher");
|
|
22
|
-
const { synthesize } = require("./synthesizer");
|
|
23
|
-
const telemetry = require("./telemetry");
|
|
24
|
-
const { analyzeComplexity } = require("../../routing/complexity-analyzer");
|
|
25
|
-
const confidenceScorer = require("../../routing/confidence-scorer");
|
|
26
|
-
|
|
27
|
-
const CODE_HINT_RE = /\b(code|function|implement|refactor|bug|class|api|module|test)\b/i;
|
|
28
|
-
|
|
29
|
-
function getConfig() {
|
|
30
|
-
return (
|
|
31
|
-
config.taskDecomposition || {
|
|
32
|
-
enabled: false,
|
|
33
|
-
shadow: false,
|
|
34
|
-
planModel: "sonnet",
|
|
35
|
-
synthModel: "sonnet",
|
|
36
|
-
minConfidence: 0.5,
|
|
37
|
-
gate: {},
|
|
38
|
-
}
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* @param {string} task - the task text to (maybe) decompose
|
|
44
|
-
* @param {Object} [options]
|
|
45
|
-
* @param {string} [options.sessionId]
|
|
46
|
-
* @param {string} [options.cwd]
|
|
47
|
-
* @param {string} [options.riskLevel] - 'high' disables decomposition
|
|
48
|
-
* @param {Object} [options._inject] - test seams { generatePlan, dispatchPlan, synthesize, analyze }
|
|
49
|
-
* @returns {Promise<Object>} result object (see below)
|
|
50
|
-
*/
|
|
51
|
-
async function runDecomposedTask(task, options = {}) {
|
|
52
|
-
const cfg = getConfig();
|
|
53
|
-
const inject = options._inject || {};
|
|
54
|
-
|
|
55
|
-
if (!cfg.enabled) {
|
|
56
|
-
return { decomposed: false, reason: "disabled" };
|
|
57
|
-
}
|
|
58
|
-
if (!config.agents?.enabled) {
|
|
59
|
-
return { decomposed: false, reason: "agents_disabled" };
|
|
60
|
-
}
|
|
61
|
-
if (!task || typeof task !== "string") {
|
|
62
|
-
return { decomposed: false, reason: "empty_task" };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const payload = { messages: [{ role: "user", content: task }] };
|
|
66
|
-
|
|
67
|
-
let analysis;
|
|
68
|
-
try {
|
|
69
|
-
analysis = await (inject.analyze || analyzeComplexity)(payload);
|
|
70
|
-
} catch (err) {
|
|
71
|
-
logger.warn({ err: err.message }, "[Decomposition] Complexity analysis failed");
|
|
72
|
-
return { decomposed: false, reason: "analysis_failed" };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const monolithicTokens =
|
|
76
|
-
analysis?.breakdown?.tokens?.estimated || telemetry.estimateTokens(task);
|
|
77
|
-
|
|
78
|
-
const gate = shouldDecompose(analysis, payload, {
|
|
79
|
-
config: cfg.gate,
|
|
80
|
-
riskLevel: options.riskLevel,
|
|
81
|
-
taskText: task,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// Shadow mode: record what we WOULD do, but never actually decompose.
|
|
85
|
-
if (cfg.shadow) {
|
|
86
|
-
telemetry.record({
|
|
87
|
-
mode: "shadow",
|
|
88
|
-
sessionId: options.sessionId,
|
|
89
|
-
gate,
|
|
90
|
-
monolithicTokens,
|
|
91
|
-
});
|
|
92
|
-
return { decomposed: false, reason: "shadow_mode", gate };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (!gate.decompose) {
|
|
96
|
-
telemetry.record({ mode: "live", decision: "skip", gate, monolithicTokens });
|
|
97
|
-
return { decomposed: false, reason: gate.reason, gate };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Phase 2: plan
|
|
101
|
-
const plan = await (inject.generatePlan || generatePlan)({
|
|
102
|
-
task,
|
|
103
|
-
model: cfg.planModel,
|
|
104
|
-
maxSubtasks: cfg.gate?.maxSubtasks || 6,
|
|
105
|
-
});
|
|
106
|
-
if (!plan) {
|
|
107
|
-
telemetry.record({ mode: "live", decision: "plan_failed", gate, monolithicTokens });
|
|
108
|
-
return { decomposed: false, reason: "plan_failed", gate };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Phase 3: dispatch
|
|
112
|
-
const dispatch = await (inject.dispatchPlan || dispatchPlan)(plan, {
|
|
113
|
-
sessionId: options.sessionId,
|
|
114
|
-
cwd: options.cwd,
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Phase 4: synthesize
|
|
118
|
-
const synth = await (inject.synthesize || synthesize)({
|
|
119
|
-
task,
|
|
120
|
-
subtaskResults: dispatch.results,
|
|
121
|
-
model: cfg.synthModel,
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// Phase 5: quality gate
|
|
125
|
-
const taskType = CODE_HINT_RE.test(task) ? "code" : "reasoning";
|
|
126
|
-
let confidence = 1;
|
|
127
|
-
try {
|
|
128
|
-
confidence = await confidenceScorer.score(
|
|
129
|
-
{ content: [{ type: "text", text: synth.text }] },
|
|
130
|
-
{ taskType }
|
|
131
|
-
);
|
|
132
|
-
} catch (err) {
|
|
133
|
-
logger.debug({ err: err.message }, "[Decomposition] Confidence scoring failed");
|
|
134
|
-
}
|
|
135
|
-
const belowThreshold = confidence < (cfg.minConfidence ?? 0.5);
|
|
136
|
-
|
|
137
|
-
const savings = telemetry.estimateSavings({
|
|
138
|
-
monolithicTokens,
|
|
139
|
-
planUsage: plan.usage,
|
|
140
|
-
dispatchStats: dispatch.stats,
|
|
141
|
-
synthUsage: synth.usage,
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
telemetry.record({
|
|
145
|
-
mode: "live",
|
|
146
|
-
decision: "decomposed",
|
|
147
|
-
sessionId: options.sessionId,
|
|
148
|
-
gate,
|
|
149
|
-
subtasks: plan.subtasks.length,
|
|
150
|
-
levels: dispatch.levels.length,
|
|
151
|
-
strategy: plan.strategy,
|
|
152
|
-
confidence,
|
|
153
|
-
belowThreshold,
|
|
154
|
-
synthesisFallback: synth.fallback,
|
|
155
|
-
savings,
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
logger.info(
|
|
159
|
-
{
|
|
160
|
-
subtasks: plan.subtasks.length,
|
|
161
|
-
levels: dispatch.levels.length,
|
|
162
|
-
confidence: confidence.toFixed(2),
|
|
163
|
-
savedTokens: savings.savedTokens,
|
|
164
|
-
},
|
|
165
|
-
"[Decomposition] Task decomposed"
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
return {
|
|
169
|
-
decomposed: true,
|
|
170
|
-
result: synth.text,
|
|
171
|
-
reason: "decomposed",
|
|
172
|
-
plan,
|
|
173
|
-
subtaskResults: dispatch.results,
|
|
174
|
-
quality: { confidence, belowThreshold, taskType },
|
|
175
|
-
// When confidence is low, the caller should prefer a monolithic re-solve.
|
|
176
|
-
recommendFallback: belowThreshold,
|
|
177
|
-
stats: { ...dispatch.stats, levels: dispatch.levels.length },
|
|
178
|
-
savings,
|
|
179
|
-
gate,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
module.exports = { runDecomposedTask, getConfig };
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thin model-call helper for the decomposition planner/synthesizer.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors the provider-forcing logic in agents/executor.js so planning and
|
|
5
|
-
* synthesis use the configured MODEL_PROVIDER rather than hard-falling back to
|
|
6
|
-
* Azure. The actual invoker is injectable (`opts.invoke`) so the planner and
|
|
7
|
-
* synthesizer can be unit-tested without a live provider.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const logger = require("../../logger");
|
|
11
|
-
|
|
12
|
-
function resolveForceProvider(model) {
|
|
13
|
-
const modelLower = String(model || "").toLowerCase();
|
|
14
|
-
const isClaudeFamily =
|
|
15
|
-
modelLower.includes("claude") ||
|
|
16
|
-
modelLower.includes("sonnet") ||
|
|
17
|
-
modelLower.includes("haiku") ||
|
|
18
|
-
modelLower.includes("opus");
|
|
19
|
-
const isGptFamily = modelLower.includes("gpt");
|
|
20
|
-
|
|
21
|
-
if (isClaudeFamily || isGptFamily) {
|
|
22
|
-
const config = require("../../config");
|
|
23
|
-
return config.modelProvider?.type || config.modelProvider?.provider || null;
|
|
24
|
-
}
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Call the model and return the Anthropic-format response JSON.
|
|
30
|
-
* @param {Object} params
|
|
31
|
-
* @param {Array} params.messages
|
|
32
|
-
* @param {string} params.model
|
|
33
|
-
* @param {number} [params.maxTokens=2048]
|
|
34
|
-
* @param {number} [params.temperature=0.2]
|
|
35
|
-
* @param {Function} [params.invoke] - injectable invoker (default: clients/databricks.invokeModel)
|
|
36
|
-
* @returns {Promise<Object>} Anthropic-format response JSON
|
|
37
|
-
*/
|
|
38
|
-
async function callModel({ messages, model, maxTokens = 2048, temperature = 0.2, invoke } = {}) {
|
|
39
|
-
const invoker = invoke || require("../../clients/databricks").invokeModel;
|
|
40
|
-
const payload = {
|
|
41
|
-
model,
|
|
42
|
-
messages,
|
|
43
|
-
max_tokens: maxTokens,
|
|
44
|
-
temperature,
|
|
45
|
-
};
|
|
46
|
-
const forceProvider = resolveForceProvider(model);
|
|
47
|
-
|
|
48
|
-
const response = await invoker(payload, { forceProvider });
|
|
49
|
-
if (!response || !response.json) {
|
|
50
|
-
throw new Error("Invalid model response in decomposition model-call");
|
|
51
|
-
}
|
|
52
|
-
return response.json;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Extract concatenated text from an Anthropic-format response.
|
|
57
|
-
*/
|
|
58
|
-
function extractText(responseJson) {
|
|
59
|
-
const content = responseJson?.content;
|
|
60
|
-
if (!Array.isArray(content)) return "";
|
|
61
|
-
return content
|
|
62
|
-
.filter((b) => b?.type === "text")
|
|
63
|
-
.map((b) => b.text || "")
|
|
64
|
-
.join("\n")
|
|
65
|
-
.trim();
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function sumUsage(responseJson) {
|
|
69
|
-
return {
|
|
70
|
-
inputTokens: responseJson?.usage?.input_tokens || 0,
|
|
71
|
-
outputTokens: responseJson?.usage?.output_tokens || 0,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
module.exports = { callModel, extractText, sumUsage, resolveForceProvider, logger };
|
|
@@ -1,223 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,89 +0,0 @@
|
|
|
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 };
|