symposium 3.0.7 → 3.1.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
@@ -502,23 +502,39 @@ ${context_string}
502
502
  const completion_options = {};
503
503
  if (this.response_schema) {
504
504
  const schema = this.response_schema;
505
- const converted = (schema.type === 'object' && model.structured_output)
506
- ? this.convertFunctionToResponseFormat(JSON.parse(JSON.stringify(schema)))
507
- : null;
508
505
 
509
- if (converted && converted.count <= 100) { // OpenAI does not support structured output if there are more than 100 parameters
510
- completion_options.response_format = {
511
- type: 'json_schema',
512
- name: 'response',
513
- schema: converted.obj,
514
- strict: true,
515
- };
516
- } else {
517
- completion_options.tools = [{
506
+ // When the agent also exposes toolkit tools, the structured answer cannot be
507
+ // forced up-front (that would prevent the model from calling its tools first).
508
+ // Instead we offer an extra `response` tool, alongside the real ones, that the
509
+ // model calls once it is ready to answer. handleCompletion() treats a `response`
510
+ // call as terminal and every other call as a normal tool invocation.
511
+ const has_tools = (await this.getTools()).length > 0;
512
+
513
+ if (has_tools) {
514
+ completion_options.append_tools = [{
518
515
  name: 'response',
516
+ description: 'Call this tool with your final answer, conforming to the required schema, only once you have gathered everything you need. Do not call it before you are ready to give the definitive answer.',
519
517
  parameters: schema,
520
518
  }];
521
- completion_options.force_tool = 'response';
519
+ } else {
520
+ const converted = (schema.type === 'object' && model.structured_output)
521
+ ? this.convertFunctionToResponseFormat(JSON.parse(JSON.stringify(schema)))
522
+ : null;
523
+
524
+ if (converted && converted.count <= 100) { // OpenAI does not support structured output if there are more than 100 parameters
525
+ completion_options.response_format = {
526
+ type: 'json_schema',
527
+ name: 'response',
528
+ schema: converted.obj,
529
+ strict: true,
530
+ };
531
+ } else {
532
+ completion_options.tools = [{
533
+ name: 'response',
534
+ parameters: schema,
535
+ }];
536
+ completion_options.force_tool = 'response';
537
+ }
522
538
  }
523
539
  }
524
540
 
@@ -733,10 +749,18 @@ ${context_string}
733
749
  }
734
750
 
735
751
  if (tool_calls.length) {
736
- if (this.response_schema)
752
+ // With response_schema set, a call to the synthetic `response` tool is the
753
+ // terminal answer; any other call is a real toolkit tool to be executed.
754
+ const response_call = this.response_schema ? tool_calls.find(t => t.name === 'response') : null;
755
+ if (response_call)
756
+ return {type: 'response', value: await this.afterHandle(thread, completion, response_call.arguments)};
757
+
758
+ // response_schema with no toolkit tools: legacy forced-`response` path — the lone
759
+ // tool call carries the answer even if the model named it unexpectedly.
760
+ if (this.response_schema && (await this.getTools()).length === 0)
737
761
  return {type: 'response', value: await this.afterHandle(thread, completion, tool_calls[0].arguments)};
738
762
 
739
- return yield* this.callTools(thread, completion, tool_calls);
763
+ return yield* this.callTools(thread, completion, tool_calls.filter(t => t.name !== 'response'));
740
764
  } else {
741
765
  await thread.storeState();
742
766
  await this.afterHandle(thread, completion);
package/CLAUDE.md CHANGED
@@ -29,6 +29,8 @@ The execution core. After Phase 6, `agent.message()` is a non-generator dispatch
29
29
  - `chat` — yields the full event set (`start`, `chunk`, `output`, `reasoning`, `tool`, `tool_response`, `tools_auth`, `retry`, `turn_end`, `end`). `turn_end` fires after each completed turn (after the LLM finishes a response with no more tool calls), regardless of streaming vs single-shot mode — consumers use it to detect turn boundaries within a streaming run, where `start`/`end` only bracket the whole run. If `response_schema` is set, the final assistant message is parsed against it and a `{type:'result', value}` event is yielded before `end`; the run terminates after the schema-conforming answer (no further turns).
30
30
  - `utility` — `await agent.message(...)` resolves to the parsed value. With no `response_schema`, the value is the raw assistant text. With `response_schema` set, the value is the parsed JSON object: structured-output-capable models with ≤100 properties use `response_format: json_schema`; otherwise the agent falls back to a forced tool call (synthetic name `'response'`) and parses its arguments. See `convertFunctionToResponseFormat()` for the OpenAI-specific schema constraints (all properties forced to required, `additionalProperties: false`). The legacy `agent.utility = {type, function, parameters}` shape was removed in Phase 6 — use `response_schema` (a raw JSON schema) instead. `response_schema` is independent of `type` and works on chat agents too.
31
31
 
32
+ **`response_schema` + toolkits (tool use *then* structured output).** When an agent has BOTH `response_schema` and toolkit tools, the schema is NOT forced up-front (forcing would block the model from calling its tools first). Instead `execute()` advertises an extra `response` tool *alongside* the real ones via `completion_options.append_tools` (merged by `Model.parseOptions` — additive, unlike `options.tools` which replaces). The model uses its real tools across turns and, when ready, calls `response` with the final schema-conforming answer; `handleCompletion` treats a `response` call as terminal (→ `afterHandle` → `{type:'result', value}`) and every other call as a normal tool invocation (`response` is a **reserved tool name** when `response_schema` is set). The forced-tool / `response_format` paths above apply only when the agent has **no** toolkits, so existing tool-less utility agents are unchanged. This is what lets a `chat` agent self-search and still return a validated object (consume it by draining for the `result` event).
33
+
32
34
  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
35
 
34
36
  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.
package/Model.js CHANGED
@@ -32,6 +32,11 @@ export default class Model {
32
32
  if (options.tools !== null)
33
33
  tools = options.tools;
34
34
 
35
+ // Additive tools (e.g. the synthetic `response` tool for schema output) ride
36
+ // alongside the toolkit tools instead of replacing them.
37
+ if (options.append_tools)
38
+ tools = [...tools, ...options.append_tools];
39
+
35
40
  if (options.force_tool && !tools.find(t => t.name === options.force_tool))
36
41
  throw new Error('Tool ' + options.force_tool + ' not found.');
37
42
 
package/README.md CHANGED
@@ -343,6 +343,8 @@ for await (const ev of agent.message('Look up the weather and reply in JSON')) {
343
343
 
344
344
  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.
345
345
 
346
+ **Combining `response_schema` with toolkits.** If the agent also has toolkit tools, the schema is not forced up-front (that would stop the model from calling its tools). Instead an extra `response` tool is offered alongside the real ones: the model calls its tools across turns and, when ready, calls `response` with the final schema-conforming answer, which surfaces as the `{type:'result', value}` event. This lets a chat agent search/act on its own and still return a validated object. `response` is a reserved tool name while `response_schema` is set, so don't expose a toolkit tool by that name.
347
+
346
348
  ### Real-time Voice and Transcription
347
349
 
348
350
  Symposium has built-in support for audio transcription and real-time voice sessions, currently powered by OpenAI.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "symposium",
4
- "version": "3.0.7",
4
+ "version": "3.1.0",
5
5
  "description": "Agents",
6
6
  "main": "index.js",
7
7
  "author": "Domenico Giambra",
@@ -284,6 +284,80 @@ test('chat agent with response_schema yields normal events plus final result eve
284
284
  assert.deepEqual(events[2].value, {city: 'Rome'});
285
285
  });
286
286
 
287
+ // ────────────────────────────────────────────────────────────────────────────────
288
+ // response_schema + toolkit: the model may call its real tools across turns and then
289
+ // deliver the structured answer through the synthetic `response` tool. Verifies the
290
+ // two are NOT mutually exclusive (regression guard for the schema-vs-tools fix).
291
+ // ────────────────────────────────────────────────────────────────────────────────
292
+ test('chat agent with response_schema + toolkit: runs a tool then `response` yields the result', async () => {
293
+ const label = 'fake-chat-schema-tools';
294
+ await Symposium.loadModel(new ScriptedModel(label, [
295
+ {
296
+ deltas: [],
297
+ messages: [new Message('assistant', [
298
+ {type: 'tool_call', content: [{id: 'call_s', name: 'search', arguments: {q: 'Rome'}}]},
299
+ ])],
300
+ },
301
+ {
302
+ deltas: [],
303
+ messages: [new Message('assistant', [
304
+ {type: 'tool_call', content: [{id: 'call_r', name: 'response', arguments: {unique_code: 'IT-05-087', confidence: 0.9}}]},
305
+ ])],
306
+ },
307
+ ]));
308
+
309
+ let searched = null;
310
+ class SearchTool extends Toolkit {
311
+ name = 'search';
312
+ async getTools() {
313
+ return [{name: 'search', description: 'search the master tree', parameters: {type: 'object', properties: {q: {type: 'string'}}}}];
314
+ }
315
+ async callTool(_thread, _name, payload) {
316
+ searched = payload.q;
317
+ return {results: [{unique_code: 'IT-05-087'}]};
318
+ }
319
+ }
320
+
321
+ const agent = new Agent();
322
+ agent.default_model = label;
323
+ agent.response_schema = {
324
+ type: 'object',
325
+ properties: {unique_code: {type: 'string'}, confidence: {type: 'number'}},
326
+ required: ['unique_code', 'confidence'],
327
+ };
328
+ await agent.addToolkit(new SearchTool());
329
+ await agent.init();
330
+
331
+ const thread = await makeThread(agent, label);
332
+
333
+ const events = [];
334
+ for await (const ev of agent.message('match Rome', thread))
335
+ events.push(ev);
336
+
337
+ const types = events.map(e => e.type);
338
+ // the real `search` tool ran (tool + tool_response), THEN `response` carried the answer
339
+ assert.deepEqual(types, ['start', 'tool', 'tool_response', 'result', 'end']);
340
+ assert.equal(searched, 'Rome');
341
+ assert.equal(events[1].name, 'search');
342
+ assert.deepEqual(events.find(e => e.type === 'result').value, {unique_code: 'IT-05-087', confidence: 0.9});
343
+ });
344
+
345
+ // ────────────────────────────────────────────────────────────────────────────────
346
+ // parseOptions: append_tools augments the toolkit tools rather than replacing them,
347
+ // so the synthetic `response` tool coexists with the real ones.
348
+ // ────────────────────────────────────────────────────────────────────────────────
349
+ test('Model.parseOptions merges append_tools alongside the toolkit tools', () => {
350
+ const model = new Model();
351
+ const toolkitTools = [{name: 'search'}, {name: 'get_chain'}];
352
+
353
+ const {tools} = model.parseOptions({append_tools: [{name: 'response'}]}, toolkitTools);
354
+ assert.deepEqual(tools.map(t => t.name), ['search', 'get_chain', 'response']);
355
+
356
+ // force_tool validation still sees the appended tool
357
+ const {tools: forced} = model.parseOptions({append_tools: [{name: 'response'}], force_tool: 'response'}, toolkitTools);
358
+ assert.ok(forced.find(t => t.name === 'response'));
359
+ });
360
+
287
361
  // ────────────────────────────────────────────────────────────────────────────────
288
362
  // Tool authorization: tool.authorize() returns false, generator suspends until an
289
363
  // {type:'auth'} control message arrives on the input channel.