klyro 0.1.62 → 1.0.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/dist/agent/anthropic-adapter.d.ts +30 -9
- package/dist/agent/anthropic-adapter.js +107 -49
- package/dist/agent/capabilities.d.ts +122 -0
- package/dist/agent/capabilities.js +150 -0
- package/dist/agent/orchestrator.d.ts +131 -0
- package/dist/agent/orchestrator.js +269 -0
- package/dist/agent/provider-adapter.d.ts +9 -0
- package/dist/agent/provider-adapter.js +85 -39
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +12 -1
- package/dist/agent/retry.js +19 -1
- package/dist/agent/runtime.d.ts +23 -1
- package/dist/agent/runtime.js +236 -26
- package/dist/agent/scoped-registry.d.ts +22 -0
- package/dist/agent/scoped-registry.js +42 -0
- package/dist/agent/task-manager.d.ts +115 -0
- package/dist/agent/task-manager.js +250 -0
- package/dist/agent/worker-spawner.d.ts +17 -12
- package/dist/agent/worker-spawner.js +26 -20
- package/dist/cli/config.d.ts +21 -0
- package/dist/cli/config.js +31 -0
- package/dist/cli/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/repl.js +72 -4
- package/dist/cli/run.d.ts +3 -0
- package/dist/cli/run.js +127 -7
- package/dist/context/klyro-md.d.ts +6 -0
- package/dist/context/klyro-md.js +21 -15
- package/dist/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +71 -0
- package/dist/index.js +4 -0
- package/dist/mcp/client.d.ts +53 -0
- package/dist/mcp/client.js +225 -0
- package/dist/mcp/config.d.ts +30 -0
- package/dist/mcp/config.js +82 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +50 -0
- package/dist/mcp/registry.js +172 -0
- package/dist/mcp/schema.d.ts +11 -0
- package/dist/mcp/schema.js +46 -0
- package/dist/persistence/store.d.ts +1 -1
- package/dist/policy/approval.d.ts +27 -6
- package/dist/policy/approval.js +36 -8
- package/dist/policy/engine.d.ts +13 -0
- package/dist/policy/engine.js +31 -2
- package/dist/policy/patterns.d.ts +16 -0
- package/dist/policy/patterns.js +26 -0
- package/dist/tools/agent/spawn-agent.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -0
- package/dist/tools/agent/task-get.d.ts +8 -0
- package/dist/tools/agent/task-get.js +40 -0
- package/dist/tools/agent/task-list.d.ts +4 -0
- package/dist/tools/agent/task-list.js +41 -0
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/types.d.ts +12 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentOrchestrator — spawns child agents and turns them into tasks.
|
|
3
|
+
*
|
|
4
|
+
* The lowest-level way to run Klyro is `run(options, deps)` (one agent,
|
|
5
|
+
* one loop). Orchestration layers on top of that: a parent agent calls
|
|
6
|
+
* `spawn_agent`, which resolves the target `AgentDefinition`, computes its
|
|
7
|
+
* effective capabilities (via `resolveCapabilities`), creates a
|
|
8
|
+
* `TaskRecord`, and runs a *scoped* child loop. The child runs in-process
|
|
9
|
+
* with a `ScopedRegistry` (narrowed tool set), an optional model override,
|
|
10
|
+
* a depth cap, and an AbortController wired to the parent's signal.
|
|
11
|
+
*
|
|
12
|
+
* The compact result is a `ChildSummary` — a `ToolResult` the parent model
|
|
13
|
+
* can act on — never the full child transcript.
|
|
14
|
+
*/
|
|
15
|
+
import type { RuntimeDeps } from './runtime.js';
|
|
16
|
+
import type { ToolResult } from '../tools/types.js';
|
|
17
|
+
import { TaskManager, type TaskStatus, type TaskSummary } from './task-manager.js';
|
|
18
|
+
import { WorkerSpawner } from './worker-spawner.js';
|
|
19
|
+
import { type DropReason } from './capabilities.js';
|
|
20
|
+
/** An agent a model can delegate to via `spawn_agent`. */
|
|
21
|
+
export interface AgentDefinition {
|
|
22
|
+
id: string;
|
|
23
|
+
description: string;
|
|
24
|
+
/** Explicit allow-list. Undefined means "inherit every tool the parent has". */
|
|
25
|
+
allowedTools?: string[];
|
|
26
|
+
readonly?: boolean;
|
|
27
|
+
/** Children cannot spawn further agents unless explicitly enabled. */
|
|
28
|
+
canSpawn?: boolean;
|
|
29
|
+
/** Per-agent model override. Must be honoured (or surfaced as an error). */
|
|
30
|
+
model?: string;
|
|
31
|
+
maxDepth?: number;
|
|
32
|
+
maxSteps?: number;
|
|
33
|
+
maxCost?: number;
|
|
34
|
+
maxTimeMs?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Default agents a model can delegate to. */
|
|
37
|
+
export declare const BUILTIN_AGENTS: readonly AgentDefinition[];
|
|
38
|
+
/** Compact summary returned to the parent — the child's transcript stays separate. */
|
|
39
|
+
export interface ChildSummary {
|
|
40
|
+
taskId: string;
|
|
41
|
+
agentName: string;
|
|
42
|
+
status: TaskStatus;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
finalText?: string;
|
|
45
|
+
changedFiles: string[];
|
|
46
|
+
usage?: {
|
|
47
|
+
input: number;
|
|
48
|
+
output: number;
|
|
49
|
+
estimated?: boolean;
|
|
50
|
+
};
|
|
51
|
+
error?: {
|
|
52
|
+
code: string;
|
|
53
|
+
message: string;
|
|
54
|
+
};
|
|
55
|
+
/** Tool drops from capability resolution, surfaced for parent visibility. */
|
|
56
|
+
droppedTools?: {
|
|
57
|
+
tool: string;
|
|
58
|
+
reason: DropReason;
|
|
59
|
+
}[];
|
|
60
|
+
}
|
|
61
|
+
/** Capability context the bridge passes when the parent calls spawn_agent. */
|
|
62
|
+
export interface ParentContextRef {
|
|
63
|
+
taskId?: string;
|
|
64
|
+
parentTaskId?: string;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
cwd: string;
|
|
67
|
+
depth: number;
|
|
68
|
+
maxDepth: number;
|
|
69
|
+
allowedTools: ReadonlySet<string> | null;
|
|
70
|
+
model?: string;
|
|
71
|
+
}
|
|
72
|
+
/** Interface exposed to the runtime/tools for a child spawn request. */
|
|
73
|
+
export interface AgentSpawnBridge {
|
|
74
|
+
parent: ParentContextRef;
|
|
75
|
+
spawnAgent(input: {
|
|
76
|
+
agent: string;
|
|
77
|
+
task: string;
|
|
78
|
+
cwd?: string;
|
|
79
|
+
model?: string;
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
}): Promise<ToolResult<ChildSummary>>;
|
|
82
|
+
listAgents(): AgentDefinition[];
|
|
83
|
+
getAgent(id: string): AgentDefinition | undefined;
|
|
84
|
+
listTasks(filter?: {
|
|
85
|
+
parentTaskId?: string;
|
|
86
|
+
status?: TaskStatus;
|
|
87
|
+
}): TaskSummary[];
|
|
88
|
+
getTask(id: string): TaskSummary & {
|
|
89
|
+
error?: {
|
|
90
|
+
code: string;
|
|
91
|
+
message: string;
|
|
92
|
+
};
|
|
93
|
+
} | undefined;
|
|
94
|
+
}
|
|
95
|
+
/** Constructor options for the orchestrator — the parent runtime's deps. */
|
|
96
|
+
export interface OrchestratorOpts {
|
|
97
|
+
sessionId: string;
|
|
98
|
+
deps: RuntimeDeps;
|
|
99
|
+
taskManager?: TaskManager;
|
|
100
|
+
workerSpawner?: WorkerSpawner;
|
|
101
|
+
}
|
|
102
|
+
export declare class AgentOrchestrator {
|
|
103
|
+
readonly sessionId: string;
|
|
104
|
+
readonly deps: RuntimeDeps;
|
|
105
|
+
readonly taskManager: TaskManager;
|
|
106
|
+
readonly workerSpawner: WorkerSpawner;
|
|
107
|
+
constructor(opts: OrchestratorOpts);
|
|
108
|
+
listAgents(): AgentDefinition[];
|
|
109
|
+
getAgent(id: string): AgentDefinition | undefined;
|
|
110
|
+
/** Build the bridge the parent's runtime hands to tools. */
|
|
111
|
+
bridgeFor(parent: ParentContextRef): AgentSpawnBridge;
|
|
112
|
+
/** Compute a child's effective capabilities from the parent's own. */
|
|
113
|
+
private resolveChild;
|
|
114
|
+
/**
|
|
115
|
+
* Spawn a child agent for a given capability context, await its run, and
|
|
116
|
+
* return a compact summary. Blocks until the child settles (P0 scope;
|
|
117
|
+
* async task_wait arrives in a later slice).
|
|
118
|
+
*/
|
|
119
|
+
spawnAgent(input: {
|
|
120
|
+
agent: string;
|
|
121
|
+
task: string;
|
|
122
|
+
cwd?: string;
|
|
123
|
+
model?: string;
|
|
124
|
+
timeoutMs?: number;
|
|
125
|
+
}, parent: ParentContextRef): Promise<ToolResult<ChildSummary>>;
|
|
126
|
+
private toChildSummary;
|
|
127
|
+
}
|
|
128
|
+
/** Module-scoped holder the orchestrator sets so children inherit the parent's signal. */
|
|
129
|
+
export declare const parentAbortSignalRef: {
|
|
130
|
+
current: AbortSignal | null;
|
|
131
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentOrchestrator — spawns child agents and turns them into tasks.
|
|
3
|
+
*
|
|
4
|
+
* The lowest-level way to run Klyro is `run(options, deps)` (one agent,
|
|
5
|
+
* one loop). Orchestration layers on top of that: a parent agent calls
|
|
6
|
+
* `spawn_agent`, which resolves the target `AgentDefinition`, computes its
|
|
7
|
+
* effective capabilities (via `resolveCapabilities`), creates a
|
|
8
|
+
* `TaskRecord`, and runs a *scoped* child loop. The child runs in-process
|
|
9
|
+
* with a `ScopedRegistry` (narrowed tool set), an optional model override,
|
|
10
|
+
* a depth cap, and an AbortController wired to the parent's signal.
|
|
11
|
+
*
|
|
12
|
+
* The compact result is a `ChildSummary` — a `ToolResult` the parent model
|
|
13
|
+
* can act on — never the full child transcript.
|
|
14
|
+
*/
|
|
15
|
+
import { run } from './runtime.js';
|
|
16
|
+
import { ScopedRegistry } from './scoped-registry.js';
|
|
17
|
+
import { globalBus } from '../events/bus.js';
|
|
18
|
+
import { TaskManager } from './task-manager.js';
|
|
19
|
+
import { WorkerSpawner } from './worker-spawner.js';
|
|
20
|
+
import { resolveCapabilities, DEFAULT_WRITE_TOOLS, DEFAULT_SPAWN_TOOLS, DEFAULT_DENIED_TOOLS, } from './capabilities.js';
|
|
21
|
+
/** Default agents a model can delegate to. */
|
|
22
|
+
export const BUILTIN_AGENTS = [
|
|
23
|
+
{
|
|
24
|
+
id: 'explorer',
|
|
25
|
+
description: 'Read-only reconnaissance: map the repo, find symbols and tests.',
|
|
26
|
+
readonly: true,
|
|
27
|
+
canSpawn: false,
|
|
28
|
+
allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'git_status', 'git_log', 'git_diff', 'recent_files', 'imports_of', 'importers_of'],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'implementer',
|
|
32
|
+
description: 'Write-capable worker for concrete, well-scoped coding tasks.',
|
|
33
|
+
canSpawn: false,
|
|
34
|
+
allowedTools: ['read_file', 'list_dir', '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
|
+
maxSteps: 60,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'tester',
|
|
39
|
+
description: 'Runs verification and tests, reports failures with diagnostics.',
|
|
40
|
+
canSpawn: false,
|
|
41
|
+
allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'shell_exec', 'run_verify', 'git_status', 'git_log', 'git_diff'],
|
|
42
|
+
maxTimeMs: 120_000,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'reviewer',
|
|
46
|
+
description: 'Read-only review of a diff or change set for bugs.',
|
|
47
|
+
readonly: true,
|
|
48
|
+
canSpawn: false,
|
|
49
|
+
allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'search_files', 'git_status', 'git_diff', 'git_log', 'imports_of', 'importers_of', 'find_symbol'],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
/** Map a runtime `RunResult.status` to a task status. */
|
|
53
|
+
function mapResultStatus(status) {
|
|
54
|
+
switch (status) {
|
|
55
|
+
case 'complete':
|
|
56
|
+
return 'succeeded';
|
|
57
|
+
case 'aborted':
|
|
58
|
+
return 'cancelled';
|
|
59
|
+
case 'blocked':
|
|
60
|
+
return 'blocked';
|
|
61
|
+
case 'max_steps':
|
|
62
|
+
case 'no_final':
|
|
63
|
+
case 'verify_failed':
|
|
64
|
+
case 'limit':
|
|
65
|
+
case 'stuck':
|
|
66
|
+
return 'failed';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export class AgentOrchestrator {
|
|
70
|
+
sessionId;
|
|
71
|
+
deps;
|
|
72
|
+
taskManager;
|
|
73
|
+
workerSpawner;
|
|
74
|
+
constructor(opts) {
|
|
75
|
+
this.sessionId = opts.sessionId;
|
|
76
|
+
this.deps = opts.deps;
|
|
77
|
+
this.taskManager = opts.taskManager ?? new TaskManager({ sessionId: opts.sessionId });
|
|
78
|
+
this.workerSpawner = opts.workerSpawner ?? new WorkerSpawner();
|
|
79
|
+
}
|
|
80
|
+
listAgents() {
|
|
81
|
+
return [...BUILTIN_AGENTS];
|
|
82
|
+
}
|
|
83
|
+
getAgent(id) {
|
|
84
|
+
return BUILTIN_AGENTS.find((a) => a.id === id);
|
|
85
|
+
}
|
|
86
|
+
/** Build the bridge the parent's runtime hands to tools. */
|
|
87
|
+
bridgeFor(parent) {
|
|
88
|
+
return {
|
|
89
|
+
parent,
|
|
90
|
+
listAgents: () => this.listAgents(),
|
|
91
|
+
getAgent: (id) => this.getAgent(id),
|
|
92
|
+
spawnAgent: (input) => this.spawnAgent(input, parent),
|
|
93
|
+
listTasks: (filter) => this.taskManager.list(filter),
|
|
94
|
+
getTask: (id) => {
|
|
95
|
+
const r = this.taskManager.get(id);
|
|
96
|
+
if (!r)
|
|
97
|
+
return undefined;
|
|
98
|
+
const s = this.taskManager.toSummary(r);
|
|
99
|
+
return r.error ? { ...s, error: { code: r.error.code, message: r.error.message } } : s;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Compute a child's effective capabilities from the parent's own. */
|
|
104
|
+
resolveChild(def, parent, registryTools) {
|
|
105
|
+
const input = {
|
|
106
|
+
parentTools: parent.allowedTools,
|
|
107
|
+
agent: def,
|
|
108
|
+
policyAllowed: registryTools,
|
|
109
|
+
registryTools,
|
|
110
|
+
writeTools: DEFAULT_WRITE_TOOLS,
|
|
111
|
+
spawnTools: DEFAULT_SPAWN_TOOLS,
|
|
112
|
+
denied: DEFAULT_DENIED_TOOLS,
|
|
113
|
+
};
|
|
114
|
+
const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth });
|
|
115
|
+
return resolved;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Spawn a child agent for a given capability context, await its run, and
|
|
119
|
+
* return a compact summary. Blocks until the child settles (P0 scope;
|
|
120
|
+
* async task_wait arrives in a later slice).
|
|
121
|
+
*/
|
|
122
|
+
async spawnAgent(input, parent) {
|
|
123
|
+
const def = this.getAgent(input.agent);
|
|
124
|
+
if (!def) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: { code: 'UNKNOWN_AGENT', message: `Unknown agent: ${input.agent}` },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Depth guard — block before creating a (running) task.
|
|
131
|
+
const childDepth = parent.depth + 1;
|
|
132
|
+
const maxDepth = def.maxDepth ?? parent.maxDepth;
|
|
133
|
+
if (childDepth > maxDepth) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: {
|
|
137
|
+
code: 'RECURSION_LIMIT',
|
|
138
|
+
message: `maxDepth exceeded for agent "${def.id}": cannot spawn at depth ${childDepth} (cap ${maxDepth})`,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const registryTools = new Set(this.deps.registry.list().map((t) => t.name));
|
|
143
|
+
const resolved = this.resolveChild(def, parent, registryTools);
|
|
144
|
+
const childModel = input.model ?? resolved.model ?? parent.model;
|
|
145
|
+
const childCwd = input.cwd ?? parent.cwd;
|
|
146
|
+
const createOpts = {
|
|
147
|
+
agentName: def.id,
|
|
148
|
+
cwd: childCwd,
|
|
149
|
+
depth: childDepth,
|
|
150
|
+
maxDepth,
|
|
151
|
+
model: childModel,
|
|
152
|
+
parentTaskId: parent.taskId,
|
|
153
|
+
timeoutMs: input.timeoutMs ?? def.maxTimeMs,
|
|
154
|
+
abortOnParent: undefined, // wired below via the parent signal
|
|
155
|
+
};
|
|
156
|
+
const record = this.taskManager.create(createOpts);
|
|
157
|
+
const childRegistry = new ScopedRegistry(this.deps.registry, resolved.allowed);
|
|
158
|
+
const childDeps = { ...this.deps, registry: childRegistry };
|
|
159
|
+
const childOptions = {
|
|
160
|
+
task: input.task,
|
|
161
|
+
cwd: childCwd,
|
|
162
|
+
model: childModel ?? 'inherit', // model override must reach the adapter (see runtime)
|
|
163
|
+
maxSteps: def.maxSteps,
|
|
164
|
+
maxCost: def.maxCost,
|
|
165
|
+
maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
|
|
166
|
+
signal: record.abortController.signal,
|
|
167
|
+
nonInteractive: true,
|
|
168
|
+
};
|
|
169
|
+
// Lifecycle events on the shared bus (mirror TaskManager transitions).
|
|
170
|
+
globalBus.emit({
|
|
171
|
+
type: 'subtask.started',
|
|
172
|
+
ts: Date.now(),
|
|
173
|
+
sessionId: this.sessionId,
|
|
174
|
+
taskId: record.id,
|
|
175
|
+
...(record.parentTaskId ? { parentTaskId: record.parentTaskId } : {}),
|
|
176
|
+
agentName: def.id,
|
|
177
|
+
depth: childDepth,
|
|
178
|
+
...(typeof childModel === 'string' ? { model: childModel } : {}),
|
|
179
|
+
});
|
|
180
|
+
const handle = this.workerSpawner.spawn(async () => {
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
result = await run(childOptions, childDeps);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
this.taskManager.finish(record.id, 'failed', {
|
|
187
|
+
error: { code: 'CHILD_CRASH', message: err instanceof Error ? err.message : String(err) },
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const status = mapResultStatus(result.status);
|
|
192
|
+
if (status === 'cancelled') {
|
|
193
|
+
this.taskManager.cancel(record.id, 'parent aborted');
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.taskManager.finish(record.id, status, {
|
|
197
|
+
summary: [`status: ${status}`, `steps: ${result.steps}`, `toolCalls: ${result.toolCalls}`],
|
|
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
|
+
}
|
|
228
|
+
}, { label: `agent:${def.id}` });
|
|
229
|
+
await handle.done.catch(() => undefined);
|
|
230
|
+
const finalRecord = this.taskManager.get(record.id);
|
|
231
|
+
if (!finalRecord) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: { code: 'INTERNAL', message: `task ${record.id} missing after child run` },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, value: this.toChildSummary(finalRecord, def, resolved.dropped) };
|
|
238
|
+
}
|
|
239
|
+
toChildSummary(r, def, dropped) {
|
|
240
|
+
const s = this.taskManager.toSummary(r);
|
|
241
|
+
return {
|
|
242
|
+
taskId: s.id,
|
|
243
|
+
agentName: s.agentName,
|
|
244
|
+
status: s.status,
|
|
245
|
+
durationMs: s.durationMs ?? 0,
|
|
246
|
+
...(def.model ? { model: def.model } : {}),
|
|
247
|
+
changedFiles: r.summary.filter((line) => line.startsWith('changed: ')).map((line) => line.slice('changed: '.length)),
|
|
248
|
+
droppedTools: dropped.length ? dropped : undefined,
|
|
249
|
+
error: r.error ? { code: r.error.code, message: r.error.message } : undefined,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function mapFailureCode(status) {
|
|
254
|
+
switch (status) {
|
|
255
|
+
case 'max_steps':
|
|
256
|
+
return 'MAX_STEPS';
|
|
257
|
+
case 'verify_failed':
|
|
258
|
+
return 'VERIFY_FAILED';
|
|
259
|
+
case 'limit':
|
|
260
|
+
return 'LIMIT';
|
|
261
|
+
case 'stuck':
|
|
262
|
+
return 'STUCK';
|
|
263
|
+
case 'no_final':
|
|
264
|
+
default:
|
|
265
|
+
return 'NO_FINAL';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Module-scoped holder the orchestrator sets so children inherit the parent's signal. */
|
|
269
|
+
export const parentAbortSignalRef = { current: null };
|
|
@@ -45,6 +45,8 @@ export type StreamEvent = {
|
|
|
45
45
|
code: string;
|
|
46
46
|
message: string;
|
|
47
47
|
retryable: boolean;
|
|
48
|
+
status?: string;
|
|
49
|
+
retryAfterMs?: number;
|
|
48
50
|
};
|
|
49
51
|
export interface ToolDefinition {
|
|
50
52
|
name: string;
|
|
@@ -72,6 +74,13 @@ export interface HttpAdapterOptions {
|
|
|
72
74
|
/** Override fetch (e.g. for tests). */
|
|
73
75
|
fetchImpl?: typeof fetch;
|
|
74
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse a `Retry-After` response header value into milliseconds.
|
|
79
|
+
* Returns undefined when absent or unparseable. Handles both forms:
|
|
80
|
+
* delay-seconds ("120") and HTTP-date ("Wed, 21 Oct 2015 07:28:00 GMT",
|
|
81
|
+
* clamped at 0 when the date is in the past).
|
|
82
|
+
*/
|
|
83
|
+
export declare function parseRetryAfterMs(value: string | null | undefined): number | undefined;
|
|
75
84
|
/** Convert a Zod schema to a permissive JSON Schema object for tool defs. */
|
|
76
85
|
export declare function zodToJsonSchema(schema: z.ZodType<unknown>): Record<string, unknown>;
|
|
77
86
|
interface ChatCompletionsRequest {
|
|
@@ -12,6 +12,25 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { redact } from '../policy/secret-redactor.js';
|
|
14
14
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
+
/**
|
|
16
|
+
* Parse a `Retry-After` response header value into milliseconds.
|
|
17
|
+
* Returns undefined when absent or unparseable. Handles both forms:
|
|
18
|
+
* delay-seconds ("120") and HTTP-date ("Wed, 21 Oct 2015 07:28:00 GMT",
|
|
19
|
+
* clamped at 0 when the date is in the past).
|
|
20
|
+
*/
|
|
21
|
+
export function parseRetryAfterMs(value) {
|
|
22
|
+
if (value == null)
|
|
23
|
+
return undefined;
|
|
24
|
+
const v = value.trim();
|
|
25
|
+
if (!v)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (/^\d+$/.test(v))
|
|
28
|
+
return Number(v) * 1000;
|
|
29
|
+
const t = Date.parse(v);
|
|
30
|
+
if (!Number.isNaN(t))
|
|
31
|
+
return Math.max(0, t - Date.now());
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
15
34
|
/** Convert a Zod schema to a permissive JSON Schema object for tool defs. */
|
|
16
35
|
export function zodToJsonSchema(schema) {
|
|
17
36
|
// We keep this simple: zod's own _def is enough to give the model a
|
|
@@ -183,11 +202,15 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
183
202
|
req.signal?.removeEventListener('abort', onAbort);
|
|
184
203
|
const rawErr = await res.text().catch(() => '');
|
|
185
204
|
const errText = redact(rawErr).slice(0, 500);
|
|
205
|
+
const retryable = res.status >= 500 || res.status === 429;
|
|
206
|
+
const retryAfterMs = retryable ? parseRetryAfterMs(res.headers?.get('retry-after')) : undefined;
|
|
186
207
|
yield {
|
|
187
208
|
kind: 'error',
|
|
188
209
|
code: `HTTP_${res.status}`,
|
|
189
210
|
message: `provider returned ${res.status}: ${errText}`,
|
|
190
|
-
retryable
|
|
211
|
+
retryable,
|
|
212
|
+
status: String(res.status),
|
|
213
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
191
214
|
};
|
|
192
215
|
return;
|
|
193
216
|
}
|
|
@@ -195,10 +218,41 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
195
218
|
const reader = res.body.getReader();
|
|
196
219
|
const decoder = new TextDecoder();
|
|
197
220
|
let buf = '';
|
|
198
|
-
|
|
199
|
-
const toolIds = new Map();
|
|
200
|
-
const toolNames = new Map();
|
|
221
|
+
const blocks = new Map();
|
|
201
222
|
let pendingUsage;
|
|
223
|
+
// Canonical contract: exactly one terminal event per stream.
|
|
224
|
+
let terminalEmitted = false;
|
|
225
|
+
function* emitTerminal(finishReason, usage) {
|
|
226
|
+
if (terminalEmitted)
|
|
227
|
+
return;
|
|
228
|
+
terminalEmitted = true;
|
|
229
|
+
for (const b of blocks.values()) {
|
|
230
|
+
if (b.started && !b.ended && b.id) {
|
|
231
|
+
b.ended = true;
|
|
232
|
+
yield { kind: 'tool_call_end', id: b.id };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Fragments that never gained an identity are surfaced as incomplete
|
|
236
|
+
// calls (the runtime turns them into structured errors) — never dropped.
|
|
237
|
+
let incomplete = 0;
|
|
238
|
+
for (const b of blocks.values()) {
|
|
239
|
+
if (!b.started && (b.argsJson || b.id || b.name)) {
|
|
240
|
+
const id = b.id ?? `incomplete_${incomplete++}`;
|
|
241
|
+
yield { kind: 'tool_call_start', id, name: b.name ?? 'unknown' };
|
|
242
|
+
if (b.argsJson)
|
|
243
|
+
yield { kind: 'tool_call_delta', id, argsJson: b.argsJson };
|
|
244
|
+
yield { kind: 'tool_call_end', id };
|
|
245
|
+
b.started = true;
|
|
246
|
+
b.ended = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const end = { kind: 'message_end' };
|
|
250
|
+
if (finishReason)
|
|
251
|
+
end.finishReason = finishReason;
|
|
252
|
+
if (usage)
|
|
253
|
+
end.usage = usage;
|
|
254
|
+
yield end;
|
|
255
|
+
}
|
|
202
256
|
try {
|
|
203
257
|
while (true) {
|
|
204
258
|
const { value, done } = await reader.read();
|
|
@@ -215,7 +269,7 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
215
269
|
continue;
|
|
216
270
|
const data = line.slice(5).trim();
|
|
217
271
|
if (data === '[DONE]') {
|
|
218
|
-
yield
|
|
272
|
+
yield* emitTerminal(undefined, pendingUsage);
|
|
219
273
|
return;
|
|
220
274
|
}
|
|
221
275
|
let chunk;
|
|
@@ -248,17 +302,28 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
248
302
|
yield { kind: 'thinking_delta', text: thinking };
|
|
249
303
|
}
|
|
250
304
|
for (const tc of choice.delta.tool_calls ?? []) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
305
|
+
let b = blocks.get(tc.index);
|
|
306
|
+
if (!b) {
|
|
307
|
+
b = { argsJson: '', started: false, ended: false };
|
|
308
|
+
blocks.set(tc.index, b);
|
|
255
309
|
}
|
|
256
|
-
|
|
257
|
-
|
|
310
|
+
if (tc.id)
|
|
311
|
+
b.id = tc.id;
|
|
312
|
+
if (tc.function?.name)
|
|
313
|
+
b.name = tc.function.name;
|
|
314
|
+
const newArgs = tc.function?.arguments;
|
|
315
|
+
if (newArgs)
|
|
316
|
+
b.argsJson += newArgs;
|
|
317
|
+
if (b.id && b.name && !b.started) {
|
|
318
|
+
// Identity arrived (possibly after earlier fragments). Emit
|
|
319
|
+
// start, then flush any accumulated args as one delta.
|
|
320
|
+
b.started = true;
|
|
321
|
+
yield { kind: 'tool_call_start', id: b.id, name: b.name };
|
|
322
|
+
if (b.argsJson)
|
|
323
|
+
yield { kind: 'tool_call_delta', id: b.id, argsJson: b.argsJson };
|
|
258
324
|
}
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
|
|
325
|
+
else if (b.started && b.id && newArgs) {
|
|
326
|
+
yield { kind: 'tool_call_delta', id: b.id, argsJson: newArgs };
|
|
262
327
|
}
|
|
263
328
|
}
|
|
264
329
|
if (choice.finish_reason) {
|
|
@@ -266,38 +331,19 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
266
331
|
? { input: chunk.usage.prompt_tokens, output: chunk.usage.completion_tokens }
|
|
267
332
|
: undefined);
|
|
268
333
|
pendingUsage = undefined;
|
|
269
|
-
|
|
270
|
-
const ids = [...toolIds.values()];
|
|
271
|
-
toolIds.clear();
|
|
272
|
-
toolNames.clear();
|
|
273
|
-
for (const id of ids)
|
|
274
|
-
yield { kind: 'tool_call_end', id };
|
|
275
|
-
yield { kind: 'message_end', finishReason: choice.finish_reason, usage };
|
|
334
|
+
yield* emitTerminal(choice.finish_reason, usage);
|
|
276
335
|
}
|
|
277
336
|
}
|
|
278
337
|
}
|
|
279
338
|
}
|
|
280
339
|
}
|
|
281
|
-
|
|
282
|
-
for (const id of toolIds.values())
|
|
283
|
-
yield { kind: 'tool_call_end', id };
|
|
284
|
-
if (pendingUsage) {
|
|
285
|
-
yield { kind: 'message_end', usage: pendingUsage };
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
yield { kind: 'message_end' };
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
else if (pendingUsage) {
|
|
292
|
-
yield { kind: 'message_end', usage: pendingUsage };
|
|
293
|
-
}
|
|
294
|
-
else {
|
|
295
|
-
yield { kind: 'message_end' };
|
|
296
|
-
}
|
|
340
|
+
yield* emitTerminal(undefined, pendingUsage);
|
|
297
341
|
}
|
|
298
342
|
catch (err) {
|
|
299
|
-
|
|
300
|
-
|
|
343
|
+
if (!terminalEmitted) {
|
|
344
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
345
|
+
yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
|
|
346
|
+
}
|
|
301
347
|
}
|
|
302
348
|
finally {
|
|
303
349
|
clearTimeout(timer);
|
package/dist/agent/registry.d.ts
CHANGED
package/dist/agent/registry.js
CHANGED
package/dist/agent/retry.d.ts
CHANGED
|
@@ -28,8 +28,19 @@ export interface RetryOptions {
|
|
|
28
28
|
sleep?: (ms: number) => Promise<void>;
|
|
29
29
|
/** Test hook: called once per attempt with 0-indexed attempt number. */
|
|
30
30
|
onAttempt?: (attempt: number) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Retry telemetry hook (operator-visible; the model stays blind).
|
|
33
|
+
* Called each time a retryable error is buffered and another attempt
|
|
34
|
+
* will follow (NOT on the terminal failure). `attempt` is the
|
|
35
|
+
* 1-indexed retry number (1 = first retry after the initial failure).
|
|
36
|
+
*/
|
|
37
|
+
onRetry?: (info: {
|
|
38
|
+
attempt: number;
|
|
39
|
+
status: string;
|
|
40
|
+
retryAfterMs?: number;
|
|
41
|
+
}) => void;
|
|
31
42
|
}
|
|
32
|
-
export declare const DEFAULT_RETRY: Required<Omit<RetryOptions, 'signal' | 'onAttempt'>>;
|
|
43
|
+
export declare const DEFAULT_RETRY: Required<Omit<RetryOptions, 'signal' | 'onAttempt' | 'onRetry'>>;
|
|
33
44
|
/**
|
|
34
45
|
* Sleep that resolves early when `signal` aborts (never rejects — callers
|
|
35
46
|
* check `signal.aborted` themselves after waking).
|
package/dist/agent/retry.js
CHANGED
|
@@ -152,7 +152,25 @@ export function retryingAdapter(inner, opts = {}) {
|
|
|
152
152
|
yield lastError;
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
|
-
|
|
155
|
+
// Telemetry for the operator: a retryable error was buffered and
|
|
156
|
+
// another attempt will follow. The consumer stream never sees the
|
|
157
|
+
// buffered error, so the model stays blind.
|
|
158
|
+
const errRec = lastError;
|
|
159
|
+
const statusRaw = errRec?.['status'];
|
|
160
|
+
const codeRaw = errRec?.['code'];
|
|
161
|
+
const status = typeof statusRaw === 'string' && statusRaw.length > 0
|
|
162
|
+
? statusRaw
|
|
163
|
+
: typeof codeRaw === 'string' && codeRaw.length > 0
|
|
164
|
+
? codeRaw
|
|
165
|
+
: 'retryable';
|
|
166
|
+
const retryAfterRaw = errRec?.['retryAfterMs'];
|
|
167
|
+
const retryAfterMs = typeof retryAfterRaw === 'number' && Number.isFinite(retryAfterRaw) && retryAfterRaw >= 0
|
|
168
|
+
? retryAfterRaw
|
|
169
|
+
: undefined;
|
|
170
|
+
opts.onRetry?.({ attempt: attempt + 1, status, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
171
|
+
// Honor a server-provided Retry-After delay when present; otherwise
|
|
172
|
+
// fall back to exponential backoff with jitter.
|
|
173
|
+
const delay = retryAfterMs ?? computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
|
|
156
174
|
// Abort-aware backoff: Ctrl+C during the sleep must stop promptly
|
|
157
175
|
// instead of stalling up to maxMs before noticing.
|
|
158
176
|
if (delay > 0)
|