@junejs/server 0.1.2-dev.4 → 0.1.2-dev.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-durable.d.ts +29 -2
- package/dist/agent-durable.js +118 -11
- package/dist/agent-native.js +9 -0
- package/dist/core-version.d.ts +5 -0
- package/dist/core-version.js +12 -0
- package/package.json +2 -2
- package/src/agent-durable.ts +222 -15
- package/src/agent-native.ts +8 -0
- package/src/core-version.ts +34 -0
package/dist/agent-durable.d.ts
CHANGED
|
@@ -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 { InboundEvent, Model, Msg, ProactiveTrigger, SessionStore, Tool, TurnError, TurnEvent, TurnFailurePhase } 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;
|
|
@@ -36,6 +36,8 @@ declare class DoSessionStore implements SessionStore {
|
|
|
36
36
|
delStep(id: string): void;
|
|
37
37
|
getStatus(): string;
|
|
38
38
|
setStatus(s: string): void;
|
|
39
|
+
getSessionKey(): string | undefined;
|
|
40
|
+
setSessionKey(key: string): void;
|
|
39
41
|
tx<T>(fn: () => T): T;
|
|
40
42
|
unwrap<H = unknown>(): H;
|
|
41
43
|
}
|
|
@@ -49,23 +51,48 @@ type DoAgentDef = {
|
|
|
49
51
|
env?: unknown;
|
|
50
52
|
resources?: Resources;
|
|
51
53
|
services?: unknown;
|
|
54
|
+
onTurnError?: (failure: {
|
|
55
|
+
turnId: string;
|
|
56
|
+
error: TurnError;
|
|
57
|
+
phase?: TurnFailurePhase;
|
|
58
|
+
step?: string;
|
|
59
|
+
}) => void | Promise<void>;
|
|
52
60
|
};
|
|
53
61
|
declare class AgentDurableObject {
|
|
54
62
|
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?;
|
|
55
70
|
private readonly resources;
|
|
56
71
|
private readonly services;
|
|
57
72
|
constructor(state: DurableObjectState, def: DoAgentDef);
|
|
73
|
+
private resolveSession;
|
|
58
74
|
turn(input: {
|
|
59
75
|
turnId?: string;
|
|
60
76
|
userText: string;
|
|
61
77
|
event?: InboundEvent;
|
|
62
78
|
trigger?: ProactiveTrigger;
|
|
79
|
+
session?: string;
|
|
63
80
|
}): 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
|
+
}>;
|
|
64
90
|
transcript(): import("@junejs/core/agent-runtime").Turn[];
|
|
65
91
|
fetch(req: Request): Promise<Response>;
|
|
66
92
|
}
|
|
67
93
|
declare function sseTurnFinalText(res: Response): Promise<string>;
|
|
68
94
|
declare function sseTurnEvents(res: Response): AsyncIterable<TurnEvent>;
|
|
95
|
+
declare const SESSION_HEADER = "x-june-session";
|
|
69
96
|
declare function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response>;
|
|
70
97
|
declare function durableAgentSurface(getNamespace: () => DurableObjectNamespace | undefined, opts: {
|
|
71
98
|
agentName: string;
|
|
@@ -79,4 +106,4 @@ declare function durableChannelSurface(getNamespace: () => DurableObjectNamespac
|
|
|
79
106
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
80
107
|
}): (req: Request) => Promise<Response | null>;
|
|
81
108
|
//#endregion
|
|
82
|
-
export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
|
|
109
|
+
export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SESSION_HEADER, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
|
package/dist/agent-durable.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertCoreRuntimeVersion } from "./core-version.js";
|
|
1
2
|
import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
|
|
2
3
|
import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
|
|
3
4
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
@@ -40,6 +41,13 @@ var DoSessionStore = class {
|
|
|
40
41
|
setStatus(s) {
|
|
41
42
|
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
|
|
42
43
|
}
|
|
44
|
+
getSessionKey() {
|
|
45
|
+
const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
|
|
46
|
+
return rows.length ? rows[0].v : void 0;
|
|
47
|
+
}
|
|
48
|
+
setSessionKey(key) {
|
|
49
|
+
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
|
|
50
|
+
}
|
|
43
51
|
tx(fn) {
|
|
44
52
|
return this.storage.transactionSync(fn);
|
|
45
53
|
}
|
|
@@ -66,13 +74,43 @@ const crossDoUnsupported = { session() {
|
|
|
66
74
|
} };
|
|
67
75
|
var AgentDurableObject = class {
|
|
68
76
|
session;
|
|
77
|
+
sessionKey;
|
|
78
|
+
name;
|
|
79
|
+
store;
|
|
80
|
+
sink;
|
|
81
|
+
model;
|
|
82
|
+
tools;
|
|
83
|
+
channelInstructions;
|
|
69
84
|
resources;
|
|
70
85
|
services;
|
|
71
86
|
constructor(state, def) {
|
|
87
|
+
assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`);
|
|
72
88
|
const store = new DoSessionStore(state.storage);
|
|
73
89
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
74
90
|
this.resources = def.resources ?? {};
|
|
75
91
|
this.services = def.services;
|
|
92
|
+
const name = def.name ?? "agent";
|
|
93
|
+
const sink = new InProcEventSink();
|
|
94
|
+
sink.subscribe((e) => {
|
|
95
|
+
if (e.type !== "turn.failed") return;
|
|
96
|
+
const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed${e.step ? ` at ${e.step}` : ""}: ${e.error.stack ?? e.error.message}${e.error.causeChain?.length ? `\ncaused by: ${e.error.causeChain.join(" ← ")}` : ""}`);
|
|
97
|
+
if (def.onTurnError) try {
|
|
98
|
+
const out = def.onTurnError({
|
|
99
|
+
turnId: e.turnId,
|
|
100
|
+
error: e.error,
|
|
101
|
+
phase: e.phase,
|
|
102
|
+
step: e.step
|
|
103
|
+
});
|
|
104
|
+
if (out && typeof out.then === "function") out.then(void 0, (hookErr) => {
|
|
105
|
+
console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
|
|
106
|
+
defaultLog();
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
} catch (hookErr) {
|
|
110
|
+
console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
|
|
111
|
+
}
|
|
112
|
+
defaultLog();
|
|
113
|
+
});
|
|
76
114
|
const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
|
|
77
115
|
const tools = [...def.tools];
|
|
78
116
|
const seen = new Set(tools.map((t) => t.spec.name));
|
|
@@ -81,29 +119,62 @@ var AgentDurableObject = class {
|
|
|
81
119
|
seen.add(t.spec.name);
|
|
82
120
|
tools.push(t);
|
|
83
121
|
}
|
|
84
|
-
this.
|
|
122
|
+
this.name = name;
|
|
123
|
+
this.store = store;
|
|
124
|
+
this.sink = sink;
|
|
125
|
+
this.model = model;
|
|
126
|
+
this.tools = tools;
|
|
127
|
+
this.channelInstructions = def.channelInstructions;
|
|
128
|
+
}
|
|
129
|
+
resolveSession(key) {
|
|
130
|
+
if (key !== void 0) assertSessionKey(key);
|
|
131
|
+
if (this.session) {
|
|
132
|
+
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)`);
|
|
133
|
+
return this.session;
|
|
134
|
+
}
|
|
135
|
+
const stored = this.store.getSessionKey();
|
|
136
|
+
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)`);
|
|
137
|
+
const resolved = key ?? stored ?? "self";
|
|
138
|
+
if (key !== void 0 && key !== "self" && stored === void 0) this.store.setSessionKey(key);
|
|
139
|
+
this.sessionKey = resolved;
|
|
140
|
+
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
141
|
+
return this.session;
|
|
85
142
|
}
|
|
86
143
|
async turn(input) {
|
|
87
144
|
await ensureScope();
|
|
145
|
+
const session = this.resolveSession(input.session);
|
|
88
146
|
return runInScope({
|
|
89
147
|
resources: this.resources,
|
|
90
148
|
services: this.services
|
|
91
|
-
}, () =>
|
|
149
|
+
}, () => session.turn(input));
|
|
150
|
+
}
|
|
151
|
+
async start(input) {
|
|
152
|
+
await ensureScope();
|
|
153
|
+
const session = this.resolveSession(input.session);
|
|
154
|
+
return runInScope({
|
|
155
|
+
resources: this.resources,
|
|
156
|
+
services: this.services
|
|
157
|
+
}, () => session.start(input));
|
|
92
158
|
}
|
|
93
159
|
transcript() {
|
|
94
|
-
return this.session.transcript();
|
|
160
|
+
if (this.session) return this.session.transcript();
|
|
161
|
+
if (this.store.getSessionKey() !== void 0) return this.resolveSession().transcript();
|
|
162
|
+
return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
|
|
95
163
|
}
|
|
96
164
|
async fetch(req) {
|
|
97
165
|
const url = new URL(req.url);
|
|
166
|
+
const key = req.headers.get("x-june-session") ?? void 0;
|
|
98
167
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
99
168
|
const { userText, turnId, event, trigger } = await req.json();
|
|
100
169
|
await ensureScope();
|
|
170
|
+
let session;
|
|
101
171
|
let started;
|
|
102
172
|
try {
|
|
173
|
+
session = this.resolveSession(key);
|
|
103
174
|
started = runInScope({
|
|
104
175
|
resources: this.resources,
|
|
105
176
|
services: this.services
|
|
106
|
-
}, () =>
|
|
177
|
+
}, () => session.start({
|
|
107
178
|
userText,
|
|
108
179
|
turnId,
|
|
109
180
|
event,
|
|
@@ -112,23 +183,33 @@ var AgentDurableObject = class {
|
|
|
112
183
|
} catch (err) {
|
|
113
184
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
114
185
|
}
|
|
115
|
-
return
|
|
186
|
+
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
187
|
+
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
116
188
|
}
|
|
117
189
|
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
118
190
|
const { turnId, inputId, input, by } = await req.json();
|
|
119
191
|
await ensureScope();
|
|
192
|
+
let session;
|
|
120
193
|
try {
|
|
194
|
+
session = this.resolveSession(key);
|
|
121
195
|
runInScope({
|
|
122
196
|
resources: this.resources,
|
|
123
197
|
services: this.services
|
|
124
|
-
}, () =>
|
|
198
|
+
}, () => session.resume(turnId, inputId, input, { by }));
|
|
125
199
|
} catch (err) {
|
|
126
200
|
const message = err instanceof Error ? err.message : String(err);
|
|
127
201
|
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
128
202
|
}
|
|
129
|
-
return new Response(sseTurnStream(
|
|
203
|
+
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
204
|
+
}
|
|
205
|
+
if (url.pathname.endsWith("/transcript")) {
|
|
206
|
+
if (key !== void 0) try {
|
|
207
|
+
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
210
|
+
}
|
|
211
|
+
return Response.json({ transcript: this.transcript() });
|
|
130
212
|
}
|
|
131
|
-
if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
|
|
132
213
|
return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
|
|
133
214
|
}
|
|
134
215
|
};
|
|
@@ -185,7 +266,7 @@ async function sseTurnFinalText(res) {
|
|
|
185
266
|
if (!line) continue;
|
|
186
267
|
const e = JSON.parse(line.slice(5).trim());
|
|
187
268
|
if (e.type === "turn.completed") return e.text;
|
|
188
|
-
if (e.type === "turn.failed") throw new Error(e.error.message);
|
|
269
|
+
if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error });
|
|
189
270
|
}
|
|
190
271
|
if (done) break;
|
|
191
272
|
}
|
|
@@ -212,8 +293,16 @@ async function* sseTurnEvents(res) {
|
|
|
212
293
|
if (done) break;
|
|
213
294
|
}
|
|
214
295
|
}
|
|
296
|
+
const SESSION_HEADER = "x-june-session";
|
|
297
|
+
const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
|
|
298
|
+
function assertSessionKey(session) {
|
|
299
|
+
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)`);
|
|
300
|
+
}
|
|
215
301
|
function durableFetch(namespace, agent, session, req) {
|
|
216
|
-
|
|
302
|
+
assertSessionKey(session);
|
|
303
|
+
const headers = new Headers(req.headers);
|
|
304
|
+
headers.set(SESSION_HEADER, session);
|
|
305
|
+
return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
|
|
217
306
|
}
|
|
218
307
|
function durableAgentSurface(getNamespace, opts) {
|
|
219
308
|
const chatPath = opts.chatPath ?? "/message";
|
|
@@ -223,6 +312,11 @@ function durableAgentSurface(getNamespace, opts) {
|
|
|
223
312
|
const namespace = getNamespace();
|
|
224
313
|
if (!namespace) return null;
|
|
225
314
|
const { message, session } = await req.json();
|
|
315
|
+
if (session !== void 0) try {
|
|
316
|
+
assertSessionKey(session);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
|
|
319
|
+
}
|
|
226
320
|
const res = await durableFetch(namespace, opts.agentName, session ?? "default", new Request("https://do/turn", {
|
|
227
321
|
method: "POST",
|
|
228
322
|
body: JSON.stringify({ userText: message })
|
|
@@ -251,6 +345,19 @@ function durableChannelSurface(getNamespace, opts) {
|
|
|
251
345
|
body: serializeTurn(message, o)
|
|
252
346
|
})));
|
|
253
347
|
},
|
|
348
|
+
runDetached: async (message, o) => {
|
|
349
|
+
const namespace = getNamespace();
|
|
350
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
351
|
+
const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?detach=1", {
|
|
352
|
+
method: "POST",
|
|
353
|
+
body: serializeTurn(message, o)
|
|
354
|
+
}));
|
|
355
|
+
if (res.status !== 202) {
|
|
356
|
+
const detail = (await res.text()).slice(0, 200);
|
|
357
|
+
throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
|
|
358
|
+
}
|
|
359
|
+
return await res.json();
|
|
360
|
+
},
|
|
254
361
|
runStream: async function* (message, o) {
|
|
255
362
|
const namespace = getNamespace();
|
|
256
363
|
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
@@ -302,4 +409,4 @@ function serializeTurn(userText, o) {
|
|
|
302
409
|
}
|
|
303
410
|
}
|
|
304
411
|
//#endregion
|
|
305
|
-
export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
|
|
412
|
+
export { AgentDurableObject, DoSessionStore, SESSION_HEADER, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
|
package/dist/agent-native.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertCoreRuntimeVersion } from "./core-version.js";
|
|
1
2
|
import { openLocalSqliteSync } from "./sqlite-driver.js";
|
|
2
3
|
import { channelFetch } from "@junejs/core/agent-config";
|
|
3
4
|
import { AgentSession, withSystem } from "@junejs/core/agent-runtime";
|
|
@@ -73,6 +74,7 @@ var NativeRuntime = class {
|
|
|
73
74
|
agents;
|
|
74
75
|
db;
|
|
75
76
|
constructor(agents, db) {
|
|
77
|
+
assertCoreRuntimeVersion("NativeRuntime");
|
|
76
78
|
this.agents = agents;
|
|
77
79
|
this.db = db;
|
|
78
80
|
initSchema(db);
|
|
@@ -131,6 +133,7 @@ var MemoryRuntime = class {
|
|
|
131
133
|
stores = /* @__PURE__ */ new Map();
|
|
132
134
|
agents;
|
|
133
135
|
constructor(agents) {
|
|
136
|
+
assertCoreRuntimeVersion("MemoryRuntime");
|
|
134
137
|
this.agents = agents;
|
|
135
138
|
}
|
|
136
139
|
session(agent, id) {
|
|
@@ -164,6 +167,12 @@ function mountAgent(agent, runtime, opts = {}) {
|
|
|
164
167
|
userText: message,
|
|
165
168
|
event: o?.event,
|
|
166
169
|
trigger: o?.trigger
|
|
170
|
+
}),
|
|
171
|
+
runDetached: async (message, o) => runtime.session(agent.name, o?.session ?? "default").start({
|
|
172
|
+
turnId: o?.turnId,
|
|
173
|
+
userText: message,
|
|
174
|
+
event: o?.event,
|
|
175
|
+
trigger: o?.trigger
|
|
167
176
|
})
|
|
168
177
|
};
|
|
169
178
|
const channels = channelFetch(agent, ctx);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//#region src/core-version.d.ts
|
|
2
|
+
declare function assertCoreRuntimeVersion(surface: string): void;
|
|
3
|
+
declare function checkCoreRuntimeVersion(surface: string, actual: number | undefined, expected?: number): void;
|
|
4
|
+
//#endregion
|
|
5
|
+
export { assertCoreRuntimeVersion, checkCoreRuntimeVersion };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as coreRuntime from "@junejs/core/agent-runtime";
|
|
2
|
+
//#region src/core-version.ts
|
|
3
|
+
const EXPECTED_CORE_RUNTIME_API = 1;
|
|
4
|
+
function assertCoreRuntimeVersion(surface) {
|
|
5
|
+
checkCoreRuntimeVersion(surface, coreRuntime.RUNTIME_API_VERSION);
|
|
6
|
+
}
|
|
7
|
+
function checkCoreRuntimeVersion(surface, actual, expected = EXPECTED_CORE_RUNTIME_API) {
|
|
8
|
+
if (actual === expected) return;
|
|
9
|
+
throw new Error(`${surface}: @junejs/server is built against @junejs/core runtime API v${expected}, but the @junejs/core it resolved provides ${actual === void 0 ? "no RUNTIME_API_VERSION (a pre-0.1.1-dev.16 core)" : `v${actual}`} — two copies of @junejs/core are likely installed (one nested under @junejs/server). Align the app's @junejs/core with @junejs/server's peer range, then reinstall/dedupe so one copy remains.`);
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { assertCoreRuntimeVersion, checkCoreRuntimeVersion };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.9",
|
|
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,7 +125,6 @@
|
|
|
125
125
|
"build": "tsdown"
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
-
"@junejs/core": ">=0.1.1-dev.8",
|
|
129
128
|
"@junejs/db": ">=0.0.34-dev.0",
|
|
130
129
|
"@momiji-rs/sparkdown": "^0.0.4",
|
|
131
130
|
"lightningcss": "^1.27.0",
|
|
@@ -133,6 +132,7 @@
|
|
|
133
132
|
"rolldown": "^1.1.0"
|
|
134
133
|
},
|
|
135
134
|
"peerDependencies": {
|
|
135
|
+
"@junejs/core": ">=0.1.1-dev.16",
|
|
136
136
|
"react": "^19.2.0",
|
|
137
137
|
"react-dom": "^19.2.0",
|
|
138
138
|
"react-server-dom-webpack": "19.2.7",
|
package/src/agent-durable.ts
CHANGED
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
type SessionStore,
|
|
29
29
|
type ProactiveTrigger,
|
|
30
30
|
type Tool,
|
|
31
|
+
type TurnError,
|
|
32
|
+
type TurnFailurePhase,
|
|
31
33
|
} from "@junejs/core/agent-runtime";
|
|
32
34
|
import type { Resources } from "@junejs/core/resources";
|
|
33
35
|
import {
|
|
@@ -39,6 +41,7 @@ import {
|
|
|
39
41
|
type ChannelFactory,
|
|
40
42
|
} from "@junejs/core/agent-config";
|
|
41
43
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
44
|
+
import { assertCoreRuntimeVersion } from "./core-version";
|
|
42
45
|
|
|
43
46
|
// ── minimal structural Cloudflare surface (no @cloudflare/workers-types dep) ──
|
|
44
47
|
// SqlStorage row values are exactly what workerd's SQLite returns. Mirroring
|
|
@@ -117,6 +120,17 @@ export class DoSessionStore implements SessionStore {
|
|
|
117
120
|
setStatus(s: string) {
|
|
118
121
|
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
|
|
119
122
|
}
|
|
123
|
+
// The externally-assigned session key (#75). A DO cannot read its own idFromName
|
|
124
|
+
// name, so the key arrives on the first routed request and is persisted here —
|
|
125
|
+
// surviving hibernation/eviction, where the constructor re-runs with no request
|
|
126
|
+
// in hand. Not part of core's SessionStore contract: only the DO seam needs it.
|
|
127
|
+
getSessionKey(): string | undefined {
|
|
128
|
+
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
|
|
129
|
+
return rows.length ? rows[0]!.v : undefined;
|
|
130
|
+
}
|
|
131
|
+
setSessionKey(key: string) {
|
|
132
|
+
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
|
|
133
|
+
}
|
|
120
134
|
tx<T>(fn: () => T): T {
|
|
121
135
|
return this.storage.transactionSync(fn);
|
|
122
136
|
}
|
|
@@ -171,6 +185,17 @@ export type DoAgentDef = {
|
|
|
171
185
|
env?: unknown;
|
|
172
186
|
resources?: Resources;
|
|
173
187
|
services?: unknown;
|
|
188
|
+
// Called on every turn.failed for this session — the app's seam for routing turn
|
|
189
|
+
// failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
|
|
190
|
+
// default console.error; if the hook throws (or an async hook rejects), the default
|
|
191
|
+
// log fires anyway (both errors), so a failure is never silent. `error` carries the
|
|
192
|
+
// full serialized failure (stack, cause chain — see TurnError, #96), and phase/step
|
|
193
|
+
// name the engine step that was in flight — for a DETACHED turn this hook is the
|
|
194
|
+
// only failure-surfacing path, so nothing may be flattened before it fires. It
|
|
195
|
+
// happens to run inside the failed turn's request scope today (turn.failed is
|
|
196
|
+
// emitted mid-turn and ALS propagates), but that is NOT contract — don't rely on
|
|
197
|
+
// ambient db/services; close over what you need.
|
|
198
|
+
onTurnError?: (failure: { turnId: string; error: TurnError; phase?: TurnFailurePhase; step?: string }) => void | Promise<void>;
|
|
174
199
|
};
|
|
175
200
|
|
|
176
201
|
// The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
|
|
@@ -188,16 +213,67 @@ export type DoAgentDef = {
|
|
|
188
213
|
// fetch(req) { return this.agent.fetch(req); }
|
|
189
214
|
// }
|
|
190
215
|
export class AgentDurableObject {
|
|
191
|
-
|
|
216
|
+
// The AgentSession is constructed LAZILY (#75): the DO is keyed externally by
|
|
217
|
+
// idFromName(`${agent}:${session}`) but cannot read its own name, so the true
|
|
218
|
+
// session key only exists on a routed request (the SESSION_HEADER durableFetch
|
|
219
|
+
// sets). First keyed request wins and persists the key (agent_meta) so it
|
|
220
|
+
// survives hibernation; key-less paths fall back to the persisted key, then
|
|
221
|
+
// "self" (the pre-#75 behavior, kept for backward compatibility).
|
|
222
|
+
private session: AgentSession | undefined;
|
|
223
|
+
private sessionKey: string | undefined;
|
|
224
|
+
private readonly name: string;
|
|
225
|
+
private readonly store: DoSessionStore;
|
|
226
|
+
private readonly sink: InProcEventSink;
|
|
227
|
+
private readonly model: Model;
|
|
228
|
+
private readonly tools: Tool[];
|
|
229
|
+
private readonly channelInstructions?: Record<string, string>;
|
|
192
230
|
// Built from the DO's env in the constructor (this isolate), shared across turns —
|
|
193
231
|
// env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
|
|
194
232
|
private readonly resources: Resources;
|
|
195
233
|
private readonly services: unknown;
|
|
196
234
|
constructor(state: DurableObjectState, def: DoAgentDef) {
|
|
235
|
+
assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`); // #94: fail power-on, not mid-turn
|
|
197
236
|
const store = new DoSessionStore(state.storage);
|
|
198
237
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
199
238
|
this.resources = def.resources ?? {};
|
|
200
239
|
this.services = def.services;
|
|
240
|
+
const name = def.name ?? "agent";
|
|
241
|
+
// Failure observability (#76): a turn that dies after the fast-ACK has no other
|
|
242
|
+
// observable surface on the edge — the webhook already 200'd and runBackground
|
|
243
|
+
// swallows the rejection unless the channel wired onError. Subscribing HERE (the
|
|
244
|
+
// session sink) covers every turn path — turn(), /turn, /resume — with one seam.
|
|
245
|
+
// Default: console.error, which wrangler tail surfaces. An app onTurnError takes
|
|
246
|
+
// over reporting; if IT throws, fall back to the default so nothing goes silent.
|
|
247
|
+
// (InProcEventSink already guards emit against a throwing subscriber.)
|
|
248
|
+
const sink = new InProcEventSink();
|
|
249
|
+
sink.subscribe((e) => {
|
|
250
|
+
if (e.type !== "turn.failed") return;
|
|
251
|
+
// The stack already opens with the message; log it INSTEAD of the message line
|
|
252
|
+
// when present so wrangler tail shows one coherent trace, not the message twice.
|
|
253
|
+
const defaultLog = () =>
|
|
254
|
+
console.error(`[june] agent "${name}" turn ${e.turnId} failed${e.step ? ` at ${e.step}` : ""}: ${e.error.stack ?? e.error.message}${e.error.causeChain?.length ? `\ncaused by: ${e.error.causeChain.join(" ← ")}` : ""}`);
|
|
255
|
+
if (def.onTurnError) {
|
|
256
|
+
try {
|
|
257
|
+
const out = def.onTurnError({ turnId: e.turnId, error: e.error, phase: e.phase, step: e.step });
|
|
258
|
+
// An async hook (e.g. `await sendToSentry(...)`) types as void but returns a
|
|
259
|
+
// Promise — a rejection there must hit the same fallback as a sync throw, or
|
|
260
|
+
// the failure goes silent again (plus an unhandled rejection). No waitUntil
|
|
261
|
+
// keeper is needed here: unlike a Worker, a DO stays active while it has
|
|
262
|
+
// pending work/IO, and DurableObjectState.waitUntil is documented as a no-op —
|
|
263
|
+
// this .then subscription is what retains the promise.
|
|
264
|
+
if (out && typeof (out as Promise<void>).then === "function") {
|
|
265
|
+
(out as Promise<void>).then(undefined, (hookErr) => {
|
|
266
|
+
console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
|
|
267
|
+
defaultLog();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
} catch (hookErr) {
|
|
272
|
+
console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
defaultLog();
|
|
276
|
+
});
|
|
201
277
|
// Merge the mounted channels' capability tools (built here from this DO's env, since a
|
|
202
278
|
// tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
|
|
203
279
|
// keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
|
|
@@ -209,7 +285,49 @@ export class AgentDurableObject {
|
|
|
209
285
|
seen.add(t.spec.name);
|
|
210
286
|
tools.push(t);
|
|
211
287
|
}
|
|
212
|
-
this.
|
|
288
|
+
this.name = name;
|
|
289
|
+
this.store = store;
|
|
290
|
+
this.sink = sink;
|
|
291
|
+
this.model = model;
|
|
292
|
+
this.tools = tools;
|
|
293
|
+
this.channelInstructions = def.channelInstructions;
|
|
294
|
+
}
|
|
295
|
+
// Resolve THE session for this DO: explicit key (from a routed request) → persisted
|
|
296
|
+
// key (a prior life learned it) → "self". A key that contradicts the persisted or
|
|
297
|
+
// live identity is a mis-route (or a key-less path was used first) — fail loudly:
|
|
298
|
+
// silently proceeding is exactly the per-conversation data corruption #75 hit.
|
|
299
|
+
private resolveSession(key?: string): AgentSession {
|
|
300
|
+
// Every entry point shares the session-key contract (turn({ session }) and hand-rolled
|
|
301
|
+
// SESSION_HEADER values included, not just durableFetch): an invalid key persisted here
|
|
302
|
+
// would bind an identity no routed request could ever address — an orphaned session.
|
|
303
|
+
if (key !== undefined) assertSessionKey(key);
|
|
304
|
+
// Live fast path — no storage read once the session exists: this method is the only
|
|
305
|
+
// writer of the persisted key, so within one life it cannot diverge from the live one.
|
|
306
|
+
if (this.session) {
|
|
307
|
+
if (key !== undefined && this.sessionKey !== key) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
this.sessionKey === "self"
|
|
310
|
+
? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)`
|
|
311
|
+
: `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return this.session;
|
|
315
|
+
}
|
|
316
|
+
const stored = this.store.getSessionKey();
|
|
317
|
+
if (key !== undefined && stored !== undefined && key !== stored) {
|
|
318
|
+
throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
|
|
319
|
+
}
|
|
320
|
+
const resolved = key ?? stored ?? "self";
|
|
321
|
+
// "self" is deliberately NOT persisted — even when a caller passes it EXPLICITLY: it
|
|
322
|
+
// is a placeholder ("no identity externally assigned yet"), not an identity. Within
|
|
323
|
+
// one life a keyed request after a "self" binding is refused above — two concurrently
|
|
324
|
+
// active paths disagreeing is a live bug. But across eviction a keyed request ADOPTS
|
|
325
|
+
// the legacy transcript: that asymmetry IS the migration path from pre-keyed
|
|
326
|
+
// deployments, where a persisted "self" would 409 every keyed request forever.
|
|
327
|
+
if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
|
|
328
|
+
this.sessionKey = resolved;
|
|
329
|
+
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
330
|
+
return this.session;
|
|
213
331
|
}
|
|
214
332
|
// Run the whole turn inside a request scope seeded from this DO's env, so ambient
|
|
215
333
|
// `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
|
|
@@ -218,32 +336,59 @@ export class AgentDurableObject {
|
|
|
218
336
|
// Juno's batch-loader registry) can't leak across turns on a long-lived DO.
|
|
219
337
|
// ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
|
|
220
338
|
// pipeline does; without it runInScope is a pass-through and ambient reads throw.
|
|
221
|
-
async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger }): Promise<string> {
|
|
339
|
+
async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
|
|
222
340
|
await ensureScope();
|
|
223
|
-
|
|
341
|
+
const session = this.resolveSession(input.session);
|
|
342
|
+
return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
|
|
224
343
|
}
|
|
344
|
+
// Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
|
|
345
|
+
// it then runs under the DO's own lifetime (a DO stays alive while it has pending
|
|
346
|
+
// work), not the caller's. No live consumer sees the result — failures surface via
|
|
347
|
+
// the default turn-failure log / onTurnError (#76).
|
|
348
|
+
async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
|
|
349
|
+
await ensureScope();
|
|
350
|
+
const session = this.resolveSession(input.session);
|
|
351
|
+
return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
|
|
352
|
+
}
|
|
353
|
+
// Read-only: folds the durable log. When an identity exists (live, or persisted from a
|
|
354
|
+
// prior life) the session resolves and caches like any other path. Only a read on a
|
|
355
|
+
// NEVER-keyed object stays non-committal — caching there would bake "self" in as the
|
|
356
|
+
// identity and 409 a later keyed turn — so it folds through an ephemeral AgentSession.
|
|
225
357
|
transcript() {
|
|
226
|
-
return this.session.transcript();
|
|
358
|
+
if (this.session) return this.session.transcript();
|
|
359
|
+
if (this.store.getSessionKey() !== undefined) return this.resolveSession().transcript();
|
|
360
|
+
return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
|
|
227
361
|
}
|
|
228
362
|
// Default HTTP surface: POST …/turn STREAMS the turn's TurnEvents as SSE (start the
|
|
229
363
|
// turn in-scope, then stream from the session sink); GET …/transcript reads the log.
|
|
230
364
|
// The app can call turn()/transcript() directly instead (turn() awaits the final text).
|
|
231
365
|
async fetch(req: Request): Promise<Response> {
|
|
232
366
|
const url = new URL(req.url);
|
|
367
|
+
// The session key durableFetch stamped on the routed request (#75); absent on a
|
|
368
|
+
// hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
|
|
369
|
+
const key = req.headers.get(SESSION_HEADER) ?? undefined;
|
|
233
370
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
234
371
|
const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
|
|
235
372
|
await ensureScope();
|
|
236
373
|
// start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
|
|
237
374
|
// db/services (ALS propagates to the .then continuation registered here); subscribing
|
|
238
375
|
// happens synchronously right after, before any event can emit.
|
|
376
|
+
let session: AgentSession;
|
|
239
377
|
let started: { turnId: string };
|
|
240
378
|
try {
|
|
241
|
-
|
|
379
|
+
session = this.resolveSession(key);
|
|
380
|
+
started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
|
|
242
381
|
} catch (err) {
|
|
243
|
-
// e.g. the session is suspended awaiting input
|
|
382
|
+
// e.g. the session is suspended awaiting input, or the key mis-matches this
|
|
383
|
+
// object's identity — a client-resolvable conflict, not a crash
|
|
244
384
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
245
385
|
}
|
|
246
|
-
|
|
386
|
+
// DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
|
|
387
|
+
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
388
|
+
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
389
|
+
// ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
|
|
390
|
+
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
391
|
+
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
247
392
|
}
|
|
248
393
|
// Provide the input a suspended turn is waiting on and stream its continuation as SSE.
|
|
249
394
|
// NOTE: this default surface passes `by` straight from the body — the engine treats it as a
|
|
@@ -252,16 +397,29 @@ export class AgentDurableObject {
|
|
|
252
397
|
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
253
398
|
const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
|
|
254
399
|
await ensureScope();
|
|
400
|
+
let session: AgentSession;
|
|
255
401
|
try {
|
|
256
|
-
|
|
402
|
+
session = this.resolveSession(key);
|
|
403
|
+
runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
|
|
257
404
|
} catch (err) {
|
|
258
405
|
const message = err instanceof Error ? err.message : String(err);
|
|
259
|
-
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id
|
|
406
|
+
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
|
|
260
407
|
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
261
408
|
}
|
|
262
|
-
return new Response(sseTurnStream(
|
|
409
|
+
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
410
|
+
}
|
|
411
|
+
if (url.pathname.endsWith("/transcript")) {
|
|
412
|
+
// A keyed read still conflict-checks (and may commit the key — it is authentic,
|
|
413
|
+
// idFromName routed it here); a key-less read stays non-committal via transcript().
|
|
414
|
+
if (key !== undefined) {
|
|
415
|
+
try {
|
|
416
|
+
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
417
|
+
} catch (err) {
|
|
418
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return Response.json({ transcript: this.transcript() });
|
|
263
422
|
}
|
|
264
|
-
if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
|
|
265
423
|
return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
|
|
266
424
|
}
|
|
267
425
|
}
|
|
@@ -321,7 +479,7 @@ export async function sseTurnFinalText(res: Response): Promise<string> {
|
|
|
321
479
|
if (!line) continue;
|
|
322
480
|
const e = JSON.parse(line.slice(5).trim()) as TurnEvent;
|
|
323
481
|
if (e.type === "turn.completed") return e.text;
|
|
324
|
-
if (e.type === "turn.failed") throw new Error(e.error.message);
|
|
482
|
+
if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error }); // full TurnError rides along
|
|
325
483
|
}
|
|
326
484
|
if (done) break;
|
|
327
485
|
}
|
|
@@ -352,10 +510,31 @@ export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
|
|
|
352
510
|
}
|
|
353
511
|
}
|
|
354
512
|
|
|
513
|
+
// The header durableFetch stamps the session key on (#75). A DO cannot read its own
|
|
514
|
+
// idFromName name, so this is how the externally-assigned session identity crosses
|
|
515
|
+
// into the object — exported so a custom shell that bypasses durableFetch can set it.
|
|
516
|
+
export const SESSION_HEADER = "x-june-session";
|
|
517
|
+
|
|
518
|
+
// A session key rides an HTTP header (SESSION_HEADER) and the DO name (idFromName) —
|
|
519
|
+
// reject values that can't: CR/LF would be header injection, and Headers.set throws a
|
|
520
|
+
// bare TypeError on control/non-ASCII bytes, turning a bad client value into an opaque
|
|
521
|
+
// 500 deep inside durableFetch. Non-empty printable ASCII is the contract.
|
|
522
|
+
const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
|
|
523
|
+
function assertSessionKey(session: string) {
|
|
524
|
+
if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) {
|
|
525
|
+
throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
355
529
|
// Worker-side routing: address a session's DO by (agent, session) and forward the
|
|
356
|
-
// request to it. `env.AGENT` is the DO namespace binding.
|
|
530
|
+
// request to it. `env.AGENT` is the DO namespace binding. The session key rides a
|
|
531
|
+
// header (not the body): one seam covers /turn, /resume, and /transcript without
|
|
532
|
+
// touching any body contract, and an older DO simply ignores it.
|
|
357
533
|
export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
|
|
358
|
-
|
|
534
|
+
assertSessionKey(session);
|
|
535
|
+
const headers = new Headers(req.headers);
|
|
536
|
+
headers.set(SESSION_HEADER, session);
|
|
537
|
+
return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
|
|
359
538
|
}
|
|
360
539
|
|
|
361
540
|
// The EDGE agent surface for June's router: route the chat endpoint to the
|
|
@@ -377,6 +556,15 @@ export function durableAgentSurface(
|
|
|
377
556
|
const namespace = getNamespace();
|
|
378
557
|
if (!namespace) return null; // no DO binding → not mounted here
|
|
379
558
|
const { message, session } = (await req.json()) as { message: string; session?: string };
|
|
559
|
+
// `session` is CLIENT input here — an un-headerable value must be a clear 400, not
|
|
560
|
+
// the opaque 500 assertSessionKey would become this deep in the worker.
|
|
561
|
+
if (session !== undefined) {
|
|
562
|
+
try {
|
|
563
|
+
assertSessionKey(session);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
380
568
|
// Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
|
|
381
569
|
// that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
|
|
382
570
|
// through; otherwise collapse to the final { text } (the prior JSON contract).
|
|
@@ -450,6 +638,25 @@ export function durableChannelSurface(
|
|
|
450
638
|
// /turn streams SSE; the simple path collapses it to the final text.
|
|
451
639
|
return sseTurnFinalText(res);
|
|
452
640
|
},
|
|
641
|
+
// FIRE-AND-FORGET (#77): POST /turn?detach=1 — the DO 202s once the turn is accepted
|
|
642
|
+
// and runs it under its OWN lifetime. This is how a shadow/observe hook (onEvent →
|
|
643
|
+
// assessment turn, reply dropped) escapes the edge waitUntil ceiling that killed
|
|
644
|
+
// 24–38s turns in production: nothing holds the edge open while the turn runs.
|
|
645
|
+
runDetached: async (message, o) => {
|
|
646
|
+
const namespace = getNamespace();
|
|
647
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
648
|
+
const res = await durableFetch(
|
|
649
|
+
namespace,
|
|
650
|
+
opts.agentName,
|
|
651
|
+
o?.session ?? "default",
|
|
652
|
+
new Request("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
|
|
653
|
+
);
|
|
654
|
+
if (res.status !== 202) {
|
|
655
|
+
const detail = (await res.text()).slice(0, 200);
|
|
656
|
+
throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
|
|
657
|
+
}
|
|
658
|
+
return (await res.json()) as { turnId: string };
|
|
659
|
+
},
|
|
453
660
|
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
454
661
|
runStream: async function* (message, o) {
|
|
455
662
|
const namespace = getNamespace();
|
package/src/agent-native.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "@junejs/core/agent-runtime";
|
|
21
21
|
import { channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
|
|
22
22
|
import { openLocalSqliteSync, type SyncSqlite } from "./sqlite-driver";
|
|
23
|
+
import { assertCoreRuntimeVersion } from "./core-version";
|
|
23
24
|
|
|
24
25
|
function initSchema(db: SyncSqlite) {
|
|
25
26
|
db.exec(`CREATE TABLE IF NOT EXISTS agent_sessions (session_id TEXT PRIMARY KEY, status TEXT)`);
|
|
@@ -99,6 +100,7 @@ export class NativeRuntime implements Runtime {
|
|
|
99
100
|
private readonly db: SyncSqlite;
|
|
100
101
|
|
|
101
102
|
constructor(agents: Record<string, AgentDef>, db: SyncSqlite) {
|
|
103
|
+
assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
|
|
102
104
|
this.agents = agents;
|
|
103
105
|
this.db = db;
|
|
104
106
|
initSchema(db);
|
|
@@ -154,6 +156,7 @@ export class MemoryRuntime implements Runtime {
|
|
|
154
156
|
private stores = new Map<string, MemorySessionStore>();
|
|
155
157
|
private readonly agents: Record<string, AgentDef>;
|
|
156
158
|
constructor(agents: Record<string, AgentDef>) {
|
|
159
|
+
assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
|
|
157
160
|
this.agents = agents;
|
|
158
161
|
}
|
|
159
162
|
session(agent: string, id: string): AgentSession {
|
|
@@ -214,6 +217,11 @@ export function mountAgent(
|
|
|
214
217
|
services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
|
|
215
218
|
run: (message, o) =>
|
|
216
219
|
runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
|
|
220
|
+
// FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
|
|
221
|
+
// ceiling (Node keeps floating promises alive), but the seam is the same so a channel
|
|
222
|
+
// written against ctx.runDetached behaves identically on both targets.
|
|
223
|
+
runDetached: async (message, o) =>
|
|
224
|
+
runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
|
|
217
225
|
};
|
|
218
226
|
const channels = channelFetch(agent, ctx);
|
|
219
227
|
const surface = async (req: Request): Promise<Response | null> => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// core-version.ts — the #94 belt-and-braces: fail at POWER-ON when the @junejs/core
|
|
2
|
+
// copy this server resolved is not the one it was built for.
|
|
3
|
+
//
|
|
4
|
+
// The primary fix is @junejs/core being a peerDependency (the app's single resolution
|
|
5
|
+
// always wins), but a nested second copy can still appear (npm legacy peer handling,
|
|
6
|
+
// a stale lockfile, a transitive pin). When it does, the failure without this check
|
|
7
|
+
// is far from the cause and mid-turn ("sink.emit is not a function", first seen in
|
|
8
|
+
// crisp-agent production); with it, construction throws with both versions named.
|
|
9
|
+
//
|
|
10
|
+
// Namespace import + optional read on purpose: a PRE-#94 core has no
|
|
11
|
+
// RUNTIME_API_VERSION export, and a named import of a missing export would die at
|
|
12
|
+
// module link time with an unhelpful SyntaxError — exactly the confusion this
|
|
13
|
+
// module exists to remove.
|
|
14
|
+
import * as coreRuntime from "@junejs/core/agent-runtime";
|
|
15
|
+
|
|
16
|
+
// The contract number this server tree is built against. Bump in lockstep with
|
|
17
|
+
// core's RUNTIME_API_VERSION whenever the server↔core runtime contract changes.
|
|
18
|
+
const EXPECTED_CORE_RUNTIME_API = 1;
|
|
19
|
+
|
|
20
|
+
export function assertCoreRuntimeVersion(surface: string): void {
|
|
21
|
+
checkCoreRuntimeVersion(surface, (coreRuntime as { RUNTIME_API_VERSION?: number }).RUNTIME_API_VERSION);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The pure check, split out so the mismatch paths are unit-testable (the ambient
|
|
25
|
+
// read above can only ever see the workspace's own — matching — core).
|
|
26
|
+
export function checkCoreRuntimeVersion(surface: string, actual: number | undefined, expected: number = EXPECTED_CORE_RUNTIME_API): void {
|
|
27
|
+
if (actual === expected) return;
|
|
28
|
+
throw new Error(
|
|
29
|
+
`${surface}: @junejs/server is built against @junejs/core runtime API v${expected}, ` +
|
|
30
|
+
`but the @junejs/core it resolved provides ${actual === undefined ? "no RUNTIME_API_VERSION (a pre-0.1.1-dev.16 core)" : `v${actual}`} — ` +
|
|
31
|
+
`two copies of @junejs/core are likely installed (one nested under @junejs/server). ` +
|
|
32
|
+
`Align the app's @junejs/core with @junejs/server's peer range, then reinstall/dedupe so one copy remains.`,
|
|
33
|
+
);
|
|
34
|
+
}
|