symposium 3.2.1 → 3.4.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/Agent.js CHANGED
@@ -489,14 +489,20 @@ ${context_string}
489
489
  }
490
490
 
491
491
  async beforeExecute(thread) {
492
- if (this.options.memory_handler)
493
- thread = await this.options.memory_handler.handle(thread);
492
+ if (this.options.memory_handler) {
493
+ const handled = await this.options.memory_handler.handle(thread);
494
+ if (handled && handled !== thread) {
495
+ // The handler returned a transformed clone (e.g. Summarizer):
496
+ // re-register it so getThread() doesn't keep serving the stale
497
+ // original while storage holds the transformed version.
498
+ thread = handled;
499
+ this.threads.set(thread.id, thread);
500
+ }
501
+ }
494
502
  return thread;
495
503
  }
496
504
 
497
505
  async *execute(thread) {
498
- thread = await this.beforeExecute(thread);
499
-
500
506
  const model = Symposium.getModel(thread.state.model);
501
507
 
502
508
  const completion_options = {};
@@ -550,6 +556,12 @@ ${context_string}
550
556
  return;
551
557
  }
552
558
 
559
+ // Per-iteration, not once per run: in streaming mode a single
560
+ // execute() spans the whole session, and the memory handler
561
+ // (Summarizer, Scrubber) must get a chance before every LLM call,
562
+ // not just the first one.
563
+ thread = await this.beforeExecute(thread);
564
+
553
565
  try {
554
566
  // Inline drain of generateCompletion so we can observe `chunk`
555
567
  // events and flip output_yielded for the hybrid retry strategy.
@@ -672,8 +684,14 @@ ${context_string}
672
684
  let step = await it.next();
673
685
  while (!step.done) {
674
686
  const delta = step.value;
675
- if (delta && delta.type === 'text_delta')
687
+ if (delta && delta.type === 'text_delta') {
676
688
  yield {type: 'chunk', content: delta.content};
689
+ } else if (delta && delta.type === 'usage') {
690
+ // Last reported usage rides on thread state too, so it survives
691
+ // storeState and is readable at turn_end / after a reload.
692
+ thread.state.usage = delta.content;
693
+ yield {type: 'usage', content: delta.content};
694
+ }
677
695
  step = await it.next();
678
696
  }
679
697
  messages = step.value;
package/CLAUDE.md CHANGED
@@ -26,7 +26,7 @@ The execution core. After Phase 6, `agent.message()` is a non-generator dispatch
26
26
 
27
27
  `message(content, thread)` accepts three input shapes (Phase 3): a plain `string`, a `ContentBlock[]`, or an `AsyncIterable<string | ContentBlock | ContentBlock[] | ControlMessage>`. The first two behave as they always have — one user turn, one model loop, done. An async iterable enables streaming input: the agent drains the iterable into the initial user message (stopping on a `{type:'submit'}` control message, on iterable close, or once at least one content piece has arrived and the next read would block), kicks off the loop, then keeps reading concurrently. New content items pushed during a turn are queued and inserted as a user message at the next inter-turn boundary. A `{type:'cancel'}` control message terminates the loop gracefully after the in-flight turn. `{type:'auth', id, decision}` control messages carry tool-authorization responses — see the tool-authorization paragraph below. Use `createInputChannel()` (exported from `index.js`) for a simple promise-queue-backed `AsyncIterable` with `send(item)` / `close()` methods; under the hood it's implemented in `InputChannel.js`. For streaming input, the chat agent does NOT terminate after a no-tool-call turn — it waits for the next message; the run ends only when the iterable closes (or cancel is received).
28
28
 
29
- - `chat` — yields the full event set (`start`, `chunk`, `output`, `reasoning`, `tool`, `tool_response`, `tools_auth`, `retry`, `turn_end`, `end`). `turn_end` fires after each completed turn (after the LLM finishes a response with no more tool calls), regardless of streaming vs single-shot mode — consumers use it to detect turn boundaries within a streaming run, where `start`/`end` only bracket the whole run. If `response_schema` is set, the final assistant message is parsed against it and a `{type:'result', value}` event is yielded before `end`; the run terminates after the schema-conforming answer (no further turns).
29
+ - `chat` — yields the full event set (`start`, `chunk`, `output`, `reasoning`, `tool`, `tool_response`, `tools_auth`, `retry`, `usage`, `turn_end`, `end`). `turn_end` fires after each completed turn (after the LLM finishes a response with no more tool calls), regardless of streaming vs single-shot mode — consumers use it to detect turn boundaries within a streaming run, where `start`/`end` only bracket the whole run. If `response_schema` is set, the final assistant message is parsed against it and a `{type:'result', value}` event is yielded before `end`; the run terminates after the schema-conforming answer (no further turns).
30
30
  - `utility` — `await agent.message(...)` resolves to the parsed value. With no `response_schema`, the value is the raw assistant text. With `response_schema` set, the value is the parsed JSON object: structured-output-capable models with ≤100 properties use `response_format: json_schema`; otherwise the agent falls back to a forced tool call (synthetic name `'response'`) and parses its arguments. See `convertFunctionToResponseFormat()` for the OpenAI-specific schema constraints (all properties forced to required, `additionalProperties: false`). The legacy `agent.utility = {type, function, parameters}` shape was removed in Phase 6 — use `response_schema` (a raw JSON schema) instead. `response_schema` is independent of `type` and works on chat agents too.
31
31
 
32
32
  **`response_schema` + toolkits (tool use *then* structured output).** When an agent has BOTH `response_schema` and toolkit tools, the schema is NOT forced up-front (forcing would block the model from calling its tools first). Instead `execute()` advertises an extra `response` tool *alongside* the real ones via `completion_options.append_tools` (merged by `Model.parseOptions` — additive, unlike `options.tools` which replaces). The model uses its real tools across turns and, when ready, calls `response` with the final schema-conforming answer; `handleCompletion` treats a `response` call as terminal (→ `afterHandle` → `{type:'result', value}`) and every other call as a normal tool invocation (`response` is a **reserved tool name** when `response_schema` is set). The forced-tool / `response_format` paths above apply only when the agent has **no** toolkits, so existing tool-less utility agents are unchanged. This is what lets a `chat` agent self-search and still return a validated object (consume it by draining for the `result` event).
@@ -40,8 +40,8 @@ Within a single LLM turn, tools are executed **sequentially** (in `tools_to_call
40
40
  ### Models (`Models/*.js`, base in `Model.js`)
41
41
 
42
42
  Every provider extends `Model` and implements:
43
- - `getModels()` — returns `Map<label, definition>` where definition flags capabilities: `tools`, `structured_output`, `audio`, `image_generation`, `tokens` (context window), `tiktoken` (encoding name).
44
- - `generate(model, thread, tools, options)` — **async generator** (Phase 1 of v3 refactor). Yields streaming deltas during generation and `return`s the final assembled `Message[]`. Delta union: `{type: 'text_delta', content}`, `{type: 'reasoning_delta', content}`, `{type: 'tool_call', content: {id?, name, arguments}}` (emitted complete), `{type: 'image', content, meta}`. `Agent.generateCompletion()` (Phase 2) is itself an async generator: it forwards `text_delta` to consumers as `{type:'chunk', content}` events and returns the assembled `Message[]`. Other delta types are not forwarded yet and only contribute to the final assembly. Tool-call deltas from chat-completions-style APIs (OpenAI legacy, Groq) are accumulated per `index` and yielded once at end-of-stream.
43
+ - `getModels()` — returns `Map<label, definition>` where definition flags capabilities: `tools`, `structured_output`, `audio`, `image_generation`, `reasoning` (the OpenAI Responses adapter only sends `include: ['reasoning.encrypted_content']` + `reasoning: {summary}` when set — non-reasoning models like `gpt-4o` reject them), `tokens` (context window), `tiktoken` (encoding name).
44
+ - `generate(model, thread, tools, options)` — **async generator** (Phase 1 of v3 refactor). Yields streaming deltas during generation and `return`s the final assembled `Message[]`. Delta union: `{type: 'text_delta', content}`, `{type: 'reasoning_delta', content}`, `{type: 'tool_call', content: {id?, name, arguments}}` (emitted complete), `{type: 'image', content, meta}`, `{type: 'usage', content: {input, output, cached, context}}` (once at end-of-stream, via `Model.usageDelta()`, only when the provider reported usage — `input` includes `cached`; `context` = `input + output`; Anthropic's cache read/creation tokens are folded back into `input` since the API reports them outside `input_tokens`). `Agent.generateCompletion()` (Phase 2) is itself an async generator: it forwards `text_delta` to consumers as `{type:'chunk', content}` events, forwards `usage` as `{type:'usage', content}` (also stamping `thread.state.usage`, last call wins), and returns the assembled `Message[]`. Other delta types are not forwarded yet and only contribute to the final assembly. Tool-call deltas from chat-completions-style APIs (OpenAI legacy, Groq) are accumulated per `index` and yielded once at end-of-stream; on those APIs the usage chunk arrives last with empty `choices` (OpenAI/DeepSeek need `stream_options: {include_usage: true}`, sent by `LegacyOpenAIModel`; Groq ships it natively under `x_groq.usage`).
45
45
  - Optionally `countTokens(thread)` (used by `Summarizer`).
46
46
 
47
47
  A model definition's `tools: true` means the provider supports native tool calling. When false, `Agent.parseTools()` falls back to parsing `\`\`\`\nCALL <name>\n<json>\n\`\`\`` blocks out of plain text — the prompt for this is built by `Model.promptFromTools()` (in Italian; do not translate without verifying the existing parser still matches).
@@ -54,12 +54,14 @@ A `Thread` owns the message history and a free-form `state` object (which always
54
54
 
55
55
  `thread.unique` (`<agent_name>-<id>`) is the storage key — never reuse the same thread id across agents with different names without realizing they share namespace.
56
56
 
57
- ### Context system (`Context.js`, `Contexts/*.js`, `ContextHandler.js`, `Summarizer.js`, `GetContextToolkit.js`)
57
+ ### Context system (`Context.js`, `Contexts/*.js`, `ContextHandler.js`, `Summarizer.js`, `Scrubber.js`, `GetContextToolkit.js`)
58
58
 
59
59
  Two distinct concepts share the word "context":
60
60
 
61
61
  1. **`Context` / `Contexts/*`** — static reference material attached to an agent via `agent.addContext(text_or_context, {type: 'always' | 'on_request'})`. `always` contexts are inlined into the system message at thread init; `on_request` contexts are advertised by title/description and fetched lazily through the auto-injected `GetContextToolkit`. Mixing both is supported.
62
- 2. **`ContextHandler`** — pre-execute hook (set as `options.memory_handler` on the agent) that can transform the thread before each LLM call. `Summarizer` extends this: when token count crosses `threshold * model.tokens`, it summarizes earlier messages down to `summary_length * model.tokens`, preserving the system prompt.
62
+ 2. **`ContextHandler`** — pre-execute hook (set as `options.memory_handler` on the agent) that can transform the thread before each LLM call. Since 3.4 `beforeExecute` runs inside the `execute()` turn loop, so the handler fires before **every** LLM call — including every later turn of a streaming run (previously it ran once per `execute()`, i.e. once per session in streaming mode). If the handler returns a different object than it was given (a transformed clone, as `Summarizer` does), `beforeExecute` re-registers it in `agent.threads`, so `getThread()` and storage stay coherent. Two shipped handlers:
63
+ - `Summarizer` — when token count crosses `threshold * model.tokens`, it summarizes earlier messages down to `summary_length * model.tokens`, preserving the system prompt. Returns a clone.
64
+ - `Scrubber` — surgical auto-forget: in messages older than the last `keep_last` (default 3) user messages, replaces `image`/`audio` blocks with short text stubs (carrying the block's optional `content.path` so the model can re-read the file, and the audio transcription when present) and trims `text` (user role only) / `tool_result` contents longer than `max_text_length` chars down to `keep_chars` + a marker. Mutates in place (no clone), never touches `system` messages, keeps tool_call/tool_result pairing intact, idempotent. The persisted thread is scrubbed too — this is deliberate destructive forgetting, whose point is freeing context permanently. Note the prompt-cache tradeoff: each scrub invalidates the provider prefix cache from the edit point, so it trades a one-time cache re-write for permanent window headroom.
63
65
 
64
66
  ### MCP servers (`MCPServer.js`, `Contexts/MCPResource.js`)
65
67
 
@@ -79,6 +81,7 @@ Two distinct concepts share the word "context":
79
81
  | `{type:'tool_response', id, name, toolkit, success, response?, error?}` | tool result | After tool returns or throws. `id` matches the `tool` event's id; `name` is the tool name (same as the `tool` event); `toolkit` is the owning toolkit's name. |
80
82
  | `{type:'tools_auth', id, tools}` | uuid + pending tool calls | When `tool.authorize()` returns false; resume by sending `{type:'auth', id, decision}` on the input channel |
81
83
  | `{type:'retry', attempt, reason}` | 1-indexed retry number + error message | Yielded only when an error occurs AFTER at least one `chunk` has been streamed for the current turn (hybrid retry, Phase 5). Errors before any output are retried silently. |
84
+ | `{type:'usage', content}` | `{input, output, cached, context}` | Provider-reported token usage, one per LLM call (so several per turn when tools run). `context` = `input + output` — tokens occupying the model context after the call. Mirrored on `thread.state.usage` (last call wins, persisted with the thread). Absent when the provider reports no usage. |
82
85
  | `{type:'result', value}` | parsed value | Utility agents only |
83
86
  | `{type:'turn_end', thread}` | thread object | Yielded after a turn completes (the LLM finished a response with no more tool calls). Fires once per turn whether the run continues (streaming, awaiting next user message) or ends. Use this — not `end` — to detect per-turn boundaries in a streaming session. |
84
87
  | `{type:'end', thread}` | thread object | Always yielded, even on throw (yielded from a `finally`) |
package/Model.js CHANGED
@@ -15,9 +15,22 @@ export default class Model {
15
15
  // {type: 'reasoning_delta', content: string}
16
16
  // {type: 'tool_call', content: {id?, name, arguments}}
17
17
  // {type: 'image', content: <image-block-content>, meta}
18
+ // {type: 'usage', content: {input, output, cached, context}}
19
+ // The usage delta is yielded once at end of stream (via usageDelta()),
20
+ // only when the provider reported token usage for the call.
18
21
  return null;
19
22
  }
20
23
 
24
+ // Normalized token usage for one LLM call. `input` is the full prompt size
25
+ // (cache included), `cached` the portion of it served from cache, `context`
26
+ // the tokens occupying the model context after the call (input + output).
27
+ usageDelta(input, output, cached = 0) {
28
+ input = input || 0;
29
+ output = output || 0;
30
+ cached = cached || 0;
31
+ return {type: 'usage', content: {input, output, cached, context: input + output}};
32
+ }
33
+
21
34
  async countTokens(thread) {
22
35
  throw new Error('countTokens not implemented in this model');
23
36
  }
@@ -153,6 +153,14 @@ export default class AnthropicModel extends Model {
153
153
 
154
154
  const message = await stream.finalMessage();
155
155
 
156
+ if (message.usage) {
157
+ // Anthropic reports cached tokens OUTSIDE input_tokens: the real
158
+ // prompt size is input + cache reads + cache writes.
159
+ const cached = message.usage.cache_read_input_tokens || 0;
160
+ const input = (message.usage.input_tokens || 0) + cached + (message.usage.cache_creation_input_tokens || 0);
161
+ yield this.usageDelta(input, message.usage.output_tokens, cached);
162
+ }
163
+
156
164
  const message_content = [];
157
165
  if (message.content) {
158
166
  for (let m of message.content) {
@@ -7,26 +7,31 @@ export default class GrokModel extends OpenAIModel {
7
7
  ['grok-4-1-fast-reasoning', {
8
8
  name: 'grok-4-1-fast-reasoning',
9
9
  tokens: 2000000,
10
+ reasoning: true,
10
11
  tools: true,
11
12
  }],
12
13
  ['grok-4-1-fast-non-reasoning', {
13
14
  name: 'grok-4-1-fast-non-reasoning',
14
15
  tokens: 2000000,
16
+ reasoning: true,
15
17
  tools: true,
16
18
  }],
17
19
  ['grok-4-20-fast-reasoning', {
18
20
  name: 'grok-4.20-beta-0309-reasoning',
19
21
  tokens: 2000000,
22
+ reasoning: true,
20
23
  tools: true,
21
24
  }],
22
25
  ['grok-4-20-fast-non-reasoning', {
23
26
  name: 'grok-4.20-beta-0309-non-reasoning',
24
27
  tokens: 2000000,
28
+ reasoning: true,
25
29
  tools: true,
26
30
  }],
27
31
  ['grok-4.5', {
28
32
  name: 'grok-4.5',
29
33
  tokens: 5000000,
34
+ reasoning: true,
30
35
  tools: true,
31
36
  }],
32
37
  ]);
@@ -43,7 +48,7 @@ export default class GrokModel extends OpenAIModel {
43
48
  return this.openai;
44
49
  }
45
50
 
46
- async generate(model, thread, tools = [], options = {}) {
51
+ async *generate(model, thread, tools = [], options = {}) {
47
52
  if (options.image_generation) {
48
53
  tools.push({
49
54
  name: 'generate_image',
@@ -61,7 +66,7 @@ export default class GrokModel extends OpenAIModel {
61
66
  });
62
67
  }
63
68
 
64
- const response = await super.generate(model, thread, tools, options);
69
+ const response = yield* super.generate(model, thread, tools, options);
65
70
 
66
71
  // Check for image generation response
67
72
  if (options.image_generation) {
@@ -79,10 +79,14 @@ export default class GroqModel extends Model {
79
79
 
80
80
  const stream = await this.getGroq().chat.completions.create({...completion_payload, stream: true});
81
81
 
82
- let fullText = '';
82
+ let fullText = '', usage = null;
83
83
  const toolBuffer = new Map();
84
84
 
85
85
  for await (const chunk of stream) {
86
+ // Groq ships usage on the final chunk under x_groq — grab it before the delta guard.
87
+ if (chunk.x_groq?.usage || chunk.usage)
88
+ usage = chunk.x_groq?.usage || chunk.usage;
89
+
86
90
  const delta = chunk.choices?.[0]?.delta;
87
91
  if (!delta)
88
92
  continue;
@@ -119,6 +123,9 @@ export default class GroqModel extends Model {
119
123
  yield {type: 'tool_call', content: tc};
120
124
  }
121
125
 
126
+ if (usage)
127
+ yield this.usageDelta(usage.prompt_tokens, usage.completion_tokens, usage.prompt_tokens_details?.cached_tokens);
128
+
122
129
  const message_content = [];
123
130
  if (fullText)
124
131
  message_content.push({type: 'text', content: fullText});
@@ -70,12 +70,16 @@ export default class LegacyOpenAIModel extends Model {
70
70
  if (!completion_payload.tools.length)
71
71
  delete completion_payload.tools;
72
72
 
73
- const stream = await this.getOpenAi().chat.completions.create({...completion_payload, stream: true});
73
+ const stream = await this.getOpenAi().chat.completions.create({...completion_payload, stream: true, stream_options: {include_usage: true}});
74
74
 
75
- let fullText = '';
75
+ let fullText = '', usage = null;
76
76
  const toolBuffer = new Map();
77
77
 
78
78
  for await (const chunk of stream) {
79
+ // The usage chunk arrives last with empty choices — grab it before the delta guard.
80
+ if (chunk.usage)
81
+ usage = chunk.usage;
82
+
79
83
  const delta = chunk.choices?.[0]?.delta;
80
84
  if (!delta)
81
85
  continue;
@@ -112,6 +116,9 @@ export default class LegacyOpenAIModel extends Model {
112
116
  yield {type: 'tool_call', content: tc};
113
117
  }
114
118
 
119
+ if (usage)
120
+ yield this.usageDelta(usage.prompt_tokens, usage.completion_tokens, usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens);
121
+
115
122
  const message_content = [];
116
123
  if (fullText)
117
124
  message_content.push({type: 'text', content: fullText});
@@ -90,9 +90,14 @@ export default class OllamaModel extends Model {
90
90
 
91
91
  let fullText = '';
92
92
  let fullThinking = '';
93
+ let usage = null;
93
94
  const toolCalls = [];
94
95
 
95
96
  for await (const chunk of stream) {
97
+ // The final done chunk carries the token counts — grab them before the message guard.
98
+ if (chunk.prompt_eval_count != null || chunk.eval_count != null)
99
+ usage = {input: chunk.prompt_eval_count, output: chunk.eval_count};
100
+
96
101
  const m = chunk.message;
97
102
  if (!m)
98
103
  continue;
@@ -122,6 +127,9 @@ export default class OllamaModel extends Model {
122
127
  }
123
128
  }
124
129
 
130
+ if (usage)
131
+ yield this.usageDelta(usage.input, usage.output);
132
+
125
133
  const message_content = [];
126
134
  if (fullThinking)
127
135
  message_content.push({type: 'reasoning', content: fullThinking});
@@ -19,6 +19,7 @@ export default class OpenAIModel extends Model {
19
19
  name: 'gpt-5',
20
20
  tiktoken: 'gpt-4',
21
21
  tokens: 400000,
22
+ reasoning: true,
22
23
  tools: true,
23
24
  structured_output: true,
24
25
  audio: true,
@@ -28,6 +29,7 @@ export default class OpenAIModel extends Model {
28
29
  name: 'gpt-5-mini',
29
30
  tiktoken: 'gpt-4',
30
31
  tokens: 400000,
32
+ reasoning: true,
31
33
  tools: true,
32
34
  structured_output: true,
33
35
  }],
@@ -35,6 +37,7 @@ export default class OpenAIModel extends Model {
35
37
  name: 'gpt-5.1',
36
38
  tiktoken: 'gpt-4',
37
39
  tokens: 400000,
40
+ reasoning: true,
38
41
  tools: true,
39
42
  structured_output: true,
40
43
  audio: true,
@@ -44,6 +47,7 @@ export default class OpenAIModel extends Model {
44
47
  name: 'gpt-5.2',
45
48
  tiktoken: 'gpt-4',
46
49
  tokens: 400000,
50
+ reasoning: true,
47
51
  tools: true,
48
52
  structured_output: true,
49
53
  audio: true,
@@ -53,6 +57,7 @@ export default class OpenAIModel extends Model {
53
57
  name: 'gpt-5.2-codex',
54
58
  tiktoken: 'gpt-4',
55
59
  tokens: 400000,
60
+ reasoning: true,
56
61
  tools: true,
57
62
  structured_output: true,
58
63
  audio: false,
@@ -62,6 +67,7 @@ export default class OpenAIModel extends Model {
62
67
  name: 'gpt-5.4',
63
68
  tiktoken: 'gpt-4',
64
69
  tokens: 1000000,
70
+ reasoning: true,
65
71
  tools: true,
66
72
  structured_output: true,
67
73
  audio: false,
@@ -71,6 +77,7 @@ export default class OpenAIModel extends Model {
71
77
  name: 'gpt-5.5',
72
78
  tiktoken: 'gpt-5',
73
79
  tokens: 1000000,
80
+ reasoning: true,
74
81
  tools: true,
75
82
  structured_output: true,
76
83
  audio: false,
@@ -80,6 +87,7 @@ export default class OpenAIModel extends Model {
80
87
  name: 'gpt-5.6-luna',
81
88
  tiktoken: 'gpt-5',
82
89
  tokens: 1000000,
90
+ reasoning: true,
83
91
  tools: true,
84
92
  structured_output: true,
85
93
  audio: false,
@@ -89,6 +97,7 @@ export default class OpenAIModel extends Model {
89
97
  name: 'gpt-5.6-terra',
90
98
  tiktoken: 'gpt-5',
91
99
  tokens: 1000000,
100
+ reasoning: true,
92
101
  tools: true,
93
102
  structured_output: true,
94
103
  audio: false,
@@ -98,6 +107,7 @@ export default class OpenAIModel extends Model {
98
107
  name: 'gpt-5.6-sol',
99
108
  tiktoken: 'gpt-5',
100
109
  tokens: 1000000,
110
+ reasoning: true,
101
111
  tools: true,
102
112
  structured_output: true,
103
113
  audio: false,
@@ -156,13 +166,17 @@ export default class OpenAIModel extends Model {
156
166
  model: model.name,
157
167
  input: convertedMessages,
158
168
  store: false,
159
- include: ['reasoning.encrypted_content'],
160
169
  tools: apiTools,
161
- reasoning: {
162
- summary: 'auto',
163
- },
164
170
  };
165
171
 
172
+ // Reasoning options are rejected by non-reasoning models (e.g. gpt-4o)
173
+ if (model.reasoning) {
174
+ completion_payload.include = ['reasoning.encrypted_content'];
175
+ completion_payload.reasoning = {
176
+ summary: 'auto',
177
+ };
178
+ }
179
+
166
180
  if (options.force_tool) {
167
181
  completion_payload.tool_choice = {
168
182
  type: 'function',
@@ -223,6 +237,9 @@ export default class OpenAIModel extends Model {
223
237
 
224
238
  const completion = await stream.finalResponse();
225
239
 
240
+ if (completion.usage)
241
+ yield this.usageDelta(completion.usage.input_tokens, completion.usage.output_tokens, completion.usage.input_tokens_details?.cached_tokens);
242
+
226
243
  const message_content = [];
227
244
  for (let output of completion.output) {
228
245
  switch (output.type) {
package/README.md CHANGED
@@ -42,7 +42,7 @@ Symposium uses environment variables to configure access to various services. Yo
42
42
  - **`Thread`**: A single conversation with an agent. Maintains message history and per-conversation state.
43
43
  - **`Toolkit`**: Base class for toolkits — groupings of one or more tools that an `Agent` can call.
44
44
  - **`Message`**: A typed message inside a `Thread`.
45
- - **`ContextHandler`** / **`Summarizer`**: Pre-execute hooks for managing long-context strategies.
45
+ - **`ContextHandler`** / **`Summarizer`** / **`Scrubber`**: Pre-execute hooks for managing long-context strategies.
46
46
  - **`createInputChannel`**: Helper that creates an `AsyncIterable` with `send(item)` / `close()` for streaming input into an agent.
47
47
 
48
48
  ## Getting Started
@@ -161,6 +161,7 @@ All events yielded from the generator:
161
161
  | `tool_response` | `{id, name, toolkit, success, response?, error?}` | Emitted after the tool returns or throws. `id` matches the `tool` event's id; `name` is the tool name; `toolkit` is the owning toolkit's name. |
162
162
  | `tools_auth` | `{id, tools}` | Yielded when authorization is required — see below. |
163
163
  | `retry` | `{attempt, reason}` | Only when an error occurs *after* at least one chunk has already streamed for the current turn. |
164
+ | `usage` | `{content: {input, output, cached, context}}` | Token usage reported by the provider after each LLM call (one per call, so several per turn when tools run). `input` is the full prompt size (cache included), `cached` the portion served from cache, `context` = `input + output` — the tokens occupying the model context after the call. Also stored on `thread.state.usage` (last call wins), so it survives persistence and is readable at `turn_end`. Omitted when the provider does not report usage. |
164
165
  | `result` | `{value}` | Only when `response_schema` is set — parsed structured answer. |
165
166
  | `turn_end` | `{thread}` | After each completed turn (LLM done with no more tool calls). Fires per-turn whether the run continues or ends — use it (not `end`) to detect turn boundaries inside a streaming run. |
166
167
  | `end` | `{thread}` | Always yielded last, even on throw. |
@@ -452,8 +453,9 @@ Returns `{ send(item), close(), [Symbol.asyncIterator]() }`. Push strings, conte
452
453
 
453
454
  ### Other Classes
454
455
 
455
- - **`ContextHandler`**: Base for managing long-term context.
456
+ - **`ContextHandler`**: Base for managing long-term context. Set as `options.memory_handler` on the agent; since 3.4 it runs before **every** LLM call (previously once per run, which skipped every later turn of a streaming session). A handler returning a transformed clone is re-registered in the agent's thread cache.
456
457
  - **`Summarizer`**: Utility agent that compresses old messages once a thread crosses a token threshold.
458
+ - **`Scrubber`**: Surgical auto-forget of heavy content: replaces images/audio blocks and trims oversized texts and tool results in messages older than the last `keep_last` user turns, leaving short text stubs (mentioning the on-disk `path` when the block carries one, so an agent with filesystem tools can re-read the original). Mutates the thread in place so the scrub persists with it; message structure and tool pairing stay intact; system messages are never touched. Options: `keep_last` (3), `max_text_length` (20000 chars), `keep_chars` (1000), plus `images`/`audio`/`texts`/`tool_results` toggles.
457
459
  - **`Logger`**: Simple per-agent logger.
458
460
 
459
461
  ## License
package/Scrubber.js ADDED
@@ -0,0 +1,142 @@
1
+ import ContextHandler from "./ContextHandler.js";
2
+
3
+ /**
4
+ * Context handler that retroactively removes heavy content from old messages,
5
+ * once the conversation has moved past them. Unlike Summarizer (which rewrites
6
+ * whole spans of history into a summary), Scrubber is surgical: it replaces
7
+ * individual heavy blocks — images, audio, oversized texts and tool results —
8
+ * with short text stubs, keeping the message structure (roles, tool pairing)
9
+ * intact. Mutates the thread in place, so the scrub persists with the normal
10
+ * thread storeState at turn end.
11
+ *
12
+ * Messages belonging to the last `keep_last` user turns are never touched, so
13
+ * the model always has the recent originals while it is still working on them.
14
+ * System messages are never touched.
15
+ *
16
+ * An image or audio block may carry a `path` in its content (where the source
17
+ * file lives on disk); the stub then mentions it, so an agent with filesystem
18
+ * tools can re-read the original if it turns out to still be needed.
19
+ */
20
+ export default class Scrubber extends ContextHandler {
21
+ constructor(options = {}) {
22
+ super();
23
+ this.options = {
24
+ keep_last: 3, // most recent user messages whose turns are fully protected
25
+ max_text_length: 20000, // chars; longer text/tool_result contents get trimmed
26
+ keep_chars: 1000, // head kept when trimming an oversized text
27
+ images: true,
28
+ audio: true,
29
+ texts: true, // oversized text blocks in user messages
30
+ tool_results: true, // oversized tool responses
31
+ ...options,
32
+ };
33
+ }
34
+
35
+ async handle(thread) {
36
+ const boundary = this.protectedBoundary(thread.messages);
37
+
38
+ let scrubbed = 0;
39
+ for (let i = 0; i < boundary; i++)
40
+ scrubbed += this.scrubMessage(thread.messages[i]);
41
+
42
+ // Exposed for consumers that scrub on demand (e.g. a model-invoked
43
+ // "forget" tool) and want to report how much was removed.
44
+ this.last_scrubbed = scrubbed;
45
+
46
+ if (scrubbed && this.agent)
47
+ await this.agent.log('scrub', {thread: thread.id, blocks: scrubbed});
48
+
49
+ return thread;
50
+ }
51
+
52
+ // Index of the keep_last-th user message counting from the end; everything
53
+ // before it is old enough to scrub. 0 (nothing to scrub) when the thread
54
+ // doesn't have that many user messages yet.
55
+ protectedBoundary(messages) {
56
+ let seen = 0;
57
+ for (let i = messages.length - 1; i >= 0; i--) {
58
+ if (messages[i].role === 'user') {
59
+ seen++;
60
+ if (seen >= this.options.keep_last)
61
+ return i;
62
+ }
63
+ }
64
+ return 0;
65
+ }
66
+
67
+ // Returns the number of blocks replaced or trimmed. Idempotent: stubs and
68
+ // already-trimmed blocks are always below the thresholds.
69
+ scrubMessage(message) {
70
+ if (message.role === 'system')
71
+ return 0;
72
+
73
+ let scrubbed = 0;
74
+ message.content = message.content.map(block => {
75
+ const replacement = this.scrubBlock(block, message.role);
76
+ if (replacement !== block)
77
+ scrubbed++;
78
+ return replacement;
79
+ });
80
+ return scrubbed;
81
+ }
82
+
83
+ scrubBlock(block, role) {
84
+ switch (block?.type) {
85
+ case 'image':
86
+ if (this.options.images)
87
+ return this.stub('image', block.content?.path);
88
+ break;
89
+
90
+ case 'audio':
91
+ if (this.options.audio) {
92
+ const transcription = block.content?.transcription;
93
+ const stub = this.stub('audio recording', block.content?.path);
94
+ return transcription
95
+ ? {type: 'text', content: stub.content + "\nTranscription: " + transcription}
96
+ : stub;
97
+ }
98
+ break;
99
+
100
+ case 'text':
101
+ if (this.options.texts && role === 'user' && typeof block.content === 'string' && block.content.length > this.options.max_text_length) {
102
+ const removed = block.content.length - this.options.keep_chars;
103
+ return {
104
+ type: 'text',
105
+ content: block.content.slice(0, this.options.keep_chars)
106
+ + "\n[... " + removed + " characters removed from context to save space]",
107
+ };
108
+ }
109
+ break;
110
+
111
+ case 'tool_result': {
112
+ if (!this.options.tool_results)
113
+ break;
114
+ const response = block.content?.response;
115
+ const serialized = typeof response === 'string' ? response : JSON.stringify(response);
116
+ if (typeof serialized === 'string' && serialized.length > this.options.max_text_length) {
117
+ const removed = serialized.length - this.options.keep_chars;
118
+ return {
119
+ ...block,
120
+ content: {
121
+ ...block.content,
122
+ response: serialized.slice(0, this.options.keep_chars)
123
+ + "\n[... " + removed + " characters removed from context to save space]",
124
+ },
125
+ };
126
+ }
127
+ break;
128
+ }
129
+ }
130
+
131
+ return block;
132
+ }
133
+
134
+ stub(what, path) {
135
+ return {
136
+ type: 'text',
137
+ content: path
138
+ ? "[" + what + " removed from context to save space — the original file is still available at " + path + ", read it again only if actually needed]"
139
+ : "[" + what + " removed from context to save space — ask the user to send it again if actually needed]",
140
+ };
141
+ }
142
+ }
package/index.js CHANGED
@@ -8,6 +8,7 @@ import Logger from "./Logger.js";
8
8
 
9
9
  import ContextHandler from "./ContextHandler.js";
10
10
  import Summarizer from "./Summarizer.js";
11
+ import Scrubber from "./Scrubber.js";
11
12
 
12
13
  import Context from "./Context.js";
13
14
  import File from "./Contexts/File.js";
@@ -26,6 +27,7 @@ export {
26
27
  Logger,
27
28
  ContextHandler,
28
29
  Summarizer,
30
+ Scrubber,
29
31
  Context,
30
32
  File,
31
33
  Text,
package/package.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
- "type": "module",
3
- "name": "symposium",
4
- "version": "3.2.1",
5
- "description": "Agents",
6
- "main": "index.js",
7
- "author": "Domenico Giambra",
8
- "license": "ISC",
9
- "scripts": {
10
- "test": "node --test \"test/**/*.test.js\""
11
- },
12
- "dependencies": {
13
- "@anthropic-ai/sdk": "^0.110.0",
14
- "@modelcontextprotocol/sdk": "^1.29.0",
15
- "groq-sdk": "^1.0.0",
16
- "ollama": "^0.6.0",
17
- "openai": "^6.0.0",
18
- "tiktoken": "^1.0.10",
19
- "uuid": "^14.0.0"
20
- }
2
+ "type": "module",
3
+ "name": "symposium",
4
+ "version": "3.4.0",
5
+ "description": "Agents",
6
+ "main": "index.js",
7
+ "author": "Domenico Giambra",
8
+ "license": "ISC",
9
+ "scripts": {
10
+ "test": "node --test \"test/**/*.test.js\""
11
+ },
12
+ "dependencies": {
13
+ "@anthropic-ai/sdk": "^0.110.0",
14
+ "@modelcontextprotocol/sdk": "^1.29.0",
15
+ "groq-sdk": "^1.0.0",
16
+ "ollama": "^0.6.0",
17
+ "openai": "^6.0.0",
18
+ "tiktoken": "^1.0.10",
19
+ "uuid": "^14.0.0"
20
+ }
21
21
  }
@@ -84,6 +84,32 @@ test('Agent.generateCompletion forwards text_delta as chunks and returns Message
84
84
  assert.deepEqual(value[0].content, [{type: 'text', content: 'Hello world'}]);
85
85
  });
86
86
 
87
+ test('Agent.generateCompletion forwards usage deltas and stamps thread.state.usage', async () => {
88
+ const label = 'fake-gen-usage';
89
+ const usage = {input: 550, output: 30, cached: 400, context: 580};
90
+ await Symposium.loadModel(new ScriptedModel(label, [{
91
+ deltas: [
92
+ {type: 'text_delta', content: 'Hi'},
93
+ {type: 'usage', content: usage},
94
+ ],
95
+ messages: [new Message('assistant', [{type: 'text', content: 'Hi'}])],
96
+ }]));
97
+
98
+ const agent = new Agent();
99
+ agent.default_model = label;
100
+ await agent.init();
101
+
102
+ const thread = await makeThread(agent, label);
103
+
104
+ const {deltas} = await drain(agent.generateCompletion(thread));
105
+
106
+ assert.deepEqual(deltas, [
107
+ {type: 'chunk', content: 'Hi'},
108
+ {type: 'usage', content: usage},
109
+ ]);
110
+ assert.deepEqual(thread.state.usage, usage);
111
+ });
112
+
87
113
  // ────────────────────────────────────────────────────────────────────────────────
88
114
  // Chat happy path
89
115
  // ────────────────────────────────────────────────────────────────────────────────
@@ -98,6 +98,35 @@ test('AnthropicModel buffers input_json_delta and yields a complete tool_call',
98
98
  ]);
99
99
  });
100
100
 
101
+ test('AnthropicModel yields a normalized usage delta folding cache tokens into input', async () => {
102
+ const m = new AnthropicModel();
103
+ const finalMessage = {
104
+ content: [{type: 'text', text: 'Hi'}],
105
+ usage: {
106
+ input_tokens: 100,
107
+ cache_read_input_tokens: 400,
108
+ cache_creation_input_tokens: 50,
109
+ output_tokens: 30,
110
+ },
111
+ };
112
+ const stream = anthropicMessagesStream(
113
+ [
114
+ {type: 'content_block_start', index: 0, content_block: {type: 'text'}},
115
+ {type: 'content_block_delta', index: 0, delta: {type: 'text_delta', text: 'Hi'}},
116
+ {type: 'content_block_stop', index: 0},
117
+ ],
118
+ finalMessage,
119
+ );
120
+ installFakeAnthropic(m, stream);
121
+
122
+ const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
123
+
124
+ assert.deepEqual(deltas[deltas.length - 1], {
125
+ type: 'usage',
126
+ content: {input: 550, output: 30, cached: 400, context: 580},
127
+ });
128
+ });
129
+
101
130
  test('AnthropicModel emits reasoning_delta from thinking_delta', async () => {
102
131
  const m = new AnthropicModel();
103
132
  const thinkingBlock = {type: 'thinking', thinking: 'reasoning text'};
@@ -69,3 +69,18 @@ test('GroqModel accumulates tool_call deltas across chunks', async () => {
69
69
  },
70
70
  ]);
71
71
  });
72
+
73
+ test('GroqModel yields a normalized usage delta from the x_groq final chunk', async () => {
74
+ const m = new GroqModel();
75
+ installFakeGroq(m, [
76
+ {choices: [{delta: {content: 'Hi'}}]},
77
+ {choices: [{delta: {}, finish_reason: 'stop'}], x_groq: {usage: {prompt_tokens: 80, completion_tokens: 20}}},
78
+ ]);
79
+
80
+ const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
81
+
82
+ assert.deepEqual(deltas[deltas.length - 1], {
83
+ type: 'usage',
84
+ content: {input: 80, output: 20, cached: 0, context: 100},
85
+ });
86
+ });
@@ -85,3 +85,45 @@ test('LegacyOpenAIModel combines text + tool_calls in one Message', async () =>
85
85
  assert.equal(value[0].content[1].type, 'tool_call');
86
86
  assert.equal(value[0].content[1].content[0].name, 'f');
87
87
  });
88
+
89
+ test('LegacyOpenAIModel requests include_usage and yields a normalized usage delta', async () => {
90
+ const m = new LegacyOpenAIModel();
91
+ let capturedPayload = null;
92
+ m.getOpenAi = () => ({
93
+ chat: {
94
+ completions: {
95
+ async create(payload) {
96
+ capturedPayload = payload;
97
+ return asyncIterable([
98
+ {choices: [{delta: {content: 'Hi'}}]},
99
+ {choices: [{delta: {}, finish_reason: 'stop'}]},
100
+ {choices: [], usage: {prompt_tokens: 500, completion_tokens: 25, prompt_tokens_details: {cached_tokens: 300}}},
101
+ ]);
102
+ },
103
+ },
104
+ },
105
+ });
106
+
107
+ const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
108
+
109
+ assert.deepEqual(capturedPayload.stream_options, {include_usage: true});
110
+ assert.deepEqual(deltas[deltas.length - 1], {
111
+ type: 'usage',
112
+ content: {input: 500, output: 25, cached: 300, context: 525},
113
+ });
114
+ });
115
+
116
+ test('LegacyOpenAIModel maps DeepSeek prompt_cache_hit_tokens to cached', async () => {
117
+ const m = new LegacyOpenAIModel();
118
+ installFakeOpenAi(m, [
119
+ {choices: [{delta: {content: 'Hi'}}]},
120
+ {choices: [], usage: {prompt_tokens: 500, completion_tokens: 25, prompt_cache_hit_tokens: 200}},
121
+ ]);
122
+
123
+ const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
124
+
125
+ assert.deepEqual(deltas[deltas.length - 1], {
126
+ type: 'usage',
127
+ content: {input: 500, output: 25, cached: 200, context: 525},
128
+ });
129
+ });
@@ -88,3 +88,18 @@ test('OllamaModel emulates streaming when only a single full chunk arrives', asy
88
88
  ]);
89
89
  assert.deepEqual(value[0].content, [{type: 'text', content: 'One shot response'}]);
90
90
  });
91
+
92
+ test('OllamaModel yields a normalized usage delta from the final done chunk', async () => {
93
+ const m = new OllamaModel();
94
+ installFakeOllama(m, [
95
+ {message: {content: 'Hello'}},
96
+ {message: {content: ''}, done: true, prompt_eval_count: 60, eval_count: 15},
97
+ ]);
98
+
99
+ const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
100
+
101
+ assert.deepEqual(deltas[deltas.length - 1], {
102
+ type: 'usage',
103
+ content: {input: 60, output: 15, cached: 0, context: 75},
104
+ });
105
+ });
@@ -138,6 +138,30 @@ test('OpenAIModel emits reasoning_delta and final reasoning block', async () =>
138
138
  assert.deepEqual(value[0].content[1], {type: 'text', content: 'done'});
139
139
  });
140
140
 
141
+ test('OpenAIModel yields a normalized usage delta from the final response', async () => {
142
+ const m = new OpenAIModel();
143
+ const stream = openAiResponsesStream(
144
+ [{type: 'response.output_text.delta', delta: 'Hi'}],
145
+ {
146
+ output: [{type: 'message', content: [{text: 'Hi'}]}],
147
+ usage: {
148
+ input_tokens: 1200,
149
+ input_tokens_details: {cached_tokens: 1000},
150
+ output_tokens: 40,
151
+ total_tokens: 1240,
152
+ },
153
+ },
154
+ );
155
+ installFakeOpenAi(m, stream);
156
+
157
+ const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
158
+
159
+ assert.deepEqual(deltas[deltas.length - 1], {
160
+ type: 'usage',
161
+ content: {input: 1200, output: 40, cached: 1000, context: 1240},
162
+ });
163
+ });
164
+
141
165
  test('OpenAIModel emits image delta and final image block', async () => {
142
166
  const m = new OpenAIModel();
143
167
  const imageItem = {
@@ -0,0 +1,216 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import Agent from '../Agent.js';
5
+ import Symposium from '../Symposium.js';
6
+ import Model from '../Model.js';
7
+ import Message from '../Message.js';
8
+ import Thread from '../Thread.js';
9
+ import ContextHandler from '../ContextHandler.js';
10
+ import Scrubber from '../Scrubber.js';
11
+
12
+ import {createInputChannel} from '../InputChannel.js';
13
+
14
+ class ScriptedModel extends Model {
15
+ constructor(label, script) {
16
+ super();
17
+ this.label = label;
18
+ this.script = script;
19
+ this.calls = 0;
20
+ }
21
+
22
+ async getModels() {
23
+ return new Map([
24
+ [this.label, {
25
+ name: this.label,
26
+ tokens: 1000,
27
+ tools: true,
28
+ structured_output: false,
29
+ }],
30
+ ]);
31
+ }
32
+
33
+ async *generate(_model, _thread, _functions, _options) {
34
+ const turn = this.script[this.calls++];
35
+ if (!turn)
36
+ throw new Error('No more scripted turns for model ' + this.label);
37
+ return turn.messages;
38
+ }
39
+ }
40
+
41
+ function imageBlock(path = undefined) {
42
+ return {type: 'image', content: {type: 'base64', mime: 'image/png', data: 'AAAA', path}};
43
+ }
44
+
45
+ function makeScrubbableThread() {
46
+ const thread = new Thread('scrub-test', {name: 'TestAgent'});
47
+ thread.addMessage('system', 'You are a test agent');
48
+ thread.addMessage('user', [{type: 'text', content: 'look at this'}, imageBlock()]);
49
+ thread.addMessage('assistant', 'I see a cat');
50
+ thread.addMessage('user', 'second message');
51
+ thread.addMessage('assistant', 'ok');
52
+ thread.addMessage('user', 'third message');
53
+ thread.addMessage('assistant', 'ok');
54
+ thread.addMessage('user', 'fourth message');
55
+ return thread;
56
+ }
57
+
58
+ test('Scrubber replaces old image blocks with a stub, keeps recent turns intact', async () => {
59
+ const thread = makeScrubbableThread();
60
+ const scrubber = new Scrubber({keep_last: 3});
61
+
62
+ await scrubber.handle(thread);
63
+
64
+ const first_user = thread.messages[1];
65
+ assert.equal(first_user.content[0].content, 'look at this');
66
+ assert.equal(first_user.content[1].type, 'text');
67
+ assert.match(first_user.content[1].content, /image removed from context/);
68
+ // Recent messages untouched
69
+ assert.equal(thread.messages[7].content[0].content, 'fourth message');
70
+ });
71
+
72
+ test('Scrubber leaves images inside the protected window alone', async () => {
73
+ const thread = new Thread('scrub-window', {name: 'TestAgent'});
74
+ thread.addMessage('user', [imageBlock()]);
75
+ thread.addMessage('assistant', 'ok');
76
+ thread.addMessage('user', 'follow-up');
77
+
78
+ // Only 2 user messages, keep_last 3 → nothing is old enough
79
+ await new Scrubber({keep_last: 3}).handle(thread);
80
+ assert.equal(thread.messages[0].content[0].type, 'image');
81
+ });
82
+
83
+ test('Scrubber stub mentions the on-disk path when the block carries one', async () => {
84
+ const thread = makeScrubbableThread();
85
+ thread.messages[1].content[1] = imageBlock('/tmp/photo.png');
86
+
87
+ await new Scrubber({keep_last: 3}).handle(thread);
88
+ assert.match(thread.messages[1].content[1].content, /\/tmp\/photo\.png/);
89
+ });
90
+
91
+ test('Scrubber trims oversized user texts but not assistant ones', async () => {
92
+ const long = 'x'.repeat(500);
93
+ const thread = new Thread('scrub-text', {name: 'TestAgent'});
94
+ thread.addMessage('user', long);
95
+ thread.addMessage('assistant', long);
96
+ thread.addMessage('user', 'a');
97
+ thread.addMessage('user', 'b');
98
+ thread.addMessage('user', 'c');
99
+
100
+ await new Scrubber({keep_last: 3, max_text_length: 100, keep_chars: 10}).handle(thread);
101
+
102
+ assert.match(thread.messages[0].content[0].content, /^x{10}\n\[\.\.\. 490 characters removed/);
103
+ assert.equal(thread.messages[1].content[0].content, long);
104
+ });
105
+
106
+ test('Scrubber trims oversized tool results preserving name and id', async () => {
107
+ const thread = new Thread('scrub-tool', {name: 'TestAgent'});
108
+ thread.addMessage('tool', [{
109
+ type: 'tool_result',
110
+ content: {name: 'WebFetch', id: 'call_1', response: 'y'.repeat(500)},
111
+ }], 'WebFetch');
112
+ thread.addMessage('user', 'a');
113
+ thread.addMessage('user', 'b');
114
+ thread.addMessage('user', 'c');
115
+
116
+ await new Scrubber({keep_last: 3, max_text_length: 100, keep_chars: 10}).handle(thread);
117
+
118
+ const block = thread.messages[0].content[0];
119
+ assert.equal(block.type, 'tool_result');
120
+ assert.equal(block.content.name, 'WebFetch');
121
+ assert.equal(block.content.id, 'call_1');
122
+ assert.match(block.content.response, /characters removed from context/);
123
+ });
124
+
125
+ test('Scrubber never touches system messages and is idempotent', async () => {
126
+ const long_system = 's'.repeat(500);
127
+ const thread = new Thread('scrub-sys', {name: 'TestAgent'});
128
+ thread.addMessage('system', long_system);
129
+ thread.addMessage('user', [imageBlock()]);
130
+ thread.addMessage('user', 'a');
131
+ thread.addMessage('user', 'b');
132
+ thread.addMessage('user', 'c');
133
+
134
+ const scrubber = new Scrubber({keep_last: 3, max_text_length: 100, keep_chars: 10});
135
+ await scrubber.handle(thread);
136
+ assert.equal(thread.messages[0].content[0].content, long_system);
137
+
138
+ const after_first = JSON.stringify(thread.messages);
139
+ await scrubber.handle(thread);
140
+ assert.equal(JSON.stringify(thread.messages), after_first);
141
+ });
142
+
143
+ // ────────────────────────────────────────────────────────────────────────────────
144
+ // Agent integration: the memory handler must run before EVERY LLM call in a
145
+ // streaming run, not just once at the start of execute().
146
+ // ────────────────────────────────────────────────────────────────────────────────
147
+ test('memory_handler runs per turn in a streaming run', async () => {
148
+ const label = 'fake-scrub-streaming';
149
+ await Symposium.loadModel(new ScriptedModel(label, [
150
+ {messages: [new Message('assistant', [{type: 'text', content: 'first'}])]},
151
+ {messages: [new Message('assistant', [{type: 'text', content: 'second'}])]},
152
+ ]));
153
+
154
+ class CountingHandler extends ContextHandler {
155
+ calls = 0;
156
+
157
+ async handle(thread) {
158
+ this.calls++;
159
+ return thread;
160
+ }
161
+ }
162
+
163
+ const handler = new CountingHandler();
164
+ const agent = new Agent({memory_handler: handler});
165
+ agent.default_model = label;
166
+ await agent.init();
167
+
168
+ const thread = new Thread('scrub-streaming', agent);
169
+ thread.state = {model: label};
170
+
171
+ const channel = createInputChannel();
172
+ channel.send('first message');
173
+ channel.send({type: 'submit'});
174
+
175
+ let turns = 0;
176
+ for await (const ev of agent.message(channel, thread)) {
177
+ if (ev.type === 'turn_end') {
178
+ turns++;
179
+ if (turns === 1)
180
+ channel.send('second message');
181
+ else
182
+ channel.close();
183
+ }
184
+ }
185
+
186
+ assert.equal(turns, 2);
187
+ assert.equal(handler.calls, 2);
188
+ });
189
+
190
+ test('a memory_handler returning a clone gets re-registered in agent.threads', async () => {
191
+ const label = 'fake-scrub-clone';
192
+ await Symposium.loadModel(new ScriptedModel(label, [
193
+ {messages: [new Message('assistant', [{type: 'text', content: 'done'}])]},
194
+ ]));
195
+
196
+ class CloningHandler extends ContextHandler {
197
+ async handle(thread) {
198
+ this.last_clone = thread.clone(true);
199
+ return this.last_clone;
200
+ }
201
+ }
202
+
203
+ const handler = new CloningHandler();
204
+ const agent = new Agent({memory_handler: handler});
205
+ agent.default_model = label;
206
+ await agent.init();
207
+
208
+ const thread = new Thread('scrub-clone', agent);
209
+ thread.state = {model: label};
210
+
211
+ for await (const ev of agent.trigger(thread)) {
212
+ void ev;
213
+ }
214
+
215
+ assert.equal(agent.threads.get('scrub-clone'), handler.last_clone);
216
+ });