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.
Files changed (115) hide show
  1. package/README.md +92 -5
  2. package/dist/browser/worklets/capture-processor.js +34 -0
  3. package/dist/browser/worklets/playback-processor.js +188 -0
  4. package/dist/browser.d.ts +1 -0
  5. package/dist/browser.js +1 -0
  6. package/dist/internal/browser/browser-voice-client.d.ts +33 -0
  7. package/dist/internal/browser/browser-voice-client.js +316 -0
  8. package/dist/internal/browser/index.d.ts +2 -0
  9. package/dist/internal/browser/index.js +1 -0
  10. package/dist/internal/harness/conversation-context.d.ts +45 -0
  11. package/dist/internal/harness/conversation-context.js +79 -0
  12. package/dist/internal/harness/conversation-projection.d.ts +55 -0
  13. package/dist/internal/harness/conversation-projection.js +145 -0
  14. package/dist/internal/harness/event-stream.d.ts +23 -2
  15. package/dist/internal/harness/event-stream.js +148 -18
  16. package/dist/internal/harness/index.d.ts +7 -1
  17. package/dist/internal/harness/index.js +7 -1
  18. package/dist/internal/harness/message.d.ts +24 -11
  19. package/dist/internal/harness/message.js +271 -77
  20. package/dist/internal/harness/report-diff.d.ts +3 -0
  21. package/dist/internal/harness/report-diff.js +10 -0
  22. package/dist/internal/harness/report.d.ts +17 -14
  23. package/dist/internal/harness/report.js +41 -24
  24. package/dist/internal/harness/runtime-limits.d.ts +18 -0
  25. package/dist/internal/harness/runtime-limits.js +19 -0
  26. package/dist/internal/harness/session-persistence.d.ts +16 -0
  27. package/dist/internal/harness/session-persistence.js +106 -0
  28. package/dist/internal/harness/shadow.d.ts +5 -5
  29. package/dist/internal/harness/shadow.js +214 -52
  30. package/dist/internal/harness/socket-bridge.d.ts +45 -4
  31. package/dist/internal/harness/socket-bridge.js +215 -46
  32. package/dist/internal/harness/task-supervisor.d.ts +6 -2
  33. package/dist/internal/harness/task-supervisor.js +97 -15
  34. package/dist/internal/harness/tool-calls.d.ts +40 -0
  35. package/dist/internal/harness/tool-calls.js +132 -0
  36. package/dist/internal/harness/tool-policy.d.ts +2 -2
  37. package/dist/internal/harness/tool-runtime.d.ts +2 -2
  38. package/dist/internal/harness/tool-runtime.js +37 -40
  39. package/dist/internal/harness/tools.d.ts +21 -0
  40. package/dist/internal/harness/tools.js +7 -1
  41. package/dist/internal/harness/usage-tracker.d.ts +39 -0
  42. package/dist/internal/harness/usage-tracker.js +104 -0
  43. package/dist/internal/harness/voice-session.d.ts +39 -8
  44. package/dist/internal/harness/voice-session.js +353 -58
  45. package/dist/internal/harness/xo.d.ts +7 -12
  46. package/dist/internal/harness/xo.js +25 -13
  47. package/dist/internal/protocol/async-queue.d.ts +22 -1
  48. package/dist/internal/protocol/async-queue.js +86 -12
  49. package/dist/internal/protocol/audio.d.ts +16 -2
  50. package/dist/internal/protocol/audio.js +23 -5
  51. package/dist/internal/protocol/backend-output.d.ts +70 -0
  52. package/dist/internal/protocol/backend-output.js +39 -0
  53. package/dist/internal/protocol/event-json.d.ts +3 -0
  54. package/dist/internal/protocol/event-json.js +15 -0
  55. package/dist/internal/protocol/events.d.ts +342 -4
  56. package/dist/internal/protocol/events.js +53 -25
  57. package/dist/internal/protocol/index.d.ts +5 -0
  58. package/dist/internal/protocol/index.js +5 -0
  59. package/dist/internal/protocol/output-source.d.ts +18 -0
  60. package/dist/internal/protocol/output-source.js +15 -0
  61. package/dist/internal/protocol/paced-audio.d.ts +27 -0
  62. package/dist/internal/protocol/paced-audio.js +117 -0
  63. package/dist/internal/protocol/parts.d.ts +154 -0
  64. package/dist/internal/protocol/parts.js +32 -6
  65. package/dist/internal/protocol/provider.d.ts +308 -1
  66. package/dist/internal/protocol/provider.js +102 -3
  67. package/dist/internal/protocol/records.d.ts +773 -0
  68. package/dist/internal/protocol/records.js +46 -0
  69. package/dist/internal/protocol/tools.d.ts +40 -0
  70. package/dist/internal/protocol/tools.js +12 -0
  71. package/dist/internal/protocol/transcript.d.ts +19 -0
  72. package/dist/internal/protocol/transcript.js +53 -0
  73. package/dist/internal/provider/contract.d.ts +25 -2
  74. package/dist/internal/provider/event-queue.d.ts +17 -0
  75. package/dist/internal/provider/event-queue.js +78 -0
  76. package/dist/internal/provider/grok-voice.d.ts +12 -9
  77. package/dist/internal/provider/grok-voice.js +26 -15
  78. package/dist/internal/provider/index.d.ts +1 -0
  79. package/dist/internal/provider/index.js +1 -0
  80. package/dist/internal/provider/live-session.d.ts +29 -0
  81. package/dist/internal/provider/live-session.js +840 -0
  82. package/dist/internal/provider/node-socket.js +6 -0
  83. package/dist/internal/provider/openai-live.d.ts +62 -0
  84. package/dist/internal/provider/openai-live.js +160 -0
  85. package/dist/internal/provider/openai-realtime.d.ts +9 -5
  86. package/dist/internal/provider/openai-realtime.js +24 -10
  87. package/dist/internal/provider/realtime-session.d.ts +12 -1
  88. package/dist/internal/provider/realtime-session.js +373 -70
  89. package/dist/internal/provider/realtime-socket.d.ts +3 -1
  90. package/dist/internal/provider/tool-status-context.d.ts +10 -0
  91. package/dist/internal/provider/tool-status-context.js +30 -0
  92. package/dist/internal/provider/workers-socket.js +63 -15
  93. package/dist/internal/provider/workers.d.ts +1 -0
  94. package/dist/internal/provider/workers.js +1 -0
  95. package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
  96. package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
  97. package/dist/internal/storage/event-store.d.ts +22 -4
  98. package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
  99. package/dist/internal/storage/jsonl-event-store.js +21 -16
  100. package/dist/internal/storage/memory-event-store.d.ts +4 -3
  101. package/dist/internal/storage/memory-event-store.js +8 -2
  102. package/dist/internal/storage/memory.d.ts +1 -1
  103. package/dist/internal/testkit/events.d.ts +14 -0
  104. package/dist/internal/testkit/events.js +33 -0
  105. package/dist/internal/testkit/runtime.d.ts +3 -0
  106. package/dist/internal/testkit/runtime.js +3 -0
  107. package/dist/internal/testkit/trajectory.d.ts +23 -0
  108. package/dist/internal/testkit/trajectory.js +58 -0
  109. package/dist/internal/tools-openai/index.d.ts +2 -0
  110. package/dist/internal/tools-openai/index.js +79 -72
  111. package/dist/internal/tools-openai/responses.d.ts +5 -1
  112. package/dist/internal/tools-openai/responses.js +53 -5
  113. package/dist/testing.d.ts +1 -0
  114. package/dist/testing.js +1 -0
  115. 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 { HarnessEvent, ToolCall } from "../protocol/index.js";
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 HarnessEvent[]>;
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 HarnessEvent[]>;
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.#settleDenied(call, admission.reason);
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.#settleFailure(call.callId, `Tool not found: ${call.name}`);
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.#settleFailure(call.callId, errorMessage(error));
72
+ await this.#settle({ type: "tool.failed", callId: call.callId, error: errorMessage(error) });
63
73
  return;
64
74
  }
65
- await this.#settleCompleted(call.callId, outcome);
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
- this.#inFlight.delete(call.callId);
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 #settleCompleted(callId, outcome) {
118
- this.#assertNotTerminal(callId);
119
- this.#terminal.add(callId);
120
- try {
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({ type: "tool.failed", callId, error: message });
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, { type: "failed", error: message });
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 the session with max_duration_reached once elapsed. Guards runaway metered sessions. */
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 HarnessEvent[]>;
33
- sendAudio(chunk: AudioChunk): Promise<void>;
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
- * Injects a user turn. Accepts a plain string or multimodal `AgentInput` (text /
36
- * image / file parts). The parts are recorded on the `message.input` event, so the
37
- * log keeps full fidelity for projection; realtime providers only consume text, so
38
- * the collapsed text is what goes over the wire (media-native providers wire later).
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
  }