@junejs/server 1.0.0-dev.20 → 1.0.0-dev.25

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,3 +1,4 @@
1
+ import { observeTurnEvents } from "./turn-events.js";
1
2
  import { isolateLocal } from "./isolate-local.js";
2
3
  import { assertCoreRuntimeVersion } from "./core-version.js";
3
4
  import { DeliverUnsupportedError, actionToTool, channelDispatch, resolveChannel } from "@junejs/core/agent-config";
@@ -373,49 +374,6 @@ function sseTurnStream(session, turnId) {
373
374
  }
374
375
  });
375
376
  }
376
- function observeTurnEvents(session, turnId) {
377
- const queue = [];
378
- let terminal = false;
379
- let notify;
380
- const unsub = session.observe((e) => {
381
- queue.push(e);
382
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
383
- notify?.();
384
- }, { turnId });
385
- return { [Symbol.asyncIterator]() {
386
- return {
387
- async next() {
388
- for (;;) {
389
- const e = queue.shift();
390
- if (e) return {
391
- value: e,
392
- done: false
393
- };
394
- if (terminal) {
395
- unsub();
396
- return {
397
- value: void 0,
398
- done: true
399
- };
400
- }
401
- await new Promise((resolve) => {
402
- notify = resolve;
403
- });
404
- notify = void 0;
405
- }
406
- },
407
- async return() {
408
- unsub();
409
- terminal = true;
410
- queue.length = 0;
411
- return {
412
- value: void 0,
413
- done: true
414
- };
415
- }
416
- };
417
- } };
418
- }
419
377
  async function sseTurnFinalText(res) {
420
378
  const ct = res.headers.get("content-type") ?? "";
421
379
  if (!res.body || !ct.includes("text/event-stream")) {
@@ -1,32 +1,42 @@
1
1
  import { SyncSqlite } from "./sqlite-driver.js";
2
2
  import { AgentDefinition, ChannelContext } from "@junejs/core/agent-config";
3
- import { AgentSession, Model, Runtime, Tool } from "@junejs/core/agent-runtime";
3
+ import { AgentSession, ChannelPolicy, Model, Runtime, Tool } from "@junejs/core/agent-runtime";
4
4
  //#region src/agent-native.d.ts
5
5
  type AgentDef = {
6
6
  model: Model;
7
7
  tools: Tool[];
8
8
  instructions?: string;
9
- channelInstructions?: Record<string, string>;
9
+ channelInstructions?: Record<string, string | ChannelPolicy>;
10
+ };
11
+ declare function toAgentDef(agent: AgentDefinition, model: Model): AgentDef;
12
+ type NativeRuntimeOptions = {
13
+ maxSessions?: number;
10
14
  };
11
15
  declare class NativeRuntime implements Runtime {
12
16
  private actors;
13
17
  private readonly agents;
14
18
  private readonly db;
15
- constructor(agents: Record<string, AgentDef>, db: SyncSqlite);
19
+ private readonly maxSessions;
20
+ constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts?: NativeRuntimeOptions);
21
+ agentDef(name: string): AgentDef | undefined;
16
22
  session(agent: string, id: string): AgentSession;
23
+ get sessionCount(): number;
24
+ private evictIdle;
17
25
  }
18
- declare function createNativeRuntime(agents: Record<string, AgentDef>, path?: string): Promise<NativeRuntime>;
26
+ declare function createNativeRuntime(agents: Record<string, AgentDef>, path?: string, opts?: NativeRuntimeOptions): Promise<NativeRuntime>;
19
27
  declare class MemoryRuntime implements Runtime {
20
28
  private actors;
21
29
  private stores;
22
30
  private readonly agents;
23
31
  constructor(agents: Record<string, AgentDef>);
32
+ agentDef(name: string): AgentDef | undefined;
24
33
  session(agent: string, id: string): AgentSession;
25
34
  }
26
35
  type AgentBackend = "native" | "memory" | "durable";
27
36
  declare function createAgentRuntime(agents: Record<string, AgentDef>, opts?: {
28
37
  backend?: AgentBackend;
29
38
  path?: string;
39
+ maxSessions?: number;
30
40
  }): Promise<Runtime>;
31
41
  declare function mountAgent(agent: AgentDefinition, runtime: Runtime, opts?: {
32
42
  chatPath?: string;
@@ -39,4 +49,4 @@ declare function mountAgent(agent: AgentDefinition, runtime: Runtime, opts?: {
39
49
  ctx: ChannelContext;
40
50
  };
41
51
  //#endregion
42
- export { AgentBackend, AgentDef, MemoryRuntime, NativeRuntime, createAgentRuntime, createNativeRuntime, mountAgent };
52
+ export { AgentBackend, AgentDef, MemoryRuntime, NativeRuntime, NativeRuntimeOptions, createAgentRuntime, createNativeRuntime, mountAgent, toAgentDef };
@@ -1,11 +1,13 @@
1
+ import { observeTurnEvents } from "./turn-events.js";
1
2
  import { assertCoreRuntimeVersion } from "./core-version.js";
2
3
  import { openLocalSqliteSync } from "./sqlite-driver.js";
3
- import { channelFetch } from "@junejs/core/agent-config";
4
+ import { buildSystemPrompt, channelFetch } from "@junejs/core/agent-config";
4
5
  import { AgentSession, withSystem } from "@junejs/core/agent-runtime";
5
6
  //#region src/agent-native.ts
6
7
  function initSchema(db) {
7
8
  db.exec(`CREATE TABLE IF NOT EXISTS agent_sessions (session_id TEXT PRIMARY KEY, status TEXT)`);
8
9
  db.exec(`CREATE TABLE IF NOT EXISTS agent_messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, body TEXT)`);
10
+ db.exec(`CREATE INDEX IF NOT EXISTS agent_messages_session ON agent_messages (session_id, seq)`);
9
11
  db.exec(`CREATE TABLE IF NOT EXISTS agent_steps (session_id TEXT, id TEXT, output TEXT, PRIMARY KEY (session_id, id))`);
10
12
  }
11
13
  var SqliteSessionStore = class {
@@ -22,7 +24,7 @@ var SqliteSessionStore = class {
22
24
  return this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid).map((r) => JSON.parse(r.body));
23
25
  }
24
26
  hasOpeningMessage(turnId) {
25
- return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
27
+ return this.db.query("SELECT 1 FROM agent_messages WHERE session_id = ? AND json_extract(body, '$.turnId') = ? AND json_extract(body, '$.role') IN ('user', 'trigger') LIMIT 1").get(this.sid, turnId) != null;
26
28
  }
27
29
  getStep(id) {
28
30
  const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id);
@@ -83,32 +85,68 @@ var InProcEventSink = class {
83
85
  this.subs.add(cb);
84
86
  return () => this.subs.delete(cb);
85
87
  }
88
+ get size() {
89
+ return this.subs.size;
90
+ }
86
91
  };
92
+ function toAgentDef(agent, model) {
93
+ return {
94
+ model,
95
+ tools: agent.tools,
96
+ instructions: buildSystemPrompt(agent),
97
+ ...agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {}
98
+ };
99
+ }
87
100
  var NativeRuntime = class {
88
101
  actors = /* @__PURE__ */ new Map();
89
102
  agents;
90
103
  db;
91
- constructor(agents, db) {
104
+ maxSessions;
105
+ constructor(agents, db, opts = {}) {
92
106
  assertCoreRuntimeVersion("NativeRuntime");
93
107
  this.agents = agents;
94
108
  this.db = db;
109
+ const max = opts.maxSessions ?? 1e3;
110
+ if (!(Number.isInteger(max) && max >= 1) && max !== Infinity) throw new RangeError(`NativeRuntime: maxSessions must be an integer >= 1 (or Infinity for no cap), got ${max}`);
111
+ this.maxSessions = max;
95
112
  initSchema(db);
96
113
  }
114
+ agentDef(name) {
115
+ return this.agents[name];
116
+ }
97
117
  session(agent, id) {
98
118
  const key = `${agent}:${id}`;
99
- let a = this.actors.get(key);
100
- if (!a) {
101
- const def = this.agents[agent];
102
- if (!def) throw new Error(`unknown agent: ${agent}`);
103
- const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
104
- a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcEventSink(), model, def.tools, this, def.channelInstructions);
105
- this.actors.set(key, a);
119
+ const hit = this.actors.get(key);
120
+ if (hit) {
121
+ this.actors.delete(key);
122
+ this.actors.set(key, hit);
123
+ return hit.session;
124
+ }
125
+ const def = this.agents[agent];
126
+ if (!def) throw new Error(`unknown agent: ${agent}`);
127
+ const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
128
+ const sink = new InProcEventSink();
129
+ const session = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
130
+ this.evictIdle();
131
+ this.actors.set(key, {
132
+ session,
133
+ sink
134
+ });
135
+ return session;
136
+ }
137
+ get sessionCount() {
138
+ return this.actors.size;
139
+ }
140
+ evictIdle() {
141
+ if (this.actors.size < this.maxSessions) return;
142
+ for (const [key, a] of this.actors) {
143
+ if (this.actors.size < this.maxSessions) break;
144
+ if (a.session.idle() && a.sink.size === 0) this.actors.delete(key);
106
145
  }
107
- return a;
108
146
  }
109
147
  };
110
- async function createNativeRuntime(agents, path = ":memory:") {
111
- return new NativeRuntime(agents, await openLocalSqliteSync(path));
148
+ async function createNativeRuntime(agents, path = ":memory:", opts = {}) {
149
+ return new NativeRuntime(agents, await openLocalSqliteSync(path), opts);
112
150
  }
113
151
  var MemorySessionStore = class {
114
152
  msgs = [];
@@ -165,6 +203,9 @@ var MemoryRuntime = class {
165
203
  assertCoreRuntimeVersion("MemoryRuntime");
166
204
  this.agents = agents;
167
205
  }
206
+ agentDef(name) {
207
+ return this.agents[name];
208
+ }
168
209
  session(agent, id) {
169
210
  const key = `${agent}:${id}`;
170
211
  let a = this.actors.get(key);
@@ -173,7 +214,8 @@ var MemoryRuntime = class {
173
214
  if (!def) throw new Error(`unknown agent: ${agent}`);
174
215
  const store = new MemorySessionStore();
175
216
  this.stores.set(key, store);
176
- a = new AgentSession(agent, id, store, new InProcEventSink(), def.model, def.tools, this);
217
+ const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
218
+ a = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
177
219
  this.actors.set(key, a);
178
220
  }
179
221
  return a;
@@ -182,12 +224,23 @@ var MemoryRuntime = class {
182
224
  async function createAgentRuntime(agents, opts = {}) {
183
225
  const backend = opts.backend ?? "native";
184
226
  if (backend === "memory") return new MemoryRuntime(agents);
185
- if (backend === "native") return createNativeRuntime(agents, opts.path);
227
+ if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions });
186
228
  throw new Error("backend 'durable' is the Cloudflare Durable Object target — construct AgentDurableObject in your worker, not via createAgentRuntime");
187
229
  }
230
+ function warnOnDrift(agent, runtime) {
231
+ const def = runtime.agentDef?.(agent.name);
232
+ if (!def) return;
233
+ const names = (tools) => tools.map((t) => t.spec.name).sort();
234
+ const want = names(agent.tools), have = names(def.tools);
235
+ if (want.join("\0") !== have.join("\0")) {
236
+ const missing = want.filter((n) => !have.includes(n)), extra = have.filter((n) => !want.includes(n));
237
+ console.warn(`[june] mountAgent("${agent.name}"): the runtime's tools differ from the agent definition's` + (missing.length ? ` — missing from the runtime: ${missing.join(", ")}` : "") + (extra.length ? ` — only in the runtime: ${extra.join(", ")}` : "") + `. Build the runtime entry with toAgentDef(agent, model) so both come from one definition.`);
238
+ }
239
+ }
188
240
  function mountAgent(agent, runtime, opts = {}) {
189
241
  const chatPath = opts.chatPath ?? "/message";
190
242
  const channelsOn = opts.channels ?? true;
243
+ warnOnDrift(agent, runtime);
191
244
  const ctx = {
192
245
  agent,
193
246
  services: opts.services,
@@ -205,6 +258,22 @@ function mountAgent(agent, runtime, opts = {}) {
205
258
  trigger: o?.trigger,
206
259
  replace: o?.replace
207
260
  }),
261
+ runStream: async function* (message, o) {
262
+ const session = runtime.session(agent.name, o?.session ?? "default");
263
+ const { turnId } = session.start({
264
+ turnId: o?.turnId,
265
+ userText: message,
266
+ event: o?.event,
267
+ trigger: o?.trigger,
268
+ replace: o?.replace
269
+ });
270
+ yield* observeTurnEvents(session, turnId);
271
+ },
272
+ resumeStream: async function* (o) {
273
+ const session = runtime.session(agent.name, o.session ?? "default");
274
+ const { turnId } = session.resume(o.turnId, o.inputId, o.input, { by: o.by });
275
+ yield* observeTurnEvents(session, turnId);
276
+ },
208
277
  resetSession: (o) => runtime.session(agent.name, o?.session ?? "default").reset()
209
278
  };
210
279
  const channels = channelFetch(agent, ctx);
@@ -226,4 +295,4 @@ function mountAgent(agent, runtime, opts = {}) {
226
295
  };
227
296
  }
228
297
  //#endregion
229
- export { MemoryRuntime, NativeRuntime, createAgentRuntime, createNativeRuntime, mountAgent };
298
+ export { MemoryRuntime, NativeRuntime, createAgentRuntime, createNativeRuntime, mountAgent, toAgentDef };
package/dist/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { discoverAgent } from "./agent-discover.js";
2
- import { createAgentRuntime, mountAgent } from "./agent-native.js";
2
+ import { createAgentRuntime, mountAgent, toAgentDef } from "./agent-native.js";
3
3
  import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles } from "./router.js";
4
4
  import { createPipeline } from "./pipeline.js";
5
5
  import { resolveBoundary } from "./segment.js";
@@ -13,7 +13,6 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
13
13
  import { readFile } from "node:fs/promises";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { pathToFileURL } from "node:url";
16
- import { buildSystemPrompt } from "@junejs/core/agent-config";
17
16
  import { routeFromModule } from "@junejs/core/route";
18
17
  import { resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
19
18
  import { runWithTrace } from "@junejs/core/instrumentation";
@@ -125,11 +124,7 @@ function createApp({ appDir: appDirInput, config = {} }) {
125
124
  const def = await discoverAgent(agentDir);
126
125
  const backend = agent.runtime.backend === "durable" ? "native" : agent.runtime.backend;
127
126
  const model = anthropic({ model: def.model });
128
- const mounted = mountAgent(def, await createAgentRuntime({ [def.name]: {
129
- model,
130
- tools: def.tools,
131
- instructions: buildSystemPrompt(def)
132
- } }, { backend }), {
127
+ const mounted = mountAgent(def, await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, { backend }), {
133
128
  chatPath: agent.runtime.chat.path,
134
129
  channels: agent.runtime.channels
135
130
  });
@@ -1,6 +1,6 @@
1
1
  import * as coreRuntime from "@junejs/core/agent-runtime";
2
2
  //#region src/core-version.ts
3
- const EXPECTED_CORE_RUNTIME_API = 1;
3
+ const EXPECTED_CORE_RUNTIME_API = 2;
4
4
  function assertCoreRuntimeVersion(surface) {
5
5
  checkCoreRuntimeVersion(surface, coreRuntime.RUNTIME_API_VERSION);
6
6
  }
@@ -0,0 +1,5 @@
1
+ import { AgentSession, TurnEvent } from "@junejs/core/agent-runtime";
2
+ //#region src/turn-events.d.ts
3
+ declare function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable<TurnEvent>;
4
+ //#endregion
5
+ export { observeTurnEvents };
@@ -0,0 +1,46 @@
1
+ //#region src/turn-events.ts
2
+ function observeTurnEvents(session, turnId) {
3
+ const queue = [];
4
+ let terminal = false;
5
+ let notify;
6
+ const unsub = session.observe((e) => {
7
+ queue.push(e);
8
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
9
+ notify?.();
10
+ }, { turnId });
11
+ return { [Symbol.asyncIterator]() {
12
+ return {
13
+ async next() {
14
+ for (;;) {
15
+ const e = queue.shift();
16
+ if (e) return {
17
+ value: e,
18
+ done: false
19
+ };
20
+ if (terminal) {
21
+ unsub();
22
+ return {
23
+ value: void 0,
24
+ done: true
25
+ };
26
+ }
27
+ await new Promise((resolve) => {
28
+ notify = resolve;
29
+ });
30
+ notify = void 0;
31
+ }
32
+ },
33
+ async return() {
34
+ unsub();
35
+ terminal = true;
36
+ queue.length = 0;
37
+ return {
38
+ value: void 0,
39
+ done: true
40
+ };
41
+ }
42
+ };
43
+ } };
44
+ }
45
+ //#endregion
46
+ export { observeTurnEvents };
package/dist/worker.js CHANGED
@@ -178,7 +178,7 @@ function withAssets(pipeline, opts = {}) {
178
178
  });
179
179
  }
180
180
  }
181
- if (assets) {
181
+ if (assets && (request.method === "GET" || request.method === "HEAD")) {
182
182
  const a = await assets.fetch(request);
183
183
  if (a.status !== 404) {
184
184
  const ct = a.headers.get("content-type") ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.20",
3
+ "version": "1.0.0-dev.25",
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",
@@ -138,7 +138,7 @@
138
138
  "rolldown": "^1.1.0"
139
139
  },
140
140
  "peerDependencies": {
141
- "@junejs/core": ">=0.2.0-dev.38",
141
+ "@junejs/core": ">=0.2.0-dev.44",
142
142
  "react": "^19.2.0",
143
143
  "react-dom": "^19.2.0",
144
144
  "react-server-dom-webpack": "19.2.7",
@@ -120,8 +120,8 @@ export async function discoverAgentModule(dir: string): Promise<AgentModule> {
120
120
 
121
121
  // Discover an agent from its directory, returning the assembled AgentDefinition
122
122
  // (tools already adapted from defineActions; read_skill added if any skills
123
- // exist). Mount it on a runtime with createNativeRuntime({ [agent.name]: { model,
124
- // tools: agent.tools } }).
123
+ // exist). Mount it on a runtime with createNativeRuntime({ [agent.name]:
124
+ // toAgentDef(agent, model) }).
125
125
  export async function discoverAgent(dir: string): Promise<AgentDefinition> {
126
126
  return assembleAgent(await discoverAgentModule(dir), process.env);
127
127
  }
@@ -33,6 +33,7 @@ import {
33
33
  type TurnFailurePhase,
34
34
  } from "@junejs/core/agent-runtime";
35
35
  import type { Resources } from "@junejs/core/resources";
36
+ import { observeTurnEvents } from "./turn-events";
36
37
  import {
37
38
  channelDispatch,
38
39
  DeliverUnsupportedError,
@@ -692,45 +693,6 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
692
693
  });
693
694
  }
694
695
 
695
- // The IN-PROCESS sibling of sseTurnStream: one turn's TurnEvents as an AsyncIterable, for a
696
- // consumer living in the SAME isolate as the session (a delivered render). Subscribes eagerly
697
- // at call time — not at first iteration — so the sseTurnStream timing guarantee carries over
698
- // (call this synchronously after start(), before any event can emit); events landing before
699
- // the consumer catches up are buffered. Ends after the turn's terminal event (completed,
700
- // failed, or input.requested — a park ends this stream; a later /resume is a new one), and an
701
- // early consumer exit (for-await break/return) unsubscribes rather than buffering forever.
702
- function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable<TurnEvent> {
703
- const queue: TurnEvent[] = [];
704
- let terminal = false;
705
- let notify: (() => void) | undefined;
706
- const unsub = session.observe((e) => {
707
- queue.push(e);
708
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
709
- notify?.();
710
- }, { turnId });
711
- return {
712
- [Symbol.asyncIterator]() {
713
- return {
714
- async next(): Promise<IteratorResult<TurnEvent>> {
715
- for (;;) {
716
- const e = queue.shift();
717
- if (e) return { value: e, done: false };
718
- if (terminal) { unsub(); return { value: undefined, done: true }; }
719
- await new Promise<void>((resolve) => { notify = resolve; });
720
- notify = undefined;
721
- }
722
- },
723
- async return(): Promise<IteratorResult<TurnEvent>> {
724
- unsub();
725
- terminal = true;
726
- queue.length = 0;
727
- return { value: undefined, done: true };
728
- },
729
- };
730
- },
731
- };
732
- }
733
-
734
696
  // Consume an SSE turn stream to its terminal state: the final text, or throw on failure.
735
697
  // The non-streaming path (channels, a JSON chat response) uses this. Guards a non-SSE /
736
698
  // body-less upstream response (a misroute or an error) into a clear error, not a TypeError.
@@ -10,6 +10,7 @@
10
10
  import {
11
11
  AgentSession,
12
12
  withSystem,
13
+ type ChannelPolicy,
13
14
  type EventSink,
14
15
  type TurnEvent,
15
16
  type Model,
@@ -18,13 +19,17 @@ import {
18
19
  type SessionStore,
19
20
  type Tool,
20
21
  } from "@junejs/core/agent-runtime";
21
- import { channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
22
+ import { buildSystemPrompt, channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
22
23
  import { openLocalSqliteSync, type SyncSqlite } from "./sqlite-driver";
23
24
  import { assertCoreRuntimeVersion } from "./core-version";
25
+ import { observeTurnEvents } from "./turn-events";
24
26
 
25
27
  function initSchema(db: SyncSqlite) {
26
28
  db.exec(`CREATE TABLE IF NOT EXISTS agent_sessions (session_id TEXT PRIMARY KEY, status TEXT)`);
27
29
  db.exec(`CREATE TABLE IF NOT EXISTS agent_messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, body TEXT)`);
30
+ // Every store read is scoped to one session; without this index each one scanned the
31
+ // messages of ALL sessions in the file (#168). IF NOT EXISTS: existing DBs gain it.
32
+ db.exec(`CREATE INDEX IF NOT EXISTS agent_messages_session ON agent_messages (session_id, seq)`);
28
33
  // NOTE: PRIMARY KEY (session_id, id) — never id alone. The store view scopes
29
34
  // every query by session, so a step id can't leak across sessions.
30
35
  db.exec(`CREATE TABLE IF NOT EXISTS agent_steps (session_id TEXT, id TEXT, output TEXT, PRIMARY KEY (session_id, id))`);
@@ -47,8 +52,11 @@ class SqliteSessionStore implements SessionStore {
47
52
  return (this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid) as { body: string }[])
48
53
  .map((r) => JSON.parse(r.body));
49
54
  }
55
+ // Asked once per turn start: probe for the one row instead of parsing the transcript.
50
56
  hasOpeningMessage(turnId: string): boolean {
51
- return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
57
+ return this.db
58
+ .query("SELECT 1 FROM agent_messages WHERE session_id = ? AND json_extract(body, '$.turnId') = ? AND json_extract(body, '$.role') IN ('user', 'trigger') LIMIT 1")
59
+ .get(this.sid, turnId) != null; // bun:sqlite misses with null, node:sqlite with undefined
52
60
  }
53
61
  getStep(id: string): unknown | undefined {
54
62
  const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id) as { output: string } | undefined;
@@ -106,37 +114,96 @@ class InProcEventSink implements EventSink {
106
114
  private subs = new Set<(e: TurnEvent) => void>();
107
115
  emit(e: TurnEvent) { this.subs.forEach((cb) => { try { cb(e); } catch { /* a bad subscriber must not break emit */ } }); }
108
116
  subscribe(cb: (e: TurnEvent) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
117
+ get size(): number { return this.subs.size; }
109
118
  }
110
119
 
111
120
  // `instructions` (the agent's system prompt) is injected into the model per turn
112
121
  // by the runtime (withSystem) — single-sourced on the def, not baked into `model`.
113
- export type AgentDef = { model: Model; tools: Tool[]; instructions?: string; channelInstructions?: Record<string, string> };
122
+ export type AgentDef = { model: Model; tools: Tool[]; instructions?: string; channelInstructions?: Record<string, string | ChannelPolicy> };
123
+
124
+ // The runtime-side def for an assembled AgentDefinition (#173): the tools (channel
125
+ // capability tools and read_skill included), the system prompt (instructions + the
126
+ // skill index) and the per-surface policies all come from the ONE definition that
127
+ // mountAgent mounts, so the engine and the channels can't drift apart. Only the model
128
+ // is the host's choice.
129
+ // createNativeRuntime({ [agent.name]: toAgentDef(agent, anthropic({ … })) })
130
+ export function toAgentDef(agent: AgentDefinition, model: Model): AgentDef {
131
+ return {
132
+ model,
133
+ tools: agent.tools,
134
+ instructions: buildSystemPrompt(agent),
135
+ ...(agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {}),
136
+ };
137
+ }
138
+
139
+ export type NativeRuntimeOptions = {
140
+ // Soft cap on memoized session actors (#174), default 1000. Past it, the least recently
141
+ // used IDLE actors (no turn running or queued, no reset pending, no live subscriber) are
142
+ // dropped; their state is in SQLite, so the next session() rebuilds them transparently.
143
+ // Busy actors are never dropped, so the count can exceed the cap while they run.
144
+ maxSessions?: number;
145
+ };
114
146
 
115
147
  // The native Runtime: a registry of agent definitions over one SQLite handle,
116
- // handing out (and memoizing) an AgentSession actor per (agent, id).
148
+ // handing out (and memoizing) an AgentSession actor per (agent, id). The memo is an
149
+ // LRU bounded by maxSessions — with slackChannel every thread is a session, so an
150
+ // unbounded memo grew by one actor per thread for the life of the process.
151
+ // Callers must not hold an AgentSession across an await and start turns on it later:
152
+ // if it was evicted meanwhile, a fresh actor for the same session would run turns
153
+ // unserialized with it. Call session() at the point of use (mountAgent does).
117
154
  export class NativeRuntime implements Runtime {
118
- private actors = new Map<string, AgentSession>();
155
+ private actors = new Map<string, { session: AgentSession; sink: InProcEventSink }>();
119
156
  private readonly agents: Record<string, AgentDef>;
120
157
  private readonly db: SyncSqlite;
158
+ private readonly maxSessions: number;
121
159
 
122
- constructor(agents: Record<string, AgentDef>, db: SyncSqlite) {
160
+ constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
123
161
  assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
124
162
  this.agents = agents;
125
163
  this.db = db;
164
+ const max = opts.maxSessions ?? 1000;
165
+ // At least one actor is always retained (the one being handed out), so a cap below 1
166
+ // means nothing, and NaN would compare false and evict every idle actor on each miss.
167
+ // Infinity is allowed: an explicit "no cap".
168
+ if (!(Number.isInteger(max) && max >= 1) && max !== Infinity) {
169
+ throw new RangeError(`NativeRuntime: maxSessions must be an integer >= 1 (or Infinity for no cap), got ${max}`);
170
+ }
171
+ this.maxSessions = max;
126
172
  initSchema(db);
127
173
  }
128
174
 
175
+ agentDef(name: string): AgentDef | undefined { return this.agents[name]; }
176
+
129
177
  session(agent: string, id: string): AgentSession {
130
178
  const key = `${agent}:${id}`;
131
- let a = this.actors.get(key);
132
- if (!a) {
133
- const def = this.agents[agent];
134
- if (!def) throw new Error(`unknown agent: ${agent}`);
135
- const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
136
- a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcEventSink(), model, def.tools, this, def.channelInstructions);
137
- this.actors.set(key, a);
179
+ const hit = this.actors.get(key);
180
+ if (hit) {
181
+ // most recently used → the tail (Map iteration order is insertion order)
182
+ this.actors.delete(key);
183
+ this.actors.set(key, hit);
184
+ return hit.session;
185
+ }
186
+ const def = this.agents[agent];
187
+ if (!def) throw new Error(`unknown agent: ${agent}`);
188
+ const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
189
+ const sink = new InProcEventSink();
190
+ const session = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
191
+ this.evictIdle();
192
+ this.actors.set(key, { session, sink });
193
+ return session;
194
+ }
195
+
196
+ // Number of memoized actors (observability / tests).
197
+ get sessionCount(): number { return this.actors.size; }
198
+
199
+ // Drop least recently used idle actors until one more fits under the cap. Runs before
200
+ // the new actor is inserted, so the actor being handed out is never a candidate.
201
+ private evictIdle() {
202
+ if (this.actors.size < this.maxSessions) return;
203
+ for (const [key, a] of this.actors) {
204
+ if (this.actors.size < this.maxSessions) break;
205
+ if (a.session.idle() && a.sink.size === 0) this.actors.delete(key);
138
206
  }
139
- return a;
140
207
  }
141
208
  }
142
209
 
@@ -146,14 +213,16 @@ export class NativeRuntime implements Runtime {
146
213
  export async function createNativeRuntime(
147
214
  agents: Record<string, AgentDef>,
148
215
  path = ":memory:",
216
+ opts: NativeRuntimeOptions = {},
149
217
  ): Promise<NativeRuntime> {
150
- return new NativeRuntime(agents, await openLocalSqliteSync(path));
218
+ return new NativeRuntime(agents, await openLocalSqliteSync(path), opts);
151
219
  }
152
220
 
153
221
  // ── memory backend — in-process, ephemeral (no DB, no disk) ───────────────────
154
222
  // The lightest "no Durable Object, no persistence" option: great for dev, tests,
155
223
  // and stateless previews. Same engine + seams; state is a Map that dies with the
156
- // process. (For durability pick `native` on a long-running host or the Durable
224
+ // process — and is never evicted (the actor IS the state), so it grows with every
225
+ // session: not for a long-running host with unbounded sessions. (For durability pick `native` on a long-running host or the Durable
157
226
  // Object target on the edge.)
158
227
  class MemorySessionStore implements SessionStore {
159
228
  private msgs: Msg[] = [];
@@ -191,6 +260,7 @@ export class MemoryRuntime implements Runtime {
191
260
  assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
192
261
  this.agents = agents;
193
262
  }
263
+ agentDef(name: string): AgentDef | undefined { return this.agents[name]; }
194
264
  session(agent: string, id: string): AgentSession {
195
265
  const key = `${agent}:${id}`;
196
266
  let a = this.actors.get(key);
@@ -199,7 +269,9 @@ export class MemoryRuntime implements Runtime {
199
269
  if (!def) throw new Error(`unknown agent: ${agent}`);
200
270
  const store = new MemorySessionStore();
201
271
  this.stores.set(key, store);
202
- a = new AgentSession(agent, id, store, new InProcEventSink(), def.model, def.tools, this);
272
+ // Same def handling as NativeRuntime: switching backend must not change behavior.
273
+ const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
274
+ a = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
203
275
  this.actors.set(key, a);
204
276
  }
205
277
  return a;
@@ -217,16 +289,39 @@ export type AgentBackend = "native" | "memory" | "durable";
217
289
  // choice is explicit and a mis-selection fails loudly.
218
290
  export async function createAgentRuntime(
219
291
  agents: Record<string, AgentDef>,
220
- opts: { backend?: AgentBackend; path?: string } = {},
292
+ opts: { backend?: AgentBackend; path?: string; maxSessions?: number } = {},
221
293
  ): Promise<Runtime> {
222
294
  const backend = opts.backend ?? "native";
223
295
  if (backend === "memory") return new MemoryRuntime(agents);
224
- if (backend === "native") return createNativeRuntime(agents, opts.path);
296
+ if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions });
225
297
  throw new Error("backend 'durable' is the Cloudflare Durable Object target — construct AgentDurableObject in your worker, not via createAgentRuntime");
226
298
  }
227
299
 
228
- // Mount a discovered agent on a runtime. Builds the ChannelContext whose `run`
229
- // bridges to a durable turn, and exposes:
300
+ // #173: the engine runs the runtime's AgentDef, the channels see the AgentDefinition —
301
+ // declared separately by hand, they drift silently (a tool the model can't call, a
302
+ // channel capability tool the engine never got). Say so once at mount. Only the
303
+ // in-process runtimes expose their def; tools compare by name.
304
+ function warnOnDrift(agent: AgentDefinition, runtime: Runtime) {
305
+ const def = (runtime as { agentDef?: (name: string) => AgentDef | undefined }).agentDef?.(agent.name);
306
+ if (!def) return;
307
+ const names = (tools: Tool[]) => tools.map((t) => t.spec.name).sort();
308
+ const want = names(agent.tools), have = names(def.tools);
309
+ if (want.join("\0") !== have.join("\0")) {
310
+ const missing = want.filter((n) => !have.includes(n)), extra = have.filter((n) => !want.includes(n));
311
+ console.warn(
312
+ `[june] mountAgent("${agent.name}"): the runtime's tools differ from the agent definition's` +
313
+ (missing.length ? ` — missing from the runtime: ${missing.join(", ")}` : "") +
314
+ (extra.length ? ` — only in the runtime: ${extra.join(", ")}` : "") +
315
+ `. Build the runtime entry with toAgentDef(agent, model) so both come from one definition.`,
316
+ );
317
+ }
318
+ }
319
+
320
+ // Mount a discovered agent on a runtime. Builds the ChannelContext the channels drive
321
+ // turns through — run, runDetached, runStream, resumeStream and resetSession, each over
322
+ // the runtime's in-process session. Not provided: runDelivered/resumeDelivered, which
323
+ // exist to escape the edge waitUntil ceiling (a native host has none; channels fall back
324
+ // to runStream/resumeStream without them). Exposes:
230
325
  // • surface(req) — the composable agent surface for June's router: a framework
231
326
  // chat endpoint at `chatPath` (POST {message, session?} → a turn) PLUS the
232
327
  // discovered channels; returns null when the request isn't an agent route.
@@ -244,6 +339,7 @@ export function mountAgent(
244
339
  } {
245
340
  const chatPath = opts.chatPath ?? "/message";
246
341
  const channelsOn = opts.channels ?? true;
342
+ warnOnDrift(agent, runtime);
247
343
  const ctx: ChannelContext = {
248
344
  agent,
249
345
  services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
@@ -254,6 +350,22 @@ export function mountAgent(
254
350
  // written against ctx.runDetached behaves identically on both targets.
255
351
  runDetached: async (message, o) =>
256
352
  runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
353
+ // LIVE (#169): the turn's event stream, so a channel renders as the turn runs —
354
+ // slackChannel({ stream: true }) edits in place here exactly as on the edge, instead of
355
+ // silently degrading to one post at the end. Lazy like the DO's (the turn starts on the
356
+ // first pull); start()/resume() and the subscription run with nothing awaited between,
357
+ // so no event can emit unobserved.
358
+ runStream: async function* (message, o) {
359
+ const session = runtime.session(agent.name, o?.session ?? "default");
360
+ const { turnId } = session.start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace });
361
+ yield* observeTurnEvents(session, turnId);
362
+ },
363
+ // HITL: answer a parked turn and stream its continuation (the approval-button path).
364
+ resumeStream: async function* (o) {
365
+ const session = runtime.session(agent.name, o.session ?? "default");
366
+ const { turnId } = session.resume(o.turnId, o.inputId, o.input, { by: o.by });
367
+ yield* observeTurnEvents(session, turnId);
368
+ },
257
369
  // SESSION RESET (#129): same seam as durableChannelSurface — the in-process runtimes'
258
370
  // stores implement archival, so a channel written against ctx.resetSession behaves
259
371
  // identically on both targets.
package/src/app.ts CHANGED
@@ -28,8 +28,7 @@ import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
28
28
  import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
29
29
  import { createPipeline, type ExtraHandler, type LayoutComponent, type MiddlewareHandler, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
30
30
  import { discoverAgent } from "./agent-discover";
31
- import { createAgentRuntime, mountAgent } from "./agent-native";
32
- import { buildSystemPrompt } from "@junejs/core/agent-config";
31
+ import { createAgentRuntime, mountAgent, toAgentDef } from "./agent-native";
33
32
  import { anthropic } from "@junejs/core/agent-models";
34
33
  import { resolveBoundary } from "./segment";
35
34
  import { memoizeResources } from "./resources";
@@ -237,10 +236,9 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
237
236
  // instructions live on the def (single source); the runtime injects them
238
237
  // into the model per turn via withSystem — no need to bake into anthropic().
239
238
  const model = anthropic({ model: def.model });
240
- const rt = await createAgentRuntime(
241
- { [def.name]: { model, tools: def.tools, instructions: buildSystemPrompt(def) } },
242
- { backend },
243
- );
239
+ // toAgentDef: tools, prompt AND the per-surface policies (#149) from the one
240
+ // definition — channelInstructions was previously dropped on this path (#173).
241
+ const rt = await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, { backend });
244
242
  const mounted = mountAgent(def, rt, { chatPath: agent.runtime.chat.path, channels: agent.runtime.channels });
245
243
  agentSurface = (req) => mounted.surface(req);
246
244
  await mounted.startAll();
@@ -15,7 +15,7 @@ import * as coreRuntime from "@junejs/core/agent-runtime";
15
15
 
16
16
  // The contract number this server tree is built against. Bump in lockstep with
17
17
  // core's RUNTIME_API_VERSION whenever the server↔core runtime contract changes.
18
- const EXPECTED_CORE_RUNTIME_API = 1;
18
+ const EXPECTED_CORE_RUNTIME_API = 2;
19
19
 
20
20
  export function assertCoreRuntimeVersion(surface: string): void {
21
21
  checkCoreRuntimeVersion(surface, (coreRuntime as { RUNTIME_API_VERSION?: number }).RUNTIME_API_VERSION);
@@ -0,0 +1,43 @@
1
+ // turn-events.ts — one turn's live TurnEvents as an AsyncIterable, for a consumer in the
2
+ // SAME process as the session: the Durable Object's delivered renders (agent-durable.ts)
3
+ // and the native host's ChannelContext.runStream/resumeStream (agent-native.ts).
4
+
5
+ import type { AgentSession, TurnEvent } from "@junejs/core/agent-runtime";
6
+
7
+ // Subscribes eagerly at call time — not at first iteration — so the sseTurnStream timing
8
+ // guarantee carries over (call this synchronously after start()/resume(), before any event
9
+ // can emit); events landing before the consumer catches up are buffered. Ends after the
10
+ // turn's terminal event (completed, failed, cancelled, or input.requested — a park ends
11
+ // this stream; a later resume is a new one), and an early consumer exit (for-await
12
+ // break/return) unsubscribes rather than buffering forever.
13
+ export function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable<TurnEvent> {
14
+ const queue: TurnEvent[] = [];
15
+ let terminal = false;
16
+ let notify: (() => void) | undefined;
17
+ const unsub = session.observe((e) => {
18
+ queue.push(e);
19
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
20
+ notify?.();
21
+ }, { turnId });
22
+ return {
23
+ [Symbol.asyncIterator]() {
24
+ return {
25
+ async next(): Promise<IteratorResult<TurnEvent>> {
26
+ for (;;) {
27
+ const e = queue.shift();
28
+ if (e) return { value: e, done: false };
29
+ if (terminal) { unsub(); return { value: undefined, done: true }; }
30
+ await new Promise<void>((resolve) => { notify = resolve; });
31
+ notify = undefined;
32
+ }
33
+ },
34
+ async return(): Promise<IteratorResult<TurnEvent>> {
35
+ unsub();
36
+ terminal = true;
37
+ queue.length = 0;
38
+ return { value: undefined, done: true };
39
+ },
40
+ };
41
+ },
42
+ };
43
+ }
package/src/worker.ts CHANGED
@@ -322,8 +322,11 @@ export function withAssets(
322
322
  }
323
323
 
324
324
  // 2. Static assets (prerendered HTML/.md/.json, /client.js, hashed CSS)
325
- // served direct.
326
- if (assets) {
325
+ // served direct — for reads only. Assets never answer a write, and on
326
+ // workerd handing the incoming request to ASSETS.fetch() consumes its
327
+ // body: a POST that 404s here would reach the pipeline with a used stream
328
+ // (POST /mcp → -32700 Parse error, an action's input gone).
329
+ if (assets && (request.method === "GET" || request.method === "HEAD")) {
327
330
  const a = await assets.fetch(request);
328
331
  if (a.status !== 404) {
329
332
  const ct = a.headers.get("content-type") ?? "";