@animalabs/agent-framework 0.18.0 → 0.20.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 +29 -0
- package/dist/src/agent.d.ts +22 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +128 -10
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/types.d.ts +1 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +10 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +27 -2
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +21 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +34 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +161 -7
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1183 -87
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +49 -5
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +74 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +156 -10
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +3 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +318 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
- package/dist/src/mcpl/push-coalescer.js +709 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +7 -2
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +43 -4
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +44 -1
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +71 -0
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
- package/dist/src/mcpl/tool-classes.js +170 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -0
- package/dist/src/mcpl/tool-glob.d.ts +16 -0
- package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
- package/dist/src/mcpl/tool-glob.js +41 -0
- package/dist/src/mcpl/tool-glob.js.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.js +592 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +12 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +33 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +6 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +85 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +1019 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +202 -0
- package/dist/src/modules/history/semantic.d.ts.map +1 -0
- package/dist/src/modules/history/semantic.js +418 -0
- package/dist/src/modules/history/semantic.js.map +1 -0
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +82 -0
- package/dist/src/tool-result-guard.d.ts.map +1 -0
- package/dist/src/tool-result-guard.js +306 -0
- package/dist/src/tool-result-guard.js.map +1 -0
- package/dist/src/types/agent.d.ts +14 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +21 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +17 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +59 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/channels-changed-respond-first.test.d.ts +2 -0
- package/dist/test/channels-changed-respond-first.test.d.ts.map +1 -0
- package/dist/test/channels-changed-respond-first.test.js +74 -0
- package/dist/test/channels-changed-respond-first.test.js.map +1 -0
- package/dist/test/code-execution.test.js +139 -0
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-fixture.js +145 -0
- package/dist/test/helpers/coalescing-fixture.js.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.js +24 -0
- package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
- package/dist/test/history-module-realstore.test.d.ts +2 -0
- package/dist/test/history-module-realstore.test.d.ts.map +1 -0
- package/dist/test/history-module-realstore.test.js +363 -0
- package/dist/test/history-module-realstore.test.js.map +1 -0
- package/dist/test/history-module-semantic.test.d.ts +2 -0
- package/dist/test/history-module-semantic.test.d.ts.map +1 -0
- package/dist/test/history-module-semantic.test.js +213 -0
- package/dist/test/history-module-semantic.test.js.map +1 -0
- package/dist/test/history-module-ux.test.d.ts +2 -0
- package/dist/test/history-module-ux.test.d.ts.map +1 -0
- package/dist/test/history-module-ux.test.js +304 -0
- package/dist/test/history-module-ux.test.js.map +1 -0
- package/dist/test/history-module.test.js +25 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
- package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
- package/dist/test/history-semantic-review-findings.test.js +338 -0
- package/dist/test/history-semantic-review-findings.test.js.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.js +109 -0
- package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
- package/dist/test/journal-tool.test.d.ts +2 -0
- package/dist/test/journal-tool.test.d.ts.map +1 -0
- package/dist/test/journal-tool.test.js +33 -0
- package/dist/test/journal-tool.test.js.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +3 -1
- package/dist/test/mcpl-capability-grant.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.d.ts +2 -0
- package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
- package/dist/test/mcpl-child-env.test.js +69 -0
- package/dist/test/mcpl-child-env.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.d.ts +2 -0
- package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +1050 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -0
- package/dist/test/mcpl-list-status.test.js +4 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -1
- package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.js +147 -0
- package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
- package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
- package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-classes.test.js +80 -0
- package/dist/test/mcpl-tool-classes.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
- package/dist/test/overbudget-drain-breaker.test.js +44 -4
- package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
- package/dist/test/silent-heartbeat.test.d.ts +2 -0
- package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat.test.js +154 -0
- package/dist/test/silent-heartbeat.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.d.ts +2 -0
- package/dist/test/tool-result-guard.test.d.ts.map +1 -0
- package/dist/test/tool-result-guard.test.js +1081 -0
- package/dist/test/tool-result-guard.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +67 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +128 -10
- package/src/api/types.ts +2 -0
- package/src/code-execution/py-runner.ts +34 -2
- package/src/code-execution/tool-definition.ts +46 -0
- package/src/framework.ts +1215 -91
- package/src/index.ts +13 -0
- package/src/mcpl/capability-grant.ts +54 -6
- package/src/mcpl/channel-registry.ts +170 -10
- package/src/mcpl/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- package/src/mcpl/server-connection.ts +48 -2
- package/src/mcpl/tool-classes.ts +188 -0
- package/src/mcpl/tool-glob.ts +36 -0
- package/src/mcpl/tool-lifecycle.ts +730 -0
- package/src/mcpl/transport.ts +35 -1
- package/src/mcpl/types.ts +69 -1
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +1089 -24
- package/src/modules/history/semantic.ts +473 -0
- package/src/modules/index.ts +2 -1
- package/src/tool-result-guard.ts +318 -0
- package/src/types/agent.ts +15 -0
- package/src/types/framework.ts +23 -0
- package/src/types/trace.ts +17 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
import { afterEach, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { PassthroughStrategy } from '@animalabs/context-manager';
|
|
7
|
+
import { Membrane as RealMembrane, NativeFormatter } from '@animalabs/membrane';
|
|
8
|
+
import { AgentFramework } from '../src/index.js';
|
|
9
|
+
import { TOOL_RESULT_GUARD_AUDIT_STATE, TOOL_RESULT_GUARD_NOTICE } from '../src/tool-result-guard.js';
|
|
10
|
+
import { MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js';
|
|
11
|
+
const dirs = [];
|
|
12
|
+
afterEach(() => { while (dirs.length)
|
|
13
|
+
rmSync(dirs.pop(), { recursive: true, force: true }); });
|
|
14
|
+
const answer = () => createMockResponse([{ type: 'text', text: 'continued' }]);
|
|
15
|
+
const refused = () => ({
|
|
16
|
+
...createMockResponse([{ type: 'text', text: 'discard-this-partial-output' }], 'refusal'),
|
|
17
|
+
raw: { request: {}, response: { stop_details: { category: 'test-category' } } },
|
|
18
|
+
});
|
|
19
|
+
const calls = (...ids) => createMockResponse(ids.map((id) => ({
|
|
20
|
+
type: 'tool_use', id, name: 'test--read', input: {},
|
|
21
|
+
})), 'tool_use');
|
|
22
|
+
class ScriptMembrane {
|
|
23
|
+
scripts;
|
|
24
|
+
requests = [];
|
|
25
|
+
streams = [];
|
|
26
|
+
retriesAtSubmission = [];
|
|
27
|
+
onSubmit;
|
|
28
|
+
constructor(scripts) {
|
|
29
|
+
this.scripts = scripts;
|
|
30
|
+
}
|
|
31
|
+
streamYielding(request, options = {}) {
|
|
32
|
+
this.requests.push(structuredClone({ ...request, onCacheWireReceipt: undefined }));
|
|
33
|
+
const script = this.scripts.shift();
|
|
34
|
+
assert.ok(script, 'unexpected extra inference');
|
|
35
|
+
const stream = new MockYieldingStream(script);
|
|
36
|
+
const provide = stream.provideToolResults.bind(stream);
|
|
37
|
+
stream.provideToolResults = (...args) => {
|
|
38
|
+
this.retriesAtSubmission.push(options.refusalRetries ?? 0);
|
|
39
|
+
this.onSubmit?.();
|
|
40
|
+
provide(...args);
|
|
41
|
+
};
|
|
42
|
+
this.streams.push(stream);
|
|
43
|
+
return stream;
|
|
44
|
+
}
|
|
45
|
+
asMembrane() { return this; }
|
|
46
|
+
}
|
|
47
|
+
class ReadModule {
|
|
48
|
+
results;
|
|
49
|
+
name = 'test';
|
|
50
|
+
calls = [];
|
|
51
|
+
speeches = [];
|
|
52
|
+
constructor(results = {}) {
|
|
53
|
+
this.results = results;
|
|
54
|
+
}
|
|
55
|
+
async start(ctx) { ctx.registerSpeechHandler('*'); }
|
|
56
|
+
async stop() { }
|
|
57
|
+
getTools() {
|
|
58
|
+
return [
|
|
59
|
+
{ name: 'read', description: 'Read a result', inputSchema: { type: 'object', properties: {} } },
|
|
60
|
+
{ name: 'send_message', description: 'Explicit send', inputSchema: { type: 'object', properties: {} } },
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
async handleToolCall(call) {
|
|
64
|
+
this.calls.push(call.id);
|
|
65
|
+
return this.results[call.id] ?? { success: true, data: `payload-${call.id}` };
|
|
66
|
+
}
|
|
67
|
+
async onProcess(event, _state) {
|
|
68
|
+
return event.type === 'external-message'
|
|
69
|
+
? { addMessages: [{ participant: 'user', content: [{ type: 'text', text: String(event.content) }] }], requestInference: true }
|
|
70
|
+
: {};
|
|
71
|
+
}
|
|
72
|
+
async onAgentSpeech(_name, content) {
|
|
73
|
+
this.speeches.push(...content.flatMap((block) => block.type === 'text' ? [block.text] : []));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
class IngressObserver extends PassthroughStrategy {
|
|
77
|
+
snapshots = [];
|
|
78
|
+
async onNewMessage(_message, ctx) {
|
|
79
|
+
this.snapshots.push(JSON.stringify(ctx.messageStore.getAll()));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function harness(scripts, config = {}, results) {
|
|
83
|
+
const dir = mkdtempSync(join(tmpdir(), 'af-tool-result-guard-'));
|
|
84
|
+
dirs.push(dir);
|
|
85
|
+
const membrane = new ScriptMembrane(scripts);
|
|
86
|
+
const module = new ReadModule(results);
|
|
87
|
+
const base = { storePath: join(dir, 'store'), membrane: membrane.asMembrane(),
|
|
88
|
+
agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system', ...config }], modules: [module], syncIntervalMs: 0 };
|
|
89
|
+
const framework = await AgentFramework.create(base);
|
|
90
|
+
const run = async () => {
|
|
91
|
+
framework.pushEvent({ type: 'external-message', source: 'test', content: 'read', metadata: {} });
|
|
92
|
+
await framework.runUntilIdle();
|
|
93
|
+
};
|
|
94
|
+
return { framework, membrane, module, base, run };
|
|
95
|
+
}
|
|
96
|
+
function extension(framework) {
|
|
97
|
+
const extensions = framework.collectAgentSettingsExtensions();
|
|
98
|
+
return [...extensions.values()].find((ext) => ext.keys.includes('tool_result_guard'));
|
|
99
|
+
}
|
|
100
|
+
function toolResults(framework) {
|
|
101
|
+
return framework.getAgent('assistant').getContextManager().getAllMessages()
|
|
102
|
+
.flatMap((message) => message.content.filter((block) => block.type === 'tool_result'));
|
|
103
|
+
}
|
|
104
|
+
test('default off retains existing refusal behavior and original tool output', async () => {
|
|
105
|
+
const h = await harness([[calls('one'), refused()]]);
|
|
106
|
+
try {
|
|
107
|
+
await h.run();
|
|
108
|
+
assert.equal(h.membrane.requests.length, 1);
|
|
109
|
+
assert.match(JSON.stringify(toolResults(h.framework)), /payload-one/);
|
|
110
|
+
assert.equal(extension(h.framework).get('assistant').tool_result_guard, false);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await h.framework.stop();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
test('withholds the entire latest batch, retries inference once, and keeps originals in Chronicle', async () => {
|
|
117
|
+
const strategy = new IngressObserver();
|
|
118
|
+
const image = Buffer.from('original-image-bytes').toString('base64');
|
|
119
|
+
const h = await harness([[calls('text', 'image', 'error'), refused()], [answer()]], { toolResultGuard: true, strategy, refusalHandling: { retries: 3 } }, {
|
|
120
|
+
text: { success: true, data: 'original-text-payload' },
|
|
121
|
+
image: { success: true, data: [{ type: 'image', mimeType: 'image/png', data: image }] },
|
|
122
|
+
error: { success: false, isError: true, error: 'original-error-payload' },
|
|
123
|
+
});
|
|
124
|
+
let stagedSequence = 0;
|
|
125
|
+
let originalStopped = false;
|
|
126
|
+
h.membrane.onSubmit = () => { stagedSequence = h.framework.getStore().currentSequence(); };
|
|
127
|
+
try {
|
|
128
|
+
await h.run();
|
|
129
|
+
assert.deepEqual(h.module.calls.sort(), ['error', 'image', 'text']);
|
|
130
|
+
assert.equal(h.membrane.requests.length, 2);
|
|
131
|
+
assert.deepEqual(h.membrane.retriesAtSubmission, [0], 'first refusal must reach guard before plain retries');
|
|
132
|
+
const retry = h.membrane.requests[1];
|
|
133
|
+
assert.equal(retry.config.model, 'test');
|
|
134
|
+
assert.doesNotMatch(JSON.stringify(retry), /original-text-payload|original-error-payload|discard-this-partial-output|test-category/);
|
|
135
|
+
assert.ok(!JSON.stringify(retry).includes(image));
|
|
136
|
+
assert.equal(retry.messages.flatMap((m) => m.content).filter((b) => b.type === 'tool_use').length, 3);
|
|
137
|
+
const guarded = toolResults(h.framework);
|
|
138
|
+
assert.equal(guarded.length, 3);
|
|
139
|
+
assert.ok(guarded.every((block) => block.content === TOOL_RESULT_GUARD_NOTICE));
|
|
140
|
+
assert.equal(guarded.find((b) => b.toolUseId === 'error')?.isError, true);
|
|
141
|
+
assert.deepEqual(h.module.speeches, ['continued']);
|
|
142
|
+
assert.ok(strategy.snapshots.every((s) => !s.includes('original-text-payload') && !s.includes(image)), 'background strategy ingress must never see withheld payloads');
|
|
143
|
+
const store = h.framework.getStore();
|
|
144
|
+
const audit = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
145
|
+
assert.match(JSON.stringify(audit[0]), /original-text-payload|original-error-payload/);
|
|
146
|
+
assert.ok(JSON.stringify(audit[0]).includes(image));
|
|
147
|
+
assert.equal(audit.at(-1)?.type, 'withheld');
|
|
148
|
+
const historical = store.getStateJsonAt(TOOL_RESULT_GUARD_AUDIT_STATE, stagedSequence);
|
|
149
|
+
assert.deepEqual(audit[0], historical[0], 'redaction only appends; original Chronicle record is unchanged');
|
|
150
|
+
const original = structuredClone(audit[0]);
|
|
151
|
+
extension(h.framework).update('assistant', { tool_result_guard: false });
|
|
152
|
+
assert.ok(toolResults(h.framework).every((block) => block.content === TOOL_RESULT_GUARD_NOTICE));
|
|
153
|
+
await h.framework.stop();
|
|
154
|
+
originalStopped = true;
|
|
155
|
+
const restarted = await AgentFramework.create(h.base);
|
|
156
|
+
try {
|
|
157
|
+
assert.equal(extension(restarted).get('assistant').tool_result_guard, false, 'explicit disable persists');
|
|
158
|
+
assert.ok(toolResults(restarted).every((block) => block.content === TOOL_RESULT_GUARD_NOTICE));
|
|
159
|
+
assert.deepEqual(restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE)[0], original);
|
|
160
|
+
const preview = await restarted.previewActivation('assistant');
|
|
161
|
+
assert.doesNotMatch(JSON.stringify(preview), /original-text-payload|original-error-payload/);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await restarted.stop();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
if (!originalStopped)
|
|
169
|
+
await h.framework.stop();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
test('successful physical rounds admit results; refusal affects only the newest batch', async () => {
|
|
173
|
+
const h = await harness([[calls('accepted'), calls('withheld'), refused()], [answer()]], { toolResultGuard: true });
|
|
174
|
+
try {
|
|
175
|
+
await h.run();
|
|
176
|
+
const results = toolResults(h.framework);
|
|
177
|
+
assert.match(String(results.find((b) => b.toolUseId === 'accepted')?.content), /payload-accepted/);
|
|
178
|
+
assert.equal(results.find((b) => b.toolUseId === 'withheld')?.content, TOOL_RESULT_GUARD_NOTICE);
|
|
179
|
+
assert.deepEqual(h.module.calls, ['accepted', 'withheld']);
|
|
180
|
+
assert.equal(h.framework.getAgent('assistant').toolResultGuard.enabled, true);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await h.framework.stop();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
test('a second refusal stops recovery without auto-rewinding older or human messages', async () => {
|
|
187
|
+
const h = await harness([[calls('one'), refused()], [refused()]], { toolResultGuard: true, refusalHandling: { autoRewind: true, retries: 3 } });
|
|
188
|
+
try {
|
|
189
|
+
await h.run();
|
|
190
|
+
assert.equal(h.membrane.requests.length, 2);
|
|
191
|
+
assert.deepEqual(h.module.calls, ['one']);
|
|
192
|
+
assert.deepEqual(h.module.speeches, []);
|
|
193
|
+
const messages = h.framework.getAgent('assistant').getContextManager().getAllMessages();
|
|
194
|
+
assert.ok(messages.some((m) => m.content.some((b) => b.type === 'text' && b.text === 'read')));
|
|
195
|
+
assert.doesNotMatch(JSON.stringify(messages), /discard-this-partial-output|refusal-rewind/);
|
|
196
|
+
assert.equal(toolResults(h.framework)[0].content, TOOL_RESULT_GUARD_NOTICE);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await h.framework.stop();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
test('durable typed agent setting can be enabled, disabled, and explicitly reset', async () => {
|
|
203
|
+
const h = await harness([]);
|
|
204
|
+
let originalStopped = false;
|
|
205
|
+
try {
|
|
206
|
+
const ext = extension(h.framework);
|
|
207
|
+
for (const value of ['true', 1, null, {}]) {
|
|
208
|
+
assert.throws(() => ext.update('assistant', { tool_result_guard: value }), /must be a boolean/);
|
|
209
|
+
}
|
|
210
|
+
ext.update('assistant', { tool_result_guard: true });
|
|
211
|
+
assert.equal(ext.get('assistant').tool_result_guard, true);
|
|
212
|
+
await h.framework.stop();
|
|
213
|
+
originalStopped = true;
|
|
214
|
+
const restarted = await AgentFramework.create(h.base);
|
|
215
|
+
try {
|
|
216
|
+
const restored = extension(restarted);
|
|
217
|
+
assert.equal(restored.get('assistant').tool_result_guard, true);
|
|
218
|
+
assert.equal(restored.get('assistant').tool_result_guard_source, 'runtime_override');
|
|
219
|
+
restored.reset('assistant');
|
|
220
|
+
assert.equal(restored.get('assistant').tool_result_guard, false);
|
|
221
|
+
const tool = restarted.getAllTools().find((t) => t.name === 'agent_settings');
|
|
222
|
+
assert.equal(tool.inputSchema.properties.tool_result_guard.type, 'boolean');
|
|
223
|
+
assert.doesNotMatch(JSON.stringify(tool), /classifier/i);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
await restarted.stop();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
if (!originalStopped)
|
|
231
|
+
await h.framework.stop();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
test('a normal successful response releases the pending output into versioned history', async () => {
|
|
235
|
+
const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
|
|
236
|
+
try {
|
|
237
|
+
await h.run();
|
|
238
|
+
assert.equal(h.membrane.requests.length, 1);
|
|
239
|
+
assert.match(String(toolResults(h.framework)[0].content), /payload-one/);
|
|
240
|
+
const records = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
241
|
+
assert.equal(records.at(-1)?.type, 'accepted');
|
|
242
|
+
assert.equal(h.framework.getAgent('assistant').toolResultGuard.enabled, true);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await h.framework.stop();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
test('refusal without a new tool result does not invoke tool guard recovery', async () => {
|
|
249
|
+
const h = await harness([[refused()]], { toolResultGuard: true });
|
|
250
|
+
try {
|
|
251
|
+
await h.run();
|
|
252
|
+
assert.equal(h.membrane.requests.length, 1);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await h.framework.stop();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
test('budget restart submits staged originals, then recovers without re-executing tools', async () => {
|
|
259
|
+
const h = await harness([[calls('one')], [refused()], [answer()]], { toolResultGuard: true, maxStreamTokens: 1 });
|
|
260
|
+
try {
|
|
261
|
+
await h.run();
|
|
262
|
+
assert.equal(h.membrane.requests.length, 3);
|
|
263
|
+
assert.match(JSON.stringify(h.membrane.requests[1]), /payload-one/);
|
|
264
|
+
assert.doesNotMatch(JSON.stringify(h.membrane.requests[2]), /payload-one/);
|
|
265
|
+
assert.deepEqual(h.module.calls, ['one']);
|
|
266
|
+
assert.equal(toolResults(h.framework)[0].content, TOOL_RESULT_GUARD_NOTICE);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
await h.framework.stop();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
test('ephemeral run settles only after recovery and counts each tool once', async () => {
|
|
273
|
+
const h = await harness([[calls('one'), refused()], [answer()]]);
|
|
274
|
+
try {
|
|
275
|
+
const created = await h.framework.createEphemeralAgent({
|
|
276
|
+
name: 'ephemeral', model: 'test', systemPrompt: 'system', toolResultGuard: true,
|
|
277
|
+
});
|
|
278
|
+
created.contextManager.addMessage('user', [{ type: 'text', text: 'read' }]);
|
|
279
|
+
const completion = h.framework.runEphemeralToCompletion(created.agent, created.contextManager);
|
|
280
|
+
h.framework.start();
|
|
281
|
+
const result = await completion;
|
|
282
|
+
assert.deepEqual(result, { speech: 'continued', toolCallsCount: 1 });
|
|
283
|
+
assert.deepEqual(h.module.calls, ['one']);
|
|
284
|
+
assert.equal(h.membrane.requests.length, 2);
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
await h.framework.stop();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
test('quiesced scheduler admits a queued guard recovery as a continuation', async () => {
|
|
291
|
+
const h = await harness([[answer()]], { toolResultGuard: true });
|
|
292
|
+
try {
|
|
293
|
+
await h.framework.quiesce();
|
|
294
|
+
h.framework.getAgent('assistant').getContextManager().addMessage('user', [{
|
|
295
|
+
type: 'text', text: TOOL_RESULT_GUARD_NOTICE,
|
|
296
|
+
}]);
|
|
297
|
+
// A recovery can be requeued while waiting for provider admission. It
|
|
298
|
+
// must finish the held turn even after the host stops admitting new work.
|
|
299
|
+
h.framework.pendingRequests.push({
|
|
300
|
+
agentName: 'assistant', reason: 'tool_result_guard_retry', source: 'framework', timestamp: Date.now(),
|
|
301
|
+
});
|
|
302
|
+
await h.framework.runUntilIdle();
|
|
303
|
+
assert.equal(h.membrane.requests.length, 1);
|
|
304
|
+
assert.equal(h.framework.getHostModeStatus().quiesced, true);
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
await h.framework.stop();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
test('native Membrane observes the first refusal even when guard is enabled by a tool mid-stream', async () => {
|
|
311
|
+
const h = await harness([]);
|
|
312
|
+
await h.framework.stop();
|
|
313
|
+
const requests = [];
|
|
314
|
+
const adapter = {
|
|
315
|
+
name: 'test', usageCacheConvention: 'cache-excluded', supportsModel: () => true,
|
|
316
|
+
complete: async () => { throw new Error('unexpected complete'); },
|
|
317
|
+
stream: async (request, callbacks) => {
|
|
318
|
+
requests.push(structuredClone(request));
|
|
319
|
+
const index = requests.length;
|
|
320
|
+
assert.ok(index <= 3, 'must not retry unchanged refused input');
|
|
321
|
+
const content = index === 1 ? [
|
|
322
|
+
{ type: 'tool_use', id: 'enable', name: 'agent_settings', input: { action: 'update', tool_result_guard: true } },
|
|
323
|
+
{ type: 'tool_use', id: 'one', name: 'test--read', input: {} },
|
|
324
|
+
] : [{ type: 'text', text: index === 2 ? 'discard-native-partial' : 'continued' }];
|
|
325
|
+
if (index > 1)
|
|
326
|
+
callbacks.onChunk?.(index === 2 ? 'discard-native-partial' : 'continued');
|
|
327
|
+
return {
|
|
328
|
+
content, stopReason: index === 1 ? 'tool_use' : index === 2 ? 'refusal' : 'end_turn',
|
|
329
|
+
usage: { inputTokens: 20, outputTokens: 5 }, model: 'test',
|
|
330
|
+
raw: { response: { stop_details: { category: 'test-category' } } },
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
const framework = await AgentFramework.create({ ...h.base,
|
|
335
|
+
agents: [{ name: 'assistant', model: 'test', systemPrompt: 'system', refusalHandling: { retries: 4 } }],
|
|
336
|
+
membrane: new RealMembrane(adapter, { formatter: new NativeFormatter() }),
|
|
337
|
+
});
|
|
338
|
+
const routed = [];
|
|
339
|
+
const outgoing = [];
|
|
340
|
+
framework.channelRegistry = new Proxy({
|
|
341
|
+
resolveLocus: () => 'world:test',
|
|
342
|
+
routeSpeech: async (_agent, speech) => {
|
|
343
|
+
routed.push(speech);
|
|
344
|
+
return { delivered: true, channelId: 'world:test' };
|
|
345
|
+
},
|
|
346
|
+
sendOutgoingChunk: (_channel, _agent, _id, _index, delta) => { outgoing.push(delta); },
|
|
347
|
+
getDefaultPublishChannel: () => null, isChannelOpen: () => true,
|
|
348
|
+
getDescriptor: () => undefined, getChannelTools: () => [],
|
|
349
|
+
}, { get: (target, key) => key in target ? target[key] : () => undefined });
|
|
350
|
+
const tokenTraces = [];
|
|
351
|
+
framework.onTrace((event) => {
|
|
352
|
+
if (event.type === 'inference:tokens')
|
|
353
|
+
tokenTraces.push(String(event.content));
|
|
354
|
+
});
|
|
355
|
+
try {
|
|
356
|
+
framework.pushEvent({ type: 'external-message', source: 'test', content: 'read', metadata: {} });
|
|
357
|
+
await framework.runUntilIdle();
|
|
358
|
+
assert.equal(requests.length, 3);
|
|
359
|
+
assert.match(JSON.stringify(requests[1]), /payload-one/);
|
|
360
|
+
assert.doesNotMatch(JSON.stringify(requests[2]), /payload-one|discard-native-partial|test-category/);
|
|
361
|
+
assert.match(JSON.stringify(requests[2]), /Tool result withheld by the guard/);
|
|
362
|
+
assert.deepEqual(h.module.calls, ['one']);
|
|
363
|
+
assert.equal(extension(framework).get('assistant').tool_result_guard, true);
|
|
364
|
+
assert.deepEqual(h.module.speeches, ['continued']);
|
|
365
|
+
assert.deepEqual(routed, ['continued']);
|
|
366
|
+
assert.doesNotMatch(outgoing.join(''), /discard-native-partial/);
|
|
367
|
+
assert.match(outgoing.join(''), /continued/, 'accepted answer must reach outgoing-stream consumers');
|
|
368
|
+
assert.doesNotMatch(tokenTraces.join(''), /discard-native-partial/, 'inference:tokens must not leak refused text');
|
|
369
|
+
assert.match(tokenTraces.join(''), /continued/);
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
await framework.stop();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
test('backward-compatible direct Agent inference also guards tool results', async () => {
|
|
376
|
+
const h = await harness([], { toolResultGuard: true });
|
|
377
|
+
const responses = [calls('one'), refused(), answer()];
|
|
378
|
+
const requests = [];
|
|
379
|
+
h.membrane.stream = async (request) => {
|
|
380
|
+
requests.push(structuredClone(request));
|
|
381
|
+
const response = responses.shift();
|
|
382
|
+
assert.ok(response);
|
|
383
|
+
return response;
|
|
384
|
+
};
|
|
385
|
+
try {
|
|
386
|
+
const agent = h.framework.getAgent('assistant');
|
|
387
|
+
agent.getContextManager().addMessage('user', [{ type: 'text', text: 'read' }]);
|
|
388
|
+
const first = await agent.runInference(h.framework.getAllTools());
|
|
389
|
+
assert.equal(first.toolCalls.length, 1);
|
|
390
|
+
agent.provideToolResult('one', { success: true, data: 'direct-original' });
|
|
391
|
+
const final = await agent.runInference(h.framework.getAllTools());
|
|
392
|
+
assert.deepEqual(final.speechContent, [{ type: 'text', text: 'continued' }]);
|
|
393
|
+
assert.equal(requests.length, 3);
|
|
394
|
+
assert.equal(final.usage?.inputTokens, 20, 'abandoned refused attempt usage is included');
|
|
395
|
+
assert.match(JSON.stringify(requests[1]), /direct-original/);
|
|
396
|
+
assert.doesNotMatch(JSON.stringify(requests[2]), /direct-original|discard-this/);
|
|
397
|
+
assert.equal(toolResults(h.framework)[0].content, TOOL_RESULT_GUARD_NOTICE);
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
await h.framework.stop();
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
test('full oversized output survives withholding and reopening as a Chronicle blob', async () => {
|
|
404
|
+
const original = 'original-large-'.repeat(8_000) + 'end-of-original';
|
|
405
|
+
const h = await harness([[calls('large'), refused()], [answer()]], { toolResultGuard: true }, {
|
|
406
|
+
large: { success: true, data: original },
|
|
407
|
+
});
|
|
408
|
+
let originalStopped = false;
|
|
409
|
+
try {
|
|
410
|
+
await h.run();
|
|
411
|
+
const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
412
|
+
const blobId = audit[0].originals.blobId;
|
|
413
|
+
assert.equal(typeof blobId, 'string');
|
|
414
|
+
const blob = h.framework.getStore().getBlob(blobId);
|
|
415
|
+
assert.equal(JSON.parse(blob.toString())[0].result.data, original, 'keep pre-truncation bytes');
|
|
416
|
+
await h.framework.stop();
|
|
417
|
+
originalStopped = true;
|
|
418
|
+
const restarted = await AgentFramework.create(h.base);
|
|
419
|
+
try {
|
|
420
|
+
assert.deepEqual(restarted.getStore().getBlob(blobId), blob);
|
|
421
|
+
assert.equal(toolResults(restarted)[0].content, TOOL_RESULT_GUARD_NOTICE);
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
await restarted.stop();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
if (!originalStopped)
|
|
429
|
+
await h.framework.stop();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Review regressions (PR #159): guard effects apply only to a batch that was
|
|
434
|
+
// actually SUBMITTED to the provider in the current turn.
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
class OmitToolExchange extends PassthroughStrategy {
|
|
437
|
+
select(...args) {
|
|
438
|
+
return super.select(...args).filter((entry) => !entry.content.some((block) => block.type === 'tool_use' || block.type === 'tool_result'));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
test('a staged batch omitted by compilation does not claim the refusal; autoRewind proceeds', async () => {
|
|
442
|
+
const h = await harness([[calls('one')], [refused()], [answer()]], {
|
|
443
|
+
toolResultGuard: true, maxStreamTokens: 1, strategy: new OmitToolExchange(),
|
|
444
|
+
refusalHandling: { autoRewind: true },
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
await h.run();
|
|
448
|
+
const guard = h.framework.getAgent('assistant').toolResultGuard;
|
|
449
|
+
assert.equal(guard.hasPending, false, 'unsubmitted batch must be settled, not stranded');
|
|
450
|
+
assert.equal(guard.recovering, false);
|
|
451
|
+
assert.doesNotMatch(JSON.stringify(h.membrane.requests[1]), /payload-one/);
|
|
452
|
+
assert.equal(h.membrane.requests.length, 3, 'ordinary refusal rewind retry must run');
|
|
453
|
+
const messages = h.framework.getAgent('assistant').getContextManager().getAllMessages();
|
|
454
|
+
assert.match(JSON.stringify(messages), /\[refusal-rewind\]/);
|
|
455
|
+
const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
456
|
+
assert.ok(audit.some((r) => r.type === 'withheld' && r.reason === 'unsubmitted'));
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
await h.framework.stop();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
test('a turn ended by an endTurn tool settles (accepts) its batch', async () => {
|
|
463
|
+
const h = await harness([[calls('one')]], { toolResultGuard: true }, { one: { success: true, data: 'payload-one', endTurn: true } });
|
|
464
|
+
try {
|
|
465
|
+
await h.run();
|
|
466
|
+
const guard = h.framework.getAgent('assistant').toolResultGuard;
|
|
467
|
+
assert.equal(guard.hasPending, false);
|
|
468
|
+
assert.match(String(toolResults(h.framework)[0].content), /payload-one/);
|
|
469
|
+
const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
470
|
+
assert.equal(audit.at(-1)?.type, 'accepted');
|
|
471
|
+
assert.equal(audit.at(-1)?.reason, 'turn_ended');
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
await h.framework.stop();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
test('a refusal on the next turn after endTurn uses ordinary refusal handling', async () => {
|
|
478
|
+
const h = await harness([[calls('one')], [refused()], [answer()]], {
|
|
479
|
+
toolResultGuard: true, refusalHandling: { autoRewind: true, retries: 2 },
|
|
480
|
+
}, { one: { success: true, data: 'payload-one', endTurn: true } });
|
|
481
|
+
try {
|
|
482
|
+
await h.run();
|
|
483
|
+
await h.run();
|
|
484
|
+
const messages = h.framework.getAgent('assistant').getContextManager().getAllMessages();
|
|
485
|
+
assert.match(JSON.stringify(messages), /\[refusal-rewind\]/, 'autoRewind must not be suppressed');
|
|
486
|
+
assert.equal(h.membrane.requests.length, 3);
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
await h.framework.stop();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
test('budget restart reserves the real wire cost of the pending batch', async () => {
|
|
493
|
+
const big = 'x'.repeat(40_000); // ~10k tokens on the wire, notice is ~20
|
|
494
|
+
const budget = 14_000;
|
|
495
|
+
const h = await harness([[calls('one')], [answer()]], {
|
|
496
|
+
toolResultGuard: true, maxStreamTokens: 1, contextBudgetTokens: budget, maxTokens: 1_000,
|
|
497
|
+
}, { one: { success: true, data: big } });
|
|
498
|
+
try {
|
|
499
|
+
const cm = h.framework.getAgent('assistant').getContextManager();
|
|
500
|
+
for (let i = 0; i < 20; i++) {
|
|
501
|
+
cm.addMessage('user', [{ type: 'text', text: `filler-${i} ` + 'y'.repeat(2_000) }]);
|
|
502
|
+
}
|
|
503
|
+
await h.run();
|
|
504
|
+
const rebuilt = h.membrane.requests[1];
|
|
505
|
+
assert.match(JSON.stringify(rebuilt), /xxxxxxxx/, 'pending original still submitted');
|
|
506
|
+
const chars = rebuilt.messages.flatMap((m) => m.content).reduce((n, b) => n + JSON.stringify(b).length, 0);
|
|
507
|
+
assert.ok(Math.ceil(chars / 4) <= budget - 1_000, `rebuilt request ~${Math.ceil(chars / 4)} tokens exceeds budget ${budget - 1_000}`);
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
await h.framework.stop();
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
test('audit is synced before the pending batch is submitted', async () => {
|
|
514
|
+
const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
|
|
515
|
+
const store = h.framework.getStore();
|
|
516
|
+
const sync = store.sync.bind(store);
|
|
517
|
+
let syncedWhilePending = false;
|
|
518
|
+
store.sync = () => {
|
|
519
|
+
if (h.framework.getAgent('assistant').toolResultGuard.hasPending)
|
|
520
|
+
syncedWhilePending = true;
|
|
521
|
+
sync();
|
|
522
|
+
};
|
|
523
|
+
let durableAtSubmit = false;
|
|
524
|
+
h.membrane.onSubmit = () => { durableAtSubmit = syncedWhilePending; };
|
|
525
|
+
try {
|
|
526
|
+
await h.run();
|
|
527
|
+
assert.equal(durableAtSubmit, true);
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
store.sync = sync;
|
|
531
|
+
await h.framework.stop();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
test('direct API: a transient compile failure does not wedge the guard', async () => {
|
|
535
|
+
const h = await harness([], { toolResultGuard: true });
|
|
536
|
+
const responses = [calls('one'), answer()];
|
|
537
|
+
h.membrane.stream =
|
|
538
|
+
async () => responses.shift();
|
|
539
|
+
try {
|
|
540
|
+
const agent = h.framework.getAgent('assistant');
|
|
541
|
+
agent.getContextManager().addMessage('user', [{ type: 'text', text: 'read' }]);
|
|
542
|
+
await agent.runInference(h.framework.getAllTools());
|
|
543
|
+
agent.provideToolResult('one', { success: true, data: 'direct-original' });
|
|
544
|
+
const compile = agent.compileWithInjections.bind(agent);
|
|
545
|
+
let failed = false;
|
|
546
|
+
agent.compileWithInjections = async (...args) => {
|
|
547
|
+
if (!failed) {
|
|
548
|
+
failed = true;
|
|
549
|
+
throw new Error('transient compile failure');
|
|
550
|
+
}
|
|
551
|
+
return compile(...args);
|
|
552
|
+
};
|
|
553
|
+
await assert.rejects(agent.runInference(h.framework.getAllTools()), /transient compile failure/);
|
|
554
|
+
const final = await agent.runInference(h.framework.getAllTools());
|
|
555
|
+
assert.deepEqual(final.speechContent, [{ type: 'text', text: 'continued' }]);
|
|
556
|
+
assert.equal(toolResults(h.framework).length, 1, 'batch staged exactly once');
|
|
557
|
+
assert.match(String(toolResults(h.framework)[0].content), /direct-original/);
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
await h.framework.stop();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
test('usage of an abandoned guarded round is counted in session totals', async () => {
|
|
564
|
+
const withUsage = (r, input) => ({ ...r, details: { ...(r.details ?? {}), usage: { inputTokens: input, outputTokens: 1 } } });
|
|
565
|
+
const h = await harness([[calls('one'), withUsage(refused(), 1_000)], [withUsage(answer(), 7)]], { toolResultGuard: true });
|
|
566
|
+
try {
|
|
567
|
+
await h.run();
|
|
568
|
+
const totals = h.framework.getSessionUsage().totals;
|
|
569
|
+
assert.ok(totals.inputTokens >= 1_007, `refused round usage missing: ${JSON.stringify(totals)}`);
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
await h.framework.stop();
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// Greptile review regressions (PR #159, head 647f081).
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
function fakeRegistry(framework) {
|
|
579
|
+
const routed = [];
|
|
580
|
+
framework.channelRegistry = new Proxy({
|
|
581
|
+
resolveLocus: () => 'world:test',
|
|
582
|
+
routeSpeech: async (_agent, speech) => {
|
|
583
|
+
routed.push(speech);
|
|
584
|
+
return { delivered: true, channelId: 'world:test' };
|
|
585
|
+
},
|
|
586
|
+
sendOutgoingChunk: () => { },
|
|
587
|
+
getDefaultPublishChannel: () => null, isChannelOpen: () => true,
|
|
588
|
+
getDescriptor: () => undefined, getChannelTools: () => [],
|
|
589
|
+
}, { get: (target, key) => key in target ? target[key] : () => undefined });
|
|
590
|
+
return routed;
|
|
591
|
+
}
|
|
592
|
+
test('failed audit sync fails closed: originals are never submitted', async () => {
|
|
593
|
+
const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
|
|
594
|
+
const store = h.framework.getStore();
|
|
595
|
+
const sync = store.sync.bind(store);
|
|
596
|
+
store.sync = () => {
|
|
597
|
+
if (h.framework.getAgent('assistant').toolResultGuard.hasPending)
|
|
598
|
+
throw new Error('disk full');
|
|
599
|
+
sync();
|
|
600
|
+
};
|
|
601
|
+
try {
|
|
602
|
+
await h.run();
|
|
603
|
+
const provided = JSON.stringify(h.membrane.streams[0].receivedToolResults);
|
|
604
|
+
assert.doesNotMatch(provided, /payload-one/, 'non-durable audit must not release originals');
|
|
605
|
+
assert.match(provided, /Tool result withheld by the guard/);
|
|
606
|
+
assert.equal(toolResults(h.framework)[0].content, TOOL_RESULT_GUARD_NOTICE);
|
|
607
|
+
const guard = h.framework.getAgent('assistant').toolResultGuard;
|
|
608
|
+
assert.equal(guard.hasPending, false);
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
store.sync = sync;
|
|
612
|
+
await h.framework.stop();
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
test('an aborted stream settles its submitted batch; the next turn neither resubmits nor claims it', async () => {
|
|
616
|
+
const h = await harness([[calls('one')], [refused()], [answer()]], {
|
|
617
|
+
toolResultGuard: true, refusalHandling: { autoRewind: true },
|
|
618
|
+
});
|
|
619
|
+
h.membrane.onSubmit = () => {
|
|
620
|
+
const stream = h.membrane.streams.at(-1);
|
|
621
|
+
queueMicrotask(() => stream.cancel());
|
|
622
|
+
};
|
|
623
|
+
try {
|
|
624
|
+
await h.run();
|
|
625
|
+
const guard = h.framework.getAgent('assistant').toolResultGuard;
|
|
626
|
+
assert.equal(guard.hasPending, false, 'aborted batch must not stay pending');
|
|
627
|
+
h.membrane.onSubmit = undefined;
|
|
628
|
+
await h.run();
|
|
629
|
+
assert.doesNotMatch(JSON.stringify(h.membrane.requests[1]), /payload-one/, 'old originals must not be resubmitted');
|
|
630
|
+
const messages = h.framework.getAgent('assistant').getContextManager().getAllMessages();
|
|
631
|
+
assert.match(JSON.stringify(messages), /\[refusal-rewind\]/, 'ordinary autoRewind must run');
|
|
632
|
+
const audit = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
633
|
+
assert.ok(audit.some((r) => r.type === 'withheld' && r.reason === 'aborted'));
|
|
634
|
+
}
|
|
635
|
+
finally {
|
|
636
|
+
await h.framework.stop();
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
test('guard recovery keeps same-turn explicit-send suppression', async () => {
|
|
640
|
+
const h = await harness([[createMockResponse([
|
|
641
|
+
{ type: 'tool_use', id: 'send', name: 'test--send_message', input: {} },
|
|
642
|
+
{ type: 'tool_use', id: 'one', name: 'test--read', input: {} },
|
|
643
|
+
], 'tool_use'), refused()], [createMockResponse([{ type: 'text', text: 'postscript' }])]], { toolResultGuard: true });
|
|
644
|
+
const routed = fakeRegistry(h.framework);
|
|
645
|
+
try {
|
|
646
|
+
await h.run();
|
|
647
|
+
assert.equal(h.membrane.requests.length, 2);
|
|
648
|
+
assert.ok(!routed.some((text) => text.includes('postscript')), `postscript routed: ${JSON.stringify(routed)}`);
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
await h.framework.stop();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
test('a failed audit append while settling an aborted batch does not block teardown', async () => {
|
|
655
|
+
const h = await harness([[calls('one')], [answer()]], { toolResultGuard: true });
|
|
656
|
+
const store = h.framework.getStore();
|
|
657
|
+
const append = store.appendToStateJson.bind(store);
|
|
658
|
+
let failAborted = true;
|
|
659
|
+
store.appendToStateJson = (id, value) => {
|
|
660
|
+
if (failAborted && value?.reason === 'aborted')
|
|
661
|
+
throw new Error('storage down');
|
|
662
|
+
return append(id, value);
|
|
663
|
+
};
|
|
664
|
+
h.membrane.onSubmit = () => {
|
|
665
|
+
const stream = h.membrane.streams.at(-1);
|
|
666
|
+
queueMicrotask(() => stream.cancel());
|
|
667
|
+
};
|
|
668
|
+
try {
|
|
669
|
+
await h.run().catch(() => { });
|
|
670
|
+
assert.equal(h.framework.getAgent('assistant').toolResultGuard.hasPending, false);
|
|
671
|
+
failAborted = false;
|
|
672
|
+
h.membrane.onSubmit = undefined;
|
|
673
|
+
await h.run();
|
|
674
|
+
assert.equal(h.membrane.requests.length, 2, 'agent must accept a later turn after teardown');
|
|
675
|
+
assert.deepEqual(h.module.speeches.at(-1), 'continued');
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
store.appendToStateJson = append;
|
|
679
|
+
await h.framework.stop();
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
test('an aborted outcome that failed to append is recorded on the next guard write or stop', async () => {
|
|
683
|
+
const h = await harness([[calls('one')], [calls('two'), answer()]], { toolResultGuard: true });
|
|
684
|
+
const store = h.framework.getStore();
|
|
685
|
+
const append = store.appendToStateJson.bind(store);
|
|
686
|
+
let failAborted = true;
|
|
687
|
+
store.appendToStateJson = (id, value) => {
|
|
688
|
+
if (failAborted && value?.reason === 'aborted')
|
|
689
|
+
throw new Error('storage down');
|
|
690
|
+
return append(id, value);
|
|
691
|
+
};
|
|
692
|
+
h.membrane.onSubmit = () => {
|
|
693
|
+
const stream = h.membrane.streams.at(-1);
|
|
694
|
+
queueMicrotask(() => stream.cancel());
|
|
695
|
+
};
|
|
696
|
+
try {
|
|
697
|
+
await h.run().catch(() => { });
|
|
698
|
+
failAborted = false;
|
|
699
|
+
h.membrane.onSubmit = undefined;
|
|
700
|
+
await h.run();
|
|
701
|
+
const audit = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
702
|
+
const first = audit[0].batchId;
|
|
703
|
+
const outcome = audit.findIndex((r) => r.batchId === first && r.type === 'withheld' && r.reason === 'aborted');
|
|
704
|
+
assert.ok(outcome > 0, 'lost aborted outcome must be retried');
|
|
705
|
+
const nextStaged = audit.findIndex((r, i) => i > 0 && r.type === 'staged');
|
|
706
|
+
assert.ok(outcome < nextStaged, 'retried outcome lands before the next batch');
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
store.appendToStateJson = append;
|
|
710
|
+
await h.framework.stop();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
test('a lost aborted outcome is flushed at stop', async () => {
|
|
714
|
+
const h = await harness([[calls('one')]], { toolResultGuard: true });
|
|
715
|
+
const store = h.framework.getStore();
|
|
716
|
+
const append = store.appendToStateJson.bind(store);
|
|
717
|
+
let failAborted = true;
|
|
718
|
+
store.appendToStateJson = (id, value) => {
|
|
719
|
+
if (failAborted && value?.reason === 'aborted')
|
|
720
|
+
throw new Error('storage down');
|
|
721
|
+
return append(id, value);
|
|
722
|
+
};
|
|
723
|
+
h.membrane.onSubmit = () => {
|
|
724
|
+
const stream = h.membrane.streams.at(-1);
|
|
725
|
+
queueMicrotask(() => stream.cancel());
|
|
726
|
+
};
|
|
727
|
+
let stopped = false;
|
|
728
|
+
try {
|
|
729
|
+
await h.run().catch(() => { });
|
|
730
|
+
failAborted = false;
|
|
731
|
+
const written = [];
|
|
732
|
+
store.appendToStateJson = (id, value) => {
|
|
733
|
+
if (id === TOOL_RESULT_GUARD_AUDIT_STATE)
|
|
734
|
+
written.push(value);
|
|
735
|
+
return append(id, value);
|
|
736
|
+
};
|
|
737
|
+
await h.framework.stop();
|
|
738
|
+
stopped = true;
|
|
739
|
+
assert.ok(written.some((r) => r.type === 'withheld' && r.reason === 'aborted'), 'outcome flushed at stop');
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
if (!stopped)
|
|
743
|
+
await h.framework.stop();
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
// Compression holds (CM 0.12): real Autobiographical compression with the
|
|
748
|
+
// guard on (Codex 09-26 P1 repro / review finding #3).
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
async function compressionRig() {
|
|
751
|
+
const { ContextManager, AutobiographicalStrategy } = await import('@animalabs/context-manager');
|
|
752
|
+
const { ToolResultGuard } = await import('../src/tool-result-guard.js');
|
|
753
|
+
const dir = mkdtempSync(join(tmpdir(), 'af-guard-compression-'));
|
|
754
|
+
dirs.push(dir);
|
|
755
|
+
const prompts = [];
|
|
756
|
+
const strategy = new AutobiographicalStrategy({
|
|
757
|
+
compressionModel: 'test-compression-model', targetChunkTokens: 50, headWindowTokens: 0,
|
|
758
|
+
recentWindowTokens: 0, autoTickOnNewMessage: false, minChunkCharsForLLM: 0, l1HoldbackChunks: 0,
|
|
759
|
+
});
|
|
760
|
+
const cm = await ContextManager.open({ path: join(dir, 'store'), strategy, membrane: {
|
|
761
|
+
complete: async (req) => {
|
|
762
|
+
prompts.push(JSON.stringify(req));
|
|
763
|
+
return { stopReason: 'end_turn', content: [{ type: 'text', text: `Summary #${prompts.length}` }] };
|
|
764
|
+
},
|
|
765
|
+
} });
|
|
766
|
+
cm.setToolDefinitions([{ name: 'search', description: 'search', inputSchema: { type: 'object', properties: {} } }]);
|
|
767
|
+
const filler = (n) => 'word '.repeat(n);
|
|
768
|
+
for (let i = 0; i < 6; i++)
|
|
769
|
+
cm.addMessage(i % 2 === 0 ? 'User' : 'Claude', [{ type: 'text', text: filler(30) }]);
|
|
770
|
+
cm.addMessage('Claude', [{ type: 'text', text: filler(20) }, { type: 'tool_use', id: 'tu-1', name: 'search', input: {} }]);
|
|
771
|
+
const guard = new ToolResultGuard('Claude', cm, true);
|
|
772
|
+
const REAL = 'REAL-ACCEPTED-TOOL-OUTPUT';
|
|
773
|
+
const content = [{ type: 'tool_result', toolUseId: 'tu-1', content: REAL }];
|
|
774
|
+
const messageId = guard.storeResults(content, [{ toolUseId: 'tu-1', content: REAL }], []);
|
|
775
|
+
// Deferred messages flushed right behind the staged batch push it out of
|
|
776
|
+
// the protected tail.
|
|
777
|
+
for (let i = 0; i < 6; i++)
|
|
778
|
+
cm.addMessage(i % 2 === 0 ? 'Claude' : 'User', [{ type: 'text', text: filler(30) }]);
|
|
779
|
+
const drain = async () => {
|
|
780
|
+
const s = strategy;
|
|
781
|
+
await cm.compile();
|
|
782
|
+
let n = 0;
|
|
783
|
+
while (s.compressionQueue.length > 0 && n++ < 50)
|
|
784
|
+
await cm.tick();
|
|
785
|
+
};
|
|
786
|
+
return { cm, guard, prompts, drain, messageId, REAL };
|
|
787
|
+
}
|
|
788
|
+
test('real compression: a pending batch is held; accepted output reaches memory, placeholder never does', async () => {
|
|
789
|
+
const r = await compressionRig();
|
|
790
|
+
try {
|
|
791
|
+
assert.deepEqual([...r.cm.getCompressionHolds()], [r.messageId], 'staged with a compression hold');
|
|
792
|
+
await r.drain();
|
|
793
|
+
assert.ok(r.prompts.length > 0, 'history before the hold still compresses');
|
|
794
|
+
assert.ok(!r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)), 'placeholder summarized while pending');
|
|
795
|
+
r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
|
|
796
|
+
r.guard.accept();
|
|
797
|
+
assert.equal(r.cm.getCompressionHolds().size, 0, 'accept releases the hold');
|
|
798
|
+
await r.drain();
|
|
799
|
+
assert.ok(r.prompts.some((p) => p.includes(r.REAL)), 'accepted output must reach the compression request');
|
|
800
|
+
assert.ok(!r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)), 'placeholder must never be summarized');
|
|
801
|
+
}
|
|
802
|
+
finally {
|
|
803
|
+
r.cm.close();
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
test('real compression: a withheld batch releases its hold and the placeholder compresses normally', async () => {
|
|
807
|
+
const r = await compressionRig();
|
|
808
|
+
try {
|
|
809
|
+
await r.drain();
|
|
810
|
+
r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
|
|
811
|
+
assert.ok(r.guard.withhold('test-category'));
|
|
812
|
+
assert.equal(r.cm.getCompressionHolds().size, 0, 'withhold releases the hold');
|
|
813
|
+
await r.drain();
|
|
814
|
+
assert.ok(r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)), 'placeholder compresses after release');
|
|
815
|
+
assert.ok(!r.prompts.some((p) => p.includes(r.REAL)), 'withheld output never reaches memory');
|
|
816
|
+
}
|
|
817
|
+
finally {
|
|
818
|
+
r.cm.close();
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
test('every settlement path releases the hold, even when the audit write fails', async () => {
|
|
822
|
+
const paths = [
|
|
823
|
+
['accept-submitted', (g) => { g.submissionResults([{ toolUseId: 'tu-1', content: 'x' }]); g.accept(); }],
|
|
824
|
+
['accept-unsubmitted', (g) => g.accept()],
|
|
825
|
+
['withhold', (g) => { g.submissionResults([{ toolUseId: 'tu-1', content: 'x' }]); g.withhold('c'); }],
|
|
826
|
+
['turn-ended', (g) => g.settleTurnEnded()],
|
|
827
|
+
['unsubmitted', (g) => g.settleUnsubmitted('c')],
|
|
828
|
+
['abandon', (g) => g.abandon('aborted')],
|
|
829
|
+
];
|
|
830
|
+
for (const [name, settle] of paths) {
|
|
831
|
+
const r = await compressionRig();
|
|
832
|
+
try {
|
|
833
|
+
const store = r.cm.getStore();
|
|
834
|
+
store.appendToStateJson = () => { throw new Error('storage down'); };
|
|
835
|
+
try {
|
|
836
|
+
settle(r.guard);
|
|
837
|
+
}
|
|
838
|
+
catch { /* audit failure surfaces; hold must still go */ }
|
|
839
|
+
assert.equal(r.guard.hasPending, false, `${name}: batch cleared`);
|
|
840
|
+
assert.equal(r.cm.getCompressionHolds().size, 0, `${name}: hold released`);
|
|
841
|
+
}
|
|
842
|
+
finally {
|
|
843
|
+
r.cm.close();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
for (const failedType of ['staged', 'linked']) {
|
|
848
|
+
test(`storage recovery: failed ${failedType} append continues with placeholders and releases the hold`, async () => {
|
|
849
|
+
const h = await harness([[calls('one'), answer()], [answer()]], { toolResultGuard: true });
|
|
850
|
+
const store = h.framework.getStore();
|
|
851
|
+
const append = store.appendToStateJson.bind(store);
|
|
852
|
+
let failing = true;
|
|
853
|
+
store.appendToStateJson = (id, value) => {
|
|
854
|
+
if (failing && id === TOOL_RESULT_GUARD_AUDIT_STATE && value?.type === failedType) {
|
|
855
|
+
throw new Error(`injected ${failedType} append failure`);
|
|
856
|
+
}
|
|
857
|
+
return append(id, value);
|
|
858
|
+
};
|
|
859
|
+
try {
|
|
860
|
+
await assert.doesNotReject(h.run(), 'storage failure must not strand the live tool loop');
|
|
861
|
+
const agent = h.framework.getAgent('assistant');
|
|
862
|
+
assert.equal(agent.state.status, 'idle');
|
|
863
|
+
assert.equal(agent.toolResultGuard.hasPending, false);
|
|
864
|
+
assert.equal(agent.getContextManager().getCompressionHolds().size, 0);
|
|
865
|
+
assert.match(JSON.stringify(h.membrane.streams[0].receivedToolResults), /Tool result withheld/);
|
|
866
|
+
assert.doesNotMatch(JSON.stringify(h.membrane.streams[0].receivedToolResults), /payload-one/);
|
|
867
|
+
failing = false;
|
|
868
|
+
await h.run(); // A normal activation, with no new tool call, retries the audit.
|
|
869
|
+
const records = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
870
|
+
assert.deepEqual(records.map((r) => r.type), ['staged', 'linked', 'withheld']);
|
|
871
|
+
assert.match(JSON.stringify(records[0]), /payload-one/, 'the original is recoverable after storage returns');
|
|
872
|
+
assert.doesNotMatch(JSON.stringify(h.membrane.requests[1]), /payload-one/);
|
|
873
|
+
assert.deepEqual(h.module.calls, ['one']);
|
|
874
|
+
}
|
|
875
|
+
finally {
|
|
876
|
+
store.appendToStateJson = append;
|
|
877
|
+
for (const stream of h.membrane.streams)
|
|
878
|
+
stream.cancel();
|
|
879
|
+
await h.framework.stop();
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
test('storage recovery: failed accepted edit is retried before the next activation without rerunning tools', async () => {
|
|
884
|
+
const h = await harness([[calls('one'), answer()], [answer()]], { toolResultGuard: true });
|
|
885
|
+
const agent = h.framework.getAgent('assistant');
|
|
886
|
+
const cm = agent.getContextManager();
|
|
887
|
+
const edit = cm.editMessage.bind(cm);
|
|
888
|
+
let failing = true;
|
|
889
|
+
cm.editMessage = (id, content) => {
|
|
890
|
+
if (failing && JSON.stringify(content).includes('payload-one'))
|
|
891
|
+
throw new Error('injected edit failure');
|
|
892
|
+
edit(id, content);
|
|
893
|
+
};
|
|
894
|
+
try {
|
|
895
|
+
await h.run();
|
|
896
|
+
assert.equal(agent.state.status, 'idle');
|
|
897
|
+
assert.equal(agent.toolResultGuard.hasPending, false, 'an accepted batch cannot claim another refusal');
|
|
898
|
+
assert.equal(cm.getCompressionHolds().size, 1, 'keep provisional history out of compression until repaired');
|
|
899
|
+
assert.equal(toolResults(h.framework)[0].content, TOOL_RESULT_GUARD_NOTICE);
|
|
900
|
+
assert.throws(() => agent.toolResultGuard.flushUnrecorded(), /injected edit failure/);
|
|
901
|
+
failing = false;
|
|
902
|
+
await h.run();
|
|
903
|
+
assert.match(JSON.stringify(h.membrane.requests[1]), /payload-one/, 'next activation must see the accepted result');
|
|
904
|
+
assert.equal(cm.getCompressionHolds().size, 0);
|
|
905
|
+
const records = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
906
|
+
assert.equal(records.filter((r) => r.type === 'accepted').length, 1);
|
|
907
|
+
assert.deepEqual(h.module.calls, ['one']);
|
|
908
|
+
assert.equal(h.module.speeches.at(-1), 'continued');
|
|
909
|
+
assert.equal(cm.getAllMessages().filter((message) => message.participant === 'assistant'
|
|
910
|
+
&& message.content.some((block) => block.type === 'text' && block.text === 'continued')).length, 2);
|
|
911
|
+
}
|
|
912
|
+
finally {
|
|
913
|
+
cm.editMessage = edit;
|
|
914
|
+
await h.framework.stop();
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
test('storage recovery: failed accepted edit stays held until the real compressor can see the original', async () => {
|
|
918
|
+
const r = await compressionRig();
|
|
919
|
+
const edit = r.cm.editMessage.bind(r.cm);
|
|
920
|
+
r.cm.editMessage = () => { throw new Error('injected edit failure'); };
|
|
921
|
+
try {
|
|
922
|
+
r.guard.submissionResults([{ toolUseId: 'tu-1', content: r.REAL }]);
|
|
923
|
+
assert.doesNotThrow(() => r.guard.accept());
|
|
924
|
+
assert.equal(r.cm.getCompressionHolds().size, 1);
|
|
925
|
+
await r.drain();
|
|
926
|
+
assert.ok(!r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)));
|
|
927
|
+
r.cm.editMessage = edit;
|
|
928
|
+
r.guard.flushUnrecorded();
|
|
929
|
+
assert.equal(r.cm.getCompressionHolds().size, 0);
|
|
930
|
+
await r.drain();
|
|
931
|
+
assert.ok(r.prompts.some((p) => p.includes(r.REAL)));
|
|
932
|
+
assert.ok(!r.prompts.some((p) => p.includes(TOOL_RESULT_GUARD_NOTICE)));
|
|
933
|
+
}
|
|
934
|
+
finally {
|
|
935
|
+
r.cm.editMessage = edit;
|
|
936
|
+
r.cm.close();
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
test('storage recovery: failed accepted edit and its audit outcome are repaired at stop', async () => {
|
|
940
|
+
const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
|
|
941
|
+
const cm = h.framework.getAgent('assistant').getContextManager();
|
|
942
|
+
const edit = cm.editMessage.bind(cm);
|
|
943
|
+
cm.editMessage = () => { throw new Error('injected edit failure'); };
|
|
944
|
+
let stopped = false;
|
|
945
|
+
try {
|
|
946
|
+
await h.run();
|
|
947
|
+
cm.editMessage = edit;
|
|
948
|
+
await h.framework.stop();
|
|
949
|
+
stopped = true;
|
|
950
|
+
const restarted = await AgentFramework.create(h.base);
|
|
951
|
+
try {
|
|
952
|
+
assert.match(String(toolResults(restarted)[0].content), /payload-one/);
|
|
953
|
+
const records = restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
954
|
+
assert.equal(records.at(-1)?.type, 'accepted');
|
|
955
|
+
}
|
|
956
|
+
finally {
|
|
957
|
+
await restarted.stop();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
finally {
|
|
961
|
+
cm.editMessage = edit;
|
|
962
|
+
if (!stopped)
|
|
963
|
+
await h.framework.stop();
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
test('storage recovery: endTurn never admits a batch whose audit sync failed', async () => {
|
|
967
|
+
const h = await harness([[calls('one')], [answer()]], { toolResultGuard: true }, {
|
|
968
|
+
one: { success: true, data: 'payload-one', endTurn: true },
|
|
969
|
+
});
|
|
970
|
+
const agent = h.framework.getAgent('assistant');
|
|
971
|
+
const store = h.framework.getStore();
|
|
972
|
+
const sync = store.sync.bind(store);
|
|
973
|
+
store.sync = () => {
|
|
974
|
+
if (agent.toolResultGuard.hasPending)
|
|
975
|
+
throw new Error('injected sync failure');
|
|
976
|
+
sync();
|
|
977
|
+
};
|
|
978
|
+
try {
|
|
979
|
+
await h.run();
|
|
980
|
+
assert.equal(toolResults(h.framework)[0].content, TOOL_RESULT_GUARD_NOTICE);
|
|
981
|
+
assert.equal(agent.getContextManager().getCompressionHolds().size, 0);
|
|
982
|
+
assert.equal(agent.toolResultGuard.hasPending, false);
|
|
983
|
+
store.sync = sync;
|
|
984
|
+
await h.run();
|
|
985
|
+
assert.doesNotMatch(JSON.stringify(h.membrane.requests[1]), /payload-one/);
|
|
986
|
+
const records = store.getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
987
|
+
assert.equal(records.at(-1)?.type, 'withheld');
|
|
988
|
+
assert.match(JSON.stringify(records[0]), /payload-one/, 'the original is still archived');
|
|
989
|
+
}
|
|
990
|
+
finally {
|
|
991
|
+
store.sync = sync;
|
|
992
|
+
await h.framework.stop();
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
for (const supersedingAction of ['edit', 'branch']) {
|
|
996
|
+
test(`storage recovery: queued acceptance respects an operator ${supersedingAction}`, async () => {
|
|
997
|
+
const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
|
|
998
|
+
const agent = h.framework.getAgent('assistant');
|
|
999
|
+
const cm = agent.getContextManager();
|
|
1000
|
+
const edit = cm.editMessage.bind(cm);
|
|
1001
|
+
cm.editMessage = () => { throw new Error('injected edit failure'); };
|
|
1002
|
+
try {
|
|
1003
|
+
await h.run();
|
|
1004
|
+
cm.editMessage = edit;
|
|
1005
|
+
const result = cm.getAllMessages().find((message) => message.content.some((block) => block.type === 'tool_result'));
|
|
1006
|
+
if (supersedingAction === 'edit') {
|
|
1007
|
+
cm.editMessage(result.id, [{ type: 'tool_result', toolUseId: 'one', content: 'operator replacement' }]);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
await cm.fork('operator-branch');
|
|
1011
|
+
}
|
|
1012
|
+
agent.toolResultGuard.flushUnrecorded();
|
|
1013
|
+
assert.equal(toolResults(h.framework)[0].content, supersedingAction === 'edit' ? 'operator replacement' : TOOL_RESULT_GUARD_NOTICE);
|
|
1014
|
+
assert.equal(cm.getCompressionHolds().size, 0);
|
|
1015
|
+
const records = h.framework.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
1016
|
+
assert.equal(records.at(-1)?.historyUpdate, 'superseded');
|
|
1017
|
+
assert.match(JSON.stringify(records[0]), /payload-one/, 'original audit remains intact');
|
|
1018
|
+
}
|
|
1019
|
+
finally {
|
|
1020
|
+
cm.editMessage = edit;
|
|
1021
|
+
await h.framework.stop();
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
test('storage recovery: idle maintenance retries acceptance without a new activation', async () => {
|
|
1026
|
+
const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
|
|
1027
|
+
const cm = h.framework.getAgent('assistant').getContextManager();
|
|
1028
|
+
const edit = cm.editMessage.bind(cm);
|
|
1029
|
+
cm.editMessage = () => { throw new Error('injected edit failure'); };
|
|
1030
|
+
try {
|
|
1031
|
+
await h.run();
|
|
1032
|
+
assert.equal(cm.getCompressionHolds().size, 1);
|
|
1033
|
+
cm.editMessage = edit;
|
|
1034
|
+
h.framework.start();
|
|
1035
|
+
await h.framework.maintenanceTick();
|
|
1036
|
+
assert.equal(cm.getCompressionHolds().size, 0);
|
|
1037
|
+
assert.match(String(toolResults(h.framework)[0].content), /payload-one/);
|
|
1038
|
+
assert.equal(h.membrane.requests.length, 1);
|
|
1039
|
+
}
|
|
1040
|
+
finally {
|
|
1041
|
+
cm.editMessage = edit;
|
|
1042
|
+
await h.framework.stop();
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
test('storage recovery: shutdown retries edits queued by its terminating stream', async () => {
|
|
1046
|
+
const h = await harness([[calls('one'), answer()]], { toolResultGuard: true });
|
|
1047
|
+
const cm = h.framework.getAgent('assistant').getContextManager();
|
|
1048
|
+
const edit = cm.editMessage.bind(cm);
|
|
1049
|
+
let failOnce = true;
|
|
1050
|
+
cm.editMessage = (id, content) => {
|
|
1051
|
+
if (failOnce) {
|
|
1052
|
+
failOnce = false;
|
|
1053
|
+
throw new Error('injected edit failure');
|
|
1054
|
+
}
|
|
1055
|
+
edit(id, content);
|
|
1056
|
+
};
|
|
1057
|
+
let stopping;
|
|
1058
|
+
h.membrane.onSubmit = () => { stopping = h.framework.stop(); };
|
|
1059
|
+
try {
|
|
1060
|
+
await h.run();
|
|
1061
|
+
assert.ok(stopping);
|
|
1062
|
+
await stopping;
|
|
1063
|
+
const restarted = await AgentFramework.create(h.base);
|
|
1064
|
+
try {
|
|
1065
|
+
assert.match(String(toolResults(restarted)[0].content), /payload-one/);
|
|
1066
|
+
const records = restarted.getStore().getStateJson(TOOL_RESULT_GUARD_AUDIT_STATE);
|
|
1067
|
+
assert.equal(records.at(-1)?.type, 'accepted');
|
|
1068
|
+
}
|
|
1069
|
+
finally {
|
|
1070
|
+
await restarted.stop();
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
finally {
|
|
1074
|
+
cm.editMessage = edit;
|
|
1075
|
+
if (stopping)
|
|
1076
|
+
await stopping;
|
|
1077
|
+
else
|
|
1078
|
+
await h.framework.stop();
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
//# sourceMappingURL=tool-result-guard.test.js.map
|