aiden-runtime 4.5.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/dist/cli/v4/aidenCLI.js +185 -99
- package/dist/cli/v4/chatSession.js +107 -0
- package/dist/cli/v4/commands/_runtimeToggleHelpers.js +2 -0
- package/dist/cli/v4/commands/fanout.js +42 -59
- package/dist/cli/v4/commands/help.js +6 -0
- package/dist/cli/v4/commands/index.js +16 -1
- package/dist/cli/v4/commands/mcp.js +80 -54
- package/dist/cli/v4/commands/plannerGuard.js +53 -0
- package/dist/cli/v4/commands/recovery.js +122 -0
- package/dist/cli/v4/commands/runs.js +22 -2
- package/dist/cli/v4/commands/spawnPause.js +93 -0
- package/dist/cli/v4/daemonAgentBuilder.js +4 -1
- package/dist/cli/v4/defaultSoul.js +1 -1
- package/dist/core/v4/aidenAgent.js +219 -1
- package/dist/core/v4/daemon/bootstrap.js +47 -0
- package/dist/core/v4/daemon/db/migrations.js +66 -0
- package/dist/core/v4/daemon/runStore.js +33 -3
- package/dist/core/v4/providerFallback.js +35 -2
- package/dist/core/v4/runtimeToggles.js +30 -3
- package/dist/core/v4/selfimprovement/recoveryStore.js +307 -0
- package/dist/core/v4/selfimprovement/signatureBuilder.js +158 -0
- package/dist/core/v4/subagent/childBuilder.js +391 -0
- package/dist/core/v4/subagent/fanout.js +75 -51
- package/dist/core/v4/subagent/spawnPause.js +191 -0
- package/dist/core/v4/subagent/spawnSubAgent.js +310 -0
- package/dist/core/v4/toolRegistry.js +19 -3
- package/dist/core/version.js +1 -1
- package/dist/moat/plannerGuard.js +29 -0
- package/dist/providers/v4/anthropicAdapter.js +31 -3
- package/dist/providers/v4/chatCompletionsAdapter.js +26 -3
- package/dist/providers/v4/codexResponsesAdapter.js +25 -2
- package/dist/providers/v4/ollamaPromptToolsAdapter.js +57 -2
- package/dist/tools/v4/index.js +17 -3
- package/dist/tools/v4/skills/lookupToolSchema.js +6 -1
- package/dist/tools/v4/subagent/spawnSubAgentTool.js +334 -0
- package/dist/tools/v4/subagent/subagentFanout.js +53 -1
- package/package.json +7 -3
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/subagent/spawnPause.ts — v4.6 Phase 3A.
|
|
10
|
+
*
|
|
11
|
+
* Operator kill-switch for sub-agent spawning. When PAUSED, any new
|
|
12
|
+
* `spawn_sub_agent` or `subagent_fanout` invocation returns a typed
|
|
13
|
+
* failure envelope (`errorCode: 'SUBAGENT_SPAWN_PAUSED'`) BEFORE any
|
|
14
|
+
* runs row is written, child agent built, or provider hit. In-flight
|
|
15
|
+
* children continue uninterrupted — the gate is at tool-handler
|
|
16
|
+
* entry only.
|
|
17
|
+
*
|
|
18
|
+
* Storage: a file marker at `$aidenHome/spawn.paused` (the
|
|
19
|
+
* `paths.root` returned by `resolveAidenPaths()`). This choice
|
|
20
|
+
* differs deliberately from the reference multi-agent system, whose
|
|
21
|
+
* pause flag is an in-process boolean — Aiden's REPL, daemon, and
|
|
22
|
+
* MCP server can all coexist on the same machine, so a single
|
|
23
|
+
* shared marker file is the cheapest way to coordinate pause state
|
|
24
|
+
* across all three runtimes. The marker survives process restart;
|
|
25
|
+
* the boot card surfaces a "spawn-paused" indicator so an operator
|
|
26
|
+
* who forgot they paused last week doesn't sit confused.
|
|
27
|
+
*
|
|
28
|
+
* Marker format: a single-line JSON document
|
|
29
|
+
* { pausedAt: number; reason: string | null; pausedBy: string }
|
|
30
|
+
*
|
|
31
|
+
* Atomic writes: every `pause()` writes to a sibling `.tmp` path
|
|
32
|
+
* and renames atomically so a concurrent reader can never observe
|
|
33
|
+
* a half-written file. `status()` tolerates an unreadable marker
|
|
34
|
+
* (returns `{paused: true}` with no metadata) rather than crashing
|
|
35
|
+
* — the marker EXISTING is the durable fact; the JSON payload is
|
|
36
|
+
* forensic detail.
|
|
37
|
+
*
|
|
38
|
+
* Module-level singleton: `initSpawnPause({aidenHome})` then
|
|
39
|
+
* `getSpawnPause()`. Mirrors the `runtimeToggles` pattern in
|
|
40
|
+
* `core/v4/runtimeToggles.ts` but does NOT route through it —
|
|
41
|
+
* runtimeToggles is config-yaml backed (no per-toggle metadata
|
|
42
|
+
* field), and the reason/pausedAt/pausedBy fields are first-class
|
|
43
|
+
* here.
|
|
44
|
+
*/
|
|
45
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
46
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
47
|
+
};
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.SpawnPauseState = void 0;
|
|
50
|
+
exports.initSpawnPause = initSpawnPause;
|
|
51
|
+
exports.getSpawnPause = getSpawnPause;
|
|
52
|
+
exports._resetSpawnPauseForTests = _resetSpawnPauseForTests;
|
|
53
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
54
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
55
|
+
// ── Implementation ───────────────────────────────────────────────────────
|
|
56
|
+
const MARKER_FILENAME = 'spawn.paused';
|
|
57
|
+
/**
|
|
58
|
+
* File-marker-backed pause state. Concurrent processes (REPL, daemon,
|
|
59
|
+
* MCP server) all read/write the same marker, so flipping pause from
|
|
60
|
+
* a REPL slash command is observed by an MCP-mode `subagent_fanout`
|
|
61
|
+
* call within milliseconds (next read).
|
|
62
|
+
*/
|
|
63
|
+
class SpawnPauseState {
|
|
64
|
+
constructor(opts) {
|
|
65
|
+
this.markerPath = node_path_1.default.join(opts.aidenHome, MARKER_FILENAME);
|
|
66
|
+
this.now = opts.now ?? (() => Date.now());
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Hot path — called at the top of every `spawn_sub_agent` and
|
|
70
|
+
* `subagent_fanout` invocation. MUST stay cheap (single
|
|
71
|
+
* `fs.existsSync`). The metadata read is deferred to `status()`.
|
|
72
|
+
*
|
|
73
|
+
* Any error (FS busy, permission, etc.) silently returns
|
|
74
|
+
* `false` — failing-open is the right default because a paused
|
|
75
|
+
* state that operators can't query/clear due to FS hiccups would
|
|
76
|
+
* brick the whole spawning surface.
|
|
77
|
+
*/
|
|
78
|
+
isPaused() {
|
|
79
|
+
try {
|
|
80
|
+
return node_fs_1.default.existsSync(this.markerPath);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Apply the pause marker. Atomic via tmp-file + rename so a
|
|
88
|
+
* mid-write status read never sees corrupt JSON. Idempotent —
|
|
89
|
+
* pausing while already paused just overwrites the marker (which
|
|
90
|
+
* is the right semantic for "re-pause with a fresh reason").
|
|
91
|
+
*/
|
|
92
|
+
pause(opts) {
|
|
93
|
+
const payload = {
|
|
94
|
+
pausedAt: this.now(),
|
|
95
|
+
reason: opts.reason ?? null,
|
|
96
|
+
pausedBy: opts.pausedBy,
|
|
97
|
+
};
|
|
98
|
+
const tmpPath = `${this.markerPath}.tmp`;
|
|
99
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.markerPath), { recursive: true });
|
|
100
|
+
node_fs_1.default.writeFileSync(tmpPath, JSON.stringify(payload), { encoding: 'utf8' });
|
|
101
|
+
node_fs_1.default.renameSync(tmpPath, this.markerPath);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Clear the pause marker. Idempotent — ENOENT (already resumed)
|
|
105
|
+
* is treated as success, so two operators calling resume back-
|
|
106
|
+
* to-back don't error on the second.
|
|
107
|
+
*/
|
|
108
|
+
resume() {
|
|
109
|
+
try {
|
|
110
|
+
node_fs_1.default.unlinkSync(this.markerPath);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
const code = e.code;
|
|
114
|
+
if (code !== 'ENOENT')
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Read the current pause state with metadata. When the marker
|
|
120
|
+
* exists but is unreadable / malformed JSON, returns
|
|
121
|
+
* `{paused: true}` with no metadata fields — the EXISTENCE of
|
|
122
|
+
* the marker is the durable contract; the JSON payload is best-
|
|
123
|
+
* effort forensic detail.
|
|
124
|
+
*/
|
|
125
|
+
status() {
|
|
126
|
+
if (!this.isPaused()) {
|
|
127
|
+
return { paused: false };
|
|
128
|
+
}
|
|
129
|
+
let raw;
|
|
130
|
+
try {
|
|
131
|
+
raw = node_fs_1.default.readFileSync(this.markerPath, 'utf8');
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return { paused: true };
|
|
135
|
+
}
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { paused: true };
|
|
142
|
+
}
|
|
143
|
+
const pausedAt = typeof parsed.pausedAt === 'number' ? parsed.pausedAt : undefined;
|
|
144
|
+
return {
|
|
145
|
+
paused: true,
|
|
146
|
+
pausedAt,
|
|
147
|
+
reason: parsed.reason ?? null,
|
|
148
|
+
pausedBy: parsed.pausedBy ?? 'unknown',
|
|
149
|
+
durationMs: pausedAt !== undefined ? Math.max(0, this.now() - pausedAt) : undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
exports.SpawnPauseState = SpawnPauseState;
|
|
154
|
+
// ── Module-level singleton ───────────────────────────────────────────────
|
|
155
|
+
let _singleton = null;
|
|
156
|
+
/**
|
|
157
|
+
* Initialize the process-wide pause state. Called once at boot
|
|
158
|
+
* (REPL: `buildAgentRuntime`; daemon: dispatcher bootstrap; MCP:
|
|
159
|
+
* `wireSubagentFanout`). Subsequent calls REPLACE the singleton —
|
|
160
|
+
* tests rely on this to swap the marker dir cleanly.
|
|
161
|
+
*/
|
|
162
|
+
function initSpawnPause(opts) {
|
|
163
|
+
_singleton = new SpawnPauseState(opts);
|
|
164
|
+
return _singleton;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Read the current singleton. Throws if `initSpawnPause` hasn't
|
|
168
|
+
* been called yet — the spawn / fanout tool handlers cannot
|
|
169
|
+
* function without it, and a silent fallback to "not paused" would
|
|
170
|
+
* defeat the kill-switch's purpose. Boot wiring is responsible for
|
|
171
|
+
* calling init before any tool handler can fire.
|
|
172
|
+
*
|
|
173
|
+
* For environments that genuinely don't have a marker dir (some
|
|
174
|
+
* test contexts), call `initSpawnPause({aidenHome: <tmp>})` with
|
|
175
|
+
* a throwaway path.
|
|
176
|
+
*/
|
|
177
|
+
function getSpawnPause() {
|
|
178
|
+
if (!_singleton) {
|
|
179
|
+
throw new Error('spawnPause: not initialized — call initSpawnPause({aidenHome}) at boot. ' +
|
|
180
|
+
'This usually means a sub-agent tool handler fired before runtime wiring completed.');
|
|
181
|
+
}
|
|
182
|
+
return _singleton;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Test-only — reset the singleton so the next `initSpawnPause`
|
|
186
|
+
* call wires a fresh state. Production callers should never need
|
|
187
|
+
* this; `initSpawnPause` is already idempotent for re-init.
|
|
188
|
+
*/
|
|
189
|
+
function _resetSpawnPauseForTests() {
|
|
190
|
+
_singleton = null;
|
|
191
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/subagent/spawnSubAgent.ts — v4.6 Phase 1.
|
|
10
|
+
*
|
|
11
|
+
* Public spawn primitive. Synchronously runs one child agent to handle
|
|
12
|
+
* a delegated sub-task, returns a structured `SubAgentResult` envelope.
|
|
13
|
+
* NEVER throws — every error path produces an envelope with the
|
|
14
|
+
* appropriate `status` + `error` fields so the parent's LLM can
|
|
15
|
+
* reason about the failure.
|
|
16
|
+
*
|
|
17
|
+
* Contract per `docs/v4.6/phase-1-design.md` §3, §6, §8.
|
|
18
|
+
*
|
|
19
|
+
* - Single child (Phase 1; batch is Phase 2 via subagent_fanout
|
|
20
|
+
* refactor)
|
|
21
|
+
* - Synchronous: the parent's tool dispatch awaits this Promise
|
|
22
|
+
* - Cooperative cancellation: parent's AbortSignal cascades to
|
|
23
|
+
* child via a linked AbortController
|
|
24
|
+
* - Wall-clock timeout: hard cap via setTimeout → child interrupt
|
|
25
|
+
* - Persistence: writes a `runs` row with `spawned_from_run_id` +
|
|
26
|
+
* `spawned_from_session_id` linking back to the parent
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.spawnSubAgent = spawnSubAgent;
|
|
30
|
+
const node_crypto_1 = require("node:crypto");
|
|
31
|
+
const childBuilder_1 = require("./childBuilder");
|
|
32
|
+
const factory_1 = require("../logger/factory");
|
|
33
|
+
// ── Constants ─────────────────────────────────────────────────────────────
|
|
34
|
+
const DEFAULT_TIMEOUT_MS = 600000;
|
|
35
|
+
const MIN_TIMEOUT_MS = 1000;
|
|
36
|
+
const MAX_TIMEOUT_MS = 3600000;
|
|
37
|
+
const DEFAULT_MAX_ITERATIONS = 50;
|
|
38
|
+
const MIN_MAX_ITERATIONS = 1;
|
|
39
|
+
const MAX_MAX_ITERATIONS = 200;
|
|
40
|
+
// ── Implementation ────────────────────────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* Spawn one child agent. Always returns an envelope; never throws.
|
|
43
|
+
*
|
|
44
|
+
* Lifecycle (per §6 state machine):
|
|
45
|
+
*
|
|
46
|
+
* 1. Generate child sessionId (flat UUID).
|
|
47
|
+
* 2. Insert `runs` row with `status: 'running'` + lineage columns.
|
|
48
|
+
* 3. Build child agent (clones FallbackAdapter, intersects toolsets,
|
|
49
|
+
* filters blocklist, fresh ApprovalEngine with auto-deny).
|
|
50
|
+
* 4. Construct linked AbortController: parent's signal feeds into
|
|
51
|
+
* it; a setTimeout on `timeoutMs` also aborts it.
|
|
52
|
+
* 5. Run `child.runConversation(history, { signal: childCtrl.signal })`.
|
|
53
|
+
* 6. On return / throw / timeout, classify into the envelope's
|
|
54
|
+
* status + exitReason and update the runs row's status.
|
|
55
|
+
* 7. Clean up timer + signal listener.
|
|
56
|
+
*/
|
|
57
|
+
async function spawnSubAgent(spec, deps, ctx) {
|
|
58
|
+
const startedAt = Date.now();
|
|
59
|
+
// ── 1. Clamp inputs ─────────────────────────────────────────────────────
|
|
60
|
+
const maxIterations = clamp(spec.maxIterations ?? DEFAULT_MAX_ITERATIONS, MIN_MAX_ITERATIONS, MAX_MAX_ITERATIONS);
|
|
61
|
+
const timeoutMs = clamp(spec.timeoutMs ?? DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
62
|
+
// ── 2. Fresh sessionId + run row ────────────────────────────────────────
|
|
63
|
+
const childSessionId = (0, node_crypto_1.randomUUID)();
|
|
64
|
+
// Pre-create the child run row in 'running' state so the envelope
|
|
65
|
+
// always carries a valid childRunId — even if buildChildAgent throws.
|
|
66
|
+
let childRunId;
|
|
67
|
+
try {
|
|
68
|
+
childRunId = deps.runStore.create({
|
|
69
|
+
sessionId: childSessionId,
|
|
70
|
+
instanceId: deps.instanceId,
|
|
71
|
+
status: 'running',
|
|
72
|
+
startedAt,
|
|
73
|
+
spawnedFromRunId: ctx.parentRunId,
|
|
74
|
+
spawnedFromSessionId: ctx.parentSessionId,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
// Persistence failed before we even started — surface as failed
|
|
79
|
+
// envelope with a synthetic id of '0' so the contract holds.
|
|
80
|
+
return failureEnvelope({
|
|
81
|
+
childRunId: '0',
|
|
82
|
+
childSessionId,
|
|
83
|
+
error: `Failed to create child run row: ${errorMessage(err)}`,
|
|
84
|
+
durationMs: Date.now() - startedAt,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// ── 3. Build child agent ────────────────────────────────────────────────
|
|
88
|
+
const logger = deps.logger ?? (0, factory_1.noopLogger)();
|
|
89
|
+
let agentBundle;
|
|
90
|
+
try {
|
|
91
|
+
agentBundle = (0, childBuilder_1.buildChildAgent)({
|
|
92
|
+
...deps,
|
|
93
|
+
// v4.6 Phase 1 observability — pass runStore + childRunId
|
|
94
|
+
// through so childBuilder can wire onToolCall → run_events
|
|
95
|
+
// for the child's tool dispatches. Both are optional in
|
|
96
|
+
// ChildBuilderDeps so unit tests of buildChildAgent stay
|
|
97
|
+
// dependency-light.
|
|
98
|
+
runStore: deps.runStore,
|
|
99
|
+
childRunId,
|
|
100
|
+
logger,
|
|
101
|
+
}, {
|
|
102
|
+
sessionId: childSessionId,
|
|
103
|
+
goal: spec.goal,
|
|
104
|
+
context: spec.context,
|
|
105
|
+
requestedToolsets: spec.toolsets,
|
|
106
|
+
maxIterations,
|
|
107
|
+
// v4.6 Phase 2P — per-spawn provider override (per design doc §12.2).
|
|
108
|
+
providerOverride: spec.provider,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// v4.6 Phase 2P — distinguish provider-not-found from other build
|
|
113
|
+
// failures. ProviderNotFoundError carries the failing name + the
|
|
114
|
+
// list of valid alternatives, surfaced verbatim to the LLM in the
|
|
115
|
+
// envelope so it can pick a real provider next time. Other build
|
|
116
|
+
// failures (constructor throws, registry issues, etc.) collapse
|
|
117
|
+
// to the generic 'error' exitReason.
|
|
118
|
+
if (err instanceof childBuilder_1.ProviderNotFoundError) {
|
|
119
|
+
deps.runStore.setStatus(childRunId, 'failed', { finishReason: 'provider_not_found' });
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
status: 'failed',
|
|
123
|
+
summary: null,
|
|
124
|
+
error: err.message,
|
|
125
|
+
exitReason: 'provider_not_found',
|
|
126
|
+
metrics: { apiCalls: 0, durationMs: Date.now() - startedAt, tokensIn: 0, tokensOut: 0 },
|
|
127
|
+
childRunId: String(childRunId),
|
|
128
|
+
childSessionId,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
deps.runStore.setStatus(childRunId, 'failed', { finishReason: 'error' });
|
|
132
|
+
return failureEnvelope({
|
|
133
|
+
childRunId: String(childRunId),
|
|
134
|
+
childSessionId,
|
|
135
|
+
error: `Failed to build child agent: ${errorMessage(err)}`,
|
|
136
|
+
durationMs: Date.now() - startedAt,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// v4.6 Phase 1 observability — log the child's actual tool catalog
|
|
140
|
+
// so we can see whether the toolsets-resolution path produced a
|
|
141
|
+
// sensible set or stripped everything. The single most-load-bearing
|
|
142
|
+
// diagnostic for the "child returned 0" class of bugs.
|
|
143
|
+
const childToolNames = agentBundle.agent.tools.map((t) => t.name);
|
|
144
|
+
logger.info('spawn_sub_agent child built', {
|
|
145
|
+
childRunId: String(childRunId),
|
|
146
|
+
childSessionId,
|
|
147
|
+
toolCount: childToolNames.length,
|
|
148
|
+
toolNames: childToolNames,
|
|
149
|
+
requestedToolsets: spec.toolsets ?? null,
|
|
150
|
+
maxIterations,
|
|
151
|
+
timeoutMs,
|
|
152
|
+
});
|
|
153
|
+
// ── 4. Linked AbortController ───────────────────────────────────────────
|
|
154
|
+
// Two abort sources cascade into the child's signal:
|
|
155
|
+
// (a) parent signal aborts — child aborts.
|
|
156
|
+
// (b) timeoutMs elapses — child aborts.
|
|
157
|
+
// Track which one fired so we can label the envelope as
|
|
158
|
+
// 'interrupted' vs 'timeout' (the spec distinguishes them).
|
|
159
|
+
const childCtrl = new AbortController();
|
|
160
|
+
let timedOut = false;
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
timedOut = true;
|
|
163
|
+
childCtrl.abort();
|
|
164
|
+
}, timeoutMs);
|
|
165
|
+
let parentAbortHandler = null;
|
|
166
|
+
if (ctx.signal) {
|
|
167
|
+
if (ctx.signal.aborted) {
|
|
168
|
+
childCtrl.abort();
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
parentAbortHandler = () => childCtrl.abort();
|
|
172
|
+
ctx.signal.addEventListener('abort', parentAbortHandler, { once: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const cleanupAbortWiring = () => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
if (parentAbortHandler && ctx.signal) {
|
|
178
|
+
ctx.signal.removeEventListener('abort', parentAbortHandler);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
// ── 5. Run the child ─────────────────────────────────────────────────────
|
|
182
|
+
// `child.runConversation` propagates the signal into the loop's
|
|
183
|
+
// between-iteration + pre-tool-call abort checks via the prep
|
|
184
|
+
// dispatch (commit fd62f96d).
|
|
185
|
+
let summary = null;
|
|
186
|
+
let error = null;
|
|
187
|
+
let status = 'completed';
|
|
188
|
+
let exitReason = 'completed';
|
|
189
|
+
let apiCalls = 0;
|
|
190
|
+
let tokensIn = 0;
|
|
191
|
+
let tokensOut = 0;
|
|
192
|
+
try {
|
|
193
|
+
const result = await agentBundle.agent.runConversation(agentBundle.history, { signal: childCtrl.signal });
|
|
194
|
+
apiCalls = result.turnCount; // one provider call per turn
|
|
195
|
+
tokensIn = result.totalUsage.inputTokens;
|
|
196
|
+
tokensOut = result.totalUsage.outputTokens;
|
|
197
|
+
// Classify the result per design doc §8.
|
|
198
|
+
if (result.finishReason === 'interrupted') {
|
|
199
|
+
// Distinguish timeout from parent-interrupt by which source fired.
|
|
200
|
+
if (timedOut) {
|
|
201
|
+
status = 'timeout';
|
|
202
|
+
exitReason = 'timeout';
|
|
203
|
+
error = `Sub-agent timed out after ${timeoutMs}ms (maxIterations=${maxIterations})`;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
status = 'interrupted';
|
|
207
|
+
exitReason = 'interrupted';
|
|
208
|
+
error = 'Parent interrupted — child did not finish in time';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (result.finishReason === 'budget_exhausted') {
|
|
212
|
+
// Hit maxIterations. If the model produced a partial final reply,
|
|
213
|
+
// we ship it as a 'completed/max_iterations' (partial summary);
|
|
214
|
+
// otherwise it's a failure.
|
|
215
|
+
if (result.finalContent && result.finalContent.length > 0) {
|
|
216
|
+
status = 'completed';
|
|
217
|
+
exitReason = 'max_iterations';
|
|
218
|
+
summary = result.finalContent;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
status = 'failed';
|
|
222
|
+
exitReason = 'error';
|
|
223
|
+
error = `Sub-agent hit max_iterations (${maxIterations}) without producing a summary`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else if (result.finishReason === 'error') {
|
|
227
|
+
status = 'failed';
|
|
228
|
+
exitReason = 'error';
|
|
229
|
+
error = 'Sub-agent loop reported an internal error';
|
|
230
|
+
}
|
|
231
|
+
else if (result.finishReason === 'tool_loop') {
|
|
232
|
+
// TCE surfaced a tool loop — treat as a failure with structured
|
|
233
|
+
// payload buried in error string (Phase 1 doesn't yet ship the
|
|
234
|
+
// capability-card detail into the envelope).
|
|
235
|
+
status = 'failed';
|
|
236
|
+
exitReason = 'error';
|
|
237
|
+
error = `Sub-agent detected a tool loop and stopped: ${result.toolLoopCard?.title ?? 'tool_loop'}`;
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
// 'stop' → natural completion.
|
|
241
|
+
status = 'completed';
|
|
242
|
+
exitReason = 'completed';
|
|
243
|
+
summary = result.finalContent;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
// child.runConversation threw — typically only happens when the
|
|
248
|
+
// provider call fails after exhausting fallback chain. Surface as
|
|
249
|
+
// a failed envelope with the error string.
|
|
250
|
+
status = 'failed';
|
|
251
|
+
exitReason = 'error';
|
|
252
|
+
error = `Sub-agent threw: ${errorMessage(err)}`;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
cleanupAbortWiring();
|
|
256
|
+
}
|
|
257
|
+
// ── 6. Update run row + emit envelope ────────────────────────────────────
|
|
258
|
+
const dbStatus = status === 'completed' ? 'completed'
|
|
259
|
+
: status === 'interrupted' ? 'interrupted'
|
|
260
|
+
: 'failed';
|
|
261
|
+
deps.runStore.setStatus(childRunId, dbStatus, { finishReason: exitReason });
|
|
262
|
+
const durationMs = Date.now() - startedAt;
|
|
263
|
+
const ok = status === 'completed' && exitReason !== 'error';
|
|
264
|
+
return {
|
|
265
|
+
ok,
|
|
266
|
+
status,
|
|
267
|
+
summary,
|
|
268
|
+
error,
|
|
269
|
+
exitReason,
|
|
270
|
+
metrics: {
|
|
271
|
+
apiCalls,
|
|
272
|
+
durationMs,
|
|
273
|
+
tokensIn,
|
|
274
|
+
tokensOut,
|
|
275
|
+
},
|
|
276
|
+
childRunId: String(childRunId),
|
|
277
|
+
childSessionId,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
281
|
+
function clamp(n, lo, hi) {
|
|
282
|
+
if (!Number.isFinite(n))
|
|
283
|
+
return lo;
|
|
284
|
+
return Math.max(lo, Math.min(hi, Math.floor(n)));
|
|
285
|
+
}
|
|
286
|
+
function errorMessage(err) {
|
|
287
|
+
return err instanceof Error ? err.message : String(err);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Build a failed envelope for pre-run errors (run-row creation,
|
|
291
|
+
* agent construction). Always carries `summary: null`, `ok: false`,
|
|
292
|
+
* `status: 'failed'`, `exitReason: 'error'`.
|
|
293
|
+
*/
|
|
294
|
+
function failureEnvelope(opts) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
status: 'failed',
|
|
298
|
+
summary: null,
|
|
299
|
+
error: opts.error,
|
|
300
|
+
exitReason: 'error',
|
|
301
|
+
metrics: {
|
|
302
|
+
apiCalls: 0,
|
|
303
|
+
durationMs: opts.durationMs,
|
|
304
|
+
tokensIn: 0,
|
|
305
|
+
tokensOut: 0,
|
|
306
|
+
},
|
|
307
|
+
childRunId: opts.childRunId,
|
|
308
|
+
childSessionId: opts.childSessionId,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
@@ -49,10 +49,19 @@ class ToolRegistry {
|
|
|
49
49
|
return [...this.handlers.keys()];
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* Schemas to advertise to the LLM.
|
|
53
|
-
*
|
|
52
|
+
* Schemas to advertise to the LLM. Two optional filters, AND-combined:
|
|
53
|
+
*
|
|
54
|
+
* - `filterToolsets`: include only handlers whose `toolset` matches
|
|
55
|
+
* one of the entries. Applied first (preserves pre-v4.6 behaviour
|
|
56
|
+
* when called with one argument).
|
|
57
|
+
* - `context` (v4.6 Phase 1): include only handlers whose
|
|
58
|
+
* `contexts` array contains this value, OR whose `contexts` is
|
|
59
|
+
* undefined (default = visible everywhere). Applied second.
|
|
60
|
+
*
|
|
61
|
+
* Both filters default to "no filter" when omitted. Callers that
|
|
62
|
+
* predate v4.6 pass one arg or none and continue working unchanged.
|
|
54
63
|
*/
|
|
55
|
-
getSchemas(filterToolsets) {
|
|
64
|
+
getSchemas(filterToolsets, context) {
|
|
56
65
|
const out = [];
|
|
57
66
|
for (const handler of this.handlers.values()) {
|
|
58
67
|
if (filterToolsets && filterToolsets.length > 0) {
|
|
@@ -60,6 +69,13 @@ class ToolRegistry {
|
|
|
60
69
|
continue;
|
|
61
70
|
}
|
|
62
71
|
}
|
|
72
|
+
if (context !== undefined) {
|
|
73
|
+
// contexts undefined → tool is visible in both REPL and daemon
|
|
74
|
+
// (backward-compat default for every pre-v4.6 tool).
|
|
75
|
+
if (handler.contexts !== undefined && !handler.contexts.includes(context)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
63
79
|
out.push(handler.schema);
|
|
64
80
|
}
|
|
65
81
|
return out;
|
package/dist/core/version.js
CHANGED
|
@@ -87,10 +87,39 @@ const RULES = [
|
|
|
87
87
|
toolsets: ['execute'],
|
|
88
88
|
},
|
|
89
89
|
// Process registry
|
|
90
|
+
//
|
|
91
|
+
// Note: the bare word "spawn" appears in this rule's keyword list
|
|
92
|
+
// (legacy — predates v4.6 sub-agents). The dedicated 'subagent'
|
|
93
|
+
// rule below ALSO matches "spawn" via a tighter delegation
|
|
94
|
+
// vocabulary, so a message like "spawn a background server" hits
|
|
95
|
+
// BOTH rules and adds both toolsets — UNION semantics make this
|
|
96
|
+
// additive, not conflicting.
|
|
90
97
|
{
|
|
91
98
|
keywords: /\b(process|background|long.?running|server|spawn|kill|daemon)\b/i,
|
|
92
99
|
toolsets: ['process'],
|
|
93
100
|
},
|
|
101
|
+
// v4.6 Phase 1 — sub-agent delegation surface (spawn_sub_agent +
|
|
102
|
+
// subagent_fanout). Both tools live in toolset `'subagent'`, which
|
|
103
|
+
// no pre-v4.6 rule mapped to — so PlannerGuard's per-turn narrowing
|
|
104
|
+
// silently stripped them from the model's catalog whenever any
|
|
105
|
+
// other rule fired. The model could see them via lookup_tool_schema
|
|
106
|
+
// but failed to actually invoke them because the provider tool list
|
|
107
|
+
// (post-narrow) didn't include them — see Dispatch 2H diagnostic.
|
|
108
|
+
//
|
|
109
|
+
// Regex notes:
|
|
110
|
+
// - `spawn_sub_agent` and `subagent_fanout` literals are listed
|
|
111
|
+
// explicitly because `\bspawn\b` does NOT match within
|
|
112
|
+
// `spawn_sub_agent` (underscore is a word char in JS regex,
|
|
113
|
+
// so there's no word boundary between `n` and `_`). Users who
|
|
114
|
+
// name the tool directly hit the literal arm.
|
|
115
|
+
// - The free-form vocabulary arm (`spawn`, `delegate`, etc.)
|
|
116
|
+
// catches natural-language delegation intent. UNION semantics
|
|
117
|
+
// with other rules let "spawn a child to read files" surface
|
|
118
|
+
// both 'subagent' AND 'files'.
|
|
119
|
+
{
|
|
120
|
+
keywords: /\b(spawn_sub_agent|subagent_fanout|spawn|subagent|sub.?agent|delegate|fanout|fan.?out|child.?agent|parallel|isolated)\b/i,
|
|
121
|
+
toolsets: ['subagent'],
|
|
122
|
+
},
|
|
94
123
|
// Media playback control (v4.1.4-media)
|
|
95
124
|
//
|
|
96
125
|
// Without this, intents like "list media sessions" matched the
|
|
@@ -92,14 +92,14 @@ class AnthropicAdapter {
|
|
|
92
92
|
// ── Public: non-streaming ────────────────────────────────────────────────
|
|
93
93
|
async call(input) {
|
|
94
94
|
const body = this.buildBody(input, /* streaming */ false);
|
|
95
|
-
const reply = await this.dispatch(body, /* streaming */ false);
|
|
95
|
+
const reply = await this.dispatch(body, /* streaming */ false, input.signal);
|
|
96
96
|
const json = (await reply.json());
|
|
97
97
|
return decodeResponse(json);
|
|
98
98
|
}
|
|
99
99
|
// ── Public: streaming ────────────────────────────────────────────────────
|
|
100
100
|
async *callStream(input) {
|
|
101
101
|
const body = this.buildBody(input, /* streaming */ true);
|
|
102
|
-
const reply = await this.dispatch(body, /* streaming */ true);
|
|
102
|
+
const reply = await this.dispatch(body, /* streaming */ true, input.signal);
|
|
103
103
|
if (!reply.body) {
|
|
104
104
|
// Server promised SSE but gave us nothing — fall through to a synthetic
|
|
105
105
|
// empty done event so the agent loop terminates rather than hangs.
|
|
@@ -163,7 +163,7 @@ class AnthropicAdapter {
|
|
|
163
163
|
// beta flags, or per-deployment routing tags without forking the adapter.
|
|
164
164
|
return { ...headers, ...this.extraHeaders };
|
|
165
165
|
}
|
|
166
|
-
async dispatch(body, streaming) {
|
|
166
|
+
async dispatch(body, streaming, externalSignal) {
|
|
167
167
|
// Resolved once per process via the userAgent module's cache, so paying
|
|
168
168
|
// for the version detection here is cheap on every retry/turn.
|
|
169
169
|
const userAgent = await (0, userAgent_1.getClaudeCliUserAgent)();
|
|
@@ -174,6 +174,22 @@ class AnthropicAdapter {
|
|
|
174
174
|
for (let attempt = 0; attempt < totalTries; attempt++) {
|
|
175
175
|
const controller = new AbortController();
|
|
176
176
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
177
|
+
// v4.6 prep — forward an external AbortSignal into this attempt's
|
|
178
|
+
// internal controller so a parent agent that aborts mid-flight
|
|
179
|
+
// cancels the in-flight fetch. External aborts surface as a raw
|
|
180
|
+
// AbortError (NOT ProviderTimeoutError) so AidenAgent can route
|
|
181
|
+
// them as `finishReason: 'interrupted'` instead of treating them
|
|
182
|
+
// as a retryable timeout.
|
|
183
|
+
let externalAbortHandler = null;
|
|
184
|
+
if (externalSignal) {
|
|
185
|
+
if (externalSignal.aborted) {
|
|
186
|
+
controller.abort();
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
externalAbortHandler = () => controller.abort();
|
|
190
|
+
externalSignal.addEventListener('abort', externalAbortHandler, { once: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
177
193
|
let response;
|
|
178
194
|
try {
|
|
179
195
|
response = await fetch(this.endpoint, {
|
|
@@ -185,7 +201,16 @@ class AnthropicAdapter {
|
|
|
185
201
|
}
|
|
186
202
|
catch (err) {
|
|
187
203
|
clearTimeout(timer);
|
|
204
|
+
if (externalAbortHandler && externalSignal) {
|
|
205
|
+
externalSignal.removeEventListener('abort', externalAbortHandler);
|
|
206
|
+
}
|
|
188
207
|
if (err?.name === 'AbortError') {
|
|
208
|
+
// v4.6 prep — external abort takes priority over internal
|
|
209
|
+
// timeout. Surface the raw AbortError immediately (no retry)
|
|
210
|
+
// so AidenAgent's catch routes it as 'interrupted'.
|
|
211
|
+
if (externalSignal?.aborted) {
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
189
214
|
// Treat timeout as retryable; only surface ProviderTimeoutError if
|
|
190
215
|
// we've burned the last attempt.
|
|
191
216
|
lastErr = new errors_1.ProviderTimeoutError(this.providerName, this.timeoutMs);
|
|
@@ -200,6 +225,9 @@ class AnthropicAdapter {
|
|
|
200
225
|
throw lastErr;
|
|
201
226
|
}
|
|
202
227
|
clearTimeout(timer);
|
|
228
|
+
if (externalAbortHandler && externalSignal) {
|
|
229
|
+
externalSignal.removeEventListener('abort', externalAbortHandler);
|
|
230
|
+
}
|
|
203
231
|
if (response.ok)
|
|
204
232
|
return response;
|
|
205
233
|
// Phase 25.1.5d diagnostic: gated dump of request + response so we
|