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.
Files changed (62) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +30 -9
  2. package/dist/agent/anthropic-adapter.js +107 -49
  3. package/dist/agent/capabilities.d.ts +122 -0
  4. package/dist/agent/capabilities.js +150 -0
  5. package/dist/agent/orchestrator.d.ts +131 -0
  6. package/dist/agent/orchestrator.js +269 -0
  7. package/dist/agent/provider-adapter.d.ts +9 -0
  8. package/dist/agent/provider-adapter.js +85 -39
  9. package/dist/agent/registry.d.ts +1 -0
  10. package/dist/agent/registry.js +1 -0
  11. package/dist/agent/retry.d.ts +12 -1
  12. package/dist/agent/retry.js +19 -1
  13. package/dist/agent/runtime.d.ts +23 -1
  14. package/dist/agent/runtime.js +236 -26
  15. package/dist/agent/scoped-registry.d.ts +22 -0
  16. package/dist/agent/scoped-registry.js +42 -0
  17. package/dist/agent/task-manager.d.ts +115 -0
  18. package/dist/agent/task-manager.js +250 -0
  19. package/dist/agent/worker-spawner.d.ts +17 -12
  20. package/dist/agent/worker-spawner.js +26 -20
  21. package/dist/cli/config.d.ts +21 -0
  22. package/dist/cli/config.js +31 -0
  23. package/dist/cli/dotenv.d.ts +3 -0
  24. package/dist/cli/dotenv.js +57 -0
  25. package/dist/cli/repl.js +72 -4
  26. package/dist/cli/run.d.ts +3 -0
  27. package/dist/cli/run.js +127 -7
  28. package/dist/context/klyro-md.d.ts +6 -0
  29. package/dist/context/klyro-md.js +21 -15
  30. package/dist/context/trust.d.ts +42 -0
  31. package/dist/context/trust.js +111 -0
  32. package/dist/events/catalog.d.ts +71 -0
  33. package/dist/index.js +4 -0
  34. package/dist/mcp/client.d.ts +53 -0
  35. package/dist/mcp/client.js +225 -0
  36. package/dist/mcp/config.d.ts +30 -0
  37. package/dist/mcp/config.js +82 -0
  38. package/dist/mcp/policy.d.ts +13 -0
  39. package/dist/mcp/policy.js +12 -0
  40. package/dist/mcp/registry.d.ts +50 -0
  41. package/dist/mcp/registry.js +172 -0
  42. package/dist/mcp/schema.d.ts +11 -0
  43. package/dist/mcp/schema.js +46 -0
  44. package/dist/persistence/store.d.ts +1 -1
  45. package/dist/policy/approval.d.ts +27 -6
  46. package/dist/policy/approval.js +36 -8
  47. package/dist/policy/engine.d.ts +13 -0
  48. package/dist/policy/engine.js +31 -2
  49. package/dist/policy/patterns.d.ts +16 -0
  50. package/dist/policy/patterns.js +26 -0
  51. package/dist/tools/agent/spawn-agent.d.ts +9 -0
  52. package/dist/tools/agent/spawn-agent.js +50 -0
  53. package/dist/tools/agent/task-get.d.ts +8 -0
  54. package/dist/tools/agent/task-get.js +40 -0
  55. package/dist/tools/agent/task-list.d.ts +4 -0
  56. package/dist/tools/agent/task-list.js +41 -0
  57. package/dist/tools/plan/todo-write.d.ts +1 -1
  58. package/dist/tools/registry.js +6 -0
  59. package/dist/tools/types.d.ts +12 -0
  60. package/dist/tui/approval.d.ts +1 -1
  61. package/dist/tui/approval.js +2 -2
  62. 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: res.status >= 500 || res.status === 429,
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
- // Track per-tool-call id by index.
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 { kind: 'message_end' };
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
- if (tc.id && tc.function?.name) {
252
- toolIds.set(tc.index, tc.id);
253
- toolNames.set(tc.index, tc.function.name);
254
- yield { kind: 'tool_call_start', id: tc.id, name: tc.function.name };
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
- else if (tc.id) {
257
- toolIds.set(tc.index, tc.id);
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 (tc.function?.arguments) {
260
- const id = toolIds.get(tc.index) ?? `call_${tc.index}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
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
- // Clear tool tracking per message to avoid stale ids on next turn
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
- if (toolIds.size) {
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
- const msg = err instanceof Error ? err.message : String(err);
300
- yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
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);
@@ -39,4 +39,5 @@ export declare function buildProviderFromCli(args: {
39
39
  baseUrl?: string;
40
40
  apiKey?: string;
41
41
  timeoutMs?: number;
42
+ retry?: Partial<RetryOptions> | false;
42
43
  }): ProviderAdapter;
@@ -190,5 +190,6 @@ export function buildProviderFromCli(args) {
190
190
  baseURL: args.baseUrl,
191
191
  apiKey: args.apiKey,
192
192
  timeoutMs: args.timeoutMs,
193
+ ...(args.retry !== undefined ? { retry: args.retry } : {}),
193
194
  });
194
195
  }
@@ -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).
@@ -152,7 +152,25 @@ export function retryingAdapter(inner, opts = {}) {
152
152
  yield lastError;
153
153
  return;
154
154
  }
155
- const delay = computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
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)