@junejs/server 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/icon-latin.LICENSE.txt +92 -0
- package/assets/icon-latin.ttf +0 -0
- package/dist/adapter-DcZ-W1-7.d.ts +62 -0
- package/dist/adapter.d.ts +2 -56
- package/dist/adapter.js +62 -5
- package/dist/agent-compile.d.ts +38 -0
- package/dist/agent-compile.js +145 -0
- package/dist/agent-discover.d.ts +4 -4
- package/dist/agent-discover.js +30 -44
- package/dist/agent-durable.d.ts +94 -19
- package/dist/agent-durable.js +647 -32
- package/dist/agent-native.d.ts +44 -13
- package/dist/agent-native.js +264 -27
- package/dist/app.d.ts +5 -5
- package/dist/app.js +42 -10
- package/dist/blob.d.ts +4 -5
- package/dist/build.d.ts +8 -29
- package/dist/build.js +138 -289
- package/dist/client-bundle.d.ts +8 -9
- package/dist/client-bundle.js +1 -1
- package/dist/config-freeze-CyXvNqi2.d.ts +17 -0
- package/dist/config-freeze.d.ts +2 -0
- package/dist/config-freeze.js +54 -0
- package/dist/config-loader.d.ts +3 -4
- package/dist/connection-auth.d.ts +38 -0
- package/dist/connection-auth.js +31 -0
- package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
- package/dist/content-freeze.d.ts +2 -0
- package/dist/content-freeze.js +83 -0
- package/dist/content.d.ts +22 -9
- package/dist/content.js +43 -3
- package/dist/core-version.d.ts +4 -0
- package/dist/core-version.js +12 -0
- package/dist/css-modules-ambient.d.ts +2 -1
- package/dist/css-modules.d.ts +10 -11
- package/dist/css.d.ts +9 -10
- package/dist/d1-migrate.d.ts +6 -7
- package/dist/d1-migrate.js +3 -2
- package/dist/db.d.ts +2 -2
- package/dist/deploy.d.ts +3 -4
- package/dist/deploy.js +3 -2
- package/dist/dev-reload.d.ts +3 -4
- package/dist/dev.d.ts +8 -5
- package/dist/dev.js +40 -9
- package/dist/favicon.d.ts +37 -0
- package/dist/favicon.js +248 -0
- package/dist/host.d.ts +6 -5
- package/dist/host.js +2 -1
- package/dist/icon-letter.d.ts +3 -0
- package/dist/icon-letter.js +8 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +9 -3
- package/dist/instrumentation.d.ts +2 -3
- package/dist/instrumentation.js +2 -1
- package/dist/island-registry.d.ts +6 -7
- package/dist/isolate-local.d.ts +5 -0
- package/dist/isolate-local.js +12 -0
- package/dist/kv.d.ts +3 -4
- package/dist/llms-links.d.ts +9 -0
- package/dist/llms-links.js +65 -0
- package/dist/manifest-CwTT1zVs.d.ts +11 -0
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.js +113 -0
- package/dist/negotiate.d.ts +4 -3
- package/dist/negotiate.js +24 -14
- package/dist/{pipeline-CCp9idQ4.d.ts → pipeline-BHUjBO0G.d.ts} +13 -1
- package/dist/pipeline.d.ts +2 -2
- package/dist/pipeline.js +286 -35
- package/dist/resources.d.ts +5 -6
- package/dist/route-rank.d.ts +10 -0
- package/dist/route-rank.js +38 -0
- package/dist/route-scan-BMbBzPIB.d.ts +20 -0
- package/dist/route-scan.d.ts +2 -0
- package/dist/route-scan.js +100 -0
- package/dist/router.d.ts +12 -13
- package/dist/router.js +58 -44
- package/dist/rsc-build.d.ts +6 -7
- package/dist/rsc-bundle.d.ts +6 -7
- package/dist/rsc-manifest.d.ts +5 -6
- package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
- package/dist/rsc-runtime/flight-render.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.js +3 -2
- package/dist/rsc-runtime/rsc-worker.js +11 -8
- package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
- package/dist/segment.d.ts +4 -5
- package/dist/sqlite-driver.d.ts +11 -11
- package/dist/sqlite-driver.js +17 -4
- package/dist/static-files.d.ts +4 -5
- package/dist/tsconfig-jsx.d.ts +4 -4
- package/dist/tsconfig-jsx.js +1 -1
- package/dist/turn-events.d.ts +4 -0
- package/dist/turn-events.js +46 -0
- package/dist/web-manifest.d.ts +5 -0
- package/dist/web-manifest.js +42 -0
- package/dist/worker-2qbQjj2c.d.ts +64 -0
- package/dist/worker.d.ts +2 -56
- package/dist/worker.js +121 -103
- package/package.json +22 -9
- package/src/adapter.ts +84 -3
- package/src/agent-compile.ts +271 -0
- package/src/agent-discover.ts +77 -63
- package/src/agent-durable.ts +998 -30
- package/src/agent-native.ts +370 -35
- package/src/app.ts +75 -6
- package/src/build.ts +237 -444
- package/src/config-freeze.ts +109 -0
- package/src/connection-auth.ts +101 -0
- package/src/content-freeze.ts +151 -0
- package/src/content.ts +55 -4
- package/src/core-version.ts +34 -0
- package/src/dev.ts +41 -8
- package/src/favicon.ts +325 -0
- package/src/host.ts +4 -3
- package/src/icon-letter.ts +14 -0
- package/src/index.ts +22 -0
- package/src/isolate-local.ts +33 -0
- package/src/llms-links.ts +96 -0
- package/src/manifest.ts +130 -0
- package/src/negotiate.ts +39 -15
- package/src/pipeline.ts +494 -44
- package/src/route-rank.ts +60 -0
- package/src/route-scan.ts +161 -0
- package/src/router.ts +75 -58
- package/src/rsc-runtime/rsc-worker.tsx +5 -4
- package/src/sqlite-driver.ts +36 -6
- package/src/tsconfig-jsx.ts +2 -2
- package/src/turn-events.ts +43 -0
- package/src/web-manifest.ts +38 -0
- package/src/worker.ts +197 -117
package/src/agent-durable.ts
CHANGED
|
@@ -17,24 +17,46 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
AgentSession,
|
|
20
|
+
FatalToolError,
|
|
21
|
+
grantAnswer,
|
|
22
|
+
ResumeAuthorizationError,
|
|
20
23
|
withSystem,
|
|
21
|
-
type
|
|
24
|
+
type Answerers,
|
|
25
|
+
type AuthorizeAnswer,
|
|
26
|
+
type InputAnnouncement,
|
|
27
|
+
type NoteKind,
|
|
28
|
+
type ChannelPolicy,
|
|
29
|
+
type EventSink,
|
|
30
|
+
type TurnEvent,
|
|
22
31
|
type Model,
|
|
23
32
|
type Msg,
|
|
24
33
|
type Runtime,
|
|
34
|
+
type InboundEvent,
|
|
25
35
|
type SessionStore,
|
|
36
|
+
type ProactiveTrigger,
|
|
26
37
|
type Tool,
|
|
38
|
+
type TurnError,
|
|
39
|
+
type TurnFailurePhase,
|
|
27
40
|
} from "@junejs/core/agent-runtime";
|
|
41
|
+
import type { Principal } from "@junejs/core/context";
|
|
28
42
|
import type { Resources } from "@junejs/core/resources";
|
|
43
|
+
import { blobFileStore, type FileStore } from "@junejs/core/agent-files";
|
|
44
|
+
import { observeTurnEvents } from "./turn-events";
|
|
29
45
|
import {
|
|
30
46
|
channelDispatch,
|
|
47
|
+
DeliverUnsupportedError,
|
|
48
|
+
actionToTool,
|
|
31
49
|
resolveChannel,
|
|
32
50
|
type AgentDefinition,
|
|
33
51
|
type Channel,
|
|
34
52
|
type ChannelContext,
|
|
35
53
|
type ChannelFactory,
|
|
54
|
+
type ResumeDeliveryTarget,
|
|
36
55
|
} from "@junejs/core/agent-config";
|
|
56
|
+
import { connectAll, type Connection } from "@junejs/core/connections";
|
|
37
57
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
58
|
+
import { isolateLocal } from "./isolate-local";
|
|
59
|
+
import { assertCoreRuntimeVersion } from "./core-version";
|
|
38
60
|
|
|
39
61
|
// ── minimal structural Cloudflare surface (no @cloudflare/workers-types dep) ──
|
|
40
62
|
// SqlStorage row values are exactly what workerd's SQLite returns. Mirroring
|
|
@@ -58,6 +80,10 @@ export interface SqlStorage {
|
|
|
58
80
|
export interface DurableStorage {
|
|
59
81
|
sql: SqlStorage;
|
|
60
82
|
transactionSync<T>(fn: () => T): T;
|
|
83
|
+
// Schedules the object's alarm() — the watchdog and retry for input-announcement delivery
|
|
84
|
+
// (#260). Optional so a storage without alarms (a test double) still works; delivery then
|
|
85
|
+
// waits for the next announcement, settled turn or rebuild.
|
|
86
|
+
setAlarm?(scheduledTime: number): unknown;
|
|
61
87
|
}
|
|
62
88
|
export interface DurableObjectState {
|
|
63
89
|
storage: DurableStorage;
|
|
@@ -93,8 +119,8 @@ export class DoSessionStore implements SessionStore {
|
|
|
93
119
|
messages(): Msg[] {
|
|
94
120
|
return this.sql.exec<{ body: string }>("SELECT body FROM agent_messages ORDER BY seq").toArray().map((r) => JSON.parse(r.body));
|
|
95
121
|
}
|
|
96
|
-
|
|
97
|
-
return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
|
|
122
|
+
hasOpeningMessage(turnId: string): boolean {
|
|
123
|
+
return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
|
|
98
124
|
}
|
|
99
125
|
getStep(id: string): unknown | undefined {
|
|
100
126
|
const rows = this.sql.exec<{ output: string }>("SELECT output FROM agent_steps WHERE id = ?", id).toArray();
|
|
@@ -103,6 +129,9 @@ export class DoSessionStore implements SessionStore {
|
|
|
103
129
|
putStep(id: string, output: unknown) {
|
|
104
130
|
this.sql.exec("INSERT INTO agent_steps (id, output) VALUES (?, ?)", id, JSON.stringify(output));
|
|
105
131
|
}
|
|
132
|
+
delStep(id: string) {
|
|
133
|
+
this.sql.exec("DELETE FROM agent_steps WHERE id = ?", id);
|
|
134
|
+
}
|
|
106
135
|
getStatus(): string {
|
|
107
136
|
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'status'").toArray();
|
|
108
137
|
return rows.length ? rows[0]!.v : "new";
|
|
@@ -110,18 +139,52 @@ export class DoSessionStore implements SessionStore {
|
|
|
110
139
|
setStatus(s: string) {
|
|
111
140
|
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
|
|
112
141
|
}
|
|
142
|
+
// The externally-assigned session key (#75). A DO cannot read its own idFromName
|
|
143
|
+
// name, so the key arrives on the first routed request and is persisted here —
|
|
144
|
+
// surviving hibernation/eviction, where the constructor re-runs with no request
|
|
145
|
+
// in hand. Not part of core's SessionStore contract: only the DO seam needs it.
|
|
146
|
+
getSessionKey(): string | undefined {
|
|
147
|
+
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
|
|
148
|
+
return rows.length ? rows[0]!.v : undefined;
|
|
149
|
+
}
|
|
150
|
+
setSessionKey(key: string) {
|
|
151
|
+
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
|
|
152
|
+
}
|
|
113
153
|
tx<T>(fn: () => T): T {
|
|
114
154
|
return this.storage.transactionSync(fn);
|
|
115
155
|
}
|
|
156
|
+
// Session reset (#129): ARCHIVE messages/steps under the current generation (the audit
|
|
157
|
+
// trail — never deleted), clear the live tables, status → "new". The session key in
|
|
158
|
+
// agent_meta is deliberately untouched: the DO's identity survives its history. Archive
|
|
159
|
+
// tables + the generation counter are created lazily, so existing objects stay
|
|
160
|
+
// untouched until their first reset. seq keeps counting across generations (DELETE
|
|
161
|
+
// doesn't reset AUTOINCREMENT), so an archived (generation, seq) stays unique.
|
|
162
|
+
reset(inTx?: () => void): number {
|
|
163
|
+
return this.storage.transactionSync(() => {
|
|
164
|
+
const sql = this.sql;
|
|
165
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
|
|
166
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (generation INTEGER, id TEXT, output TEXT)`);
|
|
167
|
+
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'generation'").toArray();
|
|
168
|
+
const generation = rows.length ? Number(rows[0]!.v) : 0;
|
|
169
|
+
sql.exec("INSERT INTO agent_messages_archive (generation, seq, body) SELECT ?, seq, body FROM agent_messages", generation);
|
|
170
|
+
sql.exec("DELETE FROM agent_messages");
|
|
171
|
+
sql.exec("INSERT INTO agent_steps_archive (generation, id, output) SELECT ?, id, output FROM agent_steps", generation);
|
|
172
|
+
sql.exec("DELETE FROM agent_steps");
|
|
173
|
+
this.setStatus("new");
|
|
174
|
+
sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
|
|
175
|
+
inTx?.(); // writes into the fresh generation, committed with the archive (#260)
|
|
176
|
+
return generation;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
116
179
|
unwrap<H = unknown>(): H {
|
|
117
180
|
return this.sql as unknown as H;
|
|
118
181
|
}
|
|
119
182
|
}
|
|
120
183
|
|
|
121
|
-
class
|
|
122
|
-
private subs = new Set<(
|
|
123
|
-
|
|
124
|
-
subscribe(cb: (
|
|
184
|
+
class InProcEventSink implements EventSink {
|
|
185
|
+
private subs = new Set<(e: TurnEvent) => void>();
|
|
186
|
+
emit(e: TurnEvent) { this.subs.forEach((cb) => { try { cb(e); } catch { /* a bad subscriber must not break emit */ } }); }
|
|
187
|
+
subscribe(cb: (e: TurnEvent) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
|
|
125
188
|
}
|
|
126
189
|
|
|
127
190
|
// A DO can't run in-process subagents; a subagent there is a sibling DO (cross-DO
|
|
@@ -152,8 +215,52 @@ export type DoAgentDef = {
|
|
|
152
215
|
model: Model;
|
|
153
216
|
tools: Tool[];
|
|
154
217
|
instructions?: string;
|
|
155
|
-
|
|
218
|
+
// Per-channel-source turn policies (see AgentDefinition.channelInstructions):
|
|
219
|
+
// an overlay string, or a ChannelPolicy carrying overlay/overlayMode/denyTools
|
|
220
|
+
// (#149 — derived from agent-level instructions.<source>.md + surfaces config
|
|
221
|
+
// by assembleDurable). The shared agent branches by real inbound source, no
|
|
222
|
+
// userText marker.
|
|
223
|
+
channelInstructions?: Record<string, string | ChannelPolicy>;
|
|
224
|
+
// Channels whose CAPABILITY tools should be available to this session's turns. Their
|
|
225
|
+
// tools are built HERE, in the DO isolate, from `env` — because a channel's tool `run`
|
|
226
|
+
// is a closure (over the bot token, etc.) that can't cross the worker→DO RPC. This is
|
|
227
|
+
// the edge equivalent of defineAgent merging channel.tools() on native: pass the same
|
|
228
|
+
// channel factories you mount on the worker, plus this DO's env.
|
|
229
|
+
channels?: (Channel | ChannelFactory)[];
|
|
230
|
+
env?: unknown;
|
|
231
|
+
// A resolved bag, OR an env-driven — possibly async — provider
|
|
232
|
+
// (bindWorkerResources' shape): opening a D1 handle is async and a DO
|
|
233
|
+
// constructor cannot await, so a provider is resolved lazily at the first
|
|
234
|
+
// turn (once per DO instance) with this def's `env`.
|
|
235
|
+
resources?: Resources | ((env: unknown) => Promise<Resources> | Resources);
|
|
236
|
+
// Outbound connection DEFINITIONS (external MCP/OpenAPI servers). Wiring them
|
|
237
|
+
// is network I/O (connectAll), which a DO constructor cannot await — resolved
|
|
238
|
+
// lazily at the first turn, their tools merged into the agent's tool set
|
|
239
|
+
// before any session is constructed. The native equivalent happens at
|
|
240
|
+
// assembly (assembleAgent → connectAll).
|
|
241
|
+
connections?: Connection[];
|
|
156
242
|
services?: unknown;
|
|
243
|
+
// Called on every turn.failed for this session — the app's seam for routing turn
|
|
244
|
+
// failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
|
|
245
|
+
// default console.error; if the hook throws (or an async hook rejects), the default
|
|
246
|
+
// log fires anyway (both errors), so a failure is never silent. `error` carries the
|
|
247
|
+
// full serialized failure (stack, cause chain — see TurnError, #96), and phase/step
|
|
248
|
+
// name the engine step that was in flight — for a DETACHED turn this hook is the
|
|
249
|
+
// only failure-surfacing path, so nothing may be flattened before it fires. It
|
|
250
|
+
// happens to run inside the failed turn's request scope today (turn.failed is
|
|
251
|
+
// emitted mid-turn and ALS propagates), but that is NOT contract — don't rely on
|
|
252
|
+
// ambient db/services; close over what you need.
|
|
253
|
+
onTurnError?: (failure: { turnId: string; error: TurnError; phase?: TurnFailurePhase; step?: string }) => void | Promise<void>;
|
|
254
|
+
// Decides a { policy } answerer of a parked requestInput at /resume (#261) — the same hook
|
|
255
|
+
// an AgentDefinition carries natively; assembleDurable passes agent.ts's through.
|
|
256
|
+
authorizeAnswer?: AuthorizeAnswer;
|
|
257
|
+
// Receives this session's input announcements (#260) — run inside the request scope, so it
|
|
258
|
+
// can write the app's cross-session index with the ambient db.
|
|
259
|
+
onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
260
|
+
// Where this session's tools store the files they produce (docs/rfc-agent-files.md), and
|
|
261
|
+
// where host-side delivery reads them back. Default: the `blob` of `resources` (an R2
|
|
262
|
+
// bucket), under agent-files/. Without either, a tool's ctx.files.put fails the turn.
|
|
263
|
+
files?: FileStore;
|
|
157
264
|
};
|
|
158
265
|
|
|
159
266
|
// The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
|
|
@@ -171,17 +278,288 @@ export type DoAgentDef = {
|
|
|
171
278
|
// fetch(req) { return this.agent.fetch(req); }
|
|
172
279
|
// }
|
|
173
280
|
export class AgentDurableObject {
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
281
|
+
// The AgentSession is constructed LAZILY (#75): the DO is keyed externally by
|
|
282
|
+
// idFromName(`${agent}:${session}`) but cannot read its own name, so the true
|
|
283
|
+
// session key only exists on a routed request (the SESSION_HEADER durableFetch
|
|
284
|
+
// sets). First keyed request wins and persists the key (agent_meta) so it
|
|
285
|
+
// survives hibernation; key-less paths fall back to the persisted key, then
|
|
286
|
+
// "self" (the pre-#75 behavior, kept for backward compatibility).
|
|
287
|
+
private session: AgentSession | undefined;
|
|
288
|
+
private sessionKey: string | undefined;
|
|
289
|
+
private readonly name: string;
|
|
290
|
+
private readonly store: DoSessionStore;
|
|
291
|
+
private readonly sink: InProcEventSink;
|
|
292
|
+
private readonly model: Model;
|
|
293
|
+
private readonly tools: Tool[];
|
|
294
|
+
private readonly channels: Channel[];
|
|
295
|
+
private readonly channelInstructions?: Record<string, string | ChannelPolicy>;
|
|
296
|
+
// A bag is used as-is; a provider (bindWorkerResources' shape) is resolved
|
|
297
|
+
// lazily at the first turn with the def's env — opening a D1 handle is async
|
|
298
|
+
// and this constructor cannot await. Resolved once, shared across turns (env
|
|
299
|
+
// is stable per isolate, like bindWorkerResources memoizes per worker isolate).
|
|
300
|
+
private readonly resourcesInput: DoAgentDef["resources"];
|
|
301
|
+
private resourcesOpened: Promise<Resources> | undefined;
|
|
302
|
+
private readonly connectionsInput: Connection[] | undefined;
|
|
303
|
+
private connectionsOpened: Promise<void> | undefined;
|
|
304
|
+
private readonly doEnv: unknown;
|
|
178
305
|
private readonly services: unknown;
|
|
306
|
+
private readonly authorizeAnswer?: AuthorizeAnswer;
|
|
307
|
+
private readonly onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
308
|
+
private readonly storage: DurableStorage;
|
|
309
|
+
private readonly files: FileStore;
|
|
310
|
+
// Consecutive failed announcement deliveries (#260) — sets the alarm retry's backoff.
|
|
311
|
+
private announceFailures = 0;
|
|
312
|
+
private resolveResources(): Promise<Resources> {
|
|
313
|
+
return (this.resourcesOpened ??= Promise.resolve(
|
|
314
|
+
typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : (this.resourcesInput ?? {}),
|
|
315
|
+
));
|
|
316
|
+
}
|
|
317
|
+
// Wire outbound connections once (network I/O the constructor can't await):
|
|
318
|
+
// their tools merge into this.tools BEFORE any session is constructed — the
|
|
319
|
+
// ready() callers hoist this ahead of resolveSession. A failed connection is
|
|
320
|
+
// reported and skipped (connectAll never throws), matching native assembly.
|
|
321
|
+
private resolveConnections(): Promise<void> {
|
|
322
|
+
return (this.connectionsOpened ??= (async () => {
|
|
323
|
+
if (!this.connectionsInput?.length) return;
|
|
324
|
+
const { actions, report } = await connectAll(this.connectionsInput);
|
|
325
|
+
for (const r of report) {
|
|
326
|
+
if (r.error) console.error(`[june] agent "${this.name}": connection "${r.name}" (${r.url}) failed: ${r.error}`);
|
|
327
|
+
}
|
|
328
|
+
const seen = new Set(this.tools.map((t) => t.spec.name));
|
|
329
|
+
for (const a of actions) {
|
|
330
|
+
const tool = actionToTool(a);
|
|
331
|
+
if (seen.has(tool.spec.name)) {
|
|
332
|
+
throw new Error(`AgentDurableObject(${this.name}): duplicate tool name "${tool.spec.name}" from a connection — rename so dispatch is unambiguous.`);
|
|
333
|
+
}
|
|
334
|
+
seen.add(tool.spec.name);
|
|
335
|
+
this.tools.push(tool);
|
|
336
|
+
}
|
|
337
|
+
})());
|
|
338
|
+
}
|
|
339
|
+
// Everything a turn needs opened BEFORE its synchronous scope-and-subscribe
|
|
340
|
+
// section: resources (possibly an async provider) and outbound connections.
|
|
341
|
+
private async ready(): Promise<Resources> {
|
|
342
|
+
const [resources] = await Promise.all([this.resolveResources(), this.resolveConnections()]);
|
|
343
|
+
return resources;
|
|
344
|
+
}
|
|
179
345
|
constructor(state: DurableObjectState, def: DoAgentDef) {
|
|
346
|
+
assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`); // #94: fail power-on, not mid-turn
|
|
180
347
|
const store = new DoSessionStore(state.storage);
|
|
181
348
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
182
|
-
this.
|
|
349
|
+
this.resourcesInput = def.resources;
|
|
350
|
+
this.connectionsInput = def.connections;
|
|
351
|
+
this.doEnv = def.env;
|
|
183
352
|
this.services = def.services;
|
|
184
|
-
this.
|
|
353
|
+
this.authorizeAnswer = def.authorizeAnswer;
|
|
354
|
+
this.onInputAnnouncement = def.onInputAnnouncement;
|
|
355
|
+
this.storage = state.storage;
|
|
356
|
+
const name = def.name ?? "agent";
|
|
357
|
+
// The blob resource opens with the rest of resources, lazily — the store is installed
|
|
358
|
+
// on the session before then, and only a tool that stores a file opens it.
|
|
359
|
+
this.files =
|
|
360
|
+
def.files ??
|
|
361
|
+
blobFileStore(async () => {
|
|
362
|
+
const blob = (await this.resolveResources()).blob;
|
|
363
|
+
if (!blob) throw new FatalToolError(`agent "${name}": no file store — declare a blob resource (DoAgentDef.resources) or pass DoAgentDef.files`);
|
|
364
|
+
return blob;
|
|
365
|
+
});
|
|
366
|
+
// Failure observability (#76): a turn that dies after the fast-ACK has no other
|
|
367
|
+
// observable surface on the edge — the webhook already 200'd and runBackground
|
|
368
|
+
// swallows the rejection unless the channel wired onError. Subscribing HERE (the
|
|
369
|
+
// session sink) covers every turn path — turn(), /turn, /resume — with one seam.
|
|
370
|
+
// Default: console.error, which wrangler tail surfaces. An app onTurnError takes
|
|
371
|
+
// over reporting; if IT throws, fall back to the default so nothing goes silent.
|
|
372
|
+
// (InProcEventSink already guards emit against a throwing subscriber.)
|
|
373
|
+
const sink = new InProcEventSink();
|
|
374
|
+
// Announcement watchdog (#260): a park is committed inside a turn, after turn.started, so
|
|
375
|
+
// arming here comes before its announcement can commit — whichever path started the turn,
|
|
376
|
+
// a held turn drained later included. Resume, reset and a held inbound turn arm at their
|
|
377
|
+
// call sites (armWatchdog).
|
|
378
|
+
sink.subscribe((e) => {
|
|
379
|
+
if (e.type === "turn.started") this.armWatchdog();
|
|
380
|
+
});
|
|
381
|
+
sink.subscribe((e) => {
|
|
382
|
+
if (e.type !== "turn.failed") return;
|
|
383
|
+
// The stack already opens with the message; log it INSTEAD of the message line
|
|
384
|
+
// when present so wrangler tail shows one coherent trace, not the message twice.
|
|
385
|
+
const defaultLog = () =>
|
|
386
|
+
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(" ← ")}` : ""}`);
|
|
387
|
+
if (def.onTurnError) {
|
|
388
|
+
try {
|
|
389
|
+
const out = def.onTurnError({ turnId: e.turnId, error: e.error, phase: e.phase, step: e.step });
|
|
390
|
+
// An async hook (e.g. `await sendToSentry(...)`) types as void but returns a
|
|
391
|
+
// Promise — a rejection there must hit the same fallback as a sync throw, or
|
|
392
|
+
// the failure goes silent again (plus an unhandled rejection). No waitUntil
|
|
393
|
+
// keeper is needed here: unlike a Worker, a DO stays active while it has
|
|
394
|
+
// pending work/IO, and DurableObjectState.waitUntil is documented as a no-op —
|
|
395
|
+
// this .then subscription is what retains the promise.
|
|
396
|
+
if (out && typeof (out as Promise<void>).then === "function") {
|
|
397
|
+
(out as Promise<void>).then(undefined, (hookErr) => {
|
|
398
|
+
console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
|
|
399
|
+
defaultLog();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
} catch (hookErr) {
|
|
404
|
+
console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
defaultLog();
|
|
408
|
+
});
|
|
409
|
+
// Merge the mounted channels' capability tools (built here from this DO's env, since a
|
|
410
|
+
// tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
|
|
411
|
+
// keeps a Slack tool inert during a Crisp turn, so merging all of them is safe. The
|
|
412
|
+
// resolved channels are also RETAINED (this.channels): a delivered turn (/turn?deliver=1)
|
|
413
|
+
// renders its reply through the source channel's deliver() from inside this DO.
|
|
414
|
+
const channels = (def.channels ?? []).map((c) => resolveChannel(c, def.env));
|
|
415
|
+
const channelTools = channels.flatMap((c) => c.tools?.() ?? []);
|
|
416
|
+
const tools: Tool[] = [...def.tools];
|
|
417
|
+
const seen = new Set(tools.map((t) => t.spec.name));
|
|
418
|
+
for (const t of channelTools) {
|
|
419
|
+
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.`);
|
|
420
|
+
seen.add(t.spec.name);
|
|
421
|
+
tools.push(t);
|
|
422
|
+
}
|
|
423
|
+
this.name = name;
|
|
424
|
+
this.store = store;
|
|
425
|
+
this.sink = sink;
|
|
426
|
+
this.model = model;
|
|
427
|
+
this.tools = tools;
|
|
428
|
+
this.channels = channels;
|
|
429
|
+
this.channelInstructions = def.channelInstructions;
|
|
430
|
+
// Orphan-policy check (#149): assembly can't validate surface keys against
|
|
431
|
+
// factory channels (they resolve only here, with env) — so warn now, once,
|
|
432
|
+
// when a policy's source matches no resolved channel. A policy that never
|
|
433
|
+
// fires is the old silent failure; a warning is its floor.
|
|
434
|
+
if (def.channelInstructions) {
|
|
435
|
+
const names = new Set(channels.map((c) => c.name));
|
|
436
|
+
for (const source of Object.keys(def.channelInstructions)) {
|
|
437
|
+
if (!names.has(source)) {
|
|
438
|
+
console.warn(`[june] agent "${name}": surface policy "${source}" matches no channel wired into this DO (${[...names].join(", ") || "none"}) — it will never fire.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Resolve THE session for this DO: explicit key (from a routed request) → persisted
|
|
444
|
+
// key (a prior life learned it) → "self". A key that contradicts the persisted or
|
|
445
|
+
// live identity is a mis-route (or a key-less path was used first) — fail loudly:
|
|
446
|
+
// silently proceeding is exactly the per-conversation data corruption #75 hit.
|
|
447
|
+
private resolveSession(key?: string): AgentSession {
|
|
448
|
+
// Every entry point shares the session-key contract (turn({ session }) and hand-rolled
|
|
449
|
+
// SESSION_HEADER values included, not just durableFetch): an invalid key persisted here
|
|
450
|
+
// would bind an identity no routed request could ever address — an orphaned session.
|
|
451
|
+
if (key !== undefined) assertSessionKey(key);
|
|
452
|
+
// Live fast path — no storage read once the session exists: this method is the only
|
|
453
|
+
// writer of the persisted key, so within one life it cannot diverge from the live one.
|
|
454
|
+
if (this.session) {
|
|
455
|
+
if (key !== undefined && this.sessionKey !== key) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
this.sessionKey === "self"
|
|
458
|
+
? `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)`
|
|
459
|
+
: `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)`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
return this.session;
|
|
463
|
+
}
|
|
464
|
+
const stored = this.store.getSessionKey();
|
|
465
|
+
if (key !== undefined && stored !== undefined && key !== stored) {
|
|
466
|
+
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)`);
|
|
467
|
+
}
|
|
468
|
+
const resolved = key ?? stored ?? "self";
|
|
469
|
+
// "self" is deliberately NOT persisted — even when a caller passes it EXPLICITLY: it
|
|
470
|
+
// is a placeholder ("no identity externally assigned yet"), not an identity. Within
|
|
471
|
+
// one life a keyed request after a "self" binding is refused above — two concurrently
|
|
472
|
+
// active paths disagreeing is a live bug. But across eviction a keyed request ADOPTS
|
|
473
|
+
// the legacy transcript: that asymmetry IS the migration path from pre-keyed
|
|
474
|
+
// deployments, where a persisted "self" would 409 every keyed request forever.
|
|
475
|
+
if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
|
|
476
|
+
this.sessionKey = resolved;
|
|
477
|
+
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
478
|
+
this.session.files = this.files;
|
|
479
|
+
// A held turn (#263) that asked for delivery is rendered through its source channel when
|
|
480
|
+
// it finally runs — possibly in a later life of this object, which is why the ask rides
|
|
481
|
+
// in the turn's hostContext and this hook is installed on every rebuild.
|
|
482
|
+
this.session.onDequeue = (heldTurnId, held) => {
|
|
483
|
+
if ((held.hostContext as { deliver?: boolean } | undefined)?.deliver && held.event) this.deliverHeld(this.session!, heldTurnId, held.event);
|
|
484
|
+
};
|
|
485
|
+
// Input announcements (#260): delivered in the request scope (the hook writes the app's
|
|
486
|
+
// index), and flushed on every rebuild — an earlier life may have committed a park and
|
|
487
|
+
// died before delivering it.
|
|
488
|
+
const announce = this.onInputAnnouncement;
|
|
489
|
+
if (announce) {
|
|
490
|
+
// ensureScope() here, not only in the request handlers: a session can first be rebuilt by
|
|
491
|
+
// a path that never wires async scope (/reset, /transcript, the direct API), and without it
|
|
492
|
+
// runInScope is a pass-through and the hook would see no ambient db.
|
|
493
|
+
this.session.onAnnounce = async (a) => {
|
|
494
|
+
await ensureScope();
|
|
495
|
+
const resources = await this.ready();
|
|
496
|
+
try {
|
|
497
|
+
await runInScope({ resources, services: this.services }, () => announce(a));
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// The engine keeps the announcement and logs; retry it on the alarm, 5 s doubling
|
|
500
|
+
// to 5 min — a quiet session would otherwise hold it until its next activity.
|
|
501
|
+
this.scheduleAlarm(Math.min(5_000 * 2 ** this.announceFailures, 300_000));
|
|
502
|
+
this.announceFailures++;
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
this.announceFailures = 0;
|
|
506
|
+
};
|
|
507
|
+
void this.session.flushAnnouncements();
|
|
508
|
+
}
|
|
509
|
+
return this.session;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The Durable Object alarm (#260): delivers input announcements left undelivered — a hook
|
|
513
|
+
// that failed (its retry), or an object that died between committing one and delivering
|
|
514
|
+
// it (the watchdog). The shell forwards it — `alarm() { return this.agent.alarm(); }`;
|
|
515
|
+
// june build's does. Resolves the session like any key-less path (the persisted key),
|
|
516
|
+
// which rebuilds it after an eviction and flushes. Never rejects: a failed delivery
|
|
517
|
+
// reschedules itself through onAnnounce.
|
|
518
|
+
async alarm(): Promise<void> {
|
|
519
|
+
if (!this.onInputAnnouncement) return;
|
|
520
|
+
await this.resolveSession().flushAnnouncements();
|
|
521
|
+
}
|
|
522
|
+
// A watchdog alarm a minute out, armed BEFORE anything can commit an announcement (a
|
|
523
|
+
// turn starting, a resume, a reset, a held inbound turn): arming after the commit would
|
|
524
|
+
// leave exactly the window it exists for. A delivery that succeeds leaves it to find
|
|
525
|
+
// nothing.
|
|
526
|
+
private armWatchdog(): void {
|
|
527
|
+
if (this.onInputAnnouncement) this.scheduleAlarm(60_000);
|
|
528
|
+
}
|
|
529
|
+
// One alarm per object: the latest schedule wins.
|
|
530
|
+
private scheduleAlarm(inMs: number): void {
|
|
531
|
+
const failed = (err: unknown) => console.error(`[june] agent "${this.name}": could not schedule the announcement alarm — delivery waits for the next announcement, turn or rebuild:`, err);
|
|
532
|
+
try {
|
|
533
|
+
Promise.resolve(this.storage.setAlarm?.(Date.now() + inMs)).catch(failed);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
failed(err);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Render a held turn's reply through its source channel (#263), like /turn?deliver=1 does
|
|
540
|
+
// for a turn started now. Called from onDequeue, synchronously as the turn starts: the
|
|
541
|
+
// event subscription is taken HERE, before any event can emit, and buffers until the
|
|
542
|
+
// (async) resources are ready. Nothing may escape — there is no caller to report to; the
|
|
543
|
+
// log is the only surfacing path, as for any delivered render.
|
|
544
|
+
private deliverHeld(session: AgentSession, turnId: string, event: InboundEvent): void {
|
|
545
|
+
const logRenderFailure = (err: unknown) =>
|
|
546
|
+
console.error(`[june] agent "${this.name}": delivered render for held turn ${turnId} failed:`, err);
|
|
547
|
+
try {
|
|
548
|
+
const channel = this.channels.find((c) => c.name === event.source);
|
|
549
|
+
if (!channel?.deliver) {
|
|
550
|
+
logRenderFailure(new Error(`no deliver()-capable channel named "${event.source}" is wired into this DO — the held turn runs, but its reply is not rendered`));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
|
|
554
|
+
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
555
|
+
const target = { channelId: event.channelId, threadId: event.threadId, recipientUserId: event.user?.id, recipientTeamId: event.teamId };
|
|
556
|
+
this.ready()
|
|
557
|
+
.then((resources) => runInScope({ resources, services: this.services }, () => channel.deliver!(target, events, { session: this.sessionKey, files: this.files })))
|
|
558
|
+
.catch(logRenderFailure)
|
|
559
|
+
.finally(() => { void it.return?.(); });
|
|
560
|
+
} catch (err) {
|
|
561
|
+
logRenderFailure(err);
|
|
562
|
+
}
|
|
185
563
|
}
|
|
186
564
|
// Run the whole turn inside a request scope seeded from this DO's env, so ambient
|
|
187
565
|
// `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
|
|
@@ -190,30 +568,405 @@ export class AgentDurableObject {
|
|
|
190
568
|
// Juno's batch-loader registry) can't leak across turns on a long-lived DO.
|
|
191
569
|
// ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
|
|
192
570
|
// pipeline does; without it runInScope is a pass-through and ambient reads throw.
|
|
193
|
-
async turn(input: { turnId?: string; userText: string }): Promise<string> {
|
|
571
|
+
async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
|
|
572
|
+
await ensureScope();
|
|
573
|
+
const resources = await this.ready();
|
|
574
|
+
const session = this.resolveSession(input.session);
|
|
575
|
+
return runInScope({ resources, services: this.services }, () => session.turn(input));
|
|
576
|
+
}
|
|
577
|
+
// Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
|
|
578
|
+
// it then runs under the DO's own lifetime (a DO stays alive while it has pending
|
|
579
|
+
// work), not the caller's. No live consumer sees the result — failures surface via
|
|
580
|
+
// the default turn-failure log / onTurnError (#76).
|
|
581
|
+
async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
|
|
194
582
|
await ensureScope();
|
|
195
|
-
|
|
583
|
+
const resources = await this.ready();
|
|
584
|
+
const session = this.resolveSession(input.session);
|
|
585
|
+
return runInScope({ resources, services: this.services }, () => session.start(input));
|
|
196
586
|
}
|
|
587
|
+
// An attributed note (#262) for custom shells — the direct sibling of the /note route. No
|
|
588
|
+
// scope needed: a note touches only the store.
|
|
589
|
+
note(input: { by: string; kind: NoteKind; text: string; session?: string }): Promise<{ noteId: string }> {
|
|
590
|
+
const { session, ...note } = input;
|
|
591
|
+
return this.resolveSession(session).note(note);
|
|
592
|
+
}
|
|
593
|
+
// Session reset (#129) for custom shells — the direct sibling of the /reset route. No
|
|
594
|
+
// scope needed: reset touches only the store (no tools run).
|
|
595
|
+
reset(opts?: { session?: string }): Promise<{ previousSession: string; generation: number }> {
|
|
596
|
+
const session = this.resolveSession(opts?.session);
|
|
597
|
+
this.armWatchdog(); // a reset can retire a park (#260)
|
|
598
|
+
return session.reset();
|
|
599
|
+
}
|
|
600
|
+
// Read-only: folds the durable log. When an identity exists (live, or persisted from a
|
|
601
|
+
// prior life) the session resolves and caches like any other path. Only a read on a
|
|
602
|
+
// NEVER-keyed object stays non-committal — caching there would bake "self" in as the
|
|
603
|
+
// identity and 409 a later keyed turn — so it folds through an ephemeral AgentSession.
|
|
197
604
|
transcript() {
|
|
198
|
-
return this.session.transcript();
|
|
605
|
+
if (this.session) return this.session.transcript();
|
|
606
|
+
if (this.store.getSessionKey() !== undefined) return this.resolveSession().transcript();
|
|
607
|
+
return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
|
|
199
608
|
}
|
|
200
|
-
// Default HTTP surface: POST …/turn
|
|
201
|
-
//
|
|
609
|
+
// Default HTTP surface: POST …/turn STREAMS the turn's TurnEvents as SSE (start the
|
|
610
|
+
// turn in-scope, then stream from the session sink); GET …/transcript reads the log.
|
|
611
|
+
// The app can call turn()/transcript() directly instead (turn() awaits the final text).
|
|
202
612
|
async fetch(req: Request): Promise<Response> {
|
|
203
613
|
const url = new URL(req.url);
|
|
614
|
+
// The session key durableFetch stamped on the routed request (#75); absent on a
|
|
615
|
+
// hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
|
|
616
|
+
const key = req.headers.get(SESSION_HEADER) ?? undefined;
|
|
204
617
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
205
|
-
const { userText, turnId } = (await req.json()) as { userText: string; turnId?: string };
|
|
206
|
-
|
|
618
|
+
const { userText, turnId, event, trigger, ifSuspended } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger; ifSuspended?: "reject" | "queue" };
|
|
619
|
+
await ensureScope();
|
|
620
|
+
// CANCEL-AND-REPLACE (#129): supersede every unfinished turn on this session before
|
|
621
|
+
// queuing this one (debounce). A mode flag like deliver/detach, so it rides the query
|
|
622
|
+
// string and composes with both. A suspended session still 409s below — replace
|
|
623
|
+
// never cancels a parked approval.
|
|
624
|
+
const replace = url.searchParams.get("replace") === "1";
|
|
625
|
+
// DELIVERED: like detach=1, the caller gets a 202 and goes away — but the reply is NOT
|
|
626
|
+
// dropped: this DO renders the turn's event stream through the source channel's own
|
|
627
|
+
// deliver() (the inbound renderer), under the DO's lifetime. Capability is checked
|
|
628
|
+
// BEFORE the turn starts — that ordering is DeliverUnsupportedError's contract at the
|
|
629
|
+
// surface: on a pre-start rejection the caller may fall back to consumer-side rendering
|
|
630
|
+
// without double-running the turn.
|
|
631
|
+
const wantsDeliver = url.searchParams.get("deliver") === "1";
|
|
632
|
+
const detached = url.searchParams.get("detach") === "1";
|
|
633
|
+
// HOLD (#263): only a caller with no live consumer may ask to be held — a streaming
|
|
634
|
+
// caller wants the reply now, and a held turn's reply comes after the park resolves.
|
|
635
|
+
if (ifSuspended === "queue" && !wantsDeliver && !detached) {
|
|
636
|
+
return Response.json({ error: `ifSuspended "queue" needs deliver=1 or detach=1: a held turn runs later, when no one is streaming it` }, { status: 400 });
|
|
637
|
+
}
|
|
638
|
+
if (ifSuspended === "queue" && replace) {
|
|
639
|
+
return Response.json({ error: `ifSuspended "queue" and replace=1 cannot be combined — replacing would drop held turns that were accepted` }, { status: 400 });
|
|
640
|
+
}
|
|
641
|
+
let deliverChannel: Channel | undefined;
|
|
642
|
+
if (wantsDeliver) {
|
|
643
|
+
if (!event?.channelId) {
|
|
644
|
+
return Response.json({ error: "deliver=1 needs an inbound event (event.channelId names the reply target)" }, { status: 400 });
|
|
645
|
+
}
|
|
646
|
+
deliverChannel = this.channels.find((c) => c.name === event.source);
|
|
647
|
+
if (!deliverChannel?.deliver) {
|
|
648
|
+
return Response.json(
|
|
649
|
+
{ error: `agent "${this.name}": no deliver()-capable channel named "${event.source}" is wired into this DO (DoAgentDef.channels) — the turn was NOT started` },
|
|
650
|
+
{ status: 501 },
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
|
|
655
|
+
// db/services (ALS propagates to the .then continuation registered here); subscribing
|
|
656
|
+
// happens synchronously right after, before any event can emit. Resources resolve
|
|
657
|
+
// BEFORE this block — the section from start() to the subscription stays synchronous.
|
|
658
|
+
const resources = await this.ready();
|
|
659
|
+
let session: AgentSession;
|
|
660
|
+
let started: { turnId: string; queued?: true };
|
|
661
|
+
try {
|
|
662
|
+
session = this.resolveSession(key);
|
|
663
|
+
// `hostContext` tells onDequeue how to finish a held turn (#263): a delivered one is
|
|
664
|
+
// rendered through its source channel when it runs, exactly as it would be now.
|
|
665
|
+
// start() itself runs turns a restart left held ahead of this one.
|
|
666
|
+
this.armWatchdog(); // a held turn commits a "held" announcement without starting (#260)
|
|
667
|
+
started = runInScope({ resources, services: this.services }, () =>
|
|
668
|
+
session.start({ userText, turnId, event, trigger, replace, ifSuspended, hostContext: wantsDeliver ? { deliver: true } : undefined }));
|
|
669
|
+
} catch (err) {
|
|
670
|
+
// e.g. the session is suspended awaiting input, or the key mis-matches this
|
|
671
|
+
// object's identity — a client-resolvable conflict, not a crash
|
|
672
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
673
|
+
}
|
|
674
|
+
if (started.queued) return Response.json({ turnId: started.turnId, queued: true }, { status: 202 });
|
|
675
|
+
if (wantsDeliver) {
|
|
676
|
+
// Subscribe synchronously (same guarantee sseTurnStream relies on: start() scheduled
|
|
677
|
+
// the turn, no event can have emitted yet), then render under THIS DO's lifetime —
|
|
678
|
+
// the retained promise is pending work that keeps the DO alive, so a long turn's
|
|
679
|
+
// rendering survives however briefly the caller held its connection. The event is
|
|
680
|
+
// present (checked above); its author/thread are the reply target, exactly the
|
|
681
|
+
// surface the channel's inbound renderStream derives.
|
|
682
|
+
//
|
|
683
|
+
// Once start() has queued the turn, NOTHING here may escape: a throw would 500 the
|
|
684
|
+
// caller for a turn that runs anyway — the exact ambiguity delivered turns exist to
|
|
685
|
+
// remove. deliver() is app/third-party code that can throw synchronously before
|
|
686
|
+
// returning its promise, so it is deferred into the chain (the eager subscription
|
|
687
|
+
// above stays synchronous — that timing guarantee must not move); the try/catch
|
|
688
|
+
// backstops the subscription itself. Rendering failures have no live consumer —
|
|
689
|
+
// this log is their only surfacing path (the TURN's own failures already surface
|
|
690
|
+
// via the #76 sink subscription).
|
|
691
|
+
const logRenderFailure = (err: unknown) =>
|
|
692
|
+
console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
|
|
693
|
+
try {
|
|
694
|
+
// Own the iterator here: observeTurnEvents subscribes EAGERLY, and a deliver()
|
|
695
|
+
// that throws before ever iterating would otherwise leave that subscription
|
|
696
|
+
// attached, buffering this turn's events forever. return() is idempotent, so
|
|
697
|
+
// closing in finally is safe whether deliver consumed the stream or died early.
|
|
698
|
+
const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
|
|
699
|
+
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
700
|
+
const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
|
|
701
|
+
Promise.resolve()
|
|
702
|
+
.then(() => runInScope({ resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey, files: this.files })))
|
|
703
|
+
.catch(logRenderFailure)
|
|
704
|
+
.finally(() => { void it.return?.(); });
|
|
705
|
+
} catch (err) {
|
|
706
|
+
logRenderFailure(err);
|
|
707
|
+
}
|
|
708
|
+
return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
709
|
+
}
|
|
710
|
+
// DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
|
|
711
|
+
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
712
|
+
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
713
|
+
// ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
|
|
714
|
+
if (detached) return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
715
|
+
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
716
|
+
}
|
|
717
|
+
// Provide the input a suspended turn is waiting on and stream its continuation as SSE.
|
|
718
|
+
// NOTE: this default surface passes `by` straight from the body — the engine treats it as a
|
|
719
|
+
// VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
|
|
720
|
+
// signature-checked Slack interaction payload), never expose this endpoint raw to clients.
|
|
721
|
+
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
722
|
+
const { turnId, inputId, input, by, principal, source, target } = (await req.json()) as {
|
|
723
|
+
turnId: string; inputId: string; input: unknown; by?: string; principal?: Principal;
|
|
724
|
+
source?: string; target?: ResumeDeliveryTarget;
|
|
725
|
+
};
|
|
726
|
+
await ensureScope();
|
|
727
|
+
// DELIVERED resume: like /turn?deliver=1, the caller 202s away and THIS DO renders the
|
|
728
|
+
// continuation through the source channel's deliverResume() — a long continuation no
|
|
729
|
+
// longer dies with the webhook isolate. Capability is checked BEFORE resume() applies
|
|
730
|
+
// the answer — the DeliverUnsupportedError contract at the surface: on a pre-effect
|
|
731
|
+
// refusal the caller may fall back to consumer-side rendering without double-answering
|
|
732
|
+
// (a second resume of an already-applied input would 409).
|
|
733
|
+
const wantsDeliver = url.searchParams.get("deliver") === "1";
|
|
734
|
+
let resumeChannel: Channel | undefined;
|
|
735
|
+
if (wantsDeliver) {
|
|
736
|
+
if (!source || !target?.channelId || !target.messageTs) {
|
|
737
|
+
return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
|
|
738
|
+
}
|
|
739
|
+
resumeChannel = this.channels.find((c) => c.name === source);
|
|
740
|
+
if (!resumeChannel?.deliverResume) {
|
|
741
|
+
return Response.json(
|
|
742
|
+
{ error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` },
|
|
743
|
+
{ status: 501 },
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// Same discipline as /turn: resolve resources before the synchronous
|
|
748
|
+
// resume-then-subscribe section.
|
|
749
|
+
const resources = await this.ready();
|
|
750
|
+
let session: AgentSession;
|
|
751
|
+
try {
|
|
752
|
+
session = this.resolveSession(key);
|
|
753
|
+
} catch (err) {
|
|
754
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
755
|
+
}
|
|
756
|
+
// A { policy } answerer is the app's call (#261), made before the synchronous
|
|
757
|
+
// resume-then-subscribe section and inside the request scope (the hook may read the db).
|
|
758
|
+
// The hook is app code: a failure there (a db outage) is a 500 — never the 409 a
|
|
759
|
+
// resume-state conflict gets, which would tell the caller its answer is stale.
|
|
760
|
+
let granted: Answerers | undefined;
|
|
761
|
+
try {
|
|
762
|
+
granted = await runInScope({ resources, services: this.services }, () =>
|
|
763
|
+
grantAnswer(session, { turnId, inputId, by, principal }, this.authorizeAnswer));
|
|
764
|
+
} catch (err) {
|
|
765
|
+
console.error(`[june] agent "${this.name}": authorizeAnswer failed for input "${inputId}" of turn ${turnId}:`, err);
|
|
766
|
+
return Response.json({ error: `authorizeAnswer failed: ${err instanceof Error ? err.message : String(err)} — the answer was NOT applied` }, { status: 500 });
|
|
767
|
+
}
|
|
768
|
+
this.armWatchdog(); // the answer commits a "resolved" announcement (#260)
|
|
769
|
+
try {
|
|
770
|
+
runInScope({ resources, services: this.services }, () => session.resume(turnId, inputId, input, { by, granted }));
|
|
771
|
+
} catch (err) {
|
|
772
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
773
|
+
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
|
|
774
|
+
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
775
|
+
}
|
|
776
|
+
if (wantsDeliver) {
|
|
777
|
+
// Same post-acceptance discipline as delivered turns: subscribe synchronously, render
|
|
778
|
+
// under this DO's lifetime, and let NOTHING escape past the accepted resume — the log
|
|
779
|
+
// is the render failure's only surfacing path (the TURN's own failures surface via
|
|
780
|
+
// the #76 sink subscription).
|
|
781
|
+
const logRenderFailure = (err: unknown) =>
|
|
782
|
+
console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
|
|
783
|
+
try {
|
|
784
|
+
// Own the iterator (same rationale as the delivered-turn path): the eager
|
|
785
|
+
// subscription must be released even when deliverResume throws before
|
|
786
|
+
// iterating — e.g. its very first message update fails.
|
|
787
|
+
const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
|
|
788
|
+
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
789
|
+
Promise.resolve()
|
|
790
|
+
.then(() => runInScope({ resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey, files: this.files })))
|
|
791
|
+
.catch(logRenderFailure)
|
|
792
|
+
.finally(() => { void it.return?.(); });
|
|
793
|
+
} catch (err) {
|
|
794
|
+
logRenderFailure(err);
|
|
795
|
+
}
|
|
796
|
+
return Response.json({ turnId }, { status: 202 });
|
|
797
|
+
}
|
|
798
|
+
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
207
799
|
}
|
|
208
|
-
|
|
209
|
-
|
|
800
|
+
// SESSION RESET (#129): terminally retire this session's history — supersede unfinished
|
|
801
|
+
// turns, archive messages/steps under the current generation (the audit trail), and
|
|
802
|
+
// answer with the audit handle. The response waits for in-flight turns to unwind at
|
|
803
|
+
// their next checkpoint boundary (the reset op runs on the turn chain), which is fine:
|
|
804
|
+
// a DO stays alive while it has pending work.
|
|
805
|
+
if (req.method === "POST" && url.pathname.endsWith("/reset")) {
|
|
806
|
+
let session: AgentSession;
|
|
807
|
+
try {
|
|
808
|
+
session = this.resolveSession(key);
|
|
809
|
+
} catch (err) {
|
|
810
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
811
|
+
}
|
|
812
|
+
this.armWatchdog(); // a reset can retire a park (#260)
|
|
813
|
+
try {
|
|
814
|
+
return Response.json(await session.reset());
|
|
815
|
+
} catch (err) {
|
|
816
|
+
// e.g. a store without reset() — a caller-resolvable refusal, not a crash
|
|
817
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// An attributed note (#262): body { by, kind, text } → { noteId }. A malformed body is a
|
|
821
|
+
// 400; a session-key conflict is a 409, like every other route.
|
|
822
|
+
if (req.method === "POST" && url.pathname.endsWith("/note")) {
|
|
823
|
+
let input: { by?: unknown; kind?: unknown; text?: unknown };
|
|
824
|
+
try {
|
|
825
|
+
input = (await req.json()) as typeof input;
|
|
826
|
+
} catch {
|
|
827
|
+
return Response.json({ error: "note: the body must be JSON { by, kind, text }" }, { status: 400 });
|
|
828
|
+
}
|
|
829
|
+
let session: AgentSession;
|
|
830
|
+
try {
|
|
831
|
+
session = this.resolveSession(key);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
return Response.json(await session.note(input as { by: string; kind: string; text: string }));
|
|
837
|
+
} catch (err) {
|
|
838
|
+
if (err instanceof TypeError) return Response.json({ error: err.message }, { status: 400 });
|
|
839
|
+
throw err;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (url.pathname.endsWith("/transcript")) {
|
|
843
|
+
// A keyed read still conflict-checks (and may commit the key — it is authentic,
|
|
844
|
+
// idFromName routed it here); a key-less read stays non-committal via transcript().
|
|
845
|
+
if (key !== undefined) {
|
|
846
|
+
try {
|
|
847
|
+
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
848
|
+
} catch (err) {
|
|
849
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return Response.json({ transcript: this.transcript() });
|
|
853
|
+
}
|
|
854
|
+
return new Response("agent DO — POST /turn, POST /resume, POST /reset, POST /note, or GET /transcript", { status: 404 });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── SSE transport for the turn event stream (crosses the worker→DO isolate) ───
|
|
859
|
+
// The DO streams a turn's TurnEvents as text/event-stream; the worker either pipes
|
|
860
|
+
// that straight to a browser (live chat) or collapses it to the final text (channels).
|
|
861
|
+
// no-store (not no-cache): an intermediary must NOT store chat/user content at all, and it
|
|
862
|
+
// matches the repo's other SSE surface (dev-reload).
|
|
863
|
+
const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-store" };
|
|
864
|
+
|
|
865
|
+
// A ReadableStream of SSE frames from a session's live event stream, scoped to one turn
|
|
866
|
+
// and closed on its terminal event. Subscribes synchronously (no replay needed — we start
|
|
867
|
+
// the turn then subscribe before any event can emit). A `:hb` comment heartbeat keeps the
|
|
868
|
+
// connection alive across long model/tool gaps (a silent SSE stream gets culled by idle
|
|
869
|
+
// timeouts on hosts/proxies) — cleared on terminal close and on cancel.
|
|
870
|
+
function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Uint8Array> {
|
|
871
|
+
const enc = new TextEncoder();
|
|
872
|
+
let unsub: (() => void) | undefined;
|
|
873
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
874
|
+
const stop = () => { unsub?.(); clearInterval(heartbeat); };
|
|
875
|
+
return new ReadableStream<Uint8Array>({
|
|
876
|
+
start(controller) {
|
|
877
|
+
heartbeat = setInterval(() => {
|
|
878
|
+
try { controller.enqueue(enc.encode(":hb\n\n")); } catch { clearInterval(heartbeat); }
|
|
879
|
+
}, 20_000);
|
|
880
|
+
unsub = session.observe((e) => {
|
|
881
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
882
|
+
// terminal for the STREAM: completed, failed, cancelled, OR suspended (input.requested
|
|
883
|
+
// → the turn parked; the stream ends and a later /resume opens a fresh continuation stream).
|
|
884
|
+
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") { stop(); controller.close(); }
|
|
885
|
+
}, { turnId });
|
|
886
|
+
},
|
|
887
|
+
cancel() { stop(); },
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Consume an SSE turn stream to its terminal state: the final text, or throw on failure.
|
|
892
|
+
// The non-streaming path (channels, a JSON chat response) uses this. Guards a non-SSE /
|
|
893
|
+
// body-less upstream response (a misroute or an error) into a clear error, not a TypeError.
|
|
894
|
+
export async function sseTurnFinalText(res: Response): Promise<string> {
|
|
895
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
896
|
+
if (!res.body || !ct.includes("text/event-stream")) {
|
|
897
|
+
const detail = res.body ? (await res.text()).slice(0, 200) : "no body";
|
|
898
|
+
throw new Error(`turn stream: expected an SSE response, got ${ct || "no content-type"} (status ${res.status}): ${detail}`);
|
|
899
|
+
}
|
|
900
|
+
const reader = res.body.getReader();
|
|
901
|
+
const dec = new TextDecoder();
|
|
902
|
+
let buf = "";
|
|
903
|
+
for (;;) {
|
|
904
|
+
const { done, value } = await reader.read();
|
|
905
|
+
if (value) buf += dec.decode(value, { stream: true });
|
|
906
|
+
let i: number;
|
|
907
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
908
|
+
const line = buf.slice(0, i).split("\n").find((l) => l.startsWith("data:"));
|
|
909
|
+
buf = buf.slice(i + 2);
|
|
910
|
+
if (!line) continue;
|
|
911
|
+
const e = JSON.parse(line.slice(5).trim()) as TurnEvent;
|
|
912
|
+
if (e.type === "turn.completed") return e.text;
|
|
913
|
+
if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error }); // full TurnError rides along
|
|
914
|
+
if (e.type === "turn.cancelled") throw new Error(`turn ${e.turnId} was cancelled (${e.reason})`);
|
|
915
|
+
}
|
|
916
|
+
if (done) break;
|
|
917
|
+
}
|
|
918
|
+
throw new Error("turn stream ended without a terminal event");
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Parse an SSE turn response into the stream of TurnEvents (skipping `:hb` heartbeats).
|
|
922
|
+
// The streaming consumer (a channel's render path) iterates this to drive live UI.
|
|
923
|
+
export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
|
|
924
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
925
|
+
if (!res.body || !ct.includes("text/event-stream")) {
|
|
926
|
+
const detail = res.body ? (await res.text()).slice(0, 200) : "no body";
|
|
927
|
+
throw new Error(`turn stream: expected an SSE response, got ${ct || "no content-type"} (status ${res.status}): ${detail}`);
|
|
928
|
+
}
|
|
929
|
+
const reader = res.body.getReader();
|
|
930
|
+
const dec = new TextDecoder();
|
|
931
|
+
let buf = "";
|
|
932
|
+
for (;;) {
|
|
933
|
+
const { done, value } = await reader.read();
|
|
934
|
+
if (value) buf += dec.decode(value, { stream: true });
|
|
935
|
+
let i: number;
|
|
936
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
937
|
+
const line = buf.slice(0, i).split("\n").find((l) => l.startsWith("data:"));
|
|
938
|
+
buf = buf.slice(i + 2);
|
|
939
|
+
if (line) yield JSON.parse(line.slice(5).trim()) as TurnEvent;
|
|
940
|
+
}
|
|
941
|
+
if (done) break;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// The header durableFetch stamps the session key on (#75). A DO cannot read its own
|
|
946
|
+
// idFromName name, so this is how the externally-assigned session identity crosses
|
|
947
|
+
// into the object — exported so a custom shell that bypasses durableFetch can set it.
|
|
948
|
+
export const SESSION_HEADER = "x-june-session";
|
|
949
|
+
|
|
950
|
+
// A session key rides an HTTP header (SESSION_HEADER) and the DO name (idFromName) —
|
|
951
|
+
// reject values that can't: CR/LF would be header injection, and Headers.set throws a
|
|
952
|
+
// bare TypeError on control/non-ASCII bytes, turning a bad client value into an opaque
|
|
953
|
+
// 500 deep inside durableFetch. Non-empty printable ASCII is the contract.
|
|
954
|
+
const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
|
|
955
|
+
function assertSessionKey(session: string) {
|
|
956
|
+
if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) {
|
|
957
|
+
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)`);
|
|
210
958
|
}
|
|
211
959
|
}
|
|
212
960
|
|
|
213
961
|
// Worker-side routing: address a session's DO by (agent, session) and forward the
|
|
214
|
-
// request to it. `env.AGENT` is the DO namespace binding.
|
|
962
|
+
// request to it. `env.AGENT` is the DO namespace binding. The session key rides a
|
|
963
|
+
// header (not the body): one seam covers /turn, /resume, and /transcript without
|
|
964
|
+
// touching any body contract, and an older DO simply ignores it.
|
|
215
965
|
export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
|
|
216
|
-
|
|
966
|
+
assertSessionKey(session);
|
|
967
|
+
const headers = new Headers(req.headers);
|
|
968
|
+
headers.set(SESSION_HEADER, session);
|
|
969
|
+
return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
|
|
217
970
|
}
|
|
218
971
|
|
|
219
972
|
// The EDGE agent surface for June's router: route the chat endpoint to the
|
|
@@ -235,13 +988,26 @@ export function durableAgentSurface(
|
|
|
235
988
|
const namespace = getNamespace();
|
|
236
989
|
if (!namespace) return null; // no DO binding → not mounted here
|
|
237
990
|
const { message, session } = (await req.json()) as { message: string; session?: string };
|
|
238
|
-
//
|
|
239
|
-
|
|
991
|
+
// `session` is CLIENT input here — an un-headerable value must be a clear 400, not
|
|
992
|
+
// the opaque 500 assertSessionKey would become this deep in the worker.
|
|
993
|
+
if (session !== undefined) {
|
|
994
|
+
try {
|
|
995
|
+
assertSessionKey(session);
|
|
996
|
+
} catch (err) {
|
|
997
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
|
|
1001
|
+
// that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
|
|
1002
|
+
// through; otherwise collapse to the final { text } (the prior JSON contract).
|
|
1003
|
+
const res = await durableFetch(
|
|
240
1004
|
namespace,
|
|
241
1005
|
opts.agentName,
|
|
242
1006
|
session ?? "default",
|
|
243
1007
|
new Request("https://do/turn", { method: "POST", body: JSON.stringify({ userText: message }) }),
|
|
244
1008
|
);
|
|
1009
|
+
if (req.headers.get("accept")?.includes("text/event-stream")) return res;
|
|
1010
|
+
return Response.json({ text: await sseTurnFinalText(res) });
|
|
245
1011
|
};
|
|
246
1012
|
}
|
|
247
1013
|
|
|
@@ -271,13 +1037,68 @@ export function durableAgentSurface(
|
|
|
271
1037
|
// fails in the background — surfaced via the channel's onError IF one is configured,
|
|
272
1038
|
// otherwise swallowed (runBackground never rejects). The app is expected to bind
|
|
273
1039
|
// env.AGENT when it mounts channels. Returns null for unclaimed requests.
|
|
1040
|
+
// The services bag, memoized per (env object, agentName).
|
|
1041
|
+
//
|
|
1042
|
+
// Why that key. `env` identity is what makes the memo survive: a worker builds
|
|
1043
|
+
// the surface inside `fetch`, so the only thing stable across requests is the
|
|
1044
|
+
// env object the host hands in — and NOT the provider function, which the
|
|
1045
|
+
// documented shape (`services: (e) => makeServices(e)`) recreates on every
|
|
1046
|
+
// fetch. But env alone is too coarse: two surfaces mounted in one worker over
|
|
1047
|
+
// the same env would then share whichever bag was built first, making the
|
|
1048
|
+
// result depend on mount order. `agentName` is the surface's own stable
|
|
1049
|
+
// identity, so it partitions them. (One agent must therefore have ONE services
|
|
1050
|
+
// provider — mounting the same agentName twice with different providers is
|
|
1051
|
+
// incoherent and keeps the first bag.)
|
|
1052
|
+
//
|
|
1053
|
+
// Held in an isolateLocal so duplicate module instances (workspace symlinks)
|
|
1054
|
+
// share one cache; the outer WeakMap is keyed by env so a host handing out
|
|
1055
|
+
// fresh env objects gets fresh bags instead of a leak. A non-object env (tests
|
|
1056
|
+
// passing a primitive) skips memoization rather than throwing.
|
|
1057
|
+
function resolveServices(
|
|
1058
|
+
factory: ((env: unknown) => unknown) | undefined,
|
|
1059
|
+
env: unknown,
|
|
1060
|
+
agentName: string,
|
|
1061
|
+
): unknown {
|
|
1062
|
+
if (!factory) return undefined;
|
|
1063
|
+
if (typeof env !== "object" || env === null) return factory(env);
|
|
1064
|
+
const cache = isolateLocal(
|
|
1065
|
+
"june.durableChannelSurface.services",
|
|
1066
|
+
() => new WeakMap<object, Map<string, unknown>>(),
|
|
1067
|
+
);
|
|
1068
|
+
let perAgent = cache.get(env);
|
|
1069
|
+
if (!perAgent) {
|
|
1070
|
+
perAgent = new Map<string, unknown>();
|
|
1071
|
+
cache.set(env, perAgent);
|
|
1072
|
+
}
|
|
1073
|
+
if (!perAgent.has(agentName)) perAgent.set(agentName, factory(env));
|
|
1074
|
+
return perAgent.get(agentName);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
274
1077
|
export function durableChannelSurface(
|
|
275
1078
|
getNamespace: () => DurableObjectNamespace | undefined,
|
|
276
1079
|
opts: {
|
|
277
1080
|
agentName: string;
|
|
278
1081
|
channels: (Channel | ChannelFactory)[];
|
|
279
1082
|
env: unknown;
|
|
1083
|
+
// The app's services factory — the SAME one AgentDurableObject uses — resolved HERE
|
|
1084
|
+
// (worker isolate) and exposed to channel hooks as ctx.services. Channel hooks run at
|
|
1085
|
+
// the edge, outside the DO, so they can't read the DO's ambient currentServices(); this
|
|
1086
|
+
// gives them the same DI bag.
|
|
1087
|
+
//
|
|
1088
|
+
// Called once per (env object, agentName) — NOT once per surface. A worker
|
|
1089
|
+
// typically constructs the surface inside `fetch` (env only exists inside an
|
|
1090
|
+
// invocation), so resolving per construction rebuilt every client on every
|
|
1091
|
+
// request and any cache the bag held could never hit. Memoizing on env
|
|
1092
|
+
// identity fixes that; see resolveServices above for why the key is env ×
|
|
1093
|
+
// agentName and not the provider function. Practical consequences: the
|
|
1094
|
+
// provider must be a function of `env` alone, it runs again whenever the
|
|
1095
|
+
// host hands in a different env object (a new isolate, or a test), and one
|
|
1096
|
+
// agentName must have one provider.
|
|
1097
|
+
services?: (env: unknown) => unknown;
|
|
280
1098
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
1099
|
+
// Where the channels read the files a turn attached (docs/rfc-agent-files.md) — the
|
|
1100
|
+
// same storage the Durable Object's tools write, opened from this isolate's env.
|
|
1101
|
+
files?: FileStore;
|
|
281
1102
|
},
|
|
282
1103
|
): (req: Request) => Promise<Response | null> {
|
|
283
1104
|
const resolved = opts.channels.map((c) => resolveChannel(c, opts.env));
|
|
@@ -285,7 +1106,9 @@ export function durableChannelSurface(
|
|
|
285
1106
|
// A minimal but complete AgentDefinition — channels only read ctx.agent.name; the
|
|
286
1107
|
// full def isn't present in the worker (tools/model live in the DO).
|
|
287
1108
|
agent: { name: opts.agentName, instructions: "", tools: [], skills: [], channels: [], connections: [] } satisfies AgentDefinition,
|
|
1109
|
+
services: resolveServices(opts.services, opts.env, opts.agentName),
|
|
288
1110
|
waitUntil: opts.waitUntil,
|
|
1111
|
+
files: opts.files,
|
|
289
1112
|
run: async (message, o) => {
|
|
290
1113
|
const namespace = getNamespace();
|
|
291
1114
|
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
@@ -293,11 +1116,156 @@ export function durableChannelSurface(
|
|
|
293
1116
|
namespace,
|
|
294
1117
|
opts.agentName,
|
|
295
1118
|
o?.session ?? "default",
|
|
296
|
-
new Request(
|
|
1119
|
+
new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
|
|
1120
|
+
);
|
|
1121
|
+
// /turn streams SSE; the simple path collapses it to the final text.
|
|
1122
|
+
return sseTurnFinalText(res);
|
|
1123
|
+
},
|
|
1124
|
+
// FIRE-AND-FORGET (#77): POST /turn?detach=1 — the DO 202s once the turn is accepted
|
|
1125
|
+
// and runs it under its OWN lifetime. This is how a shadow/observe hook (onEvent →
|
|
1126
|
+
// assessment turn, reply dropped) escapes the edge waitUntil ceiling that killed
|
|
1127
|
+
// 24–38s turns in production: nothing holds the edge open while the turn runs.
|
|
1128
|
+
runDetached: async (message, o) => {
|
|
1129
|
+
const namespace = getNamespace();
|
|
1130
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
1131
|
+
const res = await durableFetch(
|
|
1132
|
+
namespace,
|
|
1133
|
+
opts.agentName,
|
|
1134
|
+
o?.session ?? "default",
|
|
1135
|
+
new Request(`https://do/turn?detach=1${o?.replace ? "&replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
|
|
297
1136
|
);
|
|
298
|
-
|
|
299
|
-
|
|
1137
|
+
if (res.status !== 202) {
|
|
1138
|
+
const detail = (await res.text()).slice(0, 200);
|
|
1139
|
+
throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
|
|
1140
|
+
}
|
|
1141
|
+
return (await res.json()) as { turnId: string; queued?: true };
|
|
1142
|
+
},
|
|
1143
|
+
// DELIVERED: runDetached's reply-bearing sibling — the DO runs the turn AND renders its
|
|
1144
|
+
// reply through the source channel's deliver() under its OWN lifetime, so a reply-bearing
|
|
1145
|
+
// turn escapes the edge waitUntil ceiling exactly as a shadow turn does. A 501 means the
|
|
1146
|
+
// DO refused BEFORE starting the turn (the channel isn't wired there / has no deliver) —
|
|
1147
|
+
// surfaced as DeliverUnsupportedError, the one rejection a channel may answer with a
|
|
1148
|
+
// consumer-side rendering fallback without double-running the turn.
|
|
1149
|
+
runDelivered: async (message, o) => {
|
|
1150
|
+
const namespace = getNamespace();
|
|
1151
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
1152
|
+
const res = await durableFetch(
|
|
1153
|
+
namespace,
|
|
1154
|
+
opts.agentName,
|
|
1155
|
+
o?.session ?? "default",
|
|
1156
|
+
new Request(`https://do/turn?deliver=1${o?.replace ? "&replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
|
|
1157
|
+
);
|
|
1158
|
+
if (res.status === 501) {
|
|
1159
|
+
throw new DeliverUnsupportedError(`runDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this turn"}`);
|
|
1160
|
+
}
|
|
1161
|
+
if (res.status !== 202) {
|
|
1162
|
+
const detail = (await res.text()).slice(0, 200);
|
|
1163
|
+
throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
|
|
1164
|
+
}
|
|
1165
|
+
return (await res.json()) as { turnId: string; queued?: true };
|
|
1166
|
+
},
|
|
1167
|
+
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
1168
|
+
runStream: async function* (message, o) {
|
|
1169
|
+
const namespace = getNamespace();
|
|
1170
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
1171
|
+
const res = await durableFetch(
|
|
1172
|
+
namespace,
|
|
1173
|
+
opts.agentName,
|
|
1174
|
+
o?.session ?? "default",
|
|
1175
|
+
new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
|
|
1176
|
+
);
|
|
1177
|
+
yield* sseTurnEvents(res);
|
|
1178
|
+
},
|
|
1179
|
+
// Resume a parked turn on its session's DO and stream the continuation.
|
|
1180
|
+
resumeStream: async function* (o) {
|
|
1181
|
+
const namespace = getNamespace();
|
|
1182
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
1183
|
+
const res = await durableFetch(
|
|
1184
|
+
namespace,
|
|
1185
|
+
opts.agentName,
|
|
1186
|
+
o.session ?? "default",
|
|
1187
|
+
new Request("https://do/resume", { method: "POST", body: serializeResume(o) }),
|
|
1188
|
+
);
|
|
1189
|
+
yield* sseTurnEvents(res);
|
|
1190
|
+
},
|
|
1191
|
+
// DELIVERED resume (runDelivered's sibling for the HITL leg): the DO applies the answer
|
|
1192
|
+
// AND renders the continuation through the source channel's deliverResume() under its
|
|
1193
|
+
// OWN lifetime. A 501 means the DO refused BEFORE applying the answer (channel not
|
|
1194
|
+
// wired there / no deliverResume) — surfaced as DeliverUnsupportedError, the one
|
|
1195
|
+
// rejection a channel may answer with a consumer-side fallback without double-answering.
|
|
1196
|
+
// Engine rejections (403 unauthorized clicker / 409 stale-or-double click) throw as
|
|
1197
|
+
// ordinary errors, mirroring what resumeStream's first pull would do.
|
|
1198
|
+
resumeDelivered: async (o) => {
|
|
1199
|
+
const namespace = getNamespace();
|
|
1200
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
1201
|
+
const res = await durableFetch(
|
|
1202
|
+
namespace,
|
|
1203
|
+
opts.agentName,
|
|
1204
|
+
o.session ?? "default",
|
|
1205
|
+
new Request("https://do/resume?deliver=1", { method: "POST", body: serializeResume(o) }),
|
|
1206
|
+
);
|
|
1207
|
+
if (res.status === 501) {
|
|
1208
|
+
throw new DeliverUnsupportedError(`resumeDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this continuation"}`);
|
|
1209
|
+
}
|
|
1210
|
+
if (res.status !== 202) {
|
|
1211
|
+
const detail = (await res.text()).slice(0, 200);
|
|
1212
|
+
throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
|
|
1213
|
+
}
|
|
1214
|
+
return (await res.json()) as { turnId: string };
|
|
1215
|
+
},
|
|
1216
|
+
// SESSION RESET (#129): retire the session's history on its DO. The response carries
|
|
1217
|
+
// the audit handle ({ previousSession, generation }); a 409 (store without reset(),
|
|
1218
|
+
// key mismatch) surfaces as an ordinary error — there is no fallback to invent.
|
|
1219
|
+
resetSession: async (o) => {
|
|
1220
|
+
const namespace = getNamespace();
|
|
1221
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
1222
|
+
const res = await durableFetch(
|
|
1223
|
+
namespace,
|
|
1224
|
+
opts.agentName,
|
|
1225
|
+
o?.session ?? "default",
|
|
1226
|
+
new Request("https://do/reset", { method: "POST" }),
|
|
1227
|
+
);
|
|
1228
|
+
if (!res.ok) {
|
|
1229
|
+
const detail = (await res.text()).slice(0, 200);
|
|
1230
|
+
throw new Error(`resetSession: reset was not accepted (status ${res.status}): ${detail}`);
|
|
1231
|
+
}
|
|
1232
|
+
return (await res.json()) as { previousSession: string; generation: number };
|
|
300
1233
|
},
|
|
301
1234
|
};
|
|
302
1235
|
return channelDispatch(resolved, ctx);
|
|
303
1236
|
}
|
|
1237
|
+
|
|
1238
|
+
// Serialize a turn for the /turn RPC body. InboundEvent.raw is `unknown` — the
|
|
1239
|
+
// untouched platform payload — so a (third-party) channel could attach something
|
|
1240
|
+
// JSON.stringify chokes on (a circular object, a BigInt). raw isn't needed to route or
|
|
1241
|
+
// run the turn, so on a serialization failure we drop it rather than let an
|
|
1242
|
+
// unserializable payload take down turn forwarding entirely. (raw is optional on
|
|
1243
|
+
// InboundEvent precisely because it may not survive this boundary.)
|
|
1244
|
+
// Serialize a /resume RPC body. `input` (the human's answer) and `principal` (the resumer,
|
|
1245
|
+
// whose extra fields are `unknown`) come from a (third-party) host and could hold something
|
|
1246
|
+
// JSON.stringify chokes on (BigInt, circular). Unlike serializeTurn's `raw`, neither is
|
|
1247
|
+
// droppable: resuming without them answers wrongly or as the wrong person. So fail loudly,
|
|
1248
|
+
// naming the field that failed, rather than corrupt the resume.
|
|
1249
|
+
function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string; principal?: Principal; source?: string; target?: ResumeDeliveryTarget }): string {
|
|
1250
|
+
try {
|
|
1251
|
+
return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by, principal: o.principal, source: o.source, target: o.target });
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
const culprit = (["input", "principal"] as const).find((field) => {
|
|
1254
|
+
try { JSON.stringify(o[field]); return false; } catch { return true; }
|
|
1255
|
+
}) ?? "payload";
|
|
1256
|
+
// API-neutral prefix: this serializer backs both resumeStream and resumeDelivered.
|
|
1257
|
+
throw new Error(`resume: ${culprit} is not JSON-serializable (${(err as Error).message}) — a resume must round-trip to the DO`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// `trigger` is ProactiveTrigger — plain strings by construction — so only event.raw can make
|
|
1262
|
+
// the stringify throw; the fallback that strips it stays sufficient.
|
|
1263
|
+
function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger; ifSuspended?: "reject" | "queue" }): string {
|
|
1264
|
+
const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger, ifSuspended: o?.ifSuspended };
|
|
1265
|
+
try {
|
|
1266
|
+
return JSON.stringify(payload);
|
|
1267
|
+
} catch {
|
|
1268
|
+
const event = o?.event ? { ...o.event, raw: undefined } : undefined;
|
|
1269
|
+
return JSON.stringify({ ...payload, event });
|
|
1270
|
+
}
|
|
1271
|
+
}
|