symposium 2.4.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,396 +1,458 @@
1
- # Symposium
2
-
3
- Symposium is a powerful and flexible Node.js framework for building Large Language Model (LLM)-powered agents. It provides a structured, extensible architecture for creating complex AI systems with distinct behaviors, tools, and memory.
4
-
5
- ## Features
6
-
7
- - **Agent-Based Architecture**: Create multiple, specialized agents that can be extended with unique behaviors.
8
- - **Model Agnostic**: Easily integrate with various LLM providers (OpenAI, Anthropic, Groq, etc.). A list of supported models is available in the `models` folder.
9
- - **Tool Integration**: Extend agents' capabilities by giving them tools to interact with external systems.
10
- - **Stateful Conversations**: Manages conversational state and history through Threads.
11
- - **Persistent Memory**: Pluggable storage adapters allow for long-term memory.
12
- - **Real-time Sessions**: Built-in support for real-time voice conversations.
13
-
14
- ## Installation
15
-
16
- Requires Node.js v18 or higher.
17
-
18
- ```bash
19
- npm install symposium
20
- ```
21
-
22
- ## Configuration
23
-
24
- Symposium uses environment variables to configure access to various services. You can set these in a `.env` file at the root of your project.
25
-
26
- - `OPENAI_API_KEY`: Required for using OpenAI models and for Real-time Voice Sessions.
27
- - `ANTHROPIC_API_KEY`: Required for using Anthropic models.
28
- - `GROQ_API_KEY`: Required for using Groq models.
29
- - `DEEPSEEK_API_KEY`: Required for using DeepSeek models.
30
- - `TRANSCRIPTION_MODEL`: (Optional) The name of the model to use for audio transcription (currently, only `gpt-4o-transcribe` is supported).
31
-
32
- ## Core Concepts
33
-
34
- The framework is built around a few core components:
35
-
36
- - **`Symposium`**: A static class that acts as the central hub. It's responsible for loading models and initializing the storage adapter.
37
- - **`Agent`**: The heart of the framework. An `Agent` is an autonomous entity with a specific goal. You extend this class to define your agent's unique prompt, behavior, and tools.
38
- - **`Thread`**: Represents a single conversation with an agent. It maintains the message history and the agent's state for that conversation. Each thread has a unique ID.
39
- - **`Tool`**: A base class for creating tools that an `Agent` can use. Tools expose functions that the LLM can call to interact with external APIs or data.
40
- - **`Message`**: A wrapper for messages within a `Thread`, containing the role (`user`, `assistant`, `system`, `tool`), content, and other metadata.
41
- - **`ContextHandler`**: A class for managing an agent's long contexts. It can be extended to create custom memory strategies.
42
- - **`Summarizer`**: A utility agent for summarizing text or conversations.
43
- - **`Logger`**: A simple logging utility that can be passed to an agent to log its activity.
44
-
45
- ## Getting Started
46
-
47
- Here's a simple example of how to create a basic chat agent.
48
-
49
- ### 1. Initialize Symposium
50
-
51
- First, you need to initialize `Symposium`. This will load all the available models. You can also provide a storage adapter for persistence.
52
-
53
- ```javascript
54
- // index.js
55
- import { Symposium } from 'symposium';
56
-
57
- async function main() {
58
- await Symposium.init(); // You can pass a storage adapter here
59
- // ... your agent code
60
- }
61
-
62
- main();
63
- ```
64
-
65
- ### 2. One shot prompts
66
-
67
- You can also use the static `Symposium.prompt()` method for one-off prompts without creating an agent.
68
-
69
- ```javascript
70
- import { Symposium } from 'symposium';
71
- await Symposium.init();
72
-
73
- const response = await Symposium.prompt('Translate the text from English to French.', 'Hello, how are you?');
74
- console.log(response); // "Bonjour, comment ça va?"
75
-
76
- const structured_response = await Symposium.prompt('Extract name and emails from the following email', email_text, {
77
- response: {
78
- type: 'json',
79
- function: {
80
- name: 'extract_data',
81
- parameters: {
82
- type: 'array',
83
- items: {
84
- type: 'object',
85
- properties: {
86
- name: {type: 'string'},
87
- email: {type: 'string'},
88
- },
89
- required: ['name', 'email'],
90
- },
91
- },
92
- },
93
- },
94
- });
95
- ```
96
-
97
- ### 3. Create your Agent
98
-
99
- For more structured and/or reusable tasks, create a new class that extends `Agent`. At a minimum, you'll want to define a name and a system prompt.
100
-
101
- ```javascript
102
- // MyChatAgent.js
103
- import { Agent } from 'symposium';
104
-
105
- export default class MyChatAgent extends Agent {
106
- name = 'MyChatAgent';
107
- description = 'A simple chat agent.';
108
-
109
- async doInitThread(thread) {
110
- await thread.addMessage('system', 'You are a helpful assistant.');
111
- }
112
- }
113
- ```
114
-
115
- ### 4. Start a Conversation
116
-
117
- Now you can instantiate your agent and start a conversation.
118
-
119
- ```javascript
120
- // index.js
121
- import { Symposium } from 'symposium';
122
- import MyChatAgent from './MyChatAgent.js';
123
-
124
- async function main() {
125
- await Symposium.init();
126
-
127
- const agent = new MyChatAgent();
128
- await agent.init();
129
-
130
- const emitter = await agent.message('Hello, who are you?');
131
-
132
- emitter.on('output', message => {
133
- switch (message.type) {
134
- case 'text':
135
- process.stdout.write(message.content);
136
- break;
137
-
138
- case 'image':
139
- // Process image
140
- break;
141
- }
142
- });
143
-
144
- emitter.on('error', error => {
145
- console.error(`\nAn error occurred: ${error.message}`);
146
- });
147
-
148
- emitter.on('tool', tool => {
149
- console.log(`\n> Using tool: ${tool.name} with arguments ${JSON.stringify(tool.arguments)}\n`);
150
- });
151
-
152
- emitter.on('tool_response', (tool_response) => {
153
- if (tool_response.success)
154
- console.log(`\n> Tool ${tool_response.name} completed successfully with response: ${JSON.stringify(tool_response.response)}\n`);
155
- else
156
- console.log(`\n> Tool ${tool_response.name} failed with error: ${tool_response.error}\n`);
157
- });
158
- }
159
-
160
- main();
161
- ```
162
-
163
- When you run this, the agent will respond to your message, and the response will be streamed to the console. The `message` method returns an `EventEmitter` that emits several events:
164
-
165
- - `start`: Emitted when the agent begins processing the message. The `thread` object is passed as an argument.
166
- - `output`: Emitted for each chunk of text in the response stream.
167
- - `reasoning`: Emitted when the agent generates reasoning steps (if applicable).
168
- - `tool`: Emitted when the agent decides to use a tool. The tool name and arguments are provided.
169
- - `tool_response`: Emitted when a tool call completes, with the response or error
170
- - `error`: Emitted if an error occurs during processing.
171
-
172
- ## Advanced Usage
173
-
174
- ### Using Tools
175
-
176
- Tools allow your agent to interact with the outside world. To create a tool, extend the `Tool` class and define one or more functions.
177
-
178
- #### 1. Create a Tool
179
-
180
- Here's an example of a tool that can get the current weather.
181
-
182
- ```javascript
183
- // WeatherTool.js
184
- import { Tool } from 'symposium';
185
-
186
- export default class WeatherTool extends Tool {
187
- name = 'WeatherTool';
188
-
189
- async getFunctions() {
190
- return [
191
- {
192
- name: 'get_weather',
193
- description: 'Get the current weather for a specific city',
194
- parameters: {
195
- type: 'object',
196
- properties: {
197
- city: {
198
- type: 'string',
199
- description: 'The city name',
200
- },
201
- },
202
- required: ['city'],
203
- },
204
- },
205
- ];
206
- }
207
-
208
- async callFunction(thread, name, payload) {
209
- if (name === 'get_weather') {
210
- const city = payload.city;
211
- // In a real app, you would call a weather API here
212
- return { temperature: '25°C', condition: 'sunny' };
213
- }
214
- }
215
- }
216
- ```
217
-
218
- #### 2. Add the Tool to your Agent
219
-
220
- Now, add the tool to your agent instance.
221
-
222
- ```javascript
223
- // index.js
224
- import MyChatAgent from './MyChatAgent.js';
225
- import WeatherTool from './WeatherTool.js';
226
-
227
- // ... inside main()
228
- const agent = new MyChatAgent();
229
- await agent.addTool(new WeatherTool());
230
- await agent.init();
231
-
232
- const emitter = await agent.message("What's the weather like in Paris?");
233
- // ...
234
- ```
235
-
236
- The agent's underlying LLM will now be able to see the `get_weather` function and will call it when appropriate, passing the result back into the conversation.
237
-
238
- ### Real-time Voice and Transcription
239
-
240
- Symposium has built-in support for audio transcription and real-time voice sessions, currently powered by OpenAI.
241
-
242
- #### Audio Transcription
243
-
244
- You can send audio content directly in a message. If the model doesn't support audio input, Symposium will automatically transcribe it to text.
245
-
246
- ```javascript
247
- // Transcribing an audio file from a URL
248
- const emitter = await agent.message([
249
- {
250
- type: 'audio',
251
- content: {
252
- type: 'url',
253
- data: 'http://example.com/audio.mp3'
254
- }
255
- }
256
- ]);
257
- ```
258
-
259
- You can also use the static `Symposium.transcribe()` method for standalone transcription.
260
-
261
- #### Real-time Voice Sessions
262
-
263
- For interactive voice conversations, you can create a real-time session. This is useful for building voice bots.
264
-
265
- ```javascript
266
- // (inside an async function)
267
- const { response, thread } = await agent.createRealtimeSession();
268
- const sessionId = response.id;
269
- const clientSecret = response.client_secret.value;
270
-
271
- // You would then use this session ID and client secret on the client-side
272
- // to connect to the real-time session endpoint.
273
- ```
274
-
275
- ### Switching Models
276
-
277
- You can set a default model for an agent or change it on a per-thread basis.
278
-
279
- ```javascript
280
- // Setting a default model for the agent
281
- class MyAgent extends Agent {
282
- default_model = 'claude-3-5-sonnet';
283
- //...
284
- }
285
-
286
- // Changing the model for a specific thread
287
- const thread = await agent.getThread('thread-id');
288
- await agent.setModel(thread, 'gpt-3.5-turbo');
289
- ```
290
-
291
- The model label must be one of the models available in the `models` directory.
292
-
293
- ### Persistence
294
-
295
- Symposium can persist thread state and messages if you provide a storage adapter. The adapter must implement three methods: `init()`, `get(key)`, and `set(key, value)`.
296
-
297
- ```javascript
298
- // MySimpleFileStorage.js
299
- import fs from 'fs/promises';
300
-
301
- class MySimpleFileStorage {
302
- async init() {
303
- await fs.mkdir('./storage', { recursive: true });
304
- }
305
- async get(key) {
306
- try {
307
- const data = await fs.readFile(`./storage/${key}.json`, 'utf-8');
308
- return JSON.parse(data);
309
- } catch (e) {
310
- return null;
311
- }
312
- }
313
- async set(key, value) {
314
- await fs.writeFile(`./storage/${key}.json`, JSON.stringify(value, null, 2));
315
- }
316
- }
317
-
318
- // index.js
319
- await Symposium.init(new MySimpleFileStorage());
320
- ```
321
-
322
- With a storage adapter in place, conversations will be saved and loaded automatically based on the thread ID.
323
-
324
- ### Utility Agents
325
-
326
- Besides `chat` agents, you can create `utility` agents. These are designed for specific, one-shot tasks like data extraction or classification, rather than open-ended conversation. They typically return structured JSON.
327
-
328
- ```javascript
329
- // TextExtractorAgent.js
330
- import { Agent } from 'symposium';
331
-
332
- export default class TextExtractorAgent extends Agent {
333
- name = 'TextExtractorAgent';
334
- type = 'utility';
335
- utility = {
336
- type: 'json',
337
- function: {
338
- name: 'extract_data',
339
- parameters: {
340
- type: 'object',
341
- properties: {
342
- name: { type: 'string' },
343
- email: { type: 'string' },
344
- },
345
- required: ['name', 'email'],
346
- },
347
- },
348
- };
349
-
350
- async doInitThread(thread) {
351
- await thread.addMessage('system', 'Extract the name and email from the text.');
352
- }
353
- }
354
-
355
- // Usage
356
- const extractor = new TextExtractorAgent();
357
- await extractor.init();
358
- const result = await extractor.message('My name is John Doe and my email is john.doe@example.com');
359
- console.log(result); // { name: 'John Doe', email: 'john.doe@example.com' }
360
- ```
361
-
362
- ## API Reference
363
-
364
- This is a high-level overview. For details, please refer to the source code.
365
-
366
- ### `Agent`
367
-
368
- - `constructor(options)`: Creates a new agent. Options can include a `memory_handler` or `logger`.
369
- - `init()`: Initializes the agent. Must be called before use.
370
- - `addTool(tool)`: Adds a `Tool` instance to the agent.
371
- - `message(content, thread)`: Sends a message to the agent. Returns an EventEmitter.
372
- - `getThread(id)`: Retrieves a `Thread` instance by its ID.
373
- - `setModel(thread, modelLabel)`: Changes the LLM for a specific thread.
374
- - `createRealtimeSession(thread_id, options)`: Creates a real-time session for voice interaction.
375
-
376
- ### `Thread`
377
-
378
- - `constructor(id, agent)`: Creates a new thread.
379
- - `addMessage(role, content, name, tags)`: Adds a message to the thread.
380
- - `setState(state, save)`: Updates the thread's state object.
381
- - `loadState()` / `storeState()`: Manages persistence (used internally).
382
-
383
- ### `Tool`
384
-
385
- - `getFunctions()`: **Abstract**. Must return an array of function definitions that the LLM can call.
386
- - `callFunction(thread, name, payload)`: **Abstract**. Called when the LLM decides to use one of the tool's functions.
387
-
388
- ### Other Classes
389
-
390
- - **`ContextHandler`**: Provides a base for managing long-term context. Can be extended for custom memory strategies.
391
- - **`Summarizer`**: A utility agent for text summarization.
392
- - **`Logger`**: A simple logger for agent activity.
393
-
394
- ## License
395
-
396
- ISC
1
+ # Symposium
2
+
3
+ Symposium is a Node.js framework for building Large Language Model (LLM)-powered agents. It provides a structured, extensible architecture for creating complex AI systems with distinct behaviors, tools, and memory.
4
+
5
+ > **3.0 is a breaking release.** The old `EventEmitter` API is gone, replaced by async generators and streaming input channels. See [MIGRATION.md](./MIGRATION.md) for upgrade instructions.
6
+
7
+ ## Features
8
+
9
+ - **Agent-Based Architecture**: Create multiple, specialized agents that can be extended with unique behaviors.
10
+ - **Model Agnostic**: Easily integrate with various LLM providers (OpenAI, Anthropic, Groq, DeepSeek, Grok, Ollama). A list of supported models is available in the `Models` folder.
11
+ - **Real streaming**: Model adapters use the underlying providers' streaming APIs and forward token deltas to consumers as they arrive.
12
+ - **Async generator API**: `agent.message(...)` returns an async iterable — consume it with `for await`.
13
+ - **Streaming input**: Push user messages, tool-authorization decisions, and control signals into a running agent via an input channel.
14
+ - **Tool integration**: Extend agents' capabilities with tools that the LLM can call.
15
+ - **Stateful conversations**: Manage conversational state and history through Threads.
16
+ - **Persistent memory**: Pluggable storage adapters allow for long-term memory.
17
+ - **Structured output**: Set `response_schema` on any agent (chat or utility) to constrain the final answer to a JSON schema.
18
+ - **Real-time sessions**: Built-in support for real-time voice conversations.
19
+
20
+ ## Installation
21
+
22
+ Requires Node.js v18 or higher.
23
+
24
+ ```bash
25
+ npm install symposium
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Symposium uses environment variables to configure access to various services. You can set these in a `.env` file at the root of your project.
31
+
32
+ - `OPENAI_API_KEY`: Required for OpenAI models and real-time voice sessions.
33
+ - `ANTHROPIC_API_KEY`: Required for Anthropic models.
34
+ - `GROQ_API_KEY`: Required for Groq models.
35
+ - `DEEPSEEK_API_KEY`: Required for DeepSeek models.
36
+ - `TRANSCRIPTION_MODEL`, `EMBEDDING_MODEL`: Model labels routed to STT / embedding providers.
37
+
38
+ ## Core Concepts
39
+
40
+ - **`Symposium`**: Static class that acts as the central hub. Responsible for loading models and initializing the storage adapter.
41
+ - **`Agent`**: The heart of the framework. Extend this class to define an agent's prompt, behavior, and tools.
42
+ - **`Thread`**: A single conversation with an agent. Maintains message history and per-conversation state.
43
+ - **`Toolkit`**: Base class for toolkits groupings of one or more tools that an `Agent` can call.
44
+ - **`Message`**: A typed message inside a `Thread`.
45
+ - **`ContextHandler`** / **`Summarizer`**: Pre-execute hooks for managing long-context strategies.
46
+ - **`createInputChannel`**: Helper that creates an `AsyncIterable` with `send(item)` / `close()` for streaming input into an agent.
47
+
48
+ ## Getting Started
49
+
50
+ ### 1. Initialize Symposium
51
+
52
+ ```javascript
53
+ import { Symposium } from 'symposium';
54
+
55
+ await Symposium.init(); // optional: pass a storage adapter
56
+ ```
57
+
58
+ ### 2. One-shot prompts
59
+
60
+ `Symposium.prompt(system, prompt, options)` is a shortcut that spins up a bare utility agent and resolves directly to the final value.
61
+
62
+ ```javascript
63
+ import { Symposium } from 'symposium';
64
+ await Symposium.init();
65
+
66
+ const reply = await Symposium.prompt(
67
+ 'Translate from English to French.',
68
+ 'Hello, how are you?',
69
+ );
70
+ console.log(reply); // "Bonjour, comment ça va ?"
71
+
72
+ // With structured output:
73
+ const data = await Symposium.prompt(
74
+ 'Extract name and emails from the following text',
75
+ email_text,
76
+ {
77
+ response_schema: {
78
+ type: 'array',
79
+ items: {
80
+ type: 'object',
81
+ properties: {
82
+ name: { type: 'string' },
83
+ email: { type: 'string' },
84
+ },
85
+ required: ['name', 'email'],
86
+ },
87
+ },
88
+ },
89
+ );
90
+ ```
91
+
92
+ ### 3. Create your Agent
93
+
94
+ ```javascript
95
+ // MyChatAgent.js
96
+ import { Agent } from 'symposium';
97
+
98
+ export default class MyChatAgent extends Agent {
99
+ name = 'MyChatAgent';
100
+ description = 'A simple chat agent.';
101
+
102
+ async doInitThread(thread) {
103
+ await thread.addMessage('system', 'You are a helpful assistant.');
104
+ }
105
+ }
106
+ ```
107
+
108
+ ### 4. Start a Conversation
109
+
110
+ `agent.message()` returns an async generator. Consume it with `for await`.
111
+
112
+ ```javascript
113
+ import { Symposium } from 'symposium';
114
+ import MyChatAgent from './MyChatAgent.js';
115
+
116
+ await Symposium.init();
117
+
118
+ const agent = new MyChatAgent();
119
+ await agent.init();
120
+
121
+ for await (const ev of agent.message('Hello, who are you?')) {
122
+ switch (ev.type) {
123
+ case 'chunk':
124
+ process.stdout.write(ev.content); // streamed text delta
125
+ break;
126
+
127
+ case 'output':
128
+ // Final assembled content block for this assistant turn.
129
+ // ev.content is a typed block ({type:'text'|'image', ...}).
130
+ break;
131
+
132
+ case 'reasoning':
133
+ // Reasoning text from models that emit it (o-series, Claude thinking, etc.).
134
+ break;
135
+
136
+ case 'tool':
137
+ console.log(`\n> Using tool: ${ev.name}(${JSON.stringify(ev.arguments)})`);
138
+ break;
139
+
140
+ case 'tool_response':
141
+ if (ev.success)
142
+ console.log(`> ${ev.name} OK: ${JSON.stringify(ev.response)}`);
143
+ else
144
+ console.log(`> ${ev.name} FAILED: ${ev.error}`);
145
+ break;
146
+ }
147
+ }
148
+ ```
149
+
150
+ #### Event reference
151
+
152
+ All events yielded from the generator:
153
+
154
+ | Event | Payload | Notes |
155
+ |---|---|---|
156
+ | `start` | `{thread}` | First yield. |
157
+ | `chunk` | `{content}` | Streamed text delta — concatenate to render incrementally. |
158
+ | `output` | `{content}` | Final assembled content block (`text` / `image`) for one assistant message. |
159
+ | `reasoning` | `{content}` | Reasoning text. |
160
+ | `tool` | `{id, name, arguments}` | Emitted before invoking a tool. |
161
+ | `tool_response` | `{name, success, response?, error?}` | Emitted after the tool returns or throws. |
162
+ | `tools_auth` | `{id, tools}` | Yielded when authorization is required — see below. |
163
+ | `retry` | `{attempt, reason}` | Only when an error occurs *after* at least one chunk has already streamed for the current turn. |
164
+ | `result` | `{value}` | Only when `response_schema` is set — parsed structured answer. |
165
+ | `end` | `{thread}` | Always yielded last, even on throw. |
166
+
167
+ Errors throw out of the generator. There is no `error` event.
168
+
169
+ ## Advanced Usage
170
+
171
+ ### Using Tools
172
+
173
+ Tools allow your agent to interact with the outside world. Extend `Toolkit` and expose one or more tools.
174
+
175
+ ```javascript
176
+ // WeatherToolkit.js
177
+ import { Toolkit } from 'symposium';
178
+
179
+ export default class WeatherToolkit extends Toolkit {
180
+ name = 'WeatherToolkit';
181
+
182
+ async getTools() {
183
+ return [{
184
+ name: 'get_weather',
185
+ description: 'Get the current weather for a specific city',
186
+ parameters: {
187
+ type: 'object',
188
+ properties: {
189
+ city: { type: 'string', description: 'The city name' },
190
+ },
191
+ required: ['city'],
192
+ },
193
+ }];
194
+ }
195
+
196
+ async callTool(thread, name, payload) {
197
+ if (name === 'get_weather')
198
+ return { temperature: '25°C', condition: 'sunny' };
199
+ }
200
+ }
201
+ ```
202
+
203
+ Add the toolkit to your agent:
204
+
205
+ ```javascript
206
+ const agent = new MyChatAgent();
207
+ await agent.addToolkit(new WeatherToolkit());
208
+ await agent.init();
209
+
210
+ for await (const ev of agent.message("What's the weather in Paris?")) {
211
+ // ...
212
+ }
213
+ ```
214
+
215
+ Tools within a single LLM turn are executed sequentially, in the order the model requested them, so the event stream is fully deterministic.
216
+
217
+ ### Tool Authorization
218
+
219
+ To require explicit approval for a tool, override `Toolkit.authorize()`. When it returns `false`, the agent yields a `tools_auth` event and suspends. The consumer resumes the run by sending an `auth` control message through the input channel.
220
+
221
+ ```javascript
222
+ import { Toolkit } from 'symposium';
223
+
224
+ class DangerousToolkit extends Toolkit {
225
+ async authorize(thread, name, payload) {
226
+ return false; // always ask
227
+ }
228
+ async authorizeAlways(thread, name, payload) {
229
+ // Persist an "always approve" decision somewhere (DB, file, etc.).
230
+ }
231
+ // ... getTools / callTool
232
+ }
233
+ ```
234
+
235
+ ```javascript
236
+ import { createInputChannel } from 'symposium';
237
+
238
+ const input = createInputChannel();
239
+ input.send('Please run that risky operation');
240
+
241
+ for await (const ev of agent.message(input)) {
242
+ if (ev.type === 'tools_auth') {
243
+ const decision = await askUser(ev.tools); // 'approve' | 'approve_always' | 'reject'
244
+ input.send({ type: 'auth', id: ev.id, decision });
245
+ }
246
+ }
247
+ ```
248
+
249
+ `'approve_always'` calls `tool.authorizeAlways()` on each pending tool so the decision is persisted. If the input channel closes while a `tools_auth` is pending, the decision is treated as `'reject'` and the run is cancelled. If you call `agent.message()` with a plain string (no channel), any auth request auto-rejects, since there is no way to deliver a decision.
250
+
251
+ ### Streaming Input
252
+
253
+ `agent.message()` accepts three input shapes:
254
+
255
+ 1. a plain `string`,
256
+ 2. a `ContentBlock[]` (e.g. text + image),
257
+ 3. an `AsyncIterable<string | ContentBlock | ContentBlock[] | ControlMessage>`.
258
+
259
+ The first two behave traditionally one user turn, one model loop, done. An async iterable enables **streaming input**: keep pushing messages into the agent at any time.
260
+
261
+ ```javascript
262
+ import { createInputChannel } from 'symposium';
263
+
264
+ const input = createInputChannel();
265
+ input.send('Plan a trip to Rome');
266
+
267
+ // Concurrently, from elsewhere:
268
+ setTimeout(() => input.send('Actually, make it Florence instead'), 2000);
269
+
270
+ for await (const ev of agent.message(input)) {
271
+ if (ev.type === 'chunk')
272
+ process.stdout.write(ev.content);
273
+ }
274
+
275
+ // When you're done, close the channel to end the run:
276
+ input.close();
277
+ ```
278
+
279
+ Behavior with a channel:
280
+
281
+ - The agent drains incoming items into the initial user message and starts the first model turn once content has arrived (or once a `{type:'submit'}` control message lands).
282
+ - New items pushed during a turn are queued and inserted as a new `user` message at the next inter-turn boundary (after the current tool batch finishes — there is no mid-turn cancellation).
283
+ - The run keeps going across multiple turns until the channel closes, or a `{type:'cancel'}` control message is sent.
284
+
285
+ Control messages accepted on the channel:
286
+
287
+ ```js
288
+ { type: 'auth', id, decision: 'approve' | 'approve_always' | 'reject' }
289
+ { type: 'submit' } // closes the initial user-message build-up
290
+ { type: 'cancel' } // gracefully stops the agent loop after the in-flight turn
291
+ ```
292
+
293
+ ### Structured Output
294
+
295
+ `response_schema` is independent of the agent type set it on either a chat or utility agent to constrain the final answer.
296
+
297
+ **Utility agent** — `await agent.message(...)` resolves directly to the parsed value:
298
+
299
+ ```javascript
300
+ // TextExtractorAgent.js
301
+ import { Agent } from 'symposium';
302
+
303
+ export default class TextExtractorAgent extends Agent {
304
+ name = 'TextExtractorAgent';
305
+ type = 'utility';
306
+ response_schema = {
307
+ type: 'object',
308
+ properties: {
309
+ name: { type: 'string' },
310
+ email: { type: 'string' },
311
+ },
312
+ required: ['name', 'email'],
313
+ };
314
+
315
+ async doInitThread(thread) {
316
+ await thread.addMessage('system', 'Extract the name and email from the text.');
317
+ }
318
+ }
319
+
320
+ const extractor = new TextExtractorAgent();
321
+ await extractor.init();
322
+ const result = await extractor.message('My name is John Doe, john.doe@example.com');
323
+ console.log(result); // { name: 'John Doe', email: 'john.doe@example.com' }
324
+ ```
325
+
326
+ **Chat agent with structured final answer** events stream normally; a final `{type:'result', value}` event carries the parsed object just before `end`:
327
+
328
+ ```javascript
329
+ const agent = new MyChatAgent();
330
+ agent.response_schema = {
331
+ type: 'object',
332
+ properties: { city: { type: 'string' } },
333
+ required: ['city'],
334
+ };
335
+ await agent.init();
336
+
337
+ for await (const ev of agent.message('Look up the weather and reply in JSON')) {
338
+ if (ev.type === 'result')
339
+ console.log(ev.value); // { city: '...' }
340
+ }
341
+ ```
342
+
343
+ Internally, structured-output-capable OpenAI models use `response_format: json_schema`; otherwise the agent falls back to a forced function call and parses its arguments.
344
+
345
+ ### Real-time Voice and Transcription
346
+
347
+ Symposium has built-in support for audio transcription and real-time voice sessions, currently powered by OpenAI.
348
+
349
+ ```javascript
350
+ // Inline audio in a message — automatically transcribed if the model doesn't accept audio:
351
+ for await (const ev of agent.message([
352
+ {
353
+ type: 'audio',
354
+ content: { type: 'url', data: 'http://example.com/audio.mp3' },
355
+ },
356
+ ])) {
357
+ // ...
358
+ }
359
+
360
+ // Standalone transcription:
361
+ const text = await Symposium.transcribe(audio_buffer);
362
+
363
+ // Real-time voice session:
364
+ const { response, thread } = await agent.createRealtimeSession();
365
+ const sessionId = response.id;
366
+ const clientSecret = response.client_secret.value;
367
+ ```
368
+
369
+ ### Switching Models
370
+
371
+ ```javascript
372
+ class MyAgent extends Agent {
373
+ default_model = 'claude-3-5-sonnet';
374
+ // ...
375
+ }
376
+
377
+ const thread = await agent.getThread('thread-id');
378
+ await agent.setModel(thread, 'gpt-5');
379
+ ```
380
+
381
+ ### Persistence
382
+
383
+ Provide a storage adapter implementing `init()`, `get(key)`, `set(key, value)`:
384
+
385
+ ```javascript
386
+ import fs from 'fs/promises';
387
+
388
+ class FileStorage {
389
+ async init() {
390
+ await fs.mkdir('./storage', { recursive: true });
391
+ }
392
+ async get(key) {
393
+ try {
394
+ return JSON.parse(await fs.readFile(`./storage/${key}.json`, 'utf-8'));
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+ async set(key, value) {
400
+ await fs.writeFile(`./storage/${key}.json`, JSON.stringify(value, null, 2));
401
+ }
402
+ }
403
+
404
+ await Symposium.init(new FileStorage());
405
+ ```
406
+
407
+ ### Retries
408
+
409
+ The agent retries each turn up to `max_retries` (default 5) times on transport / model errors. The retry strategy is hybrid:
410
+
411
+ - If no `chunk` has been streamed yet for the current turn, the retry is **silent** (no consumer-visible event).
412
+ - If at least one `chunk` has already been streamed, the agent yields `{type:'retry', attempt, reason}` before retrying so consumers can react (e.g. show a spinner, clear partial output).
413
+
414
+ Errors during *tool* execution are not retried — they're surfaced as `{type:'tool_response', success:false, error}`.
415
+
416
+ ## API Reference
417
+
418
+ High-level overview — see source for full details.
419
+
420
+ ### `Agent`
421
+
422
+ - `constructor(options)` — Optional `memory_handler` and `logger`.
423
+ - `init()` — Must be called before use.
424
+ - `addToolkit(toolkit)` — Add a `Toolkit` instance.
425
+ - `message(content, thread)` — Send a message. Returns an async generator for chat agents; resolves to the parsed value (Promise) for utility agents.
426
+ - `getThread(id)` — Retrieve a `Thread` instance.
427
+ - `setModel(thread, modelLabel)` — Change the LLM for a thread.
428
+ - `createRealtimeSession(thread_id, options)` — Create a real-time voice session.
429
+
430
+ ### `Thread`
431
+
432
+ - `constructor(id, agent)`
433
+ - `addMessage(role, content, name, tags)`
434
+ - `setState(state, save)`
435
+ - `loadState()` / `storeState()`
436
+
437
+ ### `Toolkit`
438
+
439
+ A `Toolkit` groups one or more LLM-callable tools. Extend it to publish your own; `MCPServer` is itself a `Toolkit` that exposes whatever tools the remote MCP server lists.
440
+
441
+ - `getTools()` — Abstract. Return an array of tool definitions for the LLM.
442
+ - `callTool(thread, name, payload)` — Abstract. Called when the LLM invokes one of the tools.
443
+ - `authorize(thread, name, payload)` — Optional. Return `false` to require explicit consumer approval (`tools_auth` event).
444
+ - `authorizeAlways(thread, name, payload)` — Optional. Called when the consumer responds with `'approve_always'`.
445
+
446
+ ### `createInputChannel()`
447
+
448
+ Returns `{ send(item), close(), [Symbol.asyncIterator]() }`. Push strings, content blocks, or control messages from anywhere; iterate the channel from `agent.message()`.
449
+
450
+ ### Other Classes
451
+
452
+ - **`ContextHandler`**: Base for managing long-term context.
453
+ - **`Summarizer`**: Utility agent that compresses old messages once a thread crosses a token threshold.
454
+ - **`Logger`**: Simple per-agent logger.
455
+
456
+ ## License
457
+
458
+ ISC