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.
Files changed (67) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +4 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +2 -2
  7. package/scripts/validate-difficulty-classifier.js +27 -6
  8. package/src/api/providers-handler.js +0 -1
  9. package/src/api/router.js +275 -160
  10. package/src/clients/databricks.js +95 -6
  11. package/src/config/index.js +3 -43
  12. package/src/orchestrator/index.js +117 -1235
  13. package/src/orchestrator/passthrough-stream.js +382 -0
  14. package/src/orchestrator/sse-transformer.js +408 -0
  15. package/src/routing/affinity-store.js +17 -3
  16. package/src/routing/difficulty-classifier.js +52 -10
  17. package/src/routing/index.js +35 -3
  18. package/src/routing/intent-score.js +20 -2
  19. package/src/routing/session-affinity.js +8 -1
  20. package/src/routing/side-channel-detector.js +103 -0
  21. package/src/server.js +0 -46
  22. package/src/agents/context-manager.js +0 -236
  23. package/src/agents/decomposition/dispatcher.js +0 -185
  24. package/src/agents/decomposition/gate.js +0 -136
  25. package/src/agents/decomposition/index.js +0 -183
  26. package/src/agents/decomposition/model-call.js +0 -75
  27. package/src/agents/decomposition/planner.js +0 -223
  28. package/src/agents/decomposition/synthesizer.js +0 -89
  29. package/src/agents/decomposition/telemetry.js +0 -55
  30. package/src/agents/definitions/loader.js +0 -653
  31. package/src/agents/executor.js +0 -457
  32. package/src/agents/index.js +0 -165
  33. package/src/agents/parallel-coordinator.js +0 -68
  34. package/src/agents/reflector.js +0 -331
  35. package/src/agents/skillbook.js +0 -331
  36. package/src/agents/store.js +0 -259
  37. package/src/edits/index.js +0 -171
  38. package/src/indexer/babel-parser.js +0 -213
  39. package/src/indexer/index.js +0 -1629
  40. package/src/indexer/navigation/index.js +0 -32
  41. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  42. package/src/indexer/parser.js +0 -443
  43. package/src/tasks/store.js +0 -349
  44. package/src/tests/coverage.js +0 -173
  45. package/src/tests/index.js +0 -171
  46. package/src/tests/store.js +0 -213
  47. package/src/tools/agent-task.js +0 -145
  48. package/src/tools/code-mode.js +0 -304
  49. package/src/tools/decompose.js +0 -91
  50. package/src/tools/edits.js +0 -94
  51. package/src/tools/execution.js +0 -171
  52. package/src/tools/git.js +0 -1346
  53. package/src/tools/index.js +0 -306
  54. package/src/tools/indexer.js +0 -360
  55. package/src/tools/lazy-loader.js +0 -366
  56. package/src/tools/mcp-remote.js +0 -88
  57. package/src/tools/mcp.js +0 -116
  58. package/src/tools/process.js +0 -167
  59. package/src/tools/smart-selection.js +0 -180
  60. package/src/tools/stubs.js +0 -55
  61. package/src/tools/tasks.js +0 -260
  62. package/src/tools/tests.js +0 -132
  63. package/src/tools/tinyfish.js +0 -358
  64. package/src/tools/truncate.js +0 -106
  65. package/src/tools/web-client.js +0 -71
  66. package/src/tools/web.js +0 -415
  67. package/src/tools/workspace.js +0 -204
@@ -347,9 +347,21 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
347
347
  // Classifier says LOWER tier — trust it. Fixes over-routing.
348
348
  return { score: TIER_MIDPOINT[classifierTier], reconciled: 'down' };
349
349
  }
350
- // Classifier says HIGHER tier — gate on confidence.
350
+ // Classifier says HIGHER tier — gate on confidence, and cap the jump at
351
+ // ONE band above the anchor. The classifier is a tiebreaker, not an
352
+ // oracle: qwen2.5:3b's confidence is degenerate (92% of eval answers say
353
+ // 0.9-1.0, incl. every wrong one), so an unbounded jump let a 3B verdict
354
+ // catapult "Who kills him ?" from anchor 25 to 88 → REASONING →
355
+ // subscription passthrough (live incident 2026-07-21). Escalating past
356
+ // the adjacent band now takes consecutive turns that keep re-scoring
357
+ // higher, which is exactly the persistence a genuinely hard conversation
358
+ // exhibits.
351
359
  if (classifierResult.confidence >= 0.8) {
352
- return { score: TIER_MIDPOINT[classifierTier], reconciled: 'up' };
360
+ const cappedIdx = Math.min(classifierIdx, anchorIdx + 1);
361
+ return {
362
+ score: TIER_MIDPOINT[TIER_ORDER[cappedIdx]],
363
+ reconciled: cappedIdx < classifierIdx ? 'up_capped' : 'up',
364
+ };
353
365
  }
354
366
  return { score: anchorScore, reconciled: 'up_gated' };
355
367
  }
@@ -385,6 +397,12 @@ async function scoreIntent(payload, opts = {}) {
385
397
  classifierResult = await classifyDifficulty(text, {
386
398
  forceMatched: opts.forceMatched,
387
399
  riskLevel: opts.riskLevel,
400
+ // Condensed prior turns, threaded by the router's window loop.
401
+ // Without it a short follow-up ("Who kills him ?") is
402
+ // unclassifiable in isolation.
403
+ context: typeof payload?._conversationContext === 'string'
404
+ ? payload._conversationContext
405
+ : null,
388
406
  });
389
407
  } catch (err) {
390
408
  logger.debug({ err: err.message }, '[IntentScore] classifier failed — anchor only');
@@ -114,12 +114,18 @@ function getPin(sessionId) {
114
114
  * Accepts a routing decision plus session-scoped stats used by the re-pin
115
115
  * heuristics.
116
116
  *
117
+ * `hasToolHistory` is sticky-true: once set to true it stays true for the
118
+ * pin's lifetime — a follow-up refresh that didn't carry tool blocks (e.g.
119
+ * a plain-text turn between tool exchanges) must not clear the flag, or the
120
+ * Signal-2 side-channel check would flip open again.
121
+ *
117
122
  * @param {string} sessionId
118
123
  * @param {{provider:string, model?:string|null, tier?:string|null}} decision
119
- * @param {{messageCount?:number|null, promptTokensEst?:number|null}} [stats]
124
+ * @param {{messageCount?:number|null, promptTokensEst?:number|null, hasToolHistory?:boolean}} [stats]
120
125
  */
121
126
  function setPin(sessionId, decision, stats = {}) {
122
127
  if (!sessionId || !decision?.provider) return;
128
+ const prev = pins.get(sessionId);
123
129
  const pin = {
124
130
  provider: decision.provider,
125
131
  model: decision.model ?? null,
@@ -127,6 +133,7 @@ function setPin(sessionId, decision, stats = {}) {
127
133
  score: typeof decision.score === 'number' ? decision.score : null,
128
134
  messageCount: stats.messageCount ?? null,
129
135
  promptTokensEst: stats.promptTokensEst ?? null,
136
+ hasToolHistory: !!(stats.hasToolHistory || prev?.hasToolHistory),
130
137
  ts: Date.now(),
131
138
  };
132
139
  // Refresh insertion order so active sessions aren't evicted.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Side-Channel Detector — first-user-message fingerprint drift
3
+ *
4
+ * Complements the pin-based side-channel signals in router.js:
5
+ * 1. Message-count regression → needs pin
6
+ * 2. Tool-history mismatch → needs pin with hasToolHistory
7
+ * 3. First-message fingerprint → THIS module — works before any pin exists
8
+ *
9
+ * Purpose: on a session's first request, cache a fingerprint of messages[0].
10
+ * On later requests within the TTL, if messages[0] no longer matches, the
11
+ * payload has been replayed by a background utility caller (Claude Code
12
+ * wraps the original user text in `<session>...</session>` for title-gen,
13
+ * substitutes recap prompts, etc.).
14
+ *
15
+ * Trade-off: if a background call fires BEFORE the first real user turn
16
+ * lands, its wrapper prompt becomes the anchor. That's rare in practice —
17
+ * observed order in real traffic is user turn first, background call after.
18
+ * If it does happen, the real user's next request will be mislabelled once,
19
+ * then the anchor will "settle" once ttl expires.
20
+ *
21
+ * @module routing/side-channel-detector
22
+ */
23
+
24
+ const crypto = require('crypto');
25
+ const logger = require('../logger');
26
+
27
+ const TTL_MS = 30 * 60 * 1000; // 30 min — matches an interactive Claude Code session
28
+ const MAX_ENTRIES = 2000; // LRU cap (same order of magnitude as session-affinity)
29
+
30
+ /** @type {Map<string, {hash:string, ts:number}>} */
31
+ const anchors = new Map();
32
+
33
+ function _evictIfNeeded() {
34
+ if (anchors.size <= MAX_ENTRIES) return;
35
+ const oldest = anchors.keys().next().value;
36
+ if (oldest !== undefined) anchors.delete(oldest);
37
+ }
38
+
39
+ function _firstUserText(messages) {
40
+ if (!Array.isArray(messages)) return null;
41
+ for (const m of messages) {
42
+ if (m?.role !== 'user') continue;
43
+ if (typeof m.content === 'string') return m.content;
44
+ if (Array.isArray(m.content)) {
45
+ const t = m.content.filter(b => b?.type === 'text').map(b => b.text || '').join('');
46
+ if (t) return t;
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function _hash(str) {
53
+ return crypto.createHash('sha1').update(str).digest('hex').slice(0, 16);
54
+ }
55
+
56
+ /**
57
+ * Check whether the payload's first user message drifts from the cached
58
+ * anchor for this session. Returns true only when we have an anchor AND
59
+ * it disagrees; returns false when no anchor exists yet (bootstrap case)
60
+ * and stores this payload's hash as the new anchor.
61
+ *
62
+ * @param {string|null} sessionId
63
+ * @param {Array} messages
64
+ * @returns {boolean} true if drift detected (caller should treat as side channel)
65
+ */
66
+ function check(sessionId, messages) {
67
+ if (!sessionId) return false;
68
+ const text = _firstUserText(messages);
69
+ if (!text) return false;
70
+ const hash = _hash(text);
71
+ const now = Date.now();
72
+
73
+ const cached = anchors.get(sessionId);
74
+ if (cached) {
75
+ // Refresh insertion order for LRU
76
+ anchors.delete(sessionId);
77
+ if (now - cached.ts > TTL_MS) {
78
+ // Expired — reset the anchor, no drift claim.
79
+ anchors.set(sessionId, { hash, ts: now });
80
+ _evictIfNeeded();
81
+ return false;
82
+ }
83
+ anchors.set(sessionId, cached);
84
+ if (cached.hash !== hash) {
85
+ logger.debug({ sessionId, cachedHash: cached.hash, newHash: hash },
86
+ '[SideChannel] First-message fingerprint drift');
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ // Bootstrap — first sighting of this session, store and pass.
93
+ anchors.set(sessionId, { hash, ts: now });
94
+ _evictIfNeeded();
95
+ return false;
96
+ }
97
+
98
+ /** Test/maintenance helper — clear the in-memory anchors. */
99
+ function _clear() {
100
+ anchors.clear();
101
+ }
102
+
103
+ module.exports = { check, _clear };
package/src/server.js CHANGED
@@ -18,52 +18,13 @@ const { getCircuitBreakerRegistry } = require("./clients/circuit-breaker");
18
18
  const metrics = require("./metrics");
19
19
  const logger = require("./logger");
20
20
  const { initialiseMcp } = require("./mcp");
21
- const { registerStubTools } = require("./tools/stubs");
22
- const { registerWorkspaceTools } = require("./tools/workspace");
23
- const { registerExecutionTools } = require("./tools/execution");
24
- const { registerWebTools } = require("./tools/web");
25
- const { registerIndexerTools } = require("./tools/indexer");
26
- const { registerEditTools } = require("./tools/edits");
27
- const { registerGitTools } = require("./tools/git");
28
- const { registerTaskTools } = require("./tools/tasks");
29
- const { registerTestTools } = require("./tools/tests");
30
- const { registerMcpTools } = require("./tools/mcp");
31
- const { registerAgentTaskTool } = require("./tools/agent-task");
32
- const { registerDecomposeTool } = require("./tools/decompose");
33
21
  const { initConfigWatcher, getConfigWatcher } = require("./config/watcher");
34
22
  const { initializeHeadroom, shutdownHeadroom, getHeadroomManager } = require("./headroom");
35
23
  const { getWorkerPool, isWorkerPoolReady } = require("./workers/pool");
36
- const lazyLoader = require("./tools/lazy-loader");
37
- const { setLazyLoader } = require("./tools");
38
24
  const { waitForOllama } = require("./clients/ollama-startup");
39
25
 
40
26
  initialiseMcp();
41
27
 
42
- setLazyLoader(lazyLoader);
43
-
44
- const LAZY_TOOLS_ENABLED = process.env.LAZY_TOOLS_ENABLED !== "false";
45
-
46
- if (LAZY_TOOLS_ENABLED) {
47
- // Only load core tools at startup (stubs, workspace, execution)
48
- lazyLoader.loadCoreTools();
49
- logger.info({ mode: "lazy" }, "Lazy tool loading enabled - other tools will load on demand");
50
- } else {
51
- // Backwards compatibility: load all tools at startup
52
- registerStubTools();
53
- registerWorkspaceTools();
54
- registerExecutionTools();
55
- registerWebTools();
56
- registerIndexerTools();
57
- registerEditTools();
58
- registerGitTools();
59
- registerTaskTools();
60
- registerTestTools();
61
- registerMcpTools();
62
- registerAgentTaskTool();
63
- registerDecomposeTool();
64
- logger.info({ mode: "eager" }, "All tools loaded at startup");
65
- }
66
-
67
28
  function createApp() {
68
29
  const app = express();
69
30
  const path = require('path');
@@ -171,13 +132,6 @@ function createApp() {
171
132
  res.json({ enabled: true, ...cache.getStats() });
172
133
  });
173
134
 
174
- app.get("/metrics/lazy-tools", (req, res) => {
175
- res.json({
176
- enabled: LAZY_TOOLS_ENABLED,
177
- ...lazyLoader.getLoaderStats(),
178
- });
179
- });
180
-
181
135
  app.use(router);
182
136
 
183
137
  app.use('/dashboard', require('./dashboard/router'));
@@ -1,236 +0,0 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const logger = require("../logger");
4
-
5
- class ContextManager {
6
- constructor() {
7
- this.transcriptsDir = path.join(process.cwd(), "data", "agent-transcripts");
8
- this.ensureTranscriptsDir();
9
- }
10
-
11
- ensureTranscriptsDir() {
12
- if (!fs.existsSync(this.transcriptsDir)) {
13
- fs.mkdirSync(this.transcriptsDir, { recursive: true });
14
- }
15
- }
16
-
17
- /**
18
- * Create fresh context for subagent
19
- * @param {Object} agentDef - Agent definition
20
- * @param {string} taskPrompt - Task from main agent
21
- * @param {Object} mainContext - Minimal context from main agent
22
- * @returns {Object} - Fresh context for subagent
23
- */
24
- createSubagentContext(agentDef, taskPrompt, mainContext = {}) {
25
- const agentId = this.generateAgentId();
26
- const transcriptPath = path.join(this.transcriptsDir, `agent-${agentId}.jsonl`);
27
-
28
- // Initialize transcript file
29
- fs.writeFileSync(transcriptPath, "");
30
-
31
- // Build minimal context (NOT full main agent history)
32
- const messages = [];
33
-
34
- // System prompt from agent definition
35
- messages.push({
36
- role: "system",
37
- content: agentDef.systemPrompt
38
- });
39
-
40
- // Optional: Add minimal context from main agent
41
- if (mainContext.relevant_context) {
42
- messages.push({
43
- role: "system",
44
- content: `Context from main agent:\n${mainContext.relevant_context}`
45
- });
46
- }
47
-
48
- // Task prompt
49
- messages.push({
50
- role: "user",
51
- content: taskPrompt
52
- });
53
-
54
- const context = {
55
- agentId,
56
- agentName: agentDef.name,
57
- transcriptPath,
58
- messages,
59
- steps: 0,
60
- maxSteps: agentDef.maxSteps,
61
- model: agentDef.model,
62
- allowedTools: agentDef.allowedTools,
63
- startTime: Date.now(),
64
-
65
- // Original task prompt — consumed by the Reflector to infer task type.
66
- taskPrompt,
67
-
68
- // In-memory transcript mirror of the JSONL file — consumed by the
69
- // Reflector for tool-usage / error-recovery analysis. The JSONL file
70
- // remains the durable record; this array is the hot-path copy.
71
- transcript: [],
72
-
73
- // Token tracking
74
- inputTokens: 0,
75
- outputTokens: 0,
76
-
77
- // State
78
- terminated: false,
79
- result: null
80
- };
81
-
82
- this.writeTranscriptEntry(transcriptPath, {
83
- type: "agent_start",
84
- agentId,
85
- agentName: agentDef.name,
86
- taskPrompt,
87
- timestamp: Date.now()
88
- });
89
-
90
- logger.info({ agentId, agentName: agentDef.name }, "Created fresh subagent context");
91
-
92
- return context;
93
- }
94
-
95
- /**
96
- * Add message to subagent context
97
- */
98
- addMessage(context, message) {
99
- context.messages.push(message);
100
-
101
- this.writeTranscriptEntry(context.transcriptPath, {
102
- type: "message",
103
- agentId: context.agentId,
104
- message,
105
- timestamp: Date.now()
106
- });
107
- }
108
-
109
- /**
110
- * Record tool execution in transcript
111
- */
112
- recordToolCall(context, toolName, input, output, error = null) {
113
- const entry = {
114
- type: "tool_call",
115
- agentId: context.agentId,
116
- step: context.steps,
117
- toolName,
118
- input,
119
- output: error ? null : output,
120
- error: error ? error.message : null,
121
- timestamp: Date.now()
122
- };
123
-
124
- // Keep the in-memory transcript in sync so the Reflector (which reads
125
- // context.transcript) sees tool calls without re-parsing the JSONL file.
126
- if (Array.isArray(context.transcript)) {
127
- context.transcript.push(entry);
128
- }
129
-
130
- this.writeTranscriptEntry(context.transcriptPath, entry);
131
- }
132
-
133
- /**
134
- * Complete subagent execution
135
- */
136
- completeExecution(context, result) {
137
- context.terminated = true;
138
- context.result = result;
139
-
140
- this.writeTranscriptEntry(context.transcriptPath, {
141
- type: "agent_complete",
142
- agentId: context.agentId,
143
- result,
144
- stats: {
145
- steps: context.steps,
146
- durationMs: Date.now() - context.startTime,
147
- inputTokens: context.inputTokens,
148
- outputTokens: context.outputTokens
149
- },
150
- timestamp: Date.now()
151
- });
152
-
153
- logger.info({
154
- agentId: context.agentId,
155
- steps: context.steps,
156
- durationMs: Date.now() - context.startTime
157
- }, "Subagent execution completed");
158
- }
159
-
160
- /**
161
- * Fail subagent execution
162
- */
163
- failExecution(context, error) {
164
- context.terminated = true;
165
-
166
- this.writeTranscriptEntry(context.transcriptPath, {
167
- type: "agent_failed",
168
- agentId: context.agentId,
169
- error: error.message,
170
- stats: {
171
- steps: context.steps,
172
- durationMs: Date.now() - context.startTime
173
- },
174
- timestamp: Date.now()
175
- });
176
-
177
- logger.error({
178
- agentId: context.agentId,
179
- error: error.message
180
- }, "Subagent execution failed");
181
- }
182
-
183
- /**
184
- * Write entry to transcript file (JSONL format)
185
- */
186
- writeTranscriptEntry(transcriptPath, entry) {
187
- try {
188
- fs.appendFileSync(transcriptPath, JSON.stringify(entry) + "\n");
189
- } catch (error) {
190
- logger.warn({ error: error.message }, "Failed to write transcript entry");
191
- }
192
- }
193
-
194
- /**
195
- * Read transcript for debugging
196
- */
197
- readTranscript(agentId) {
198
- const transcriptPath = path.join(this.transcriptsDir, `agent-${agentId}.jsonl`);
199
-
200
- if (!fs.existsSync(transcriptPath)) {
201
- return null;
202
- }
203
-
204
- const lines = fs.readFileSync(transcriptPath, "utf8").split("\n").filter(l => l.trim());
205
- return lines.map(line => JSON.parse(line));
206
- }
207
-
208
- /**
209
- * Generate unique agent ID
210
- */
211
- generateAgentId() {
212
- return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
213
- }
214
-
215
- /**
216
- * Clean old transcripts (older than 7 days)
217
- */
218
- cleanOldTranscripts() {
219
- const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
220
- const now = Date.now();
221
-
222
- const files = fs.readdirSync(this.transcriptsDir);
223
-
224
- for (const file of files) {
225
- const filePath = path.join(this.transcriptsDir, file);
226
- const stats = fs.statSync(filePath);
227
-
228
- if (now - stats.mtimeMs > maxAge) {
229
- fs.unlinkSync(filePath);
230
- logger.debug({ file }, "Cleaned old transcript");
231
- }
232
- }
233
- }
234
- }
235
-
236
- module.exports = ContextManager;
@@ -1,185 +0,0 @@
1
- /**
2
- * Subtask dispatcher (Phase 3).
3
- *
4
- * Executes a validated plan respecting its dependency DAG:
5
- * - subtasks are grouped into topological "levels" (Kahn's algorithm)
6
- * - subtasks in the same level have no dependency on each other → run in
7
- * parallel via the existing ParallelCoordinator (spawnParallel)
8
- * - a subtask receives ONLY its own prompt plus the compressed results of the
9
- * subtasks it depends on (context isolation — the token win)
10
- *
11
- * The spawn functions are injectable for testing.
12
- */
13
-
14
- const logger = require("../../logger");
15
-
16
- // Cap how much of a dependency's result we forward, to preserve the
17
- // context-isolation savings (subagents already return summaries; this bounds
18
- // pathological cases).
19
- const MAX_CONTEXT_CHARS = 2000;
20
-
21
- /**
22
- * Group subtasks into dependency levels. Returns array of arrays of ids.
23
- * Throws if the graph is unresolvable (should not happen — planner validated).
24
- */
25
- function topologicalLevels(subtasks) {
26
- const byId = new Map(subtasks.map((s) => [s.id, s]));
27
- const indegree = new Map(subtasks.map((s) => [s.id, 0]));
28
- const dependents = new Map(subtasks.map((s) => [s.id, []]));
29
-
30
- for (const s of subtasks) {
31
- for (const dep of s.dependsOn) {
32
- if (!byId.has(dep)) continue;
33
- indegree.set(s.id, indegree.get(s.id) + 1);
34
- dependents.get(dep).push(s.id);
35
- }
36
- }
37
-
38
- const levels = [];
39
- let frontier = subtasks.filter((s) => indegree.get(s.id) === 0).map((s) => s.id);
40
- const resolved = new Set();
41
-
42
- while (frontier.length > 0) {
43
- levels.push(frontier);
44
- const next = [];
45
- for (const id of frontier) {
46
- resolved.add(id);
47
- for (const child of dependents.get(id)) {
48
- indegree.set(child, indegree.get(child) - 1);
49
- if (indegree.get(child) === 0) next.push(child);
50
- }
51
- }
52
- frontier = next;
53
- }
54
-
55
- if (resolved.size !== subtasks.length) {
56
- throw new Error("Unresolvable subtask graph (cycle or dangling dependency)");
57
- }
58
- return levels;
59
- }
60
-
61
- function compressResult(text) {
62
- if (typeof text !== "string") return String(text ?? "");
63
- if (text.length <= MAX_CONTEXT_CHARS) return text;
64
- return text.slice(0, MAX_CONTEXT_CHARS) + "\n…[truncated]";
65
- }
66
-
67
- function buildContextForSubtask(subtask, resultsById) {
68
- if (!subtask.dependsOn || subtask.dependsOn.length === 0) return null;
69
- const parts = [];
70
- for (const dep of subtask.dependsOn) {
71
- const r = resultsById.get(dep);
72
- if (r && r.success && r.result) {
73
- parts.push(`Result of subtask ${dep}:\n${compressResult(r.result)}`);
74
- } else if (r) {
75
- parts.push(`Subtask ${dep} did not complete successfully.`);
76
- }
77
- }
78
- return parts.length > 0 ? parts.join("\n\n") : null;
79
- }
80
-
81
- /**
82
- * Dispatch a validated plan.
83
- * @param {Object} plan - { subtasks: [...] }
84
- * @param {Object} [options]
85
- * @param {string} [options.sessionId]
86
- * @param {string} [options.cwd]
87
- * @param {Function} [options.spawnParallel] - (agentTypes[], prompts[], opts) => results[]
88
- * @returns {Promise<{results: Array, levels: Array, stats: Object}>}
89
- */
90
- async function dispatchPlan(plan, options = {}) {
91
- const spawnParallel = options.spawnParallel || require("../index").spawnParallel;
92
- const subtasks = plan.subtasks;
93
- const byId = new Map(subtasks.map((s) => [s.id, s]));
94
- const levels = topologicalLevels(subtasks);
95
- const resultsById = new Map();
96
-
97
- let totalInputTokens = 0;
98
- let totalOutputTokens = 0;
99
- let totalSubagents = 0;
100
-
101
- for (let li = 0; li < levels.length; li++) {
102
- const levelIds = levels[li];
103
- const levelSubtasks = levelIds.map((id) => byId.get(id));
104
-
105
- const agentTypes = levelSubtasks.map((s) => s.agentType);
106
- const prompts = levelSubtasks.map((s) => s.prompt);
107
- const perTaskContext = levelSubtasks.map((s) => buildContextForSubtask(s, resultsById));
108
-
109
- logger.info(
110
- { level: li, count: levelIds.length, ids: levelIds },
111
- "[Decomposition] Dispatching subtask level"
112
- );
113
-
114
- // spawnParallel shares one options object; pass per-task context by spawning
115
- // the level as individual parallel calls when contexts differ.
116
- const levelResults = await runLevel(
117
- spawnParallel,
118
- agentTypes,
119
- prompts,
120
- perTaskContext,
121
- options
122
- );
123
-
124
- levelResults.forEach((res, idx) => {
125
- const st = levelSubtasks[idx];
126
- totalSubagents += 1;
127
- totalInputTokens += res?.stats?.inputTokens || 0;
128
- totalOutputTokens += res?.stats?.outputTokens || 0;
129
- resultsById.set(st.id, {
130
- id: st.id,
131
- agentType: st.agentType,
132
- success: !!res?.success,
133
- result: res?.success ? res.result : null,
134
- error: res?.success ? null : res?.error || "unknown error",
135
- stats: res?.stats || {},
136
- });
137
- });
138
- }
139
-
140
- const results = subtasks.map((s) => resultsById.get(s.id));
141
- return {
142
- results,
143
- levels,
144
- stats: {
145
- subagents: totalSubagents,
146
- inputTokens: totalInputTokens,
147
- outputTokens: totalOutputTokens,
148
- },
149
- };
150
- }
151
-
152
- /**
153
- * Run one level. When subtasks in the level have differing injected contexts we
154
- * spawn them as separate parallel calls (each with its own mainContext), then
155
- * await all. When none need context, a single spawnParallel batch is used.
156
- */
157
- async function runLevel(spawnParallel, agentTypes, prompts, perTaskContext, options) {
158
- const anyContext = perTaskContext.some((c) => c);
159
-
160
- if (!anyContext) {
161
- return spawnParallel(agentTypes, prompts, {
162
- sessionId: options.sessionId,
163
- cwd: options.cwd,
164
- });
165
- }
166
-
167
- // Mixed/with-context: one spawnParallel call per subtask so each gets its own
168
- // mainContext, executed concurrently.
169
- const calls = agentTypes.map((type, i) =>
170
- spawnParallel([type], [prompts[i]], {
171
- sessionId: options.sessionId,
172
- cwd: options.cwd,
173
- mainContext: perTaskContext[i] ? { relevant_context: perTaskContext[i] } : undefined,
174
- }).then((arr) => arr[0])
175
- );
176
- return Promise.all(calls);
177
- }
178
-
179
- module.exports = {
180
- dispatchPlan,
181
- topologicalLevels,
182
- buildContextForSubtask,
183
- compressResult,
184
- MAX_CONTEXT_CHARS,
185
- };