klyro 1.0.0 → 1.0.2
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/dist/agent/anthropic-adapter.d.ts +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -19
- package/dist/mcp/registry.js +127 -8
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +20 -0
- package/dist/trace/writer.js +62 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- package/package.json +1 -1
|
@@ -12,12 +12,19 @@
|
|
|
12
12
|
* The compact result is a `ChildSummary` — a `ToolResult` the parent model
|
|
13
13
|
* can act on — never the full child transcript.
|
|
14
14
|
*/
|
|
15
|
-
import { run } from './runtime.js';
|
|
15
|
+
import { run, resolveSystemPrompt } from './runtime.js';
|
|
16
16
|
import { ScopedRegistry } from './scoped-registry.js';
|
|
17
17
|
import { globalBus } from '../events/bus.js';
|
|
18
18
|
import { TaskManager } from './task-manager.js';
|
|
19
19
|
import { WorkerSpawner } from './worker-spawner.js';
|
|
20
20
|
import { resolveCapabilities, DEFAULT_WRITE_TOOLS, DEFAULT_SPAWN_TOOLS, DEFAULT_DENIED_TOOLS, } from './capabilities.js';
|
|
21
|
+
import { forkChild, workerEntryPath } from './child-worker.js';
|
|
22
|
+
import { resolveAndFollowSymlinks } from '../policy/path-guard.js';
|
|
23
|
+
import { ensureGitRepo, createWorktree, mergeWorktree, removeWorktree, deleteBranch, } from './worktree-manager.js';
|
|
24
|
+
/** Concurrency budgets enforced in `spawnAgent` (CONCURRENCY_LIMIT on exceed). */
|
|
25
|
+
export const MAX_CONCURRENT_TASKS = 4;
|
|
26
|
+
export const MAX_TASKS_PER_PARENT = 8;
|
|
27
|
+
export const MAX_TOTAL_TASKS_PER_SESSION = 32;
|
|
21
28
|
/** Default agents a model can delegate to. */
|
|
22
29
|
export const BUILTIN_AGENTS = [
|
|
23
30
|
{
|
|
@@ -25,20 +32,20 @@ export const BUILTIN_AGENTS = [
|
|
|
25
32
|
description: 'Read-only reconnaissance: map the repo, find symbols and tests.',
|
|
26
33
|
readonly: true,
|
|
27
34
|
canSpawn: false,
|
|
28
|
-
allowedTools: ['read_file', '
|
|
35
|
+
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'git_status', 'git_log', 'git_diff', 'recent_files', 'imports_of', 'importers_of'],
|
|
29
36
|
},
|
|
30
37
|
{
|
|
31
38
|
id: 'implementer',
|
|
32
39
|
description: 'Write-capable worker for concrete, well-scoped coding tasks.',
|
|
33
40
|
canSpawn: false,
|
|
34
|
-
allowedTools: ['read_file', '
|
|
41
|
+
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'write_file', 'edit_file', 'multi_edit', 'apply_patch', 'shell_exec', 'git_status', 'git_log', 'git_diff', 'run_verify', 'todo_write'],
|
|
35
42
|
maxSteps: 60,
|
|
36
43
|
},
|
|
37
44
|
{
|
|
38
45
|
id: 'tester',
|
|
39
46
|
description: 'Runs verification and tests, reports failures with diagnostics.',
|
|
40
47
|
canSpawn: false,
|
|
41
|
-
allowedTools: ['read_file', '
|
|
48
|
+
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'shell_exec', 'run_verify', 'git_status', 'git_log', 'git_diff'],
|
|
42
49
|
maxTimeMs: 120_000,
|
|
43
50
|
},
|
|
44
51
|
{
|
|
@@ -46,7 +53,21 @@ export const BUILTIN_AGENTS = [
|
|
|
46
53
|
description: 'Read-only review of a diff or change set for bugs.',
|
|
47
54
|
readonly: true,
|
|
48
55
|
canSpawn: false,
|
|
49
|
-
allowedTools: ['read_file', '
|
|
56
|
+
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'git_status', 'git_diff', 'git_log', 'imports_of', 'importers_of', 'find_symbol'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'debugger',
|
|
60
|
+
description: 'Read-only diagnosis: inspect failures, traces, and code paths without modifying files.',
|
|
61
|
+
readonly: true,
|
|
62
|
+
canSpawn: false,
|
|
63
|
+
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'imports_of', 'importers_of', 'git_status', 'git_log', 'git_diff', 'recent_files', 'run_verify'],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'docs',
|
|
67
|
+
description: 'Read-only documentation lookup: find and summarise docs, READMEs, and code structure.',
|
|
68
|
+
readonly: true,
|
|
69
|
+
canSpawn: false,
|
|
70
|
+
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'recent_files'],
|
|
50
71
|
},
|
|
51
72
|
];
|
|
52
73
|
/** Map a runtime `RunResult.status` to a task status. */
|
|
@@ -66,16 +87,61 @@ function mapResultStatus(status) {
|
|
|
66
87
|
return 'failed';
|
|
67
88
|
}
|
|
68
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Build a `subtask.progress` note for one finished tool call.
|
|
92
|
+
* Pure — unit-tested directly (see agent-tools.test.ts).
|
|
93
|
+
*/
|
|
94
|
+
export function progressNote(step, tool, isError) {
|
|
95
|
+
return `step ${step}: ${tool} ${isError ? 'ERR' : 'ok'}`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the `RunOptions.onEvent` handler the orchestrator passes into each
|
|
99
|
+
* child's run options. Emits at most one `subtask.progress` per tool call:
|
|
100
|
+
* a `tool_result` is only mirrored when its `tool_call_end` was observed
|
|
101
|
+
* first, so duplicate/late results can never double-emit. (The note needs
|
|
102
|
+
* the ok/ERR outcome, which only `tool_result` carries — `tool_call_end`
|
|
103
|
+
* alone cannot build it — hence the end-gated result throttle.)
|
|
104
|
+
*/
|
|
105
|
+
export function createSubtaskProgressEmitter(opts) {
|
|
106
|
+
let step = 0;
|
|
107
|
+
const ended = new Set();
|
|
108
|
+
return (ev) => {
|
|
109
|
+
if (ev.kind === 'step_start') {
|
|
110
|
+
step = ev.step;
|
|
111
|
+
}
|
|
112
|
+
else if (ev.kind === 'tool_call_end') {
|
|
113
|
+
ended.add(ev.id);
|
|
114
|
+
}
|
|
115
|
+
else if (ev.kind === 'tool_result') {
|
|
116
|
+
if (!ended.has(ev.id))
|
|
117
|
+
return;
|
|
118
|
+
ended.delete(ev.id);
|
|
119
|
+
globalBus.emit({
|
|
120
|
+
type: 'subtask.progress',
|
|
121
|
+
ts: Date.now(),
|
|
122
|
+
sessionId: opts.sessionId,
|
|
123
|
+
taskId: opts.taskId,
|
|
124
|
+
note: progressNote(step, ev.name, ev.isError),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
69
129
|
export class AgentOrchestrator {
|
|
70
130
|
sessionId;
|
|
71
131
|
deps;
|
|
72
132
|
taskManager;
|
|
73
133
|
workerSpawner;
|
|
134
|
+
isTui;
|
|
135
|
+
/** Per-task spawn metadata: capability drops + worktree placement. */
|
|
136
|
+
taskMeta = new Map();
|
|
137
|
+
/** Finished summaries not yet drained via `drainCompletions`. */
|
|
138
|
+
undrained = new Map();
|
|
74
139
|
constructor(opts) {
|
|
75
140
|
this.sessionId = opts.sessionId;
|
|
76
141
|
this.deps = opts.deps;
|
|
77
142
|
this.taskManager = opts.taskManager ?? new TaskManager({ sessionId: opts.sessionId });
|
|
78
143
|
this.workerSpawner = opts.workerSpawner ?? new WorkerSpawner();
|
|
144
|
+
this.isTui = opts.isTui ?? false;
|
|
79
145
|
}
|
|
80
146
|
listAgents() {
|
|
81
147
|
return [...BUILTIN_AGENTS];
|
|
@@ -98,6 +164,10 @@ export class AgentOrchestrator {
|
|
|
98
164
|
const s = this.taskManager.toSummary(r);
|
|
99
165
|
return r.error ? { ...s, error: { code: r.error.code, message: r.error.message } } : s;
|
|
100
166
|
},
|
|
167
|
+
drainCompletions: () => this.drainCompletions(),
|
|
168
|
+
waitForTasks: (taskIds, timeoutMs) => this.waitForTasks(taskIds, timeoutMs),
|
|
169
|
+
cancelTask: (taskId) => this.cancelTask(taskId),
|
|
170
|
+
applyTask: (taskId) => this.applyTask(taskId),
|
|
101
171
|
};
|
|
102
172
|
}
|
|
103
173
|
/** Compute a child's effective capabilities from the parent's own. */
|
|
@@ -111,13 +181,14 @@ export class AgentOrchestrator {
|
|
|
111
181
|
spawnTools: DEFAULT_SPAWN_TOOLS,
|
|
112
182
|
denied: DEFAULT_DENIED_TOOLS,
|
|
113
183
|
};
|
|
114
|
-
const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth });
|
|
184
|
+
const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth, parentAllowedPaths: parent.allowedPaths });
|
|
115
185
|
return resolved;
|
|
116
186
|
}
|
|
117
187
|
/**
|
|
118
|
-
* Spawn a child agent
|
|
119
|
-
*
|
|
120
|
-
*
|
|
188
|
+
* Spawn a child agent asynchronously: start the child worker and return
|
|
189
|
+
* IMMEDIATELY with `{ status: 'running' }`. Completion/failure bus emits
|
|
190
|
+
* and `taskManager.finish` happen in the worker closure; the parent
|
|
191
|
+
* observes them via `task_wait` / `task_get` / `drainCompletions`.
|
|
121
192
|
*/
|
|
122
193
|
async spawnAgent(input, parent) {
|
|
123
194
|
const def = this.getAgent(input.agent);
|
|
@@ -139,10 +210,67 @@ export class AgentOrchestrator {
|
|
|
139
210
|
},
|
|
140
211
|
};
|
|
141
212
|
}
|
|
213
|
+
// Concurrency budgets — enforced before creating the task.
|
|
214
|
+
const all = this.taskManager.list();
|
|
215
|
+
const runningCount = all.filter((t) => t.status === 'running').length;
|
|
216
|
+
if (runningCount >= MAX_CONCURRENT_TASKS) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: { code: 'CONCURRENCY_LIMIT', message: `maxConcurrentTasks (${MAX_CONCURRENT_TASKS}) reached` },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const perParentCount = all.filter((t) => t.parentTaskId === parent.taskId).length;
|
|
223
|
+
if (perParentCount >= MAX_TASKS_PER_PARENT) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
error: { code: 'CONCURRENCY_LIMIT', message: `maxTasksPerParent (${MAX_TASKS_PER_PARENT}) reached` },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (all.length >= MAX_TOTAL_TASKS_PER_SESSION) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: { code: 'CONCURRENCY_LIMIT', message: `maxTotalTasksPerSession (${MAX_TOTAL_TASKS_PER_SESSION}) reached` },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// Spawn cwd containment (S6): an explicit cwd must stay inside the parent.
|
|
236
|
+
let baseCwd = parent.cwd;
|
|
237
|
+
if (input.cwd) {
|
|
238
|
+
try {
|
|
239
|
+
baseCwd = (await resolveAndFollowSymlinks(parent.cwd, input.cwd)).resolved;
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: {
|
|
245
|
+
code: 'PATH_ESCAPE',
|
|
246
|
+
message: err instanceof Error ? err.message : `cwd escapes parent: ${input.cwd}`,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
142
251
|
const registryTools = new Set(this.deps.registry.list().map((t) => t.name));
|
|
143
252
|
const resolved = this.resolveChild(def, parent, registryTools);
|
|
144
253
|
const childModel = input.model ?? resolved.model ?? parent.model;
|
|
145
|
-
|
|
254
|
+
// Worktree isolation: write-capable children get their own worktree —
|
|
255
|
+
// including when the spawn carries an explicit cwd (the worktree is
|
|
256
|
+
// then rooted at the resolved explicit cwd, which containment above
|
|
257
|
+
// already pinned inside the parent). Readonly agents keep the resolved
|
|
258
|
+
// cwd with no worktree. A write-capable spawn outside a git repo is
|
|
259
|
+
// rejected outright.
|
|
260
|
+
const writeCapable = [...resolved.allowed].some((t) => DEFAULT_WRITE_TOOLS.has(t));
|
|
261
|
+
let childCwd = baseCwd;
|
|
262
|
+
let worktree;
|
|
263
|
+
let repoCwd;
|
|
264
|
+
if (!resolved.readonly && writeCapable) {
|
|
265
|
+
const isRepo = await ensureGitRepo(baseCwd).catch(() => false);
|
|
266
|
+
if (!isRepo) {
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
error: { code: 'WRITES_REQUIRE_GIT', message: 'parallel write agents require a git repository for worktree isolation' },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
repoCwd = baseCwd;
|
|
273
|
+
}
|
|
146
274
|
const createOpts = {
|
|
147
275
|
agentName: def.id,
|
|
148
276
|
cwd: childCwd,
|
|
@@ -154,8 +282,42 @@ export class AgentOrchestrator {
|
|
|
154
282
|
abortOnParent: undefined, // wired below via the parent signal
|
|
155
283
|
};
|
|
156
284
|
const record = this.taskManager.create(createOpts);
|
|
285
|
+
// Worktree creation needs the task id (branch `klyro/<taskId>`), so it
|
|
286
|
+
// happens after `create`. On failure the task is marked failed and the
|
|
287
|
+
// spawn returns the error.
|
|
288
|
+
if (repoCwd !== undefined) {
|
|
289
|
+
try {
|
|
290
|
+
worktree = await createWorktree({ repoCwd, taskId: record.id });
|
|
291
|
+
childCwd = worktree.worktreePath;
|
|
292
|
+
record.cwd = childCwd;
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
this.taskManager.finish(record.id, 'failed', {
|
|
296
|
+
error: { code: 'WORKTREE_FAILED', message: err instanceof Error ? err.message : String(err) },
|
|
297
|
+
});
|
|
298
|
+
const failed = this.taskManager.get(record.id);
|
|
299
|
+
this.stashSummary(failed, def, resolved.dropped);
|
|
300
|
+
this.taskMeta.set(record.id, { def, dropped: resolved.dropped });
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: { code: 'WORKTREE_FAILED', message: err instanceof Error ? err.message : String(err) },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
this.taskMeta.set(record.id, worktree ? { def, dropped: resolved.dropped, worktree, repoCwd } : { def, dropped: resolved.dropped });
|
|
157
308
|
const childRegistry = new ScopedRegistry(this.deps.registry, resolved.allowed);
|
|
158
309
|
const childDeps = { ...this.deps, registry: childRegistry };
|
|
310
|
+
const childRef = {
|
|
311
|
+
taskId: record.id,
|
|
312
|
+
...(parent.taskId !== undefined ? { parentTaskId: parent.taskId } : {}),
|
|
313
|
+
sessionId: this.sessionId,
|
|
314
|
+
cwd: childCwd,
|
|
315
|
+
depth: childDepth,
|
|
316
|
+
maxDepth,
|
|
317
|
+
allowedTools: resolved.allowed,
|
|
318
|
+
...(childModel !== undefined ? { model: childModel } : {}),
|
|
319
|
+
...(resolved.allowedPaths !== undefined ? { allowedPaths: resolved.allowedPaths } : {}),
|
|
320
|
+
};
|
|
159
321
|
const childOptions = {
|
|
160
322
|
task: input.task,
|
|
161
323
|
cwd: childCwd,
|
|
@@ -165,6 +327,15 @@ export class AgentOrchestrator {
|
|
|
165
327
|
maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
|
|
166
328
|
signal: record.abortController.signal,
|
|
167
329
|
nonInteractive: true,
|
|
330
|
+
// Mid-life progress: mirror each finished tool call as one
|
|
331
|
+
// `subtask.progress` bus event (see createSubtaskProgressEmitter).
|
|
332
|
+
// Process-isolated children don't run this closure — only the
|
|
333
|
+
// in-process path reports mid-life progress.
|
|
334
|
+
onEvent: createSubtaskProgressEmitter({ taskId: record.id, sessionId: this.sessionId }),
|
|
335
|
+
// Grandchildren: only children that canSpawn receive the bridge —
|
|
336
|
+
// otherwise tools see NO_ORCHESTRATOR as before.
|
|
337
|
+
...(resolved.canSpawn ? { agentBridge: this.bridgeFor(childRef) } : {}),
|
|
338
|
+
...(def.maxTokens !== undefined ? { maxTokens: def.maxTokens } : {}),
|
|
168
339
|
};
|
|
169
340
|
// Lifecycle events on the shared bus (mirror TaskManager transitions).
|
|
170
341
|
globalBus.emit({
|
|
@@ -177,65 +348,261 @@ export class AgentOrchestrator {
|
|
|
177
348
|
depth: childDepth,
|
|
178
349
|
...(typeof childModel === 'string' ? { model: childModel } : {}),
|
|
179
350
|
});
|
|
180
|
-
|
|
181
|
-
|
|
351
|
+
// G2 — process isolation for headless sub-agents. In-process is the
|
|
352
|
+
// fallback (and mandatory for TUI children — see OrchestratorOpts.isTui),
|
|
353
|
+
// and opt-out via KLYRO_WORKER=0 for tests/dev.
|
|
354
|
+
const useProcessIsolation = !this.isTui && process.env.KLYRO_WORKER !== '0';
|
|
355
|
+
this.workerSpawner.spawn(async (signal) => {
|
|
356
|
+
// Both the in-process path and the forked child resolve to the same
|
|
357
|
+
// minimal outcome shape the settle tail needs.
|
|
358
|
+
let childOutcome;
|
|
182
359
|
try {
|
|
183
|
-
|
|
360
|
+
if (useProcessIsolation) {
|
|
361
|
+
const sysPrompt = resolveSystemPrompt(this.deps.systemPrompt, { cwd: childCwd });
|
|
362
|
+
// Splice the volatile telemetry suffix into the stable prefix so the
|
|
363
|
+
// child's provider sees one system string. Telemetry is best-effort
|
|
364
|
+
// inside the child (it re-emits); the goal here is parity, not
|
|
365
|
+
// perfect replay.
|
|
366
|
+
const systemPrompt = sysPrompt.suffix ? `${sysPrompt.system}\n${sysPrompt.suffix}` : sysPrompt.system;
|
|
367
|
+
const payload = {
|
|
368
|
+
cwd: childCwd,
|
|
369
|
+
task: input.task,
|
|
370
|
+
// A concrete provider model must reach the child — 'inherit' only
|
|
371
|
+
// exists to defer resolution inside the parent's run().
|
|
372
|
+
model: (childModel ?? parent.model),
|
|
373
|
+
systemPrompt,
|
|
374
|
+
agentId: def.id,
|
|
375
|
+
maxSteps: def.maxSteps,
|
|
376
|
+
maxCost: def.maxCost,
|
|
377
|
+
maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
|
|
378
|
+
...(def.maxTokens !== undefined ? { maxTokens: def.maxTokens } : {}),
|
|
379
|
+
};
|
|
380
|
+
const cr = await forkChild(workerEntryPath(), payload, { signal });
|
|
381
|
+
childOutcome = cr; // ChildResult is the minimal settle shape
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const r = await run(childOptions, childDeps);
|
|
385
|
+
childOutcome = { status: r.status, steps: r.steps, toolCalls: r.toolCalls, finalText: r.finalText };
|
|
386
|
+
}
|
|
184
387
|
}
|
|
185
388
|
catch (err) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
389
|
+
const isCrash = err instanceof Error && err.name === 'ChildCrashError';
|
|
390
|
+
const crashErr = isCrash ? err : undefined;
|
|
391
|
+
this.settleChild(record.id, 'failed', {
|
|
392
|
+
error: {
|
|
393
|
+
code: 'CHILD_CRASH',
|
|
394
|
+
message: isCrash
|
|
395
|
+
? `child process isolated failure (${crashErr?.likelyCause ?? 'exit'}): ${crashErr?.message ?? ''}`.trim()
|
|
396
|
+
: err instanceof Error ? err.message : String(err),
|
|
397
|
+
},
|
|
398
|
+
}, { steps: 0, toolCalls: 0 });
|
|
189
399
|
return;
|
|
190
400
|
}
|
|
191
|
-
const status = mapResultStatus(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
error: status === 'failed'
|
|
199
|
-
? { code: mapFailureCode(result.status), message: result.finalText?.slice(0, 300) ?? 'child failed' }
|
|
200
|
-
: undefined,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
const final = this.taskManager.get(record.id);
|
|
204
|
-
const durationMs = final.finishedAt ? final.finishedAt - final.startedAt : 0;
|
|
205
|
-
if (status === 'succeeded') {
|
|
206
|
-
globalBus.emit({
|
|
207
|
-
type: 'subtask.completed',
|
|
208
|
-
ts: Date.now(),
|
|
209
|
-
sessionId: this.sessionId,
|
|
210
|
-
taskId: record.id,
|
|
211
|
-
status: 'succeeded',
|
|
212
|
-
durationMs,
|
|
213
|
-
steps: result?.steps ?? 0,
|
|
214
|
-
toolCalls: result?.toolCalls ?? 0,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
globalBus.emit({
|
|
219
|
-
type: 'subtask.failed',
|
|
220
|
-
ts: Date.now(),
|
|
221
|
-
sessionId: this.sessionId,
|
|
222
|
-
taskId: record.id,
|
|
223
|
-
status: status,
|
|
224
|
-
durationMs,
|
|
225
|
-
error: final.error ? { code: final.error.code, message: final.error.message } : undefined,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
401
|
+
const status = mapResultStatus(childOutcome.status);
|
|
402
|
+
this.settleChild(record.id, status, {
|
|
403
|
+
summary: [`status: ${status}`, `steps: ${childOutcome.steps}`, `toolCalls: ${childOutcome.toolCalls}`],
|
|
404
|
+
error: status === 'failed'
|
|
405
|
+
? { code: mapFailureCode(childOutcome.status), message: childOutcome.finalText?.slice(0, 300) ?? 'child failed' }
|
|
406
|
+
: undefined,
|
|
407
|
+
}, { steps: childOutcome.steps, toolCalls: childOutcome.toolCalls });
|
|
228
408
|
}, { label: `agent:${def.id}` });
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
409
|
+
// Async contract: return immediately with the running summary. The final
|
|
410
|
+
// text is omitted while running; observe via task_wait / drainCompletions.
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
value: { taskId: record.id, agentName: def.id, status: 'running', durationMs: 0, changedFiles: [] },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Settle a child in the worker closure: finish the task (idempotent —
|
|
418
|
+
* a TaskManager timeout/cancel that fired first wins), emit the terminal
|
|
419
|
+
* bus event, stash the summary for `drainCompletions`, and best-effort
|
|
420
|
+
* remove the worktree unless the child succeeded (merge happens later in
|
|
421
|
+
* `task_apply`).
|
|
422
|
+
*/
|
|
423
|
+
settleChild(taskId, status, patch, counts) {
|
|
424
|
+
const existing = this.taskManager.get(taskId);
|
|
425
|
+
if (!existing)
|
|
426
|
+
return;
|
|
427
|
+
let finalStatus = status;
|
|
428
|
+
if (existing.status === 'queued' || existing.status === 'running') {
|
|
429
|
+
this.taskManager.finish(taskId, status, patch);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
// Already terminal (timeout/cancel raced the run) — preserve it.
|
|
433
|
+
finalStatus = existing.status;
|
|
434
|
+
}
|
|
435
|
+
const final = this.taskManager.get(taskId);
|
|
436
|
+
this.stashSummary(final);
|
|
437
|
+
const durationMs = final.finishedAt ? final.finishedAt - final.startedAt : 0;
|
|
438
|
+
const error = final.error ? { code: final.error.code, message: final.error.message } : undefined;
|
|
439
|
+
const steps = counts?.steps ?? 0;
|
|
440
|
+
const toolCalls = counts?.toolCalls ?? 0;
|
|
441
|
+
if (finalStatus === 'succeeded') {
|
|
442
|
+
globalBus.emit({
|
|
443
|
+
type: 'subtask.completed',
|
|
444
|
+
ts: Date.now(),
|
|
445
|
+
sessionId: this.sessionId,
|
|
446
|
+
taskId,
|
|
447
|
+
status: 'succeeded',
|
|
448
|
+
durationMs,
|
|
449
|
+
steps,
|
|
450
|
+
toolCalls,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
else if (finalStatus === 'cancelled') {
|
|
454
|
+
globalBus.emit({
|
|
455
|
+
type: 'subtask.cancelled',
|
|
456
|
+
ts: Date.now(),
|
|
457
|
+
sessionId: this.sessionId,
|
|
458
|
+
taskId,
|
|
459
|
+
status: 'cancelled',
|
|
460
|
+
durationMs,
|
|
461
|
+
...(error ? { error } : {}),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
else if (finalStatus === 'timed_out') {
|
|
465
|
+
globalBus.emit({
|
|
466
|
+
type: 'subtask.timed_out',
|
|
467
|
+
ts: Date.now(),
|
|
468
|
+
sessionId: this.sessionId,
|
|
469
|
+
taskId,
|
|
470
|
+
status: 'timed_out',
|
|
471
|
+
durationMs,
|
|
472
|
+
...(error ? { error } : {}),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
globalBus.emit({
|
|
477
|
+
type: 'subtask.failed',
|
|
478
|
+
ts: Date.now(),
|
|
479
|
+
sessionId: this.sessionId,
|
|
480
|
+
taskId,
|
|
481
|
+
status: finalStatus,
|
|
482
|
+
durationMs,
|
|
483
|
+
...(error ? { error } : {}),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
// Non-success terminal states never merge — drop the worktree.
|
|
487
|
+
const meta = this.taskMeta.get(taskId);
|
|
488
|
+
if (meta?.worktree && meta.repoCwd && finalStatus !== 'succeeded') {
|
|
489
|
+
const { worktree, repoCwd } = meta;
|
|
490
|
+
void removeWorktree({ repoCwd, worktreePath: worktree.worktreePath, force: true }).catch(() => undefined);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** Build and stash the finished summary for `drainCompletions`. */
|
|
494
|
+
stashSummary(r, def, dropped) {
|
|
495
|
+
const meta = this.taskMeta.get(r.id);
|
|
496
|
+
const d = def ?? meta?.def;
|
|
497
|
+
if (!d)
|
|
498
|
+
return;
|
|
499
|
+
this.undrained.set(r.id, this.toChildSummary(r, d, dropped ?? meta?.dropped ?? []));
|
|
500
|
+
}
|
|
501
|
+
/** Completed-then-undrained child summaries; each drained exactly once. */
|
|
502
|
+
drainCompletions() {
|
|
503
|
+
const out = [...this.undrained.values()];
|
|
504
|
+
this.undrained.clear();
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Await each task's `done` up to `timeoutMs` per task. Unsettled entries
|
|
509
|
+
* come back with status `'running'` and a running summary — the underlying
|
|
510
|
+
* task is NOT cancelled.
|
|
511
|
+
*/
|
|
512
|
+
async waitForTasks(taskIds, timeoutMs) {
|
|
513
|
+
const tasks = await Promise.all(taskIds.map(async (taskId) => {
|
|
514
|
+
const rec = this.taskManager.get(taskId);
|
|
515
|
+
if (!rec) {
|
|
516
|
+
return { taskId, status: 'failed', error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
|
|
517
|
+
}
|
|
518
|
+
const meta = this.taskMeta.get(taskId);
|
|
519
|
+
const settled = await this.awaitDone(rec, timeoutMs);
|
|
520
|
+
const summary = meta ? this.toChildSummary(settled, meta.def, meta.dropped) : undefined;
|
|
521
|
+
const entry = { taskId, status: settled.status };
|
|
522
|
+
if (summary)
|
|
523
|
+
entry.summary = summary;
|
|
524
|
+
if (settled.error)
|
|
525
|
+
entry.error = { code: settled.error.code, message: settled.error.message };
|
|
526
|
+
return entry;
|
|
527
|
+
}));
|
|
528
|
+
return { tasks };
|
|
529
|
+
}
|
|
530
|
+
async awaitDone(rec, timeoutMs) {
|
|
531
|
+
if (timeoutMs === undefined)
|
|
532
|
+
return rec.done;
|
|
533
|
+
let timer;
|
|
534
|
+
try {
|
|
535
|
+
return await Promise.race([
|
|
536
|
+
rec.done,
|
|
537
|
+
new Promise((resolve) => {
|
|
538
|
+
timer = setTimeout(() => resolve(this.taskManager.get(rec.id)), timeoutMs);
|
|
539
|
+
}),
|
|
540
|
+
]);
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
if (timer)
|
|
544
|
+
clearTimeout(timer);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/** Signal abort for a live task via `taskManager.cancel` (in-process workers; no subprocesses yet). Idempotent on terminal tasks. */
|
|
548
|
+
cancelTask(taskId) {
|
|
549
|
+
const rec = this.taskManager.get(taskId);
|
|
550
|
+
if (!rec) {
|
|
551
|
+
return { ok: false, error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
|
|
552
|
+
}
|
|
553
|
+
this.taskManager.cancel(taskId, 'stopped via task_stop');
|
|
554
|
+
const final = this.taskManager.get(taskId);
|
|
555
|
+
return { ok: true, value: { taskId, status: final.status } };
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Apply a succeeded task: merge its worktree branch into the parent tree
|
|
559
|
+
* (or acknowledge a shared-cwd task whose edits already landed) and emit
|
|
560
|
+
* `subtask.merged`. Non-succeeded tasks error with NOT_READY; merge
|
|
561
|
+
* conflicts error with MERGE_CONFLICT (merge already aborted, branch kept).
|
|
562
|
+
*/
|
|
563
|
+
async applyTask(taskId) {
|
|
564
|
+
const rec = this.taskManager.get(taskId);
|
|
565
|
+
if (!rec) {
|
|
566
|
+
return { ok: false, error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
|
|
567
|
+
}
|
|
568
|
+
if (rec.status !== 'succeeded') {
|
|
232
569
|
return {
|
|
233
570
|
ok: false,
|
|
234
|
-
error: { code: '
|
|
571
|
+
error: { code: 'NOT_READY', message: `task ${taskId} is ${rec.status}, not succeeded` },
|
|
235
572
|
};
|
|
236
573
|
}
|
|
237
|
-
|
|
574
|
+
const meta = this.taskMeta.get(taskId);
|
|
575
|
+
const summary = meta ? this.toChildSummary(rec, meta.def, meta.dropped) : this.toChildSummary(rec, this.getAgent(rec.agentName) ?? { id: rec.agentName, description: '' }, []);
|
|
576
|
+
// merged=true only when a real worktree branch merge ran; shared-cwd
|
|
577
|
+
// tasks (edits already in the parent tree) are an acknowledgement.
|
|
578
|
+
let didMerge = false;
|
|
579
|
+
if (meta?.worktree && meta.repoCwd) {
|
|
580
|
+
const merged = await mergeWorktree({ repoCwd: meta.repoCwd, branch: meta.worktree.branch });
|
|
581
|
+
if (!merged.merged) {
|
|
582
|
+
return {
|
|
583
|
+
ok: false,
|
|
584
|
+
error: {
|
|
585
|
+
code: 'MERGE_CONFLICT',
|
|
586
|
+
message: `merge of ${meta.worktree.branch} conflicted`,
|
|
587
|
+
details: { conflictFiles: merged.conflictFiles },
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
await removeWorktree({ repoCwd: meta.repoCwd, worktreePath: meta.worktree.worktreePath }).catch(() => undefined);
|
|
592
|
+
await deleteBranch({ repoCwd: meta.repoCwd, branch: meta.worktree.branch });
|
|
593
|
+
didMerge = true;
|
|
594
|
+
}
|
|
595
|
+
globalBus.emit({
|
|
596
|
+
type: 'subtask.merged',
|
|
597
|
+
ts: Date.now(),
|
|
598
|
+
sessionId: this.sessionId,
|
|
599
|
+
taskId,
|
|
600
|
+
changedFiles: summary.changedFiles,
|
|
601
|
+
merged: didMerge,
|
|
602
|
+
});
|
|
603
|
+
return { ok: true, value: summary };
|
|
238
604
|
}
|
|
605
|
+
/** Compact summary for a task record (also used by task_wait). */
|
|
239
606
|
toChildSummary(r, def, dropped) {
|
|
240
607
|
const s = this.taskManager.toSummary(r);
|
|
241
608
|
return {
|
|
@@ -28,6 +28,8 @@ export type StreamEvent = {
|
|
|
28
28
|
usage?: {
|
|
29
29
|
input: number;
|
|
30
30
|
output: number;
|
|
31
|
+
cacheRead?: number;
|
|
32
|
+
cacheWrite?: number;
|
|
31
33
|
};
|
|
32
34
|
} | {
|
|
33
35
|
kind: 'tool_call_start';
|
|
@@ -56,6 +58,12 @@ export interface ToolDefinition {
|
|
|
56
58
|
export interface CallRequest {
|
|
57
59
|
model: string;
|
|
58
60
|
system?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Volatile prompt suffix (Level-7 runtime telemetry). Adapters keep it
|
|
63
|
+
* out of the cacheable prefix: Anthropic sends the array system form
|
|
64
|
+
* (breakpoint on the stable part), OpenAI appends it with a separator.
|
|
65
|
+
*/
|
|
66
|
+
systemSuffix?: string;
|
|
59
67
|
messages: Message[];
|
|
60
68
|
tools: ToolDefinition[];
|
|
61
69
|
maxTokens?: number;
|
|
@@ -108,8 +108,12 @@ function zodFieldSchema(s) {
|
|
|
108
108
|
*/
|
|
109
109
|
export function buildChatCompletionsBody(req) {
|
|
110
110
|
const messages = [];
|
|
111
|
+
// OpenAI has no system array form: the volatile suffix rides along as a
|
|
112
|
+
// plain concatenation (behavior-preserving when absent).
|
|
111
113
|
if (req.system)
|
|
112
|
-
messages.push({ role: 'system', content: req.system });
|
|
114
|
+
messages.push({ role: 'system', content: req.systemSuffix ? `${req.system}\n\n${req.systemSuffix}` : req.system });
|
|
115
|
+
else if (req.systemSuffix)
|
|
116
|
+
messages.push({ role: 'system', content: req.systemSuffix });
|
|
113
117
|
for (const m of req.messages) {
|
|
114
118
|
if (m.role === 'assistant') {
|
|
115
119
|
const text = m.content.filter((b) => b.kind === 'text').map((b) => b.text).join('');
|
|
@@ -202,11 +206,16 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
202
206
|
req.signal?.removeEventListener('abort', onAbort);
|
|
203
207
|
const rawErr = await res.text().catch(() => '');
|
|
204
208
|
const errText = redact(rawErr).slice(0, 500);
|
|
205
|
-
|
|
209
|
+
// Context overflow is a client-side budget problem, not a transient
|
|
210
|
+
// failure: surface a dedicated code (never retryable here — the runtime
|
|
211
|
+
// owns the compress-and-retry recovery) so callers can distinguish it
|
|
212
|
+
// from 429/5xx backoff cases.
|
|
213
|
+
const isOverflow = res.status === 413 || /request_too_large|too_large|prompt_too_long|context_length|maximum context length/i.test(rawErr);
|
|
214
|
+
const retryable = !isOverflow && (res.status >= 500 || res.status === 429);
|
|
206
215
|
const retryAfterMs = retryable ? parseRetryAfterMs(res.headers?.get('retry-after')) : undefined;
|
|
207
216
|
yield {
|
|
208
217
|
kind: 'error',
|
|
209
|
-
code: `HTTP_${res.status}`,
|
|
218
|
+
code: isOverflow ? 'REQUEST_TOO_LARGE' : `HTTP_${res.status}`,
|
|
210
219
|
message: `provider returned ${res.status}: ${errText}`,
|
|
211
220
|
retryable,
|
|
212
221
|
status: String(res.status),
|
package/dist/agent/retry.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* delay = min(maxMs, baseMs * 2^attempt) ± jitter
|
|
17
17
|
*
|
|
18
|
-
* The default policy matches the L6 plan:
|
|
18
|
+
* The default policy matches the L6 plan: 5 attempts, 500ms base, 8s cap.
|
|
19
19
|
*/
|
|
20
20
|
import type { ProviderAdapter } from './provider-adapter.js';
|
|
21
21
|
export interface RetryOptions {
|