xo-harness 0.1.0 → 0.1.1
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/dist/internal/harness/task-supervisor.js +7 -1
- package/dist/internal/harness/tool-runtime.js +14 -2
- package/dist/internal/harness/voice-session.d.ts +2 -0
- package/dist/internal/harness/voice-session.js +42 -9
- package/dist/internal/harness/xo.d.ts +2 -0
- package/dist/internal/harness/xo.js +1 -0
- package/dist/internal/storage/jsonl-event-store.js +1 -1
- package/package.json +1 -1
|
@@ -149,8 +149,14 @@ export class TaskSupervisor {
|
|
|
149
149
|
const key = taskKey(event.taskId, event.callId);
|
|
150
150
|
if (this.#terminalTasks.has(key))
|
|
151
151
|
return false;
|
|
152
|
-
await this.#record(event);
|
|
153
152
|
this.#terminalTasks.add(key);
|
|
153
|
+
try {
|
|
154
|
+
await this.#record(event);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
this.#terminalTasks.delete(key);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
154
160
|
await this.#flush();
|
|
155
161
|
if (deliverContext)
|
|
156
162
|
await this.#deliverContext(event, "terminal");
|
|
@@ -54,15 +54,27 @@ export class ToolRuntime {
|
|
|
54
54
|
}
|
|
55
55
|
async #settleCompleted(callId, outcome) {
|
|
56
56
|
this.#assertNotTerminal(callId);
|
|
57
|
-
await this.#record({ type: "tool.completed", callId, outcome });
|
|
58
57
|
this.#terminal.add(callId);
|
|
58
|
+
try {
|
|
59
|
+
await this.#record({ type: "tool.completed", callId, outcome });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
this.#terminal.delete(callId);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
59
65
|
await this.#flush();
|
|
60
66
|
await this.#deliver(callId, outcome);
|
|
61
67
|
}
|
|
62
68
|
async #settleFailure(callId, message) {
|
|
63
69
|
this.#assertNotTerminal(callId);
|
|
64
|
-
await this.#record({ type: "tool.failed", callId, error: message });
|
|
65
70
|
this.#terminal.add(callId);
|
|
71
|
+
try {
|
|
72
|
+
await this.#record({ type: "tool.failed", callId, error: message });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
this.#terminal.delete(callId);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
66
78
|
await this.#flush();
|
|
67
79
|
await this.#deliver(callId, { type: "failed", error: message });
|
|
68
80
|
}
|
|
@@ -5,6 +5,8 @@ import type { ToolRegistry } from "./tools.js";
|
|
|
5
5
|
export interface CreateVoiceSessionOptions {
|
|
6
6
|
sessionId: string;
|
|
7
7
|
provider: VoiceProvider;
|
|
8
|
+
/** Cancels provider startup and closes the live session when its owner is revoked. */
|
|
9
|
+
signal?: AbortSignal;
|
|
8
10
|
store: EventStore;
|
|
9
11
|
tools: ToolRegistry;
|
|
10
12
|
instructions?: string;
|
|
@@ -13,6 +13,7 @@ export class VoiceSession {
|
|
|
13
13
|
#pendingRecords = new Set();
|
|
14
14
|
#tasks;
|
|
15
15
|
#toolRuntime;
|
|
16
|
+
#detachOwnerAbort;
|
|
16
17
|
#providerPump;
|
|
17
18
|
#closePromise;
|
|
18
19
|
#maxDurationTimer;
|
|
@@ -28,7 +29,7 @@ export class VoiceSession {
|
|
|
28
29
|
flush: () => this.#store.flush(this.id),
|
|
29
30
|
sessionSignal: this.#controller.signal,
|
|
30
31
|
onError: () => {
|
|
31
|
-
|
|
32
|
+
this.#closeFromInternal("storage_error");
|
|
32
33
|
},
|
|
33
34
|
});
|
|
34
35
|
this.#toolRuntime = new ToolRuntime({
|
|
@@ -43,6 +44,10 @@ export class VoiceSession {
|
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
static async create(options) {
|
|
47
|
+
const existingEvents = await options.store.list(options.sessionId);
|
|
48
|
+
if (existingEvents.length > 0) {
|
|
49
|
+
throw new Error(`Session id already exists: ${options.sessionId}`);
|
|
50
|
+
}
|
|
46
51
|
const startedAt = performance.now();
|
|
47
52
|
const startedEvent = await options.store.append(createUnsequencedEvent(options.sessionId, startedAt, {
|
|
48
53
|
type: "session.started",
|
|
@@ -55,7 +60,15 @@ export class VoiceSession {
|
|
|
55
60
|
: { outputModalities: [...options.outputModalities] }),
|
|
56
61
|
}));
|
|
57
62
|
await options.store.flush(options.sessionId);
|
|
63
|
+
if (options.signal?.aborted) {
|
|
64
|
+
await recordFailedCreation(options.store, options.sessionId, startedAt, "owner_revoked");
|
|
65
|
+
throw options.signal.reason ?? new Error("Session owner revoked before startup");
|
|
66
|
+
}
|
|
58
67
|
const providerController = new AbortController();
|
|
68
|
+
const abortProviderFromOwner = () => providerController.abort(options.signal?.reason ?? "owner_revoked");
|
|
69
|
+
if (options.signal?.aborted)
|
|
70
|
+
abortProviderFromOwner();
|
|
71
|
+
options.signal?.addEventListener("abort", abortProviderFromOwner, { once: true });
|
|
59
72
|
let providerSession;
|
|
60
73
|
try {
|
|
61
74
|
providerSession = await options.provider.createSession({
|
|
@@ -67,18 +80,33 @@ export class VoiceSession {
|
|
|
67
80
|
});
|
|
68
81
|
}
|
|
69
82
|
catch (error) {
|
|
83
|
+
options.signal?.removeEventListener("abort", abortProviderFromOwner);
|
|
70
84
|
providerController.abort(error);
|
|
71
|
-
await recordFailedCreation(options.store, options.sessionId, startedAt);
|
|
85
|
+
await recordFailedCreation(options.store, options.sessionId, startedAt, options.signal?.aborted ? "owner_revoked" : "provider_create_failed");
|
|
72
86
|
throw error;
|
|
73
87
|
}
|
|
88
|
+
if (options.signal?.aborted) {
|
|
89
|
+
options.signal.removeEventListener("abort", abortProviderFromOwner);
|
|
90
|
+
await providerSession.close("owner_revoked").catch(() => undefined);
|
|
91
|
+
await recordFailedCreation(options.store, options.sessionId, startedAt, "owner_revoked");
|
|
92
|
+
throw options.signal.reason ?? new Error("Session owner revoked during startup");
|
|
93
|
+
}
|
|
74
94
|
const session = new VoiceSession(options, providerSession, startedAt, startedEvent);
|
|
75
95
|
session.#controller.signal.addEventListener("abort", () => providerController.abort(session.#controller.signal.reason), {
|
|
76
96
|
once: true,
|
|
77
97
|
});
|
|
98
|
+
options.signal?.removeEventListener("abort", abortProviderFromOwner);
|
|
99
|
+
if (options.signal) {
|
|
100
|
+
const abortSessionFromOwner = () => {
|
|
101
|
+
session.#closeFromInternal("owner_revoked");
|
|
102
|
+
};
|
|
103
|
+
options.signal.addEventListener("abort", abortSessionFromOwner, { once: true });
|
|
104
|
+
session.#detachOwnerAbort = () => options.signal?.removeEventListener("abort", abortSessionFromOwner);
|
|
105
|
+
}
|
|
78
106
|
session.#providerPump = session.#pumpProviderEvents();
|
|
79
107
|
if (options.maxDurationMs !== undefined) {
|
|
80
108
|
session.#maxDurationTimer = setTimeout(() => {
|
|
81
|
-
|
|
109
|
+
session.#closeFromInternal("max_duration_reached");
|
|
82
110
|
}, options.maxDurationMs);
|
|
83
111
|
}
|
|
84
112
|
return session;
|
|
@@ -131,7 +159,12 @@ export class VoiceSession {
|
|
|
131
159
|
this.#closePromise ??= this.#performClose(reason);
|
|
132
160
|
return this.#closePromise;
|
|
133
161
|
}
|
|
162
|
+
#closeFromInternal(reason) {
|
|
163
|
+
void this.close(reason).catch(() => undefined);
|
|
164
|
+
}
|
|
134
165
|
async #performClose(reason) {
|
|
166
|
+
this.#detachOwnerAbort?.();
|
|
167
|
+
this.#detachOwnerAbort = undefined;
|
|
135
168
|
if (this.#maxDurationTimer)
|
|
136
169
|
clearTimeout(this.#maxDurationTimer);
|
|
137
170
|
this.#controller.abort(reason);
|
|
@@ -198,11 +231,11 @@ export class VoiceSession {
|
|
|
198
231
|
recoverable: event.recoverable,
|
|
199
232
|
});
|
|
200
233
|
if (!event.recoverable)
|
|
201
|
-
|
|
234
|
+
this.#closeFromInternal("provider_error");
|
|
202
235
|
break;
|
|
203
236
|
case "closed":
|
|
204
237
|
await this.#record({ type: "provider.closed", reason: event.reason });
|
|
205
|
-
|
|
238
|
+
this.#closeFromInternal("provider_closed");
|
|
206
239
|
break;
|
|
207
240
|
}
|
|
208
241
|
}
|
|
@@ -221,14 +254,14 @@ export class VoiceSession {
|
|
|
221
254
|
this.#toolExecutions.add(execution);
|
|
222
255
|
void execution.then(() => this.#toolExecutions.delete(execution), () => {
|
|
223
256
|
this.#toolExecutions.delete(execution);
|
|
224
|
-
|
|
257
|
+
this.#closeFromInternal("storage_error");
|
|
225
258
|
});
|
|
226
259
|
}
|
|
227
260
|
#trackRecord(record) {
|
|
228
261
|
this.#pendingRecords.add(record);
|
|
229
262
|
void record.then(() => this.#pendingRecords.delete(record), () => {
|
|
230
263
|
this.#pendingRecords.delete(record);
|
|
231
|
-
|
|
264
|
+
this.#closeFromInternal("storage_error");
|
|
232
265
|
});
|
|
233
266
|
}
|
|
234
267
|
async #record(event) {
|
|
@@ -251,11 +284,11 @@ function createUnsequencedEvent(sessionId, startedAt, event) {
|
|
|
251
284
|
sessionTimeMs: performance.now() - startedAt,
|
|
252
285
|
};
|
|
253
286
|
}
|
|
254
|
-
async function recordFailedCreation(store, sessionId, startedAt) {
|
|
287
|
+
async function recordFailedCreation(store, sessionId, startedAt, reason) {
|
|
255
288
|
try {
|
|
256
289
|
await store.append(createUnsequencedEvent(sessionId, startedAt, {
|
|
257
290
|
type: "session.ended",
|
|
258
|
-
reason
|
|
291
|
+
reason,
|
|
259
292
|
}));
|
|
260
293
|
await store.flush(sessionId);
|
|
261
294
|
}
|
|
@@ -9,6 +9,8 @@ export interface XOOptions {
|
|
|
9
9
|
}
|
|
10
10
|
export interface StartSessionOptions {
|
|
11
11
|
provider: VoiceProvider;
|
|
12
|
+
/** Cancels provider startup and closes the live session when its owner is revoked. */
|
|
13
|
+
signal?: AbortSignal;
|
|
12
14
|
sessionId?: string;
|
|
13
15
|
instructions?: string;
|
|
14
16
|
/** What the model may produce this session; ["text"] disables audio output. Default: audio. */
|
|
@@ -14,6 +14,7 @@ export class XO {
|
|
|
14
14
|
return VoiceSession.create({
|
|
15
15
|
sessionId: options.sessionId ?? crypto.randomUUID(),
|
|
16
16
|
provider: options.provider,
|
|
17
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
17
18
|
store: this.store,
|
|
18
19
|
tools: this.tools,
|
|
19
20
|
...(options.instructions === undefined ? {} : { instructions: options.instructions }),
|
|
@@ -28,7 +28,7 @@ export class JsonlEventStore {
|
|
|
28
28
|
this.#assertSessionId(sessionId);
|
|
29
29
|
return this.#tails.run(sessionId, async () => {
|
|
30
30
|
try {
|
|
31
|
-
const file = await open(this.#sessionPath(sessionId), "r");
|
|
31
|
+
const file = await open(this.#sessionPath(sessionId), "r+");
|
|
32
32
|
try {
|
|
33
33
|
await file.sync();
|
|
34
34
|
}
|