aiden-runtime 4.9.3 → 4.9.4

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 CHANGED
@@ -235,7 +235,7 @@ Full v4.5 internals: [`docs/v4.5/`](docs/v4.5/) (overview, triggers, architectur
235
235
 
236
236
 
237
237
 
238
- https://github.com/user-attachments/assets/a76bf4a5-28ca-43b5-8975-5ef0a66ee90d
238
+
239
239
 
240
240
 
241
241
 
@@ -80,6 +80,9 @@ exports.AidenAgent = void 0;
80
80
  // AIDEN_TCE=0 to disable. Zero
81
81
  // behavioral change when unset. See core/v4/turnState.ts.
82
82
  const turnState_1 = require("./turnState");
83
+ // v4.9.4 Slice 1 — tool-call/result protocol invariant + synthetic
84
+ // blocked-result helpers used at the surface + abort fill sites.
85
+ const toolCallInvariant_1 = require("./toolCallInvariant");
83
86
  // v4.2 Phase 1 — per-tool result verifier. Same TCE gate as
84
87
  // TurnState (default ON, opt-out via AIDEN_TCE=0); classification
85
88
  // feeds the recovery controller.
@@ -152,6 +155,7 @@ class AidenAgent {
152
155
  this.provider = opts.provider;
153
156
  this.toolExecutor = opts.toolExecutor;
154
157
  this.tools = opts.tools;
158
+ this.turnStateFactory = opts.turnStateFactory;
155
159
  this.maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
156
160
  this.fallback = opts.fallback;
157
161
  this.onToolCall = opts.onToolCall;
@@ -640,7 +644,9 @@ class AidenAgent {
640
644
  // When disabled, TurnState.recordToolCall short-circuits with
641
645
  // `{kind: 'allow'}` and the entire v4.2 recovery surface stays
642
646
  // dormant (zero behavioural change vs v4.1.6).
643
- const turnState = new turnState_1.TurnState();
647
+ // v4.9.4 Slice 1 — honor optional test-seam factory. Production
648
+ // paths never pass turnStateFactory → falls through to real ctor.
649
+ const turnState = this.turnStateFactory?.() ?? new turnState_1.TurnState();
644
650
  // v4.2 Phase 1 — per-tool verifier registry. Constructed
645
651
  // unconditionally (cheap, no side effects) but only used to
646
652
  // classify tool outcomes when TCE is enabled; verification args
@@ -850,13 +856,27 @@ class AidenAgent {
850
856
  // TurnState internals + pushes a corrective system message,
851
857
  // then continues the outer iteration loop from a clean baseline.
852
858
  let rollbackDecision = null;
853
- for (const call of output.toolCalls) {
859
+ // v4.9.4 Slice 1 — `.entries()` so the surface + abort fill sites
860
+ // can slice from `callIndex + 1` to compute the un-dispatched tail.
861
+ for (const [callIndex, call] of output.toolCalls.entries()) {
854
862
  // v4.6 prep — pre-tool-call cooperative-cancellation check.
855
863
  // If the caller aborted between the model emitting tool calls
856
864
  // and us dispatching them, skip the remaining calls in this
857
865
  // batch. We set finishReason here; the outer-while break is
858
866
  // handled after the for-of exits.
859
867
  if (runOptions.signal?.aborted) {
868
+ // v4.9.4 Slice 1 — fill synthetic results so the assistant's
869
+ // toolCalls[] is balanced before we break. `call` (the one we
870
+ // were ABOUT to dispatch) gets variant='interrupted'; every
871
+ // remaining call gets variant='skipped'. Both with reason
872
+ // 'cancelled'. CRITICAL: also push turnToolMessages into the
873
+ // history NOW — the outer `if (finishReason === 'interrupted')`
874
+ // break (post-for-of) exits before reaching the line 1599
875
+ // bulk-push. Without this explicit push the synthetic results
876
+ // we just collected get discarded.
877
+ turnToolMessages.push((0, toolCallInvariant_1.synthesizeBlockedToolResult)(call, 'cancelled', { variant: 'interrupted' }));
878
+ (0, toolCallInvariant_1.fillRemainingAsBlocked)(turnToolMessages, output.toolCalls, callIndex + 1, 'cancelled', 'skipped');
879
+ messages.push(...turnToolMessages);
860
880
  finishReason = 'interrupted';
861
881
  finalContent = '';
862
882
  break;
@@ -1084,9 +1104,22 @@ class AidenAgent {
1084
1104
  }
1085
1105
  else if (recovery.kind === 'surface' && recovery.surfaceCard) {
1086
1106
  // Stage 3: structured failure. Stop dispatching the rest of
1087
- // the batch — anything else is throwing good budget after
1088
- // bad. The outer loop reads `surfaceDecision` below and
1089
- // exits cleanly.
1107
+ // the batch — anything else is throwing good budget after bad.
1108
+ // The outer loop reads `surfaceDecision` below and exits cleanly.
1109
+ //
1110
+ // v4.9.4 Slice 1 — BEFORE breaking, fill synthetic blocked-
1111
+ // tool-result messages for every un-dispatched call in this
1112
+ // batch (slice from callIndex+1; the current call already had
1113
+ // its real result pushed at line ~1440 just above). Without
1114
+ // this fill, the assistant message at line ~1170 carries
1115
+ // tool_call_ids whose matching tool results never land in
1116
+ // history. The outer surfaceDecision branch (line ~1573)
1117
+ // pushes turnToolMessages into `messages` and breaks the
1118
+ // outer while loop, ending the turn — but the persisted
1119
+ // history carries the orphans. A resumed conversation (or
1120
+ // any second provider call in the same turn) then returns
1121
+ // 400 "No tool output found for function call <id>".
1122
+ (0, toolCallInvariant_1.fillRemainingAsBlocked)(turnToolMessages, output.toolCalls, callIndex + 1, 'tool_loop_surface');
1090
1123
  surfaceDecision = recovery;
1091
1124
  break;
1092
1125
  }
@@ -1211,6 +1244,15 @@ class AidenAgent {
1211
1244
  * loop sees the same `ProviderCallOutput` regardless.
1212
1245
  */
1213
1246
  async callProvider(messages, tools, runOptions) {
1247
+ // v4.9.4 Slice 1 — tool-call protocol preflight. Every assistant
1248
+ // toolCalls[] entry must have a matching {role:'tool', toolCallId}
1249
+ // BEFORE shipping to any provider. If this throws, a guard in
1250
+ // runTurnLoop is leaking orphan tool_call_ids — find the culprit,
1251
+ // don't catch this. The surface + abort fill sites above already
1252
+ // satisfy the invariant; preflight is the audit-loud safety net
1253
+ // for new guards added later (v4.10 rate-limit / cost-budget /
1254
+ // hook-deny). See core/v4/toolCallInvariant.ts.
1255
+ (0, toolCallInvariant_1.assertNoUnansweredToolCalls)(messages);
1214
1256
  const wantStream = runOptions.stream === true && typeof this.provider.callStream === 'function';
1215
1257
  // v4.1.5 Issue K — fire just before the HTTP request opens, so the
1216
1258
  // display layer can transition the activity verb from local-prep
@@ -0,0 +1,150 @@
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/toolCallInvariant.ts — v4.9.4 SLICE 1.
10
+ *
11
+ * The tool-call/tool-result protocol invariant required by the OpenAI /
12
+ * ChatGPT-Plus / Anthropic / Codex Responses message wire formats:
13
+ *
14
+ * For every assistant message with toolCalls[],
15
+ * every tool_call.id MUST be answered by a later `tool` role message
16
+ * carrying the same toolCallId, before the next provider request.
17
+ *
18
+ * Aiden previously violated this in two known dispatch sites
19
+ * (aidenAgent runTurnLoop's surfaceDecision break + abort-signal break)
20
+ * which left orphan tool_call_ids in persisted history. Resuming such
21
+ * a history triggered 400 from the provider:
22
+ *
23
+ * Provider chatgpt-plus request failed (400):
24
+ * No tool output found for function call call_<id>.
25
+ *
26
+ * This module exposes three primitives:
27
+ * - assertNoUnansweredToolCalls(messages) — preflight gate
28
+ * - synthesizeBlockedToolResult(call, reason) — fill primitive
29
+ * - fillRemainingAsBlocked(buf, calls, idx, ..) — batch helper
30
+ *
31
+ * Plus the OrphanToolCallError class thrown by the preflight.
32
+ *
33
+ * Provider-agnostic — each adapter translates Aiden's internal Message
34
+ * type into its native wire shape. Assertions run against the internal
35
+ * Message shape itself.
36
+ */
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.OrphanToolCallError = void 0;
39
+ exports.assertNoUnansweredToolCalls = assertNoUnansweredToolCalls;
40
+ exports.synthesizeBlockedToolResult = synthesizeBlockedToolResult;
41
+ exports.fillRemainingAsBlocked = fillRemainingAsBlocked;
42
+ // ── Error class ──────────────────────────────────────────────────────
43
+ /**
44
+ * Thrown by assertNoUnansweredToolCalls. Subclassed from Error so
45
+ * triage code can:
46
+ *
47
+ * try { ... } catch (e) {
48
+ * if (e instanceof OrphanToolCallError) { ... }
49
+ * }
50
+ *
51
+ * Production code MUST NOT catch this. If it fires, a guard upstream
52
+ * is leaking orphan tool_call_ids and we want the failure loud at the
53
+ * site that introduced the leak.
54
+ */
55
+ class OrphanToolCallError extends Error {
56
+ constructor(orphans) {
57
+ const ids = orphans.map((o) => `${o.toolName}#${o.toolCallId}`).join(', ');
58
+ super(`Tool-call/result protocol violated: ${orphans.length} unanswered tool_call_id(s) [${ids}]. ` +
59
+ `Some guard in the dispatch loop emitted an assistant message with tool_calls[] ` +
60
+ `but did not push a matching {role:'tool', toolCallId} for every id. ` +
61
+ `Find the guard and add a synthesizeBlockedToolResult() call before its break/continue.`);
62
+ this.name = 'OrphanToolCallError';
63
+ this.orphans = orphans;
64
+ }
65
+ }
66
+ exports.OrphanToolCallError = OrphanToolCallError;
67
+ // ── Preflight assertion ──────────────────────────────────────────────
68
+ /**
69
+ * Walk the messages once. For each assistant message at index i, scan
70
+ * messages[i+1..] for `{ role: 'tool', toolCallId }` entries matching
71
+ * each toolCalls[].id. Orphans (unmatched ids) accumulate; a single
72
+ * Error is thrown listing all of them so a single debugging session
73
+ * sees the full damage (better than throw-on-first).
74
+ *
75
+ * Pure. No IO, no clock. Cost is O(N*M) where N = total messages and
76
+ * M = avg tool-calls-per-assistant-turn; trivial for any realistic
77
+ * session (low hundreds of messages, low tens of tool calls per turn).
78
+ *
79
+ * Called from AidenAgent.callProvider() as the single boundary preflight
80
+ * — every provider adapter receives messages[] through that one funnel.
81
+ */
82
+ function assertNoUnansweredToolCalls(messages) {
83
+ // Collect all tool-result ids first (single pass) so we can resolve
84
+ // each assistant's tool_calls in O(1) against a Set.
85
+ const answeredIds = new Set();
86
+ for (const m of messages) {
87
+ if (m.role === 'tool')
88
+ answeredIds.add(m.toolCallId);
89
+ }
90
+ // Now walk assistants and collect orphans.
91
+ const orphans = [];
92
+ for (const m of messages) {
93
+ if (m.role !== 'assistant' || !m.toolCalls)
94
+ continue;
95
+ for (const tc of m.toolCalls) {
96
+ if (!answeredIds.has(tc.id)) {
97
+ orphans.push({ toolCallId: tc.id, toolName: tc.name });
98
+ }
99
+ }
100
+ }
101
+ if (orphans.length > 0)
102
+ throw new OrphanToolCallError(orphans);
103
+ }
104
+ // ── Synthesis primitives ─────────────────────────────────────────────
105
+ /**
106
+ * Build a tool-role message whose content is a JSON-stringified failure
107
+ * object the LLM can parse:
108
+ *
109
+ * { ok: false, blocked: true, reason: <code>, message: <human> }
110
+ *
111
+ * Same shape regardless of which guard fired so the LLM sees a uniform
112
+ * signal. Internal Aiden Message type — providers/v4 adapters handle
113
+ * wire-shape translation per their native protocol.
114
+ */
115
+ function synthesizeBlockedToolResult(call, reason, opts = {}) {
116
+ const variant = opts.variant ?? 'skipped';
117
+ const humanMessage = variant === 'interrupted'
118
+ ? `This call was interrupted before execution. (reason: ${reason})`
119
+ : `This call was skipped because the turn was cancelled. (reason: ${reason})`;
120
+ // tool_loop_surface variant is always 'skipped' semantically (we
121
+ // already executed the call before the surface decision fired, so
122
+ // the SKIPPED calls are the remainder). But we still let the caller
123
+ // override if a future site has a different shape.
124
+ const content = JSON.stringify({
125
+ ok: false,
126
+ blocked: true,
127
+ reason,
128
+ message: humanMessage,
129
+ });
130
+ return {
131
+ role: 'tool',
132
+ toolCallId: call.id,
133
+ content,
134
+ };
135
+ }
136
+ /**
137
+ * Push synthetic blocked-tool-result messages for every unprocessed
138
+ * call from `startIdx` (inclusive) onward. Mutates `buf` in place
139
+ * (matches the existing turnToolMessages accumulator pattern in
140
+ * aidenAgent.ts; pure-returning would force a spread at every call
141
+ * site).
142
+ *
143
+ * Exported because v4.10 guards (rate-limit, cost-budget, hook-deny)
144
+ * will want the same shape.
145
+ */
146
+ function fillRemainingAsBlocked(buf, toolCalls, startIdx, reason, variant = 'skipped') {
147
+ for (let i = startIdx; i < toolCalls.length; i++) {
148
+ buf.push(synthesizeBlockedToolResult(toolCalls[i], reason, { variant }));
149
+ }
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiden-runtime",
3
- "version": "4.9.3",
3
+ "version": "4.9.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },