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
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -87,6 +87,26 @@ export interface RunOptions {
|
|
|
87
87
|
store?: import('../persistence/store.js').SessionStore;
|
|
88
88
|
sessionId?: string;
|
|
89
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* Orchestration context (P0). Present for any agent that is itself managed
|
|
92
|
+
* by an AgentOrchestrator — so a child knows who its parent is, how deep the
|
|
93
|
+
* call stack is, which tools it may use, and which task/session it belongs to.
|
|
94
|
+
*/
|
|
95
|
+
parentContext?: {
|
|
96
|
+
taskId?: string;
|
|
97
|
+
parentTaskId?: string;
|
|
98
|
+
sessionId: string;
|
|
99
|
+
depth: number;
|
|
100
|
+
maxDepth: number;
|
|
101
|
+
allowedTools?: ReadonlySet<string>;
|
|
102
|
+
model?: string;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Delegation bridge (P0). Present on the root run so the model can call
|
|
106
|
+
* spawn_agent / task_list / task_get. The tool layer reads it from the
|
|
107
|
+
* ToolContext.
|
|
108
|
+
*/
|
|
109
|
+
agentBridge?: import('./orchestrator.js').AgentSpawnBridge;
|
|
90
110
|
}
|
|
91
111
|
/** A single plan step emitted by the agent. */
|
|
92
112
|
export interface PlanStep {
|
|
@@ -139,6 +159,7 @@ export type RuntimeEvent = {
|
|
|
139
159
|
kind: 'usage';
|
|
140
160
|
input: number;
|
|
141
161
|
output: number;
|
|
162
|
+
estimated?: boolean;
|
|
142
163
|
} | {
|
|
143
164
|
kind: 'final_text';
|
|
144
165
|
text: string;
|
|
@@ -171,7 +192,7 @@ export type RuntimeEvent = {
|
|
|
171
192
|
sessionId: string;
|
|
172
193
|
};
|
|
173
194
|
export interface RunResult {
|
|
174
|
-
status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked';
|
|
195
|
+
status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked' | 'stuck';
|
|
175
196
|
steps: number;
|
|
176
197
|
toolCalls: number;
|
|
177
198
|
finalText: string;
|
|
@@ -181,6 +202,7 @@ export interface RunResult {
|
|
|
181
202
|
usage: {
|
|
182
203
|
input: number;
|
|
183
204
|
output: number;
|
|
205
|
+
estimated?: boolean;
|
|
184
206
|
};
|
|
185
207
|
/** Number of policy-driven user prompts the user accepted. */
|
|
186
208
|
repairs?: number;
|
package/dist/agent/runtime.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { text, toolUse, toolResult as mkToolResult } from './message.js';
|
|
18
18
|
import { redact } from '../policy/secret-redactor.js';
|
|
19
|
+
import { patternForCall } from '../policy/patterns.js';
|
|
19
20
|
import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
|
|
20
21
|
import * as path from 'node:path';
|
|
21
22
|
import { verify, diagnosticForModel } from '../verification/engine.js';
|
|
@@ -173,6 +174,8 @@ export async function run(opts, deps) {
|
|
|
173
174
|
// 5.2 — stuck detection state
|
|
174
175
|
const callHistory = [];
|
|
175
176
|
const fileEditCounts = new Map();
|
|
177
|
+
let stuckTriggers = 0;
|
|
178
|
+
let stuckAbort = false;
|
|
176
179
|
outer: while (steps < maxSteps) {
|
|
177
180
|
// 5.1 limits: max-cost, max-time
|
|
178
181
|
if (maxCost !== undefined) {
|
|
@@ -226,7 +229,7 @@ export async function run(opts, deps) {
|
|
|
226
229
|
emitKlyro({ type: 'context.compacted', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', dropped: c.dropped });
|
|
227
230
|
}
|
|
228
231
|
const req = {
|
|
229
|
-
model: opts.model,
|
|
232
|
+
model: opts.parentContext?.model ?? opts.model,
|
|
230
233
|
system: reqSystem,
|
|
231
234
|
messages: reqMessages,
|
|
232
235
|
tools: toolDefinitions(deps.registry),
|
|
@@ -273,6 +276,17 @@ export async function run(opts, deps) {
|
|
|
273
276
|
telemetry.recordUsage(ev.usage.input, ev.usage.output);
|
|
274
277
|
emit?.({ kind: 'usage', input: usage.input, output: usage.output });
|
|
275
278
|
}
|
|
279
|
+
else {
|
|
280
|
+
// Providers that omit usage (Ollama, vLLM, proxies): estimate from
|
|
281
|
+
// the actual request + generated output so cost accounting never
|
|
282
|
+
// silently records zero. Marked estimated for the UI/debugging.
|
|
283
|
+
const est = estimateTurnUsage(reqSystem, reqMessages, textBuf, pendingToolCalls);
|
|
284
|
+
usage.input += est.input;
|
|
285
|
+
usage.output += est.output;
|
|
286
|
+
usage.estimated = true;
|
|
287
|
+
telemetry.recordUsage(est.input, est.output);
|
|
288
|
+
emit?.({ kind: 'usage', input: usage.input, output: usage.output, estimated: true });
|
|
289
|
+
}
|
|
276
290
|
}
|
|
277
291
|
else if (ev.kind === 'error') {
|
|
278
292
|
telemetry.recordError(`stream_error: ${ev.code}`);
|
|
@@ -296,22 +310,47 @@ export async function run(opts, deps) {
|
|
|
296
310
|
};
|
|
297
311
|
}
|
|
298
312
|
}
|
|
299
|
-
// Build the assistant message.
|
|
313
|
+
// Build the assistant message. Tool calls are finalized here: JSON is
|
|
314
|
+
// parsed and schema-validated BEFORE policy/execution. Malformed calls
|
|
315
|
+
// become structured MALFORMED_TOOL_CALL results — garbage arguments must
|
|
316
|
+
// never reach a real tool.
|
|
300
317
|
const assistantContent = [];
|
|
301
318
|
if (textBuf)
|
|
302
319
|
assistantContent.push(text(textBuf));
|
|
303
320
|
const finalizedCalls = [];
|
|
321
|
+
let hadInvalidTool = false;
|
|
304
322
|
for (const tc of pendingToolCalls.values()) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
323
|
+
const validated = validateFinalizedCall(deps.registry, tc.id, tc.name, tc.argsJson);
|
|
324
|
+
if (!validated.ok) {
|
|
325
|
+
hadInvalidTool = true;
|
|
326
|
+
// Record the model's tool_use (empty input — safe to replay to any
|
|
327
|
+
// provider) and answer it immediately with a structured error.
|
|
328
|
+
assistantContent.push(toolUse(tc.id, tc.name, {}));
|
|
329
|
+
emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input: {} });
|
|
330
|
+
const errMsg = {
|
|
331
|
+
role: 'tool',
|
|
332
|
+
content: [mkToolResult(tc.id, tc.name, validated.output, true)],
|
|
333
|
+
};
|
|
334
|
+
transcript.push(errMsg);
|
|
335
|
+
await checkpoint(errMsg, {
|
|
336
|
+
toolCallId: tc.id, toolName: tc.name,
|
|
337
|
+
input: { raw: redact(tc.argsJson).slice(0, 500) }, output: validated.output, isError: true,
|
|
338
|
+
});
|
|
339
|
+
let attempted = {};
|
|
340
|
+
try {
|
|
341
|
+
attempted = JSON.parse(tc.argsJson);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
attempted = {};
|
|
345
|
+
}
|
|
346
|
+
telemetry.recordToolError(toolUse(tc.id, tc.name, attempted), validated.code);
|
|
347
|
+
emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: tc.id, name: tc.name, output: validated.output, isError: true, latencyMs: 0 });
|
|
348
|
+
emit?.({ kind: 'tool_result', id: tc.id, name: tc.name, output: validated.output, isError: true, latencyMs: 0 });
|
|
349
|
+
continue;
|
|
311
350
|
}
|
|
312
|
-
finalizedCalls.push(toolUse(tc.id, tc.name, input));
|
|
313
|
-
assistantContent.push(toolUse(tc.id, tc.name, input));
|
|
314
|
-
emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input });
|
|
351
|
+
finalizedCalls.push(toolUse(tc.id, tc.name, validated.input));
|
|
352
|
+
assistantContent.push(toolUse(tc.id, tc.name, validated.input));
|
|
353
|
+
emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input: validated.input });
|
|
315
354
|
}
|
|
316
355
|
const assistantMsg = { role: 'assistant', content: assistantContent };
|
|
317
356
|
transcript.push(assistantMsg);
|
|
@@ -332,6 +371,14 @@ export async function run(opts, deps) {
|
|
|
332
371
|
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
333
372
|
}
|
|
334
373
|
if (finalizedCalls.length === 0) {
|
|
374
|
+
if (hadInvalidTool) {
|
|
375
|
+
// The model attempted a tool call that failed validation; the error is
|
|
376
|
+
// already a tool_result in the transcript — loop so the model can
|
|
377
|
+
// repair next turn instead of treating an answerable error as a
|
|
378
|
+
// completion. Bounded by maxSteps and stuck detection (P0-3).
|
|
379
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
335
382
|
finalText = textBuf;
|
|
336
383
|
// Level 8 — Verification + Autonomous Repair (gated on hasEdits below — pure analysis skips verify)
|
|
337
384
|
const verifyEnabled = opts.verify?.enabled !== false;
|
|
@@ -526,10 +573,21 @@ export async function run(opts, deps) {
|
|
|
526
573
|
signal: opts.signal,
|
|
527
574
|
nonInteractive: opts.nonInteractive,
|
|
528
575
|
sessionId,
|
|
576
|
+
...(opts.agentBridge ? { agentBridge: opts.agentBridge } : {}),
|
|
577
|
+
...(opts.parentContext?.taskId
|
|
578
|
+
? { parentTaskId: opts.parentContext.parentTaskId ?? opts.parentContext.taskId }
|
|
579
|
+
: {}),
|
|
580
|
+
agentDepth: opts.parentContext?.depth ?? 0,
|
|
581
|
+
agentMaxDepth: opts.parentContext?.maxDepth ?? 1,
|
|
582
|
+
...(opts.parentContext?.allowedTools ? { agentAllowedTools: opts.parentContext.allowedTools } : {}),
|
|
583
|
+
...(opts.parentContext?.model ?? opts.model ? { agentModel: opts.parentContext?.model ?? opts.model } : {}),
|
|
529
584
|
};
|
|
530
585
|
const allSafe = finalizedCalls.length > 1 && finalizedCalls.every((c) => deps.registry.get(c.name)?.isConcurrencySafe !== false);
|
|
531
|
-
|
|
532
|
-
|
|
586
|
+
// Gate phase: policy decision + approval prompt for one call. Runs
|
|
587
|
+
// sequentially (approval UI is one-modal-at-a-time). Commits deny/user-deny
|
|
588
|
+
// results immediately — gate runs in call order so these stay ordered.
|
|
589
|
+
// Returns true when the call is approved for execution.
|
|
590
|
+
const gateCall = async (call) => {
|
|
533
591
|
const decision = await deps.policy.evaluate({ name: call.name, input: call.input }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
|
|
534
592
|
emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
|
|
535
593
|
// Mirror to KlyroEvent bus
|
|
@@ -546,7 +604,7 @@ export async function run(opts, deps) {
|
|
|
546
604
|
emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
|
|
547
605
|
telemetry.recordToolError(call, 'policy_denied');
|
|
548
606
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
|
|
549
|
-
return;
|
|
607
|
+
return false;
|
|
550
608
|
}
|
|
551
609
|
if (decision.action === 'ask') {
|
|
552
610
|
emitKlyro({ type: 'permission.ask', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, reason: decision.reason });
|
|
@@ -554,6 +612,8 @@ export async function run(opts, deps) {
|
|
|
554
612
|
toolName: call.name,
|
|
555
613
|
reason: decision.reason,
|
|
556
614
|
summary: summarizeToolCall(call),
|
|
615
|
+
input: call.input,
|
|
616
|
+
pattern: patternForCall(call.name, call.input),
|
|
557
617
|
});
|
|
558
618
|
// Approval UI in TUI handles y/a/A/n/e/? — e edits input, ? explains
|
|
559
619
|
if (choice === 'deny') {
|
|
@@ -567,15 +627,49 @@ export async function run(opts, deps) {
|
|
|
567
627
|
await checkpoint(denyMsg2, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true });
|
|
568
628
|
telemetry.recordToolError(call, 'user_denied');
|
|
569
629
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true, latencyMs: 0 });
|
|
570
|
-
return;
|
|
630
|
+
return false;
|
|
571
631
|
}
|
|
572
632
|
// Handle 'edit' choice: for now treat as allow with edited input (future: re-prompt)
|
|
573
633
|
repairs++;
|
|
574
634
|
}
|
|
635
|
+
return true;
|
|
636
|
+
};
|
|
637
|
+
// Execute phase: run the tool with no transcript writes, so concurrent
|
|
638
|
+
// executions can't interleave. A throw here becomes a tool error (an
|
|
639
|
+
// executor crash must never kill the step).
|
|
640
|
+
const execTool = async (call) => {
|
|
575
641
|
const t0 = Date.now();
|
|
576
642
|
emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
|
|
577
|
-
|
|
643
|
+
let obs;
|
|
644
|
+
try {
|
|
645
|
+
obs = await deps.registry.execute(call.name, call.input, toolCtx);
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
obs = { ok: false, error: { code: 'EXEC_CRASH', message: err instanceof Error ? err.message : String(err) } };
|
|
649
|
+
}
|
|
578
650
|
const latencyMs = Date.now() - t0;
|
|
651
|
+
return { obs, latencyMs };
|
|
652
|
+
};
|
|
653
|
+
// 5.2 stuck termination (P0-3): the FIRST detection injects one
|
|
654
|
+
// "change approach" synthetic message for the next model turn; the SECOND
|
|
655
|
+
// detection aborts the automated loop with a `stuck` status so a repeated
|
|
656
|
+
// tool loop never burns unbounded tokens. Covers both signals: identical
|
|
657
|
+
// call ×3 and same-file edited >8×.
|
|
658
|
+
const markStuck = async (msg) => {
|
|
659
|
+
stuckTriggers++;
|
|
660
|
+
emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: msg });
|
|
661
|
+
if (stuckTriggers === 1) {
|
|
662
|
+
const note = { role: 'user', content: [text(`[system note] Stuck detected: ${msg}. Stop repeating the same action; change approach.`)] };
|
|
663
|
+
transcript.push(note);
|
|
664
|
+
await checkpoint(note);
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
stuckAbort = true;
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
// Commit phase: fold one execution result into the transcript, in original
|
|
671
|
+
// call order. The only writer — call sequentially, never concurrently.
|
|
672
|
+
const commitResult = async (call, obs, latencyMs) => {
|
|
579
673
|
const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
|
|
580
674
|
const toolMsg = {
|
|
581
675
|
role: 'tool',
|
|
@@ -620,7 +714,7 @@ export async function run(opts, deps) {
|
|
|
620
714
|
const cnt = (fileEditCounts.get(fileChanged.path) ?? 0) + 1;
|
|
621
715
|
fileEditCounts.set(fileChanged.path, cnt);
|
|
622
716
|
if (cnt > 8) {
|
|
623
|
-
|
|
717
|
+
await markStuck(`same file edited >8×: ${fileChanged.path}`);
|
|
624
718
|
}
|
|
625
719
|
}
|
|
626
720
|
}
|
|
@@ -631,23 +725,42 @@ export async function run(opts, deps) {
|
|
|
631
725
|
callHistory.shift();
|
|
632
726
|
const last3 = callHistory.slice(-3);
|
|
633
727
|
if (last3.length === 3 && last3[0] === last3[1] && last3[1] === last3[2]) {
|
|
634
|
-
|
|
635
|
-
// Inject system note for next turn
|
|
636
|
-
const note = { role: 'user', content: [text(`[system note] Stuck detected: identical call ×3: ${sig}. Try a different approach.`)] };
|
|
637
|
-
transcript.push(note);
|
|
638
|
-
await checkpoint(note);
|
|
728
|
+
await markStuck(`identical call ×3: ${sig}`);
|
|
639
729
|
}
|
|
640
730
|
};
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
731
|
+
// Sequential path: gate → execute → commit per call, in order.
|
|
732
|
+
const runOne = async (call) => {
|
|
733
|
+
if (!(await gateCall(call)))
|
|
734
|
+
return;
|
|
735
|
+
const { obs, latencyMs } = await execTool(call);
|
|
736
|
+
await commitResult(call, obs, latencyMs);
|
|
737
|
+
};
|
|
738
|
+
// 3.5 — parallel when every call is concurrencySafe, sequential otherwise.
|
|
739
|
+
// Gate runs sequentially in both paths (approval UI is one-at-a-time).
|
|
740
|
+
// Parallel path executes concurrently but commits in original call order,
|
|
741
|
+
// so the transcript reads exactly as if the calls ran in order (this is
|
|
742
|
+
// what the old BUG-002 sequential fallback was protecting).
|
|
644
743
|
if (allSafe) {
|
|
744
|
+
const approved = [];
|
|
645
745
|
for (const call of finalizedCalls) {
|
|
646
746
|
toolCallCount++;
|
|
647
|
-
await
|
|
747
|
+
if (await gateCall(call))
|
|
748
|
+
approved.push(call);
|
|
648
749
|
if (opts.signal?.aborted)
|
|
649
750
|
break;
|
|
650
751
|
}
|
|
752
|
+
if (approved.length > 0 && !opts.signal?.aborted) {
|
|
753
|
+
const settled = await Promise.allSettled(approved.map((c) => execTool(c)));
|
|
754
|
+
for (let i = 0; i < approved.length; i++) {
|
|
755
|
+
const s = settled[i];
|
|
756
|
+
if (s.status === 'fulfilled') {
|
|
757
|
+
await commitResult(approved[i], s.value.obs, s.value.latencyMs);
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
await commitResult(approved[i], { ok: false, error: { code: 'EXEC_CRASH', message: String(s.reason) } }, 0);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
651
764
|
}
|
|
652
765
|
else {
|
|
653
766
|
for (const call of finalizedCalls) {
|
|
@@ -666,6 +779,9 @@ export async function run(opts, deps) {
|
|
|
666
779
|
}
|
|
667
780
|
catch { /* ignore */ }
|
|
668
781
|
}
|
|
782
|
+
// 5.2 — terminate the automated loop once stuck recurs after the note
|
|
783
|
+
if (stuckAbort)
|
|
784
|
+
break;
|
|
669
785
|
}
|
|
670
786
|
if (opts.signal?.aborted) {
|
|
671
787
|
emit?.({ kind: 'aborted' });
|
|
@@ -678,6 +794,18 @@ export async function run(opts, deps) {
|
|
|
678
794
|
await closeTracer();
|
|
679
795
|
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
680
796
|
}
|
|
797
|
+
// 5.2 — stuck termination (P0-3): bounded stop instead of unbounded token burn.
|
|
798
|
+
if (stuckAbort) {
|
|
799
|
+
emit?.({ kind: 'final_text', text: finalText });
|
|
800
|
+
if (store && sessionId) {
|
|
801
|
+
try {
|
|
802
|
+
await store.setStatus(sessionId, 'stuck', finalText);
|
|
803
|
+
}
|
|
804
|
+
catch { /* ignore */ }
|
|
805
|
+
}
|
|
806
|
+
await closeTracer();
|
|
807
|
+
return { status: 'stuck', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
808
|
+
}
|
|
681
809
|
emit?.({ kind: 'final_text', text: finalText });
|
|
682
810
|
if (store && sessionId) {
|
|
683
811
|
try {
|
|
@@ -688,6 +816,88 @@ export async function run(opts, deps) {
|
|
|
688
816
|
await closeTracer();
|
|
689
817
|
return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
690
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* Estimate usage for a turn whose provider omitted it (Ollama, vLLM,
|
|
821
|
+
* proxies). Input is measured from the actual request via the tokenizer;
|
|
822
|
+
* output is a chars/4 heuristic over generated text + tool arguments.
|
|
823
|
+
*/
|
|
824
|
+
function estimateTurnUsage(system, messages, textOut, toolCalls) {
|
|
825
|
+
let argsChars = 0;
|
|
826
|
+
for (const tc of toolCalls.values())
|
|
827
|
+
argsChars += tc.argsJson.length;
|
|
828
|
+
return {
|
|
829
|
+
input: totalTokens(system, messages),
|
|
830
|
+
output: Math.max(1, Math.ceil((textOut.length + argsChars) / 4)),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Validate one assembled tool call before it reaches policy or execution.
|
|
835
|
+
* Returns the parsed+schema-validated input, or a structured error output
|
|
836
|
+
* (MALFORMED_TOOL_CALL / UNKNOWN_TOOL) that the runtime records as a tool
|
|
837
|
+
* result without executing anything.
|
|
838
|
+
*/
|
|
839
|
+
function validateFinalizedCall(registry, id, name, argsJson) {
|
|
840
|
+
void id;
|
|
841
|
+
let parsed = {};
|
|
842
|
+
if (argsJson.trim()) {
|
|
843
|
+
try {
|
|
844
|
+
parsed = JSON.parse(argsJson);
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return {
|
|
848
|
+
ok: false,
|
|
849
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
850
|
+
output: {
|
|
851
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
852
|
+
tool: name,
|
|
853
|
+
message: 'Tool arguments were incomplete or invalid JSON. Re-issue the call with complete arguments.',
|
|
854
|
+
retryable: true,
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
860
|
+
return {
|
|
861
|
+
ok: false,
|
|
862
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
863
|
+
output: {
|
|
864
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
865
|
+
tool: name,
|
|
866
|
+
message: 'Tool arguments must be a JSON object. Re-issue the call with complete arguments.',
|
|
867
|
+
retryable: true,
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
const tool = registry.get(name);
|
|
872
|
+
if (!tool) {
|
|
873
|
+
return {
|
|
874
|
+
ok: false,
|
|
875
|
+
code: 'UNKNOWN_TOOL',
|
|
876
|
+
output: {
|
|
877
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
878
|
+
tool: name,
|
|
879
|
+
message: `Unknown tool "${name}". Use one of the available tools.`,
|
|
880
|
+
retryable: true,
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
const checked = tool.inputSchema.safeParse(parsed);
|
|
885
|
+
if (!checked.success) {
|
|
886
|
+
const first = checked.error.issues[0];
|
|
887
|
+
const detail = first ? ` (${first.path.join('.') || 'input'}: ${first.message})` : '';
|
|
888
|
+
return {
|
|
889
|
+
ok: false,
|
|
890
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
891
|
+
output: {
|
|
892
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
893
|
+
tool: name,
|
|
894
|
+
message: `Tool arguments failed validation${detail}. Re-issue the call with correct arguments.`,
|
|
895
|
+
retryable: true,
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
return { ok: true, input: parsed };
|
|
900
|
+
}
|
|
691
901
|
function redactOutput(v) {
|
|
692
902
|
if (typeof v === 'string')
|
|
693
903
|
return redact(v);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScopedRegistry — a `ToolRegistry` that narrows the surface to a resolved
|
|
3
|
+
* capability set.
|
|
4
|
+
*
|
|
5
|
+
* Both surfaces the runtime relies on (`toolDefinitions(deps.registry)`
|
|
6
|
+
* and `deps.registry.execute(...)`) go through `list()` / `execute()`, so
|
|
7
|
+
* wrapping the child's registry in this class enforces the tool allow-set
|
|
8
|
+
* end-to-end without touching the runtime loop: the model only sees the
|
|
9
|
+
* allowed tools, and any call to a disallowed tool returns `UNKNOWN_TOOL`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Tool, ToolContext, ToolResult } from '../tools/types.js';
|
|
12
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
13
|
+
export declare class ScopedRegistry extends ToolRegistry {
|
|
14
|
+
private readonly parent;
|
|
15
|
+
private readonly allowed;
|
|
16
|
+
constructor(parent: ToolRegistry, allowed: ReadonlySet<string>);
|
|
17
|
+
get(name: string): Tool<unknown, unknown> | undefined;
|
|
18
|
+
list(): Tool<unknown, unknown>[];
|
|
19
|
+
/** Which tools this scope permits, by name (for diagnostics/tests). */
|
|
20
|
+
names(): string[];
|
|
21
|
+
execute(name: string, rawInput: unknown, ctx: ToolContext): Promise<ToolResult<unknown>>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScopedRegistry — a `ToolRegistry` that narrows the surface to a resolved
|
|
3
|
+
* capability set.
|
|
4
|
+
*
|
|
5
|
+
* Both surfaces the runtime relies on (`toolDefinitions(deps.registry)`
|
|
6
|
+
* and `deps.registry.execute(...)`) go through `list()` / `execute()`, so
|
|
7
|
+
* wrapping the child's registry in this class enforces the tool allow-set
|
|
8
|
+
* end-to-end without touching the runtime loop: the model only sees the
|
|
9
|
+
* allowed tools, and any call to a disallowed tool returns `UNKNOWN_TOOL`.
|
|
10
|
+
*/
|
|
11
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
12
|
+
export class ScopedRegistry extends ToolRegistry {
|
|
13
|
+
parent;
|
|
14
|
+
allowed;
|
|
15
|
+
constructor(parent, allowed) {
|
|
16
|
+
super();
|
|
17
|
+
this.parent = parent;
|
|
18
|
+
this.allowed = allowed;
|
|
19
|
+
}
|
|
20
|
+
get(name) {
|
|
21
|
+
return this.allowed.has(name) ? this.parent.get(name) : undefined;
|
|
22
|
+
}
|
|
23
|
+
list() {
|
|
24
|
+
return this.parent.list().filter((t) => this.allowed.has(t.name));
|
|
25
|
+
}
|
|
26
|
+
/** Which tools this scope permits, by name (for diagnostics/tests). */
|
|
27
|
+
names() {
|
|
28
|
+
return [...this.allowed].sort();
|
|
29
|
+
}
|
|
30
|
+
async execute(name, rawInput, ctx) {
|
|
31
|
+
if (!this.allowed.has(name)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: {
|
|
35
|
+
code: 'UNKNOWN_TOOL',
|
|
36
|
+
message: `Tool not available in this agent context: ${name}`,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return this.parent.execute(name, rawInput, ctx);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task manager — tracks the lifecycle of a (possibly nested) agent run.
|
|
3
|
+
*
|
|
4
|
+
* Each `spawn_agent` call creates a `TaskRecord` in a `TaskManager`.
|
|
5
|
+
* The manager owns an `AbortController` per task so that the parent (or
|
|
6
|
+
* the user) can cancel a running child without tearing down the entire
|
|
7
|
+
* process. It enforces a recursion / depth cap and emits `subtask.*`
|
|
8
|
+
* events so the event bus and trace writer can see the task tree.
|
|
9
|
+
*
|
|
10
|
+
* Exactly one task manager exists per session, wired into the orchestrator
|
|
11
|
+
* (src/agent/orchestrator.ts), which the runtime reaches via
|
|
12
|
+
* `parentContext`.
|
|
13
|
+
*/
|
|
14
|
+
import { type EventBus } from '../events/bus.js';
|
|
15
|
+
export type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'timed_out' | 'blocked';
|
|
16
|
+
export interface TaskError {
|
|
17
|
+
code: string;
|
|
18
|
+
message: string;
|
|
19
|
+
details?: unknown;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The full runtime record for one task. `summary` is a free-form progress
|
|
23
|
+
* log; `done` resolves to this very record once the task reaches a terminal
|
|
24
|
+
* status, so `await rec.done` yields the record with its final status.
|
|
25
|
+
*/
|
|
26
|
+
export interface TaskRecord {
|
|
27
|
+
id: string;
|
|
28
|
+
parentTaskId?: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
agentName: string;
|
|
31
|
+
status: TaskStatus;
|
|
32
|
+
cwd: string;
|
|
33
|
+
depth: number;
|
|
34
|
+
maxDepth: number;
|
|
35
|
+
model?: string;
|
|
36
|
+
startedAt: number;
|
|
37
|
+
finishedAt?: number;
|
|
38
|
+
summary: string[];
|
|
39
|
+
error?: TaskError;
|
|
40
|
+
abortController: AbortController;
|
|
41
|
+
done: Promise<TaskRecord>;
|
|
42
|
+
/** Internal — resolves `done`. Do not call outside TaskManager. */
|
|
43
|
+
_resolveDone: (r: TaskRecord) => void;
|
|
44
|
+
/** Internal cleanup handles, not part of the stable contract. */
|
|
45
|
+
_cleanupParentAbort?: () => void;
|
|
46
|
+
timeoutHandle?: NodeJS.Timeout;
|
|
47
|
+
}
|
|
48
|
+
/** Serialisable, stable subset of a task for logs, tools, and the trace. */
|
|
49
|
+
export interface TaskSummary {
|
|
50
|
+
id: string;
|
|
51
|
+
parentTaskId?: string;
|
|
52
|
+
agentName: string;
|
|
53
|
+
sessionId: string;
|
|
54
|
+
status: TaskStatus;
|
|
55
|
+
cwd: string;
|
|
56
|
+
depth: number;
|
|
57
|
+
model?: string;
|
|
58
|
+
durationMs?: number;
|
|
59
|
+
}
|
|
60
|
+
export interface CreateTaskOpts {
|
|
61
|
+
agentName: string;
|
|
62
|
+
cwd: string;
|
|
63
|
+
depth: number;
|
|
64
|
+
maxDepth: number;
|
|
65
|
+
parentTaskId?: string;
|
|
66
|
+
model?: string;
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
/** Abort this task when the given signal fires. */
|
|
69
|
+
abortOnParent?: AbortSignal;
|
|
70
|
+
/** Defaults to true. When false, the task starts in 'queued'. */
|
|
71
|
+
autoStart?: boolean;
|
|
72
|
+
}
|
|
73
|
+
export declare class TaskManager {
|
|
74
|
+
private readonly tasks;
|
|
75
|
+
private readonly watchers;
|
|
76
|
+
private counter;
|
|
77
|
+
private readonly sessionId;
|
|
78
|
+
private readonly bus;
|
|
79
|
+
constructor(opts: {
|
|
80
|
+
sessionId: string;
|
|
81
|
+
bus?: EventBus;
|
|
82
|
+
});
|
|
83
|
+
/** Create a task. Blocks instead of running when the recursion cap is exceeded. */
|
|
84
|
+
create(opts: CreateTaskOpts): TaskRecord;
|
|
85
|
+
/** Look up a live record. */
|
|
86
|
+
get(id: string): TaskRecord | undefined;
|
|
87
|
+
/** List tasks, optionally filtered by parent and/or status. Returns summaries. */
|
|
88
|
+
list(filter?: {
|
|
89
|
+
parentTaskId?: string;
|
|
90
|
+
status?: TaskStatus;
|
|
91
|
+
}): TaskSummary[];
|
|
92
|
+
/** Transition a task to a terminal status and resolve its `done`. */
|
|
93
|
+
finish(id: string, status: 'succeeded' | 'failed' | 'cancelled' | 'timed_out' | 'blocked', patch?: {
|
|
94
|
+
summary?: string[];
|
|
95
|
+
error?: TaskError;
|
|
96
|
+
}): TaskRecord;
|
|
97
|
+
/** Cancel a live task: abort, mark cancelled/timed_out, resolve done. */
|
|
98
|
+
cancel(id: string, reason?: string, finalStatus?: 'cancelled' | 'timed_out'): TaskRecord;
|
|
99
|
+
/** Confirm a task that policy/depth prevented from running. */
|
|
100
|
+
block(id: string, reason: string): TaskRecord;
|
|
101
|
+
/** Cancel `id` and every transitive descendant. */
|
|
102
|
+
cancelTree(id: string, reason?: string): Promise<void>;
|
|
103
|
+
/** Subscribe to status transitions for one task. Delivers the current status immediately. */
|
|
104
|
+
subscribe(id: string, listener: (r: TaskRecord) => void): () => void;
|
|
105
|
+
/** Cancel every live task (used on shutdown / ctrl-c). */
|
|
106
|
+
shutdown(): Promise<void>;
|
|
107
|
+
/** Compact serialisable summary. */
|
|
108
|
+
toSummary(r: TaskRecord): TaskSummary;
|
|
109
|
+
/** Resolve a record's `done` (idempotent). */
|
|
110
|
+
private resolve;
|
|
111
|
+
private _resolveOnce;
|
|
112
|
+
/** Deliver a transition to the task's own watchers, then each ancestor's. */
|
|
113
|
+
private emit;
|
|
114
|
+
private notify;
|
|
115
|
+
}
|