@junejs/server 0.1.2-dev.9 → 0.1.2
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 +3 -39
- package/dist/agent-durable.js +26 -297
- package/dist/agent-native.d.ts +0 -2
- package/dist/agent-native.js +10 -28
- package/dist/worker.js +1 -1
- package/package.json +3 -3
- package/src/agent-durable.ts +25 -438
- package/src/agent-native.ts +13 -27
- package/src/worker.ts +5 -2
- package/dist/core-version.d.ts +0 -5
- package/dist/core-version.js +0 -12
- package/src/core-version.ts +0 -34
package/src/agent-durable.ts
CHANGED
|
@@ -17,19 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
AgentSession,
|
|
20
|
-
ResumeAuthorizationError,
|
|
21
20
|
withSystem,
|
|
22
|
-
type
|
|
23
|
-
type TurnEvent,
|
|
21
|
+
type Broadcaster,
|
|
24
22
|
type Model,
|
|
25
23
|
type Msg,
|
|
26
24
|
type Runtime,
|
|
27
|
-
type InboundEvent,
|
|
28
25
|
type SessionStore,
|
|
29
|
-
type ProactiveTrigger,
|
|
30
26
|
type Tool,
|
|
31
|
-
type TurnError,
|
|
32
|
-
type TurnFailurePhase,
|
|
33
27
|
} from "@junejs/core/agent-runtime";
|
|
34
28
|
import type { Resources } from "@junejs/core/resources";
|
|
35
29
|
import {
|
|
@@ -41,7 +35,6 @@ import {
|
|
|
41
35
|
type ChannelFactory,
|
|
42
36
|
} from "@junejs/core/agent-config";
|
|
43
37
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
44
|
-
import { assertCoreRuntimeVersion } from "./core-version";
|
|
45
38
|
|
|
46
39
|
// ── minimal structural Cloudflare surface (no @cloudflare/workers-types dep) ──
|
|
47
40
|
// SqlStorage row values are exactly what workerd's SQLite returns. Mirroring
|
|
@@ -100,8 +93,8 @@ export class DoSessionStore implements SessionStore {
|
|
|
100
93
|
messages(): Msg[] {
|
|
101
94
|
return this.sql.exec<{ body: string }>("SELECT body FROM agent_messages ORDER BY seq").toArray().map((r) => JSON.parse(r.body));
|
|
102
95
|
}
|
|
103
|
-
|
|
104
|
-
return this.messages().some((m) =>
|
|
96
|
+
hasUserTurn(turnId: string): boolean {
|
|
97
|
+
return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
|
|
105
98
|
}
|
|
106
99
|
getStep(id: string): unknown | undefined {
|
|
107
100
|
const rows = this.sql.exec<{ output: string }>("SELECT output FROM agent_steps WHERE id = ?", id).toArray();
|
|
@@ -110,9 +103,6 @@ export class DoSessionStore implements SessionStore {
|
|
|
110
103
|
putStep(id: string, output: unknown) {
|
|
111
104
|
this.sql.exec("INSERT INTO agent_steps (id, output) VALUES (?, ?)", id, JSON.stringify(output));
|
|
112
105
|
}
|
|
113
|
-
delStep(id: string) {
|
|
114
|
-
this.sql.exec("DELETE FROM agent_steps WHERE id = ?", id);
|
|
115
|
-
}
|
|
116
106
|
getStatus(): string {
|
|
117
107
|
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'status'").toArray();
|
|
118
108
|
return rows.length ? rows[0]!.v : "new";
|
|
@@ -120,17 +110,6 @@ export class DoSessionStore implements SessionStore {
|
|
|
120
110
|
setStatus(s: string) {
|
|
121
111
|
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
|
|
122
112
|
}
|
|
123
|
-
// The externally-assigned session key (#75). A DO cannot read its own idFromName
|
|
124
|
-
// name, so the key arrives on the first routed request and is persisted here —
|
|
125
|
-
// surviving hibernation/eviction, where the constructor re-runs with no request
|
|
126
|
-
// in hand. Not part of core's SessionStore contract: only the DO seam needs it.
|
|
127
|
-
getSessionKey(): string | undefined {
|
|
128
|
-
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
|
|
129
|
-
return rows.length ? rows[0]!.v : undefined;
|
|
130
|
-
}
|
|
131
|
-
setSessionKey(key: string) {
|
|
132
|
-
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
|
|
133
|
-
}
|
|
134
113
|
tx<T>(fn: () => T): T {
|
|
135
114
|
return this.storage.transactionSync(fn);
|
|
136
115
|
}
|
|
@@ -139,10 +118,10 @@ export class DoSessionStore implements SessionStore {
|
|
|
139
118
|
}
|
|
140
119
|
}
|
|
141
120
|
|
|
142
|
-
class
|
|
143
|
-
private subs = new Set<(
|
|
144
|
-
|
|
145
|
-
subscribe(cb: (
|
|
121
|
+
class InProcBroadcaster implements Broadcaster {
|
|
122
|
+
private subs = new Set<(t: string) => void>();
|
|
123
|
+
publish(turnId: string) { this.subs.forEach((cb) => { try { cb(turnId); } catch { /* a bad subscriber must not break publish */ } }); }
|
|
124
|
+
subscribe(cb: (t: string) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
|
|
146
125
|
}
|
|
147
126
|
|
|
148
127
|
// A DO can't run in-process subagents; a subagent there is a sibling DO (cross-DO
|
|
@@ -173,29 +152,8 @@ export type DoAgentDef = {
|
|
|
173
152
|
model: Model;
|
|
174
153
|
tools: Tool[];
|
|
175
154
|
instructions?: string;
|
|
176
|
-
// Per-channel-source system overlays (see AgentDefinition.channelInstructions) — the
|
|
177
|
-
// shared agent branches by real inbound source, no userText marker.
|
|
178
|
-
channelInstructions?: Record<string, string>;
|
|
179
|
-
// Channels whose CAPABILITY tools should be available to this session's turns. Their
|
|
180
|
-
// tools are built HERE, in the DO isolate, from `env` — because a channel's tool `run`
|
|
181
|
-
// is a closure (over the bot token, etc.) that can't cross the worker→DO RPC. This is
|
|
182
|
-
// the edge equivalent of defineAgent merging channel.tools() on native: pass the same
|
|
183
|
-
// channel factories you mount on the worker, plus this DO's env.
|
|
184
|
-
channels?: (Channel | ChannelFactory)[];
|
|
185
|
-
env?: unknown;
|
|
186
155
|
resources?: Resources;
|
|
187
156
|
services?: unknown;
|
|
188
|
-
// Called on every turn.failed for this session — the app's seam for routing turn
|
|
189
|
-
// failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
|
|
190
|
-
// default console.error; if the hook throws (or an async hook rejects), the default
|
|
191
|
-
// log fires anyway (both errors), so a failure is never silent. `error` carries the
|
|
192
|
-
// full serialized failure (stack, cause chain — see TurnError, #96), and phase/step
|
|
193
|
-
// name the engine step that was in flight — for a DETACHED turn this hook is the
|
|
194
|
-
// only failure-surfacing path, so nothing may be flattened before it fires. It
|
|
195
|
-
// happens to run inside the failed turn's request scope today (turn.failed is
|
|
196
|
-
// emitted mid-turn and ALS propagates), but that is NOT contract — don't rely on
|
|
197
|
-
// ambient db/services; close over what you need.
|
|
198
|
-
onTurnError?: (failure: { turnId: string; error: TurnError; phase?: TurnFailurePhase; step?: string }) => void | Promise<void>;
|
|
199
157
|
};
|
|
200
158
|
|
|
201
159
|
// The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
|
|
@@ -213,121 +171,17 @@ export type DoAgentDef = {
|
|
|
213
171
|
// fetch(req) { return this.agent.fetch(req); }
|
|
214
172
|
// }
|
|
215
173
|
export class AgentDurableObject {
|
|
216
|
-
|
|
217
|
-
// idFromName(`${agent}:${session}`) but cannot read its own name, so the true
|
|
218
|
-
// session key only exists on a routed request (the SESSION_HEADER durableFetch
|
|
219
|
-
// sets). First keyed request wins and persists the key (agent_meta) so it
|
|
220
|
-
// survives hibernation; key-less paths fall back to the persisted key, then
|
|
221
|
-
// "self" (the pre-#75 behavior, kept for backward compatibility).
|
|
222
|
-
private session: AgentSession | undefined;
|
|
223
|
-
private sessionKey: string | undefined;
|
|
224
|
-
private readonly name: string;
|
|
225
|
-
private readonly store: DoSessionStore;
|
|
226
|
-
private readonly sink: InProcEventSink;
|
|
227
|
-
private readonly model: Model;
|
|
228
|
-
private readonly tools: Tool[];
|
|
229
|
-
private readonly channelInstructions?: Record<string, string>;
|
|
174
|
+
private session: AgentSession;
|
|
230
175
|
// Built from the DO's env in the constructor (this isolate), shared across turns —
|
|
231
176
|
// env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
|
|
232
177
|
private readonly resources: Resources;
|
|
233
178
|
private readonly services: unknown;
|
|
234
179
|
constructor(state: DurableObjectState, def: DoAgentDef) {
|
|
235
|
-
assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`); // #94: fail power-on, not mid-turn
|
|
236
180
|
const store = new DoSessionStore(state.storage);
|
|
237
181
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
238
182
|
this.resources = def.resources ?? {};
|
|
239
183
|
this.services = def.services;
|
|
240
|
-
|
|
241
|
-
// Failure observability (#76): a turn that dies after the fast-ACK has no other
|
|
242
|
-
// observable surface on the edge — the webhook already 200'd and runBackground
|
|
243
|
-
// swallows the rejection unless the channel wired onError. Subscribing HERE (the
|
|
244
|
-
// session sink) covers every turn path — turn(), /turn, /resume — with one seam.
|
|
245
|
-
// Default: console.error, which wrangler tail surfaces. An app onTurnError takes
|
|
246
|
-
// over reporting; if IT throws, fall back to the default so nothing goes silent.
|
|
247
|
-
// (InProcEventSink already guards emit against a throwing subscriber.)
|
|
248
|
-
const sink = new InProcEventSink();
|
|
249
|
-
sink.subscribe((e) => {
|
|
250
|
-
if (e.type !== "turn.failed") return;
|
|
251
|
-
// The stack already opens with the message; log it INSTEAD of the message line
|
|
252
|
-
// when present so wrangler tail shows one coherent trace, not the message twice.
|
|
253
|
-
const defaultLog = () =>
|
|
254
|
-
console.error(`[june] agent "${name}" turn ${e.turnId} failed${e.step ? ` at ${e.step}` : ""}: ${e.error.stack ?? e.error.message}${e.error.causeChain?.length ? `\ncaused by: ${e.error.causeChain.join(" ← ")}` : ""}`);
|
|
255
|
-
if (def.onTurnError) {
|
|
256
|
-
try {
|
|
257
|
-
const out = def.onTurnError({ turnId: e.turnId, error: e.error, phase: e.phase, step: e.step });
|
|
258
|
-
// An async hook (e.g. `await sendToSentry(...)`) types as void but returns a
|
|
259
|
-
// Promise — a rejection there must hit the same fallback as a sync throw, or
|
|
260
|
-
// the failure goes silent again (plus an unhandled rejection). No waitUntil
|
|
261
|
-
// keeper is needed here: unlike a Worker, a DO stays active while it has
|
|
262
|
-
// pending work/IO, and DurableObjectState.waitUntil is documented as a no-op —
|
|
263
|
-
// this .then subscription is what retains the promise.
|
|
264
|
-
if (out && typeof (out as Promise<void>).then === "function") {
|
|
265
|
-
(out as Promise<void>).then(undefined, (hookErr) => {
|
|
266
|
-
console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
|
|
267
|
-
defaultLog();
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
return;
|
|
271
|
-
} catch (hookErr) {
|
|
272
|
-
console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
defaultLog();
|
|
276
|
-
});
|
|
277
|
-
// Merge the mounted channels' capability tools (built here from this DO's env, since a
|
|
278
|
-
// tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
|
|
279
|
-
// keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
|
|
280
|
-
const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
|
|
281
|
-
const tools: Tool[] = [...def.tools];
|
|
282
|
-
const seen = new Set(tools.map((t) => t.spec.name));
|
|
283
|
-
for (const t of channelTools) {
|
|
284
|
-
if (seen.has(t.spec.name)) throw new Error(`AgentDurableObject(${def.name ?? "agent"}): duplicate tool name "${t.spec.name}" from a channel — rename so dispatch is unambiguous.`);
|
|
285
|
-
seen.add(t.spec.name);
|
|
286
|
-
tools.push(t);
|
|
287
|
-
}
|
|
288
|
-
this.name = name;
|
|
289
|
-
this.store = store;
|
|
290
|
-
this.sink = sink;
|
|
291
|
-
this.model = model;
|
|
292
|
-
this.tools = tools;
|
|
293
|
-
this.channelInstructions = def.channelInstructions;
|
|
294
|
-
}
|
|
295
|
-
// Resolve THE session for this DO: explicit key (from a routed request) → persisted
|
|
296
|
-
// key (a prior life learned it) → "self". A key that contradicts the persisted or
|
|
297
|
-
// live identity is a mis-route (or a key-less path was used first) — fail loudly:
|
|
298
|
-
// silently proceeding is exactly the per-conversation data corruption #75 hit.
|
|
299
|
-
private resolveSession(key?: string): AgentSession {
|
|
300
|
-
// Every entry point shares the session-key contract (turn({ session }) and hand-rolled
|
|
301
|
-
// SESSION_HEADER values included, not just durableFetch): an invalid key persisted here
|
|
302
|
-
// would bind an identity no routed request could ever address — an orphaned session.
|
|
303
|
-
if (key !== undefined) assertSessionKey(key);
|
|
304
|
-
// Live fast path — no storage read once the session exists: this method is the only
|
|
305
|
-
// writer of the persisted key, so within one life it cannot diverge from the live one.
|
|
306
|
-
if (this.session) {
|
|
307
|
-
if (key !== undefined && this.sessionKey !== key) {
|
|
308
|
-
throw new Error(
|
|
309
|
-
this.sessionKey === "self"
|
|
310
|
-
? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)`
|
|
311
|
-
: `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`,
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
return this.session;
|
|
315
|
-
}
|
|
316
|
-
const stored = this.store.getSessionKey();
|
|
317
|
-
if (key !== undefined && stored !== undefined && key !== stored) {
|
|
318
|
-
throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
|
|
319
|
-
}
|
|
320
|
-
const resolved = key ?? stored ?? "self";
|
|
321
|
-
// "self" is deliberately NOT persisted — even when a caller passes it EXPLICITLY: it
|
|
322
|
-
// is a placeholder ("no identity externally assigned yet"), not an identity. Within
|
|
323
|
-
// one life a keyed request after a "self" binding is refused above — two concurrently
|
|
324
|
-
// active paths disagreeing is a live bug. But across eviction a keyed request ADOPTS
|
|
325
|
-
// the legacy transcript: that asymmetry IS the migration path from pre-keyed
|
|
326
|
-
// deployments, where a persisted "self" would 409 every keyed request forever.
|
|
327
|
-
if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
|
|
328
|
-
this.sessionKey = resolved;
|
|
329
|
-
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
330
|
-
return this.session;
|
|
184
|
+
this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcBroadcaster(), model, def.tools, crossDoUnsupported);
|
|
331
185
|
}
|
|
332
186
|
// Run the whole turn inside a request scope seeded from this DO's env, so ambient
|
|
333
187
|
// `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
|
|
@@ -336,205 +190,30 @@ export class AgentDurableObject {
|
|
|
336
190
|
// Juno's batch-loader registry) can't leak across turns on a long-lived DO.
|
|
337
191
|
// ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
|
|
338
192
|
// pipeline does; without it runInScope is a pass-through and ambient reads throw.
|
|
339
|
-
async turn(input: { turnId?: string; userText: string
|
|
193
|
+
async turn(input: { turnId?: string; userText: string }): Promise<string> {
|
|
340
194
|
await ensureScope();
|
|
341
|
-
|
|
342
|
-
return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
|
|
195
|
+
return runInScope({ resources: this.resources, services: this.services }, () => this.session.turn(input));
|
|
343
196
|
}
|
|
344
|
-
// Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
|
|
345
|
-
// it then runs under the DO's own lifetime (a DO stays alive while it has pending
|
|
346
|
-
// work), not the caller's. No live consumer sees the result — failures surface via
|
|
347
|
-
// the default turn-failure log / onTurnError (#76).
|
|
348
|
-
async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
|
|
349
|
-
await ensureScope();
|
|
350
|
-
const session = this.resolveSession(input.session);
|
|
351
|
-
return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
|
|
352
|
-
}
|
|
353
|
-
// Read-only: folds the durable log. When an identity exists (live, or persisted from a
|
|
354
|
-
// prior life) the session resolves and caches like any other path. Only a read on a
|
|
355
|
-
// NEVER-keyed object stays non-committal — caching there would bake "self" in as the
|
|
356
|
-
// identity and 409 a later keyed turn — so it folds through an ephemeral AgentSession.
|
|
357
197
|
transcript() {
|
|
358
|
-
|
|
359
|
-
if (this.store.getSessionKey() !== undefined) return this.resolveSession().transcript();
|
|
360
|
-
return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
|
|
198
|
+
return this.session.transcript();
|
|
361
199
|
}
|
|
362
|
-
// Default HTTP surface: POST …/turn
|
|
363
|
-
//
|
|
364
|
-
// The app can call turn()/transcript() directly instead (turn() awaits the final text).
|
|
200
|
+
// Default HTTP surface: POST …/turn runs a durable turn; GET …/transcript reads
|
|
201
|
+
// the log. The app can call turn()/transcript() directly instead.
|
|
365
202
|
async fetch(req: Request): Promise<Response> {
|
|
366
203
|
const url = new URL(req.url);
|
|
367
|
-
// The session key durableFetch stamped on the routed request (#75); absent on a
|
|
368
|
-
// hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
|
|
369
|
-
const key = req.headers.get(SESSION_HEADER) ?? undefined;
|
|
370
204
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
371
|
-
const { userText, turnId
|
|
372
|
-
await
|
|
373
|
-
// start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
|
|
374
|
-
// db/services (ALS propagates to the .then continuation registered here); subscribing
|
|
375
|
-
// happens synchronously right after, before any event can emit.
|
|
376
|
-
let session: AgentSession;
|
|
377
|
-
let started: { turnId: string };
|
|
378
|
-
try {
|
|
379
|
-
session = this.resolveSession(key);
|
|
380
|
-
started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
|
|
381
|
-
} catch (err) {
|
|
382
|
-
// e.g. the session is suspended awaiting input, or the key mis-matches this
|
|
383
|
-
// object's identity — a client-resolvable conflict, not a crash
|
|
384
|
-
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
385
|
-
}
|
|
386
|
-
// DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
|
|
387
|
-
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
388
|
-
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
389
|
-
// ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
|
|
390
|
-
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
391
|
-
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
392
|
-
}
|
|
393
|
-
// Provide the input a suspended turn is waiting on and stream its continuation as SSE.
|
|
394
|
-
// NOTE: this default surface passes `by` straight from the body — the engine treats it as a
|
|
395
|
-
// VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
|
|
396
|
-
// signature-checked Slack interaction payload), never expose this endpoint raw to clients.
|
|
397
|
-
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
398
|
-
const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
|
|
399
|
-
await ensureScope();
|
|
400
|
-
let session: AgentSession;
|
|
401
|
-
try {
|
|
402
|
-
session = this.resolveSession(key);
|
|
403
|
-
runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
|
|
404
|
-
} catch (err) {
|
|
405
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
406
|
-
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
|
|
407
|
-
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
408
|
-
}
|
|
409
|
-
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
410
|
-
}
|
|
411
|
-
if (url.pathname.endsWith("/transcript")) {
|
|
412
|
-
// A keyed read still conflict-checks (and may commit the key — it is authentic,
|
|
413
|
-
// idFromName routed it here); a key-less read stays non-committal via transcript().
|
|
414
|
-
if (key !== undefined) {
|
|
415
|
-
try {
|
|
416
|
-
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
417
|
-
} catch (err) {
|
|
418
|
-
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return Response.json({ transcript: this.transcript() });
|
|
422
|
-
}
|
|
423
|
-
return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// ── SSE transport for the turn event stream (crosses the worker→DO isolate) ───
|
|
428
|
-
// The DO streams a turn's TurnEvents as text/event-stream; the worker either pipes
|
|
429
|
-
// that straight to a browser (live chat) or collapses it to the final text (channels).
|
|
430
|
-
// no-store (not no-cache): an intermediary must NOT store chat/user content at all, and it
|
|
431
|
-
// matches the repo's other SSE surface (dev-reload).
|
|
432
|
-
const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-store" };
|
|
433
|
-
|
|
434
|
-
// A ReadableStream of SSE frames from a session's live event stream, scoped to one turn
|
|
435
|
-
// and closed on its terminal event. Subscribes synchronously (no replay needed — we start
|
|
436
|
-
// the turn then subscribe before any event can emit). A `:hb` comment heartbeat keeps the
|
|
437
|
-
// connection alive across long model/tool gaps (a silent SSE stream gets culled by idle
|
|
438
|
-
// timeouts on hosts/proxies) — cleared on terminal close and on cancel.
|
|
439
|
-
function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Uint8Array> {
|
|
440
|
-
const enc = new TextEncoder();
|
|
441
|
-
let unsub: (() => void) | undefined;
|
|
442
|
-
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
443
|
-
const stop = () => { unsub?.(); clearInterval(heartbeat); };
|
|
444
|
-
return new ReadableStream<Uint8Array>({
|
|
445
|
-
start(controller) {
|
|
446
|
-
heartbeat = setInterval(() => {
|
|
447
|
-
try { controller.enqueue(enc.encode(":hb\n\n")); } catch { clearInterval(heartbeat); }
|
|
448
|
-
}, 20_000);
|
|
449
|
-
unsub = session.observe((e) => {
|
|
450
|
-
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
451
|
-
// terminal for the STREAM: completed, failed, OR suspended (input.requested → the turn
|
|
452
|
-
// parked; the stream ends and a later /resume opens a fresh continuation stream).
|
|
453
|
-
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") { stop(); controller.close(); }
|
|
454
|
-
}, { turnId });
|
|
455
|
-
},
|
|
456
|
-
cancel() { stop(); },
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Consume an SSE turn stream to its terminal state: the final text, or throw on failure.
|
|
461
|
-
// The non-streaming path (channels, a JSON chat response) uses this. Guards a non-SSE /
|
|
462
|
-
// body-less upstream response (a misroute or an error) into a clear error, not a TypeError.
|
|
463
|
-
export async function sseTurnFinalText(res: Response): Promise<string> {
|
|
464
|
-
const ct = res.headers.get("content-type") ?? "";
|
|
465
|
-
if (!res.body || !ct.includes("text/event-stream")) {
|
|
466
|
-
const detail = res.body ? (await res.text()).slice(0, 200) : "no body";
|
|
467
|
-
throw new Error(`turn stream: expected an SSE response, got ${ct || "no content-type"} (status ${res.status}): ${detail}`);
|
|
468
|
-
}
|
|
469
|
-
const reader = res.body.getReader();
|
|
470
|
-
const dec = new TextDecoder();
|
|
471
|
-
let buf = "";
|
|
472
|
-
for (;;) {
|
|
473
|
-
const { done, value } = await reader.read();
|
|
474
|
-
if (value) buf += dec.decode(value, { stream: true });
|
|
475
|
-
let i: number;
|
|
476
|
-
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
477
|
-
const line = buf.slice(0, i).split("\n").find((l) => l.startsWith("data:"));
|
|
478
|
-
buf = buf.slice(i + 2);
|
|
479
|
-
if (!line) continue;
|
|
480
|
-
const e = JSON.parse(line.slice(5).trim()) as TurnEvent;
|
|
481
|
-
if (e.type === "turn.completed") return e.text;
|
|
482
|
-
if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error }); // full TurnError rides along
|
|
205
|
+
const { userText, turnId } = (await req.json()) as { userText: string; turnId?: string };
|
|
206
|
+
return Response.json({ text: await this.turn({ userText, turnId }) });
|
|
483
207
|
}
|
|
484
|
-
if (
|
|
485
|
-
|
|
486
|
-
throw new Error("turn stream ended without a terminal event");
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Parse an SSE turn response into the stream of TurnEvents (skipping `:hb` heartbeats).
|
|
490
|
-
// The streaming consumer (a channel's render path) iterates this to drive live UI.
|
|
491
|
-
export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
|
|
492
|
-
const ct = res.headers.get("content-type") ?? "";
|
|
493
|
-
if (!res.body || !ct.includes("text/event-stream")) {
|
|
494
|
-
const detail = res.body ? (await res.text()).slice(0, 200) : "no body";
|
|
495
|
-
throw new Error(`turn stream: expected an SSE response, got ${ct || "no content-type"} (status ${res.status}): ${detail}`);
|
|
496
|
-
}
|
|
497
|
-
const reader = res.body.getReader();
|
|
498
|
-
const dec = new TextDecoder();
|
|
499
|
-
let buf = "";
|
|
500
|
-
for (;;) {
|
|
501
|
-
const { done, value } = await reader.read();
|
|
502
|
-
if (value) buf += dec.decode(value, { stream: true });
|
|
503
|
-
let i: number;
|
|
504
|
-
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
505
|
-
const line = buf.slice(0, i).split("\n").find((l) => l.startsWith("data:"));
|
|
506
|
-
buf = buf.slice(i + 2);
|
|
507
|
-
if (line) yield JSON.parse(line.slice(5).trim()) as TurnEvent;
|
|
508
|
-
}
|
|
509
|
-
if (done) break;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// The header durableFetch stamps the session key on (#75). A DO cannot read its own
|
|
514
|
-
// idFromName name, so this is how the externally-assigned session identity crosses
|
|
515
|
-
// into the object — exported so a custom shell that bypasses durableFetch can set it.
|
|
516
|
-
export const SESSION_HEADER = "x-june-session";
|
|
517
|
-
|
|
518
|
-
// A session key rides an HTTP header (SESSION_HEADER) and the DO name (idFromName) —
|
|
519
|
-
// reject values that can't: CR/LF would be header injection, and Headers.set throws a
|
|
520
|
-
// bare TypeError on control/non-ASCII bytes, turning a bad client value into an opaque
|
|
521
|
-
// 500 deep inside durableFetch. Non-empty printable ASCII is the contract.
|
|
522
|
-
const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
|
|
523
|
-
function assertSessionKey(session: string) {
|
|
524
|
-
if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) {
|
|
525
|
-
throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
|
|
208
|
+
if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
|
|
209
|
+
return new Response("agent DO — POST /turn or GET /transcript", { status: 404 });
|
|
526
210
|
}
|
|
527
211
|
}
|
|
528
212
|
|
|
529
213
|
// Worker-side routing: address a session's DO by (agent, session) and forward the
|
|
530
|
-
// request to it. `env.AGENT` is the DO namespace binding.
|
|
531
|
-
// header (not the body): one seam covers /turn, /resume, and /transcript without
|
|
532
|
-
// touching any body contract, and an older DO simply ignores it.
|
|
214
|
+
// request to it. `env.AGENT` is the DO namespace binding.
|
|
533
215
|
export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
|
|
534
|
-
|
|
535
|
-
const headers = new Headers(req.headers);
|
|
536
|
-
headers.set(SESSION_HEADER, session);
|
|
537
|
-
return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
|
|
216
|
+
return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(req);
|
|
538
217
|
}
|
|
539
218
|
|
|
540
219
|
// The EDGE agent surface for June's router: route the chat endpoint to the
|
|
@@ -556,26 +235,13 @@ export function durableAgentSurface(
|
|
|
556
235
|
const namespace = getNamespace();
|
|
557
236
|
if (!namespace) return null; // no DO binding → not mounted here
|
|
558
237
|
const { message, session } = (await req.json()) as { message: string; session?: string };
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
if (session !== undefined) {
|
|
562
|
-
try {
|
|
563
|
-
assertSessionKey(session);
|
|
564
|
-
} catch (err) {
|
|
565
|
-
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
// Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
|
|
569
|
-
// that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
|
|
570
|
-
// through; otherwise collapse to the final { text } (the prior JSON contract).
|
|
571
|
-
const res = await durableFetch(
|
|
238
|
+
// Forward to the session's DO on its /turn contract (AgentDurableObject.fetch).
|
|
239
|
+
return durableFetch(
|
|
572
240
|
namespace,
|
|
573
241
|
opts.agentName,
|
|
574
242
|
session ?? "default",
|
|
575
243
|
new Request("https://do/turn", { method: "POST", body: JSON.stringify({ userText: message }) }),
|
|
576
244
|
);
|
|
577
|
-
if (req.headers.get("accept")?.includes("text/event-stream")) return res;
|
|
578
|
-
return Response.json({ text: await sseTurnFinalText(res) });
|
|
579
245
|
};
|
|
580
246
|
}
|
|
581
247
|
|
|
@@ -611,11 +277,6 @@ export function durableChannelSurface(
|
|
|
611
277
|
agentName: string;
|
|
612
278
|
channels: (Channel | ChannelFactory)[];
|
|
613
279
|
env: unknown;
|
|
614
|
-
// The app's services factory — the SAME one AgentDurableObject uses — resolved HERE
|
|
615
|
-
// (worker isolate) and exposed to channel hooks as ctx.services. Channel hooks run at
|
|
616
|
-
// the edge, outside the DO, so they can't read the DO's ambient currentServices(); this
|
|
617
|
-
// gives them the same DI bag. Resolved once per surface construction.
|
|
618
|
-
services?: (env: unknown) => unknown;
|
|
619
280
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
620
281
|
},
|
|
621
282
|
): (req: Request) => Promise<Response | null> {
|
|
@@ -624,7 +285,6 @@ export function durableChannelSurface(
|
|
|
624
285
|
// A minimal but complete AgentDefinition — channels only read ctx.agent.name; the
|
|
625
286
|
// full def isn't present in the worker (tools/model live in the DO).
|
|
626
287
|
agent: { name: opts.agentName, instructions: "", tools: [], skills: [], channels: [], connections: [] } satisfies AgentDefinition,
|
|
627
|
-
services: opts.services?.(opts.env),
|
|
628
288
|
waitUntil: opts.waitUntil,
|
|
629
289
|
run: async (message, o) => {
|
|
630
290
|
const namespace = getNamespace();
|
|
@@ -633,84 +293,11 @@ export function durableChannelSurface(
|
|
|
633
293
|
namespace,
|
|
634
294
|
opts.agentName,
|
|
635
295
|
o?.session ?? "default",
|
|
636
|
-
new Request("https://do/turn", { method: "POST", body:
|
|
296
|
+
new Request("https://do/turn", { method: "POST", body: JSON.stringify({ userText: message, turnId: o?.turnId }) }),
|
|
637
297
|
);
|
|
638
|
-
|
|
639
|
-
return
|
|
640
|
-
},
|
|
641
|
-
// FIRE-AND-FORGET (#77): POST /turn?detach=1 — the DO 202s once the turn is accepted
|
|
642
|
-
// and runs it under its OWN lifetime. This is how a shadow/observe hook (onEvent →
|
|
643
|
-
// assessment turn, reply dropped) escapes the edge waitUntil ceiling that killed
|
|
644
|
-
// 24–38s turns in production: nothing holds the edge open while the turn runs.
|
|
645
|
-
runDetached: async (message, o) => {
|
|
646
|
-
const namespace = getNamespace();
|
|
647
|
-
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
648
|
-
const res = await durableFetch(
|
|
649
|
-
namespace,
|
|
650
|
-
opts.agentName,
|
|
651
|
-
o?.session ?? "default",
|
|
652
|
-
new Request("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
|
|
653
|
-
);
|
|
654
|
-
if (res.status !== 202) {
|
|
655
|
-
const detail = (await res.text()).slice(0, 200);
|
|
656
|
-
throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
|
|
657
|
-
}
|
|
658
|
-
return (await res.json()) as { turnId: string };
|
|
659
|
-
},
|
|
660
|
-
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
661
|
-
runStream: async function* (message, o) {
|
|
662
|
-
const namespace = getNamespace();
|
|
663
|
-
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
664
|
-
const res = await durableFetch(
|
|
665
|
-
namespace,
|
|
666
|
-
opts.agentName,
|
|
667
|
-
o?.session ?? "default",
|
|
668
|
-
new Request("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
|
|
669
|
-
);
|
|
670
|
-
yield* sseTurnEvents(res);
|
|
671
|
-
},
|
|
672
|
-
// Resume a parked turn on its session's DO and stream the continuation.
|
|
673
|
-
resumeStream: async function* (o) {
|
|
674
|
-
const namespace = getNamespace();
|
|
675
|
-
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
676
|
-
const res = await durableFetch(
|
|
677
|
-
namespace,
|
|
678
|
-
opts.agentName,
|
|
679
|
-
o.session ?? "default",
|
|
680
|
-
new Request("https://do/resume", { method: "POST", body: serializeResume(o) }),
|
|
681
|
-
);
|
|
682
|
-
yield* sseTurnEvents(res);
|
|
298
|
+
const { text } = (await res.json()) as { text: string };
|
|
299
|
+
return text;
|
|
683
300
|
},
|
|
684
301
|
};
|
|
685
302
|
return channelDispatch(resolved, ctx);
|
|
686
303
|
}
|
|
687
|
-
|
|
688
|
-
// Serialize a turn for the /turn RPC body. InboundEvent.raw is `unknown` — the
|
|
689
|
-
// untouched platform payload — so a (third-party) channel could attach something
|
|
690
|
-
// JSON.stringify chokes on (a circular object, a BigInt). raw isn't needed to route or
|
|
691
|
-
// run the turn, so on a serialization failure we drop it rather than let an
|
|
692
|
-
// unserializable payload take down turn forwarding entirely. (raw is optional on
|
|
693
|
-
// InboundEvent precisely because it may not survive this boundary.)
|
|
694
|
-
// Serialize a /resume RPC body. `input` is `unknown` — the human's answer — so a
|
|
695
|
-
// (third-party) host could hand us something JSON.stringify chokes on (BigInt, circular).
|
|
696
|
-
// Unlike serializeTurn's `raw`, `input` is essential: silently dropping it would resume the
|
|
697
|
-
// turn with the wrong answer. So fail loudly with a clear message rather than corrupt the resume.
|
|
698
|
-
function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string }): string {
|
|
699
|
-
try {
|
|
700
|
-
return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by });
|
|
701
|
-
} catch (err) {
|
|
702
|
-
throw new Error(`resumeStream: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// `trigger` is ProactiveTrigger — plain strings by construction — so only event.raw can make
|
|
707
|
-
// the stringify throw; the fallback that strips it stays sufficient.
|
|
708
|
-
function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger }): string {
|
|
709
|
-
const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger };
|
|
710
|
-
try {
|
|
711
|
-
return JSON.stringify(payload);
|
|
712
|
-
} catch {
|
|
713
|
-
const event = o?.event ? { ...o.event, raw: undefined } : undefined;
|
|
714
|
-
return JSON.stringify({ ...payload, event });
|
|
715
|
-
}
|
|
716
|
-
}
|