@animalabs/agent-framework 0.6.6 → 0.6.8

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.
Files changed (117) hide show
  1. package/README.md +78 -0
  2. package/dist/src/agent.d.ts +5 -1
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +35 -1
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server.js +0 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +7 -2
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/framework.d.ts +91 -3
  11. package/dist/src/framework.d.ts.map +1 -1
  12. package/dist/src/framework.js +988 -82
  13. package/dist/src/framework.js.map +1 -1
  14. package/dist/src/gate/event-gate.d.ts.map +1 -1
  15. package/dist/src/gate/event-gate.js +9 -2
  16. package/dist/src/gate/event-gate.js.map +1 -1
  17. package/dist/src/index.d.ts +4 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +3 -0
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/mcpl/channel-registry.d.ts +3 -1
  22. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  23. package/dist/src/mcpl/channel-registry.js +29 -15
  24. package/dist/src/mcpl/channel-registry.js.map +1 -1
  25. package/dist/src/mcpl/server-connection.d.ts +36 -3
  26. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  27. package/dist/src/mcpl/server-connection.js +142 -28
  28. package/dist/src/mcpl/server-connection.js.map +1 -1
  29. package/dist/src/modules/workspace/index.d.ts +12 -1
  30. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  31. package/dist/src/modules/workspace/index.js +661 -2
  32. package/dist/src/modules/workspace/index.js.map +1 -1
  33. package/dist/src/modules/workspace/types.d.ts +4 -0
  34. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  35. package/dist/src/modules/workspace/types.js.map +1 -1
  36. package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
  37. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
  38. package/dist/src/recovery/discord-awareness-outbox.js +334 -0
  39. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
  40. package/dist/src/recovery/offline-branch.d.ts +45 -0
  41. package/dist/src/recovery/offline-branch.d.ts.map +1 -0
  42. package/dist/src/recovery/offline-branch.js +362 -0
  43. package/dist/src/recovery/offline-branch.js.map +1 -0
  44. package/dist/src/recovery/recover-cli.d.ts +3 -0
  45. package/dist/src/recovery/recover-cli.d.ts.map +1 -0
  46. package/dist/src/recovery/recover-cli.js +129 -0
  47. package/dist/src/recovery/recover-cli.js.map +1 -0
  48. package/dist/src/types/agent.d.ts +10 -0
  49. package/dist/src/types/agent.d.ts.map +1 -1
  50. package/dist/src/types/framework.d.ts +14 -0
  51. package/dist/src/types/framework.d.ts.map +1 -1
  52. package/dist/src/types/module.d.ts +31 -0
  53. package/dist/src/types/module.d.ts.map +1 -1
  54. package/dist/src/types/trace.d.ts +2 -0
  55. package/dist/src/types/trace.d.ts.map +1 -1
  56. package/dist/test/agent-runtime-settings.test.js +200 -93
  57. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  58. package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
  59. package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
  60. package/dist/test/busy-agent-scheduler.test.js +62 -0
  61. package/dist/test/busy-agent-scheduler.test.js.map +1 -0
  62. package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
  63. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
  64. package/dist/test/channel-lifecycle-provenance.test.js +47 -0
  65. package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
  66. package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
  67. package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
  68. package/dist/test/discord-awareness-outbox.test.js +248 -0
  69. package/dist/test/discord-awareness-outbox.test.js.map +1 -0
  70. package/dist/test/maintenance-timer.test.js +100 -0
  71. package/dist/test/maintenance-timer.test.js.map +1 -1
  72. package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
  73. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
  74. package/dist/test/mcpl-awareness-barrier.test.js +618 -0
  75. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
  76. package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
  77. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  78. package/dist/test/mcpl-request-timeout.test.js +15 -0
  79. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  80. package/dist/test/offline-recovery-branch.test.d.ts +2 -0
  81. package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
  82. package/dist/test/offline-recovery-branch.test.js +226 -0
  83. package/dist/test/offline-recovery-branch.test.js.map +1 -0
  84. package/dist/test/present-while-acting.test.d.ts +3 -2
  85. package/dist/test/present-while-acting.test.d.ts.map +1 -1
  86. package/dist/test/present-while-acting.test.js +31 -3
  87. package/dist/test/present-while-acting.test.js.map +1 -1
  88. package/dist/test/refusal-single-call.test.d.ts +2 -0
  89. package/dist/test/refusal-single-call.test.d.ts.map +1 -0
  90. package/dist/test/refusal-single-call.test.js +177 -0
  91. package/dist/test/refusal-single-call.test.js.map +1 -0
  92. package/dist/test/same-round-think-policy.test.d.ts +2 -0
  93. package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
  94. package/dist/test/same-round-think-policy.test.js +853 -0
  95. package/dist/test/same-round-think-policy.test.js.map +1 -0
  96. package/dist/test/workspace-read-image.test.d.ts +2 -0
  97. package/dist/test/workspace-read-image.test.d.ts.map +1 -0
  98. package/dist/test/workspace-read-image.test.js +518 -0
  99. package/dist/test/workspace-read-image.test.js.map +1 -0
  100. package/dist/tsconfig.tsbuildinfo +1 -1
  101. package/package.json +5 -3
  102. package/src/agent.ts +39 -2
  103. package/src/api/server.ts +8 -2
  104. package/src/framework.ts +1180 -79
  105. package/src/gate/event-gate.ts +15 -3
  106. package/src/index.ts +20 -0
  107. package/src/mcpl/channel-registry.ts +31 -16
  108. package/src/mcpl/server-connection.ts +163 -28
  109. package/src/modules/workspace/index.ts +759 -2
  110. package/src/modules/workspace/types.ts +5 -0
  111. package/src/recovery/discord-awareness-outbox.ts +459 -0
  112. package/src/recovery/offline-branch.ts +510 -0
  113. package/src/recovery/recover-cli.ts +130 -0
  114. package/src/types/agent.ts +14 -0
  115. package/src/types/framework.ts +17 -0
  116. package/src/types/module.ts +33 -0
  117. package/src/types/trace.ts +2 -0
@@ -0,0 +1,853 @@
1
+ import { describe, it } 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 { AgentFramework } from '../src/index.js';
7
+ import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js';
8
+ const TILDE_PREFACE_80 = 'P'.repeat(80);
9
+ const TILDE_THINK_4416 = 'T'.repeat(4416);
10
+ class TestToolsModule {
11
+ name = 'tools';
12
+ async start(_ctx) { }
13
+ async stop() { }
14
+ getTools() {
15
+ return [
16
+ {
17
+ name: 'read',
18
+ description: 'Read from the workspace.',
19
+ inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
20
+ },
21
+ {
22
+ name: 'ping',
23
+ description: 'A non-silencing tool.',
24
+ inputSchema: { type: 'object', properties: {} },
25
+ },
26
+ {
27
+ name: 'think',
28
+ description: 'A namespaced tool that happens to be called think.',
29
+ inputSchema: { type: 'object', properties: { content: { type: 'string' } } },
30
+ },
31
+ {
32
+ name: 'send_message',
33
+ description: 'Explicitly send a message.',
34
+ inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
35
+ },
36
+ ];
37
+ }
38
+ async handleToolCall(call) {
39
+ return { success: true, data: { ok: true, tool: call.name, input: call.input } };
40
+ }
41
+ async onProcess(event, _state) {
42
+ if (event.type !== 'external-message')
43
+ return {};
44
+ const text = String(event.content ?? '');
45
+ return {
46
+ addMessages: [{ participant: 'Antra', content: [{ type: 'text', text }] }],
47
+ requestInference: text === 'go',
48
+ };
49
+ }
50
+ }
51
+ function makeToolRound(preface) {
52
+ return [
53
+ { type: 'text', text: preface },
54
+ { type: 'tool_use', id: 'think-1', name: 'think', input: { content: TILDE_THINK_4416 } },
55
+ { type: 'tool_use', id: 'read-1', name: 'tools--read', input: { path: 'project/secret.txt' } },
56
+ ];
57
+ }
58
+ function makeChannelRegistry(framework) {
59
+ const routed = [];
60
+ const explicit = {
61
+ getChannelTools: () => [
62
+ {
63
+ name: 'think',
64
+ description: 'private reasoning',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: { content: { type: 'string' } },
68
+ required: [],
69
+ },
70
+ },
71
+ {
72
+ name: 'skip_reply',
73
+ description: 'stay silent',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: { reason: { type: 'string' } },
77
+ required: [],
78
+ },
79
+ },
80
+ ],
81
+ handleChannelToolCall: async (toolName) => {
82
+ if (toolName === 'think') {
83
+ return {
84
+ success: true,
85
+ data: {
86
+ noted: true,
87
+ note: 'Thought recorded (private — not sent anywhere). Same-round text routing depends on your current same_round_think_text_policy; use agent_settings get to inspect it, or call skip_reply to end the turn without replying.',
88
+ },
89
+ };
90
+ }
91
+ if (toolName === 'skip_reply') {
92
+ return {
93
+ success: true,
94
+ endTurn: true,
95
+ data: { skipped: true, note: 'Turn ended; nothing sent.' },
96
+ };
97
+ }
98
+ return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
99
+ },
100
+ resolveLocus: () => 'chan-test',
101
+ routeSpeech: async (_agent, text) => {
102
+ routed.push(text);
103
+ },
104
+ getDefaultPublishChannel: () => null,
105
+ isChannelOpen: () => true,
106
+ getDescriptor: () => undefined,
107
+ };
108
+ framework.channelRegistry = new Proxy(explicit, {
109
+ get: (target, prop) => (prop in target ? target[prop] : () => undefined),
110
+ });
111
+ return routed;
112
+ }
113
+ async function createFramework(opts) {
114
+ const dir = mkdtempSync(join(tmpdir(), 'same-round-think-'));
115
+ const membrane = opts?.membrane ?? new MockMembrane();
116
+ const agents = opts?.agents ?? [{
117
+ name: 'agent',
118
+ ...(opts?.policy !== undefined ? { sameRoundThinkTextPolicy: opts.policy } : {}),
119
+ }];
120
+ const framework = await AgentFramework.create({
121
+ storePath: join(dir, 'store'),
122
+ membrane: (membrane.asMembrane?.() ?? membrane),
123
+ agents: agents.map((agent) => ({
124
+ name: agent.name,
125
+ model: 'test-model',
126
+ systemPrompt: 'test',
127
+ ...(agent.sameRoundThinkTextPolicy !== undefined
128
+ ? { sameRoundThinkTextPolicy: agent.sameRoundThinkTextPolicy }
129
+ : {}),
130
+ })),
131
+ modules: [new TestToolsModule()],
132
+ });
133
+ const routed = makeChannelRegistry(framework);
134
+ return { dir, framework, membrane, routed };
135
+ }
136
+ function trigger(framework) {
137
+ framework.pushEvent({
138
+ type: 'external-message',
139
+ source: 'test',
140
+ content: 'go',
141
+ metadata: {},
142
+ });
143
+ }
144
+ async function waitFor(predicate, timeoutMs = 1000) {
145
+ const deadline = Date.now() + timeoutMs;
146
+ while (!predicate()) {
147
+ if (Date.now() >= deadline)
148
+ throw new Error('timed out waiting for condition');
149
+ await new Promise(resolve => setTimeout(resolve, 5));
150
+ }
151
+ }
152
+ class LegacyNoRoundContentStream {
153
+ pendingResolve = null;
154
+ deliveredComplete = false;
155
+ provideToolResults(_results) {
156
+ const resolve = this.pendingResolve;
157
+ this.pendingResolve = null;
158
+ resolve?.();
159
+ }
160
+ cancel() {
161
+ this.deliveredComplete = true;
162
+ const resolve = this.pendingResolve;
163
+ this.pendingResolve = null;
164
+ resolve?.();
165
+ }
166
+ async *[Symbol.asyncIterator]() {
167
+ yield {
168
+ type: 'tool-calls',
169
+ calls: [
170
+ { id: 'think-legacy', name: 'think', input: { content: TILDE_THINK_4416 } },
171
+ { id: 'read-legacy', name: 'tools--read', input: { path: 'project/secret.txt' } },
172
+ ],
173
+ context: {
174
+ rawText: '',
175
+ preamble: TILDE_PREFACE_80,
176
+ depth: 0,
177
+ previousResults: [],
178
+ accumulated: '',
179
+ },
180
+ };
181
+ await new Promise((resolve) => { this.pendingResolve = resolve; });
182
+ if (this.deliveredComplete)
183
+ return;
184
+ yield {
185
+ type: 'complete',
186
+ response: createMockResponse(makeToolRound(TILDE_PREFACE_80)),
187
+ };
188
+ }
189
+ }
190
+ class LegacyNoRoundContentMembrane {
191
+ calls = [];
192
+ streamYielding(request) {
193
+ this.calls.push(request);
194
+ return new LegacyNoRoundContentStream();
195
+ }
196
+ }
197
+ class PausedAfterFirstToolRoundStream {
198
+ responses;
199
+ events = [];
200
+ done = false;
201
+ _isWaitingForTools = false;
202
+ _pendingToolCallIds = [];
203
+ _toolDepth = 0;
204
+ pendingResolve = null;
205
+ paused = false;
206
+ receivedToolResults = [];
207
+ receivedToolResultOptions = [];
208
+ constructor(responses) {
209
+ this.responses = responses;
210
+ this.processResponse(0);
211
+ }
212
+ processResponse(index) {
213
+ const response = this.responses[index];
214
+ if (!response) {
215
+ this.done = true;
216
+ return;
217
+ }
218
+ if (response.rawAssistantText) {
219
+ this.events.push({
220
+ type: 'block',
221
+ event: { event: 'block_start', index: 0, block: { type: 'text' } },
222
+ });
223
+ this.events.push({
224
+ type: 'tokens',
225
+ content: response.rawAssistantText,
226
+ meta: { type: 'text', visible: true, blockIndex: 0 },
227
+ });
228
+ this.events.push({
229
+ type: 'block',
230
+ event: { event: 'block_complete', index: 0, block: { type: 'text', content: response.rawAssistantText } },
231
+ });
232
+ }
233
+ if (response.usage) {
234
+ this.events.push({ type: 'usage', usage: response.usage });
235
+ }
236
+ if (response.toolCalls.length > 0) {
237
+ this._isWaitingForTools = true;
238
+ this._pendingToolCallIds = response.toolCalls.map((tc) => tc.id);
239
+ this.events.push({
240
+ type: 'tool-calls',
241
+ calls: response.toolCalls.map((tc) => ({
242
+ id: tc.id,
243
+ name: tc.name,
244
+ input: tc.input,
245
+ })),
246
+ context: {
247
+ rawText: '',
248
+ preamble: '',
249
+ depth: this._toolDepth,
250
+ previousResults: [],
251
+ accumulated: '',
252
+ roundContent: response.content,
253
+ },
254
+ });
255
+ return;
256
+ }
257
+ this.events.push({ type: 'complete', response });
258
+ this.done = true;
259
+ }
260
+ provideToolResults(results, options) {
261
+ if (!this._isWaitingForTools)
262
+ throw new Error('Not waiting for tools');
263
+ this.receivedToolResults.push(results);
264
+ this.receivedToolResultOptions.push(options);
265
+ this._isWaitingForTools = false;
266
+ this._pendingToolCallIds = [];
267
+ this._toolDepth++;
268
+ if (this._toolDepth === 1) {
269
+ this.paused = true;
270
+ return;
271
+ }
272
+ this.processResponse(this._toolDepth);
273
+ this.pendingResolve?.();
274
+ this.pendingResolve = null;
275
+ }
276
+ release() {
277
+ if (!this.paused)
278
+ return;
279
+ this.paused = false;
280
+ this.processResponse(this._toolDepth);
281
+ this.pendingResolve?.();
282
+ this.pendingResolve = null;
283
+ }
284
+ cancel() {
285
+ this.done = true;
286
+ this._pendingToolCallIds = [];
287
+ this.pendingResolve?.();
288
+ this.pendingResolve = null;
289
+ }
290
+ get isWaitingForTools() {
291
+ return this._isWaitingForTools;
292
+ }
293
+ get pendingToolCallIds() {
294
+ return [...this._pendingToolCallIds];
295
+ }
296
+ get toolDepth() {
297
+ return this._toolDepth;
298
+ }
299
+ async *[Symbol.asyncIterator]() {
300
+ while (true) {
301
+ while (this.events.length > 0) {
302
+ const event = this.events.shift();
303
+ yield event;
304
+ if (event.type === 'complete' || event.type === 'error' || event.type === 'aborted') {
305
+ return;
306
+ }
307
+ }
308
+ if (this.done)
309
+ return;
310
+ await new Promise((resolve) => { this.pendingResolve = resolve; });
311
+ }
312
+ }
313
+ }
314
+ class ScenarioMembrane {
315
+ calls = [];
316
+ lastStream = null;
317
+ scenarios = [];
318
+ queueScenario(responses, opts) {
319
+ this.scenarios.push({ responses, pauseAfterFirstToolRound: opts?.pauseAfterFirstToolRound });
320
+ }
321
+ streamYielding(request) {
322
+ this.calls.push(request);
323
+ const next = this.scenarios.shift();
324
+ if (!next)
325
+ throw new Error('No queued stream scenario');
326
+ const stream = next.pauseAfterFirstToolRound
327
+ ? new PausedAfterFirstToolRoundStream(next.responses)
328
+ : new MockYieldingStream(next.responses);
329
+ this.lastStream = stream;
330
+ return stream;
331
+ }
332
+ asMembrane() {
333
+ return this;
334
+ }
335
+ }
336
+ function thinkDescription(request) {
337
+ const thinkTool = (request.tools ?? []).find((tool) => tool.name === 'think');
338
+ assert.ok(thinkTool, 'think tool should be present');
339
+ return thinkTool.description;
340
+ }
341
+ async function expectInFlightResetRestoresNextInferencePolicy(opts) {
342
+ const membrane = new ScenarioMembrane();
343
+ membrane.queueScenario([
344
+ createMockResponse([
345
+ { type: 'text', text: 'private request before reset' },
346
+ { type: 'tool_use', id: 'think-1', name: 'think', input: { content: TILDE_THINK_4416 } },
347
+ {
348
+ type: 'tool_use',
349
+ id: 'settings-1',
350
+ name: 'agent_settings',
351
+ input: opts.resetInput,
352
+ },
353
+ { type: 'tool_use', id: 'read-1', name: 'tools--read', input: { path: 'project/secret.txt' } },
354
+ ], 'tool_use'),
355
+ createMockResponse([
356
+ { type: 'text', text: 'still private in the continuation' },
357
+ { type: 'tool_use', id: 'think-2', name: 'think', input: { content: TILDE_THINK_4416 } },
358
+ { type: 'tool_use', id: 'ping-1', name: 'tools--ping', input: {} },
359
+ ], 'tool_use'),
360
+ createMockResponse([]),
361
+ ], { pauseAfterFirstToolRound: true });
362
+ membrane.queueScenario([
363
+ createMockResponse(makeToolRound('public on the next inference'), 'tool_use'),
364
+ createMockResponse([]),
365
+ ]);
366
+ const { dir, framework, routed } = await createFramework({
367
+ ...(opts.basePolicy !== undefined ? { policy: opts.basePolicy } : {}),
368
+ membrane,
369
+ });
370
+ try {
371
+ framework.updateAgentRuntimeSettings('agent', { sameRoundThinkTextPolicy: 'private' });
372
+ assert.equal(framework.getAgentRuntimeSettings('agent').sameRoundThinkTextPolicy, 'private');
373
+ framework.start();
374
+ trigger(framework);
375
+ await waitFor(() => membrane.calls.length === 1);
376
+ await waitFor(() => membrane.lastStream?.receivedToolResults.length === 1);
377
+ const firstRoundResults = membrane.lastStream.receivedToolResults[0];
378
+ const settingsResult = firstRoundResults.find((result) => result.toolUseId === 'settings-1');
379
+ assert.ok(settingsResult, 'agent_settings reset result should be present');
380
+ assert.match(settingsResult.content, /sameRoundThinkTextPolicyUpdateNote/);
381
+ assert.match(settingsResult.content, /next inference/);
382
+ assert.match(thinkDescription(membrane.calls[0]), /withheld from channel routing/);
383
+ assert.deepEqual(routed, []);
384
+ assert.equal(framework.getAgentRuntimeSettings('agent').sameRoundThinkTextPolicy, 'public');
385
+ assert.match(thinkDescription(await framework.previewActivation('agent')), /may still be routed publicly as your speech/);
386
+ membrane.lastStream.release();
387
+ await waitFor(() => framework.activeStreams.size === 0);
388
+ assert.deepEqual(routed, []);
389
+ trigger(framework);
390
+ await waitFor(() => membrane.calls.length === 2);
391
+ await waitFor(() => routed.length === 1);
392
+ assert.match(thinkDescription(membrane.calls[1]), /may still be routed publicly as your speech/);
393
+ assert.deepEqual(routed, ['public on the next inference']);
394
+ }
395
+ finally {
396
+ await framework.stop();
397
+ rmSync(dir, { recursive: true, force: true });
398
+ }
399
+ }
400
+ describe('same-round think text policy', () => {
401
+ it('compatibility default/public routes the exact Tilde-shaped same-round preface', async () => {
402
+ const membrane = new MockMembrane();
403
+ membrane.pushResponse(createMockResponse(makeToolRound(TILDE_PREFACE_80), 'tool_use'));
404
+ membrane.pushResponse(createMockResponse([]));
405
+ const { dir, framework, routed } = await createFramework({ membrane });
406
+ try {
407
+ trigger(framework);
408
+ await framework.runUntilIdle();
409
+ assert.deepEqual(routed, [TILDE_PREFACE_80]);
410
+ }
411
+ finally {
412
+ await framework.stop();
413
+ rmSync(dir, { recursive: true, force: true });
414
+ }
415
+ });
416
+ it('recipe private routes zero same-round prose for the exact Tilde shape', async () => {
417
+ const membrane = new MockMembrane();
418
+ membrane.pushResponse(createMockResponse(makeToolRound(TILDE_PREFACE_80), 'tool_use'));
419
+ membrane.pushResponse(createMockResponse([]));
420
+ const { dir, framework, routed } = await createFramework({ policy: 'private', membrane });
421
+ try {
422
+ trigger(framework);
423
+ await framework.runUntilIdle();
424
+ assert.deepEqual(routed, []);
425
+ }
426
+ finally {
427
+ await framework.stop();
428
+ rmSync(dir, { recursive: true, force: true });
429
+ }
430
+ });
431
+ it('private is non-sticky: later non-think rounds and trailing prose route normally', async () => {
432
+ const membrane = new MockMembrane();
433
+ membrane.pushResponse(createMockResponse(makeToolRound(TILDE_PREFACE_80), 'tool_use'));
434
+ membrane.pushResponse(createMockResponse([
435
+ { type: 'text', text: 'visible same turn after a non-think tool round' },
436
+ { type: 'tool_use', id: 'ping-1', name: 'tools--ping', input: {} },
437
+ ], 'tool_use'));
438
+ membrane.pushResponse(createMockResponse([
439
+ { type: 'text', text: 'visible trailing prose after tool results' },
440
+ ]));
441
+ const { dir, framework, routed } = await createFramework({ policy: 'private', membrane });
442
+ try {
443
+ trigger(framework);
444
+ await framework.runUntilIdle();
445
+ assert.deepEqual(routed, [
446
+ 'visible same turn after a non-think tool round',
447
+ 'visible trailing prose after tool results',
448
+ ]);
449
+ }
450
+ finally {
451
+ await framework.stop();
452
+ rmSync(dir, { recursive: true, force: true });
453
+ }
454
+ });
455
+ it('public remains non-silencing across later rounds and trailing prose', async () => {
456
+ const membrane = new MockMembrane();
457
+ membrane.pushResponse(createMockResponse(makeToolRound(TILDE_PREFACE_80), 'tool_use'));
458
+ membrane.pushResponse(createMockResponse([
459
+ { type: 'text', text: 'still public in the next tool round' },
460
+ { type: 'tool_use', id: 'ping-2', name: 'tools--ping', input: {} },
461
+ ], 'tool_use'));
462
+ membrane.pushResponse(createMockResponse([
463
+ { type: 'text', text: 'still public in trailing prose' },
464
+ ]));
465
+ const { dir, framework, routed } = await createFramework({ policy: 'public', membrane });
466
+ try {
467
+ trigger(framework);
468
+ await framework.runUntilIdle();
469
+ assert.deepEqual(routed, [
470
+ TILDE_PREFACE_80,
471
+ 'still public in the next tool round',
472
+ 'still public in trailing prose',
473
+ ]);
474
+ }
475
+ finally {
476
+ await framework.stop();
477
+ rmSync(dir, { recursive: true, force: true });
478
+ }
479
+ });
480
+ it('think plus skip_reply remains silent under both policies', async () => {
481
+ for (const policy of ['public', 'private']) {
482
+ const membrane = new MockMembrane();
483
+ membrane.pushResponse(createMockResponse([
484
+ { type: 'text', text: TILDE_PREFACE_80 },
485
+ { type: 'tool_use', id: `think-${policy}`, name: 'think', input: { content: TILDE_THINK_4416 } },
486
+ { type: 'tool_use', id: `skip-${policy}`, name: 'skip_reply', input: { reason: 'no reply' } },
487
+ ], 'tool_use'));
488
+ membrane.pushResponse(createMockResponse([]));
489
+ const { dir, framework, routed } = await createFramework({ policy, membrane });
490
+ try {
491
+ trigger(framework);
492
+ await framework.runUntilIdle();
493
+ assert.deepEqual(routed, [], `${policy} should stay silent`);
494
+ }
495
+ finally {
496
+ await framework.stop();
497
+ rmSync(dir, { recursive: true, force: true });
498
+ }
499
+ }
500
+ });
501
+ it('explicit-send behavior is unchanged', async () => {
502
+ const membrane = new MockMembrane();
503
+ membrane.pushResponse(createMockResponse([
504
+ { type: 'text', text: 'sending directly' },
505
+ { type: 'tool_use', id: 'send-1', name: 'tools--send_message', input: { text: 'hi' } },
506
+ ], 'tool_use'));
507
+ membrane.pushResponse(createMockResponse([
508
+ { type: 'text', text: 'this later tool round should still stay silent' },
509
+ { type: 'tool_use', id: 'ping-3', name: 'tools--ping', input: {} },
510
+ ], 'tool_use'));
511
+ membrane.pushResponse(createMockResponse([
512
+ { type: 'text', text: 'and trailing prose should stay silent too' },
513
+ ]));
514
+ const { dir, framework, routed } = await createFramework({ policy: 'private', membrane });
515
+ try {
516
+ trigger(framework);
517
+ await framework.runUntilIdle();
518
+ assert.deepEqual(routed, []);
519
+ }
520
+ finally {
521
+ await framework.stop();
522
+ rmSync(dir, { recursive: true, force: true });
523
+ }
524
+ });
525
+ it('private keeps the preface in Chronicle while withholding it from channel routing', async () => {
526
+ const membrane = new MockMembrane();
527
+ membrane.pushResponse(createMockResponse(makeToolRound(TILDE_PREFACE_80), 'tool_use'));
528
+ membrane.pushResponse(createMockResponse([]));
529
+ const { dir, framework, routed } = await createFramework({ policy: 'private', membrane });
530
+ try {
531
+ trigger(framework);
532
+ await framework.runUntilIdle();
533
+ assert.deepEqual(routed, []);
534
+ const compiled = await framework.getAgent('agent').getContextManager().compile();
535
+ assert.ok(compiled.messages.some((message) => JSON.stringify(message.content).includes(TILDE_PREFACE_80)), 'the same-round preface should remain in Chronicle/context');
536
+ }
537
+ finally {
538
+ await framework.stop();
539
+ rmSync(dir, { recursive: true, force: true });
540
+ }
541
+ });
542
+ it('builds different provider-bound think descriptions per agent, and preview matches activation', async () => {
543
+ const { dir, framework } = await createFramework({
544
+ agents: [
545
+ { name: 'public-agent', sameRoundThinkTextPolicy: 'public' },
546
+ { name: 'private-agent', sameRoundThinkTextPolicy: 'private' },
547
+ ],
548
+ });
549
+ try {
550
+ const previewPublic = await framework.previewActivation('public-agent');
551
+ const previewPrivate = await framework.previewActivation('private-agent');
552
+ const publicThink = previewPublic.tools?.find((tool) => tool.name === 'think');
553
+ const privateThink = previewPrivate.tools?.find((tool) => tool.name === 'think');
554
+ const staticThinkBefore = framework.getAllTools().find((tool) => tool.name === 'think');
555
+ assert.ok(publicThink);
556
+ assert.ok(privateThink);
557
+ assert.ok(staticThinkBefore);
558
+ assert.match(publicThink.description, /may still be routed publicly as your speech/);
559
+ assert.match(privateThink.description, /withheld from channel routing/);
560
+ assert.equal(staticThinkBefore.description, 'private reasoning');
561
+ const publicActivationTools = framework.getToolsForAgent('public-agent');
562
+ const privateActivationTools = framework.getToolsForAgent('private-agent');
563
+ assert.equal(publicActivationTools.find((tool) => tool.name === 'think')?.description, publicThink?.description);
564
+ assert.equal(privateActivationTools.find((tool) => tool.name === 'think')?.description, privateThink?.description);
565
+ assert.equal(framework.getAllTools().find((tool) => tool.name === 'think')?.description, 'private reasoning');
566
+ }
567
+ finally {
568
+ await framework.stop();
569
+ rmSync(dir, { recursive: true, force: true });
570
+ }
571
+ });
572
+ it('a host update from public to private does not retroactively change an in-flight request snapshot', async () => {
573
+ const membrane = new ScenarioMembrane();
574
+ membrane.queueScenario([
575
+ createMockResponse([
576
+ { type: 'text', text: 'public snapshot round one' },
577
+ { type: 'tool_use', id: 'think-1', name: 'think', input: { content: TILDE_THINK_4416 } },
578
+ { type: 'tool_use', id: 'read-1', name: 'tools--read', input: { path: 'project/secret.txt' } },
579
+ ], 'tool_use'),
580
+ createMockResponse([
581
+ { type: 'text', text: 'public snapshot round two' },
582
+ { type: 'tool_use', id: 'think-2', name: 'think', input: { content: TILDE_THINK_4416 } },
583
+ { type: 'tool_use', id: 'ping-1', name: 'tools--ping', input: {} },
584
+ ], 'tool_use'),
585
+ createMockResponse([]),
586
+ ], { pauseAfterFirstToolRound: true });
587
+ membrane.queueScenario([
588
+ createMockResponse(makeToolRound('next inference should now be private'), 'tool_use'),
589
+ createMockResponse([]),
590
+ ]);
591
+ const { dir, framework, routed } = await createFramework({ policy: 'public', membrane });
592
+ try {
593
+ framework.start();
594
+ trigger(framework);
595
+ await waitFor(() => membrane.calls.length === 1);
596
+ await waitFor(() => membrane.lastStream?.receivedToolResults.length === 1);
597
+ assert.match(thinkDescription(membrane.calls[0]), /may still be routed publicly as your speech/);
598
+ assert.deepEqual(routed, ['public snapshot round one']);
599
+ framework.updateAgentRuntimeSettings('agent', { sameRoundThinkTextPolicy: 'private' });
600
+ const preview = await framework.previewActivation('agent');
601
+ assert.match(thinkDescription(preview), /withheld from channel routing/);
602
+ membrane.lastStream.release();
603
+ await waitFor(() => routed.length === 2);
604
+ assert.deepEqual(routed, [
605
+ 'public snapshot round one',
606
+ 'public snapshot round two',
607
+ ]);
608
+ trigger(framework);
609
+ await waitFor(() => membrane.calls.length === 2);
610
+ await waitFor(() => framework.activeStreams.size === 0);
611
+ assert.match(thinkDescription(membrane.calls[1]), /withheld from channel routing/);
612
+ assert.deepEqual(routed, [
613
+ 'public snapshot round one',
614
+ 'public snapshot round two',
615
+ ]);
616
+ }
617
+ finally {
618
+ await framework.stop();
619
+ rmSync(dir, { recursive: true, force: true });
620
+ }
621
+ });
622
+ it('a host update from private to public does not retroactively change an in-flight request snapshot', async () => {
623
+ const membrane = new ScenarioMembrane();
624
+ membrane.queueScenario([
625
+ createMockResponse(makeToolRound('private snapshot round one'), 'tool_use'),
626
+ createMockResponse([
627
+ { type: 'text', text: 'private snapshot round two' },
628
+ { type: 'tool_use', id: 'think-2', name: 'think', input: { content: TILDE_THINK_4416 } },
629
+ { type: 'tool_use', id: 'ping-1', name: 'tools--ping', input: {} },
630
+ ], 'tool_use'),
631
+ createMockResponse([]),
632
+ ], { pauseAfterFirstToolRound: true });
633
+ membrane.queueScenario([
634
+ createMockResponse(makeToolRound('next inference should now be public'), 'tool_use'),
635
+ createMockResponse([]),
636
+ ]);
637
+ const { dir, framework, routed } = await createFramework({ policy: 'private', membrane });
638
+ try {
639
+ framework.start();
640
+ trigger(framework);
641
+ await waitFor(() => membrane.calls.length === 1);
642
+ await waitFor(() => membrane.lastStream?.receivedToolResults.length === 1);
643
+ assert.match(thinkDescription(membrane.calls[0]), /withheld from channel routing/);
644
+ assert.deepEqual(routed, []);
645
+ framework.updateAgentRuntimeSettings('agent', { sameRoundThinkTextPolicy: 'public' });
646
+ membrane.lastStream.release();
647
+ await waitFor(() => framework.activeStreams.size === 0);
648
+ assert.deepEqual(routed, []);
649
+ trigger(framework);
650
+ await waitFor(() => membrane.calls.length === 2);
651
+ await waitFor(() => routed.length === 1);
652
+ assert.match(thinkDescription(membrane.calls[1]), /may still be routed publicly as your speech/);
653
+ assert.deepEqual(routed, ['next inference should now be public']);
654
+ }
655
+ finally {
656
+ await framework.stop();
657
+ rmSync(dir, { recursive: true, force: true });
658
+ }
659
+ });
660
+ it('same-round agent_settings updates apply immediately to preview and next request, but not to the current request snapshot', async () => {
661
+ const membrane = new ScenarioMembrane();
662
+ membrane.queueScenario([
663
+ createMockResponse([
664
+ { type: 'text', text: 'public request before update' },
665
+ { type: 'tool_use', id: 'think-1', name: 'think', input: { content: TILDE_THINK_4416 } },
666
+ {
667
+ type: 'tool_use',
668
+ id: 'settings-1',
669
+ name: 'agent_settings',
670
+ input: { action: 'update', same_round_think_text_policy: 'private' },
671
+ },
672
+ { type: 'tool_use', id: 'read-1', name: 'tools--read', input: { path: 'project/secret.txt' } },
673
+ ], 'tool_use'),
674
+ createMockResponse([
675
+ { type: 'text', text: 'still public in the continuation' },
676
+ { type: 'tool_use', id: 'think-2', name: 'think', input: { content: TILDE_THINK_4416 } },
677
+ { type: 'tool_use', id: 'ping-1', name: 'tools--ping', input: {} },
678
+ ], 'tool_use'),
679
+ createMockResponse([]),
680
+ ], { pauseAfterFirstToolRound: true });
681
+ membrane.queueScenario([
682
+ createMockResponse(makeToolRound('private on the next inference'), 'tool_use'),
683
+ createMockResponse([]),
684
+ ]);
685
+ const { dir, framework, routed } = await createFramework({ policy: 'public', membrane });
686
+ try {
687
+ framework.start();
688
+ trigger(framework);
689
+ await waitFor(() => membrane.calls.length === 1);
690
+ await waitFor(() => membrane.lastStream?.receivedToolResults.length === 1);
691
+ const firstRoundResults = membrane.lastStream.receivedToolResults[0];
692
+ assert.ok(firstRoundResults.some((result) => result.toolUseId === 'settings-1' &&
693
+ result.content.includes('sameRoundThinkTextPolicyUpdateNote')));
694
+ assert.deepEqual(routed, ['public request before update']);
695
+ assert.equal(framework.getAgentRuntimeSettings('agent').sameRoundThinkTextPolicy, 'private');
696
+ assert.match(thinkDescription(await framework.previewActivation('agent')), /withheld from channel routing/);
697
+ membrane.lastStream.release();
698
+ await waitFor(() => routed.length === 2);
699
+ assert.deepEqual(routed, [
700
+ 'public request before update',
701
+ 'still public in the continuation',
702
+ ]);
703
+ trigger(framework);
704
+ await waitFor(() => membrane.calls.length === 2);
705
+ await waitFor(() => framework.activeStreams.size === 0);
706
+ assert.match(thinkDescription(membrane.calls[1]), /withheld from channel routing/);
707
+ assert.deepEqual(routed, [
708
+ 'public request before update',
709
+ 'still public in the continuation',
710
+ ]);
711
+ }
712
+ finally {
713
+ await framework.stop();
714
+ rmSync(dir, { recursive: true, force: true });
715
+ }
716
+ });
717
+ it('same-round agent_settings explicit reset restores the compatibility policy only on the next inference', async () => {
718
+ await expectInFlightResetRestoresNextInferencePolicy({
719
+ resetInput: { action: 'reset', settings: ['same_round_think_text_policy'] },
720
+ });
721
+ });
722
+ it('same-round agent_settings reset-all restores the recipe policy only on the next inference', async () => {
723
+ await expectInFlightResetRestoresNextInferencePolicy({
724
+ basePolicy: 'public',
725
+ resetInput: { action: 'reset' },
726
+ });
727
+ });
728
+ it('agent_settings reset without same_round_think_text_policy omits the next-inference note', async () => {
729
+ const membrane = new ScenarioMembrane();
730
+ membrane.queueScenario([
731
+ createMockResponse([
732
+ { type: 'tool_use', id: 'settings-1', name: 'agent_settings', input: { action: 'reset', settings: ['tail_tokens'] } },
733
+ ], 'tool_use'),
734
+ createMockResponse([]),
735
+ ], { pauseAfterFirstToolRound: true });
736
+ const { dir, framework } = await createFramework({ membrane });
737
+ try {
738
+ framework.start();
739
+ trigger(framework);
740
+ await waitFor(() => membrane.calls.length === 1);
741
+ await waitFor(() => membrane.lastStream?.receivedToolResults.length === 1);
742
+ const firstRoundResults = membrane.lastStream.receivedToolResults[0];
743
+ const settingsResult = firstRoundResults.find((result) => result.toolUseId === 'settings-1');
744
+ assert.ok(settingsResult, 'agent_settings reset result should be present');
745
+ assert.doesNotMatch(settingsResult.content, /sameRoundThinkTextPolicyUpdateNote/);
746
+ }
747
+ finally {
748
+ await framework.stop();
749
+ rmSync(dir, { recursive: true, force: true });
750
+ }
751
+ });
752
+ it('preview reflects a hot policy update while an already-started activation keeps its original description snapshot', async () => {
753
+ const membrane = new ScenarioMembrane();
754
+ membrane.queueScenario([
755
+ createMockResponse(makeToolRound('request snapshot stays public'), 'tool_use'),
756
+ createMockResponse([]),
757
+ ], { pauseAfterFirstToolRound: true });
758
+ const { dir, framework } = await createFramework({ policy: 'public', membrane });
759
+ try {
760
+ framework.start();
761
+ trigger(framework);
762
+ await waitFor(() => membrane.calls.length === 1);
763
+ await waitFor(() => membrane.lastStream?.receivedToolResults.length === 1);
764
+ assert.match(thinkDescription(membrane.calls[0]), /may still be routed publicly as your speech/);
765
+ framework.updateAgentRuntimeSettings('agent', { sameRoundThinkTextPolicy: 'private' });
766
+ assert.match(thinkDescription(await framework.previewActivation('agent')), /withheld from channel routing/);
767
+ assert.match(thinkDescription(membrane.calls[0]), /may still be routed publicly as your speech/);
768
+ }
769
+ finally {
770
+ await framework.stop();
771
+ rmSync(dir, { recursive: true, force: true });
772
+ }
773
+ });
774
+ it('private policy suppresses only the synthesized think tool, not namespaced tools named think', async () => {
775
+ const membrane = new MockMembrane();
776
+ membrane.pushResponse(createMockResponse([
777
+ { type: 'text', text: 'namespaced think should still route' },
778
+ { type: 'tool_use', id: 'module-think-1', name: 'tools--think', input: { content: 'module thought' } },
779
+ ], 'tool_use'));
780
+ membrane.pushResponse(createMockResponse([]));
781
+ const { dir, framework, routed } = await createFramework({ policy: 'private', membrane });
782
+ try {
783
+ trigger(framework);
784
+ await framework.runUntilIdle();
785
+ assert.deepEqual(routed, ['namespaced think should still route']);
786
+ }
787
+ finally {
788
+ await framework.stop();
789
+ rmSync(dir, { recursive: true, force: true });
790
+ }
791
+ });
792
+ it('suppression logs do not include the withheld prose text', async () => {
793
+ const membrane = new MockMembrane();
794
+ const withheld = 'withheld private prose should not appear in logs';
795
+ membrane.pushResponse(createMockResponse(makeToolRound(withheld), 'tool_use'));
796
+ membrane.pushResponse(createMockResponse([]));
797
+ const { dir, framework } = await createFramework({ policy: 'private', membrane });
798
+ const originalError = console.error;
799
+ const errors = [];
800
+ console.error = (...args) => { errors.push(args.join(' ')); };
801
+ try {
802
+ trigger(framework);
803
+ await framework.runUntilIdle();
804
+ assert.ok(errors.some((line) => line.includes('same_round_think_text_policy=private')));
805
+ assert.ok(errors.every((line) => !line.includes(withheld)));
806
+ }
807
+ finally {
808
+ console.error = originalError;
809
+ await framework.stop();
810
+ rmSync(dir, { recursive: true, force: true });
811
+ }
812
+ });
813
+ it('an ephemeral request gets its own effective think description snapshot', async () => {
814
+ const membrane = new ScenarioMembrane();
815
+ membrane.queueScenario([
816
+ createMockResponse([{ type: 'text', text: 'ephemeral done' }]),
817
+ ]);
818
+ const { dir, framework } = await createFramework({ membrane });
819
+ try {
820
+ const created = await framework.createEphemeralAgent({
821
+ name: 'ephemeral-worker',
822
+ model: 'test-model',
823
+ systemPrompt: 'test',
824
+ allowedTools: 'all',
825
+ sameRoundThinkTextPolicy: 'private',
826
+ });
827
+ created.contextManager.addMessage('user', [{ type: 'text', text: 'go' }]);
828
+ const run = framework.runEphemeralToCompletion(created.agent, created.contextManager);
829
+ framework.start();
830
+ const result = await run;
831
+ assert.equal(result.speech, 'ephemeral done');
832
+ assert.match(thinkDescription(membrane.calls[0]), /withheld from channel routing/);
833
+ }
834
+ finally {
835
+ await framework.stop();
836
+ rmSync(dir, { recursive: true, force: true });
837
+ }
838
+ });
839
+ it('legacy/no-roundContent behavior remains unchanged', async () => {
840
+ const membrane = new LegacyNoRoundContentMembrane();
841
+ const { dir, framework, routed } = await createFramework({ policy: 'private', membrane });
842
+ try {
843
+ trigger(framework);
844
+ await framework.runUntilIdle();
845
+ assert.deepEqual(routed, [TILDE_PREFACE_80]);
846
+ }
847
+ finally {
848
+ await framework.stop();
849
+ rmSync(dir, { recursive: true, force: true });
850
+ }
851
+ });
852
+ });
853
+ //# sourceMappingURL=same-round-think-policy.test.js.map