xo-harness 0.2.0 → 0.3.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 +92 -5
- package/dist/browser/worklets/capture-processor.js +34 -0
- package/dist/browser/worklets/playback-processor.js +188 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +1 -0
- package/dist/internal/browser/browser-voice-client.d.ts +33 -0
- package/dist/internal/browser/browser-voice-client.js +316 -0
- package/dist/internal/browser/index.d.ts +2 -0
- package/dist/internal/browser/index.js +1 -0
- package/dist/internal/harness/conversation-context.d.ts +45 -0
- package/dist/internal/harness/conversation-context.js +79 -0
- package/dist/internal/harness/conversation-projection.d.ts +55 -0
- package/dist/internal/harness/conversation-projection.js +145 -0
- package/dist/internal/harness/event-stream.d.ts +23 -2
- package/dist/internal/harness/event-stream.js +148 -18
- package/dist/internal/harness/index.d.ts +7 -1
- package/dist/internal/harness/index.js +7 -1
- package/dist/internal/harness/message.d.ts +24 -11
- package/dist/internal/harness/message.js +271 -77
- package/dist/internal/harness/report-diff.d.ts +3 -0
- package/dist/internal/harness/report-diff.js +10 -0
- package/dist/internal/harness/report.d.ts +17 -14
- package/dist/internal/harness/report.js +41 -24
- package/dist/internal/harness/runtime-limits.d.ts +18 -0
- package/dist/internal/harness/runtime-limits.js +19 -0
- package/dist/internal/harness/session-persistence.d.ts +16 -0
- package/dist/internal/harness/session-persistence.js +106 -0
- package/dist/internal/harness/shadow.d.ts +5 -5
- package/dist/internal/harness/shadow.js +214 -52
- package/dist/internal/harness/socket-bridge.d.ts +45 -4
- package/dist/internal/harness/socket-bridge.js +215 -46
- package/dist/internal/harness/task-supervisor.d.ts +6 -2
- package/dist/internal/harness/task-supervisor.js +97 -15
- package/dist/internal/harness/tool-calls.d.ts +40 -0
- package/dist/internal/harness/tool-calls.js +132 -0
- package/dist/internal/harness/tool-policy.d.ts +2 -2
- package/dist/internal/harness/tool-runtime.d.ts +2 -2
- package/dist/internal/harness/tool-runtime.js +37 -40
- package/dist/internal/harness/tools.d.ts +21 -0
- package/dist/internal/harness/tools.js +7 -1
- package/dist/internal/harness/usage-tracker.d.ts +39 -0
- package/dist/internal/harness/usage-tracker.js +104 -0
- package/dist/internal/harness/voice-session.d.ts +39 -8
- package/dist/internal/harness/voice-session.js +353 -58
- package/dist/internal/harness/xo.d.ts +7 -12
- package/dist/internal/harness/xo.js +25 -13
- package/dist/internal/protocol/async-queue.d.ts +22 -1
- package/dist/internal/protocol/async-queue.js +86 -12
- package/dist/internal/protocol/audio.d.ts +16 -2
- package/dist/internal/protocol/audio.js +23 -5
- package/dist/internal/protocol/backend-output.d.ts +70 -0
- package/dist/internal/protocol/backend-output.js +39 -0
- package/dist/internal/protocol/event-json.d.ts +3 -0
- package/dist/internal/protocol/event-json.js +15 -0
- package/dist/internal/protocol/events.d.ts +342 -4
- package/dist/internal/protocol/events.js +53 -25
- package/dist/internal/protocol/index.d.ts +5 -0
- package/dist/internal/protocol/index.js +5 -0
- package/dist/internal/protocol/output-source.d.ts +18 -0
- package/dist/internal/protocol/output-source.js +15 -0
- package/dist/internal/protocol/paced-audio.d.ts +27 -0
- package/dist/internal/protocol/paced-audio.js +117 -0
- package/dist/internal/protocol/parts.d.ts +154 -0
- package/dist/internal/protocol/parts.js +32 -6
- package/dist/internal/protocol/provider.d.ts +308 -1
- package/dist/internal/protocol/provider.js +102 -3
- package/dist/internal/protocol/records.d.ts +773 -0
- package/dist/internal/protocol/records.js +46 -0
- package/dist/internal/protocol/tools.d.ts +40 -0
- package/dist/internal/protocol/tools.js +12 -0
- package/dist/internal/protocol/transcript.d.ts +19 -0
- package/dist/internal/protocol/transcript.js +53 -0
- package/dist/internal/provider/contract.d.ts +25 -2
- package/dist/internal/provider/event-queue.d.ts +17 -0
- package/dist/internal/provider/event-queue.js +78 -0
- package/dist/internal/provider/grok-voice.d.ts +12 -9
- package/dist/internal/provider/grok-voice.js +26 -15
- package/dist/internal/provider/index.d.ts +1 -0
- package/dist/internal/provider/index.js +1 -0
- package/dist/internal/provider/live-session.d.ts +29 -0
- package/dist/internal/provider/live-session.js +840 -0
- package/dist/internal/provider/node-socket.js +6 -0
- package/dist/internal/provider/openai-live.d.ts +62 -0
- package/dist/internal/provider/openai-live.js +160 -0
- package/dist/internal/provider/openai-realtime.d.ts +9 -5
- package/dist/internal/provider/openai-realtime.js +24 -10
- package/dist/internal/provider/realtime-session.d.ts +12 -1
- package/dist/internal/provider/realtime-session.js +373 -70
- package/dist/internal/provider/realtime-socket.d.ts +3 -1
- package/dist/internal/provider/tool-status-context.d.ts +10 -0
- package/dist/internal/provider/tool-status-context.js +30 -0
- package/dist/internal/provider/workers-socket.js +63 -15
- package/dist/internal/provider/workers.d.ts +1 -0
- package/dist/internal/provider/workers.js +1 -0
- package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
- package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
- package/dist/internal/storage/event-store.d.ts +22 -4
- package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
- package/dist/internal/storage/jsonl-event-store.js +21 -16
- package/dist/internal/storage/memory-event-store.d.ts +4 -3
- package/dist/internal/storage/memory-event-store.js +8 -2
- package/dist/internal/storage/memory.d.ts +1 -1
- package/dist/internal/testkit/events.d.ts +14 -0
- package/dist/internal/testkit/events.js +33 -0
- package/dist/internal/testkit/runtime.d.ts +3 -0
- package/dist/internal/testkit/runtime.js +3 -0
- package/dist/internal/testkit/trajectory.d.ts +23 -0
- package/dist/internal/testkit/trajectory.js +58 -0
- package/dist/internal/tools-openai/index.d.ts +2 -0
- package/dist/internal/tools-openai/index.js +79 -72
- package/dist/internal/tools-openai/responses.d.ts +5 -1
- package/dist/internal/tools-openai/responses.js +53 -5
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +7 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { toolDenialError } from "./tool-policy.js";
|
|
2
|
+
/**
|
|
3
|
+
* Incremental projection of a single session's sequence-ordered event log. This
|
|
4
|
+
* observes execution; ToolRuntime and TaskSupervisor remain its only owners.
|
|
5
|
+
* Applying an event replaces the changed entry, leaving prior snapshots stable.
|
|
6
|
+
*/
|
|
7
|
+
export class ToolCallTracker {
|
|
8
|
+
#calls = new Map();
|
|
9
|
+
get(callId) {
|
|
10
|
+
return this.#calls.get(callId);
|
|
11
|
+
}
|
|
12
|
+
list() {
|
|
13
|
+
return [...this.#calls.values()];
|
|
14
|
+
}
|
|
15
|
+
hasPending() {
|
|
16
|
+
for (const call of this.#calls.values()) {
|
|
17
|
+
if (call.status === "pending")
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
/** Returns the updated call, or undefined when the event has no effect. */
|
|
23
|
+
apply(event) {
|
|
24
|
+
if (event.type === "tool.requested") {
|
|
25
|
+
if (this.#calls.has(event.call.callId))
|
|
26
|
+
return;
|
|
27
|
+
return this.#save({
|
|
28
|
+
callId: event.call.callId,
|
|
29
|
+
name: event.call.name,
|
|
30
|
+
...(event.call.source === undefined ? {} : { source: { ...event.call.source } }),
|
|
31
|
+
state: { status: "running", input: event.call.arguments, startedAtMs: event.recordedAtMs },
|
|
32
|
+
status: "pending",
|
|
33
|
+
deliveryFailures: [],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (!("callId" in event) || event.callId === undefined)
|
|
37
|
+
return;
|
|
38
|
+
const call = this.#calls.get(event.callId);
|
|
39
|
+
if (!call)
|
|
40
|
+
return;
|
|
41
|
+
switch (event.type) {
|
|
42
|
+
case "tool.completed":
|
|
43
|
+
if (call.state.status !== "running")
|
|
44
|
+
return;
|
|
45
|
+
return this.#settle(call, event.outcome, event.recordedAtMs);
|
|
46
|
+
case "tool.failed":
|
|
47
|
+
if (call.state.status !== "running")
|
|
48
|
+
return;
|
|
49
|
+
return this.#settle(call, { type: "failed", error: event.error }, event.recordedAtMs);
|
|
50
|
+
case "tool.denied":
|
|
51
|
+
if (call.state.status !== "running")
|
|
52
|
+
return;
|
|
53
|
+
return this.#settle(call, { type: "failed", error: toolDenialError(event.reason) }, event.recordedAtMs);
|
|
54
|
+
case "task.accepted":
|
|
55
|
+
if (call.taskId !== undefined || call.status !== "pending")
|
|
56
|
+
return;
|
|
57
|
+
return this.#save({ ...call, taskId: event.taskId });
|
|
58
|
+
case "tool.progress":
|
|
59
|
+
if (call.state.status !== "running")
|
|
60
|
+
return;
|
|
61
|
+
return this.#progress(call, event);
|
|
62
|
+
case "task.progress":
|
|
63
|
+
if (call.status !== "pending" || call.taskId !== event.taskId)
|
|
64
|
+
return;
|
|
65
|
+
return this.#progress(call, event);
|
|
66
|
+
case "task.completed":
|
|
67
|
+
case "task.failed":
|
|
68
|
+
case "task.cancelled": {
|
|
69
|
+
if (call.state.status !== "settled" ||
|
|
70
|
+
call.state.outcome.type !== "accepted_task" ||
|
|
71
|
+
call.state.outcome.taskId !== event.taskId)
|
|
72
|
+
return;
|
|
73
|
+
return this.#settle(call, event.type === "task.completed"
|
|
74
|
+
? { type: "completed", value: event.result }
|
|
75
|
+
: event.type === "task.failed"
|
|
76
|
+
? { type: "failed", error: event.error }
|
|
77
|
+
: { type: "failed", error: `Task cancelled: ${event.reason}` }, event.recordedAtMs);
|
|
78
|
+
}
|
|
79
|
+
case "tool.delivery_failed":
|
|
80
|
+
case "task.delivery_failed":
|
|
81
|
+
if (event.type === "task.delivery_failed" && call.taskId !== event.taskId)
|
|
82
|
+
return;
|
|
83
|
+
if (call.deliveryFailures.some((failure) => failure.eventId === event.id))
|
|
84
|
+
return;
|
|
85
|
+
return this.#save({
|
|
86
|
+
...call,
|
|
87
|
+
deliveryFailures: [
|
|
88
|
+
...call.deliveryFailures,
|
|
89
|
+
{
|
|
90
|
+
eventId: event.id,
|
|
91
|
+
phase: event.type === "tool.delivery_failed" ? "tool" : event.phase,
|
|
92
|
+
error: event.error,
|
|
93
|
+
recordedAtMs: event.recordedAtMs,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
default:
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
#settle(call, outcome, recordedAtMs) {
|
|
102
|
+
return this.#save({
|
|
103
|
+
...call,
|
|
104
|
+
state: {
|
|
105
|
+
status: "settled",
|
|
106
|
+
input: call.state.input,
|
|
107
|
+
startedAtMs: call.state.startedAtMs,
|
|
108
|
+
endedAtMs: recordedAtMs,
|
|
109
|
+
outcome,
|
|
110
|
+
},
|
|
111
|
+
status: outcome.type === "accepted_task" ? "pending" : "result_ready",
|
|
112
|
+
...(outcome.type === "accepted_task" ? { taskId: outcome.taskId } : {}),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
#progress(call, event) {
|
|
116
|
+
return this.#save({
|
|
117
|
+
...call,
|
|
118
|
+
progress: { update: event.update, recordedAtMs: event.recordedAtMs },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
#save(call) {
|
|
122
|
+
this.#calls.set(call.callId, call);
|
|
123
|
+
return call;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Pure replay of the same lifecycle used by live harness consumers and messages. */
|
|
127
|
+
export function projectToolCalls(events) {
|
|
128
|
+
const tracker = new ToolCallTracker();
|
|
129
|
+
for (const event of events)
|
|
130
|
+
tracker.apply(event);
|
|
131
|
+
return tracker.list();
|
|
132
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HarnessRecord, ToolCall } from "../protocol/index.js";
|
|
2
2
|
import type { ToolAnnotations } from "./tools.js";
|
|
3
3
|
/** Everything a policy may consult when admitting one tool call. */
|
|
4
4
|
export interface ToolAdmissionContext {
|
|
@@ -16,7 +16,7 @@ export interface ToolAdmissionContext {
|
|
|
16
16
|
* this. Reads the store (audio events included), so call it only when a decision
|
|
17
17
|
* needs it, and project with `projectMessages` rather than scanning raw events.
|
|
18
18
|
*/
|
|
19
|
-
history(): Promise<readonly
|
|
19
|
+
history(): Promise<readonly HarnessRecord[]>;
|
|
20
20
|
}
|
|
21
21
|
export type ToolAdmission = {
|
|
22
22
|
decision: "allow";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type HarnessEvent, type NewHarnessEvent, type ProviderToolResult, type ToolCall } from "../protocol/index.js";
|
|
1
|
+
import { type HarnessEvent, type HarnessRecord, type NewHarnessEvent, type ProviderToolResult, type ToolCall } from "../protocol/index.js";
|
|
2
2
|
import type { TaskSupervisor } from "./task-supervisor.js";
|
|
3
3
|
import { type ToolPolicy } from "./tool-policy.js";
|
|
4
4
|
import type { ToolRegistry } from "./tools.js";
|
|
@@ -16,7 +16,7 @@ export declare class ToolRuntime {
|
|
|
16
16
|
signal: AbortSignal;
|
|
17
17
|
flush: () => Promise<void>;
|
|
18
18
|
policy: ToolPolicy;
|
|
19
|
-
history: () => Promise<readonly
|
|
19
|
+
history: () => Promise<readonly HarnessRecord[]>;
|
|
20
20
|
maxResultBytes: number;
|
|
21
21
|
});
|
|
22
22
|
execute(call: ToolCall): Promise<void>;
|
|
@@ -33,13 +33,19 @@ export class ToolRuntime {
|
|
|
33
33
|
if (this.#inFlight.has(call.callId) || this.#terminal.has(call.callId))
|
|
34
34
|
return;
|
|
35
35
|
this.#inFlight.add(call.callId);
|
|
36
|
+
let acceptedTaskId;
|
|
36
37
|
try {
|
|
37
38
|
const admission = await this.#admit(call);
|
|
38
39
|
// Cancellation can follow a resolved admission before this continuation runs.
|
|
39
40
|
if (admission === undefined || this.#signal.aborted)
|
|
40
41
|
return;
|
|
41
42
|
if (admission.decision === "deny") {
|
|
42
|
-
await this.#
|
|
43
|
+
await this.#settle({
|
|
44
|
+
type: "tool.denied",
|
|
45
|
+
callId: call.callId,
|
|
46
|
+
name: call.name,
|
|
47
|
+
reason: admission.reason,
|
|
48
|
+
});
|
|
43
49
|
return;
|
|
44
50
|
}
|
|
45
51
|
await this.#record({ type: "tool.started", callId: call.callId, name: call.name });
|
|
@@ -48,7 +54,11 @@ export class ToolRuntime {
|
|
|
48
54
|
return;
|
|
49
55
|
const tool = this.#tools.get(call.name);
|
|
50
56
|
if (!tool) {
|
|
51
|
-
await this.#
|
|
57
|
+
await this.#settle({
|
|
58
|
+
type: "tool.failed",
|
|
59
|
+
callId: call.callId,
|
|
60
|
+
error: `Tool not found: ${call.name}`,
|
|
61
|
+
});
|
|
52
62
|
return;
|
|
53
63
|
}
|
|
54
64
|
let outcome;
|
|
@@ -59,13 +69,20 @@ export class ToolRuntime {
|
|
|
59
69
|
outcome = ToolOutcomeSchema.parse(await this.#run(tool, input, call.callId));
|
|
60
70
|
}
|
|
61
71
|
catch (error) {
|
|
62
|
-
await this.#
|
|
72
|
+
await this.#settle({ type: "tool.failed", callId: call.callId, error: errorMessage(error) });
|
|
63
73
|
return;
|
|
64
74
|
}
|
|
65
|
-
|
|
75
|
+
if (outcome.type === "accepted_task")
|
|
76
|
+
acceptedTaskId = outcome.taskId;
|
|
77
|
+
await this.#settle({ type: "tool.completed", callId: call.callId, outcome });
|
|
66
78
|
}
|
|
67
79
|
finally {
|
|
68
|
-
|
|
80
|
+
try {
|
|
81
|
+
await this.#tasks.cancelUnaccepted(call.callId, acceptedTaskId);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
this.#inFlight.delete(call.callId);
|
|
85
|
+
}
|
|
69
86
|
}
|
|
70
87
|
}
|
|
71
88
|
async #admit(call) {
|
|
@@ -91,21 +108,9 @@ export class ToolRuntime {
|
|
|
91
108
|
// aborted call is simply left unterminated — the same shape a lost process leaves.
|
|
92
109
|
return raceWithAbort(decision, this.#signal);
|
|
93
110
|
}
|
|
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
|
-
}
|
|
107
111
|
async #run(tool, input, callId) {
|
|
108
112
|
return tool.execute(input, {
|
|
113
|
+
callId,
|
|
109
114
|
signal: this.#signal,
|
|
110
115
|
maxResultBytes: this.#maxResultBytes,
|
|
111
116
|
tasks: this.#tasks.forCall(callId),
|
|
@@ -114,33 +119,30 @@ export class ToolRuntime {
|
|
|
114
119
|
},
|
|
115
120
|
});
|
|
116
121
|
}
|
|
117
|
-
async #
|
|
118
|
-
|
|
119
|
-
this.#terminal.
|
|
120
|
-
|
|
121
|
-
await this.#record({ type: "tool.completed", callId, outcome });
|
|
122
|
-
}
|
|
123
|
-
catch (error) {
|
|
124
|
-
this.#terminal.delete(callId);
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
127
|
-
await this.#flush();
|
|
128
|
-
await this.#deliver(callId, outcome);
|
|
129
|
-
}
|
|
130
|
-
async #settleFailure(callId, message) {
|
|
131
|
-
this.#assertNotTerminal(callId);
|
|
122
|
+
async #settle(event) {
|
|
123
|
+
const { callId } = event;
|
|
124
|
+
if (this.#terminal.has(callId))
|
|
125
|
+
throw new Error(`Tool call already settled: ${callId}`);
|
|
132
126
|
this.#terminal.add(callId);
|
|
133
127
|
try {
|
|
134
|
-
await this.#record(
|
|
128
|
+
await this.#record(event);
|
|
135
129
|
}
|
|
136
130
|
catch (error) {
|
|
137
131
|
this.#terminal.delete(callId);
|
|
138
132
|
throw error;
|
|
139
133
|
}
|
|
140
134
|
await this.#flush();
|
|
141
|
-
await this.#deliver(callId,
|
|
135
|
+
await this.#deliver(callId, event.type === "tool.completed"
|
|
136
|
+
? event.outcome
|
|
137
|
+
: {
|
|
138
|
+
type: "failed",
|
|
139
|
+
error: event.type === "tool.denied" ? toolDenialError(event.reason) : event.error,
|
|
140
|
+
});
|
|
142
141
|
}
|
|
143
142
|
async #deliver(callId, outcome) {
|
|
143
|
+
// Keep the terminal record, but do not restart model work while the session drains.
|
|
144
|
+
if (this.#signal.aborted)
|
|
145
|
+
return;
|
|
144
146
|
try {
|
|
145
147
|
// The log keeps the full outcome; only the provider-bound copy is bounded.
|
|
146
148
|
await this.#resultSink.deliver({
|
|
@@ -160,11 +162,6 @@ export class ToolRuntime {
|
|
|
160
162
|
await this.#tasks.activate(outcome.taskId, callId);
|
|
161
163
|
}
|
|
162
164
|
}
|
|
163
|
-
#assertNotTerminal(callId) {
|
|
164
|
-
if (this.#terminal.has(callId)) {
|
|
165
|
-
throw new Error(`Tool call already settled: ${callId}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
165
|
}
|
|
169
166
|
function raceWithAbort(work, signal) {
|
|
170
167
|
if (signal.aborted)
|
|
@@ -6,14 +6,35 @@ export interface BackgroundTaskContext {
|
|
|
6
6
|
report(update: JsonValue): Promise<void>;
|
|
7
7
|
}
|
|
8
8
|
export type BackgroundTaskRunner = (context: BackgroundTaskContext) => Promise<JsonValue>;
|
|
9
|
+
/** An explicit execution outcome; arbitrary JSON returned by tasks.start remains result data. */
|
|
10
|
+
export declare const BackgroundTaskOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
11
|
+
type: z.ZodLiteral<"completed">;
|
|
12
|
+
result: z.ZodJSONSchema;
|
|
13
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
14
|
+
type: z.ZodLiteral<"failed">;
|
|
15
|
+
error: z.ZodString;
|
|
16
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
17
|
+
type: z.ZodLiteral<"cancelled">;
|
|
18
|
+
reason: z.ZodString;
|
|
19
|
+
}, z.core.$strip>], "type">;
|
|
20
|
+
export type BackgroundTaskOutcome = z.infer<typeof BackgroundTaskOutcomeSchema>;
|
|
21
|
+
export type BackgroundTaskOutcomeRunner = (context: BackgroundTaskContext) => Promise<BackgroundTaskOutcome>;
|
|
22
|
+
/** A request receipt, never confirmation that a remote worker stopped. */
|
|
23
|
+
export interface TaskCancellationRequestResult {
|
|
24
|
+
status: "requested" | "already_requested" | "settled" | "not_found";
|
|
25
|
+
}
|
|
9
26
|
export interface BackgroundTaskOptions {
|
|
10
27
|
/** Release resources reserved before start if the task is cancelled before its runner activates. */
|
|
11
28
|
onPendingCancel?: () => void;
|
|
12
29
|
}
|
|
13
30
|
export interface BackgroundTaskLauncher {
|
|
14
31
|
start(run: BackgroundTaskRunner, options?: BackgroundTaskOptions): Promise<string>;
|
|
32
|
+
/** Returned outcomes remain authoritative when cancellation races settlement. Throws are failures. */
|
|
33
|
+
startOutcome(run: BackgroundTaskOutcomeRunner, options?: BackgroundTaskOptions): Promise<string>;
|
|
15
34
|
}
|
|
16
35
|
export interface ToolExecutionContext {
|
|
36
|
+
/** The provider call being executed, suitable for correlating application-owned work. */
|
|
37
|
+
readonly callId: string;
|
|
17
38
|
/** Session lifetime signal. Executions must settle on abort; arbitrary JavaScript cannot be forcibly stopped. */
|
|
18
39
|
readonly signal: AbortSignal;
|
|
19
40
|
/** JSON payload budget for provider delivery; tools can use it to size recoverable pages. */
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { ProviderToolDefinitionSchema, } from "../protocol/index.js";
|
|
1
|
+
import { JsonValueSchema, ProviderToolDefinitionSchema, } from "../protocol/index.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
/** An explicit execution outcome; arbitrary JSON returned by tasks.start remains result data. */
|
|
4
|
+
export const BackgroundTaskOutcomeSchema = z.discriminatedUnion("type", [
|
|
5
|
+
z.object({ type: z.literal("completed"), result: JsonValueSchema }),
|
|
6
|
+
z.object({ type: z.literal("failed"), error: z.string().min(1) }),
|
|
7
|
+
z.object({ type: z.literal("cancelled"), reason: z.string().min(1) }),
|
|
8
|
+
]);
|
|
3
9
|
export function defineTool(tool) {
|
|
4
10
|
return tool;
|
|
5
11
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { HarnessRecord } from "../protocol/index.js";
|
|
2
|
+
/** Provider-reported token totals. Duration is a separate accounting domain. */
|
|
3
|
+
export interface UsageTotals {
|
|
4
|
+
responses: number;
|
|
5
|
+
inputTokens: number;
|
|
6
|
+
outputTokens: number;
|
|
7
|
+
totalTokens: number;
|
|
8
|
+
cachedInputTokens: number;
|
|
9
|
+
audioInputTokens: number;
|
|
10
|
+
audioOutputTokens: number;
|
|
11
|
+
cachedAudioInputTokens: number;
|
|
12
|
+
}
|
|
13
|
+
export interface SessionUsageSnapshot {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
usage: UsageTotals;
|
|
16
|
+
backendUsage?: UsageTotals;
|
|
17
|
+
voiceDuration?: {
|
|
18
|
+
seconds: number;
|
|
19
|
+
final: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Session end, provider finalization, and final duration are independent observations. */
|
|
22
|
+
ended: boolean;
|
|
23
|
+
endedReason?: string;
|
|
24
|
+
finalized?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Incremental accounting for one session. Apply its ordered events; exact event-ID
|
|
28
|
+
* replay and repeated response IDs are counted once. Retains no PCM or transcripts.
|
|
29
|
+
* Host ledgers must commit each session's contribution idempotently themselves.
|
|
30
|
+
*/
|
|
31
|
+
export declare class SessionUsageTracker {
|
|
32
|
+
#private;
|
|
33
|
+
constructor(options: {
|
|
34
|
+
sessionId: string;
|
|
35
|
+
});
|
|
36
|
+
apply(event: HarnessRecord): boolean;
|
|
37
|
+
/** Detached values remain stable while an asynchronous persistence queue drains. */
|
|
38
|
+
snapshot(): SessionUsageSnapshot;
|
|
39
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental accounting for one session. Apply its ordered events; exact event-ID
|
|
3
|
+
* replay and repeated response IDs are counted once. Retains no PCM or transcripts.
|
|
4
|
+
* Host ledgers must commit each session's contribution idempotently themselves.
|
|
5
|
+
*/
|
|
6
|
+
export class SessionUsageTracker {
|
|
7
|
+
#sessionId;
|
|
8
|
+
#seen = new Set();
|
|
9
|
+
#responses = new Set();
|
|
10
|
+
#usage = emptyUsage();
|
|
11
|
+
#backendUsage;
|
|
12
|
+
#voiceDuration;
|
|
13
|
+
#endedReason;
|
|
14
|
+
#providerClosed = false;
|
|
15
|
+
#finalized;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
if (!options.sessionId)
|
|
18
|
+
throw new Error("Usage tracker requires a session ID");
|
|
19
|
+
this.#sessionId = options.sessionId;
|
|
20
|
+
}
|
|
21
|
+
apply(event) {
|
|
22
|
+
if (event.sessionId !== this.#sessionId)
|
|
23
|
+
throw new Error("Usage event belongs to another session");
|
|
24
|
+
if (event.type !== "usage" &&
|
|
25
|
+
event.type !== "usage.duration" &&
|
|
26
|
+
event.type !== "provider.closed" &&
|
|
27
|
+
event.type !== "session.ended")
|
|
28
|
+
return false;
|
|
29
|
+
if (this.#seen.has(event.id))
|
|
30
|
+
return false;
|
|
31
|
+
this.#seen.add(event.id);
|
|
32
|
+
switch (event.type) {
|
|
33
|
+
case "usage": {
|
|
34
|
+
const key = event.responseId && JSON.stringify([event.scope ?? "voice", event.responseId]);
|
|
35
|
+
if (key && this.#responses.has(key))
|
|
36
|
+
return false;
|
|
37
|
+
if (key)
|
|
38
|
+
this.#responses.add(key);
|
|
39
|
+
addUsage(this.#usage, event);
|
|
40
|
+
if (event.scope === "backend") {
|
|
41
|
+
this.#backendUsage ??= emptyUsage();
|
|
42
|
+
addUsage(this.#backendUsage, event);
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
case "usage.duration": {
|
|
47
|
+
if (this.#voiceDuration?.final)
|
|
48
|
+
return false;
|
|
49
|
+
// Interim snapshots cannot regress. A final provider report remains authoritative.
|
|
50
|
+
const seconds = event.final
|
|
51
|
+
? event.seconds
|
|
52
|
+
: Math.max(this.#voiceDuration?.seconds ?? 0, event.seconds);
|
|
53
|
+
const changed = seconds !== this.#voiceDuration?.seconds || event.final !== this.#voiceDuration?.final;
|
|
54
|
+
this.#voiceDuration = { seconds, final: event.final };
|
|
55
|
+
return changed;
|
|
56
|
+
}
|
|
57
|
+
case "provider.closed":
|
|
58
|
+
if (this.#providerClosed)
|
|
59
|
+
return false;
|
|
60
|
+
this.#providerClosed = true;
|
|
61
|
+
this.#finalized = event.finalized;
|
|
62
|
+
return true;
|
|
63
|
+
case "session.ended":
|
|
64
|
+
if (this.#endedReason !== undefined)
|
|
65
|
+
return false;
|
|
66
|
+
this.#endedReason = event.reason;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Detached values remain stable while an asynchronous persistence queue drains. */
|
|
71
|
+
snapshot() {
|
|
72
|
+
return {
|
|
73
|
+
sessionId: this.#sessionId,
|
|
74
|
+
usage: { ...this.#usage },
|
|
75
|
+
ended: this.#endedReason !== undefined,
|
|
76
|
+
...(this.#backendUsage === undefined ? {} : { backendUsage: { ...this.#backendUsage } }),
|
|
77
|
+
...(this.#voiceDuration === undefined ? {} : { voiceDuration: { ...this.#voiceDuration } }),
|
|
78
|
+
...(this.#endedReason === undefined ? {} : { endedReason: this.#endedReason }),
|
|
79
|
+
...(this.#finalized === undefined ? {} : { finalized: this.#finalized }),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function emptyUsage() {
|
|
84
|
+
return {
|
|
85
|
+
responses: 0,
|
|
86
|
+
inputTokens: 0,
|
|
87
|
+
outputTokens: 0,
|
|
88
|
+
totalTokens: 0,
|
|
89
|
+
cachedInputTokens: 0,
|
|
90
|
+
audioInputTokens: 0,
|
|
91
|
+
audioOutputTokens: 0,
|
|
92
|
+
cachedAudioInputTokens: 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function addUsage(total, event) {
|
|
96
|
+
total.responses += 1;
|
|
97
|
+
total.inputTokens += event.inputTokens;
|
|
98
|
+
total.outputTokens += event.outputTokens;
|
|
99
|
+
total.totalTokens += event.totalTokens;
|
|
100
|
+
total.cachedInputTokens += event.cachedInputTokens ?? 0;
|
|
101
|
+
total.audioInputTokens += event.audioInputTokens ?? 0;
|
|
102
|
+
total.audioOutputTokens += event.audioOutputTokens ?? 0;
|
|
103
|
+
total.cachedAudioInputTokens += event.cachedAudioInputTokens ?? 0;
|
|
104
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { type AgentInput, type AssistantTurnRequest, type AudioChunk, type HarnessEvent, type OutputModality, type PlayoutProgress } from "../protocol/index.js";
|
|
1
|
+
import { type AgentInput, type AssistantTurnRequest, type AudioChunk, type ConversationContextUpdate, type HarnessEvent, type HarnessRecord, type OutputModality, type PlayoutProgress, type PlayoutRange, type ProviderCapabilities } 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 SessionRuntimeLimits } from "./runtime-limits.js";
|
|
5
|
+
import { type ToolCallProjection } from "./tool-calls.js";
|
|
4
6
|
import { type ToolPolicy } from "./tool-policy.js";
|
|
7
|
+
import type { TaskCancellationRequestResult } from "./tools.js";
|
|
5
8
|
import { ToolRegistry } from "./tools.js";
|
|
6
9
|
export interface CreateVoiceSessionOptions {
|
|
7
10
|
sessionId: string;
|
|
@@ -20,8 +23,12 @@ export interface CreateVoiceSessionOptions {
|
|
|
20
23
|
instructions?: string;
|
|
21
24
|
/** What the model may produce this session; ["text"] disables audio output. Default: audio. */
|
|
22
25
|
outputModalities?: readonly OutputModality[];
|
|
23
|
-
/** Closes
|
|
26
|
+
/** Closes with max_duration_reached once elapsed. Integer 1–2147483647 ms; omitted means no timer. */
|
|
24
27
|
maxDurationMs?: number;
|
|
28
|
+
/** Bounds shutdown of an uncooperative provider or tool. Integer 1–2147483647 ms; default: 15 seconds. */
|
|
29
|
+
closeTimeoutMs?: number;
|
|
30
|
+
/** Opt-in replay-window, append-backlog, and store-operation limits. */
|
|
31
|
+
runtimeLimits?: SessionRuntimeLimits;
|
|
25
32
|
}
|
|
26
33
|
export declare class VoiceSession {
|
|
27
34
|
#private;
|
|
@@ -29,13 +36,34 @@ export declare class VoiceSession {
|
|
|
29
36
|
private constructor();
|
|
30
37
|
static create(options: CreateVoiceSessionOptions): Promise<VoiceSession>;
|
|
31
38
|
events(afterSequence?: number): AsyncIterable<HarnessEvent>;
|
|
32
|
-
history(): Promise<readonly
|
|
33
|
-
|
|
39
|
+
history(): Promise<readonly HarnessRecord[]>;
|
|
40
|
+
/** Separate buffer owners; these counters are not a whole-process memory ceiling. */
|
|
41
|
+
runtimeStats(): {
|
|
42
|
+
pendingAppends: number;
|
|
43
|
+
pendingAppendAudioBytes: number;
|
|
44
|
+
replay: {
|
|
45
|
+
retainedEvents: number;
|
|
46
|
+
retainedAudioBytes: number;
|
|
47
|
+
oldestSequence: number | undefined;
|
|
48
|
+
latestSequence: number;
|
|
49
|
+
subscribers: number;
|
|
50
|
+
};
|
|
51
|
+
providerQueue?: import("../provider/index.js").ProviderEventQueueStats;
|
|
52
|
+
};
|
|
53
|
+
/** Immutable startup snapshot; missing optional fields retain their documented legacy meaning. */
|
|
54
|
+
get capabilities(): Readonly<ProviderCapabilities>;
|
|
55
|
+
/** Recorded tool/task execution. Completion does not imply a spoken announcement. */
|
|
56
|
+
toolCalls(): readonly ToolCallProjection[];
|
|
57
|
+
/** Requests one task's cancellation. The terminal task event, not this result, records settlement. */
|
|
58
|
+
cancelTask(taskId: string, reason?: string): Promise<TaskCancellationRequestResult>;
|
|
59
|
+
sendAudio(chunk: AudioChunk, metadata?: {
|
|
60
|
+
syntheticSilence?: boolean;
|
|
61
|
+
}): Promise<void>;
|
|
34
62
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
63
|
+
* Submits a string or text-only AgentInput to the declared provider destination.
|
|
64
|
+
* Image/file parts are rejected before recording or partial delivery. Applications
|
|
65
|
+
* route those parts to their own backend. input.delivery records local submission,
|
|
66
|
+
* not backend consumption; interrupted shutdown can leave delivery unconfirmed.
|
|
39
67
|
*/
|
|
40
68
|
sendText(input: AgentInput): Promise<void>;
|
|
41
69
|
/**
|
|
@@ -50,5 +78,8 @@ export declare class VoiceSession {
|
|
|
50
78
|
*/
|
|
51
79
|
requestAssistantTurn(request?: AssistantTurnRequest): Promise<void>;
|
|
52
80
|
reportPlayout(progress: PlayoutProgress): Promise<void>;
|
|
81
|
+
/** Records local evidence even when a provider cannot consume playout acknowledgements. */
|
|
82
|
+
reportPlayoutRange(range: PlayoutRange): Promise<void>;
|
|
83
|
+
appendContext(update: ConversationContextUpdate): Promise<void>;
|
|
53
84
|
close(reason?: string): Promise<void>;
|
|
54
85
|
}
|