@junejs/server 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/icon-latin.LICENSE.txt +92 -0
- package/assets/icon-latin.ttf +0 -0
- package/dist/adapter-D0UnYBuN.d.ts +64 -0
- package/dist/adapter.d.ts +2 -56
- package/dist/adapter.js +107 -6
- package/dist/agent-compile.d.ts +38 -0
- package/dist/agent-compile.js +145 -0
- package/dist/agent-discover.d.ts +4 -4
- package/dist/agent-discover.js +30 -44
- package/dist/agent-durable.d.ts +94 -19
- package/dist/agent-durable.js +647 -32
- package/dist/agent-native.d.ts +44 -13
- package/dist/agent-native.js +264 -27
- package/dist/app.d.ts +5 -5
- package/dist/app.js +42 -10
- package/dist/assets.d.ts +23 -0
- package/dist/assets.js +123 -0
- package/dist/blob.d.ts +4 -5
- package/dist/build.d.ts +8 -30
- package/dist/build.js +143 -298
- package/dist/client-bundle.d.ts +8 -9
- package/dist/client-bundle.js +1 -1
- package/dist/config-freeze-r5p-vTWv.d.ts +17 -0
- package/dist/config-freeze.d.ts +2 -0
- package/dist/config-freeze.js +54 -0
- package/dist/config-loader.d.ts +3 -4
- package/dist/connection-auth.d.ts +38 -0
- package/dist/connection-auth.js +31 -0
- package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
- package/dist/content-freeze.d.ts +2 -0
- package/dist/content-freeze.js +83 -0
- package/dist/content.d.ts +22 -9
- package/dist/content.js +43 -3
- package/dist/core-version.d.ts +4 -0
- package/dist/core-version.js +12 -0
- package/dist/css-modules-ambient.d.ts +2 -1
- package/dist/css-modules.d.ts +10 -11
- package/dist/css.d.ts +9 -10
- package/dist/d1-migrate.d.ts +6 -7
- package/dist/d1-migrate.js +3 -2
- package/dist/db.d.ts +2 -2
- package/dist/deploy.d.ts +3 -4
- package/dist/deploy.js +3 -2
- package/dist/dev-reload.d.ts +3 -4
- package/dist/dev.d.ts +8 -5
- package/dist/dev.js +40 -9
- package/dist/favicon.d.ts +37 -0
- package/dist/favicon.js +248 -0
- package/dist/host.d.ts +6 -5
- package/dist/host.js +2 -1
- package/dist/icon-letter.d.ts +3 -0
- package/dist/icon-letter.js +8 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +9 -3
- package/dist/instrumentation.d.ts +2 -3
- package/dist/instrumentation.js +2 -1
- package/dist/island-registry.d.ts +6 -7
- package/dist/isolate-local.d.ts +5 -0
- package/dist/isolate-local.js +12 -0
- package/dist/kv.d.ts +3 -4
- package/dist/llms-links.d.ts +9 -0
- package/dist/llms-links.js +65 -0
- package/dist/manifest-CwTT1zVs.d.ts +11 -0
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.js +113 -0
- package/dist/negotiate.d.ts +4 -3
- package/dist/negotiate.js +24 -14
- package/dist/{pipeline-CCp9idQ4.d.ts → pipeline-BHUjBO0G.d.ts} +13 -1
- package/dist/pipeline.d.ts +2 -2
- package/dist/pipeline.js +286 -35
- package/dist/resources.d.ts +5 -6
- package/dist/route-rank.d.ts +6 -7
- package/dist/route-scan-BMbBzPIB.d.ts +20 -0
- package/dist/route-scan.d.ts +2 -0
- package/dist/route-scan.js +100 -0
- package/dist/router.d.ts +12 -13
- package/dist/router.js +51 -38
- package/dist/rsc-build.d.ts +6 -7
- package/dist/rsc-bundle.d.ts +6 -7
- package/dist/rsc-manifest.d.ts +5 -6
- package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
- package/dist/rsc-runtime/flight-render.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.js +3 -2
- package/dist/rsc-runtime/rsc-worker.js +11 -8
- package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
- package/dist/segment.d.ts +4 -5
- package/dist/sqlite-driver.d.ts +11 -11
- package/dist/sqlite-driver.js +17 -4
- package/dist/static-files.d.ts +4 -5
- package/dist/tsconfig-jsx.d.ts +4 -4
- package/dist/tsconfig-jsx.js +1 -1
- package/dist/turn-events.d.ts +4 -0
- package/dist/turn-events.js +46 -0
- package/dist/web-manifest.d.ts +5 -0
- package/dist/web-manifest.js +42 -0
- package/dist/worker-2qbQjj2c.d.ts +64 -0
- package/dist/worker.d.ts +2 -57
- package/dist/worker.js +69 -27
- package/package.json +28 -9
- package/src/adapter.ts +149 -4
- package/src/agent-compile.ts +271 -0
- package/src/agent-discover.ts +77 -63
- package/src/agent-durable.ts +998 -30
- package/src/agent-native.ts +370 -35
- package/src/app.ts +75 -6
- package/src/assets.ts +200 -0
- package/src/build.ts +241 -449
- package/src/config-freeze.ts +109 -0
- package/src/connection-auth.ts +101 -0
- package/src/content-freeze.ts +151 -0
- package/src/content.ts +55 -4
- package/src/core-version.ts +34 -0
- package/src/dev.ts +41 -8
- package/src/favicon.ts +325 -0
- package/src/host.ts +4 -3
- package/src/icon-letter.ts +14 -0
- package/src/index.ts +22 -0
- package/src/isolate-local.ts +33 -0
- package/src/llms-links.ts +96 -0
- package/src/manifest.ts +130 -0
- package/src/negotiate.ts +39 -15
- package/src/pipeline.ts +494 -44
- package/src/route-scan.ts +161 -0
- package/src/router.ts +66 -54
- package/src/rsc-runtime/rsc-worker.tsx +5 -4
- package/src/sqlite-driver.ts +36 -6
- package/src/tsconfig-jsx.ts +2 -2
- package/src/turn-events.ts +43 -0
- package/src/web-manifest.ts +38 -0
- package/src/worker.ts +131 -35
package/src/agent-native.ts
CHANGED
|
@@ -3,26 +3,36 @@
|
|
|
3
3
|
// SessionStore = a session-scoped view over one shared synchronous SQLite handle
|
|
4
4
|
// (bun:sqlite under Bun, node:sqlite under Node — the same handle openLocalSqlite
|
|
5
5
|
// wraps as the async JuneDb, opened here directly because the durability tx must
|
|
6
|
-
// be synchronous).
|
|
6
|
+
// be synchronous). EventSink = an in-process subscriber set. Turn serialization
|
|
7
7
|
// comes from the AgentSession actor in core. On the edge target this same shape
|
|
8
|
-
// is reimplemented over a Durable Object's ctx.storage.sql (
|
|
8
|
+
// is reimplemented over a Durable Object's ctx.storage.sql (agent-durable.ts).
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AgentSession,
|
|
12
|
+
grantAnswer,
|
|
12
13
|
withSystem,
|
|
13
|
-
type
|
|
14
|
+
type ChannelPolicy,
|
|
15
|
+
type EventSink,
|
|
16
|
+
type InputAnnouncement,
|
|
17
|
+
type TurnEvent,
|
|
14
18
|
type Model,
|
|
15
19
|
type Msg,
|
|
16
20
|
type Runtime,
|
|
17
21
|
type SessionStore,
|
|
18
22
|
type Tool,
|
|
19
23
|
} from "@junejs/core/agent-runtime";
|
|
20
|
-
import { channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
|
|
24
|
+
import { buildSystemPrompt, channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
|
|
25
|
+
import type { FileStore } from "@junejs/core/agent-files";
|
|
21
26
|
import { openLocalSqliteSync, type SyncSqlite } from "./sqlite-driver";
|
|
27
|
+
import { assertCoreRuntimeVersion } from "./core-version";
|
|
28
|
+
import { observeTurnEvents } from "./turn-events";
|
|
22
29
|
|
|
23
30
|
function initSchema(db: SyncSqlite) {
|
|
24
31
|
db.exec(`CREATE TABLE IF NOT EXISTS agent_sessions (session_id TEXT PRIMARY KEY, status TEXT)`);
|
|
25
32
|
db.exec(`CREATE TABLE IF NOT EXISTS agent_messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, body TEXT)`);
|
|
33
|
+
// Every store read is scoped to one session; without this index each one scanned the
|
|
34
|
+
// messages of ALL sessions in the file (#168). IF NOT EXISTS: existing DBs gain it.
|
|
35
|
+
db.exec(`CREATE INDEX IF NOT EXISTS agent_messages_session ON agent_messages (session_id, seq)`);
|
|
26
36
|
// NOTE: PRIMARY KEY (session_id, id) — never id alone. The store view scopes
|
|
27
37
|
// every query by session, so a step id can't leak across sessions.
|
|
28
38
|
db.exec(`CREATE TABLE IF NOT EXISTS agent_steps (session_id TEXT, id TEXT, output TEXT, PRIMARY KEY (session_id, id))`);
|
|
@@ -45,8 +55,11 @@ class SqliteSessionStore implements SessionStore {
|
|
|
45
55
|
return (this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid) as { body: string }[])
|
|
46
56
|
.map((r) => JSON.parse(r.body));
|
|
47
57
|
}
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
// Asked once per turn start: probe for the one row instead of parsing the transcript.
|
|
59
|
+
hasOpeningMessage(turnId: string): boolean {
|
|
60
|
+
return this.db
|
|
61
|
+
.query("SELECT 1 FROM agent_messages WHERE session_id = ? AND json_extract(body, '$.turnId') = ? AND json_extract(body, '$.role') IN ('user', 'trigger') LIMIT 1")
|
|
62
|
+
.get(this.sid, turnId) != null; // bun:sqlite misses with null, node:sqlite with undefined
|
|
50
63
|
}
|
|
51
64
|
getStep(id: string): unknown | undefined {
|
|
52
65
|
const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id) as { output: string } | undefined;
|
|
@@ -55,6 +68,9 @@ class SqliteSessionStore implements SessionStore {
|
|
|
55
68
|
putStep(id: string, output: unknown) {
|
|
56
69
|
this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
|
|
57
70
|
}
|
|
71
|
+
delStep(id: string) {
|
|
72
|
+
this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
|
|
73
|
+
}
|
|
58
74
|
getStatus(): string {
|
|
59
75
|
return (this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid) as { status: string } | undefined)?.status ?? "new";
|
|
60
76
|
}
|
|
@@ -74,43 +90,250 @@ class SqliteSessionStore implements SessionStore {
|
|
|
74
90
|
throw e;
|
|
75
91
|
}
|
|
76
92
|
}
|
|
93
|
+
// Session reset (#129): ARCHIVE this session's messages/steps under the current
|
|
94
|
+
// generation (audit trail — never deleted), clear the live rows, status → "new".
|
|
95
|
+
// Archive tables + the generation counter are created lazily here, so existing
|
|
96
|
+
// databases stay untouched until the first reset.
|
|
97
|
+
reset(inTx?: () => void): number {
|
|
98
|
+
return this.tx(() => {
|
|
99
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
|
|
100
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
|
|
101
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_session_generations (session_id TEXT PRIMARY KEY, generation INTEGER)`);
|
|
102
|
+
const r = this.db.query("SELECT generation FROM agent_session_generations WHERE session_id = ?").get(this.sid) as { generation: number } | undefined;
|
|
103
|
+
const generation = r?.generation ?? 0;
|
|
104
|
+
this.db.query("INSERT INTO agent_messages_archive (session_id, generation, seq, body) SELECT session_id, ?, seq, body FROM agent_messages WHERE session_id = ?").run(generation, this.sid);
|
|
105
|
+
this.db.query("DELETE FROM agent_messages WHERE session_id = ?").run(this.sid);
|
|
106
|
+
this.db.query("INSERT INTO agent_steps_archive (session_id, generation, id, output) SELECT session_id, ?, id, output FROM agent_steps WHERE session_id = ?").run(generation, this.sid);
|
|
107
|
+
this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
|
|
108
|
+
this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
|
|
109
|
+
this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
|
|
110
|
+
inTx?.(); // writes into the fresh generation, committed with the archive (#260)
|
|
111
|
+
return generation;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
77
114
|
unwrap<H = unknown>(): H { return this.db as unknown as H; }
|
|
78
115
|
}
|
|
79
116
|
|
|
80
|
-
class
|
|
81
|
-
private subs = new Set<(
|
|
82
|
-
|
|
83
|
-
subscribe(cb: (
|
|
117
|
+
class InProcEventSink implements EventSink {
|
|
118
|
+
private subs = new Set<(e: TurnEvent) => void>();
|
|
119
|
+
emit(e: TurnEvent) { this.subs.forEach((cb) => { try { cb(e); } catch { /* a bad subscriber must not break emit */ } }); }
|
|
120
|
+
subscribe(cb: (e: TurnEvent) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
|
|
121
|
+
get size(): number { return this.subs.size; }
|
|
84
122
|
}
|
|
85
123
|
|
|
86
124
|
// `instructions` (the agent's system prompt) is injected into the model per turn
|
|
87
125
|
// by the runtime (withSystem) — single-sourced on the def, not baked into `model`.
|
|
88
|
-
export type AgentDef = {
|
|
126
|
+
export type AgentDef = {
|
|
127
|
+
model: Model;
|
|
128
|
+
tools: Tool[];
|
|
129
|
+
instructions?: string;
|
|
130
|
+
channelInstructions?: Record<string, string | ChannelPolicy>;
|
|
131
|
+
// The app's receiver for input announcements (#260), installed on every session this
|
|
132
|
+
// runtime builds; undelivered announcements from an earlier process are flushed then.
|
|
133
|
+
onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Retries a failed announcement delivery on a timer (#260): the engine keeps an undelivered
|
|
137
|
+
// announcement and flushes again on the next announcement, settled turn or rebuild — but a
|
|
138
|
+
// session that goes quiet would hold it until then. 5 s doubling to 5 min, per session;
|
|
139
|
+
// unref'd, so a pending retry never holds the process open. Owned by the runtime, so the
|
|
140
|
+
// backoff survives an evicted-and-rebuilt actor.
|
|
141
|
+
class AnnouncementRetry {
|
|
142
|
+
// Explicit fields (not parameter properties) — keep the shipped source erasable.
|
|
143
|
+
private readonly flush: (agent: string, id: string) => void;
|
|
144
|
+
private readonly state = new Map<string, { failures: number; timer?: ReturnType<typeof setTimeout> }>();
|
|
145
|
+
private stopped = false;
|
|
146
|
+
constructor(flush: (agent: string, id: string) => void) {
|
|
147
|
+
this.flush = flush;
|
|
148
|
+
}
|
|
149
|
+
delivered(key: string) {
|
|
150
|
+
const s = this.state.get(key);
|
|
151
|
+
if (s?.timer) clearTimeout(s.timer);
|
|
152
|
+
this.state.delete(key);
|
|
153
|
+
}
|
|
154
|
+
failed(key: string, agent: string, id: string) {
|
|
155
|
+
if (this.stopped) return; // the runtime is closed: nothing retries into it
|
|
156
|
+
const s = this.state.get(key) ?? { failures: 0 };
|
|
157
|
+
this.state.set(key, s);
|
|
158
|
+
if (s.timer) return; // one retry pending at a time
|
|
159
|
+
const delay = Math.min(5_000 * 2 ** s.failures, 300_000);
|
|
160
|
+
s.failures++;
|
|
161
|
+
s.timer = setTimeout(() => { s.timer = undefined; this.flush(agent, id); }, delay);
|
|
162
|
+
(s.timer as { unref?: () => void }).unref?.();
|
|
163
|
+
}
|
|
164
|
+
// Cancel every pending retry, for good (#317): unref only keeps a timer from holding the
|
|
165
|
+
// process open — it still fires while the process lives, into a runtime (and a db) that
|
|
166
|
+
// is gone.
|
|
167
|
+
stop() {
|
|
168
|
+
this.stopped = true;
|
|
169
|
+
for (const s of this.state.values()) if (s.timer) clearTimeout(s.timer);
|
|
170
|
+
this.state.clear();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// SQLite handles a runtime opened itself (createNativeRuntime), so close() closes those and
|
|
175
|
+
// never one a caller passed in.
|
|
176
|
+
const ownedDbs = new WeakSet<SyncSqlite>();
|
|
177
|
+
|
|
178
|
+
// Install the announcement hook on a freshly built session and deliver what an earlier life
|
|
179
|
+
// of it recorded but never delivered (#260). The flush never rejects; it logs — and a
|
|
180
|
+
// failure schedules a retry.
|
|
181
|
+
function wireAnnouncements(session: AgentSession, def: AgentDef, retry: AnnouncementRetry): AgentSession {
|
|
182
|
+
const hook = def.onInputAnnouncement;
|
|
183
|
+
if (hook) {
|
|
184
|
+
const key = `${session.agent}:${session.id}`;
|
|
185
|
+
session.onAnnounce = async (a) => {
|
|
186
|
+
try {
|
|
187
|
+
await hook(a);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
retry.failed(key, session.agent, session.id);
|
|
190
|
+
throw err; // the engine keeps the announcement and logs
|
|
191
|
+
}
|
|
192
|
+
retry.delivered(key);
|
|
193
|
+
};
|
|
194
|
+
void session.flushAnnouncements();
|
|
195
|
+
}
|
|
196
|
+
return session;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The step the engine keeps its announcement outbox under (#260) — read by the native
|
|
200
|
+
// runtime's startup scan. Mirrors ANNOUNCE_OUTBOX in @junejs/core/agent-runtime.
|
|
201
|
+
const ANNOUNCE_OUTBOX_STEP = "announce-outbox";
|
|
202
|
+
|
|
203
|
+
// The runtime-side def for an assembled AgentDefinition (#173): the tools (channel
|
|
204
|
+
// capability tools and read_skill included), the system prompt (instructions + the
|
|
205
|
+
// skill index) and the per-surface policies all come from the ONE definition that
|
|
206
|
+
// mountAgent mounts, so the engine and the channels can't drift apart. Only the model
|
|
207
|
+
// is the host's choice.
|
|
208
|
+
// createNativeRuntime({ [agent.name]: toAgentDef(agent, anthropic({ … })) })
|
|
209
|
+
export function toAgentDef(agent: AgentDefinition, model: Model): AgentDef {
|
|
210
|
+
return {
|
|
211
|
+
model,
|
|
212
|
+
tools: agent.tools,
|
|
213
|
+
instructions: buildSystemPrompt(agent),
|
|
214
|
+
...(agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {}),
|
|
215
|
+
...(agent.onInputAnnouncement ? { onInputAnnouncement: agent.onInputAnnouncement } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type NativeRuntimeOptions = {
|
|
220
|
+
// Soft cap on memoized session actors (#174), default 1000. Past it, the least recently
|
|
221
|
+
// used IDLE actors (no turn running or queued, no reset pending, no live subscriber) are
|
|
222
|
+
// dropped; their state is in SQLite, so the next session() rebuilds them transparently.
|
|
223
|
+
// Busy actors are never dropped, so the count can exceed the cap while they run.
|
|
224
|
+
maxSessions?: number;
|
|
225
|
+
// Where the sessions' tools store the files they produce (docs/rfc-agent-files.md), and
|
|
226
|
+
// where mountAgent's channels read them back to deliver. Absent: ctx.files.put fails the
|
|
227
|
+
// turn naming the missing configuration.
|
|
228
|
+
files?: FileStore;
|
|
229
|
+
};
|
|
89
230
|
|
|
90
231
|
// The native Runtime: a registry of agent definitions over one SQLite handle,
|
|
91
|
-
// handing out (and memoizing) an AgentSession actor per (agent, id).
|
|
232
|
+
// handing out (and memoizing) an AgentSession actor per (agent, id). The memo is an
|
|
233
|
+
// LRU bounded by maxSessions — with slackChannel every thread is a session, so an
|
|
234
|
+
// unbounded memo grew by one actor per thread for the life of the process.
|
|
235
|
+
// Callers must not hold an AgentSession across an await and start turns on it later:
|
|
236
|
+
// if it was evicted meanwhile, a fresh actor for the same session would run turns
|
|
237
|
+
// unserialized with it. Call session() at the point of use (mountAgent does).
|
|
92
238
|
export class NativeRuntime implements Runtime {
|
|
93
|
-
private actors = new Map<string, AgentSession>();
|
|
239
|
+
private actors = new Map<string, { session: AgentSession; sink: InProcEventSink }>();
|
|
94
240
|
private readonly agents: Record<string, AgentDef>;
|
|
95
241
|
private readonly db: SyncSqlite;
|
|
242
|
+
// Retries failed announcement deliveries (#260), across actor rebuilds.
|
|
243
|
+
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); });
|
|
244
|
+
private readonly maxSessions: number;
|
|
245
|
+
private closing: Promise<void> | undefined; // set by close() (#317)
|
|
246
|
+
readonly files: FileStore | undefined;
|
|
96
247
|
|
|
97
|
-
constructor(agents: Record<string, AgentDef>, db: SyncSqlite) {
|
|
248
|
+
constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
|
|
249
|
+
assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
|
|
98
250
|
this.agents = agents;
|
|
99
251
|
this.db = db;
|
|
252
|
+
this.files = opts.files;
|
|
253
|
+
const max = opts.maxSessions ?? 1000;
|
|
254
|
+
// At least one actor is always retained (the one being handed out), so a cap below 1
|
|
255
|
+
// means nothing, and NaN would compare false and evict every idle actor on each miss.
|
|
256
|
+
// Infinity is allowed: an explicit "no cap".
|
|
257
|
+
if (!(Number.isInteger(max) && max >= 1) && max !== Infinity) {
|
|
258
|
+
throw new RangeError(`NativeRuntime: maxSessions must be an integer >= 1 (or Infinity for no cap), got ${max}`);
|
|
259
|
+
}
|
|
260
|
+
this.maxSessions = max;
|
|
100
261
|
initSchema(db);
|
|
101
262
|
}
|
|
102
263
|
|
|
264
|
+
agentDef(name: string): AgentDef | undefined { return this.agents[name]; }
|
|
265
|
+
|
|
103
266
|
session(agent: string, id: string): AgentSession {
|
|
104
267
|
const key = `${agent}:${id}`;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
this.actors.
|
|
268
|
+
const hit = this.actors.get(key);
|
|
269
|
+
// A closing runtime still serves the actors it has (their turns are finishing), but
|
|
270
|
+
// builds no new one.
|
|
271
|
+
if (!hit && this.closing) throw new Error(`NativeRuntime is closed: cannot open session "${key}"`);
|
|
272
|
+
if (hit) {
|
|
273
|
+
// most recently used → the tail (Map iteration order is insertion order)
|
|
274
|
+
this.actors.delete(key);
|
|
275
|
+
this.actors.set(key, hit);
|
|
276
|
+
return hit.session;
|
|
277
|
+
}
|
|
278
|
+
const def = this.agents[agent];
|
|
279
|
+
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
280
|
+
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
281
|
+
const sink = new InProcEventSink();
|
|
282
|
+
const built = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
|
|
283
|
+
built.files = this.files;
|
|
284
|
+
const session = wireAnnouncements(built, def, this.announceRetry);
|
|
285
|
+
this.evictIdle();
|
|
286
|
+
this.actors.set(key, { session, sink });
|
|
287
|
+
return session;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Number of memoized actors (observability / tests).
|
|
291
|
+
get sessionCount(): number { return this.actors.size; }
|
|
292
|
+
|
|
293
|
+
// Deliver announcements an earlier process recorded but never delivered (#260), without
|
|
294
|
+
// waiting for their sessions to be used again: build every session whose outbox is not
|
|
295
|
+
// empty — building one flushes it. The agent and session come from the announcement
|
|
296
|
+
// itself, not from splitting the store key (an agent name may contain ":"). Called once
|
|
297
|
+
// by createNativeRuntime; safe to call again.
|
|
298
|
+
recoverAnnouncements(): void {
|
|
299
|
+
const rows = this.db.query("SELECT output FROM agent_steps WHERE id = ?").all(ANNOUNCE_OUTBOX_STEP) as { output: string }[];
|
|
300
|
+
for (const { output } of rows) {
|
|
301
|
+
const [first] = JSON.parse(output) as InputAnnouncement[];
|
|
302
|
+
if (first && this.agents[first.agent]?.onInputAnnouncement) this.session(first.agent, first.session);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Shut the runtime down (#317). Pending announcement retries are cancelled and no new
|
|
307
|
+
// session opens at once; then it waits until every actor is idle — a running turn or an
|
|
308
|
+
// announcement delivery in flight still writes the store after its hook or model call
|
|
309
|
+
// returns — before dropping the actors and closing the SQLite handle createNativeRuntime
|
|
310
|
+
// opened (a db passed to the constructor stays the caller's). So it resolves only after
|
|
311
|
+
// in-flight work finishes. Call it before discarding a runtime in a process that keeps
|
|
312
|
+
// running (a test suite, a host that swaps runtimes), or a retry fires later against a
|
|
313
|
+
// closed or deleted database. Safe to call twice: both calls resolve together.
|
|
314
|
+
close(): Promise<void> {
|
|
315
|
+
this.closing ??= (async () => {
|
|
316
|
+
this.announceRetry.stop();
|
|
317
|
+
while ([...this.actors.values()].some((a) => !a.session.idle())) {
|
|
318
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
319
|
+
}
|
|
320
|
+
this.actors.clear();
|
|
321
|
+
if (ownedDbs.has(this.db)) {
|
|
322
|
+
ownedDbs.delete(this.db);
|
|
323
|
+
this.db.close();
|
|
324
|
+
}
|
|
325
|
+
})();
|
|
326
|
+
return this.closing;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Drop least recently used idle actors until one more fits under the cap. Runs before
|
|
330
|
+
// the new actor is inserted, so the actor being handed out is never a candidate.
|
|
331
|
+
private evictIdle() {
|
|
332
|
+
if (this.actors.size < this.maxSessions) return;
|
|
333
|
+
for (const [key, a] of this.actors) {
|
|
334
|
+
if (this.actors.size < this.maxSessions) break;
|
|
335
|
+
if (a.session.idle() && a.sink.size === 0) this.actors.delete(key);
|
|
112
336
|
}
|
|
113
|
-
return a;
|
|
114
337
|
}
|
|
115
338
|
}
|
|
116
339
|
|
|
@@ -120,27 +343,60 @@ export class NativeRuntime implements Runtime {
|
|
|
120
343
|
export async function createNativeRuntime(
|
|
121
344
|
agents: Record<string, AgentDef>,
|
|
122
345
|
path = ":memory:",
|
|
346
|
+
opts: NativeRuntimeOptions = {},
|
|
123
347
|
): Promise<NativeRuntime> {
|
|
124
|
-
|
|
348
|
+
const db = await openLocalSqliteSync(path);
|
|
349
|
+
ownedDbs.add(db);
|
|
350
|
+
let runtime: NativeRuntime | undefined;
|
|
351
|
+
try {
|
|
352
|
+
runtime = new NativeRuntime(agents, db, opts);
|
|
353
|
+
runtime.recoverAnnouncements(); // #260
|
|
354
|
+
return runtime;
|
|
355
|
+
} catch (err) {
|
|
356
|
+
// Nobody else holds this handle (a bad maxSessions, unreadable leftovers). A recovery
|
|
357
|
+
// that failed part-way may already have deliveries in flight, so go through close(),
|
|
358
|
+
// which waits for them before closing the db.
|
|
359
|
+
if (runtime) await runtime.close();
|
|
360
|
+
else {
|
|
361
|
+
ownedDbs.delete(db);
|
|
362
|
+
db.close();
|
|
363
|
+
}
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
125
366
|
}
|
|
126
367
|
|
|
127
368
|
// ── memory backend — in-process, ephemeral (no DB, no disk) ───────────────────
|
|
128
369
|
// The lightest "no Durable Object, no persistence" option: great for dev, tests,
|
|
129
370
|
// and stateless previews. Same engine + seams; state is a Map that dies with the
|
|
130
|
-
// process
|
|
371
|
+
// process — and is never evicted (the actor IS the state), so it grows with every
|
|
372
|
+
// session: not for a long-running host with unbounded sessions. (For durability pick `native` on a long-running host or the Durable
|
|
131
373
|
// Object target on the edge.)
|
|
132
374
|
class MemorySessionStore implements SessionStore {
|
|
133
375
|
private msgs: Msg[] = [];
|
|
134
376
|
private steps = new Map<string, unknown>();
|
|
135
377
|
private status = "new";
|
|
378
|
+
private generation = 0;
|
|
379
|
+
// Retired generations (#129) — kept for the audit contract even in memory, so the
|
|
380
|
+
// backend behaves like the durable tiers within one process lifetime.
|
|
381
|
+
private readonly archives: { generation: number; msgs: Msg[]; steps: Map<string, unknown> }[] = [];
|
|
136
382
|
appendMessage(m: Msg) { this.msgs.push(m); }
|
|
137
383
|
messages(): Msg[] { return this.msgs.slice(); }
|
|
138
|
-
|
|
384
|
+
hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
|
|
139
385
|
getStep(id: string): unknown | undefined { return this.steps.has(id) ? this.steps.get(id) : undefined; }
|
|
140
386
|
putStep(id: string, output: unknown) { this.steps.set(id, output); }
|
|
387
|
+
delStep(id: string) { this.steps.delete(id); }
|
|
141
388
|
getStatus(): string { return this.status; }
|
|
142
389
|
setStatus(s: string) { this.status = s; }
|
|
143
390
|
tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
|
|
391
|
+
reset(inTx?: () => void): number {
|
|
392
|
+
const generation = this.generation++;
|
|
393
|
+
this.archives.push({ generation, msgs: this.msgs, steps: this.steps });
|
|
394
|
+
this.msgs = [];
|
|
395
|
+
this.steps = new Map();
|
|
396
|
+
this.status = "new";
|
|
397
|
+
inTx?.(); // writes into the fresh generation (#260)
|
|
398
|
+
return generation;
|
|
399
|
+
}
|
|
144
400
|
unwrap<H = unknown>(): H { return undefined as unknown as H; }
|
|
145
401
|
}
|
|
146
402
|
|
|
@@ -148,22 +404,43 @@ export class MemoryRuntime implements Runtime {
|
|
|
148
404
|
private actors = new Map<string, AgentSession>();
|
|
149
405
|
private stores = new Map<string, MemorySessionStore>();
|
|
150
406
|
private readonly agents: Record<string, AgentDef>;
|
|
151
|
-
|
|
407
|
+
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); }); // #260
|
|
408
|
+
private closed = false; // set by close() (#317)
|
|
409
|
+
readonly files: FileStore | undefined;
|
|
410
|
+
constructor(agents: Record<string, AgentDef>, opts: { files?: FileStore } = {}) {
|
|
411
|
+
assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
|
|
152
412
|
this.agents = agents;
|
|
413
|
+
this.files = opts.files;
|
|
153
414
|
}
|
|
415
|
+
agentDef(name: string): AgentDef | undefined { return this.agents[name]; }
|
|
154
416
|
session(agent: string, id: string): AgentSession {
|
|
155
417
|
const key = `${agent}:${id}`;
|
|
156
418
|
let a = this.actors.get(key);
|
|
157
419
|
if (!a) {
|
|
420
|
+
if (this.closed) throw new Error(`MemoryRuntime is closed: cannot open session "${key}"`);
|
|
158
421
|
const def = this.agents[agent];
|
|
159
422
|
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
160
423
|
const store = new MemorySessionStore();
|
|
161
424
|
this.stores.set(key, store);
|
|
162
|
-
|
|
425
|
+
// Same def handling as NativeRuntime: switching backend must not change behavior.
|
|
426
|
+
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
427
|
+
const built = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
|
|
428
|
+
built.files = this.files;
|
|
429
|
+
a = wireAnnouncements(built, def, this.announceRetry);
|
|
163
430
|
this.actors.set(key, a);
|
|
164
431
|
}
|
|
165
432
|
return a;
|
|
166
433
|
}
|
|
434
|
+
// Shut the runtime down (#317): cancel pending announcement retries and open no new
|
|
435
|
+
// session. The state is the actors themselves, so they are dropped too; with no store
|
|
436
|
+
// to close there is nothing to wait for — work still in flight finishes against its own
|
|
437
|
+
// in-memory store. Safe to call twice.
|
|
438
|
+
async close(): Promise<void> {
|
|
439
|
+
this.closed = true;
|
|
440
|
+
this.announceRetry.stop();
|
|
441
|
+
this.actors.clear();
|
|
442
|
+
this.stores.clear();
|
|
443
|
+
}
|
|
167
444
|
}
|
|
168
445
|
|
|
169
446
|
// The selectable agent-runtime backends. `native` (SQLite via june/host, durable
|
|
@@ -175,18 +452,44 @@ export type AgentBackend = "native" | "memory" | "durable";
|
|
|
175
452
|
// Build an in-process runtime for the chosen backend. Throws for `durable` (that
|
|
176
453
|
// target is the DO the worker constructs, not an in-process object) — so the
|
|
177
454
|
// choice is explicit and a mis-selection fails loudly.
|
|
455
|
+
// An in-process runtime: a Runtime the host owns, so it can shut it down (#317).
|
|
456
|
+
export type InProcessRuntime = Runtime & { close(): Promise<void>; readonly files?: FileStore };
|
|
457
|
+
|
|
178
458
|
export async function createAgentRuntime(
|
|
179
459
|
agents: Record<string, AgentDef>,
|
|
180
|
-
opts: { backend?: AgentBackend; path?: string } = {},
|
|
181
|
-
): Promise<
|
|
460
|
+
opts: { backend?: AgentBackend; path?: string; maxSessions?: number; files?: FileStore } = {},
|
|
461
|
+
): Promise<InProcessRuntime> {
|
|
182
462
|
const backend = opts.backend ?? "native";
|
|
183
|
-
if (backend === "memory") return new MemoryRuntime(agents);
|
|
184
|
-
if (backend === "native") return createNativeRuntime(agents, opts.path);
|
|
463
|
+
if (backend === "memory") return new MemoryRuntime(agents, { files: opts.files });
|
|
464
|
+
if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions, files: opts.files });
|
|
185
465
|
throw new Error("backend 'durable' is the Cloudflare Durable Object target — construct AgentDurableObject in your worker, not via createAgentRuntime");
|
|
186
466
|
}
|
|
187
467
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
468
|
+
// #173: the engine runs the runtime's AgentDef, the channels see the AgentDefinition —
|
|
469
|
+
// declared separately by hand, they drift silently (a tool the model can't call, a
|
|
470
|
+
// channel capability tool the engine never got). Say so once at mount. Only the
|
|
471
|
+
// in-process runtimes expose their def; tools compare by name.
|
|
472
|
+
function warnOnDrift(agent: AgentDefinition, runtime: Runtime) {
|
|
473
|
+
const def = (runtime as { agentDef?: (name: string) => AgentDef | undefined }).agentDef?.(agent.name);
|
|
474
|
+
if (!def) return;
|
|
475
|
+
const names = (tools: Tool[]) => tools.map((t) => t.spec.name).sort();
|
|
476
|
+
const want = names(agent.tools), have = names(def.tools);
|
|
477
|
+
if (want.join("\0") !== have.join("\0")) {
|
|
478
|
+
const missing = want.filter((n) => !have.includes(n)), extra = have.filter((n) => !want.includes(n));
|
|
479
|
+
console.warn(
|
|
480
|
+
`[june] mountAgent("${agent.name}"): the runtime's tools differ from the agent definition's` +
|
|
481
|
+
(missing.length ? ` — missing from the runtime: ${missing.join(", ")}` : "") +
|
|
482
|
+
(extra.length ? ` — only in the runtime: ${extra.join(", ")}` : "") +
|
|
483
|
+
`. Build the runtime entry with toAgentDef(agent, model) so both come from one definition.`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Mount a discovered agent on a runtime. Builds the ChannelContext the channels drive
|
|
489
|
+
// turns through — run, runDetached, runStream, resumeStream and resetSession, each over
|
|
490
|
+
// the runtime's in-process session. Not provided: runDelivered/resumeDelivered, which
|
|
491
|
+
// exist to escape the edge waitUntil ceiling (a native host has none; channels fall back
|
|
492
|
+
// to runStream/resumeStream without them). Exposes:
|
|
190
493
|
// • surface(req) — the composable agent surface for June's router: a framework
|
|
191
494
|
// chat endpoint at `chatPath` (POST {message, session?} → a turn) PLUS the
|
|
192
495
|
// discovered channels; returns null when the request isn't an agent route.
|
|
@@ -195,7 +498,7 @@ export async function createAgentRuntime(
|
|
|
195
498
|
export function mountAgent(
|
|
196
499
|
agent: AgentDefinition,
|
|
197
500
|
runtime: Runtime,
|
|
198
|
-
opts: { chatPath?: string; channels?: boolean } = {},
|
|
501
|
+
opts: { chatPath?: string; channels?: boolean; services?: unknown; files?: FileStore } = {},
|
|
199
502
|
): {
|
|
200
503
|
surface: (req: Request) => Promise<Response | null>;
|
|
201
504
|
fetch: (req: Request) => Promise<Response | null>;
|
|
@@ -204,10 +507,42 @@ export function mountAgent(
|
|
|
204
507
|
} {
|
|
205
508
|
const chatPath = opts.chatPath ?? "/message";
|
|
206
509
|
const channelsOn = opts.channels ?? true;
|
|
510
|
+
warnOnDrift(agent, runtime);
|
|
207
511
|
const ctx: ChannelContext = {
|
|
208
512
|
agent,
|
|
513
|
+
services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
|
|
514
|
+
// The runtime's file store, so a channel can deliver the files the turn's tools stored.
|
|
515
|
+
files: opts.files ?? (runtime as { files?: FileStore }).files,
|
|
209
516
|
run: (message, o) =>
|
|
210
|
-
runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message }),
|
|
517
|
+
runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
|
|
518
|
+
// FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
|
|
519
|
+
// ceiling (Node keeps floating promises alive), but the seam is the same so a channel
|
|
520
|
+
// written against ctx.runDetached behaves identically on both targets.
|
|
521
|
+
runDetached: async (message, o) =>
|
|
522
|
+
runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
|
|
523
|
+
// LIVE (#169): the turn's event stream, so a channel renders as the turn runs —
|
|
524
|
+
// slackChannel({ stream: true }) edits in place here exactly as on the edge, instead of
|
|
525
|
+
// silently degrading to one post at the end. Lazy like the DO's (the turn starts on the
|
|
526
|
+
// first pull); start()/resume() and the subscription run with nothing awaited between,
|
|
527
|
+
// so no event can emit unobserved.
|
|
528
|
+
runStream: async function* (message, o) {
|
|
529
|
+
const session = runtime.session(agent.name, o?.session ?? "default");
|
|
530
|
+
const { turnId } = session.start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace });
|
|
531
|
+
yield* observeTurnEvents(session, turnId);
|
|
532
|
+
},
|
|
533
|
+
// HITL: answer a parked turn and stream its continuation (the approval-button path).
|
|
534
|
+
resumeStream: async function* (o) {
|
|
535
|
+
const session = runtime.session(agent.name, o.session ?? "default");
|
|
536
|
+
// A { policy } answerer is the app's call (#261): decided here, before the synchronous
|
|
537
|
+
// resume, so resume-then-subscribe stays free of awaits.
|
|
538
|
+
const granted = await grantAnswer(session, o, agent.authorizeAnswer);
|
|
539
|
+
const { turnId } = session.resume(o.turnId, o.inputId, o.input, { by: o.by, granted });
|
|
540
|
+
yield* observeTurnEvents(session, turnId);
|
|
541
|
+
},
|
|
542
|
+
// SESSION RESET (#129): same seam as durableChannelSurface — the in-process runtimes'
|
|
543
|
+
// stores implement archival, so a channel written against ctx.resetSession behaves
|
|
544
|
+
// identically on both targets.
|
|
545
|
+
resetSession: (o) => runtime.session(agent.name, o?.session ?? "default").reset(),
|
|
211
546
|
};
|
|
212
547
|
const channels = channelFetch(agent, ctx);
|
|
213
548
|
const surface = async (req: Request): Promise<Response | null> => {
|