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 +1 -1
- package/dist/core/v4/aidenAgent.js +47 -5
- package/dist/core/v4/toolCallInvariant.js +150 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1089
|
-
//
|
|
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
|
+
}
|