xo-harness 0.1.1 → 0.2.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.
Files changed (65) hide show
  1. package/README.md +20 -0
  2. package/dist/internal/harness/index.d.ts +3 -0
  3. package/dist/internal/harness/index.js +3 -0
  4. package/dist/internal/harness/message.d.ts +1 -2
  5. package/dist/internal/harness/message.js +45 -19
  6. package/dist/internal/harness/report-diff.d.ts +46 -0
  7. package/dist/internal/harness/report-diff.js +62 -0
  8. package/dist/internal/harness/report.d.ts +2 -0
  9. package/dist/internal/harness/report.js +10 -0
  10. package/dist/internal/harness/shadow.d.ts +9 -2
  11. package/dist/internal/harness/shadow.js +5 -1
  12. package/dist/internal/harness/task-supervisor.js +21 -6
  13. package/dist/internal/harness/tool-delivery.d.ts +17 -0
  14. package/dist/internal/harness/tool-delivery.js +53 -0
  15. package/dist/internal/harness/tool-policy.d.ts +52 -0
  16. package/dist/internal/harness/tool-policy.js +22 -0
  17. package/dist/internal/harness/tool-runtime.d.ts +4 -0
  18. package/dist/internal/harness/tool-runtime.js +82 -1
  19. package/dist/internal/harness/tools.d.ts +22 -1
  20. package/dist/internal/harness/voice-session.d.ts +21 -2
  21. package/dist/internal/harness/voice-session.js +65 -4
  22. package/dist/internal/harness/xo.d.ts +13 -0
  23. package/dist/internal/harness/xo.js +8 -0
  24. package/dist/internal/protocol/events.d.ts +81 -0
  25. package/dist/internal/protocol/events.js +16 -1
  26. package/dist/internal/protocol/provider.d.ts +92 -2
  27. package/dist/internal/protocol/provider.js +59 -3
  28. package/dist/internal/provider/contract.d.ts +3 -1
  29. package/dist/internal/provider/grok-voice.d.ts +1 -0
  30. package/dist/internal/provider/grok-voice.js +4 -0
  31. package/dist/internal/provider/openai-realtime.d.ts +10 -1
  32. package/dist/internal/provider/openai-realtime.js +26 -1
  33. package/dist/internal/provider/realtime-session.d.ts +8 -1
  34. package/dist/internal/provider/realtime-session.js +323 -34
  35. package/dist/internal/provider-fake/index.d.ts +3 -1
  36. package/dist/internal/provider-fake/index.js +5 -1
  37. package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -3
  38. package/dist/internal/provider-fake/replay-voice-provider.js +18 -4
  39. package/dist/internal/provider-fake/scripted-voice-provider.d.ts +5 -1
  40. package/dist/internal/provider-fake/scripted-voice-provider.js +4 -1
  41. package/dist/internal/skills/index.d.ts +2 -0
  42. package/dist/internal/skills/index.js +2 -0
  43. package/dist/internal/skills/node.d.ts +7 -0
  44. package/dist/internal/skills/node.js +99 -0
  45. package/dist/internal/skills/skill.d.ts +17 -0
  46. package/dist/internal/skills/skill.js +42 -0
  47. package/dist/internal/skills/tools.d.ts +7 -0
  48. package/dist/internal/skills/tools.js +82 -0
  49. package/dist/internal/storage/memory.d.ts +2 -0
  50. package/dist/internal/storage/memory.js +1 -0
  51. package/dist/internal/tools-openai/delegate-conversation.d.ts +12 -0
  52. package/dist/internal/tools-openai/delegate-conversation.js +66 -0
  53. package/dist/internal/tools-openai/index.d.ts +24 -0
  54. package/dist/internal/tools-openai/index.js +119 -0
  55. package/dist/internal/tools-openai/responses.d.ts +31 -0
  56. package/dist/internal/tools-openai/responses.js +146 -0
  57. package/dist/skills-node.d.ts +1 -0
  58. package/dist/skills-node.js +1 -0
  59. package/dist/skills.d.ts +1 -0
  60. package/dist/skills.js +1 -0
  61. package/dist/storage-memory.d.ts +1 -0
  62. package/dist/storage-memory.js +1 -0
  63. package/dist/tools-openai.d.ts +1 -0
  64. package/dist/tools-openai.js +1 -0
  65. package/package.json +18 -1
@@ -1,4 +1,11 @@
1
1
  import { ToolOutcomeSchema, } from "../protocol/index.js";
2
+ import { z } from "zod";
3
+ import { boundToolOutcomeForDelivery } from "./tool-delivery.js";
4
+ import { toolDenialError } from "./tool-policy.js";
5
+ const ToolAdmissionSchema = z.discriminatedUnion("decision", [
6
+ z.object({ decision: z.literal("allow") }),
7
+ z.object({ decision: z.literal("deny"), reason: z.string().trim().min(1) }),
8
+ ]);
2
9
  export class ToolRuntime {
3
10
  #tools;
4
11
  #tasks;
@@ -6,6 +13,9 @@ export class ToolRuntime {
6
13
  #resultSink;
7
14
  #signal;
8
15
  #flush;
16
+ #policy;
17
+ #history;
18
+ #maxResultBytes;
9
19
  #inFlight = new Set();
10
20
  #terminal = new Set();
11
21
  constructor(options) {
@@ -15,14 +25,27 @@ export class ToolRuntime {
15
25
  this.#resultSink = options.resultSink;
16
26
  this.#signal = options.signal;
17
27
  this.#flush = options.flush;
28
+ this.#policy = options.policy;
29
+ this.#history = options.history;
30
+ this.#maxResultBytes = options.maxResultBytes;
18
31
  }
19
32
  async execute(call) {
20
33
  if (this.#inFlight.has(call.callId) || this.#terminal.has(call.callId))
21
34
  return;
22
35
  this.#inFlight.add(call.callId);
23
36
  try {
37
+ const admission = await this.#admit(call);
38
+ // Cancellation can follow a resolved admission before this continuation runs.
39
+ if (admission === undefined || this.#signal.aborted)
40
+ return;
41
+ if (admission.decision === "deny") {
42
+ await this.#settleDenied(call, admission.reason);
43
+ return;
44
+ }
24
45
  await this.#record({ type: "tool.started", callId: call.callId, name: call.name });
25
46
  await this.#flush();
47
+ if (this.#signal.aborted)
48
+ return;
26
49
  const tool = this.#tools.get(call.name);
27
50
  if (!tool) {
28
51
  await this.#settleFailure(call.callId, `Tool not found: ${call.name}`);
@@ -31,6 +54,8 @@ export class ToolRuntime {
31
54
  let outcome;
32
55
  try {
33
56
  const input = await tool.input.parseAsync(call.arguments);
57
+ if (this.#signal.aborted)
58
+ return;
34
59
  outcome = ToolOutcomeSchema.parse(await this.#run(tool, input, call.callId));
35
60
  }
36
61
  catch (error) {
@@ -43,9 +68,46 @@ export class ToolRuntime {
43
68
  this.#inFlight.delete(call.callId);
44
69
  }
45
70
  }
71
+ async #admit(call) {
72
+ if (this.#signal.aborted)
73
+ return undefined;
74
+ const annotations = this.#tools.get(call.name)?.annotations;
75
+ const decision = (async () => {
76
+ try {
77
+ return ToolAdmissionSchema.parse(await this.#policy.admit({
78
+ call,
79
+ ...(annotations === undefined ? {} : { annotations }),
80
+ signal: this.#signal,
81
+ history: this.#history,
82
+ }));
83
+ }
84
+ catch (error) {
85
+ // A broken policy must not grant access; the gate fails closed with the reason.
86
+ return { decision: "deny", reason: `Tool policy error: ${errorMessage(error)}` };
87
+ }
88
+ })();
89
+ // Session close must never wait on a stalled policy (e.g. an unanswered approval
90
+ // round-trip). Unlike a running tool body, nothing durable has begun yet, so the
91
+ // aborted call is simply left unterminated — the same shape a lost process leaves.
92
+ return raceWithAbort(decision, this.#signal);
93
+ }
94
+ async #settleDenied(call, reason) {
95
+ this.#assertNotTerminal(call.callId);
96
+ this.#terminal.add(call.callId);
97
+ try {
98
+ await this.#record({ type: "tool.denied", callId: call.callId, name: call.name, reason });
99
+ }
100
+ catch (error) {
101
+ this.#terminal.delete(call.callId);
102
+ throw error;
103
+ }
104
+ await this.#flush();
105
+ await this.#deliver(call.callId, { type: "failed", error: toolDenialError(reason) });
106
+ }
46
107
  async #run(tool, input, callId) {
47
108
  return tool.execute(input, {
48
109
  signal: this.#signal,
110
+ maxResultBytes: this.#maxResultBytes,
49
111
  tasks: this.#tasks.forCall(callId),
50
112
  report: async (update) => {
51
113
  await this.#record({ type: "tool.progress", callId, update });
@@ -80,7 +142,11 @@ export class ToolRuntime {
80
142
  }
81
143
  async #deliver(callId, outcome) {
82
144
  try {
83
- await this.#resultSink.deliver({ callId, outcome });
145
+ // The log keeps the full outcome; only the provider-bound copy is bounded.
146
+ await this.#resultSink.deliver({
147
+ callId,
148
+ outcome: boundToolOutcomeForDelivery(outcome, this.#maxResultBytes),
149
+ });
84
150
  }
85
151
  catch (error) {
86
152
  await this.#record({ type: "tool.delivery_failed", callId, error: errorMessage(error) });
@@ -100,6 +166,21 @@ export class ToolRuntime {
100
166
  }
101
167
  }
102
168
  }
169
+ function raceWithAbort(work, signal) {
170
+ if (signal.aborted)
171
+ return Promise.resolve(undefined);
172
+ return new Promise((resolve, reject) => {
173
+ const onAbort = () => resolve(undefined);
174
+ signal.addEventListener("abort", onAbort, { once: true });
175
+ work.then((value) => {
176
+ signal.removeEventListener("abort", onAbort);
177
+ resolve(value);
178
+ }, (error) => {
179
+ signal.removeEventListener("abort", onAbort);
180
+ reject(error instanceof Error ? error : new Error(String(error)));
181
+ });
182
+ });
183
+ }
103
184
  function errorMessage(error) {
104
185
  return error instanceof Error ? error.message : String(error);
105
186
  }
@@ -1,22 +1,43 @@
1
1
  import { type JsonValue, type ProviderToolDefinition, type ToolOutcome } from "../protocol/index.js";
2
2
  import { z } from "zod";
3
3
  export interface BackgroundTaskContext {
4
+ /** Runners must release their resources and settle promptly when aborted; close waits for settlement. */
4
5
  readonly signal: AbortSignal;
5
6
  report(update: JsonValue): Promise<void>;
6
7
  }
7
8
  export type BackgroundTaskRunner = (context: BackgroundTaskContext) => Promise<JsonValue>;
9
+ export interface BackgroundTaskOptions {
10
+ /** Release resources reserved before start if the task is cancelled before its runner activates. */
11
+ onPendingCancel?: () => void;
12
+ }
8
13
  export interface BackgroundTaskLauncher {
9
- start(run: BackgroundTaskRunner): Promise<string>;
14
+ start(run: BackgroundTaskRunner, options?: BackgroundTaskOptions): Promise<string>;
10
15
  }
11
16
  export interface ToolExecutionContext {
17
+ /** Session lifetime signal. Executions must settle on abort; arbitrary JavaScript cannot be forcibly stopped. */
12
18
  readonly signal: AbortSignal;
19
+ /** JSON payload budget for provider delivery; tools can use it to size recoverable pages. */
20
+ readonly maxResultBytes?: number;
13
21
  readonly tasks: BackgroundTaskLauncher;
14
22
  report(update: JsonValue): Promise<void>;
15
23
  }
24
+ /**
25
+ * Behavior hints consumed by tool admission policies; never sent to providers. The two
26
+ * hints a permission decision actually turns on, named as in MCP `ToolAnnotations` so
27
+ * MCP-discovered tools map onto them verbatim. Hints, not guarantees — a policy should
28
+ * treat a missing hint conservatively (absent `readOnlyHint` means "may mutate").
29
+ */
30
+ export interface ToolAnnotations {
31
+ /** The tool observes without mutating its environment. */
32
+ readonly readOnlyHint?: boolean;
33
+ /** The tool may perform destructive updates (sends, deletes, spends). */
34
+ readonly destructiveHint?: boolean;
35
+ }
16
36
  export interface VoiceTool<Schema extends z.ZodType = z.ZodType> {
17
37
  readonly name: string;
18
38
  readonly description: string;
19
39
  readonly input: Schema;
40
+ readonly annotations?: ToolAnnotations;
20
41
  execute(input: z.output<Schema>, context: ToolExecutionContext): ToolOutcome | Promise<ToolOutcome>;
21
42
  }
22
43
  export declare function defineTool<const Schema extends z.ZodType>(tool: VoiceTool<Schema>): VoiceTool<Schema>;
@@ -1,7 +1,8 @@
1
- import { type AgentInput, type AudioChunk, type HarnessEvent, type OutputModality, type PlayoutProgress } from "../protocol/index.js";
1
+ import { type AgentInput, type AssistantTurnRequest, type AudioChunk, type HarnessEvent, type OutputModality, type PlayoutProgress } from "../protocol/index.js";
2
2
  import type { VoiceProvider } from "../provider/index.js";
3
3
  import type { EventStore } from "../storage/index.js";
4
- import type { ToolRegistry } from "./tools.js";
4
+ import { type ToolPolicy } from "./tool-policy.js";
5
+ import { ToolRegistry } from "./tools.js";
5
6
  export interface CreateVoiceSessionOptions {
6
7
  sessionId: string;
7
8
  provider: VoiceProvider;
@@ -9,6 +10,13 @@ export interface CreateVoiceSessionOptions {
9
10
  signal?: AbortSignal;
10
11
  store: EventStore;
11
12
  tools: ToolRegistry;
13
+ /** Admits or refuses model-requested tool calls before execution. Default: allow all. */
14
+ toolPolicy?: ToolPolicy;
15
+ /**
16
+ * UTF-8 JSON byte budget for a completed value or error string, excluding provider envelopes.
17
+ * An integer of at least 128, or Infinity (the default). The log keeps the full outcome.
18
+ */
19
+ maxToolResultBytes?: number;
12
20
  instructions?: string;
13
21
  /** What the model may produce this session; ["text"] disables audio output. Default: audio. */
14
22
  outputModalities?: readonly OutputModality[];
@@ -30,6 +38,17 @@ export declare class VoiceSession {
30
38
  * the collapsed text is what goes over the wire (media-native providers wire later).
31
39
  */
32
40
  sendText(input: AgentInput): Promise<void>;
41
+ /**
42
+ * Resolves only after the provider has acknowledged its session configuration.
43
+ * Hosts can apply their own timeout policy around this promise.
44
+ */
45
+ waitUntilReady(): Promise<void>;
46
+ /**
47
+ * Begins an assistant turn from existing provider context. The request waits for
48
+ * provider.ready, is recorded without its potentially private guidance, and never
49
+ * creates a synthetic user message in the session log.
50
+ */
51
+ requestAssistantTurn(request?: AssistantTurnRequest): Promise<void>;
33
52
  reportPlayout(progress: PlayoutProgress): Promise<void>;
34
53
  close(reason?: string): Promise<void>;
35
54
  }
@@ -1,7 +1,10 @@
1
- import { AgentInputSchema, AudioChunkSchema, agentInputToText, MessageInputTextSchema, PlayoutProgressSchema, ProviderEventSchema, } from "../protocol/index.js";
1
+ import { AgentInputSchema, AssistantTurnRequestSchema, AudioChunkSchema, agentInputToText, MessageInputTextSchema, PlayoutProgressSchema, ProviderEventSchema, } from "../protocol/index.js";
2
2
  import { ReplayEventStream } from "./event-stream.js";
3
3
  import { TaskSupervisor } from "./task-supervisor.js";
4
+ import { DEFAULT_MAX_TOOL_RESULT_BYTES, validateMaxToolResultBytes } from "./tool-delivery.js";
5
+ import { allowAllToolPolicy } from "./tool-policy.js";
4
6
  import { ToolRuntime } from "./tool-runtime.js";
7
+ import { ToolRegistry } from "./tools.js";
5
8
  export class VoiceSession {
6
9
  id;
7
10
  #providerSession;
@@ -13,6 +16,9 @@ export class VoiceSession {
13
16
  #pendingRecords = new Set();
14
17
  #tasks;
15
18
  #toolRuntime;
19
+ #providerReadiness;
20
+ #settleProviderReadiness = () => undefined;
21
+ #providerReadinessSettled = false;
16
22
  #detachOwnerAbort;
17
23
  #providerPump;
18
24
  #closePromise;
@@ -23,6 +29,9 @@ export class VoiceSession {
23
29
  this.#store = options.store;
24
30
  this.#startedAt = startedAt;
25
31
  this.#events.publish(startedEvent);
32
+ this.#providerReadiness = new Promise((resolve) => {
33
+ this.#settleProviderReadiness = resolve;
34
+ });
26
35
  this.#tasks = new TaskSupervisor({
27
36
  record: (event) => this.#record(event),
28
37
  sendContext: (event) => this.#providerSession.sendContext(event),
@@ -41,9 +50,17 @@ export class VoiceSession {
41
50
  },
42
51
  signal: this.#controller.signal,
43
52
  flush: () => this.#store.flush(this.id),
53
+ policy: options.toolPolicy ?? allowAllToolPolicy,
54
+ history: () => this.#store.list(this.id),
55
+ maxResultBytes: options.maxToolResultBytes ?? DEFAULT_MAX_TOOL_RESULT_BYTES,
44
56
  });
45
57
  }
46
58
  static async create(options) {
59
+ const maxToolResultBytes = options.maxToolResultBytes ?? DEFAULT_MAX_TOOL_RESULT_BYTES;
60
+ validateMaxToolResultBytes(maxToolResultBytes);
61
+ // Registrations made after startup begins belong to future sessions. Execution
62
+ // must use the same catalog that this session advertises to its provider.
63
+ const tools = new ToolRegistry(options.tools.list());
47
64
  const existingEvents = await options.store.list(options.sessionId);
48
65
  if (existingEvents.length > 0) {
49
66
  throw new Error(`Session id already exists: ${options.sessionId}`);
@@ -74,7 +91,7 @@ export class VoiceSession {
74
91
  providerSession = await options.provider.createSession({
75
92
  sessionId: options.sessionId,
76
93
  signal: providerController.signal,
77
- tools: options.tools.definitions(),
94
+ tools: tools.definitions(),
78
95
  ...(options.instructions === undefined ? {} : { instructions: options.instructions }),
79
96
  ...(options.outputModalities === undefined ? {} : { outputModalities: options.outputModalities }),
80
97
  });
@@ -91,7 +108,7 @@ export class VoiceSession {
91
108
  await recordFailedCreation(options.store, options.sessionId, startedAt, "owner_revoked");
92
109
  throw options.signal.reason ?? new Error("Session owner revoked during startup");
93
110
  }
94
- const session = new VoiceSession(options, providerSession, startedAt, startedEvent);
111
+ const session = new VoiceSession({ ...options, tools, maxToolResultBytes }, providerSession, startedAt, startedEvent);
95
112
  session.#controller.signal.addEventListener("abort", () => providerController.abort(session.#controller.signal.reason), {
96
113
  once: true,
97
114
  });
@@ -149,6 +166,28 @@ export class VoiceSession {
149
166
  });
150
167
  await this.#providerSession.sendText(text);
151
168
  }
169
+ /**
170
+ * Resolves only after the provider has acknowledged its session configuration.
171
+ * Hosts can apply their own timeout policy around this promise.
172
+ */
173
+ async waitUntilReady() {
174
+ this.#assertOpen();
175
+ if (!(await this.#providerReadiness)) {
176
+ throw new Error("Voice provider closed before it became ready");
177
+ }
178
+ this.#assertOpen();
179
+ }
180
+ /**
181
+ * Begins an assistant turn from existing provider context. The request waits for
182
+ * provider.ready, is recorded without its potentially private guidance, and never
183
+ * creates a synthetic user message in the session log.
184
+ */
185
+ async requestAssistantTurn(request = {}) {
186
+ const validated = AssistantTurnRequestSchema.parse(request);
187
+ await this.waitUntilReady();
188
+ await this.#record({ type: "assistant.turn.requested" });
189
+ await this.#providerSession.requestAssistantTurn(validated);
190
+ }
152
191
  async reportPlayout(progress) {
153
192
  this.#assertOpen();
154
193
  const validated = PlayoutProgressSchema.parse(progress);
@@ -192,6 +231,7 @@ export class VoiceSession {
192
231
  switch (event.type) {
193
232
  case "ready":
194
233
  await this.#record({ type: "provider.ready" });
234
+ this.#settleReadiness(true);
195
235
  break;
196
236
  case "audio.output":
197
237
  // Pipelined: the append is enqueued (log position reserved) without stalling
@@ -199,6 +239,9 @@ export class VoiceSession {
199
239
  // error behind per-chunk persistence.
200
240
  this.#trackRecord(this.#record({ type: "audio.output", chunk: event.chunk }));
201
241
  break;
242
+ case "audio.interrupted":
243
+ this.#trackRecord(this.#record({ type: "audio.interrupted", streamId: event.streamId }));
244
+ break;
202
245
  case "transcript":
203
246
  await this.#record({
204
247
  type: "transcript",
@@ -219,6 +262,12 @@ export class VoiceSession {
219
262
  await this.#record({ type: "tool.requested", call: event.call });
220
263
  this.#trackToolExecution(this.#toolRuntime.execute(event.call));
221
264
  break;
265
+ case "response.state":
266
+ await this.#record(event);
267
+ break;
268
+ case "diagnostic":
269
+ await this.#record({ type: "provider.diagnostic", diagnostic: event.diagnostic });
270
+ break;
222
271
  case "usage": {
223
272
  const { type: _type, ...usage } = event;
224
273
  await this.#record({ type: "usage", ...usage });
@@ -230,10 +279,13 @@ export class VoiceSession {
230
279
  message: event.message,
231
280
  recoverable: event.recoverable,
232
281
  });
233
- if (!event.recoverable)
282
+ if (!event.recoverable) {
283
+ this.#settleReadiness(false);
234
284
  this.#closeFromInternal("provider_error");
285
+ }
235
286
  break;
236
287
  case "closed":
288
+ this.#settleReadiness(false);
237
289
  await this.#record({ type: "provider.closed", reason: event.reason });
238
290
  this.#closeFromInternal("provider_closed");
239
291
  break;
@@ -249,6 +301,15 @@ export class VoiceSession {
249
301
  });
250
302
  }
251
303
  }
304
+ finally {
305
+ this.#settleReadiness(false);
306
+ }
307
+ }
308
+ #settleReadiness(ready) {
309
+ if (this.#providerReadinessSettled)
310
+ return;
311
+ this.#providerReadinessSettled = true;
312
+ this.#settleProviderReadiness(ready);
252
313
  }
253
314
  #trackToolExecution(execution) {
254
315
  this.#toolExecutions.add(execution);
@@ -1,17 +1,29 @@
1
1
  import type { OutputModality } from "../protocol/index.js";
2
2
  import type { VoiceProvider } from "../provider/index.js";
3
3
  import type { EventStore } from "../storage/index.js";
4
+ import type { ToolPolicy } from "./tool-policy.js";
4
5
  import { ToolRegistry, type VoiceTool } from "./tools.js";
5
6
  import { VoiceSession } from "./voice-session.js";
6
7
  export interface XOOptions {
7
8
  store: EventStore;
8
9
  tools?: readonly VoiceTool[];
10
+ /** Admits or refuses model-requested tool calls before execution. Default: allow all. */
11
+ toolPolicy?: ToolPolicy;
12
+ /**
13
+ * UTF-8 JSON byte budget for a completed value or error string, excluding provider envelopes.
14
+ * An integer of at least 128, or Infinity (the default). The log keeps the full outcome.
15
+ */
16
+ maxToolResultBytes?: number;
9
17
  }
10
18
  export interface StartSessionOptions {
11
19
  provider: VoiceProvider;
12
20
  /** Cancels provider startup and closes the live session when its owner is revoked. */
13
21
  signal?: AbortSignal;
14
22
  sessionId?: string;
23
+ /** Overrides the harness-level tool policy for this session. */
24
+ toolPolicy?: ToolPolicy;
25
+ /** Overrides the harness-level delivery budget for this session. */
26
+ maxToolResultBytes?: number;
15
27
  instructions?: string;
16
28
  /** What the model may produce this session; ["text"] disables audio output. Default: audio. */
17
29
  outputModalities?: readonly OutputModality[];
@@ -19,6 +31,7 @@ export interface StartSessionOptions {
19
31
  maxDurationMs?: number;
20
32
  }
21
33
  export declare class XO {
34
+ #private;
22
35
  readonly store: EventStore;
23
36
  readonly tools: ToolRegistry;
24
37
  constructor(options: XOOptions);
@@ -3,20 +3,28 @@ import { VoiceSession } from "./voice-session.js";
3
3
  export class XO {
4
4
  store;
5
5
  tools;
6
+ #toolPolicy;
7
+ #maxToolResultBytes;
6
8
  constructor(options) {
7
9
  this.store = options.store;
8
10
  this.tools = new ToolRegistry(options.tools);
11
+ this.#toolPolicy = options.toolPolicy;
12
+ this.#maxToolResultBytes = options.maxToolResultBytes;
9
13
  }
10
14
  registerTool(tool) {
11
15
  this.tools.register(tool);
12
16
  }
13
17
  startSession(options) {
18
+ const toolPolicy = options.toolPolicy ?? this.#toolPolicy;
19
+ const maxToolResultBytes = options.maxToolResultBytes ?? this.#maxToolResultBytes;
14
20
  return VoiceSession.create({
15
21
  sessionId: options.sessionId ?? crypto.randomUUID(),
16
22
  provider: options.provider,
17
23
  ...(options.signal === undefined ? {} : { signal: options.signal }),
18
24
  store: this.store,
19
25
  tools: this.tools,
26
+ ...(toolPolicy === undefined ? {} : { toolPolicy }),
27
+ ...(maxToolResultBytes === undefined ? {} : { maxToolResultBytes }),
20
28
  ...(options.instructions === undefined ? {} : { instructions: options.instructions }),
21
29
  ...(options.outputModalities === undefined ? {} : { outputModalities: options.outputModalities }),
22
30
  ...(options.maxDurationMs === undefined ? {} : { maxDurationMs: options.maxDurationMs }),
@@ -24,6 +24,7 @@ export declare const HarnessEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
24
24
  supportsToolCalls: z.ZodBoolean;
25
25
  supportsAsyncContext: z.ZodBoolean;
26
26
  supportsPlayoutAcknowledgements: z.ZodBoolean;
27
+ reportsResponseState: z.ZodOptional<z.ZodBoolean>;
27
28
  }, z.core.$strip>;
28
29
  instructions: z.ZodOptional<z.ZodString>;
29
30
  outputModalities: z.ZodOptional<z.ZodArray<z.ZodEnum<{
@@ -49,6 +50,64 @@ export declare const HarnessEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
49
50
  correlationId: z.ZodOptional<z.ZodString>;
50
51
  causationId: z.ZodOptional<z.ZodString>;
51
52
  type: z.ZodLiteral<"provider.ready">;
53
+ }, z.core.$strip>, z.ZodObject<{
54
+ id: z.ZodString;
55
+ sessionId: z.ZodString;
56
+ sequence: z.ZodInt;
57
+ recordedAtMs: z.ZodNumber;
58
+ sessionTimeMs: z.ZodNumber;
59
+ correlationId: z.ZodOptional<z.ZodString>;
60
+ causationId: z.ZodOptional<z.ZodString>;
61
+ type: z.ZodLiteral<"response.state">;
62
+ state: z.ZodEnum<{
63
+ idle: "idle";
64
+ pending: "pending";
65
+ }>;
66
+ }, z.core.$strip>, z.ZodObject<{
67
+ id: z.ZodString;
68
+ sessionId: z.ZodString;
69
+ sequence: z.ZodInt;
70
+ recordedAtMs: z.ZodNumber;
71
+ sessionTimeMs: z.ZodNumber;
72
+ correlationId: z.ZodOptional<z.ZodString>;
73
+ causationId: z.ZodOptional<z.ZodString>;
74
+ type: z.ZodLiteral<"provider.diagnostic">;
75
+ diagnostic: z.ZodDiscriminatedUnion<[z.ZodObject<{
76
+ kind: z.ZodLiteral<"session">;
77
+ model: z.ZodOptional<z.ZodString>;
78
+ }, z.core.$strip>, z.ZodObject<{
79
+ kind: z.ZodLiteral<"speech">;
80
+ phase: z.ZodEnum<{
81
+ started: "started";
82
+ stopped: "stopped";
83
+ }>;
84
+ streamId: z.ZodOptional<z.ZodString>;
85
+ audioOffsetMs: z.ZodOptional<z.ZodNumber>;
86
+ }, z.core.$strip>, z.ZodObject<{
87
+ kind: z.ZodLiteral<"response">;
88
+ phase: z.ZodEnum<{
89
+ completed: "completed";
90
+ started: "started";
91
+ }>;
92
+ responseId: z.ZodOptional<z.ZodString>;
93
+ status: z.ZodOptional<z.ZodString>;
94
+ reason: z.ZodOptional<z.ZodString>;
95
+ code: z.ZodOptional<z.ZodString>;
96
+ outputs: z.ZodOptional<z.ZodArray<z.ZodObject<{
97
+ streamId: z.ZodOptional<z.ZodString>;
98
+ type: z.ZodString;
99
+ contentTypes: z.ZodArray<z.ZodString>;
100
+ }, z.core.$strip>>>;
101
+ }, z.core.$strip>], "kind">;
102
+ }, z.core.$strip>, z.ZodObject<{
103
+ id: z.ZodString;
104
+ sessionId: z.ZodString;
105
+ sequence: z.ZodInt;
106
+ recordedAtMs: z.ZodNumber;
107
+ sessionTimeMs: z.ZodNumber;
108
+ correlationId: z.ZodOptional<z.ZodString>;
109
+ causationId: z.ZodOptional<z.ZodString>;
110
+ type: z.ZodLiteral<"assistant.turn.requested">;
52
111
  }, z.core.$strip>, z.ZodObject<{
53
112
  id: z.ZodString;
54
113
  sessionId: z.ZodString;
@@ -129,6 +188,16 @@ export declare const HarnessEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
129
188
  startSample: z.ZodInt;
130
189
  data: z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>;
131
190
  }, z.core.$strip>;
191
+ }, z.core.$strip>, z.ZodObject<{
192
+ id: z.ZodString;
193
+ sessionId: z.ZodString;
194
+ sequence: z.ZodInt;
195
+ recordedAtMs: z.ZodNumber;
196
+ sessionTimeMs: z.ZodNumber;
197
+ correlationId: z.ZodOptional<z.ZodString>;
198
+ causationId: z.ZodOptional<z.ZodString>;
199
+ type: z.ZodLiteral<"audio.interrupted">;
200
+ streamId: z.ZodString;
132
201
  }, z.core.$strip>, z.ZodObject<{
133
202
  role: z.ZodEnum<{
134
203
  assistant: "assistant";
@@ -200,6 +269,18 @@ export declare const HarnessEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
200
269
  name: z.ZodString;
201
270
  arguments: z.ZodRecord<z.ZodString, z.ZodUnknown>;
202
271
  }, z.core.$strip>;
272
+ }, z.core.$strip>, z.ZodObject<{
273
+ id: z.ZodString;
274
+ sessionId: z.ZodString;
275
+ sequence: z.ZodInt;
276
+ recordedAtMs: z.ZodNumber;
277
+ sessionTimeMs: z.ZodNumber;
278
+ correlationId: z.ZodOptional<z.ZodString>;
279
+ causationId: z.ZodOptional<z.ZodString>;
280
+ type: z.ZodLiteral<"tool.denied">;
281
+ callId: z.ZodString;
282
+ name: z.ZodString;
283
+ reason: z.ZodString;
203
284
  }, z.core.$strip>, z.ZodObject<{
204
285
  id: z.ZodString;
205
286
  sessionId: z.ZodString;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { AudioChunkSchema } from "./audio.js";
3
3
  import { InputPartSchema } from "./parts.js";
4
- import { OutputModalitySchema, ProviderCapabilitiesSchema, TranscriptDeltaPayloadSchema, TranscriptPayloadSchema, UsagePayloadSchema, } from "./provider.js";
4
+ import { OutputModalitySchema, ProviderCapabilitiesSchema, ProviderDiagnosticSchema, ResponseStateSchema, TranscriptDeltaPayloadSchema, TranscriptPayloadSchema, UsagePayloadSchema, } from "./provider.js";
5
5
  import { JsonValueSchema, ToolCallSchema, ToolOutcomeSchema } from "./tools.js";
6
6
  /** Bounds for a typed user message, shared by the event, the session API, and wire schemas. */
7
7
  export const MessageInputTextSchema = z.string().min(1).max(8_000);
@@ -34,6 +34,12 @@ export const HarnessEventSchema = z.discriminatedUnion("type", [
34
34
  EventBaseSchema.extend({ type: z.literal("session.ended"), reason: z.string().min(1) }),
35
35
  /** The provider accepted the session configuration; media may now flow. */
36
36
  EventBaseSchema.extend({ type: z.literal("provider.ready") }),
37
+ /** Optional provider generation state; never a gate on audio or background work. */
38
+ EventBaseSchema.extend({ type: z.literal("response.state"), state: ResponseStateSchema }),
39
+ /** Allow-listed provider observation for explaining response and speech behavior. */
40
+ EventBaseSchema.extend({ type: z.literal("provider.diagnostic"), diagnostic: ProviderDiagnosticSchema }),
41
+ /** A host asked the ready provider to begin an assistant turn without fabricating user input. */
42
+ EventBaseSchema.extend({ type: z.literal("assistant.turn.requested") }),
37
43
  /** The provider connection settled; the harness closes the session in response. */
38
44
  EventBaseSchema.extend({ type: z.literal("provider.closed"), reason: z.string().min(1) }),
39
45
  /** Provider-reported fault; recoverable errors leave the session running. */
@@ -56,6 +62,8 @@ export const HarnessEventSchema = z.discriminatedUnion("type", [
56
62
  EventBaseSchema.extend({ type: z.literal("audio.input"), chunk: AudioChunkSchema }),
57
63
  /** Model PCM as received; records are pipelined so bursts never stall the event pump. */
58
64
  EventBaseSchema.extend({ type: z.literal("audio.output"), chunk: AudioChunkSchema }),
65
+ /** User barge-in: clients must clear this output stream, including buffered audio. */
66
+ EventBaseSchema.extend({ type: z.literal("audio.interrupted"), streamId: z.string().min(1) }),
59
67
  /** Final text for either speaker — speech-to-text, or the model's text output in text mode. */
60
68
  EventBaseSchema.extend({ type: z.literal("transcript"), ...TranscriptPayloadSchema.shape }),
61
69
  /** Incremental assistant text streamed before the final transcript; live-render only. */
@@ -70,6 +78,13 @@ export const HarnessEventSchema = z.discriminatedUnion("type", [
70
78
  }),
71
79
  /** The model asked for a tool; recorded before execution begins. */
72
80
  EventBaseSchema.extend({ type: z.literal("tool.requested"), call: ToolCallSchema }),
81
+ /** The admission policy refused a requested tool; execution never began. Terminal for the call. */
82
+ EventBaseSchema.extend({
83
+ type: z.literal("tool.denied"),
84
+ callId: z.string().min(1),
85
+ name: z.string().min(1),
86
+ reason: z.string().min(1),
87
+ }),
73
88
  /** Local execution of a requested tool has begun. */
74
89
  EventBaseSchema.extend({
75
90
  type: z.literal("tool.started"),