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