symposium 2.4.2 → 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/CLAUDE.md ADDED
@@ -0,0 +1,101 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project
6
+
7
+ Symposium is a Node.js framework (ES modules, Node ≥18) for building LLM-powered agents. Published as the `symposium` npm package; consumed as a library, not a runnable app. No build or lint tooling. Test suite uses the built-in `node:test` runner — run with `npm test` (script: `node --test "test/**/*.test.js"`). Tests live under `test/` and mock provider SDKs to validate the model layer's streaming behavior without network access.
8
+
9
+ The codebase was refactored to its current shape in the 3.0 release (async-generator API, streaming input channel, real model streaming, hybrid retry, `response_schema`). See `MIGRATION.md` for 2.x → 3.0 side-by-side patterns and `README.md` for consumer-facing docs.
10
+
11
+ ## Architecture
12
+
13
+ The framework is organized around a small set of cooperating classes at the repo root. Understanding the data flow between them is the fastest way to be productive.
14
+
15
+ ### Bootstrapping (`Symposium.js`)
16
+
17
+ `Symposium` is a static registry, not an instance. `Symposium.init(storage?)` dynamically imports every file in `Models/` and calls `loadModel()` on each. Each model class returns a `Map` of model definitions (one provider class can register many model labels — see `Models/OpenAIModel.js` registering `gpt-4o`, `gpt-5`, `gpt-5.x`, etc.). Definitions are keyed by label and stored with `{...modelDef, type, class}` where `class` is the provider instance used for actual API calls.
18
+
19
+ Storage is optional; when present it must implement `init()`, `get(key)`, `set(key, value)`. Threads serialize themselves under `thread-<agent_name>-<thread_id>`.
20
+
21
+ `Symposium.prompt(system, prompt, options)` is a shortcut: it instantiates a bare `Agent`, marks it as `utility`, drains the agent's event generator, and returns the value carried by the final `{type:'result', value}` event.
22
+
23
+ ### Agents (`Agent.js`)
24
+
25
+ The execution core. After Phase 6, `agent.message()` is a non-generator dispatcher: for `chat` agents it returns an async generator (`_messageAsStream`); for `utility` agents it returns a `Promise<value>` (`_messageAsValue` drains the generator internally). `trigger()` and `execute()` remain async generators. Callers do `for await (const ev of agent.message(...))` for chat, and `const value = await agent.message(...)` for utility.
26
+
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
+
29
+ - `chat` — yields the full event set (`start`, `chunk`, `output`, `reasoning`, `tool`, `tool_response`, `tools_auth`, `retry`, `end`). 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
+ - `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
+
32
+ The `execute()` loop is a `while (true)` inside an async generator with a `max_retries` (default 5) safety net wrapped around the entire turn: generate completion (forwarding `text_delta` deltas as `{type:'chunk'}` events and flipping a per-turn `output_yielded` flag) → `afterExecute` hook → yield reasoning → `handleCompletion` → if the assistant called tools, run them via `callTools` and loop; otherwise return. On error, the loop retries up to `max_retries` times per turn (hybrid strategy, Phase 5): silent if no chunk has been yielded yet, otherwise it emits `{type:'retry', attempt, reason}` so the consumer knows. A 1-second backoff is preserved for transport-level 5xx errors. Tool-execution errors are NOT retried — they're caught in `callTool()` and surfaced as `{type:'tool_response', success:false, error}`. Errors throw out of the generator naturally — there is no `error` event. Subclasses customize via `doInitThread`, `getDefaultState`, `beforeExecute(thread)`, `afterExecute(thread, completion)`, `afterHandle(thread, completion, value?)` (note: hooks no longer receive an emitter; the parameter was dropped in v3 Phase 2).
33
+
34
+ Tool authorization is two-phase (Phase 4): `Toolkit.authorize()` runs before the call; if it returns false for any tool in the pending batch, a `{type:'tools_auth', id, tools}` event is yielded and the generator suspends. The consumer resumes by sending `{type:'auth', id, decision}` through the streaming input channel, where `decision ∈ {'approve', 'approve_always', 'reject'}` (`approve_always` calls `toolkit.authorizeAlways()` on each tool in the batch to persist the decision). The background reader routes the auth message into `inputState.pendingAuthResponses` and signals the notifier, so `_awaitAuthDecision(thread, id)` (the notifier-loop helper) wakes and resumes the run. Two implicit-reject rules close the loophole: if the input iterable closes (`readerFinished`) before a decision arrives, the decision is treated as `'reject'` and the agent loop is cancelled; and if `agent.message()` was called with a plain `string` / `ContentBlock[]` (no channel), any auth request auto-rejects since there's no way to deliver a decision.
35
+
36
+ Within a single LLM turn, tools are executed **sequentially** (in `tools_to_call` order), so event ordering is deterministic. The previous parallel `Promise.all` invocation was dropped in Phase 2 to keep the event stream coherent.
37
+
38
+ ### Models (`Models/*.js`, base in `Model.js`)
39
+
40
+ Every provider extends `Model` and implements:
41
+ - `getModels()` — returns `Map<label, definition>` where definition flags capabilities: `tools`, `structured_output`, `audio`, `image_generation`, `tokens` (context window), `tiktoken` (encoding name).
42
+ - `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
+ - Optionally `countTokens(thread)` (used by `Summarizer`).
44
+
45
+ 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).
46
+
47
+ `Model.type` is `'llm'` by default but can also be `'stt'` (transcription, see `OpenAITranscribe.js`) or `'embedding'` (see `OpenAIEmbedding.js`). `Symposium.transcribe()` and `Symposium.embed()` route to whichever model is named in `process.env.TRANSCRIPTION_MODEL` / `EMBEDDING_MODEL`.
48
+
49
+ ### Threads & Messages (`Thread.js`, `Message.js`)
50
+
51
+ A `Thread` owns the message history and a free-form `state` object (which always includes `model`). Messages are `{role, content[], name?, tags[]}`; `content` is always an array of typed parts (`text`, `image`, `audio`, `tool_call`, `tool_result`, `reasoning`). Use `addMessage()` for normal flow and `addPlannedMessage()` + `flushPlannedMessages()` to stage messages that should only land after a tool batch completes.
52
+
53
+ `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.
54
+
55
+ ### Context system (`Context.js`, `Contexts/*.js`, `ContextHandler.js`, `Summarizer.js`, `GetContextToolkit.js`)
56
+
57
+ Two distinct concepts share the word "context":
58
+
59
+ 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.
60
+ 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.
61
+
62
+ ### MCP servers (`MCPServer.js`, `Contexts/MCPResource.js`)
63
+
64
+ `agent.addMCPServer(config)` is a third population path for tools + on_request contexts. It constructs an `MCPServer` (a `Toolkit` subclass wrapping `@modelcontextprotocol/sdk`'s `Client`), connects via the configured transport (`'stdio' | 'sse' | 'http'`), calls `listTools()`, and exposes each remote tool to the LLM under the **prefixed name** `<server>__<tool>` (always — no opt-out — so multiple MCP servers can coexist without tool-name collisions). When `config.resources: true`, it also calls `listResources()` and registers each as an `on_request` `Context` (`MCPResource`), which lazily reads via `client.readResource(uri)` when the LLM asks for it through `get_context`. Returns the `MCPServer` instance; consumers running long-lived chat agents must call `await server.close()` to tear down the stdio child process or HTTP/SSE connection — there is no global `agent.dispose()`. MCP `prompts` and `sampling` are out of scope for v1. Connection is lazy: `MCPServer.init(agent)` (the normal Toolkit init hook) is what triggers `_connect()`, so the SDK is only imported when an MCP server is actually added.
65
+
66
+ ### Event flow (async generator)
67
+
68
+ `Agent.message()` / `trigger()` / `execute()` are async generators. The caller iterates with `for await (const ev of agent.message(...))`. There is no emitter, no listener-attach race, and no `BufferedEventEmitter` (removed in Phase 2). Each event is a discriminated union:
69
+
70
+ | Event | Payload | Notes |
71
+ |---|---|---|
72
+ | `{type:'start', thread}` | thread object | First yield |
73
+ | `{type:'chunk', content}` | text delta string | Streamed during model generation |
74
+ | `{type:'output', content}` | text/image content block | Yielded once the model finishes a message |
75
+ | `{type:'reasoning', content}` | reasoning text | Yielded after assembly, per reasoning block |
76
+ | `{type:'tool', id, name, arguments}` | flattened tool call | Before invoking a tool |
77
+ | `{type:'tool_response', name, success, response?, error?}` | tool result | After tool returns or throws |
78
+ | `{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 |
79
+ | `{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. |
80
+ | `{type:'result', value}` | parsed value | Utility agents only |
81
+ | `{type:'end', thread}` | thread object | Always yielded, even on throw (yielded from a `finally`) |
82
+
83
+ Errors throw out of the generator. There is no `error` event anymore.
84
+
85
+ ## Conventions specific to this repo
86
+
87
+ - ES modules everywhere (`"type": "module"`); always use `import`/`export` and include the `.js` extension in relative imports.
88
+ - Tabs for indentation; trailing commas in multi-line literals.
89
+ - The fallback tool-call prompt in `Model.promptFromTools()` and the realtime session preamble in `Agent.createRealtimeSession()` are written in Italian by design — keep them that way unless explicitly changing the language contract.
90
+ - When adding a new provider, drop the file in `Models/` and `Symposium.init()` will pick it up automatically — there is no registry to update. The class must `extends Model` and `export default`.
91
+ - MCP server lifecycle is owned by the consumer: `addMCPServer()` returns the `MCPServer` and callers must `await server.close()` when done (no global agent teardown in v1).
92
+ - When adding new public exports, update `index.js` (the package entry point).
93
+ - Bump `package.json` version on releases (see recent commits — `add support for gpt-5.4 model in OpenAIModel.js`, `bump version to 2.4.0`).
94
+
95
+ ## Required environment
96
+
97
+ Set in a `.env` file at the consumer's project root (the framework reads `process.env` directly, no dotenv loader is bundled):
98
+
99
+ - `OPENAI_API_KEY` — also required for realtime voice sessions
100
+ - `ANTHROPIC_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY` — per-provider
101
+ - `TRANSCRIPTION_MODEL`, `EMBEDDING_MODEL` — model labels routed to STT/embedding providers
@@ -0,0 +1,19 @@
1
+ import Context from "../Context.js";
2
+
3
+ export default class MCPResource extends Context {
4
+ constructor(server, resource) {
5
+ super();
6
+ this.server = server;
7
+ this.resource = resource;
8
+ this.uri = resource.uri;
9
+ this.title = resource.name || resource.uri;
10
+ }
11
+
12
+ async getTitle() {
13
+ return this.title;
14
+ }
15
+
16
+ async getText() {
17
+ return this.server.readResource(this.uri);
18
+ }
19
+ }
@@ -1,6 +1,6 @@
1
- import Tool from "./Tool.js";
1
+ import Toolkit from "./Toolkit.js";
2
2
 
3
- export default class GetContextTool extends Tool {
3
+ export default class GetContextToolkit extends Toolkit {
4
4
  name = 'get_context';
5
5
 
6
6
  constructor(agent) {
@@ -8,7 +8,7 @@ export default class GetContextTool extends Tool {
8
8
  this.agent = agent;
9
9
  }
10
10
 
11
- async getFunctions() {
11
+ async getTools() {
12
12
  return [
13
13
  {
14
14
  name: 'get_context',
@@ -26,9 +26,9 @@ export default class GetContextTool extends Tool {
26
26
  ];
27
27
  }
28
28
 
29
- async callFunction(thread, name, payload) {
29
+ async callTool(thread, name, payload) {
30
30
  if (name !== 'get_context')
31
- return {error: `Function ${name} not found`};
31
+ return {error: `Tool ${name} not found`};
32
32
 
33
33
  const title = payload.title;
34
34
  const context = this.agent.context.find(c => c.title === title && c.options.type === 'on_request');
@@ -0,0 +1,42 @@
1
+ export function createInputChannel() {
2
+ const queue = [];
3
+ const waiters = [];
4
+ let closed = false;
5
+
6
+ const channel = {
7
+ send(item) {
8
+ if (closed)
9
+ return;
10
+ if (waiters.length)
11
+ waiters.shift().resolve({value: item, done: false});
12
+ else
13
+ queue.push(item);
14
+ },
15
+ close() {
16
+ if (closed)
17
+ return;
18
+ closed = true;
19
+ while (waiters.length)
20
+ waiters.shift().resolve({value: undefined, done: true});
21
+ },
22
+ [Symbol.asyncIterator]() {
23
+ return channel;
24
+ },
25
+ async next() {
26
+ if (queue.length)
27
+ return {value: queue.shift(), done: false};
28
+ if (closed)
29
+ return {value: undefined, done: true};
30
+ return new Promise(resolve => waiters.push({resolve}));
31
+ },
32
+ async return() {
33
+ if (!closed) {
34
+ closed = true;
35
+ while (waiters.length)
36
+ waiters.shift().resolve({value: undefined, done: true});
37
+ }
38
+ return {value: undefined, done: true};
39
+ },
40
+ };
41
+ return channel;
42
+ }
package/MCPServer.js ADDED
@@ -0,0 +1,160 @@
1
+ import Toolkit from "./Toolkit.js";
2
+
3
+ const PREFIX_SEPARATOR = '__';
4
+
5
+ export default class MCPServer extends Toolkit {
6
+ constructor(config = {}) {
7
+ super();
8
+
9
+ if (!config || typeof config !== 'object')
10
+ throw new Error('MCPServer config must be an object');
11
+ if (!config.name || typeof config.name !== 'string')
12
+ throw new Error('MCPServer config.name is required');
13
+
14
+ this.config = config;
15
+ this.serverName = config.name;
16
+ this.name = 'mcp:' + this.serverName;
17
+
18
+ this.client = null;
19
+ this.transport = null;
20
+ this._toolsByPrefixed = new Map();
21
+ }
22
+
23
+ async init(agent) {
24
+ if (this.client)
25
+ return;
26
+
27
+ this.client = await this._connect();
28
+
29
+ const list = await this.client.listTools();
30
+ const tools = (list && list.tools) || [];
31
+ for (const t of tools) {
32
+ const prefixed = this.serverName + PREFIX_SEPARATOR + t.name;
33
+ this._toolsByPrefixed.set(prefixed, {
34
+ rawName: t.name,
35
+ description: t.description || '',
36
+ inputSchema: t.inputSchema || {type: 'object', properties: {}},
37
+ });
38
+ }
39
+ }
40
+
41
+ async _connect() {
42
+ const {Client} = await import('@modelcontextprotocol/sdk/client/index.js');
43
+ this.transport = await this._createTransport();
44
+
45
+ const client = new Client({
46
+ name: 'symposium',
47
+ version: '3.1.0',
48
+ });
49
+
50
+ await client.connect(this.transport);
51
+ return client;
52
+ }
53
+
54
+ async _createTransport() {
55
+ const transport = this.config.transport || 'stdio';
56
+
57
+ if (transport === 'stdio') {
58
+ const {StdioClientTransport} = await import('@modelcontextprotocol/sdk/client/stdio.js');
59
+ if (!this.config.command)
60
+ throw new Error('MCPServer stdio transport requires a command');
61
+ return new StdioClientTransport({
62
+ command: this.config.command,
63
+ args: this.config.args || [],
64
+ env: this.config.env,
65
+ cwd: this.config.cwd,
66
+ });
67
+ }
68
+
69
+ if (transport === 'sse') {
70
+ const {SSEClientTransport} = await import('@modelcontextprotocol/sdk/client/sse.js');
71
+ if (!this.config.url)
72
+ throw new Error('MCPServer sse transport requires a url');
73
+ return new SSEClientTransport(new URL(this.config.url), {
74
+ requestInit: this.config.headers ? {headers: this.config.headers} : undefined,
75
+ });
76
+ }
77
+
78
+ if (transport === 'http') {
79
+ const {StreamableHTTPClientTransport} = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
80
+ if (!this.config.url)
81
+ throw new Error('MCPServer http transport requires a url');
82
+ return new StreamableHTTPClientTransport(new URL(this.config.url), {
83
+ requestInit: this.config.headers ? {headers: this.config.headers} : undefined,
84
+ });
85
+ }
86
+
87
+ if (transport && typeof transport === 'object' && typeof transport.connect === 'function')
88
+ return transport;
89
+
90
+ throw new Error('Unknown MCPServer transport: ' + transport);
91
+ }
92
+
93
+ async getTools() {
94
+ const out = [];
95
+ for (const [prefixed, entry] of this._toolsByPrefixed) {
96
+ out.push({
97
+ name: prefixed,
98
+ description: entry.description,
99
+ parameters: entry.inputSchema,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ async callTool(thread, name, payload) {
106
+ const entry = this._toolsByPrefixed.get(name);
107
+ if (!entry)
108
+ return {error: `MCP tool ${name} not found on server ${this.serverName}`};
109
+
110
+ const result = await this.client.callTool({
111
+ name: entry.rawName,
112
+ arguments: payload || {},
113
+ });
114
+
115
+ if (result && result.isError)
116
+ throw new Error(MCPServer._renderContent(result.content) || 'MCP tool returned an error');
117
+
118
+ return {content: result.content};
119
+ }
120
+
121
+ async listResources() {
122
+ const list = await this.client.listResources();
123
+ return (list && list.resources) || [];
124
+ }
125
+
126
+ async readResource(uri) {
127
+ const result = await this.client.readResource({uri});
128
+ const contents = (result && result.contents) || [];
129
+ const parts = [];
130
+ for (const c of contents) {
131
+ if (typeof c.text === 'string')
132
+ parts.push(c.text);
133
+ else if (typeof c.blob === 'string')
134
+ parts.push(c.blob);
135
+ }
136
+ return parts.join('\n');
137
+ }
138
+
139
+ async close() {
140
+ if (this.client) {
141
+ try {
142
+ await this.client.close();
143
+ } catch {}
144
+ this.client = null;
145
+ }
146
+ this.transport = null;
147
+ this._toolsByPrefixed.clear();
148
+ }
149
+
150
+ static _renderContent(content) {
151
+ if (!Array.isArray(content))
152
+ return '';
153
+ const parts = [];
154
+ for (const c of content) {
155
+ if (c && typeof c.text === 'string')
156
+ parts.push(c.text);
157
+ }
158
+ return parts.join('\n');
159
+ }
160
+ }