@junejs/server 1.0.0-dev.36 → 1.0.0-dev.42
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 +19 -2
- package/dist/agent-durable.js +118 -7
- package/dist/agent-native.d.ts +5 -1
- package/dist/agent-native.js +66 -6
- package/dist/build.js +4 -0
- package/dist/core-version.js +1 -1
- package/package.json +2 -2
- package/src/agent-durable.ts +164 -11
- package/src/agent-native.ts +89 -6
- package/src/build.ts +4 -0
- package/src/core-version.ts +1 -1
package/dist/agent-durable.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Channel, ChannelFactory } from "@junejs/core/agent-config";
|
|
2
|
-
import { AuthorizeAnswer, ChannelPolicy, InboundEvent, Model, Msg, ProactiveTrigger, SessionStore, Tool, TurnError, TurnEvent, TurnFailurePhase } from "@junejs/core/agent-runtime";
|
|
2
|
+
import { AuthorizeAnswer, ChannelPolicy, InboundEvent, InputAnnouncement, Model, Msg, NoteKind, ProactiveTrigger, SessionStore, Tool, TurnError, TurnEvent, TurnFailurePhase } from "@junejs/core/agent-runtime";
|
|
3
3
|
import { Connection } from "@junejs/core/connections";
|
|
4
4
|
import { Resources } from "@junejs/core/resources";
|
|
5
5
|
//#region src/agent-durable.d.ts
|
|
@@ -14,6 +14,7 @@ export interface SqlStorage {
|
|
|
14
14
|
export interface DurableStorage {
|
|
15
15
|
sql: SqlStorage;
|
|
16
16
|
transactionSync<T>(fn: () => T): T;
|
|
17
|
+
setAlarm?(scheduledTime: number): unknown;
|
|
17
18
|
}
|
|
18
19
|
export interface DurableObjectState {
|
|
19
20
|
storage: DurableStorage;
|
|
@@ -40,7 +41,7 @@ export declare class DoSessionStore implements SessionStore {
|
|
|
40
41
|
getSessionKey(): string | undefined;
|
|
41
42
|
setSessionKey(key: string): void;
|
|
42
43
|
tx<T>(fn: () => T): T;
|
|
43
|
-
reset(): number;
|
|
44
|
+
reset(inTx?: () => void): number;
|
|
44
45
|
unwrap<H = unknown>(): H;
|
|
45
46
|
}
|
|
46
47
|
export type DoAgentDef = {
|
|
@@ -61,6 +62,7 @@ export type DoAgentDef = {
|
|
|
61
62
|
step?: string;
|
|
62
63
|
}) => void | Promise<void>;
|
|
63
64
|
authorizeAnswer?: AuthorizeAnswer;
|
|
65
|
+
onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
64
66
|
};
|
|
65
67
|
export declare class AgentDurableObject {
|
|
66
68
|
private session;
|
|
@@ -79,11 +81,18 @@ export declare class AgentDurableObject {
|
|
|
79
81
|
private readonly doEnv;
|
|
80
82
|
private readonly services;
|
|
81
83
|
private readonly authorizeAnswer?;
|
|
84
|
+
private readonly onInputAnnouncement?;
|
|
85
|
+
private readonly storage;
|
|
86
|
+
private announceFailures;
|
|
82
87
|
private resolveResources;
|
|
83
88
|
private resolveConnections;
|
|
84
89
|
private ready;
|
|
85
90
|
constructor(state: DurableObjectState, def: DoAgentDef);
|
|
86
91
|
private resolveSession;
|
|
92
|
+
alarm(): Promise<void>;
|
|
93
|
+
private armWatchdog;
|
|
94
|
+
private scheduleAlarm;
|
|
95
|
+
private deliverHeld;
|
|
87
96
|
turn(input: {
|
|
88
97
|
turnId?: string;
|
|
89
98
|
userText: string;
|
|
@@ -100,6 +109,14 @@ export declare class AgentDurableObject {
|
|
|
100
109
|
}): Promise<{
|
|
101
110
|
turnId: string;
|
|
102
111
|
}>;
|
|
112
|
+
note(input: {
|
|
113
|
+
by: string;
|
|
114
|
+
kind: NoteKind;
|
|
115
|
+
text: string;
|
|
116
|
+
session?: string;
|
|
117
|
+
}): Promise<{
|
|
118
|
+
noteId: string;
|
|
119
|
+
}>;
|
|
103
120
|
reset(opts?: {
|
|
104
121
|
session?: string;
|
|
105
122
|
}): Promise<{
|
package/dist/agent-durable.js
CHANGED
|
@@ -54,7 +54,7 @@ var DoSessionStore = class {
|
|
|
54
54
|
tx(fn) {
|
|
55
55
|
return this.storage.transactionSync(fn);
|
|
56
56
|
}
|
|
57
|
-
reset() {
|
|
57
|
+
reset(inTx) {
|
|
58
58
|
return this.storage.transactionSync(() => {
|
|
59
59
|
const sql = this.sql;
|
|
60
60
|
sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
|
|
@@ -67,6 +67,7 @@ var DoSessionStore = class {
|
|
|
67
67
|
sql.exec("DELETE FROM agent_steps");
|
|
68
68
|
this.setStatus("new");
|
|
69
69
|
sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
|
|
70
|
+
inTx?.();
|
|
70
71
|
return generation;
|
|
71
72
|
});
|
|
72
73
|
}
|
|
@@ -108,6 +109,9 @@ var AgentDurableObject = class {
|
|
|
108
109
|
doEnv;
|
|
109
110
|
services;
|
|
110
111
|
authorizeAnswer;
|
|
112
|
+
onInputAnnouncement;
|
|
113
|
+
storage;
|
|
114
|
+
announceFailures = 0;
|
|
111
115
|
resolveResources() {
|
|
112
116
|
return this.resourcesOpened ??= Promise.resolve(typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : this.resourcesInput ?? {});
|
|
113
117
|
}
|
|
@@ -138,8 +142,13 @@ var AgentDurableObject = class {
|
|
|
138
142
|
this.doEnv = def.env;
|
|
139
143
|
this.services = def.services;
|
|
140
144
|
this.authorizeAnswer = def.authorizeAnswer;
|
|
145
|
+
this.onInputAnnouncement = def.onInputAnnouncement;
|
|
146
|
+
this.storage = state.storage;
|
|
141
147
|
const name = def.name ?? "agent";
|
|
142
148
|
const sink = new InProcEventSink();
|
|
149
|
+
sink.subscribe((e) => {
|
|
150
|
+
if (e.type === "turn.started") this.armWatchdog();
|
|
151
|
+
});
|
|
143
152
|
sink.subscribe((e) => {
|
|
144
153
|
if (e.type !== "turn.failed") return;
|
|
145
154
|
const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed${e.step ? ` at ${e.step}` : ""}: ${e.error.stack ?? e.error.message}${e.error.causeChain?.length ? `\ncaused by: ${e.error.causeChain.join(" ← ")}` : ""}`);
|
|
@@ -193,8 +202,71 @@ var AgentDurableObject = class {
|
|
|
193
202
|
if (key !== void 0 && key !== "self" && stored === void 0) this.store.setSessionKey(key);
|
|
194
203
|
this.sessionKey = resolved;
|
|
195
204
|
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
205
|
+
this.session.onDequeue = (heldTurnId, held) => {
|
|
206
|
+
if (held.hostContext?.deliver && held.event) this.deliverHeld(this.session, heldTurnId, held.event);
|
|
207
|
+
};
|
|
208
|
+
const announce = this.onInputAnnouncement;
|
|
209
|
+
if (announce) {
|
|
210
|
+
this.session.onAnnounce = async (a) => {
|
|
211
|
+
await ensureScope();
|
|
212
|
+
const resources = await this.ready();
|
|
213
|
+
try {
|
|
214
|
+
await runInScope({
|
|
215
|
+
resources,
|
|
216
|
+
services: this.services
|
|
217
|
+
}, () => announce(a));
|
|
218
|
+
} catch (err) {
|
|
219
|
+
this.scheduleAlarm(Math.min(5e3 * 2 ** this.announceFailures, 3e5));
|
|
220
|
+
this.announceFailures++;
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
this.announceFailures = 0;
|
|
224
|
+
};
|
|
225
|
+
this.session.flushAnnouncements();
|
|
226
|
+
}
|
|
196
227
|
return this.session;
|
|
197
228
|
}
|
|
229
|
+
async alarm() {
|
|
230
|
+
if (!this.onInputAnnouncement) return;
|
|
231
|
+
await this.resolveSession().flushAnnouncements();
|
|
232
|
+
}
|
|
233
|
+
armWatchdog() {
|
|
234
|
+
if (this.onInputAnnouncement) this.scheduleAlarm(6e4);
|
|
235
|
+
}
|
|
236
|
+
scheduleAlarm(inMs) {
|
|
237
|
+
const failed = (err) => console.error(`[june] agent "${this.name}": could not schedule the announcement alarm — delivery waits for the next announcement, turn or rebuild:`, err);
|
|
238
|
+
try {
|
|
239
|
+
Promise.resolve(this.storage.setAlarm?.(Date.now() + inMs)).catch(failed);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
failed(err);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
deliverHeld(session, turnId, event) {
|
|
245
|
+
const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered render for held turn ${turnId} failed:`, err);
|
|
246
|
+
try {
|
|
247
|
+
const channel = this.channels.find((c) => c.name === event.source);
|
|
248
|
+
if (!channel?.deliver) {
|
|
249
|
+
logRenderFailure(/* @__PURE__ */ new Error(`no deliver()-capable channel named "${event.source}" is wired into this DO — the held turn runs, but its reply is not rendered`));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
|
|
253
|
+
const events = { [Symbol.asyncIterator]: () => it };
|
|
254
|
+
const target = {
|
|
255
|
+
channelId: event.channelId,
|
|
256
|
+
threadId: event.threadId,
|
|
257
|
+
recipientUserId: event.user?.id,
|
|
258
|
+
recipientTeamId: event.teamId
|
|
259
|
+
};
|
|
260
|
+
this.ready().then((resources) => runInScope({
|
|
261
|
+
resources,
|
|
262
|
+
services: this.services
|
|
263
|
+
}, () => channel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
|
|
264
|
+
it.return?.();
|
|
265
|
+
});
|
|
266
|
+
} catch (err) {
|
|
267
|
+
logRenderFailure(err);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
198
270
|
async turn(input) {
|
|
199
271
|
await ensureScope();
|
|
200
272
|
const resources = await this.ready();
|
|
@@ -213,8 +285,14 @@ var AgentDurableObject = class {
|
|
|
213
285
|
services: this.services
|
|
214
286
|
}, () => session.start(input));
|
|
215
287
|
}
|
|
288
|
+
note(input) {
|
|
289
|
+
const { session, ...note } = input;
|
|
290
|
+
return this.resolveSession(session).note(note);
|
|
291
|
+
}
|
|
216
292
|
reset(opts) {
|
|
217
|
-
|
|
293
|
+
const session = this.resolveSession(opts?.session);
|
|
294
|
+
this.armWatchdog();
|
|
295
|
+
return session.reset();
|
|
218
296
|
}
|
|
219
297
|
transcript() {
|
|
220
298
|
if (this.session) return this.session.transcript();
|
|
@@ -225,10 +303,13 @@ var AgentDurableObject = class {
|
|
|
225
303
|
const url = new URL(req.url);
|
|
226
304
|
const key = req.headers.get("x-june-session") ?? void 0;
|
|
227
305
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
228
|
-
const { userText, turnId, event, trigger } = await req.json();
|
|
306
|
+
const { userText, turnId, event, trigger, ifSuspended } = await req.json();
|
|
229
307
|
await ensureScope();
|
|
230
308
|
const replace = url.searchParams.get("replace") === "1";
|
|
231
309
|
const wantsDeliver = url.searchParams.get("deliver") === "1";
|
|
310
|
+
const detached = url.searchParams.get("detach") === "1";
|
|
311
|
+
if (ifSuspended === "queue" && !wantsDeliver && !detached) return Response.json({ error: `ifSuspended "queue" needs deliver=1 or detach=1: a held turn runs later, when no one is streaming it` }, { status: 400 });
|
|
312
|
+
if (ifSuspended === "queue" && replace) return Response.json({ error: `ifSuspended "queue" and replace=1 cannot be combined — replacing would drop held turns that were accepted` }, { status: 400 });
|
|
232
313
|
let deliverChannel;
|
|
233
314
|
if (wantsDeliver) {
|
|
234
315
|
if (!event?.channelId) return Response.json({ error: "deliver=1 needs an inbound event (event.channelId names the reply target)" }, { status: 400 });
|
|
@@ -240,6 +321,7 @@ var AgentDurableObject = class {
|
|
|
240
321
|
let started;
|
|
241
322
|
try {
|
|
242
323
|
session = this.resolveSession(key);
|
|
324
|
+
this.armWatchdog();
|
|
243
325
|
started = runInScope({
|
|
244
326
|
resources,
|
|
245
327
|
services: this.services
|
|
@@ -248,11 +330,17 @@ var AgentDurableObject = class {
|
|
|
248
330
|
turnId,
|
|
249
331
|
event,
|
|
250
332
|
trigger,
|
|
251
|
-
replace
|
|
333
|
+
replace,
|
|
334
|
+
ifSuspended,
|
|
335
|
+
hostContext: wantsDeliver ? { deliver: true } : void 0
|
|
252
336
|
}));
|
|
253
337
|
} catch (err) {
|
|
254
338
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
255
339
|
}
|
|
340
|
+
if (started.queued) return Response.json({
|
|
341
|
+
turnId: started.turnId,
|
|
342
|
+
queued: true
|
|
343
|
+
}, { status: 202 });
|
|
256
344
|
if (wantsDeliver) {
|
|
257
345
|
const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
|
|
258
346
|
try {
|
|
@@ -275,7 +363,7 @@ var AgentDurableObject = class {
|
|
|
275
363
|
}
|
|
276
364
|
return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
277
365
|
}
|
|
278
|
-
if (
|
|
366
|
+
if (detached) return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
279
367
|
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
280
368
|
}
|
|
281
369
|
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
@@ -310,6 +398,7 @@ var AgentDurableObject = class {
|
|
|
310
398
|
console.error(`[june] agent "${this.name}": authorizeAnswer failed for input "${inputId}" of turn ${turnId}:`, err);
|
|
311
399
|
return Response.json({ error: `authorizeAnswer failed: ${err instanceof Error ? err.message : String(err)} — the answer was NOT applied` }, { status: 500 });
|
|
312
400
|
}
|
|
401
|
+
this.armWatchdog();
|
|
313
402
|
try {
|
|
314
403
|
runInScope({
|
|
315
404
|
resources,
|
|
@@ -347,12 +436,33 @@ var AgentDurableObject = class {
|
|
|
347
436
|
} catch (err) {
|
|
348
437
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
349
438
|
}
|
|
439
|
+
this.armWatchdog();
|
|
350
440
|
try {
|
|
351
441
|
return Response.json(await session.reset());
|
|
352
442
|
} catch (err) {
|
|
353
443
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
354
444
|
}
|
|
355
445
|
}
|
|
446
|
+
if (req.method === "POST" && url.pathname.endsWith("/note")) {
|
|
447
|
+
let input;
|
|
448
|
+
try {
|
|
449
|
+
input = await req.json();
|
|
450
|
+
} catch {
|
|
451
|
+
return Response.json({ error: "note: the body must be JSON { by, kind, text }" }, { status: 400 });
|
|
452
|
+
}
|
|
453
|
+
let session;
|
|
454
|
+
try {
|
|
455
|
+
session = this.resolveSession(key);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
return Response.json(await session.note(input));
|
|
461
|
+
} catch (err) {
|
|
462
|
+
if (err instanceof TypeError) return Response.json({ error: err.message }, { status: 400 });
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
356
466
|
if (url.pathname.endsWith("/transcript")) {
|
|
357
467
|
if (key !== void 0) try {
|
|
358
468
|
return Response.json({ transcript: this.resolveSession(key).transcript() });
|
|
@@ -361,7 +471,7 @@ var AgentDurableObject = class {
|
|
|
361
471
|
}
|
|
362
472
|
return Response.json({ transcript: this.transcript() });
|
|
363
473
|
}
|
|
364
|
-
return new Response("agent DO — POST /turn, POST /resume, POST /reset, or GET /transcript", { status: 404 });
|
|
474
|
+
return new Response("agent DO — POST /turn, POST /resume, POST /reset, POST /note, or GET /transcript", { status: 404 });
|
|
365
475
|
}
|
|
366
476
|
};
|
|
367
477
|
const SSE_HEADERS = {
|
|
@@ -608,7 +718,8 @@ function serializeTurn(userText, o) {
|
|
|
608
718
|
userText,
|
|
609
719
|
turnId: o?.turnId,
|
|
610
720
|
event: o?.event,
|
|
611
|
-
trigger: o?.trigger
|
|
721
|
+
trigger: o?.trigger,
|
|
722
|
+
ifSuspended: o?.ifSuspended
|
|
612
723
|
};
|
|
613
724
|
try {
|
|
614
725
|
return JSON.stringify(payload);
|
package/dist/agent-native.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { SyncSqlite } from "./sqlite-driver.js";
|
|
2
2
|
import { AgentDefinition, ChannelContext } from "@junejs/core/agent-config";
|
|
3
|
-
import { AgentSession, ChannelPolicy, Model, Runtime, Tool } from "@junejs/core/agent-runtime";
|
|
3
|
+
import { AgentSession, ChannelPolicy, InputAnnouncement, Model, Runtime, Tool } from "@junejs/core/agent-runtime";
|
|
4
4
|
//#region src/agent-native.d.ts
|
|
5
5
|
export type AgentDef = {
|
|
6
6
|
model: Model;
|
|
7
7
|
tools: Tool[];
|
|
8
8
|
instructions?: string;
|
|
9
9
|
channelInstructions?: Record<string, string | ChannelPolicy>;
|
|
10
|
+
onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
10
11
|
};
|
|
11
12
|
export declare function toAgentDef(agent: AgentDefinition, model: Model): AgentDef;
|
|
12
13
|
export type NativeRuntimeOptions = {
|
|
@@ -16,11 +17,13 @@ export declare class NativeRuntime implements Runtime {
|
|
|
16
17
|
private actors;
|
|
17
18
|
private readonly agents;
|
|
18
19
|
private readonly db;
|
|
20
|
+
private readonly announceRetry;
|
|
19
21
|
private readonly maxSessions;
|
|
20
22
|
constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts?: NativeRuntimeOptions);
|
|
21
23
|
agentDef(name: string): AgentDef | undefined;
|
|
22
24
|
session(agent: string, id: string): AgentSession;
|
|
23
25
|
get sessionCount(): number;
|
|
26
|
+
recoverAnnouncements(): void;
|
|
24
27
|
private evictIdle;
|
|
25
28
|
}
|
|
26
29
|
export declare function createNativeRuntime(agents: Record<string, AgentDef>, path?: string, opts?: NativeRuntimeOptions): Promise<NativeRuntime>;
|
|
@@ -28,6 +31,7 @@ export declare class MemoryRuntime implements Runtime {
|
|
|
28
31
|
private actors;
|
|
29
32
|
private stores;
|
|
30
33
|
private readonly agents;
|
|
34
|
+
private readonly announceRetry;
|
|
31
35
|
constructor(agents: Record<string, AgentDef>);
|
|
32
36
|
agentDef(name: string): AgentDef | undefined;
|
|
33
37
|
session(agent: string, id: string): AgentSession;
|
package/dist/agent-native.js
CHANGED
|
@@ -53,7 +53,7 @@ var SqliteSessionStore = class {
|
|
|
53
53
|
throw e;
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
-
reset() {
|
|
56
|
+
reset(inTx) {
|
|
57
57
|
return this.tx(() => {
|
|
58
58
|
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
|
|
59
59
|
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
|
|
@@ -65,6 +65,7 @@ var SqliteSessionStore = class {
|
|
|
65
65
|
this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
|
|
66
66
|
this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
|
|
67
67
|
this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
|
|
68
|
+
inTx?.();
|
|
68
69
|
return generation;
|
|
69
70
|
});
|
|
70
71
|
}
|
|
@@ -89,18 +90,64 @@ var InProcEventSink = class {
|
|
|
89
90
|
return this.subs.size;
|
|
90
91
|
}
|
|
91
92
|
};
|
|
93
|
+
var AnnouncementRetry = class {
|
|
94
|
+
flush;
|
|
95
|
+
state = /* @__PURE__ */ new Map();
|
|
96
|
+
constructor(flush) {
|
|
97
|
+
this.flush = flush;
|
|
98
|
+
}
|
|
99
|
+
delivered(key) {
|
|
100
|
+
const s = this.state.get(key);
|
|
101
|
+
if (s?.timer) clearTimeout(s.timer);
|
|
102
|
+
this.state.delete(key);
|
|
103
|
+
}
|
|
104
|
+
failed(key, agent, id) {
|
|
105
|
+
const s = this.state.get(key) ?? { failures: 0 };
|
|
106
|
+
this.state.set(key, s);
|
|
107
|
+
if (s.timer) return;
|
|
108
|
+
const delay = Math.min(5e3 * 2 ** s.failures, 3e5);
|
|
109
|
+
s.failures++;
|
|
110
|
+
s.timer = setTimeout(() => {
|
|
111
|
+
s.timer = void 0;
|
|
112
|
+
this.flush(agent, id);
|
|
113
|
+
}, delay);
|
|
114
|
+
s.timer.unref?.();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
function wireAnnouncements(session, def, retry) {
|
|
118
|
+
const hook = def.onInputAnnouncement;
|
|
119
|
+
if (hook) {
|
|
120
|
+
const key = `${session.agent}:${session.id}`;
|
|
121
|
+
session.onAnnounce = async (a) => {
|
|
122
|
+
try {
|
|
123
|
+
await hook(a);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
retry.failed(key, session.agent, session.id);
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
retry.delivered(key);
|
|
129
|
+
};
|
|
130
|
+
session.flushAnnouncements();
|
|
131
|
+
}
|
|
132
|
+
return session;
|
|
133
|
+
}
|
|
134
|
+
const ANNOUNCE_OUTBOX_STEP = "announce-outbox";
|
|
92
135
|
function toAgentDef(agent, model) {
|
|
93
136
|
return {
|
|
94
137
|
model,
|
|
95
138
|
tools: agent.tools,
|
|
96
139
|
instructions: buildSystemPrompt(agent),
|
|
97
|
-
...agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {}
|
|
140
|
+
...agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {},
|
|
141
|
+
...agent.onInputAnnouncement ? { onInputAnnouncement: agent.onInputAnnouncement } : {}
|
|
98
142
|
};
|
|
99
143
|
}
|
|
100
144
|
var NativeRuntime = class {
|
|
101
145
|
actors = /* @__PURE__ */ new Map();
|
|
102
146
|
agents;
|
|
103
147
|
db;
|
|
148
|
+
announceRetry = new AnnouncementRetry((agent, id) => {
|
|
149
|
+
this.session(agent, id).flushAnnouncements();
|
|
150
|
+
});
|
|
104
151
|
maxSessions;
|
|
105
152
|
constructor(agents, db, opts = {}) {
|
|
106
153
|
assertCoreRuntimeVersion("NativeRuntime");
|
|
@@ -126,7 +173,7 @@ var NativeRuntime = class {
|
|
|
126
173
|
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
127
174
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
128
175
|
const sink = new InProcEventSink();
|
|
129
|
-
const session = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
|
|
176
|
+
const session = wireAnnouncements(new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions), def, this.announceRetry);
|
|
130
177
|
this.evictIdle();
|
|
131
178
|
this.actors.set(key, {
|
|
132
179
|
session,
|
|
@@ -137,6 +184,13 @@ var NativeRuntime = class {
|
|
|
137
184
|
get sessionCount() {
|
|
138
185
|
return this.actors.size;
|
|
139
186
|
}
|
|
187
|
+
recoverAnnouncements() {
|
|
188
|
+
const rows = this.db.query("SELECT output FROM agent_steps WHERE id = ?").all(ANNOUNCE_OUTBOX_STEP);
|
|
189
|
+
for (const { output } of rows) {
|
|
190
|
+
const [first] = JSON.parse(output);
|
|
191
|
+
if (first && this.agents[first.agent]?.onInputAnnouncement) this.session(first.agent, first.session);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
140
194
|
evictIdle() {
|
|
141
195
|
if (this.actors.size < this.maxSessions) return;
|
|
142
196
|
for (const [key, a] of this.actors) {
|
|
@@ -146,7 +200,9 @@ var NativeRuntime = class {
|
|
|
146
200
|
}
|
|
147
201
|
};
|
|
148
202
|
async function createNativeRuntime(agents, path = ":memory:", opts = {}) {
|
|
149
|
-
|
|
203
|
+
const runtime = new NativeRuntime(agents, await openLocalSqliteSync(path), opts);
|
|
204
|
+
runtime.recoverAnnouncements();
|
|
205
|
+
return runtime;
|
|
150
206
|
}
|
|
151
207
|
var MemorySessionStore = class {
|
|
152
208
|
msgs = [];
|
|
@@ -181,7 +237,7 @@ var MemorySessionStore = class {
|
|
|
181
237
|
tx(fn) {
|
|
182
238
|
return fn();
|
|
183
239
|
}
|
|
184
|
-
reset() {
|
|
240
|
+
reset(inTx) {
|
|
185
241
|
const generation = this.generation++;
|
|
186
242
|
this.archives.push({
|
|
187
243
|
generation,
|
|
@@ -191,6 +247,7 @@ var MemorySessionStore = class {
|
|
|
191
247
|
this.msgs = [];
|
|
192
248
|
this.steps = /* @__PURE__ */ new Map();
|
|
193
249
|
this.status = "new";
|
|
250
|
+
inTx?.();
|
|
194
251
|
return generation;
|
|
195
252
|
}
|
|
196
253
|
unwrap() {}
|
|
@@ -199,6 +256,9 @@ var MemoryRuntime = class {
|
|
|
199
256
|
actors = /* @__PURE__ */ new Map();
|
|
200
257
|
stores = /* @__PURE__ */ new Map();
|
|
201
258
|
agents;
|
|
259
|
+
announceRetry = new AnnouncementRetry((agent, id) => {
|
|
260
|
+
this.session(agent, id).flushAnnouncements();
|
|
261
|
+
});
|
|
202
262
|
constructor(agents) {
|
|
203
263
|
assertCoreRuntimeVersion("MemoryRuntime");
|
|
204
264
|
this.agents = agents;
|
|
@@ -215,7 +275,7 @@ var MemoryRuntime = class {
|
|
|
215
275
|
const store = new MemorySessionStore();
|
|
216
276
|
this.stores.set(key, store);
|
|
217
277
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
218
|
-
a = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
|
|
278
|
+
a = wireAnnouncements(new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions), def, this.announceRetry);
|
|
219
279
|
this.actors.set(key, a);
|
|
220
280
|
}
|
|
221
281
|
return a;
|
package/dist/build.js
CHANGED
|
@@ -208,6 +208,10 @@ export class JuneAgentDO extends DurableObject {
|
|
|
208
208
|
fetch(req: Request): Promise<Response> {
|
|
209
209
|
return this.#agent.fetch(req);
|
|
210
210
|
}
|
|
211
|
+
// Delivers input announcements left undelivered — a failed hook's retry, or the watchdog (#260).
|
|
212
|
+
alarm(): Promise<void> {
|
|
213
|
+
return this.#agent.alarm();
|
|
214
|
+
}
|
|
211
215
|
}
|
|
212
216
|
` : "";
|
|
213
217
|
const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
|
package/dist/core-version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as coreRuntime from "@junejs/core/agent-runtime";
|
|
2
2
|
//#region src/core-version.ts
|
|
3
|
-
const EXPECTED_CORE_RUNTIME_API =
|
|
3
|
+
const EXPECTED_CORE_RUNTIME_API = 6;
|
|
4
4
|
function assertCoreRuntimeVersion(surface) {
|
|
5
5
|
checkCoreRuntimeVersion(surface, coreRuntime.RUNTIME_API_VERSION);
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "1.0.0-dev.
|
|
3
|
+
"version": "1.0.0-dev.42",
|
|
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",
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
"rolldown": "^1.1.0"
|
|
141
141
|
},
|
|
142
142
|
"peerDependencies": {
|
|
143
|
-
"@junejs/core": ">=0.2.0-dev.
|
|
143
|
+
"@junejs/core": ">=0.2.0-dev.63",
|
|
144
144
|
"react": "^19.2.0",
|
|
145
145
|
"react-dom": "^19.2.0",
|
|
146
146
|
"react-server-dom-webpack": "19.3.0",
|
package/src/agent-durable.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
withSystem,
|
|
23
23
|
type Answerers,
|
|
24
24
|
type AuthorizeAnswer,
|
|
25
|
+
type InputAnnouncement,
|
|
26
|
+
type NoteKind,
|
|
25
27
|
type ChannelPolicy,
|
|
26
28
|
type EventSink,
|
|
27
29
|
type TurnEvent,
|
|
@@ -76,6 +78,10 @@ export interface SqlStorage {
|
|
|
76
78
|
export interface DurableStorage {
|
|
77
79
|
sql: SqlStorage;
|
|
78
80
|
transactionSync<T>(fn: () => T): T;
|
|
81
|
+
// Schedules the object's alarm() — the watchdog and retry for input-announcement delivery
|
|
82
|
+
// (#260). Optional so a storage without alarms (a test double) still works; delivery then
|
|
83
|
+
// waits for the next announcement, settled turn or rebuild.
|
|
84
|
+
setAlarm?(scheduledTime: number): unknown;
|
|
79
85
|
}
|
|
80
86
|
export interface DurableObjectState {
|
|
81
87
|
storage: DurableStorage;
|
|
@@ -151,7 +157,7 @@ export class DoSessionStore implements SessionStore {
|
|
|
151
157
|
// tables + the generation counter are created lazily, so existing objects stay
|
|
152
158
|
// untouched until their first reset. seq keeps counting across generations (DELETE
|
|
153
159
|
// doesn't reset AUTOINCREMENT), so an archived (generation, seq) stays unique.
|
|
154
|
-
reset(): number {
|
|
160
|
+
reset(inTx?: () => void): number {
|
|
155
161
|
return this.storage.transactionSync(() => {
|
|
156
162
|
const sql = this.sql;
|
|
157
163
|
sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
|
|
@@ -164,6 +170,7 @@ export class DoSessionStore implements SessionStore {
|
|
|
164
170
|
sql.exec("DELETE FROM agent_steps");
|
|
165
171
|
this.setStatus("new");
|
|
166
172
|
sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
|
|
173
|
+
inTx?.(); // writes into the fresh generation, committed with the archive (#260)
|
|
167
174
|
return generation;
|
|
168
175
|
});
|
|
169
176
|
}
|
|
@@ -245,6 +252,9 @@ export type DoAgentDef = {
|
|
|
245
252
|
// Decides a { policy } answerer of a parked requestInput at /resume (#261) — the same hook
|
|
246
253
|
// an AgentDefinition carries natively; assembleDurable passes agent.ts's through.
|
|
247
254
|
authorizeAnswer?: AuthorizeAnswer;
|
|
255
|
+
// Receives this session's input announcements (#260) — run inside the request scope, so it
|
|
256
|
+
// can write the app's cross-session index with the ambient db.
|
|
257
|
+
onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
248
258
|
};
|
|
249
259
|
|
|
250
260
|
// The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
|
|
@@ -288,6 +298,10 @@ export class AgentDurableObject {
|
|
|
288
298
|
private readonly doEnv: unknown;
|
|
289
299
|
private readonly services: unknown;
|
|
290
300
|
private readonly authorizeAnswer?: AuthorizeAnswer;
|
|
301
|
+
private readonly onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
302
|
+
private readonly storage: DurableStorage;
|
|
303
|
+
// Consecutive failed announcement deliveries (#260) — sets the alarm retry's backoff.
|
|
304
|
+
private announceFailures = 0;
|
|
291
305
|
private resolveResources(): Promise<Resources> {
|
|
292
306
|
return (this.resourcesOpened ??= Promise.resolve(
|
|
293
307
|
typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : (this.resourcesInput ?? {}),
|
|
@@ -330,6 +344,8 @@ export class AgentDurableObject {
|
|
|
330
344
|
this.doEnv = def.env;
|
|
331
345
|
this.services = def.services;
|
|
332
346
|
this.authorizeAnswer = def.authorizeAnswer;
|
|
347
|
+
this.onInputAnnouncement = def.onInputAnnouncement;
|
|
348
|
+
this.storage = state.storage;
|
|
333
349
|
const name = def.name ?? "agent";
|
|
334
350
|
// Failure observability (#76): a turn that dies after the fast-ACK has no other
|
|
335
351
|
// observable surface on the edge — the webhook already 200'd and runBackground
|
|
@@ -339,6 +355,13 @@ export class AgentDurableObject {
|
|
|
339
355
|
// over reporting; if IT throws, fall back to the default so nothing goes silent.
|
|
340
356
|
// (InProcEventSink already guards emit against a throwing subscriber.)
|
|
341
357
|
const sink = new InProcEventSink();
|
|
358
|
+
// Announcement watchdog (#260): a park is committed inside a turn, after turn.started, so
|
|
359
|
+
// arming here comes before its announcement can commit — whichever path started the turn,
|
|
360
|
+
// a held turn drained later included. Resume, reset and a held inbound turn arm at their
|
|
361
|
+
// call sites (armWatchdog).
|
|
362
|
+
sink.subscribe((e) => {
|
|
363
|
+
if (e.type === "turn.started") this.armWatchdog();
|
|
364
|
+
});
|
|
342
365
|
sink.subscribe((e) => {
|
|
343
366
|
if (e.type !== "turn.failed") return;
|
|
344
367
|
// The stack already opens with the message; log it INSTEAD of the message line
|
|
@@ -436,8 +459,91 @@ export class AgentDurableObject {
|
|
|
436
459
|
if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
|
|
437
460
|
this.sessionKey = resolved;
|
|
438
461
|
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
462
|
+
// A held turn (#263) that asked for delivery is rendered through its source channel when
|
|
463
|
+
// it finally runs — possibly in a later life of this object, which is why the ask rides
|
|
464
|
+
// in the turn's hostContext and this hook is installed on every rebuild.
|
|
465
|
+
this.session.onDequeue = (heldTurnId, held) => {
|
|
466
|
+
if ((held.hostContext as { deliver?: boolean } | undefined)?.deliver && held.event) this.deliverHeld(this.session!, heldTurnId, held.event);
|
|
467
|
+
};
|
|
468
|
+
// Input announcements (#260): delivered in the request scope (the hook writes the app's
|
|
469
|
+
// index), and flushed on every rebuild — an earlier life may have committed a park and
|
|
470
|
+
// died before delivering it.
|
|
471
|
+
const announce = this.onInputAnnouncement;
|
|
472
|
+
if (announce) {
|
|
473
|
+
// ensureScope() here, not only in the request handlers: a session can first be rebuilt by
|
|
474
|
+
// a path that never wires async scope (/reset, /transcript, the direct API), and without it
|
|
475
|
+
// runInScope is a pass-through and the hook would see no ambient db.
|
|
476
|
+
this.session.onAnnounce = async (a) => {
|
|
477
|
+
await ensureScope();
|
|
478
|
+
const resources = await this.ready();
|
|
479
|
+
try {
|
|
480
|
+
await runInScope({ resources, services: this.services }, () => announce(a));
|
|
481
|
+
} catch (err) {
|
|
482
|
+
// The engine keeps the announcement and logs; retry it on the alarm, 5 s doubling
|
|
483
|
+
// to 5 min — a quiet session would otherwise hold it until its next activity.
|
|
484
|
+
this.scheduleAlarm(Math.min(5_000 * 2 ** this.announceFailures, 300_000));
|
|
485
|
+
this.announceFailures++;
|
|
486
|
+
throw err;
|
|
487
|
+
}
|
|
488
|
+
this.announceFailures = 0;
|
|
489
|
+
};
|
|
490
|
+
void this.session.flushAnnouncements();
|
|
491
|
+
}
|
|
439
492
|
return this.session;
|
|
440
493
|
}
|
|
494
|
+
|
|
495
|
+
// The Durable Object alarm (#260): delivers input announcements left undelivered — a hook
|
|
496
|
+
// that failed (its retry), or an object that died between committing one and delivering
|
|
497
|
+
// it (the watchdog). The shell forwards it — `alarm() { return this.agent.alarm(); }`;
|
|
498
|
+
// june build's does. Resolves the session like any key-less path (the persisted key),
|
|
499
|
+
// which rebuilds it after an eviction and flushes. Never rejects: a failed delivery
|
|
500
|
+
// reschedules itself through onAnnounce.
|
|
501
|
+
async alarm(): Promise<void> {
|
|
502
|
+
if (!this.onInputAnnouncement) return;
|
|
503
|
+
await this.resolveSession().flushAnnouncements();
|
|
504
|
+
}
|
|
505
|
+
// A watchdog alarm a minute out, armed BEFORE anything can commit an announcement (a
|
|
506
|
+
// turn starting, a resume, a reset, a held inbound turn): arming after the commit would
|
|
507
|
+
// leave exactly the window it exists for. A delivery that succeeds leaves it to find
|
|
508
|
+
// nothing.
|
|
509
|
+
private armWatchdog(): void {
|
|
510
|
+
if (this.onInputAnnouncement) this.scheduleAlarm(60_000);
|
|
511
|
+
}
|
|
512
|
+
// One alarm per object: the latest schedule wins.
|
|
513
|
+
private scheduleAlarm(inMs: number): void {
|
|
514
|
+
const failed = (err: unknown) => console.error(`[june] agent "${this.name}": could not schedule the announcement alarm — delivery waits for the next announcement, turn or rebuild:`, err);
|
|
515
|
+
try {
|
|
516
|
+
Promise.resolve(this.storage.setAlarm?.(Date.now() + inMs)).catch(failed);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
failed(err);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Render a held turn's reply through its source channel (#263), like /turn?deliver=1 does
|
|
523
|
+
// for a turn started now. Called from onDequeue, synchronously as the turn starts: the
|
|
524
|
+
// event subscription is taken HERE, before any event can emit, and buffers until the
|
|
525
|
+
// (async) resources are ready. Nothing may escape — there is no caller to report to; the
|
|
526
|
+
// log is the only surfacing path, as for any delivered render.
|
|
527
|
+
private deliverHeld(session: AgentSession, turnId: string, event: InboundEvent): void {
|
|
528
|
+
const logRenderFailure = (err: unknown) =>
|
|
529
|
+
console.error(`[june] agent "${this.name}": delivered render for held turn ${turnId} failed:`, err);
|
|
530
|
+
try {
|
|
531
|
+
const channel = this.channels.find((c) => c.name === event.source);
|
|
532
|
+
if (!channel?.deliver) {
|
|
533
|
+
logRenderFailure(new Error(`no deliver()-capable channel named "${event.source}" is wired into this DO — the held turn runs, but its reply is not rendered`));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
|
|
537
|
+
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
538
|
+
const target = { channelId: event.channelId, threadId: event.threadId, recipientUserId: event.user?.id, recipientTeamId: event.teamId };
|
|
539
|
+
this.ready()
|
|
540
|
+
.then((resources) => runInScope({ resources, services: this.services }, () => channel.deliver!(target, events, { session: this.sessionKey })))
|
|
541
|
+
.catch(logRenderFailure)
|
|
542
|
+
.finally(() => { void it.return?.(); });
|
|
543
|
+
} catch (err) {
|
|
544
|
+
logRenderFailure(err);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
441
547
|
// Run the whole turn inside a request scope seeded from this DO's env, so ambient
|
|
442
548
|
// `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
|
|
443
549
|
// route loader. `locals` is intentionally NOT set here: a fresh scope object per
|
|
@@ -461,10 +567,18 @@ export class AgentDurableObject {
|
|
|
461
567
|
const session = this.resolveSession(input.session);
|
|
462
568
|
return runInScope({ resources, services: this.services }, () => session.start(input));
|
|
463
569
|
}
|
|
570
|
+
// An attributed note (#262) for custom shells — the direct sibling of the /note route. No
|
|
571
|
+
// scope needed: a note touches only the store.
|
|
572
|
+
note(input: { by: string; kind: NoteKind; text: string; session?: string }): Promise<{ noteId: string }> {
|
|
573
|
+
const { session, ...note } = input;
|
|
574
|
+
return this.resolveSession(session).note(note);
|
|
575
|
+
}
|
|
464
576
|
// Session reset (#129) for custom shells — the direct sibling of the /reset route. No
|
|
465
577
|
// scope needed: reset touches only the store (no tools run).
|
|
466
578
|
reset(opts?: { session?: string }): Promise<{ previousSession: string; generation: number }> {
|
|
467
|
-
|
|
579
|
+
const session = this.resolveSession(opts?.session);
|
|
580
|
+
this.armWatchdog(); // a reset can retire a park (#260)
|
|
581
|
+
return session.reset();
|
|
468
582
|
}
|
|
469
583
|
// Read-only: folds the durable log. When an identity exists (live, or persisted from a
|
|
470
584
|
// prior life) the session resolves and caches like any other path. Only a read on a
|
|
@@ -484,7 +598,7 @@ export class AgentDurableObject {
|
|
|
484
598
|
// hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
|
|
485
599
|
const key = req.headers.get(SESSION_HEADER) ?? undefined;
|
|
486
600
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
487
|
-
const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
|
|
601
|
+
const { userText, turnId, event, trigger, ifSuspended } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger; ifSuspended?: "reject" | "queue" };
|
|
488
602
|
await ensureScope();
|
|
489
603
|
// CANCEL-AND-REPLACE (#129): supersede every unfinished turn on this session before
|
|
490
604
|
// queuing this one (debounce). A mode flag like deliver/detach, so it rides the query
|
|
@@ -498,6 +612,15 @@ export class AgentDurableObject {
|
|
|
498
612
|
// surface: on a pre-start rejection the caller may fall back to consumer-side rendering
|
|
499
613
|
// without double-running the turn.
|
|
500
614
|
const wantsDeliver = url.searchParams.get("deliver") === "1";
|
|
615
|
+
const detached = url.searchParams.get("detach") === "1";
|
|
616
|
+
// HOLD (#263): only a caller with no live consumer may ask to be held — a streaming
|
|
617
|
+
// caller wants the reply now, and a held turn's reply comes after the park resolves.
|
|
618
|
+
if (ifSuspended === "queue" && !wantsDeliver && !detached) {
|
|
619
|
+
return Response.json({ error: `ifSuspended "queue" needs deliver=1 or detach=1: a held turn runs later, when no one is streaming it` }, { status: 400 });
|
|
620
|
+
}
|
|
621
|
+
if (ifSuspended === "queue" && replace) {
|
|
622
|
+
return Response.json({ error: `ifSuspended "queue" and replace=1 cannot be combined — replacing would drop held turns that were accepted` }, { status: 400 });
|
|
623
|
+
}
|
|
501
624
|
let deliverChannel: Channel | undefined;
|
|
502
625
|
if (wantsDeliver) {
|
|
503
626
|
if (!event?.channelId) {
|
|
@@ -517,15 +640,21 @@ export class AgentDurableObject {
|
|
|
517
640
|
// BEFORE this block — the section from start() to the subscription stays synchronous.
|
|
518
641
|
const resources = await this.ready();
|
|
519
642
|
let session: AgentSession;
|
|
520
|
-
let started: { turnId: string };
|
|
643
|
+
let started: { turnId: string; queued?: true };
|
|
521
644
|
try {
|
|
522
645
|
session = this.resolveSession(key);
|
|
523
|
-
|
|
646
|
+
// `hostContext` tells onDequeue how to finish a held turn (#263): a delivered one is
|
|
647
|
+
// rendered through its source channel when it runs, exactly as it would be now.
|
|
648
|
+
// start() itself runs turns a restart left held ahead of this one.
|
|
649
|
+
this.armWatchdog(); // a held turn commits a "held" announcement without starting (#260)
|
|
650
|
+
started = runInScope({ resources, services: this.services }, () =>
|
|
651
|
+
session.start({ userText, turnId, event, trigger, replace, ifSuspended, hostContext: wantsDeliver ? { deliver: true } : undefined }));
|
|
524
652
|
} catch (err) {
|
|
525
653
|
// e.g. the session is suspended awaiting input, or the key mis-matches this
|
|
526
654
|
// object's identity — a client-resolvable conflict, not a crash
|
|
527
655
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
528
656
|
}
|
|
657
|
+
if (started.queued) return Response.json({ turnId: started.turnId, queued: true }, { status: 202 });
|
|
529
658
|
if (wantsDeliver) {
|
|
530
659
|
// Subscribe synchronously (same guarantee sseTurnStream relies on: start() scheduled
|
|
531
660
|
// the turn, no event can have emitted yet), then render under THIS DO's lifetime —
|
|
@@ -565,7 +694,7 @@ export class AgentDurableObject {
|
|
|
565
694
|
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
566
695
|
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
567
696
|
// ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
|
|
568
|
-
if (
|
|
697
|
+
if (detached) return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
569
698
|
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
570
699
|
}
|
|
571
700
|
// Provide the input a suspended turn is waiting on and stream its continuation as SSE.
|
|
@@ -619,6 +748,7 @@ export class AgentDurableObject {
|
|
|
619
748
|
console.error(`[june] agent "${this.name}": authorizeAnswer failed for input "${inputId}" of turn ${turnId}:`, err);
|
|
620
749
|
return Response.json({ error: `authorizeAnswer failed: ${err instanceof Error ? err.message : String(err)} — the answer was NOT applied` }, { status: 500 });
|
|
621
750
|
}
|
|
751
|
+
this.armWatchdog(); // the answer commits a "resolved" announcement (#260)
|
|
622
752
|
try {
|
|
623
753
|
runInScope({ resources, services: this.services }, () => session.resume(turnId, inputId, input, { by, granted }));
|
|
624
754
|
} catch (err) {
|
|
@@ -662,6 +792,7 @@ export class AgentDurableObject {
|
|
|
662
792
|
} catch (err) {
|
|
663
793
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
664
794
|
}
|
|
795
|
+
this.armWatchdog(); // a reset can retire a park (#260)
|
|
665
796
|
try {
|
|
666
797
|
return Response.json(await session.reset());
|
|
667
798
|
} catch (err) {
|
|
@@ -669,6 +800,28 @@ export class AgentDurableObject {
|
|
|
669
800
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
670
801
|
}
|
|
671
802
|
}
|
|
803
|
+
// An attributed note (#262): body { by, kind, text } → { noteId }. A malformed body is a
|
|
804
|
+
// 400; a session-key conflict is a 409, like every other route.
|
|
805
|
+
if (req.method === "POST" && url.pathname.endsWith("/note")) {
|
|
806
|
+
let input: { by?: unknown; kind?: unknown; text?: unknown };
|
|
807
|
+
try {
|
|
808
|
+
input = (await req.json()) as typeof input;
|
|
809
|
+
} catch {
|
|
810
|
+
return Response.json({ error: "note: the body must be JSON { by, kind, text }" }, { status: 400 });
|
|
811
|
+
}
|
|
812
|
+
let session: AgentSession;
|
|
813
|
+
try {
|
|
814
|
+
session = this.resolveSession(key);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
return Response.json(await session.note(input as { by: string; kind: string; text: string }));
|
|
820
|
+
} catch (err) {
|
|
821
|
+
if (err instanceof TypeError) return Response.json({ error: err.message }, { status: 400 });
|
|
822
|
+
throw err;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
672
825
|
if (url.pathname.endsWith("/transcript")) {
|
|
673
826
|
// A keyed read still conflict-checks (and may commit the key — it is authentic,
|
|
674
827
|
// idFromName routed it here); a key-less read stays non-committal via transcript().
|
|
@@ -681,7 +834,7 @@ export class AgentDurableObject {
|
|
|
681
834
|
}
|
|
682
835
|
return Response.json({ transcript: this.transcript() });
|
|
683
836
|
}
|
|
684
|
-
return new Response("agent DO — POST /turn, POST /resume, POST /reset, or GET /transcript", { status: 404 });
|
|
837
|
+
return new Response("agent DO — POST /turn, POST /resume, POST /reset, POST /note, or GET /transcript", { status: 404 });
|
|
685
838
|
}
|
|
686
839
|
}
|
|
687
840
|
|
|
@@ -964,7 +1117,7 @@ export function durableChannelSurface(
|
|
|
964
1117
|
const detail = (await res.text()).slice(0, 200);
|
|
965
1118
|
throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
|
|
966
1119
|
}
|
|
967
|
-
return (await res.json()) as { turnId: string };
|
|
1120
|
+
return (await res.json()) as { turnId: string; queued?: true };
|
|
968
1121
|
},
|
|
969
1122
|
// DELIVERED: runDetached's reply-bearing sibling — the DO runs the turn AND renders its
|
|
970
1123
|
// reply through the source channel's deliver() under its OWN lifetime, so a reply-bearing
|
|
@@ -988,7 +1141,7 @@ export function durableChannelSurface(
|
|
|
988
1141
|
const detail = (await res.text()).slice(0, 200);
|
|
989
1142
|
throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
|
|
990
1143
|
}
|
|
991
|
-
return (await res.json()) as { turnId: string };
|
|
1144
|
+
return (await res.json()) as { turnId: string; queued?: true };
|
|
992
1145
|
},
|
|
993
1146
|
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
994
1147
|
runStream: async function* (message, o) {
|
|
@@ -1086,8 +1239,8 @@ function serializeResume(o: { turnId: string; inputId: string; input: unknown; b
|
|
|
1086
1239
|
|
|
1087
1240
|
// `trigger` is ProactiveTrigger — plain strings by construction — so only event.raw can make
|
|
1088
1241
|
// the stringify throw; the fallback that strips it stays sufficient.
|
|
1089
|
-
function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger }): string {
|
|
1090
|
-
const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger };
|
|
1242
|
+
function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger; ifSuspended?: "reject" | "queue" }): string {
|
|
1243
|
+
const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger, ifSuspended: o?.ifSuspended };
|
|
1091
1244
|
try {
|
|
1092
1245
|
return JSON.stringify(payload);
|
|
1093
1246
|
} catch {
|
package/src/agent-native.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
withSystem,
|
|
14
14
|
type ChannelPolicy,
|
|
15
15
|
type EventSink,
|
|
16
|
+
type InputAnnouncement,
|
|
16
17
|
type TurnEvent,
|
|
17
18
|
type Model,
|
|
18
19
|
type Msg,
|
|
@@ -92,7 +93,7 @@ class SqliteSessionStore implements SessionStore {
|
|
|
92
93
|
// generation (audit trail — never deleted), clear the live rows, status → "new".
|
|
93
94
|
// Archive tables + the generation counter are created lazily here, so existing
|
|
94
95
|
// databases stay untouched until the first reset.
|
|
95
|
-
reset(): number {
|
|
96
|
+
reset(inTx?: () => void): number {
|
|
96
97
|
return this.tx(() => {
|
|
97
98
|
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
|
|
98
99
|
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
|
|
@@ -105,6 +106,7 @@ class SqliteSessionStore implements SessionStore {
|
|
|
105
106
|
this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
|
|
106
107
|
this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
|
|
107
108
|
this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
|
|
109
|
+
inTx?.(); // writes into the fresh generation, committed with the archive (#260)
|
|
108
110
|
return generation;
|
|
109
111
|
});
|
|
110
112
|
}
|
|
@@ -120,7 +122,68 @@ class InProcEventSink implements EventSink {
|
|
|
120
122
|
|
|
121
123
|
// `instructions` (the agent's system prompt) is injected into the model per turn
|
|
122
124
|
// by the runtime (withSystem) — single-sourced on the def, not baked into `model`.
|
|
123
|
-
export type AgentDef = {
|
|
125
|
+
export type AgentDef = {
|
|
126
|
+
model: Model;
|
|
127
|
+
tools: Tool[];
|
|
128
|
+
instructions?: string;
|
|
129
|
+
channelInstructions?: Record<string, string | ChannelPolicy>;
|
|
130
|
+
// The app's receiver for input announcements (#260), installed on every session this
|
|
131
|
+
// runtime builds; undelivered announcements from an earlier process are flushed then.
|
|
132
|
+
onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Retries a failed announcement delivery on a timer (#260): the engine keeps an undelivered
|
|
136
|
+
// announcement and flushes again on the next announcement, settled turn or rebuild — but a
|
|
137
|
+
// session that goes quiet would hold it until then. 5 s doubling to 5 min, per session;
|
|
138
|
+
// unref'd, so a pending retry never holds the process open. Owned by the runtime, so the
|
|
139
|
+
// backoff survives an evicted-and-rebuilt actor.
|
|
140
|
+
class AnnouncementRetry {
|
|
141
|
+
// Explicit fields (not parameter properties) — keep the shipped source erasable.
|
|
142
|
+
private readonly flush: (agent: string, id: string) => void;
|
|
143
|
+
private readonly state = new Map<string, { failures: number; timer?: ReturnType<typeof setTimeout> }>();
|
|
144
|
+
constructor(flush: (agent: string, id: string) => void) {
|
|
145
|
+
this.flush = flush;
|
|
146
|
+
}
|
|
147
|
+
delivered(key: string) {
|
|
148
|
+
const s = this.state.get(key);
|
|
149
|
+
if (s?.timer) clearTimeout(s.timer);
|
|
150
|
+
this.state.delete(key);
|
|
151
|
+
}
|
|
152
|
+
failed(key: string, agent: string, id: string) {
|
|
153
|
+
const s = this.state.get(key) ?? { failures: 0 };
|
|
154
|
+
this.state.set(key, s);
|
|
155
|
+
if (s.timer) return; // one retry pending at a time
|
|
156
|
+
const delay = Math.min(5_000 * 2 ** s.failures, 300_000);
|
|
157
|
+
s.failures++;
|
|
158
|
+
s.timer = setTimeout(() => { s.timer = undefined; this.flush(agent, id); }, delay);
|
|
159
|
+
(s.timer as { unref?: () => void }).unref?.();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Install the announcement hook on a freshly built session and deliver what an earlier life
|
|
164
|
+
// of it recorded but never delivered (#260). The flush never rejects; it logs — and a
|
|
165
|
+
// failure schedules a retry.
|
|
166
|
+
function wireAnnouncements(session: AgentSession, def: AgentDef, retry: AnnouncementRetry): AgentSession {
|
|
167
|
+
const hook = def.onInputAnnouncement;
|
|
168
|
+
if (hook) {
|
|
169
|
+
const key = `${session.agent}:${session.id}`;
|
|
170
|
+
session.onAnnounce = async (a) => {
|
|
171
|
+
try {
|
|
172
|
+
await hook(a);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
retry.failed(key, session.agent, session.id);
|
|
175
|
+
throw err; // the engine keeps the announcement and logs
|
|
176
|
+
}
|
|
177
|
+
retry.delivered(key);
|
|
178
|
+
};
|
|
179
|
+
void session.flushAnnouncements();
|
|
180
|
+
}
|
|
181
|
+
return session;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The step the engine keeps its announcement outbox under (#260) — read by the native
|
|
185
|
+
// runtime's startup scan. Mirrors ANNOUNCE_OUTBOX in @junejs/core/agent-runtime.
|
|
186
|
+
const ANNOUNCE_OUTBOX_STEP = "announce-outbox";
|
|
124
187
|
|
|
125
188
|
// The runtime-side def for an assembled AgentDefinition (#173): the tools (channel
|
|
126
189
|
// capability tools and read_skill included), the system prompt (instructions + the
|
|
@@ -134,6 +197,7 @@ export function toAgentDef(agent: AgentDefinition, model: Model): AgentDef {
|
|
|
134
197
|
tools: agent.tools,
|
|
135
198
|
instructions: buildSystemPrompt(agent),
|
|
136
199
|
...(agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {}),
|
|
200
|
+
...(agent.onInputAnnouncement ? { onInputAnnouncement: agent.onInputAnnouncement } : {}),
|
|
137
201
|
};
|
|
138
202
|
}
|
|
139
203
|
|
|
@@ -156,6 +220,8 @@ export class NativeRuntime implements Runtime {
|
|
|
156
220
|
private actors = new Map<string, { session: AgentSession; sink: InProcEventSink }>();
|
|
157
221
|
private readonly agents: Record<string, AgentDef>;
|
|
158
222
|
private readonly db: SyncSqlite;
|
|
223
|
+
// Retries failed announcement deliveries (#260), across actor rebuilds.
|
|
224
|
+
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); });
|
|
159
225
|
private readonly maxSessions: number;
|
|
160
226
|
|
|
161
227
|
constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
|
|
@@ -188,7 +254,7 @@ export class NativeRuntime implements Runtime {
|
|
|
188
254
|
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
189
255
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
190
256
|
const sink = new InProcEventSink();
|
|
191
|
-
const session = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
|
|
257
|
+
const session = wireAnnouncements(new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions), def, this.announceRetry);
|
|
192
258
|
this.evictIdle();
|
|
193
259
|
this.actors.set(key, { session, sink });
|
|
194
260
|
return session;
|
|
@@ -197,6 +263,19 @@ export class NativeRuntime implements Runtime {
|
|
|
197
263
|
// Number of memoized actors (observability / tests).
|
|
198
264
|
get sessionCount(): number { return this.actors.size; }
|
|
199
265
|
|
|
266
|
+
// Deliver announcements an earlier process recorded but never delivered (#260), without
|
|
267
|
+
// waiting for their sessions to be used again: build every session whose outbox is not
|
|
268
|
+
// empty — building one flushes it. The agent and session come from the announcement
|
|
269
|
+
// itself, not from splitting the store key (an agent name may contain ":"). Called once
|
|
270
|
+
// by createNativeRuntime; safe to call again.
|
|
271
|
+
recoverAnnouncements(): void {
|
|
272
|
+
const rows = this.db.query("SELECT output FROM agent_steps WHERE id = ?").all(ANNOUNCE_OUTBOX_STEP) as { output: string }[];
|
|
273
|
+
for (const { output } of rows) {
|
|
274
|
+
const [first] = JSON.parse(output) as InputAnnouncement[];
|
|
275
|
+
if (first && this.agents[first.agent]?.onInputAnnouncement) this.session(first.agent, first.session);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
200
279
|
// Drop least recently used idle actors until one more fits under the cap. Runs before
|
|
201
280
|
// the new actor is inserted, so the actor being handed out is never a candidate.
|
|
202
281
|
private evictIdle() {
|
|
@@ -216,7 +295,9 @@ export async function createNativeRuntime(
|
|
|
216
295
|
path = ":memory:",
|
|
217
296
|
opts: NativeRuntimeOptions = {},
|
|
218
297
|
): Promise<NativeRuntime> {
|
|
219
|
-
|
|
298
|
+
const runtime = new NativeRuntime(agents, await openLocalSqliteSync(path), opts);
|
|
299
|
+
runtime.recoverAnnouncements(); // #260
|
|
300
|
+
return runtime;
|
|
220
301
|
}
|
|
221
302
|
|
|
222
303
|
// ── memory backend — in-process, ephemeral (no DB, no disk) ───────────────────
|
|
@@ -242,12 +323,13 @@ class MemorySessionStore implements SessionStore {
|
|
|
242
323
|
getStatus(): string { return this.status; }
|
|
243
324
|
setStatus(s: string) { this.status = s; }
|
|
244
325
|
tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
|
|
245
|
-
reset(): number {
|
|
326
|
+
reset(inTx?: () => void): number {
|
|
246
327
|
const generation = this.generation++;
|
|
247
328
|
this.archives.push({ generation, msgs: this.msgs, steps: this.steps });
|
|
248
329
|
this.msgs = [];
|
|
249
330
|
this.steps = new Map();
|
|
250
331
|
this.status = "new";
|
|
332
|
+
inTx?.(); // writes into the fresh generation (#260)
|
|
251
333
|
return generation;
|
|
252
334
|
}
|
|
253
335
|
unwrap<H = unknown>(): H { return undefined as unknown as H; }
|
|
@@ -257,6 +339,7 @@ export class MemoryRuntime implements Runtime {
|
|
|
257
339
|
private actors = new Map<string, AgentSession>();
|
|
258
340
|
private stores = new Map<string, MemorySessionStore>();
|
|
259
341
|
private readonly agents: Record<string, AgentDef>;
|
|
342
|
+
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); }); // #260
|
|
260
343
|
constructor(agents: Record<string, AgentDef>) {
|
|
261
344
|
assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
|
|
262
345
|
this.agents = agents;
|
|
@@ -272,7 +355,7 @@ export class MemoryRuntime implements Runtime {
|
|
|
272
355
|
this.stores.set(key, store);
|
|
273
356
|
// Same def handling as NativeRuntime: switching backend must not change behavior.
|
|
274
357
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
275
|
-
a = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
|
|
358
|
+
a = wireAnnouncements(new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions), def, this.announceRetry);
|
|
276
359
|
this.actors.set(key, a);
|
|
277
360
|
}
|
|
278
361
|
return a;
|
package/src/build.ts
CHANGED
|
@@ -417,6 +417,10 @@ export class JuneAgentDO extends DurableObject {
|
|
|
417
417
|
fetch(req: Request): Promise<Response> {
|
|
418
418
|
return this.#agent.fetch(req);
|
|
419
419
|
}
|
|
420
|
+
// Delivers input announcements left undelivered — a failed hook's retry, or the watchdog (#260).
|
|
421
|
+
alarm(): Promise<void> {
|
|
422
|
+
return this.#agent.alarm();
|
|
423
|
+
}
|
|
420
424
|
}
|
|
421
425
|
`
|
|
422
426
|
: "";
|
package/src/core-version.ts
CHANGED
|
@@ -15,7 +15,7 @@ import * as coreRuntime from "@junejs/core/agent-runtime";
|
|
|
15
15
|
|
|
16
16
|
// The contract number this server tree is built against. Bump in lockstep with
|
|
17
17
|
// core's RUNTIME_API_VERSION whenever the server↔core runtime contract changes.
|
|
18
|
-
const EXPECTED_CORE_RUNTIME_API =
|
|
18
|
+
const EXPECTED_CORE_RUNTIME_API = 6;
|
|
19
19
|
|
|
20
20
|
export function assertCoreRuntimeVersion(surface: string): void {
|
|
21
21
|
checkCoreRuntimeVersion(surface, (coreRuntime as { RUNTIME_API_VERSION?: number }).RUNTIME_API_VERSION);
|