@junejs/server 0.1.2-dev.3 → 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 +32 -3
- package/dist/agent-durable.js +169 -22
- package/dist/agent-native.js +18 -5
- package/package.json +2 -2
- package/src/agent-durable.ts +269 -17
- package/src/agent-native.ts +13 -4
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, SessionStore, Tool, TurnEvent } from "@junejs/core/agent-runtime";
|
|
2
|
+
import { InboundEvent, Model, Msg, ProactiveTrigger, SessionStore, Tool, TurnEvent } from "@junejs/core/agent-runtime";
|
|
3
3
|
import { Resources } from "@junejs/core/resources";
|
|
4
4
|
//#region src/agent-durable.d.ts
|
|
5
5
|
type SqlStorageValue = ArrayBuffer | string | number | null;
|
|
@@ -30,11 +30,14 @@ declare class DoSessionStore implements SessionStore {
|
|
|
30
30
|
private get sql();
|
|
31
31
|
appendMessage(m: Msg): void;
|
|
32
32
|
messages(): Msg[];
|
|
33
|
-
|
|
33
|
+
hasOpeningMessage(turnId: string): boolean;
|
|
34
34
|
getStep(id: string): unknown | undefined;
|
|
35
35
|
putStep(id: string, output: unknown): void;
|
|
36
|
+
delStep(id: string): void;
|
|
36
37
|
getStatus(): string;
|
|
37
38
|
setStatus(s: string): void;
|
|
39
|
+
getSessionKey(): string | undefined;
|
|
40
|
+
setSessionKey(key: string): void;
|
|
38
41
|
tx<T>(fn: () => T): T;
|
|
39
42
|
unwrap<H = unknown>(): H;
|
|
40
43
|
}
|
|
@@ -48,22 +51,48 @@ type DoAgentDef = {
|
|
|
48
51
|
env?: unknown;
|
|
49
52
|
resources?: Resources;
|
|
50
53
|
services?: unknown;
|
|
54
|
+
onTurnError?: (failure: {
|
|
55
|
+
turnId: string;
|
|
56
|
+
error: {
|
|
57
|
+
message: string;
|
|
58
|
+
};
|
|
59
|
+
}) => void | Promise<void>;
|
|
51
60
|
};
|
|
52
61
|
declare class AgentDurableObject {
|
|
53
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?;
|
|
54
70
|
private readonly resources;
|
|
55
71
|
private readonly services;
|
|
56
72
|
constructor(state: DurableObjectState, def: DoAgentDef);
|
|
73
|
+
private resolveSession;
|
|
57
74
|
turn(input: {
|
|
58
75
|
turnId?: string;
|
|
59
76
|
userText: string;
|
|
60
77
|
event?: InboundEvent;
|
|
78
|
+
trigger?: ProactiveTrigger;
|
|
79
|
+
session?: string;
|
|
61
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
|
+
}>;
|
|
62
90
|
transcript(): import("@junejs/core/agent-runtime").Turn[];
|
|
63
91
|
fetch(req: Request): Promise<Response>;
|
|
64
92
|
}
|
|
65
93
|
declare function sseTurnFinalText(res: Response): Promise<string>;
|
|
66
94
|
declare function sseTurnEvents(res: Response): AsyncIterable<TurnEvent>;
|
|
95
|
+
declare const SESSION_HEADER = "x-june-session";
|
|
67
96
|
declare function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response>;
|
|
68
97
|
declare function durableAgentSurface(getNamespace: () => DurableObjectNamespace | undefined, opts: {
|
|
69
98
|
agentName: string;
|
|
@@ -77,4 +106,4 @@ declare function durableChannelSurface(getNamespace: () => DurableObjectNamespac
|
|
|
77
106
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
78
107
|
}): (req: Request) => Promise<Response | null>;
|
|
79
108
|
//#endregion
|
|
80
|
-
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,5 +1,5 @@
|
|
|
1
1
|
import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
|
|
2
|
-
import { AgentSession, withSystem } from "@junejs/core/agent-runtime";
|
|
2
|
+
import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
|
|
3
3
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
4
4
|
//#region src/agent-durable.ts
|
|
5
5
|
var DoSessionStore = class {
|
|
@@ -20,8 +20,8 @@ var DoSessionStore = class {
|
|
|
20
20
|
messages() {
|
|
21
21
|
return this.sql.exec("SELECT body FROM agent_messages ORDER BY seq").toArray().map((r) => JSON.parse(r.body));
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
|
|
23
|
+
hasOpeningMessage(turnId) {
|
|
24
|
+
return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
|
|
25
25
|
}
|
|
26
26
|
getStep(id) {
|
|
27
27
|
const rows = this.sql.exec("SELECT output FROM agent_steps WHERE id = ?", id).toArray();
|
|
@@ -30,6 +30,9 @@ var DoSessionStore = class {
|
|
|
30
30
|
putStep(id, output) {
|
|
31
31
|
this.sql.exec("INSERT INTO agent_steps (id, output) VALUES (?, ?)", id, JSON.stringify(output));
|
|
32
32
|
}
|
|
33
|
+
delStep(id) {
|
|
34
|
+
this.sql.exec("DELETE FROM agent_steps WHERE id = ?", id);
|
|
35
|
+
}
|
|
33
36
|
getStatus() {
|
|
34
37
|
const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'status'").toArray();
|
|
35
38
|
return rows.length ? rows[0].v : "new";
|
|
@@ -37,6 +40,13 @@ var DoSessionStore = class {
|
|
|
37
40
|
setStatus(s) {
|
|
38
41
|
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
|
|
39
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
|
+
}
|
|
40
50
|
tx(fn) {
|
|
41
51
|
return this.storage.transactionSync(fn);
|
|
42
52
|
}
|
|
@@ -63,6 +73,13 @@ const crossDoUnsupported = { session() {
|
|
|
63
73
|
} };
|
|
64
74
|
var AgentDurableObject = class {
|
|
65
75
|
session;
|
|
76
|
+
sessionKey;
|
|
77
|
+
name;
|
|
78
|
+
store;
|
|
79
|
+
sink;
|
|
80
|
+
model;
|
|
81
|
+
tools;
|
|
82
|
+
channelInstructions;
|
|
66
83
|
resources;
|
|
67
84
|
services;
|
|
68
85
|
constructor(state, def) {
|
|
@@ -70,6 +87,26 @@ var AgentDurableObject = class {
|
|
|
70
87
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
71
88
|
this.resources = def.resources ?? {};
|
|
72
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
|
+
});
|
|
73
110
|
const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
|
|
74
111
|
const tools = [...def.tools];
|
|
75
112
|
const seen = new Set(tools.map((t) => t.spec.name));
|
|
@@ -78,35 +115,98 @@ var AgentDurableObject = class {
|
|
|
78
115
|
seen.add(t.spec.name);
|
|
79
116
|
tools.push(t);
|
|
80
117
|
}
|
|
81
|
-
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;
|
|
82
138
|
}
|
|
83
139
|
async turn(input) {
|
|
84
140
|
await ensureScope();
|
|
141
|
+
const session = this.resolveSession(input.session);
|
|
142
|
+
return runInScope({
|
|
143
|
+
resources: this.resources,
|
|
144
|
+
services: this.services
|
|
145
|
+
}, () => session.turn(input));
|
|
146
|
+
}
|
|
147
|
+
async start(input) {
|
|
148
|
+
await ensureScope();
|
|
149
|
+
const session = this.resolveSession(input.session);
|
|
85
150
|
return runInScope({
|
|
86
151
|
resources: this.resources,
|
|
87
152
|
services: this.services
|
|
88
|
-
}, () =>
|
|
153
|
+
}, () => session.start(input));
|
|
89
154
|
}
|
|
90
155
|
transcript() {
|
|
91
|
-
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();
|
|
92
159
|
}
|
|
93
160
|
async fetch(req) {
|
|
94
161
|
const url = new URL(req.url);
|
|
162
|
+
const key = req.headers.get("x-june-session") ?? void 0;
|
|
95
163
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
96
|
-
const { userText, turnId, event } = await req.json();
|
|
164
|
+
const { userText, turnId, event, trigger } = await req.json();
|
|
97
165
|
await ensureScope();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
166
|
+
let session;
|
|
167
|
+
let started;
|
|
168
|
+
try {
|
|
169
|
+
session = this.resolveSession(key);
|
|
170
|
+
started = runInScope({
|
|
171
|
+
resources: this.resources,
|
|
172
|
+
services: this.services
|
|
173
|
+
}, () => session.start({
|
|
174
|
+
userText,
|
|
175
|
+
turnId,
|
|
176
|
+
event,
|
|
177
|
+
trigger
|
|
178
|
+
}));
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
181
|
+
}
|
|
182
|
+
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
183
|
+
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
107
184
|
}
|
|
108
|
-
if (url.pathname.endsWith("/
|
|
109
|
-
|
|
185
|
+
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
186
|
+
const { turnId, inputId, input, by } = await req.json();
|
|
187
|
+
await ensureScope();
|
|
188
|
+
let session;
|
|
189
|
+
try {
|
|
190
|
+
session = this.resolveSession(key);
|
|
191
|
+
runInScope({
|
|
192
|
+
resources: this.resources,
|
|
193
|
+
services: this.services
|
|
194
|
+
}, () => session.resume(turnId, inputId, input, { by }));
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
197
|
+
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
198
|
+
}
|
|
199
|
+
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
200
|
+
}
|
|
201
|
+
if (url.pathname.endsWith("/transcript")) {
|
|
202
|
+
if (key !== void 0) try {
|
|
203
|
+
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
206
|
+
}
|
|
207
|
+
return Response.json({ transcript: this.transcript() });
|
|
208
|
+
}
|
|
209
|
+
return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
|
|
110
210
|
}
|
|
111
211
|
};
|
|
112
212
|
const SSE_HEADERS = {
|
|
@@ -132,7 +232,7 @@ function sseTurnStream(session, turnId) {
|
|
|
132
232
|
}, 2e4);
|
|
133
233
|
unsub = session.observe((e) => {
|
|
134
234
|
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
135
|
-
if (e.type === "turn.completed" || e.type === "turn.failed") {
|
|
235
|
+
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") {
|
|
136
236
|
stop();
|
|
137
237
|
controller.close();
|
|
138
238
|
}
|
|
@@ -189,8 +289,16 @@ async function* sseTurnEvents(res) {
|
|
|
189
289
|
if (done) break;
|
|
190
290
|
}
|
|
191
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
|
+
}
|
|
192
297
|
function durableFetch(namespace, agent, session, req) {
|
|
193
|
-
|
|
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 }));
|
|
194
302
|
}
|
|
195
303
|
function durableAgentSurface(getNamespace, opts) {
|
|
196
304
|
const chatPath = opts.chatPath ?? "/message";
|
|
@@ -200,6 +308,11 @@ function durableAgentSurface(getNamespace, opts) {
|
|
|
200
308
|
const namespace = getNamespace();
|
|
201
309
|
if (!namespace) return null;
|
|
202
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
|
+
}
|
|
203
316
|
const res = await durableFetch(namespace, opts.agentName, session ?? "default", new Request("https://do/turn", {
|
|
204
317
|
method: "POST",
|
|
205
318
|
body: JSON.stringify({ userText: message })
|
|
@@ -228,6 +341,19 @@ function durableChannelSurface(getNamespace, opts) {
|
|
|
228
341
|
body: serializeTurn(message, o)
|
|
229
342
|
})));
|
|
230
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
|
+
},
|
|
231
357
|
runStream: async function* (message, o) {
|
|
232
358
|
const namespace = getNamespace();
|
|
233
359
|
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
@@ -235,14 +361,35 @@ function durableChannelSurface(getNamespace, opts) {
|
|
|
235
361
|
method: "POST",
|
|
236
362
|
body: serializeTurn(message, o)
|
|
237
363
|
})));
|
|
364
|
+
},
|
|
365
|
+
resumeStream: async function* (o) {
|
|
366
|
+
const namespace = getNamespace();
|
|
367
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
368
|
+
yield* sseTurnEvents(await durableFetch(namespace, opts.agentName, o.session ?? "default", new Request("https://do/resume", {
|
|
369
|
+
method: "POST",
|
|
370
|
+
body: serializeResume(o)
|
|
371
|
+
})));
|
|
238
372
|
}
|
|
239
373
|
});
|
|
240
374
|
}
|
|
375
|
+
function serializeResume(o) {
|
|
376
|
+
try {
|
|
377
|
+
return JSON.stringify({
|
|
378
|
+
turnId: o.turnId,
|
|
379
|
+
inputId: o.inputId,
|
|
380
|
+
input: o.input,
|
|
381
|
+
by: o.by
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
throw new Error(`resumeStream: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
241
387
|
function serializeTurn(userText, o) {
|
|
242
388
|
const payload = {
|
|
243
389
|
userText,
|
|
244
390
|
turnId: o?.turnId,
|
|
245
|
-
event: o?.event
|
|
391
|
+
event: o?.event,
|
|
392
|
+
trigger: o?.trigger
|
|
246
393
|
};
|
|
247
394
|
try {
|
|
248
395
|
return JSON.stringify(payload);
|
|
@@ -258,4 +405,4 @@ function serializeTurn(userText, o) {
|
|
|
258
405
|
}
|
|
259
406
|
}
|
|
260
407
|
//#endregion
|
|
261
|
-
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
|
@@ -20,8 +20,8 @@ var SqliteSessionStore = class {
|
|
|
20
20
|
messages() {
|
|
21
21
|
return this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid).map((r) => JSON.parse(r.body));
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
|
|
23
|
+
hasOpeningMessage(turnId) {
|
|
24
|
+
return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
|
|
25
25
|
}
|
|
26
26
|
getStep(id) {
|
|
27
27
|
const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id);
|
|
@@ -30,6 +30,9 @@ var SqliteSessionStore = class {
|
|
|
30
30
|
putStep(id, output) {
|
|
31
31
|
this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
|
|
32
32
|
}
|
|
33
|
+
delStep(id) {
|
|
34
|
+
this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
|
|
35
|
+
}
|
|
33
36
|
getStatus() {
|
|
34
37
|
return this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid)?.status ?? "new";
|
|
35
38
|
}
|
|
@@ -100,8 +103,8 @@ var MemorySessionStore = class {
|
|
|
100
103
|
messages() {
|
|
101
104
|
return this.msgs.slice();
|
|
102
105
|
}
|
|
103
|
-
|
|
104
|
-
return this.msgs.some((m) => m.role === "user" && m.turnId === turnId);
|
|
106
|
+
hasOpeningMessage(turnId) {
|
|
107
|
+
return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
|
|
105
108
|
}
|
|
106
109
|
getStep(id) {
|
|
107
110
|
return this.steps.has(id) ? this.steps.get(id) : void 0;
|
|
@@ -109,6 +112,9 @@ var MemorySessionStore = class {
|
|
|
109
112
|
putStep(id, output) {
|
|
110
113
|
this.steps.set(id, output);
|
|
111
114
|
}
|
|
115
|
+
delStep(id) {
|
|
116
|
+
this.steps.delete(id);
|
|
117
|
+
}
|
|
112
118
|
getStatus() {
|
|
113
119
|
return this.status;
|
|
114
120
|
}
|
|
@@ -156,7 +162,14 @@ function mountAgent(agent, runtime, opts = {}) {
|
|
|
156
162
|
run: (message, o) => runtime.session(agent.name, o?.session ?? "default").turn({
|
|
157
163
|
turnId: o?.turnId,
|
|
158
164
|
userText: message,
|
|
159
|
-
event: o?.event
|
|
165
|
+
event: o?.event,
|
|
166
|
+
trigger: o?.trigger
|
|
167
|
+
}),
|
|
168
|
+
runDetached: async (message, o) => runtime.session(agent.name, o?.session ?? "default").start({
|
|
169
|
+
turnId: o?.turnId,
|
|
170
|
+
userText: message,
|
|
171
|
+
event: o?.event,
|
|
172
|
+
trigger: o?.trigger
|
|
160
173
|
})
|
|
161
174
|
};
|
|
162
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
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
AgentSession,
|
|
20
|
+
ResumeAuthorizationError,
|
|
20
21
|
withSystem,
|
|
21
22
|
type EventSink,
|
|
22
23
|
type TurnEvent,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
type Runtime,
|
|
26
27
|
type InboundEvent,
|
|
27
28
|
type SessionStore,
|
|
29
|
+
type ProactiveTrigger,
|
|
28
30
|
type Tool,
|
|
29
31
|
} from "@junejs/core/agent-runtime";
|
|
30
32
|
import type { Resources } from "@junejs/core/resources";
|
|
@@ -95,8 +97,8 @@ export class DoSessionStore implements SessionStore {
|
|
|
95
97
|
messages(): Msg[] {
|
|
96
98
|
return this.sql.exec<{ body: string }>("SELECT body FROM agent_messages ORDER BY seq").toArray().map((r) => JSON.parse(r.body));
|
|
97
99
|
}
|
|
98
|
-
|
|
99
|
-
return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
|
|
100
|
+
hasOpeningMessage(turnId: string): boolean {
|
|
101
|
+
return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
|
|
100
102
|
}
|
|
101
103
|
getStep(id: string): unknown | undefined {
|
|
102
104
|
const rows = this.sql.exec<{ output: string }>("SELECT output FROM agent_steps WHERE id = ?", id).toArray();
|
|
@@ -105,6 +107,9 @@ export class DoSessionStore implements SessionStore {
|
|
|
105
107
|
putStep(id: string, output: unknown) {
|
|
106
108
|
this.sql.exec("INSERT INTO agent_steps (id, output) VALUES (?, ?)", id, JSON.stringify(output));
|
|
107
109
|
}
|
|
110
|
+
delStep(id: string) {
|
|
111
|
+
this.sql.exec("DELETE FROM agent_steps WHERE id = ?", id);
|
|
112
|
+
}
|
|
108
113
|
getStatus(): string {
|
|
109
114
|
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'status'").toArray();
|
|
110
115
|
return rows.length ? rows[0]!.v : "new";
|
|
@@ -112,6 +117,17 @@ export class DoSessionStore implements SessionStore {
|
|
|
112
117
|
setStatus(s: string) {
|
|
113
118
|
this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
|
|
114
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
|
+
}
|
|
115
131
|
tx<T>(fn: () => T): T {
|
|
116
132
|
return this.storage.transactionSync(fn);
|
|
117
133
|
}
|
|
@@ -166,6 +182,14 @@ export type DoAgentDef = {
|
|
|
166
182
|
env?: unknown;
|
|
167
183
|
resources?: Resources;
|
|
168
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>;
|
|
169
193
|
};
|
|
170
194
|
|
|
171
195
|
// The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
|
|
@@ -183,7 +207,20 @@ export type DoAgentDef = {
|
|
|
183
207
|
// fetch(req) { return this.agent.fetch(req); }
|
|
184
208
|
// }
|
|
185
209
|
export class AgentDurableObject {
|
|
186
|
-
|
|
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>;
|
|
187
224
|
// Built from the DO's env in the constructor (this isolate), shared across turns —
|
|
188
225
|
// env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
|
|
189
226
|
private readonly resources: Resources;
|
|
@@ -193,6 +230,40 @@ export class AgentDurableObject {
|
|
|
193
230
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
194
231
|
this.resources = def.resources ?? {};
|
|
195
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
|
+
});
|
|
196
267
|
// Merge the mounted channels' capability tools (built here from this DO's env, since a
|
|
197
268
|
// tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
|
|
198
269
|
// keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
|
|
@@ -204,7 +275,49 @@ export class AgentDurableObject {
|
|
|
204
275
|
seen.add(t.spec.name);
|
|
205
276
|
tools.push(t);
|
|
206
277
|
}
|
|
207
|
-
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;
|
|
208
321
|
}
|
|
209
322
|
// Run the whole turn inside a request scope seeded from this DO's env, so ambient
|
|
210
323
|
// `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
|
|
@@ -213,29 +326,91 @@ export class AgentDurableObject {
|
|
|
213
326
|
// Juno's batch-loader registry) can't leak across turns on a long-lived DO.
|
|
214
327
|
// ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
|
|
215
328
|
// pipeline does; without it runInScope is a pass-through and ambient reads throw.
|
|
216
|
-
async turn(input: { turnId?: string; userText: string; event?: InboundEvent }): Promise<string> {
|
|
329
|
+
async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
|
|
330
|
+
await ensureScope();
|
|
331
|
+
const session = this.resolveSession(input.session);
|
|
332
|
+
return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
|
|
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 }> {
|
|
217
339
|
await ensureScope();
|
|
218
|
-
|
|
340
|
+
const session = this.resolveSession(input.session);
|
|
341
|
+
return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
|
|
219
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.
|
|
220
347
|
transcript() {
|
|
221
|
-
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();
|
|
222
351
|
}
|
|
223
352
|
// Default HTTP surface: POST …/turn STREAMS the turn's TurnEvents as SSE (start the
|
|
224
353
|
// turn in-scope, then stream from the session sink); GET …/transcript reads the log.
|
|
225
354
|
// The app can call turn()/transcript() directly instead (turn() awaits the final text).
|
|
226
355
|
async fetch(req: Request): Promise<Response> {
|
|
227
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;
|
|
228
360
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
229
|
-
const { userText, turnId, event } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent };
|
|
361
|
+
const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
|
|
230
362
|
await ensureScope();
|
|
231
363
|
// start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
|
|
232
364
|
// db/services (ALS propagates to the .then continuation registered here); subscribing
|
|
233
365
|
// happens synchronously right after, before any event can emit.
|
|
234
|
-
|
|
235
|
-
|
|
366
|
+
let session: AgentSession;
|
|
367
|
+
let started: { turnId: string };
|
|
368
|
+
try {
|
|
369
|
+
session = this.resolveSession(key);
|
|
370
|
+
started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
|
|
371
|
+
} catch (err) {
|
|
372
|
+
// e.g. the session is suspended awaiting input, or the key mis-matches this
|
|
373
|
+
// object's identity — a client-resolvable conflict, not a crash
|
|
374
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
375
|
+
}
|
|
376
|
+
// DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
|
|
377
|
+
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
378
|
+
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
379
|
+
// ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
|
|
380
|
+
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
381
|
+
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
382
|
+
}
|
|
383
|
+
// Provide the input a suspended turn is waiting on and stream its continuation as SSE.
|
|
384
|
+
// NOTE: this default surface passes `by` straight from the body — the engine treats it as a
|
|
385
|
+
// VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
|
|
386
|
+
// signature-checked Slack interaction payload), never expose this endpoint raw to clients.
|
|
387
|
+
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
388
|
+
const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
|
|
389
|
+
await ensureScope();
|
|
390
|
+
let session: AgentSession;
|
|
391
|
+
try {
|
|
392
|
+
session = this.resolveSession(key);
|
|
393
|
+
runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
396
|
+
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
|
|
397
|
+
return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
|
|
398
|
+
}
|
|
399
|
+
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
400
|
+
}
|
|
401
|
+
if (url.pathname.endsWith("/transcript")) {
|
|
402
|
+
// A keyed read still conflict-checks (and may commit the key — it is authentic,
|
|
403
|
+
// idFromName routed it here); a key-less read stays non-committal via transcript().
|
|
404
|
+
if (key !== undefined) {
|
|
405
|
+
try {
|
|
406
|
+
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
407
|
+
} catch (err) {
|
|
408
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return Response.json({ transcript: this.transcript() });
|
|
236
412
|
}
|
|
237
|
-
|
|
238
|
-
return new Response("agent DO — POST /turn or GET /transcript", { status: 404 });
|
|
413
|
+
return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
|
|
239
414
|
}
|
|
240
415
|
}
|
|
241
416
|
|
|
@@ -263,7 +438,9 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
|
|
|
263
438
|
}, 20_000);
|
|
264
439
|
unsub = session.observe((e) => {
|
|
265
440
|
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
266
|
-
|
|
441
|
+
// terminal for the STREAM: completed, failed, OR suspended (input.requested → the turn
|
|
442
|
+
// parked; the stream ends and a later /resume opens a fresh continuation stream).
|
|
443
|
+
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") { stop(); controller.close(); }
|
|
267
444
|
}, { turnId });
|
|
268
445
|
},
|
|
269
446
|
cancel() { stop(); },
|
|
@@ -323,10 +500,31 @@ export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
|
|
|
323
500
|
}
|
|
324
501
|
}
|
|
325
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
|
+
|
|
326
519
|
// Worker-side routing: address a session's DO by (agent, session) and forward the
|
|
327
|
-
// 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.
|
|
328
523
|
export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
|
|
329
|
-
|
|
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 }));
|
|
330
528
|
}
|
|
331
529
|
|
|
332
530
|
// The EDGE agent surface for June's router: route the chat endpoint to the
|
|
@@ -348,6 +546,15 @@ export function durableAgentSurface(
|
|
|
348
546
|
const namespace = getNamespace();
|
|
349
547
|
if (!namespace) return null; // no DO binding → not mounted here
|
|
350
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
|
+
}
|
|
351
558
|
// Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
|
|
352
559
|
// that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
|
|
353
560
|
// through; otherwise collapse to the final { text } (the prior JSON contract).
|
|
@@ -421,6 +628,25 @@ export function durableChannelSurface(
|
|
|
421
628
|
// /turn streams SSE; the simple path collapses it to the final text.
|
|
422
629
|
return sseTurnFinalText(res);
|
|
423
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
|
+
},
|
|
424
650
|
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
425
651
|
runStream: async function* (message, o) {
|
|
426
652
|
const namespace = getNamespace();
|
|
@@ -433,6 +659,18 @@ export function durableChannelSurface(
|
|
|
433
659
|
);
|
|
434
660
|
yield* sseTurnEvents(res);
|
|
435
661
|
},
|
|
662
|
+
// Resume a parked turn on its session's DO and stream the continuation.
|
|
663
|
+
resumeStream: async function* (o) {
|
|
664
|
+
const namespace = getNamespace();
|
|
665
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
666
|
+
const res = await durableFetch(
|
|
667
|
+
namespace,
|
|
668
|
+
opts.agentName,
|
|
669
|
+
o.session ?? "default",
|
|
670
|
+
new Request("https://do/resume", { method: "POST", body: serializeResume(o) }),
|
|
671
|
+
);
|
|
672
|
+
yield* sseTurnEvents(res);
|
|
673
|
+
},
|
|
436
674
|
};
|
|
437
675
|
return channelDispatch(resolved, ctx);
|
|
438
676
|
}
|
|
@@ -443,8 +681,22 @@ export function durableChannelSurface(
|
|
|
443
681
|
// run the turn, so on a serialization failure we drop it rather than let an
|
|
444
682
|
// unserializable payload take down turn forwarding entirely. (raw is optional on
|
|
445
683
|
// InboundEvent precisely because it may not survive this boundary.)
|
|
446
|
-
|
|
447
|
-
|
|
684
|
+
// Serialize a /resume RPC body. `input` is `unknown` — the human's answer — so a
|
|
685
|
+
// (third-party) host could hand us something JSON.stringify chokes on (BigInt, circular).
|
|
686
|
+
// Unlike serializeTurn's `raw`, `input` is essential: silently dropping it would resume the
|
|
687
|
+
// turn with the wrong answer. So fail loudly with a clear message rather than corrupt the resume.
|
|
688
|
+
function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string }): string {
|
|
689
|
+
try {
|
|
690
|
+
return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by });
|
|
691
|
+
} catch (err) {
|
|
692
|
+
throw new Error(`resumeStream: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// `trigger` is ProactiveTrigger — plain strings by construction — so only event.raw can make
|
|
697
|
+
// the stringify throw; the fallback that strips it stays sufficient.
|
|
698
|
+
function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger }): string {
|
|
699
|
+
const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger };
|
|
448
700
|
try {
|
|
449
701
|
return JSON.stringify(payload);
|
|
450
702
|
} catch {
|
package/src/agent-native.ts
CHANGED
|
@@ -46,8 +46,8 @@ class SqliteSessionStore implements SessionStore {
|
|
|
46
46
|
return (this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid) as { body: string }[])
|
|
47
47
|
.map((r) => JSON.parse(r.body));
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
|
|
49
|
+
hasOpeningMessage(turnId: string): boolean {
|
|
50
|
+
return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
|
|
51
51
|
}
|
|
52
52
|
getStep(id: string): unknown | undefined {
|
|
53
53
|
const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id) as { output: string } | undefined;
|
|
@@ -56,6 +56,9 @@ class SqliteSessionStore implements SessionStore {
|
|
|
56
56
|
putStep(id: string, output: unknown) {
|
|
57
57
|
this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
|
|
58
58
|
}
|
|
59
|
+
delStep(id: string) {
|
|
60
|
+
this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
|
|
61
|
+
}
|
|
59
62
|
getStatus(): string {
|
|
60
63
|
return (this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid) as { status: string } | undefined)?.status ?? "new";
|
|
61
64
|
}
|
|
@@ -136,9 +139,10 @@ class MemorySessionStore implements SessionStore {
|
|
|
136
139
|
private status = "new";
|
|
137
140
|
appendMessage(m: Msg) { this.msgs.push(m); }
|
|
138
141
|
messages(): Msg[] { return this.msgs.slice(); }
|
|
139
|
-
|
|
142
|
+
hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
|
|
140
143
|
getStep(id: string): unknown | undefined { return this.steps.has(id) ? this.steps.get(id) : undefined; }
|
|
141
144
|
putStep(id: string, output: unknown) { this.steps.set(id, output); }
|
|
145
|
+
delStep(id: string) { this.steps.delete(id); }
|
|
142
146
|
getStatus(): string { return this.status; }
|
|
143
147
|
setStatus(s: string) { this.status = s; }
|
|
144
148
|
tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
|
|
@@ -209,7 +213,12 @@ export function mountAgent(
|
|
|
209
213
|
agent,
|
|
210
214
|
services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
|
|
211
215
|
run: (message, o) =>
|
|
212
|
-
runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event }),
|
|
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 }),
|
|
213
222
|
};
|
|
214
223
|
const channels = channelFetch(agent, ctx);
|
|
215
224
|
const surface = async (req: Request): Promise<Response | null> => {
|