symposium 3.3.0 → 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.
package/CLAUDE.md CHANGED
@@ -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
 
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
@@ -453,8 +453,9 @@ Returns `{ send(item), close(), [Symbol.asyncIterator]() }`. Push strings, conte
453
453
 
454
454
  ### Other Classes
455
455
 
456
- - **`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.
457
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.
458
459
  - **`Logger`**: Simple per-agent logger.
459
460
 
460
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.3.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
- }
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
  }
@@ -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
+ });