@junejs/server 1.0.0-dev.11 → 1.0.0-dev.13
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/agent-durable.d.ts +1 -0
- package/dist/agent-durable.js +133 -5
- package/package.json +2 -2
- package/src/agent-durable.ts +202 -6
package/dist/agent-durable.d.ts
CHANGED
package/dist/agent-durable.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isolateLocal } from "./isolate-local.js";
|
|
2
2
|
import { assertCoreRuntimeVersion } from "./core-version.js";
|
|
3
|
-
import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
|
|
3
|
+
import { DeliverUnsupportedError, channelDispatch, resolveChannel } from "@junejs/core/agent-config";
|
|
4
4
|
import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
|
|
5
5
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
6
6
|
//#region src/agent-durable.ts
|
|
@@ -81,6 +81,7 @@ var AgentDurableObject = class {
|
|
|
81
81
|
sink;
|
|
82
82
|
model;
|
|
83
83
|
tools;
|
|
84
|
+
channels;
|
|
84
85
|
channelInstructions;
|
|
85
86
|
resources;
|
|
86
87
|
services;
|
|
@@ -112,7 +113,8 @@ var AgentDurableObject = class {
|
|
|
112
113
|
}
|
|
113
114
|
defaultLog();
|
|
114
115
|
});
|
|
115
|
-
const
|
|
116
|
+
const channels = (def.channels ?? []).map((c) => resolveChannel(c, def.env));
|
|
117
|
+
const channelTools = channels.flatMap((c) => c.tools?.() ?? []);
|
|
116
118
|
const tools = [...def.tools];
|
|
117
119
|
const seen = new Set(tools.map((t) => t.spec.name));
|
|
118
120
|
for (const t of channelTools) {
|
|
@@ -125,6 +127,7 @@ var AgentDurableObject = class {
|
|
|
125
127
|
this.sink = sink;
|
|
126
128
|
this.model = model;
|
|
127
129
|
this.tools = tools;
|
|
130
|
+
this.channels = channels;
|
|
128
131
|
this.channelInstructions = def.channelInstructions;
|
|
129
132
|
}
|
|
130
133
|
resolveSession(key) {
|
|
@@ -168,6 +171,13 @@ var AgentDurableObject = class {
|
|
|
168
171
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
169
172
|
const { userText, turnId, event, trigger } = await req.json();
|
|
170
173
|
await ensureScope();
|
|
174
|
+
const wantsDeliver = url.searchParams.get("deliver") === "1";
|
|
175
|
+
let deliverChannel;
|
|
176
|
+
if (wantsDeliver) {
|
|
177
|
+
if (!event?.channelId) return Response.json({ error: "deliver=1 needs an inbound event (event.channelId names the reply target)" }, { status: 400 });
|
|
178
|
+
deliverChannel = this.channels.find((c) => c.name === event.source);
|
|
179
|
+
if (!deliverChannel?.deliver) return Response.json({ error: `agent "${this.name}": no deliver()-capable channel named "${event.source}" is wired into this DO (DoAgentDef.channels) — the turn was NOT started` }, { status: 501 });
|
|
180
|
+
}
|
|
171
181
|
let session;
|
|
172
182
|
let started;
|
|
173
183
|
try {
|
|
@@ -184,12 +194,41 @@ var AgentDurableObject = class {
|
|
|
184
194
|
} catch (err) {
|
|
185
195
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
186
196
|
}
|
|
197
|
+
if (wantsDeliver) {
|
|
198
|
+
const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
|
|
199
|
+
try {
|
|
200
|
+
const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
|
|
201
|
+
const events = { [Symbol.asyncIterator]: () => it };
|
|
202
|
+
const target = {
|
|
203
|
+
channelId: event.channelId,
|
|
204
|
+
threadId: event.threadId,
|
|
205
|
+
recipientUserId: event.user?.id,
|
|
206
|
+
recipientTeamId: event.teamId
|
|
207
|
+
};
|
|
208
|
+
Promise.resolve().then(() => runInScope({
|
|
209
|
+
resources: this.resources,
|
|
210
|
+
services: this.services
|
|
211
|
+
}, () => deliverChannel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
|
|
212
|
+
it.return?.();
|
|
213
|
+
});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
logRenderFailure(err);
|
|
216
|
+
}
|
|
217
|
+
return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
218
|
+
}
|
|
187
219
|
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
188
220
|
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
189
221
|
}
|
|
190
222
|
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
191
|
-
const { turnId, inputId, input, by } = await req.json();
|
|
223
|
+
const { turnId, inputId, input, by, source, target } = await req.json();
|
|
192
224
|
await ensureScope();
|
|
225
|
+
const wantsDeliver = url.searchParams.get("deliver") === "1";
|
|
226
|
+
let resumeChannel;
|
|
227
|
+
if (wantsDeliver) {
|
|
228
|
+
if (!source || !target?.channelId || !target.messageTs) return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
|
|
229
|
+
resumeChannel = this.channels.find((c) => c.name === source);
|
|
230
|
+
if (!resumeChannel?.deliverResume) return Response.json({ error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` }, { status: 501 });
|
|
231
|
+
}
|
|
193
232
|
let session;
|
|
194
233
|
try {
|
|
195
234
|
session = this.resolveSession(key);
|
|
@@ -201,6 +240,22 @@ var AgentDurableObject = class {
|
|
|
201
240
|
const message = err instanceof Error ? err.message : String(err);
|
|
202
241
|
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
203
242
|
}
|
|
243
|
+
if (wantsDeliver) {
|
|
244
|
+
const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
|
|
245
|
+
try {
|
|
246
|
+
const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
|
|
247
|
+
const events = { [Symbol.asyncIterator]: () => it };
|
|
248
|
+
Promise.resolve().then(() => runInScope({
|
|
249
|
+
resources: this.resources,
|
|
250
|
+
services: this.services
|
|
251
|
+
}, () => resumeChannel.deliverResume(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
|
|
252
|
+
it.return?.();
|
|
253
|
+
});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
logRenderFailure(err);
|
|
256
|
+
}
|
|
257
|
+
return Response.json({ turnId }, { status: 202 });
|
|
258
|
+
}
|
|
204
259
|
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
205
260
|
}
|
|
206
261
|
if (url.pathname.endsWith("/transcript")) {
|
|
@@ -248,6 +303,49 @@ function sseTurnStream(session, turnId) {
|
|
|
248
303
|
}
|
|
249
304
|
});
|
|
250
305
|
}
|
|
306
|
+
function observeTurnEvents(session, turnId) {
|
|
307
|
+
const queue = [];
|
|
308
|
+
let terminal = false;
|
|
309
|
+
let notify;
|
|
310
|
+
const unsub = session.observe((e) => {
|
|
311
|
+
queue.push(e);
|
|
312
|
+
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
|
|
313
|
+
notify?.();
|
|
314
|
+
}, { turnId });
|
|
315
|
+
return { [Symbol.asyncIterator]() {
|
|
316
|
+
return {
|
|
317
|
+
async next() {
|
|
318
|
+
for (;;) {
|
|
319
|
+
const e = queue.shift();
|
|
320
|
+
if (e) return {
|
|
321
|
+
value: e,
|
|
322
|
+
done: false
|
|
323
|
+
};
|
|
324
|
+
if (terminal) {
|
|
325
|
+
unsub();
|
|
326
|
+
return {
|
|
327
|
+
value: void 0,
|
|
328
|
+
done: true
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
await new Promise((resolve) => {
|
|
332
|
+
notify = resolve;
|
|
333
|
+
});
|
|
334
|
+
notify = void 0;
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
async return() {
|
|
338
|
+
unsub();
|
|
339
|
+
terminal = true;
|
|
340
|
+
queue.length = 0;
|
|
341
|
+
return {
|
|
342
|
+
value: void 0,
|
|
343
|
+
done: true
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
} };
|
|
348
|
+
}
|
|
251
349
|
async function sseTurnFinalText(res) {
|
|
252
350
|
const ct = res.headers.get("content-type") ?? "";
|
|
253
351
|
if (!res.body || !ct.includes("text/event-stream")) {
|
|
@@ -371,6 +469,20 @@ function durableChannelSurface(getNamespace, opts) {
|
|
|
371
469
|
}
|
|
372
470
|
return await res.json();
|
|
373
471
|
},
|
|
472
|
+
runDelivered: async (message, o) => {
|
|
473
|
+
const namespace = getNamespace();
|
|
474
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
475
|
+
const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?deliver=1", {
|
|
476
|
+
method: "POST",
|
|
477
|
+
body: serializeTurn(message, o)
|
|
478
|
+
}));
|
|
479
|
+
if (res.status === 501) throw new DeliverUnsupportedError(`runDelivered: ${(await res.json().catch(() => void 0))?.error ?? "the turn host cannot deliver this turn"}`);
|
|
480
|
+
if (res.status !== 202) {
|
|
481
|
+
const detail = (await res.text()).slice(0, 200);
|
|
482
|
+
throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
|
|
483
|
+
}
|
|
484
|
+
return await res.json();
|
|
485
|
+
},
|
|
374
486
|
runStream: async function* (message, o) {
|
|
375
487
|
const namespace = getNamespace();
|
|
376
488
|
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
@@ -386,6 +498,20 @@ function durableChannelSurface(getNamespace, opts) {
|
|
|
386
498
|
method: "POST",
|
|
387
499
|
body: serializeResume(o)
|
|
388
500
|
})));
|
|
501
|
+
},
|
|
502
|
+
resumeDelivered: async (o) => {
|
|
503
|
+
const namespace = getNamespace();
|
|
504
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
505
|
+
const res = await durableFetch(namespace, opts.agentName, o.session ?? "default", new Request("https://do/resume?deliver=1", {
|
|
506
|
+
method: "POST",
|
|
507
|
+
body: serializeResume(o)
|
|
508
|
+
}));
|
|
509
|
+
if (res.status === 501) throw new DeliverUnsupportedError(`resumeDelivered: ${(await res.json().catch(() => void 0))?.error ?? "the turn host cannot deliver this continuation"}`);
|
|
510
|
+
if (res.status !== 202) {
|
|
511
|
+
const detail = (await res.text()).slice(0, 200);
|
|
512
|
+
throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
|
|
513
|
+
}
|
|
514
|
+
return await res.json();
|
|
389
515
|
}
|
|
390
516
|
});
|
|
391
517
|
}
|
|
@@ -395,10 +521,12 @@ function serializeResume(o) {
|
|
|
395
521
|
turnId: o.turnId,
|
|
396
522
|
inputId: o.inputId,
|
|
397
523
|
input: o.input,
|
|
398
|
-
by: o.by
|
|
524
|
+
by: o.by,
|
|
525
|
+
source: o.source,
|
|
526
|
+
target: o.target
|
|
399
527
|
});
|
|
400
528
|
} catch (err) {
|
|
401
|
-
throw new Error(`
|
|
529
|
+
throw new Error(`resume: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
|
|
402
530
|
}
|
|
403
531
|
}
|
|
404
532
|
function serializeTurn(userText, o) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "1.0.0-dev.
|
|
3
|
+
"version": "1.0.0-dev.13",
|
|
4
4
|
"description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"rolldown": "^1.1.0"
|
|
133
133
|
},
|
|
134
134
|
"peerDependencies": {
|
|
135
|
-
"@junejs/core": ">=0.2.0-dev.
|
|
135
|
+
"@junejs/core": ">=0.2.0-dev.30",
|
|
136
136
|
"react": "^19.2.0",
|
|
137
137
|
"react-dom": "^19.2.0",
|
|
138
138
|
"react-server-dom-webpack": "19.2.7",
|
package/src/agent-durable.ts
CHANGED
|
@@ -34,11 +34,13 @@ import {
|
|
|
34
34
|
import type { Resources } from "@junejs/core/resources";
|
|
35
35
|
import {
|
|
36
36
|
channelDispatch,
|
|
37
|
+
DeliverUnsupportedError,
|
|
37
38
|
resolveChannel,
|
|
38
39
|
type AgentDefinition,
|
|
39
40
|
type Channel,
|
|
40
41
|
type ChannelContext,
|
|
41
42
|
type ChannelFactory,
|
|
43
|
+
type ResumeDeliveryTarget,
|
|
42
44
|
} from "@junejs/core/agent-config";
|
|
43
45
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
44
46
|
import { isolateLocal } from "./isolate-local";
|
|
@@ -227,6 +229,7 @@ export class AgentDurableObject {
|
|
|
227
229
|
private readonly sink: InProcEventSink;
|
|
228
230
|
private readonly model: Model;
|
|
229
231
|
private readonly tools: Tool[];
|
|
232
|
+
private readonly channels: Channel[];
|
|
230
233
|
private readonly channelInstructions?: Record<string, string>;
|
|
231
234
|
// Built from the DO's env in the constructor (this isolate), shared across turns —
|
|
232
235
|
// env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
|
|
@@ -277,8 +280,11 @@ export class AgentDurableObject {
|
|
|
277
280
|
});
|
|
278
281
|
// Merge the mounted channels' capability tools (built here from this DO's env, since a
|
|
279
282
|
// tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
|
|
280
|
-
// keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
|
|
281
|
-
|
|
283
|
+
// keeps a Slack tool inert during a Crisp turn, so merging all of them is safe. The
|
|
284
|
+
// resolved channels are also RETAINED (this.channels): a delivered turn (/turn?deliver=1)
|
|
285
|
+
// renders its reply through the source channel's deliver() from inside this DO.
|
|
286
|
+
const channels = (def.channels ?? []).map((c) => resolveChannel(c, def.env));
|
|
287
|
+
const channelTools = channels.flatMap((c) => c.tools?.() ?? []);
|
|
282
288
|
const tools: Tool[] = [...def.tools];
|
|
283
289
|
const seen = new Set(tools.map((t) => t.spec.name));
|
|
284
290
|
for (const t of channelTools) {
|
|
@@ -291,6 +297,7 @@ export class AgentDurableObject {
|
|
|
291
297
|
this.sink = sink;
|
|
292
298
|
this.model = model;
|
|
293
299
|
this.tools = tools;
|
|
300
|
+
this.channels = channels;
|
|
294
301
|
this.channelInstructions = def.channelInstructions;
|
|
295
302
|
}
|
|
296
303
|
// Resolve THE session for this DO: explicit key (from a routed request) → persisted
|
|
@@ -371,6 +378,26 @@ export class AgentDurableObject {
|
|
|
371
378
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
372
379
|
const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
|
|
373
380
|
await ensureScope();
|
|
381
|
+
// DELIVERED: like detach=1, the caller gets a 202 and goes away — but the reply is NOT
|
|
382
|
+
// dropped: this DO renders the turn's event stream through the source channel's own
|
|
383
|
+
// deliver() (the inbound renderer), under the DO's lifetime. Capability is checked
|
|
384
|
+
// BEFORE the turn starts — that ordering is DeliverUnsupportedError's contract at the
|
|
385
|
+
// surface: on a pre-start rejection the caller may fall back to consumer-side rendering
|
|
386
|
+
// without double-running the turn.
|
|
387
|
+
const wantsDeliver = url.searchParams.get("deliver") === "1";
|
|
388
|
+
let deliverChannel: Channel | undefined;
|
|
389
|
+
if (wantsDeliver) {
|
|
390
|
+
if (!event?.channelId) {
|
|
391
|
+
return Response.json({ error: "deliver=1 needs an inbound event (event.channelId names the reply target)" }, { status: 400 });
|
|
392
|
+
}
|
|
393
|
+
deliverChannel = this.channels.find((c) => c.name === event.source);
|
|
394
|
+
if (!deliverChannel?.deliver) {
|
|
395
|
+
return Response.json(
|
|
396
|
+
{ error: `agent "${this.name}": no deliver()-capable channel named "${event.source}" is wired into this DO (DoAgentDef.channels) — the turn was NOT started` },
|
|
397
|
+
{ status: 501 },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
374
401
|
// start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
|
|
375
402
|
// db/services (ALS propagates to the .then continuation registered here); subscribing
|
|
376
403
|
// happens synchronously right after, before any event can emit.
|
|
@@ -384,6 +411,41 @@ export class AgentDurableObject {
|
|
|
384
411
|
// object's identity — a client-resolvable conflict, not a crash
|
|
385
412
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
386
413
|
}
|
|
414
|
+
if (wantsDeliver) {
|
|
415
|
+
// Subscribe synchronously (same guarantee sseTurnStream relies on: start() scheduled
|
|
416
|
+
// the turn, no event can have emitted yet), then render under THIS DO's lifetime —
|
|
417
|
+
// the retained promise is pending work that keeps the DO alive, so a long turn's
|
|
418
|
+
// rendering survives however briefly the caller held its connection. The event is
|
|
419
|
+
// present (checked above); its author/thread are the reply target, exactly the
|
|
420
|
+
// surface the channel's inbound renderStream derives.
|
|
421
|
+
//
|
|
422
|
+
// Once start() has queued the turn, NOTHING here may escape: a throw would 500 the
|
|
423
|
+
// caller for a turn that runs anyway — the exact ambiguity delivered turns exist to
|
|
424
|
+
// remove. deliver() is app/third-party code that can throw synchronously before
|
|
425
|
+
// returning its promise, so it is deferred into the chain (the eager subscription
|
|
426
|
+
// above stays synchronous — that timing guarantee must not move); the try/catch
|
|
427
|
+
// backstops the subscription itself. Rendering failures have no live consumer —
|
|
428
|
+
// this log is their only surfacing path (the TURN's own failures already surface
|
|
429
|
+
// via the #76 sink subscription).
|
|
430
|
+
const logRenderFailure = (err: unknown) =>
|
|
431
|
+
console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
|
|
432
|
+
try {
|
|
433
|
+
// Own the iterator here: observeTurnEvents subscribes EAGERLY, and a deliver()
|
|
434
|
+
// that throws before ever iterating would otherwise leave that subscription
|
|
435
|
+
// attached, buffering this turn's events forever. return() is idempotent, so
|
|
436
|
+
// closing in finally is safe whether deliver consumed the stream or died early.
|
|
437
|
+
const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
|
|
438
|
+
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
439
|
+
const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
|
|
440
|
+
Promise.resolve()
|
|
441
|
+
.then(() => runInScope({ resources: this.resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
|
|
442
|
+
.catch(logRenderFailure)
|
|
443
|
+
.finally(() => { void it.return?.(); });
|
|
444
|
+
} catch (err) {
|
|
445
|
+
logRenderFailure(err);
|
|
446
|
+
}
|
|
447
|
+
return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
448
|
+
}
|
|
387
449
|
// DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
|
|
388
450
|
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
389
451
|
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
@@ -396,8 +458,31 @@ export class AgentDurableObject {
|
|
|
396
458
|
// VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
|
|
397
459
|
// signature-checked Slack interaction payload), never expose this endpoint raw to clients.
|
|
398
460
|
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
399
|
-
const { turnId, inputId, input, by } = (await req.json()) as {
|
|
461
|
+
const { turnId, inputId, input, by, source, target } = (await req.json()) as {
|
|
462
|
+
turnId: string; inputId: string; input: unknown; by?: string;
|
|
463
|
+
source?: string; target?: ResumeDeliveryTarget;
|
|
464
|
+
};
|
|
400
465
|
await ensureScope();
|
|
466
|
+
// DELIVERED resume: like /turn?deliver=1, the caller 202s away and THIS DO renders the
|
|
467
|
+
// continuation through the source channel's deliverResume() — a long continuation no
|
|
468
|
+
// longer dies with the webhook isolate. Capability is checked BEFORE resume() applies
|
|
469
|
+
// the answer — the DeliverUnsupportedError contract at the surface: on a pre-effect
|
|
470
|
+
// refusal the caller may fall back to consumer-side rendering without double-answering
|
|
471
|
+
// (a second resume of an already-applied input would 409).
|
|
472
|
+
const wantsDeliver = url.searchParams.get("deliver") === "1";
|
|
473
|
+
let resumeChannel: Channel | undefined;
|
|
474
|
+
if (wantsDeliver) {
|
|
475
|
+
if (!source || !target?.channelId || !target.messageTs) {
|
|
476
|
+
return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
|
|
477
|
+
}
|
|
478
|
+
resumeChannel = this.channels.find((c) => c.name === source);
|
|
479
|
+
if (!resumeChannel?.deliverResume) {
|
|
480
|
+
return Response.json(
|
|
481
|
+
{ error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` },
|
|
482
|
+
{ status: 501 },
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
401
486
|
let session: AgentSession;
|
|
402
487
|
try {
|
|
403
488
|
session = this.resolveSession(key);
|
|
@@ -407,6 +492,28 @@ export class AgentDurableObject {
|
|
|
407
492
|
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
|
|
408
493
|
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
409
494
|
}
|
|
495
|
+
if (wantsDeliver) {
|
|
496
|
+
// Same post-acceptance discipline as delivered turns: subscribe synchronously, render
|
|
497
|
+
// under this DO's lifetime, and let NOTHING escape past the accepted resume — the log
|
|
498
|
+
// is the render failure's only surfacing path (the TURN's own failures surface via
|
|
499
|
+
// the #76 sink subscription).
|
|
500
|
+
const logRenderFailure = (err: unknown) =>
|
|
501
|
+
console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
|
|
502
|
+
try {
|
|
503
|
+
// Own the iterator (same rationale as the delivered-turn path): the eager
|
|
504
|
+
// subscription must be released even when deliverResume throws before
|
|
505
|
+
// iterating — e.g. its very first message update fails.
|
|
506
|
+
const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
|
|
507
|
+
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
508
|
+
Promise.resolve()
|
|
509
|
+
.then(() => runInScope({ resources: this.resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
|
|
510
|
+
.catch(logRenderFailure)
|
|
511
|
+
.finally(() => { void it.return?.(); });
|
|
512
|
+
} catch (err) {
|
|
513
|
+
logRenderFailure(err);
|
|
514
|
+
}
|
|
515
|
+
return Response.json({ turnId }, { status: 202 });
|
|
516
|
+
}
|
|
410
517
|
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
411
518
|
}
|
|
412
519
|
if (url.pathname.endsWith("/transcript")) {
|
|
@@ -458,6 +565,45 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
|
|
|
458
565
|
});
|
|
459
566
|
}
|
|
460
567
|
|
|
568
|
+
// The IN-PROCESS sibling of sseTurnStream: one turn's TurnEvents as an AsyncIterable, for a
|
|
569
|
+
// consumer living in the SAME isolate as the session (a delivered render). Subscribes eagerly
|
|
570
|
+
// at call time — not at first iteration — so the sseTurnStream timing guarantee carries over
|
|
571
|
+
// (call this synchronously after start(), before any event can emit); events landing before
|
|
572
|
+
// the consumer catches up are buffered. Ends after the turn's terminal event (completed,
|
|
573
|
+
// failed, or input.requested — a park ends this stream; a later /resume is a new one), and an
|
|
574
|
+
// early consumer exit (for-await break/return) unsubscribes rather than buffering forever.
|
|
575
|
+
function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable<TurnEvent> {
|
|
576
|
+
const queue: TurnEvent[] = [];
|
|
577
|
+
let terminal = false;
|
|
578
|
+
let notify: (() => void) | undefined;
|
|
579
|
+
const unsub = session.observe((e) => {
|
|
580
|
+
queue.push(e);
|
|
581
|
+
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
|
|
582
|
+
notify?.();
|
|
583
|
+
}, { turnId });
|
|
584
|
+
return {
|
|
585
|
+
[Symbol.asyncIterator]() {
|
|
586
|
+
return {
|
|
587
|
+
async next(): Promise<IteratorResult<TurnEvent>> {
|
|
588
|
+
for (;;) {
|
|
589
|
+
const e = queue.shift();
|
|
590
|
+
if (e) return { value: e, done: false };
|
|
591
|
+
if (terminal) { unsub(); return { value: undefined, done: true }; }
|
|
592
|
+
await new Promise<void>((resolve) => { notify = resolve; });
|
|
593
|
+
notify = undefined;
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
async return(): Promise<IteratorResult<TurnEvent>> {
|
|
597
|
+
unsub();
|
|
598
|
+
terminal = true;
|
|
599
|
+
queue.length = 0;
|
|
600
|
+
return { value: undefined, done: true };
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
461
607
|
// Consume an SSE turn stream to its terminal state: the final text, or throw on failure.
|
|
462
608
|
// The non-streaming path (channels, a JSON chat response) uses this. Guards a non-SSE /
|
|
463
609
|
// body-less upstream response (a misroute or an error) into a clear error, not a TypeError.
|
|
@@ -705,6 +851,30 @@ export function durableChannelSurface(
|
|
|
705
851
|
}
|
|
706
852
|
return (await res.json()) as { turnId: string };
|
|
707
853
|
},
|
|
854
|
+
// DELIVERED: runDetached's reply-bearing sibling — the DO runs the turn AND renders its
|
|
855
|
+
// reply through the source channel's deliver() under its OWN lifetime, so a reply-bearing
|
|
856
|
+
// turn escapes the edge waitUntil ceiling exactly as a shadow turn does. A 501 means the
|
|
857
|
+
// DO refused BEFORE starting the turn (the channel isn't wired there / has no deliver) —
|
|
858
|
+
// surfaced as DeliverUnsupportedError, the one rejection a channel may answer with a
|
|
859
|
+
// consumer-side rendering fallback without double-running the turn.
|
|
860
|
+
runDelivered: async (message, o) => {
|
|
861
|
+
const namespace = getNamespace();
|
|
862
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
863
|
+
const res = await durableFetch(
|
|
864
|
+
namespace,
|
|
865
|
+
opts.agentName,
|
|
866
|
+
o?.session ?? "default",
|
|
867
|
+
new Request("https://do/turn?deliver=1", { method: "POST", body: serializeTurn(message, o) }),
|
|
868
|
+
);
|
|
869
|
+
if (res.status === 501) {
|
|
870
|
+
throw new DeliverUnsupportedError(`runDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this turn"}`);
|
|
871
|
+
}
|
|
872
|
+
if (res.status !== 202) {
|
|
873
|
+
const detail = (await res.text()).slice(0, 200);
|
|
874
|
+
throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
|
|
875
|
+
}
|
|
876
|
+
return (await res.json()) as { turnId: string };
|
|
877
|
+
},
|
|
708
878
|
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
709
879
|
runStream: async function* (message, o) {
|
|
710
880
|
const namespace = getNamespace();
|
|
@@ -729,6 +899,31 @@ export function durableChannelSurface(
|
|
|
729
899
|
);
|
|
730
900
|
yield* sseTurnEvents(res);
|
|
731
901
|
},
|
|
902
|
+
// DELIVERED resume (runDelivered's sibling for the HITL leg): the DO applies the answer
|
|
903
|
+
// AND renders the continuation through the source channel's deliverResume() under its
|
|
904
|
+
// OWN lifetime. A 501 means the DO refused BEFORE applying the answer (channel not
|
|
905
|
+
// wired there / no deliverResume) — surfaced as DeliverUnsupportedError, the one
|
|
906
|
+
// rejection a channel may answer with a consumer-side fallback without double-answering.
|
|
907
|
+
// Engine rejections (403 unauthorized clicker / 409 stale-or-double click) throw as
|
|
908
|
+
// ordinary errors, mirroring what resumeStream's first pull would do.
|
|
909
|
+
resumeDelivered: async (o) => {
|
|
910
|
+
const namespace = getNamespace();
|
|
911
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
912
|
+
const res = await durableFetch(
|
|
913
|
+
namespace,
|
|
914
|
+
opts.agentName,
|
|
915
|
+
o.session ?? "default",
|
|
916
|
+
new Request("https://do/resume?deliver=1", { method: "POST", body: serializeResume(o) }),
|
|
917
|
+
);
|
|
918
|
+
if (res.status === 501) {
|
|
919
|
+
throw new DeliverUnsupportedError(`resumeDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this continuation"}`);
|
|
920
|
+
}
|
|
921
|
+
if (res.status !== 202) {
|
|
922
|
+
const detail = (await res.text()).slice(0, 200);
|
|
923
|
+
throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
|
|
924
|
+
}
|
|
925
|
+
return (await res.json()) as { turnId: string };
|
|
926
|
+
},
|
|
732
927
|
};
|
|
733
928
|
return channelDispatch(resolved, ctx);
|
|
734
929
|
}
|
|
@@ -743,11 +938,12 @@ export function durableChannelSurface(
|
|
|
743
938
|
// (third-party) host could hand us something JSON.stringify chokes on (BigInt, circular).
|
|
744
939
|
// Unlike serializeTurn's `raw`, `input` is essential: silently dropping it would resume the
|
|
745
940
|
// turn with the wrong answer. So fail loudly with a clear message rather than corrupt the resume.
|
|
746
|
-
function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string }): string {
|
|
941
|
+
function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string; source?: string; target?: ResumeDeliveryTarget }): string {
|
|
747
942
|
try {
|
|
748
|
-
return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by });
|
|
943
|
+
return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by, source: o.source, target: o.target });
|
|
749
944
|
} catch (err) {
|
|
750
|
-
|
|
945
|
+
// API-neutral prefix: this serializer backs both resumeStream and resumeDelivered.
|
|
946
|
+
throw new Error(`resume: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
|
|
751
947
|
}
|
|
752
948
|
}
|
|
753
949
|
|