kc-beta 0.5.5 → 0.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/QUICKSTART.md +17 -4
- package/README.md +58 -11
- package/bin/kc-beta.js +35 -1
- package/package.json +1 -1
- package/src/agent/bundle-tree.js +553 -0
- package/src/agent/context.js +40 -1
- package/src/agent/engine.js +644 -28
- package/src/agent/llm-client.js +67 -18
- package/src/agent/pipelines/finalization.js +186 -0
- package/src/agent/pipelines/index.js +8 -0
- package/src/agent/pipelines/initializer.js +40 -0
- package/src/agent/pipelines/skill-authoring.js +100 -6
- package/src/agent/skill-loader.js +54 -4
- package/src/agent/task-manager.js +66 -3
- package/src/agent/tools/agent-tool.js +283 -35
- package/src/agent/tools/bundle-search.js +146 -0
- package/src/agent/tools/document-chunk.js +246 -0
- package/src/agent/tools/document-classify.js +311 -0
- package/src/agent/tools/document-parse.js +8 -1
- package/src/agent/tools/phase-advance.js +30 -7
- package/src/agent/tools/registry.js +10 -0
- package/src/agent/tools/rule-catalog.js +17 -3
- package/src/agent/tools/sandbox-exec.js +30 -0
- package/src/agent/workspace.js +168 -14
- package/src/cli/components.js +165 -17
- package/src/cli/index.js +166 -19
- package/src/cli/meme.js +58 -0
- package/src/config.js +39 -2
- package/src/model-tiers.json +3 -2
- package/src/providers.js +34 -1
- package/template/skills/en/meta-meta/evolution-loop/SKILL.md +13 -1
- package/template/skills/en/meta-meta/rule-extraction/SKILL.md +74 -0
- package/template/skills/zh/meta-meta/evolution-loop/SKILL.md +7 -1
- package/template/skills/zh/meta-meta/rule-extraction/SKILL.md +73 -0
|
@@ -62,13 +62,65 @@ export class TaskManager {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Get the next pending task.
|
|
65
|
+
* Get the next pending task (read-only). For serial-mode callers.
|
|
66
|
+
* Parallel workers MUST use claimNextPending() to avoid racing.
|
|
66
67
|
* @returns {object|null}
|
|
67
68
|
*/
|
|
68
69
|
getNextPending() {
|
|
69
70
|
return this._tasks.find((t) => t.status === "pending") || null;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* B2: Atomically claim the next pending task — flips status to
|
|
75
|
+
* "in_progress" and records the worker. Single-threaded JavaScript
|
|
76
|
+
* means this is race-free WITHOUT a filesystem lock as long as neither
|
|
77
|
+
* the find nor the status mutation awaits, because the event loop
|
|
78
|
+
* won't interleave another worker's call between them. If we ever
|
|
79
|
+
* move TaskManager to share state across processes (unlikely; each
|
|
80
|
+
* session has its own file), wrap with workspace.withFileLock.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} [workerLabel] - optional identifier for the claimer,
|
|
83
|
+
* stored on the task for debugging + the TUI taskboard.
|
|
84
|
+
* @returns {object|null} The claimed task, or null if none pending.
|
|
85
|
+
*/
|
|
86
|
+
claimNextPending(workerLabel) {
|
|
87
|
+
const task = this._tasks.find((t) => t.status === "pending");
|
|
88
|
+
if (!task) return null;
|
|
89
|
+
task.status = "in_progress";
|
|
90
|
+
task.startedAt = new Date().toISOString();
|
|
91
|
+
if (workerLabel) task.worker = String(workerLabel);
|
|
92
|
+
this.save();
|
|
93
|
+
return task;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* B2: Mark a previously-claimed task as done. Pass an optional
|
|
98
|
+
* summary for the taskboard / display. Worker label is cleared since
|
|
99
|
+
* the task has left in_progress state.
|
|
100
|
+
*/
|
|
101
|
+
markDone(id, summary) {
|
|
102
|
+
const task = this._tasks.find((t) => t.id === id);
|
|
103
|
+
if (!task) return;
|
|
104
|
+
task.status = "completed";
|
|
105
|
+
task.completedAt = new Date().toISOString();
|
|
106
|
+
if (summary !== undefined) task.summary = summary;
|
|
107
|
+
delete task.worker;
|
|
108
|
+
this.save();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* B2: Mark a claimed task as failed. Preserves the worker label so
|
|
113
|
+
* post-mortems can trace which slot crashed.
|
|
114
|
+
*/
|
|
115
|
+
markFailed(id, errorMessage) {
|
|
116
|
+
const task = this._tasks.find((t) => t.id === id);
|
|
117
|
+
if (!task) return;
|
|
118
|
+
task.status = "failed";
|
|
119
|
+
task.completedAt = new Date().toISOString();
|
|
120
|
+
if (errorMessage) task.summary = String(errorMessage).slice(0, 500);
|
|
121
|
+
this.save();
|
|
122
|
+
}
|
|
123
|
+
|
|
72
124
|
/**
|
|
73
125
|
* Get all tasks.
|
|
74
126
|
* @returns {Array}
|
|
@@ -87,12 +139,23 @@ export class TaskManager {
|
|
|
87
139
|
// --- Bulk creation from rule catalog ---
|
|
88
140
|
|
|
89
141
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
142
|
+
* Phases where one-task-per-rule is the natural unit of work.
|
|
143
|
+
* For BOOTSTRAP / EXTRACTION the unit is a regulation (one PDF → many rules);
|
|
144
|
+
* ralph-loop shouldn't drive per-rule there because the rules don't exist yet
|
|
145
|
+
* (or are the *output*, not the input) — see E2E #3 coverage check.
|
|
146
|
+
*/
|
|
147
|
+
static PER_RULE_PHASES = new Set(["skill_authoring", "skill_testing"]);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create one task per rule for a given phase — but only if the phase's unit
|
|
151
|
+
* of work is actually a rule. For other phases this is a no-op, and any
|
|
152
|
+
* per-regulation tasks are created separately at session init.
|
|
153
|
+
*
|
|
92
154
|
* @param {Array<{id: string, title?: string, description?: string}>} rules
|
|
93
155
|
* @param {string} phase - The phase these tasks belong to
|
|
94
156
|
*/
|
|
95
157
|
createRuleTasks(rules, phase) {
|
|
158
|
+
if (!TaskManager.PER_RULE_PHASES.has(phase)) return;
|
|
96
159
|
for (const rule of rules) {
|
|
97
160
|
const ruleId = rule.id || rule.rule_id;
|
|
98
161
|
const title = rule.title || rule.description || ruleId;
|
|
@@ -11,18 +11,37 @@ function _newAutoTaskId() {
|
|
|
11
11
|
return `task_${crypto.randomUUID().slice(0, 8)}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const VALID_OPERATIONS = new Set(["spawn", "wait", "poll", "list", "kill"]);
|
|
15
|
+
|
|
14
16
|
/**
|
|
15
|
-
* Spawn
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Spawn + manage sub-agents.
|
|
18
|
+
*
|
|
19
|
+
* Operations (B8 expansion, 2026-04-23):
|
|
20
|
+
* spawn — (default) start a new sub-agent. Fire-and-forget; status is
|
|
21
|
+
* written to sub_agents/<taskId>/status.txt. Returns the taskId
|
|
22
|
+
* immediately so callers can use wait/poll/kill later.
|
|
23
|
+
* wait — block until the sub-agent finishes (status.txt != "running")
|
|
24
|
+
* or timeout. Lets the parent confirm completion before acting on
|
|
25
|
+
* the output, rather than re-spawning a duplicate because it missed
|
|
26
|
+
* a status.txt flip (the classic Bug 9 pattern).
|
|
27
|
+
* poll — non-blocking status read. Cheap visibility.
|
|
28
|
+
* list — enumerate all sub-agents under sub_agents/ with their status,
|
|
29
|
+
* age, and running/complete flag. Makes recursive fan-out visible:
|
|
30
|
+
* a parent can see its child spawned 8 grandchildren without
|
|
31
|
+
* inferring it from scattered task_ids.
|
|
32
|
+
* kill — abort a running sub-agent via AbortController. Cooperative:
|
|
33
|
+
* the abort takes effect between LLM events, not mid-token. Does
|
|
34
|
+
* NOT SIGKILL sandbox_exec grandchildren — those exit on their own
|
|
35
|
+
* timeout. Subagent status.txt flips to "killed".
|
|
36
|
+
*
|
|
37
|
+
* Created for session 6304673afaa0's runaway scenario: 8 subagents
|
|
38
|
+
* concurrently rewriting catalog.json with no way for the main agent
|
|
39
|
+
* to stop them.
|
|
21
40
|
*/
|
|
22
41
|
export class AgentTool extends BaseTool {
|
|
23
42
|
/**
|
|
24
43
|
* @param {import('../workspace.js').Workspace} workspace
|
|
25
|
-
* @param {(opts: {sessionId: string, subagentScope: string, initialPhase: string}) => import('../engine.js').AgentEngine} engineFactory
|
|
44
|
+
* @param {(opts: {sessionId: string, subagentScope: string, initialPhase: string, abortSignal?: AbortSignal}) => import('../engine.js').AgentEngine} engineFactory
|
|
26
45
|
* @param {() => string} getCurrentPhase Callback returning the parent's current phase (so sub-agents get phase-appropriate tools)
|
|
27
46
|
*/
|
|
28
47
|
constructor(workspace, engineFactory, getCurrentPhase = () => "bootstrap") {
|
|
@@ -30,21 +49,21 @@ export class AgentTool extends BaseTool {
|
|
|
30
49
|
this._workspace = workspace;
|
|
31
50
|
this._engineFactory = engineFactory;
|
|
32
51
|
this._getCurrentPhase = getCurrentPhase;
|
|
52
|
+
// Map<taskId, { promise, abortController, startedAt }>
|
|
33
53
|
this._runningTasks = new Map();
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
get name() { return "agent_tool"; }
|
|
37
57
|
get description() {
|
|
38
58
|
return (
|
|
39
|
-
"Spawn
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"description — it has no conversation context."
|
|
59
|
+
"Spawn + manage sub-agents. operation=spawn (default) starts one; " +
|
|
60
|
+
"wait/poll checks status; list enumerates all; kill aborts a runaway. " +
|
|
61
|
+
"Sub-agents own non-overlapping units of work — per-rule or per-document. " +
|
|
62
|
+
"Their persistence (history, events, state) lives under sub_agents/<taskId>/; " +
|
|
63
|
+
"workspace artifacts (rules/, skills/, workflows/) are shared. Give the " +
|
|
64
|
+
"sub-agent a complete self-contained brief — it has no conversation context. " +
|
|
65
|
+
"Before re-spawning a task with a familiar id, use poll to check the existing " +
|
|
66
|
+
"one's status — prevents duplicate work."
|
|
48
67
|
);
|
|
49
68
|
}
|
|
50
69
|
|
|
@@ -52,25 +71,94 @@ export class AgentTool extends BaseTool {
|
|
|
52
71
|
return {
|
|
53
72
|
type: "object",
|
|
54
73
|
properties: {
|
|
74
|
+
operation: {
|
|
75
|
+
type: "string",
|
|
76
|
+
enum: ["spawn", "wait", "poll", "list", "kill"],
|
|
77
|
+
description: "spawn (default) | wait | poll | list | kill",
|
|
78
|
+
},
|
|
55
79
|
task_description: {
|
|
56
80
|
type: "string",
|
|
57
|
-
description: "Complete task description for the sub-agent. Be specific — it has no conversation context.",
|
|
81
|
+
description: "(spawn) Complete task description for the sub-agent. Be specific — it has no conversation context.",
|
|
58
82
|
},
|
|
59
83
|
task_id: {
|
|
60
84
|
type: "string",
|
|
61
|
-
description: "
|
|
85
|
+
description: "(spawn: optional, auto-generated if invalid) | (wait/poll/kill: required) alphanumeric + _- only, max 64 chars.",
|
|
86
|
+
},
|
|
87
|
+
timeout_ms: {
|
|
88
|
+
type: "integer",
|
|
89
|
+
description: "(wait) Max time to wait in milliseconds. Default 30000 (30s).",
|
|
62
90
|
},
|
|
63
91
|
},
|
|
64
|
-
required: ["task_description"],
|
|
65
92
|
};
|
|
66
93
|
}
|
|
67
94
|
|
|
68
95
|
async execute(input) {
|
|
96
|
+
const op = (input.operation || "spawn").toLowerCase();
|
|
97
|
+
if (!VALID_OPERATIONS.has(op)) {
|
|
98
|
+
return new ToolResult(
|
|
99
|
+
`Unknown operation '${op}'. Valid: spawn, wait, poll, list, kill.`,
|
|
100
|
+
true,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (op === "spawn") return this._spawn(input);
|
|
104
|
+
if (op === "list") return this._list();
|
|
105
|
+
if (op === "poll") return this._poll(input.task_id);
|
|
106
|
+
if (op === "wait") return this._wait(input.task_id, input.timeout_ms);
|
|
107
|
+
if (op === "kill") return this._kill(input.task_id);
|
|
108
|
+
return new ToolResult(`Not implemented: ${op}`, true);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- spawn ----
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* H3: Dispatch-completeness linter. When the task_description starts by
|
|
115
|
+
* declaring it covers N items — "从以下核心法规中" / "法规1...法规2..."
|
|
116
|
+
* or "items 1 through 5" — check whether the body actually lists that
|
|
117
|
+
* many distinct items. Doesn't block spawn; prepends a warning to the
|
|
118
|
+
* tool result so the composing LLM sees the discrepancy on the next
|
|
119
|
+
* turn and can self-correct.
|
|
120
|
+
*
|
|
121
|
+
* Motivated by session 6304673afaa0: main agent wrote a brief titled
|
|
122
|
+
* "从以下核心法规中提取" (plural) but only listed 法规1. Reg 02 was
|
|
123
|
+
* silently dropped — no automation caught it until the rules were
|
|
124
|
+
* already half-extracted.
|
|
125
|
+
*/
|
|
126
|
+
_lintBriefCompleteness(taskDesc) {
|
|
127
|
+
const issues = [];
|
|
128
|
+
// Chinese enumerated: 法规1、法规2... / 文件1、文件2... / 任务1... etc
|
|
129
|
+
const zhEnumMatches = Array.from(taskDesc.matchAll(/(?:法规|文件|任务|步骤|规则组|项目)(\d+)/g))
|
|
130
|
+
.map((m) => parseInt(m[1], 10))
|
|
131
|
+
.filter((n) => Number.isFinite(n));
|
|
132
|
+
if (zhEnumMatches.length > 0) {
|
|
133
|
+
const maxN = Math.max(...zhEnumMatches);
|
|
134
|
+
const uniqueN = new Set(zhEnumMatches).size;
|
|
135
|
+
if (maxN > uniqueN) {
|
|
136
|
+
issues.push(
|
|
137
|
+
`Brief references items up to ${maxN} but only ${uniqueN} distinct item numbers appear — ` +
|
|
138
|
+
`possible dropped item (e.g. saying "法规1, 法规3" without listing 法规2).`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// English enumerated: "item 1", "step 2" etc, or bullet list mismatch
|
|
143
|
+
const enEnumMatches = Array.from(taskDesc.matchAll(/\b(?:item|step|task|regulation|file)\s+(\d+)/gi))
|
|
144
|
+
.map((m) => parseInt(m[1], 10))
|
|
145
|
+
.filter((n) => Number.isFinite(n));
|
|
146
|
+
if (enEnumMatches.length > 0) {
|
|
147
|
+
const maxN = Math.max(...enEnumMatches);
|
|
148
|
+
const uniqueN = new Set(enEnumMatches).size;
|
|
149
|
+
if (maxN > uniqueN) {
|
|
150
|
+
issues.push(
|
|
151
|
+
`Brief references items up to ${maxN} but only ${uniqueN} distinct item numbers appear — ` +
|
|
152
|
+
`possible dropped item.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return issues;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async _spawn(input) {
|
|
69
160
|
const taskDesc = input.task_description || "";
|
|
70
161
|
const requestedId = (input.task_id || "").trim();
|
|
71
|
-
// Sanitize: anything not matching VALID_TASK_ID is silently replaced with
|
|
72
|
-
// an auto-generated id. The label survives in result metadata so KC can
|
|
73
|
-
// still cross-reference, but the path component is always safe.
|
|
74
162
|
const taskId = requestedId && VALID_TASK_ID.test(requestedId)
|
|
75
163
|
? requestedId
|
|
76
164
|
: _newAutoTaskId();
|
|
@@ -78,33 +166,58 @@ export class AgentTool extends BaseTool {
|
|
|
78
166
|
|
|
79
167
|
if (!taskDesc) return new ToolResult("No task_description provided", true);
|
|
80
168
|
|
|
81
|
-
//
|
|
169
|
+
// B8: reject re-spawn of an id that's currently alive. Prevents Bug 9
|
|
170
|
+
// double-dispatch when the caller didn't poll first. Caller can kill
|
|
171
|
+
// the existing one explicitly if they want to replace it.
|
|
172
|
+
if (this._runningTasks.has(taskId)) {
|
|
173
|
+
return new ToolResult(
|
|
174
|
+
`Sub-agent '${taskId}' is still running. Use poll to check status, wait to block for completion, or kill to abort before re-spawning.`,
|
|
175
|
+
true,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
82
179
|
const taskDir = path.join(this._workspace.cwd, "sub_agents", taskId);
|
|
83
180
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
84
181
|
fs.writeFileSync(path.join(taskDir, "task.md"), taskDesc, "utf-8");
|
|
182
|
+
fs.writeFileSync(path.join(taskDir, "status.txt"), "running", "utf-8");
|
|
85
183
|
if (labelOverridden) {
|
|
86
184
|
fs.writeFileSync(path.join(taskDir, "requested_id.txt"), requestedId, "utf-8");
|
|
87
185
|
}
|
|
88
186
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// same tools registered as the parent (Bug 2 fix).
|
|
187
|
+
const abortController = new AbortController();
|
|
188
|
+
|
|
92
189
|
let childEngine;
|
|
93
190
|
try {
|
|
94
191
|
childEngine = this._engineFactory({
|
|
95
192
|
sessionId: this._workspace.sessionId,
|
|
96
193
|
subagentScope: taskId,
|
|
97
194
|
initialPhase: this._getCurrentPhase(),
|
|
195
|
+
abortSignal: abortController.signal, // forward-compatible; engine may use or ignore
|
|
98
196
|
});
|
|
99
197
|
} catch (e) {
|
|
198
|
+
fs.writeFileSync(path.join(taskDir, "status.txt"), `failed: ${e.message}`, "utf-8");
|
|
100
199
|
return new ToolResult(`Failed to create sub-agent: ${e.message}`, true);
|
|
101
200
|
}
|
|
102
201
|
|
|
103
|
-
|
|
202
|
+
const startedAt = Date.now();
|
|
104
203
|
const taskPromise = (async () => {
|
|
105
204
|
const resultEvents = [];
|
|
106
205
|
try {
|
|
107
206
|
for await (const event of childEngine.runTurn(taskDesc)) {
|
|
207
|
+
if (abortController.signal.aborted) {
|
|
208
|
+
// B8: cooperative kill check on each yielded event. Effective
|
|
209
|
+
// on next event boundary; previous event was allowed to complete
|
|
210
|
+
// so partial work is preserved.
|
|
211
|
+
fs.writeFileSync(path.join(taskDir, "status.txt"), "killed", "utf-8");
|
|
212
|
+
try {
|
|
213
|
+
fs.writeFileSync(
|
|
214
|
+
path.join(taskDir, "result.json"),
|
|
215
|
+
JSON.stringify(resultEvents, null, 2),
|
|
216
|
+
"utf-8",
|
|
217
|
+
);
|
|
218
|
+
} catch { /* best-effort */ }
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
108
221
|
resultEvents.push({
|
|
109
222
|
type: event.type,
|
|
110
223
|
text: event.text,
|
|
@@ -118,26 +231,161 @@ export class AgentTool extends BaseTool {
|
|
|
118
231
|
JSON.stringify(resultEvents, null, 2),
|
|
119
232
|
"utf-8",
|
|
120
233
|
);
|
|
121
|
-
|
|
122
234
|
const textParts = resultEvents.filter((e) => e.type === "text_delta").map((e) => e.text || "");
|
|
123
235
|
fs.writeFileSync(path.join(taskDir, "output.md"), textParts.join(""), "utf-8");
|
|
124
236
|
fs.writeFileSync(path.join(taskDir, "status.txt"), "completed", "utf-8");
|
|
125
237
|
} catch (e) {
|
|
126
|
-
|
|
238
|
+
const status = abortController.signal.aborted ? "killed" : `failed: ${e.message}`;
|
|
239
|
+
try { fs.writeFileSync(path.join(taskDir, "status.txt"), status, "utf-8"); } catch { /* ignore */ }
|
|
240
|
+
} finally {
|
|
241
|
+
// Clean up any lingering background work the child started.
|
|
242
|
+
try { childEngine.stop?.(); } catch { /* ignore */ }
|
|
127
243
|
}
|
|
128
244
|
})();
|
|
129
245
|
|
|
130
|
-
this._runningTasks.set(taskId, taskPromise);
|
|
246
|
+
this._runningTasks.set(taskId, { promise: taskPromise, abortController, startedAt });
|
|
131
247
|
taskPromise.catch(() => {}).finally(() => this._runningTasks.delete(taskId));
|
|
132
248
|
|
|
133
|
-
|
|
249
|
+
// H3: linter runs on the brief we just saved. Non-blocking; warning
|
|
250
|
+
// surfaces in the tool result so the LLM sees it.
|
|
251
|
+
const lintWarnings = this._lintBriefCompleteness(taskDesc);
|
|
252
|
+
|
|
253
|
+
const result = {
|
|
134
254
|
task_id: taskId,
|
|
135
255
|
requested_id: labelOverridden ? requestedId : undefined,
|
|
136
|
-
status: "
|
|
256
|
+
status: "running",
|
|
137
257
|
output_dir: `sub_agents/${taskId}/`,
|
|
138
258
|
message: labelOverridden
|
|
139
|
-
? `Sub-agent started under sanitized id '${taskId}' (your '${requestedId}' wasn't a valid path component).
|
|
140
|
-
: `Sub-agent
|
|
141
|
-
}
|
|
259
|
+
? `Sub-agent started under sanitized id '${taskId}' (your '${requestedId}' wasn't a valid path component). Use operation=poll with task_id=${taskId} to check progress.`
|
|
260
|
+
: `Sub-agent '${taskId}' started. Use operation=poll to check progress, operation=wait to block for completion, operation=kill to abort.`,
|
|
261
|
+
};
|
|
262
|
+
if (lintWarnings.length > 0) {
|
|
263
|
+
result.brief_lint_warnings = lintWarnings;
|
|
264
|
+
}
|
|
265
|
+
return new ToolResult(JSON.stringify(result, null, 2));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- poll ----
|
|
269
|
+
|
|
270
|
+
_poll(taskId) {
|
|
271
|
+
const id = (taskId || "").trim();
|
|
272
|
+
if (!id) return new ToolResult("task_id required for poll", true);
|
|
273
|
+
const info = this._readStatus(id);
|
|
274
|
+
if (!info) return new ToolResult(`No sub-agent dir for task_id '${id}'`, true);
|
|
275
|
+
return new ToolResult(JSON.stringify(info, null, 2));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- wait ----
|
|
279
|
+
|
|
280
|
+
async _wait(taskId, timeoutMs) {
|
|
281
|
+
const id = (taskId || "").trim();
|
|
282
|
+
if (!id) return new ToolResult("task_id required for wait", true);
|
|
283
|
+
const entry = this._runningTasks.get(id);
|
|
284
|
+
const budget = Math.max(1000, Math.min(10 * 60_000, Number(timeoutMs) || 30_000));
|
|
285
|
+
|
|
286
|
+
if (entry) {
|
|
287
|
+
const timeoutP = new Promise((resolve) => setTimeout(() => resolve("timeout"), budget));
|
|
288
|
+
const result = await Promise.race([entry.promise.then(() => "done", () => "done"), timeoutP]);
|
|
289
|
+
if (result === "timeout") {
|
|
290
|
+
const info = this._readStatus(id);
|
|
291
|
+
return new ToolResult(
|
|
292
|
+
`Timeout after ${budget}ms waiting for '${id}'. Current status: ${info?.status || "unknown"}. Task still running — poll again or kill.`,
|
|
293
|
+
false,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const info = this._readStatus(id);
|
|
299
|
+
if (!info) return new ToolResult(`No sub-agent dir for task_id '${id}'`, true);
|
|
300
|
+
return new ToolResult(JSON.stringify(info, null, 2));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- kill ----
|
|
304
|
+
|
|
305
|
+
_kill(taskId) {
|
|
306
|
+
const id = (taskId || "").trim();
|
|
307
|
+
if (!id) return new ToolResult("task_id required for kill", true);
|
|
308
|
+
const entry = this._runningTasks.get(id);
|
|
309
|
+
if (!entry) {
|
|
310
|
+
const info = this._readStatus(id);
|
|
311
|
+
if (info && info.status === "running") {
|
|
312
|
+
// Dir says running but not in _runningTasks — orphan from a previous
|
|
313
|
+
// process. Mark the file as killed so downstream readers see the
|
|
314
|
+
// truth. Nothing to abort at the JS level.
|
|
315
|
+
try { fs.writeFileSync(path.join(this._workspace.cwd, "sub_agents", id, "status.txt"), "killed", "utf-8"); } catch { /* ignore */ }
|
|
316
|
+
return new ToolResult(`No in-process handle for '${id}'; marked status.txt as killed (was orphan from a prior process).`);
|
|
317
|
+
}
|
|
318
|
+
return new ToolResult(`No running sub-agent with task_id '${id}' (already completed or never spawned).`, true);
|
|
319
|
+
}
|
|
320
|
+
entry.abortController.abort();
|
|
321
|
+
return new ToolResult(
|
|
322
|
+
`Kill signal sent to '${id}'. Cooperative abort — takes effect on the next event boundary. Poll or wait to confirm.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- list ----
|
|
327
|
+
|
|
328
|
+
_list() {
|
|
329
|
+
const baseDir = path.join(this._workspace.cwd, "sub_agents");
|
|
330
|
+
if (!fs.existsSync(baseDir)) {
|
|
331
|
+
return new ToolResult("No sub-agents have been spawned yet.");
|
|
332
|
+
}
|
|
333
|
+
let entries;
|
|
334
|
+
try { entries = fs.readdirSync(baseDir); }
|
|
335
|
+
catch { return new ToolResult("sub_agents/ not readable", true); }
|
|
336
|
+
|
|
337
|
+
const rows = [];
|
|
338
|
+
for (const name of entries.sort()) {
|
|
339
|
+
const info = this._readStatus(name);
|
|
340
|
+
if (!info) continue;
|
|
341
|
+
const inProcess = this._runningTasks.has(name);
|
|
342
|
+
rows.push({
|
|
343
|
+
task_id: name,
|
|
344
|
+
status: info.status,
|
|
345
|
+
in_process_handle: inProcess, // true → kill will abort; false → orphan
|
|
346
|
+
started_ago_s: info.age_s,
|
|
347
|
+
last_activity_s: info.idle_s,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (rows.length === 0) return new ToolResult("No sub-agents found.");
|
|
351
|
+
|
|
352
|
+
const active = rows.filter((r) => r.status === "running").length;
|
|
353
|
+
const summary = `${rows.length} sub-agent(s) (${active} running)`;
|
|
354
|
+
return new ToolResult(`${summary}\n\n${JSON.stringify(rows, null, 2)}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- helpers ----
|
|
358
|
+
|
|
359
|
+
_readStatus(taskId) {
|
|
360
|
+
const dir = path.join(this._workspace.cwd, "sub_agents", taskId);
|
|
361
|
+
if (!fs.existsSync(dir)) return null;
|
|
362
|
+
const statusPath = path.join(dir, "status.txt");
|
|
363
|
+
let status = "unknown";
|
|
364
|
+
let mtimeMs = 0;
|
|
365
|
+
let ageSec = 0;
|
|
366
|
+
let idleSec = 0;
|
|
367
|
+
try {
|
|
368
|
+
if (fs.existsSync(statusPath)) {
|
|
369
|
+
status = fs.readFileSync(statusPath, "utf-8").trim();
|
|
370
|
+
mtimeMs = fs.statSync(statusPath).mtimeMs;
|
|
371
|
+
}
|
|
372
|
+
} catch { /* ignore */ }
|
|
373
|
+
try {
|
|
374
|
+
const dirStat = fs.statSync(dir);
|
|
375
|
+
ageSec = Math.round((Date.now() - dirStat.ctimeMs) / 1000);
|
|
376
|
+
idleSec = mtimeMs ? Math.round((Date.now() - mtimeMs) / 1000) : ageSec;
|
|
377
|
+
} catch { /* ignore */ }
|
|
378
|
+
return { task_id: taskId, status, age_s: ageSec, idle_s: idleSec, dir: `sub_agents/${taskId}/` };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* B8: List currently-running sub-agents. Called by engine's phase-advance
|
|
383
|
+
* path to emit a `stale_subagents` pipeline event — the main agent's next
|
|
384
|
+
* turn sees the list and decides whether to kill each. Soft signal, not
|
|
385
|
+
* an automated kill, because phase_advance can fire from _maybeAutoAdvance
|
|
386
|
+
* unexpectedly and coupling the lifecycle would amplify blast radius.
|
|
387
|
+
*/
|
|
388
|
+
getRunningTaskIds() {
|
|
389
|
+
return Array.from(this._runningTasks.keys());
|
|
142
390
|
}
|
|
143
391
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BaseTool, ToolResult } from "./base.js";
|
|
4
|
+
import { BundleTree } from "../bundle-tree.js";
|
|
5
|
+
|
|
6
|
+
const CACHE_SUBDIR = path.join("cache", "bundles");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keyword RAG over a cached BundleTree. Requires a prior `document_chunk`
|
|
10
|
+
* call to have built a cached tree; this tool reads that cache and runs
|
|
11
|
+
* BundleTree.search() against it.
|
|
12
|
+
*
|
|
13
|
+
* Bigram (CJK 2-grams) + English word tokens. No embedding models, no
|
|
14
|
+
* vector DB — cheap and deterministic. Good enough for the
|
|
15
|
+
* regulation-to-rule retrieval pattern (rules have distinct technical
|
|
16
|
+
* vocabulary).
|
|
17
|
+
*/
|
|
18
|
+
export class BundleSearchTool extends BaseTool {
|
|
19
|
+
constructor(workspace) {
|
|
20
|
+
super();
|
|
21
|
+
this._workspace = workspace;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get name() { return "bundle_search"; }
|
|
25
|
+
|
|
26
|
+
get description() {
|
|
27
|
+
return (
|
|
28
|
+
"Search a cached BundleTree by keywords (CJK bigrams + English words). " +
|
|
29
|
+
"Returns the top-ranked chunks so you can pull evidence for a rule or " +
|
|
30
|
+
"answer a question about the bundle. Call `document_chunk` first to " +
|
|
31
|
+
"build the tree. If cache_key is omitted, uses the most recently " +
|
|
32
|
+
"built bundle in the workspace."
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get inputSchema() {
|
|
37
|
+
return {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
keywords: {
|
|
41
|
+
type: "array",
|
|
42
|
+
items: { type: "string" },
|
|
43
|
+
description: "Keywords or phrases to search for. CJK + English both supported.",
|
|
44
|
+
},
|
|
45
|
+
limit: {
|
|
46
|
+
type: "integer",
|
|
47
|
+
description: "Max results to return. Default 8.",
|
|
48
|
+
},
|
|
49
|
+
cache_key: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description:
|
|
52
|
+
"BundleTree cache file name (e.g. 'a1b2c3d4e5f6.json'). Omit to use the most recently built bundle.",
|
|
53
|
+
},
|
|
54
|
+
include_content: {
|
|
55
|
+
type: "boolean",
|
|
56
|
+
description: "Include full chunk content in output (default false — only IDs and snippets).",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
required: ["keywords"],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async execute(input) {
|
|
64
|
+
const keywords = Array.isArray(input.keywords) ? input.keywords : [];
|
|
65
|
+
const limit = Number.isFinite(input.limit) ? input.limit : 8;
|
|
66
|
+
const includeContent = input.include_content === true;
|
|
67
|
+
const cacheKey = input.cache_key || "";
|
|
68
|
+
|
|
69
|
+
if (keywords.length === 0) return new ToolResult("No keywords provided", true);
|
|
70
|
+
|
|
71
|
+
const cacheDir = path.join(this._workspace.cwd, CACHE_SUBDIR);
|
|
72
|
+
if (!fs.existsSync(cacheDir)) {
|
|
73
|
+
return new ToolResult(
|
|
74
|
+
"No bundle cache found. Call `document_chunk` first to build one.",
|
|
75
|
+
true,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let cachePath;
|
|
80
|
+
if (cacheKey) {
|
|
81
|
+
cachePath = path.join(cacheDir, cacheKey.endsWith(".json") ? cacheKey : `${cacheKey}.json`);
|
|
82
|
+
if (!fs.existsSync(cachePath)) {
|
|
83
|
+
return new ToolResult(`BundleTree cache not found: ${cacheKey}`, true);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
cachePath = this._findMostRecentCache(cacheDir);
|
|
87
|
+
if (!cachePath) {
|
|
88
|
+
return new ToolResult(
|
|
89
|
+
"No bundle cache found. Call `document_chunk` first to build one.",
|
|
90
|
+
true,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let tree;
|
|
96
|
+
try {
|
|
97
|
+
tree = BundleTree.fromJSON(JSON.parse(fs.readFileSync(cachePath, "utf-8")));
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return new ToolResult(`Corrupt bundle cache (${path.basename(cachePath)}): ${e.message}`, true);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const hits = tree.search(keywords, limit);
|
|
103
|
+
if (hits.length === 0) {
|
|
104
|
+
return new ToolResult(
|
|
105
|
+
`No matches for ${JSON.stringify(keywords)} in ${path.basename(cachePath)}.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const lines = [
|
|
110
|
+
`Found ${hits.length} chunk(s) matching ${JSON.stringify(keywords)} · source: ${path.basename(cachePath)}`,
|
|
111
|
+
"",
|
|
112
|
+
];
|
|
113
|
+
for (const ch of hits) {
|
|
114
|
+
const headerPath = (ch.header_path || []).join(" / ");
|
|
115
|
+
lines.push(
|
|
116
|
+
`[${ch.chunk_id}] ${ch.title} · ${ch.source_file} p.${ch.page_range[0]}-${ch.page_range[1]} · ${ch.tokens || 0}tok`,
|
|
117
|
+
);
|
|
118
|
+
if (headerPath) lines.push(` path: ${headerPath}`);
|
|
119
|
+
if (includeContent) {
|
|
120
|
+
lines.push(" ─");
|
|
121
|
+
lines.push((ch.content || "").split("\n").map((l) => ` ${l}`).join("\n"));
|
|
122
|
+
} else {
|
|
123
|
+
const snippet = (ch.content || "").replace(/\s+/g, " ").slice(0, 160);
|
|
124
|
+
if (snippet) lines.push(` ${snippet}${(ch.content || "").length > 160 ? "…" : ""}`);
|
|
125
|
+
}
|
|
126
|
+
lines.push("");
|
|
127
|
+
}
|
|
128
|
+
return new ToolResult(lines.join("\n"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_findMostRecentCache(cacheDir) {
|
|
132
|
+
let entries;
|
|
133
|
+
try { entries = fs.readdirSync(cacheDir); }
|
|
134
|
+
catch { return null; }
|
|
135
|
+
const candidates = entries
|
|
136
|
+
.filter((n) => n.endsWith(".json") && !n.endsWith(".classification.json"))
|
|
137
|
+
.map((n) => {
|
|
138
|
+
const full = path.join(cacheDir, n);
|
|
139
|
+
try { return { full, mtime: fs.statSync(full).mtimeMs }; }
|
|
140
|
+
catch { return null; }
|
|
141
|
+
})
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
144
|
+
return candidates[0]?.full || null;
|
|
145
|
+
}
|
|
146
|
+
}
|