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.
- package/README.md +20 -0
- package/dist/internal/harness/index.d.ts +3 -0
- package/dist/internal/harness/index.js +3 -0
- package/dist/internal/harness/message.d.ts +1 -2
- package/dist/internal/harness/message.js +45 -19
- package/dist/internal/harness/report-diff.d.ts +46 -0
- package/dist/internal/harness/report-diff.js +62 -0
- package/dist/internal/harness/report.d.ts +2 -0
- package/dist/internal/harness/report.js +10 -0
- package/dist/internal/harness/shadow.d.ts +9 -2
- package/dist/internal/harness/shadow.js +5 -1
- package/dist/internal/harness/task-supervisor.js +21 -6
- package/dist/internal/harness/tool-delivery.d.ts +17 -0
- package/dist/internal/harness/tool-delivery.js +53 -0
- package/dist/internal/harness/tool-policy.d.ts +52 -0
- package/dist/internal/harness/tool-policy.js +22 -0
- package/dist/internal/harness/tool-runtime.d.ts +4 -0
- package/dist/internal/harness/tool-runtime.js +82 -1
- package/dist/internal/harness/tools.d.ts +22 -1
- package/dist/internal/harness/voice-session.d.ts +21 -2
- package/dist/internal/harness/voice-session.js +65 -4
- package/dist/internal/harness/xo.d.ts +13 -0
- package/dist/internal/harness/xo.js +8 -0
- package/dist/internal/protocol/events.d.ts +81 -0
- package/dist/internal/protocol/events.js +16 -1
- package/dist/internal/protocol/provider.d.ts +92 -2
- package/dist/internal/protocol/provider.js +59 -3
- package/dist/internal/provider/contract.d.ts +3 -1
- package/dist/internal/provider/grok-voice.d.ts +1 -0
- package/dist/internal/provider/grok-voice.js +4 -0
- package/dist/internal/provider/openai-realtime.d.ts +10 -1
- package/dist/internal/provider/openai-realtime.js +26 -1
- package/dist/internal/provider/realtime-session.d.ts +8 -1
- package/dist/internal/provider/realtime-session.js +323 -34
- package/dist/internal/provider-fake/index.d.ts +3 -1
- package/dist/internal/provider-fake/index.js +5 -1
- package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -3
- package/dist/internal/provider-fake/replay-voice-provider.js +18 -4
- package/dist/internal/provider-fake/scripted-voice-provider.d.ts +5 -1
- package/dist/internal/provider-fake/scripted-voice-provider.js +4 -1
- package/dist/internal/skills/index.d.ts +2 -0
- package/dist/internal/skills/index.js +2 -0
- package/dist/internal/skills/node.d.ts +7 -0
- package/dist/internal/skills/node.js +99 -0
- package/dist/internal/skills/skill.d.ts +17 -0
- package/dist/internal/skills/skill.js +42 -0
- package/dist/internal/skills/tools.d.ts +7 -0
- package/dist/internal/skills/tools.js +82 -0
- package/dist/internal/storage/memory.d.ts +2 -0
- package/dist/internal/storage/memory.js +1 -0
- package/dist/internal/tools-openai/delegate-conversation.d.ts +12 -0
- package/dist/internal/tools-openai/delegate-conversation.js +66 -0
- package/dist/internal/tools-openai/index.d.ts +24 -0
- package/dist/internal/tools-openai/index.js +119 -0
- package/dist/internal/tools-openai/responses.d.ts +31 -0
- package/dist/internal/tools-openai/responses.js +146 -0
- package/dist/skills-node.d.ts +1 -0
- package/dist/skills-node.js +1 -0
- package/dist/skills.d.ts +1 -0
- package/dist/skills.js +1 -0
- package/dist/storage-memory.d.ts +1 -0
- package/dist/storage-memory.js +1 -0
- package/dist/tools-openai.d.ts +1 -0
- package/dist/tools-openai.js +1 -0
- 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
|
-
|
|
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
|
|
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:
|
|
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"),
|