@junejs/server 0.1.2-dev.4 → 0.1.2-dev.7
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 +28 -1
- package/dist/agent-durable.js +113 -10
- package/dist/agent-native.js +6 -0
- package/package.json +2 -2
- package/src/agent-durable.ts +211 -14
- package/src/agent-native.ts +5 -0
package/dist/agent-durable.d.ts
CHANGED
|
@@ -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: {
|
|
57
|
+
message: string;
|
|
58
|
+
};
|
|
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
|
@@ -40,6 +40,13 @@ var DoSessionStore = class {
|
|
|
40
40
|
setStatus(s) {
|
|
41
41
|
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
|
|
42
42
|
}
|
|
43
|
+
getSessionKey() {
|
|
44
|
+
const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
|
|
45
|
+
return rows.length ? rows[0].v : void 0;
|
|
46
|
+
}
|
|
47
|
+
setSessionKey(key) {
|
|
48
|
+
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
|
|
49
|
+
}
|
|
43
50
|
tx(fn) {
|
|
44
51
|
return this.storage.transactionSync(fn);
|
|
45
52
|
}
|
|
@@ -66,6 +73,13 @@ const crossDoUnsupported = { session() {
|
|
|
66
73
|
} };
|
|
67
74
|
var AgentDurableObject = class {
|
|
68
75
|
session;
|
|
76
|
+
sessionKey;
|
|
77
|
+
name;
|
|
78
|
+
store;
|
|
79
|
+
sink;
|
|
80
|
+
model;
|
|
81
|
+
tools;
|
|
82
|
+
channelInstructions;
|
|
69
83
|
resources;
|
|
70
84
|
services;
|
|
71
85
|
constructor(state, def) {
|
|
@@ -73,6 +87,26 @@ var AgentDurableObject = class {
|
|
|
73
87
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
74
88
|
this.resources = def.resources ?? {};
|
|
75
89
|
this.services = def.services;
|
|
90
|
+
const name = def.name ?? "agent";
|
|
91
|
+
const sink = new InProcEventSink();
|
|
92
|
+
sink.subscribe((e) => {
|
|
93
|
+
if (e.type !== "turn.failed") return;
|
|
94
|
+
const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed: ${e.error.message}`);
|
|
95
|
+
if (def.onTurnError) try {
|
|
96
|
+
const out = def.onTurnError({
|
|
97
|
+
turnId: e.turnId,
|
|
98
|
+
error: e.error
|
|
99
|
+
});
|
|
100
|
+
if (out && typeof out.then === "function") out.then(void 0, (hookErr) => {
|
|
101
|
+
console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
|
|
102
|
+
defaultLog();
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
} catch (hookErr) {
|
|
106
|
+
console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
|
|
107
|
+
}
|
|
108
|
+
defaultLog();
|
|
109
|
+
});
|
|
76
110
|
const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
|
|
77
111
|
const tools = [...def.tools];
|
|
78
112
|
const seen = new Set(tools.map((t) => t.spec.name));
|
|
@@ -81,29 +115,62 @@ var AgentDurableObject = class {
|
|
|
81
115
|
seen.add(t.spec.name);
|
|
82
116
|
tools.push(t);
|
|
83
117
|
}
|
|
84
|
-
this.
|
|
118
|
+
this.name = name;
|
|
119
|
+
this.store = store;
|
|
120
|
+
this.sink = sink;
|
|
121
|
+
this.model = model;
|
|
122
|
+
this.tools = tools;
|
|
123
|
+
this.channelInstructions = def.channelInstructions;
|
|
124
|
+
}
|
|
125
|
+
resolveSession(key) {
|
|
126
|
+
if (key !== void 0) assertSessionKey(key);
|
|
127
|
+
if (this.session) {
|
|
128
|
+
if (key !== void 0 && this.sessionKey !== key) throw new Error(this.sessionKey === "self" ? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)` : `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
|
|
129
|
+
return this.session;
|
|
130
|
+
}
|
|
131
|
+
const stored = this.store.getSessionKey();
|
|
132
|
+
if (key !== void 0 && stored !== void 0 && key !== stored) throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
|
|
133
|
+
const resolved = key ?? stored ?? "self";
|
|
134
|
+
if (key !== void 0 && key !== "self" && stored === void 0) this.store.setSessionKey(key);
|
|
135
|
+
this.sessionKey = resolved;
|
|
136
|
+
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
137
|
+
return this.session;
|
|
85
138
|
}
|
|
86
139
|
async turn(input) {
|
|
87
140
|
await ensureScope();
|
|
141
|
+
const session = this.resolveSession(input.session);
|
|
88
142
|
return runInScope({
|
|
89
143
|
resources: this.resources,
|
|
90
144
|
services: this.services
|
|
91
|
-
}, () =>
|
|
145
|
+
}, () => session.turn(input));
|
|
146
|
+
}
|
|
147
|
+
async start(input) {
|
|
148
|
+
await ensureScope();
|
|
149
|
+
const session = this.resolveSession(input.session);
|
|
150
|
+
return runInScope({
|
|
151
|
+
resources: this.resources,
|
|
152
|
+
services: this.services
|
|
153
|
+
}, () => session.start(input));
|
|
92
154
|
}
|
|
93
155
|
transcript() {
|
|
94
|
-
return this.session.transcript();
|
|
156
|
+
if (this.session) return this.session.transcript();
|
|
157
|
+
if (this.store.getSessionKey() !== void 0) return this.resolveSession().transcript();
|
|
158
|
+
return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
|
|
95
159
|
}
|
|
96
160
|
async fetch(req) {
|
|
97
161
|
const url = new URL(req.url);
|
|
162
|
+
const key = req.headers.get("x-june-session") ?? void 0;
|
|
98
163
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
99
164
|
const { userText, turnId, event, trigger } = await req.json();
|
|
100
165
|
await ensureScope();
|
|
166
|
+
let session;
|
|
101
167
|
let started;
|
|
102
168
|
try {
|
|
169
|
+
session = this.resolveSession(key);
|
|
103
170
|
started = runInScope({
|
|
104
171
|
resources: this.resources,
|
|
105
172
|
services: this.services
|
|
106
|
-
}, () =>
|
|
173
|
+
}, () => session.start({
|
|
107
174
|
userText,
|
|
108
175
|
turnId,
|
|
109
176
|
event,
|
|
@@ -112,23 +179,33 @@ var AgentDurableObject = class {
|
|
|
112
179
|
} catch (err) {
|
|
113
180
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
114
181
|
}
|
|
115
|
-
return
|
|
182
|
+
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
183
|
+
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
116
184
|
}
|
|
117
185
|
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
118
186
|
const { turnId, inputId, input, by } = await req.json();
|
|
119
187
|
await ensureScope();
|
|
188
|
+
let session;
|
|
120
189
|
try {
|
|
190
|
+
session = this.resolveSession(key);
|
|
121
191
|
runInScope({
|
|
122
192
|
resources: this.resources,
|
|
123
193
|
services: this.services
|
|
124
|
-
}, () =>
|
|
194
|
+
}, () => session.resume(turnId, inputId, input, { by }));
|
|
125
195
|
} catch (err) {
|
|
126
196
|
const message = err instanceof Error ? err.message : String(err);
|
|
127
197
|
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
128
198
|
}
|
|
129
|
-
return new Response(sseTurnStream(
|
|
199
|
+
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
200
|
+
}
|
|
201
|
+
if (url.pathname.endsWith("/transcript")) {
|
|
202
|
+
if (key !== void 0) try {
|
|
203
|
+
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
206
|
+
}
|
|
207
|
+
return Response.json({ transcript: this.transcript() });
|
|
130
208
|
}
|
|
131
|
-
if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
|
|
132
209
|
return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
|
|
133
210
|
}
|
|
134
211
|
};
|
|
@@ -212,8 +289,16 @@ async function* sseTurnEvents(res) {
|
|
|
212
289
|
if (done) break;
|
|
213
290
|
}
|
|
214
291
|
}
|
|
292
|
+
const SESSION_HEADER = "x-june-session";
|
|
293
|
+
const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
|
|
294
|
+
function assertSessionKey(session) {
|
|
295
|
+
if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
|
|
296
|
+
}
|
|
215
297
|
function durableFetch(namespace, agent, session, req) {
|
|
216
|
-
|
|
298
|
+
assertSessionKey(session);
|
|
299
|
+
const headers = new Headers(req.headers);
|
|
300
|
+
headers.set(SESSION_HEADER, session);
|
|
301
|
+
return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
|
|
217
302
|
}
|
|
218
303
|
function durableAgentSurface(getNamespace, opts) {
|
|
219
304
|
const chatPath = opts.chatPath ?? "/message";
|
|
@@ -223,6 +308,11 @@ function durableAgentSurface(getNamespace, opts) {
|
|
|
223
308
|
const namespace = getNamespace();
|
|
224
309
|
if (!namespace) return null;
|
|
225
310
|
const { message, session } = await req.json();
|
|
311
|
+
if (session !== void 0) try {
|
|
312
|
+
assertSessionKey(session);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
|
|
315
|
+
}
|
|
226
316
|
const res = await durableFetch(namespace, opts.agentName, session ?? "default", new Request("https://do/turn", {
|
|
227
317
|
method: "POST",
|
|
228
318
|
body: JSON.stringify({ userText: message })
|
|
@@ -251,6 +341,19 @@ function durableChannelSurface(getNamespace, opts) {
|
|
|
251
341
|
body: serializeTurn(message, o)
|
|
252
342
|
})));
|
|
253
343
|
},
|
|
344
|
+
runDetached: async (message, o) => {
|
|
345
|
+
const namespace = getNamespace();
|
|
346
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
347
|
+
const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?detach=1", {
|
|
348
|
+
method: "POST",
|
|
349
|
+
body: serializeTurn(message, o)
|
|
350
|
+
}));
|
|
351
|
+
if (res.status !== 202) {
|
|
352
|
+
const detail = (await res.text()).slice(0, 200);
|
|
353
|
+
throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
|
|
354
|
+
}
|
|
355
|
+
return await res.json();
|
|
356
|
+
},
|
|
254
357
|
runStream: async function* (message, o) {
|
|
255
358
|
const namespace = getNamespace();
|
|
256
359
|
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
@@ -302,4 +405,4 @@ function serializeTurn(userText, o) {
|
|
|
302
405
|
}
|
|
303
406
|
}
|
|
304
407
|
//#endregion
|
|
305
|
-
export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
|
|
408
|
+
export { AgentDurableObject, DoSessionStore, SESSION_HEADER, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
|
package/dist/agent-native.js
CHANGED
|
@@ -164,6 +164,12 @@ function mountAgent(agent, runtime, opts = {}) {
|
|
|
164
164
|
userText: message,
|
|
165
165
|
event: o?.event,
|
|
166
166
|
trigger: o?.trigger
|
|
167
|
+
}),
|
|
168
|
+
runDetached: async (message, o) => runtime.session(agent.name, o?.session ?? "default").start({
|
|
169
|
+
turnId: o?.turnId,
|
|
170
|
+
userText: message,
|
|
171
|
+
event: o?.event,
|
|
172
|
+
trigger: o?.trigger
|
|
167
173
|
})
|
|
168
174
|
};
|
|
169
175
|
const channels = channelFetch(agent, ctx);
|
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.7",
|
|
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,7 @@
|
|
|
125
125
|
"build": "tsdown"
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
-
"@junejs/core": ">=0.1.1-dev.
|
|
128
|
+
"@junejs/core": ">=0.1.1-dev.14",
|
|
129
129
|
"@junejs/db": ">=0.0.34-dev.0",
|
|
130
130
|
"@momiji-rs/sparkdown": "^0.0.4",
|
|
131
131
|
"lightningcss": "^1.27.0",
|
package/src/agent-durable.ts
CHANGED
|
@@ -117,6 +117,17 @@ export class DoSessionStore implements SessionStore {
|
|
|
117
117
|
setStatus(s: string) {
|
|
118
118
|
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
|
|
119
119
|
}
|
|
120
|
+
// The externally-assigned session key (#75). A DO cannot read its own idFromName
|
|
121
|
+
// name, so the key arrives on the first routed request and is persisted here —
|
|
122
|
+
// surviving hibernation/eviction, where the constructor re-runs with no request
|
|
123
|
+
// in hand. Not part of core's SessionStore contract: only the DO seam needs it.
|
|
124
|
+
getSessionKey(): string | undefined {
|
|
125
|
+
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
|
|
126
|
+
return rows.length ? rows[0]!.v : undefined;
|
|
127
|
+
}
|
|
128
|
+
setSessionKey(key: string) {
|
|
129
|
+
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
|
|
130
|
+
}
|
|
120
131
|
tx<T>(fn: () => T): T {
|
|
121
132
|
return this.storage.transactionSync(fn);
|
|
122
133
|
}
|
|
@@ -171,6 +182,14 @@ export type DoAgentDef = {
|
|
|
171
182
|
env?: unknown;
|
|
172
183
|
resources?: Resources;
|
|
173
184
|
services?: unknown;
|
|
185
|
+
// Called on every turn.failed for this session — the app's seam for routing turn
|
|
186
|
+
// failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
|
|
187
|
+
// default console.error; if the hook throws (or an async hook rejects), the default
|
|
188
|
+
// log fires anyway (both errors), so a failure is never silent. It happens to run
|
|
189
|
+
// inside the failed turn's request scope today (turn.failed is emitted mid-turn and
|
|
190
|
+
// ALS propagates), but that is NOT contract — don't rely on ambient db/services;
|
|
191
|
+
// close over what you need.
|
|
192
|
+
onTurnError?: (failure: { turnId: string; error: { message: string } }) => void | Promise<void>;
|
|
174
193
|
};
|
|
175
194
|
|
|
176
195
|
// The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
|
|
@@ -188,7 +207,20 @@ export type DoAgentDef = {
|
|
|
188
207
|
// fetch(req) { return this.agent.fetch(req); }
|
|
189
208
|
// }
|
|
190
209
|
export class AgentDurableObject {
|
|
191
|
-
|
|
210
|
+
// The AgentSession is constructed LAZILY (#75): the DO is keyed externally by
|
|
211
|
+
// idFromName(`${agent}:${session}`) but cannot read its own name, so the true
|
|
212
|
+
// session key only exists on a routed request (the SESSION_HEADER durableFetch
|
|
213
|
+
// sets). First keyed request wins and persists the key (agent_meta) so it
|
|
214
|
+
// survives hibernation; key-less paths fall back to the persisted key, then
|
|
215
|
+
// "self" (the pre-#75 behavior, kept for backward compatibility).
|
|
216
|
+
private session: AgentSession | undefined;
|
|
217
|
+
private sessionKey: string | undefined;
|
|
218
|
+
private readonly name: string;
|
|
219
|
+
private readonly store: DoSessionStore;
|
|
220
|
+
private readonly sink: InProcEventSink;
|
|
221
|
+
private readonly model: Model;
|
|
222
|
+
private readonly tools: Tool[];
|
|
223
|
+
private readonly channelInstructions?: Record<string, string>;
|
|
192
224
|
// Built from the DO's env in the constructor (this isolate), shared across turns —
|
|
193
225
|
// env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
|
|
194
226
|
private readonly resources: Resources;
|
|
@@ -198,6 +230,40 @@ export class AgentDurableObject {
|
|
|
198
230
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
199
231
|
this.resources = def.resources ?? {};
|
|
200
232
|
this.services = def.services;
|
|
233
|
+
const name = def.name ?? "agent";
|
|
234
|
+
// Failure observability (#76): a turn that dies after the fast-ACK has no other
|
|
235
|
+
// observable surface on the edge — the webhook already 200'd and runBackground
|
|
236
|
+
// swallows the rejection unless the channel wired onError. Subscribing HERE (the
|
|
237
|
+
// session sink) covers every turn path — turn(), /turn, /resume — with one seam.
|
|
238
|
+
// Default: console.error, which wrangler tail surfaces. An app onTurnError takes
|
|
239
|
+
// over reporting; if IT throws, fall back to the default so nothing goes silent.
|
|
240
|
+
// (InProcEventSink already guards emit against a throwing subscriber.)
|
|
241
|
+
const sink = new InProcEventSink();
|
|
242
|
+
sink.subscribe((e) => {
|
|
243
|
+
if (e.type !== "turn.failed") return;
|
|
244
|
+
const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed: ${e.error.message}`);
|
|
245
|
+
if (def.onTurnError) {
|
|
246
|
+
try {
|
|
247
|
+
const out = def.onTurnError({ turnId: e.turnId, error: e.error });
|
|
248
|
+
// An async hook (e.g. `await sendToSentry(...)`) types as void but returns a
|
|
249
|
+
// Promise — a rejection there must hit the same fallback as a sync throw, or
|
|
250
|
+
// the failure goes silent again (plus an unhandled rejection). No waitUntil
|
|
251
|
+
// keeper is needed here: unlike a Worker, a DO stays active while it has
|
|
252
|
+
// pending work/IO, and DurableObjectState.waitUntil is documented as a no-op —
|
|
253
|
+
// this .then subscription is what retains the promise.
|
|
254
|
+
if (out && typeof (out as Promise<void>).then === "function") {
|
|
255
|
+
(out as Promise<void>).then(undefined, (hookErr) => {
|
|
256
|
+
console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
|
|
257
|
+
defaultLog();
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
} catch (hookErr) {
|
|
262
|
+
console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
defaultLog();
|
|
266
|
+
});
|
|
201
267
|
// Merge the mounted channels' capability tools (built here from this DO's env, since a
|
|
202
268
|
// tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
|
|
203
269
|
// keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
|
|
@@ -209,7 +275,49 @@ export class AgentDurableObject {
|
|
|
209
275
|
seen.add(t.spec.name);
|
|
210
276
|
tools.push(t);
|
|
211
277
|
}
|
|
212
|
-
this.
|
|
278
|
+
this.name = name;
|
|
279
|
+
this.store = store;
|
|
280
|
+
this.sink = sink;
|
|
281
|
+
this.model = model;
|
|
282
|
+
this.tools = tools;
|
|
283
|
+
this.channelInstructions = def.channelInstructions;
|
|
284
|
+
}
|
|
285
|
+
// Resolve THE session for this DO: explicit key (from a routed request) → persisted
|
|
286
|
+
// key (a prior life learned it) → "self". A key that contradicts the persisted or
|
|
287
|
+
// live identity is a mis-route (or a key-less path was used first) — fail loudly:
|
|
288
|
+
// silently proceeding is exactly the per-conversation data corruption #75 hit.
|
|
289
|
+
private resolveSession(key?: string): AgentSession {
|
|
290
|
+
// Every entry point shares the session-key contract (turn({ session }) and hand-rolled
|
|
291
|
+
// SESSION_HEADER values included, not just durableFetch): an invalid key persisted here
|
|
292
|
+
// would bind an identity no routed request could ever address — an orphaned session.
|
|
293
|
+
if (key !== undefined) assertSessionKey(key);
|
|
294
|
+
// Live fast path — no storage read once the session exists: this method is the only
|
|
295
|
+
// writer of the persisted key, so within one life it cannot diverge from the live one.
|
|
296
|
+
if (this.session) {
|
|
297
|
+
if (key !== undefined && this.sessionKey !== key) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
this.sessionKey === "self"
|
|
300
|
+
? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)`
|
|
301
|
+
: `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return this.session;
|
|
305
|
+
}
|
|
306
|
+
const stored = this.store.getSessionKey();
|
|
307
|
+
if (key !== undefined && stored !== undefined && key !== stored) {
|
|
308
|
+
throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
|
|
309
|
+
}
|
|
310
|
+
const resolved = key ?? stored ?? "self";
|
|
311
|
+
// "self" is deliberately NOT persisted — even when a caller passes it EXPLICITLY: it
|
|
312
|
+
// is a placeholder ("no identity externally assigned yet"), not an identity. Within
|
|
313
|
+
// one life a keyed request after a "self" binding is refused above — two concurrently
|
|
314
|
+
// active paths disagreeing is a live bug. But across eviction a keyed request ADOPTS
|
|
315
|
+
// the legacy transcript: that asymmetry IS the migration path from pre-keyed
|
|
316
|
+
// deployments, where a persisted "self" would 409 every keyed request forever.
|
|
317
|
+
if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
|
|
318
|
+
this.sessionKey = resolved;
|
|
319
|
+
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
320
|
+
return this.session;
|
|
213
321
|
}
|
|
214
322
|
// Run the whole turn inside a request scope seeded from this DO's env, so ambient
|
|
215
323
|
// `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
|
|
@@ -218,32 +326,59 @@ export class AgentDurableObject {
|
|
|
218
326
|
// Juno's batch-loader registry) can't leak across turns on a long-lived DO.
|
|
219
327
|
// ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
|
|
220
328
|
// 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> {
|
|
329
|
+
async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
|
|
222
330
|
await ensureScope();
|
|
223
|
-
|
|
331
|
+
const session = this.resolveSession(input.session);
|
|
332
|
+
return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
|
|
224
333
|
}
|
|
334
|
+
// Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
|
|
335
|
+
// it then runs under the DO's own lifetime (a DO stays alive while it has pending
|
|
336
|
+
// work), not the caller's. No live consumer sees the result — failures surface via
|
|
337
|
+
// the default turn-failure log / onTurnError (#76).
|
|
338
|
+
async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
|
|
339
|
+
await ensureScope();
|
|
340
|
+
const session = this.resolveSession(input.session);
|
|
341
|
+
return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
|
|
342
|
+
}
|
|
343
|
+
// Read-only: folds the durable log. When an identity exists (live, or persisted from a
|
|
344
|
+
// prior life) the session resolves and caches like any other path. Only a read on a
|
|
345
|
+
// NEVER-keyed object stays non-committal — caching there would bake "self" in as the
|
|
346
|
+
// identity and 409 a later keyed turn — so it folds through an ephemeral AgentSession.
|
|
225
347
|
transcript() {
|
|
226
|
-
return this.session.transcript();
|
|
348
|
+
if (this.session) return this.session.transcript();
|
|
349
|
+
if (this.store.getSessionKey() !== undefined) return this.resolveSession().transcript();
|
|
350
|
+
return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
|
|
227
351
|
}
|
|
228
352
|
// Default HTTP surface: POST …/turn STREAMS the turn's TurnEvents as SSE (start the
|
|
229
353
|
// turn in-scope, then stream from the session sink); GET …/transcript reads the log.
|
|
230
354
|
// The app can call turn()/transcript() directly instead (turn() awaits the final text).
|
|
231
355
|
async fetch(req: Request): Promise<Response> {
|
|
232
356
|
const url = new URL(req.url);
|
|
357
|
+
// The session key durableFetch stamped on the routed request (#75); absent on a
|
|
358
|
+
// hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
|
|
359
|
+
const key = req.headers.get(SESSION_HEADER) ?? undefined;
|
|
233
360
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
234
361
|
const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
|
|
235
362
|
await ensureScope();
|
|
236
363
|
// start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
|
|
237
364
|
// db/services (ALS propagates to the .then continuation registered here); subscribing
|
|
238
365
|
// happens synchronously right after, before any event can emit.
|
|
366
|
+
let session: AgentSession;
|
|
239
367
|
let started: { turnId: string };
|
|
240
368
|
try {
|
|
241
|
-
|
|
369
|
+
session = this.resolveSession(key);
|
|
370
|
+
started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
|
|
242
371
|
} catch (err) {
|
|
243
|
-
// e.g. the session is suspended awaiting input
|
|
372
|
+
// e.g. the session is suspended awaiting input, or the key mis-matches this
|
|
373
|
+
// object's identity — a client-resolvable conflict, not a crash
|
|
244
374
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
245
375
|
}
|
|
246
|
-
|
|
376
|
+
// DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
|
|
377
|
+
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
378
|
+
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
379
|
+
// ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
|
|
380
|
+
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
381
|
+
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
247
382
|
}
|
|
248
383
|
// Provide the input a suspended turn is waiting on and stream its continuation as SSE.
|
|
249
384
|
// NOTE: this default surface passes `by` straight from the body — the engine treats it as a
|
|
@@ -252,16 +387,29 @@ export class AgentDurableObject {
|
|
|
252
387
|
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
253
388
|
const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
|
|
254
389
|
await ensureScope();
|
|
390
|
+
let session: AgentSession;
|
|
255
391
|
try {
|
|
256
|
-
|
|
392
|
+
session = this.resolveSession(key);
|
|
393
|
+
runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
|
|
257
394
|
} catch (err) {
|
|
258
395
|
const message = err instanceof Error ? err.message : String(err);
|
|
259
|
-
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id
|
|
396
|
+
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
|
|
260
397
|
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
261
398
|
}
|
|
262
|
-
return new Response(sseTurnStream(
|
|
399
|
+
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
400
|
+
}
|
|
401
|
+
if (url.pathname.endsWith("/transcript")) {
|
|
402
|
+
// A keyed read still conflict-checks (and may commit the key — it is authentic,
|
|
403
|
+
// idFromName routed it here); a key-less read stays non-committal via transcript().
|
|
404
|
+
if (key !== undefined) {
|
|
405
|
+
try {
|
|
406
|
+
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
407
|
+
} catch (err) {
|
|
408
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return Response.json({ transcript: this.transcript() });
|
|
263
412
|
}
|
|
264
|
-
if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
|
|
265
413
|
return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
|
|
266
414
|
}
|
|
267
415
|
}
|
|
@@ -352,10 +500,31 @@ export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
|
|
|
352
500
|
}
|
|
353
501
|
}
|
|
354
502
|
|
|
503
|
+
// The header durableFetch stamps the session key on (#75). A DO cannot read its own
|
|
504
|
+
// idFromName name, so this is how the externally-assigned session identity crosses
|
|
505
|
+
// into the object — exported so a custom shell that bypasses durableFetch can set it.
|
|
506
|
+
export const SESSION_HEADER = "x-june-session";
|
|
507
|
+
|
|
508
|
+
// A session key rides an HTTP header (SESSION_HEADER) and the DO name (idFromName) —
|
|
509
|
+
// reject values that can't: CR/LF would be header injection, and Headers.set throws a
|
|
510
|
+
// bare TypeError on control/non-ASCII bytes, turning a bad client value into an opaque
|
|
511
|
+
// 500 deep inside durableFetch. Non-empty printable ASCII is the contract.
|
|
512
|
+
const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
|
|
513
|
+
function assertSessionKey(session: string) {
|
|
514
|
+
if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) {
|
|
515
|
+
throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
355
519
|
// 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.
|
|
520
|
+
// request to it. `env.AGENT` is the DO namespace binding. The session key rides a
|
|
521
|
+
// header (not the body): one seam covers /turn, /resume, and /transcript without
|
|
522
|
+
// touching any body contract, and an older DO simply ignores it.
|
|
357
523
|
export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
|
|
358
|
-
|
|
524
|
+
assertSessionKey(session);
|
|
525
|
+
const headers = new Headers(req.headers);
|
|
526
|
+
headers.set(SESSION_HEADER, session);
|
|
527
|
+
return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
|
|
359
528
|
}
|
|
360
529
|
|
|
361
530
|
// The EDGE agent surface for June's router: route the chat endpoint to the
|
|
@@ -377,6 +546,15 @@ export function durableAgentSurface(
|
|
|
377
546
|
const namespace = getNamespace();
|
|
378
547
|
if (!namespace) return null; // no DO binding → not mounted here
|
|
379
548
|
const { message, session } = (await req.json()) as { message: string; session?: string };
|
|
549
|
+
// `session` is CLIENT input here — an un-headerable value must be a clear 400, not
|
|
550
|
+
// the opaque 500 assertSessionKey would become this deep in the worker.
|
|
551
|
+
if (session !== undefined) {
|
|
552
|
+
try {
|
|
553
|
+
assertSessionKey(session);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
380
558
|
// Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
|
|
381
559
|
// that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
|
|
382
560
|
// through; otherwise collapse to the final { text } (the prior JSON contract).
|
|
@@ -450,6 +628,25 @@ export function durableChannelSurface(
|
|
|
450
628
|
// /turn streams SSE; the simple path collapses it to the final text.
|
|
451
629
|
return sseTurnFinalText(res);
|
|
452
630
|
},
|
|
631
|
+
// FIRE-AND-FORGET (#77): POST /turn?detach=1 — the DO 202s once the turn is accepted
|
|
632
|
+
// and runs it under its OWN lifetime. This is how a shadow/observe hook (onEvent →
|
|
633
|
+
// assessment turn, reply dropped) escapes the edge waitUntil ceiling that killed
|
|
634
|
+
// 24–38s turns in production: nothing holds the edge open while the turn runs.
|
|
635
|
+
runDetached: async (message, o) => {
|
|
636
|
+
const namespace = getNamespace();
|
|
637
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
638
|
+
const res = await durableFetch(
|
|
639
|
+
namespace,
|
|
640
|
+
opts.agentName,
|
|
641
|
+
o?.session ?? "default",
|
|
642
|
+
new Request("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
|
|
643
|
+
);
|
|
644
|
+
if (res.status !== 202) {
|
|
645
|
+
const detail = (await res.text()).slice(0, 200);
|
|
646
|
+
throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
|
|
647
|
+
}
|
|
648
|
+
return (await res.json()) as { turnId: string };
|
|
649
|
+
},
|
|
453
650
|
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
454
651
|
runStream: async function* (message, o) {
|
|
455
652
|
const namespace = getNamespace();
|
package/src/agent-native.ts
CHANGED
|
@@ -214,6 +214,11 @@ export function mountAgent(
|
|
|
214
214
|
services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
|
|
215
215
|
run: (message, o) =>
|
|
216
216
|
runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
|
|
217
|
+
// FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
|
|
218
|
+
// ceiling (Node keeps floating promises alive), but the seam is the same so a channel
|
|
219
|
+
// written against ctx.runDetached behaves identically on both targets.
|
|
220
|
+
runDetached: async (message, o) =>
|
|
221
|
+
runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
|
|
217
222
|
};
|
|
218
223
|
const channels = channelFetch(agent, ctx);
|
|
219
224
|
const surface = async (req: Request): Promise<Response | null> => {
|