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 +16 -4
- package/CLAUDE.md +4 -2
- package/README.md +3 -2
- package/Scrubber.js +142 -0
- package/index.js +2 -0
- package/package.json +19 -19
- package/test/scrubber.test.js +216 -0
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
|
-
|
|
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. `
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
});
|