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
|
@@ -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
|
-
|
|
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
|
-
};
|