@junejs/server 1.0.0-dev.29 → 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-compile.d.ts +8 -9
- package/dist/agent-discover.d.ts +3 -4
- package/dist/agent-durable.d.ts +38 -20
- package/dist/agent-durable.js +159 -13
- package/dist/agent-native.d.ts +15 -12
- package/dist/agent-native.js +72 -8
- package/dist/app.d.ts +4 -5
- package/dist/app.js +2 -1
- package/dist/blob.d.ts +4 -5
- package/dist/build.d.ts +6 -6
- package/dist/build.js +38 -7
- package/dist/client-bundle.d.ts +8 -9
- package/dist/{config-freeze-DwI-Rmvq.d.ts → config-freeze-ZWC_Qd4h.d.ts} +1 -1
- package/dist/config-freeze.d.ts +1 -1
- package/dist/config-loader.d.ts +3 -4
- package/dist/connection-auth.d.ts +8 -9
- package/dist/content.d.ts +9 -10
- package/dist/core-version.d.ts +3 -4
- package/dist/core-version.js +1 -1
- package/dist/css-modules-ambient.d.ts +2 -1
- package/dist/css-modules.d.ts +10 -11
- package/dist/css.d.ts +9 -10
- package/dist/d1-migrate.d.ts +6 -7
- package/dist/d1-migrate.js +3 -2
- package/dist/db.d.ts +2 -2
- package/dist/deploy.d.ts +3 -4
- package/dist/deploy.js +3 -2
- package/dist/dev-reload.d.ts +3 -4
- package/dist/dev.d.ts +4 -5
- package/dist/favicon.d.ts +16 -17
- package/dist/host.d.ts +5 -5
- package/dist/icon-letter.d.ts +2 -3
- package/dist/index.d.ts +4 -4
- package/dist/instrumentation.d.ts +2 -3
- package/dist/instrumentation.js +2 -1
- package/dist/island-registry.d.ts +6 -7
- package/dist/isolate-local.d.ts +4 -5
- package/dist/kv.d.ts +3 -4
- package/dist/llms-links.d.ts +8 -5
- package/dist/llms-links.js +31 -1
- package/dist/{manifest-gcNWqWoI.d.ts → manifest-DSNX4I-G.d.ts} +2 -2
- package/dist/manifest.d.ts +1 -1
- package/dist/negotiate.d.ts +4 -3
- package/dist/negotiate.js +24 -14
- package/dist/{pipeline-cCR6MGNK.d.ts → pipeline-BHUjBO0G.d.ts} +5 -1
- package/dist/pipeline.d.ts +2 -2
- package/dist/pipeline.js +260 -41
- package/dist/resources.d.ts +5 -6
- package/dist/router.d.ts +12 -13
- package/dist/rsc-build.d.ts +6 -7
- package/dist/rsc-bundle.d.ts +6 -7
- package/dist/rsc-manifest.d.ts +5 -6
- package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
- package/dist/rsc-runtime/flight-render.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.js +3 -2
- package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
- package/dist/segment.d.ts +4 -5
- package/dist/sqlite-driver.d.ts +10 -11
- package/dist/static-files.d.ts +4 -5
- package/dist/tsconfig-jsx.d.ts +4 -5
- package/dist/turn-events.d.ts +2 -3
- package/dist/web-manifest.d.ts +3 -4
- package/dist/web-manifest.js +2 -6
- package/dist/{worker-BkFHGtxp.d.ts → worker-Dv4l6Ybs.d.ts} +4 -2
- package/dist/worker.d.ts +1 -1
- package/dist/worker.js +35 -18
- package/package.json +5 -5
- package/src/agent-durable.ts +203 -21
- package/src/agent-native.ts +94 -7
- package/src/build.ts +67 -5
- package/src/core-version.ts +1 -1
- package/src/llms-links.ts +46 -1
- package/src/negotiate.ts +39 -15
- package/src/pipeline.ts +433 -45
- package/src/web-manifest.ts +2 -6
- package/src/worker.ts +55 -17
package/src/agent-durable.ts
CHANGED
|
@@ -17,8 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
AgentSession,
|
|
20
|
+
grantAnswer,
|
|
20
21
|
ResumeAuthorizationError,
|
|
21
22
|
withSystem,
|
|
23
|
+
type Answerers,
|
|
24
|
+
type AuthorizeAnswer,
|
|
25
|
+
type InputAnnouncement,
|
|
26
|
+
type NoteKind,
|
|
22
27
|
type ChannelPolicy,
|
|
23
28
|
type EventSink,
|
|
24
29
|
type TurnEvent,
|
|
@@ -32,6 +37,7 @@ import {
|
|
|
32
37
|
type TurnError,
|
|
33
38
|
type TurnFailurePhase,
|
|
34
39
|
} from "@junejs/core/agent-runtime";
|
|
40
|
+
import type { Principal } from "@junejs/core/context";
|
|
35
41
|
import type { Resources } from "@junejs/core/resources";
|
|
36
42
|
import { observeTurnEvents } from "./turn-events";
|
|
37
43
|
import {
|
|
@@ -72,6 +78,10 @@ export interface SqlStorage {
|
|
|
72
78
|
export interface DurableStorage {
|
|
73
79
|
sql: SqlStorage;
|
|
74
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;
|
|
75
85
|
}
|
|
76
86
|
export interface DurableObjectState {
|
|
77
87
|
storage: DurableStorage;
|
|
@@ -147,7 +157,7 @@ export class DoSessionStore implements SessionStore {
|
|
|
147
157
|
// tables + the generation counter are created lazily, so existing objects stay
|
|
148
158
|
// untouched until their first reset. seq keeps counting across generations (DELETE
|
|
149
159
|
// doesn't reset AUTOINCREMENT), so an archived (generation, seq) stays unique.
|
|
150
|
-
reset(): number {
|
|
160
|
+
reset(inTx?: () => void): number {
|
|
151
161
|
return this.storage.transactionSync(() => {
|
|
152
162
|
const sql = this.sql;
|
|
153
163
|
sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
|
|
@@ -160,6 +170,7 @@ export class DoSessionStore implements SessionStore {
|
|
|
160
170
|
sql.exec("DELETE FROM agent_steps");
|
|
161
171
|
this.setStatus("new");
|
|
162
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)
|
|
163
174
|
return generation;
|
|
164
175
|
});
|
|
165
176
|
}
|
|
@@ -238,6 +249,12 @@ export type DoAgentDef = {
|
|
|
238
249
|
// emitted mid-turn and ALS propagates), but that is NOT contract — don't rely on
|
|
239
250
|
// ambient db/services; close over what you need.
|
|
240
251
|
onTurnError?: (failure: { turnId: string; error: TurnError; phase?: TurnFailurePhase; step?: string }) => void | Promise<void>;
|
|
252
|
+
// Decides a { policy } answerer of a parked requestInput at /resume (#261) — the same hook
|
|
253
|
+
// an AgentDefinition carries natively; assembleDurable passes agent.ts's through.
|
|
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>;
|
|
241
258
|
};
|
|
242
259
|
|
|
243
260
|
// The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
|
|
@@ -280,6 +297,11 @@ export class AgentDurableObject {
|
|
|
280
297
|
private connectionsOpened: Promise<void> | undefined;
|
|
281
298
|
private readonly doEnv: unknown;
|
|
282
299
|
private readonly services: unknown;
|
|
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;
|
|
283
305
|
private resolveResources(): Promise<Resources> {
|
|
284
306
|
return (this.resourcesOpened ??= Promise.resolve(
|
|
285
307
|
typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : (this.resourcesInput ?? {}),
|
|
@@ -321,6 +343,9 @@ export class AgentDurableObject {
|
|
|
321
343
|
this.connectionsInput = def.connections;
|
|
322
344
|
this.doEnv = def.env;
|
|
323
345
|
this.services = def.services;
|
|
346
|
+
this.authorizeAnswer = def.authorizeAnswer;
|
|
347
|
+
this.onInputAnnouncement = def.onInputAnnouncement;
|
|
348
|
+
this.storage = state.storage;
|
|
324
349
|
const name = def.name ?? "agent";
|
|
325
350
|
// Failure observability (#76): a turn that dies after the fast-ACK has no other
|
|
326
351
|
// observable surface on the edge — the webhook already 200'd and runBackground
|
|
@@ -330,6 +355,13 @@ export class AgentDurableObject {
|
|
|
330
355
|
// over reporting; if IT throws, fall back to the default so nothing goes silent.
|
|
331
356
|
// (InProcEventSink already guards emit against a throwing subscriber.)
|
|
332
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
|
+
});
|
|
333
365
|
sink.subscribe((e) => {
|
|
334
366
|
if (e.type !== "turn.failed") return;
|
|
335
367
|
// The stack already opens with the message; log it INSTEAD of the message line
|
|
@@ -427,8 +459,91 @@ export class AgentDurableObject {
|
|
|
427
459
|
if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
|
|
428
460
|
this.sessionKey = resolved;
|
|
429
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
|
+
}
|
|
430
492
|
return this.session;
|
|
431
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
|
+
}
|
|
432
547
|
// Run the whole turn inside a request scope seeded from this DO's env, so ambient
|
|
433
548
|
// `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
|
|
434
549
|
// route loader. `locals` is intentionally NOT set here: a fresh scope object per
|
|
@@ -452,10 +567,18 @@ export class AgentDurableObject {
|
|
|
452
567
|
const session = this.resolveSession(input.session);
|
|
453
568
|
return runInScope({ resources, services: this.services }, () => session.start(input));
|
|
454
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
|
+
}
|
|
455
576
|
// Session reset (#129) for custom shells — the direct sibling of the /reset route. No
|
|
456
577
|
// scope needed: reset touches only the store (no tools run).
|
|
457
578
|
reset(opts?: { session?: string }): Promise<{ previousSession: string; generation: number }> {
|
|
458
|
-
|
|
579
|
+
const session = this.resolveSession(opts?.session);
|
|
580
|
+
this.armWatchdog(); // a reset can retire a park (#260)
|
|
581
|
+
return session.reset();
|
|
459
582
|
}
|
|
460
583
|
// Read-only: folds the durable log. When an identity exists (live, or persisted from a
|
|
461
584
|
// prior life) the session resolves and caches like any other path. Only a read on a
|
|
@@ -475,7 +598,7 @@ export class AgentDurableObject {
|
|
|
475
598
|
// hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
|
|
476
599
|
const key = req.headers.get(SESSION_HEADER) ?? undefined;
|
|
477
600
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
478
|
-
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" };
|
|
479
602
|
await ensureScope();
|
|
480
603
|
// CANCEL-AND-REPLACE (#129): supersede every unfinished turn on this session before
|
|
481
604
|
// queuing this one (debounce). A mode flag like deliver/detach, so it rides the query
|
|
@@ -489,6 +612,15 @@ export class AgentDurableObject {
|
|
|
489
612
|
// surface: on a pre-start rejection the caller may fall back to consumer-side rendering
|
|
490
613
|
// without double-running the turn.
|
|
491
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
|
+
}
|
|
492
624
|
let deliverChannel: Channel | undefined;
|
|
493
625
|
if (wantsDeliver) {
|
|
494
626
|
if (!event?.channelId) {
|
|
@@ -508,15 +640,21 @@ export class AgentDurableObject {
|
|
|
508
640
|
// BEFORE this block — the section from start() to the subscription stays synchronous.
|
|
509
641
|
const resources = await this.ready();
|
|
510
642
|
let session: AgentSession;
|
|
511
|
-
let started: { turnId: string };
|
|
643
|
+
let started: { turnId: string; queued?: true };
|
|
512
644
|
try {
|
|
513
645
|
session = this.resolveSession(key);
|
|
514
|
-
|
|
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 }));
|
|
515
652
|
} catch (err) {
|
|
516
653
|
// e.g. the session is suspended awaiting input, or the key mis-matches this
|
|
517
654
|
// object's identity — a client-resolvable conflict, not a crash
|
|
518
655
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
519
656
|
}
|
|
657
|
+
if (started.queued) return Response.json({ turnId: started.turnId, queued: true }, { status: 202 });
|
|
520
658
|
if (wantsDeliver) {
|
|
521
659
|
// Subscribe synchronously (same guarantee sseTurnStream relies on: start() scheduled
|
|
522
660
|
// the turn, no event can have emitted yet), then render under THIS DO's lifetime —
|
|
@@ -556,7 +694,7 @@ export class AgentDurableObject {
|
|
|
556
694
|
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
557
695
|
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
558
696
|
// ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
|
|
559
|
-
if (
|
|
697
|
+
if (detached) return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
560
698
|
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
561
699
|
}
|
|
562
700
|
// Provide the input a suspended turn is waiting on and stream its continuation as SSE.
|
|
@@ -564,8 +702,8 @@ export class AgentDurableObject {
|
|
|
564
702
|
// VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
|
|
565
703
|
// signature-checked Slack interaction payload), never expose this endpoint raw to clients.
|
|
566
704
|
if (req.method === "POST" && url.pathname.endsWith("/resume")) {
|
|
567
|
-
const { turnId, inputId, input, by, source, target } = (await req.json()) as {
|
|
568
|
-
turnId: string; inputId: string; input: unknown; by?: string;
|
|
705
|
+
const { turnId, inputId, input, by, principal, source, target } = (await req.json()) as {
|
|
706
|
+
turnId: string; inputId: string; input: unknown; by?: string; principal?: Principal;
|
|
569
707
|
source?: string; target?: ResumeDeliveryTarget;
|
|
570
708
|
};
|
|
571
709
|
await ensureScope();
|
|
@@ -595,7 +733,24 @@ export class AgentDurableObject {
|
|
|
595
733
|
let session: AgentSession;
|
|
596
734
|
try {
|
|
597
735
|
session = this.resolveSession(key);
|
|
598
|
-
|
|
736
|
+
} catch (err) {
|
|
737
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
738
|
+
}
|
|
739
|
+
// A { policy } answerer is the app's call (#261), made before the synchronous
|
|
740
|
+
// resume-then-subscribe section and inside the request scope (the hook may read the db).
|
|
741
|
+
// The hook is app code: a failure there (a db outage) is a 500 — never the 409 a
|
|
742
|
+
// resume-state conflict gets, which would tell the caller its answer is stale.
|
|
743
|
+
let granted: Answerers | undefined;
|
|
744
|
+
try {
|
|
745
|
+
granted = await runInScope({ resources, services: this.services }, () =>
|
|
746
|
+
grantAnswer(session, { turnId, inputId, by, principal }, this.authorizeAnswer));
|
|
747
|
+
} catch (err) {
|
|
748
|
+
console.error(`[june] agent "${this.name}": authorizeAnswer failed for input "${inputId}" of turn ${turnId}:`, err);
|
|
749
|
+
return Response.json({ error: `authorizeAnswer failed: ${err instanceof Error ? err.message : String(err)} — the answer was NOT applied` }, { status: 500 });
|
|
750
|
+
}
|
|
751
|
+
this.armWatchdog(); // the answer commits a "resolved" announcement (#260)
|
|
752
|
+
try {
|
|
753
|
+
runInScope({ resources, services: this.services }, () => session.resume(turnId, inputId, input, { by, granted }));
|
|
599
754
|
} catch (err) {
|
|
600
755
|
const message = err instanceof Error ? err.message : String(err);
|
|
601
756
|
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
|
|
@@ -637,6 +792,7 @@ export class AgentDurableObject {
|
|
|
637
792
|
} catch (err) {
|
|
638
793
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
639
794
|
}
|
|
795
|
+
this.armWatchdog(); // a reset can retire a park (#260)
|
|
640
796
|
try {
|
|
641
797
|
return Response.json(await session.reset());
|
|
642
798
|
} catch (err) {
|
|
@@ -644,6 +800,28 @@ export class AgentDurableObject {
|
|
|
644
800
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
645
801
|
}
|
|
646
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
|
+
}
|
|
647
825
|
if (url.pathname.endsWith("/transcript")) {
|
|
648
826
|
// A keyed read still conflict-checks (and may commit the key — it is authentic,
|
|
649
827
|
// idFromName routed it here); a key-less read stays non-committal via transcript().
|
|
@@ -656,7 +834,7 @@ export class AgentDurableObject {
|
|
|
656
834
|
}
|
|
657
835
|
return Response.json({ transcript: this.transcript() });
|
|
658
836
|
}
|
|
659
|
-
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 });
|
|
660
838
|
}
|
|
661
839
|
}
|
|
662
840
|
|
|
@@ -939,7 +1117,7 @@ export function durableChannelSurface(
|
|
|
939
1117
|
const detail = (await res.text()).slice(0, 200);
|
|
940
1118
|
throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
|
|
941
1119
|
}
|
|
942
|
-
return (await res.json()) as { turnId: string };
|
|
1120
|
+
return (await res.json()) as { turnId: string; queued?: true };
|
|
943
1121
|
},
|
|
944
1122
|
// DELIVERED: runDetached's reply-bearing sibling — the DO runs the turn AND renders its
|
|
945
1123
|
// reply through the source channel's deliver() under its OWN lifetime, so a reply-bearing
|
|
@@ -963,7 +1141,7 @@ export function durableChannelSurface(
|
|
|
963
1141
|
const detail = (await res.text()).slice(0, 200);
|
|
964
1142
|
throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
|
|
965
1143
|
}
|
|
966
|
-
return (await res.json()) as { turnId: string };
|
|
1144
|
+
return (await res.json()) as { turnId: string; queued?: true };
|
|
967
1145
|
},
|
|
968
1146
|
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
969
1147
|
runStream: async function* (message, o) {
|
|
@@ -1042,23 +1220,27 @@ export function durableChannelSurface(
|
|
|
1042
1220
|
// run the turn, so on a serialization failure we drop it rather than let an
|
|
1043
1221
|
// unserializable payload take down turn forwarding entirely. (raw is optional on
|
|
1044
1222
|
// InboundEvent precisely because it may not survive this boundary.)
|
|
1045
|
-
// Serialize a /resume RPC body. `input`
|
|
1046
|
-
// (third-party) host could
|
|
1047
|
-
// Unlike serializeTurn's `raw`,
|
|
1048
|
-
//
|
|
1049
|
-
|
|
1223
|
+
// Serialize a /resume RPC body. `input` (the human's answer) and `principal` (the resumer,
|
|
1224
|
+
// whose extra fields are `unknown`) come from a (third-party) host and could hold something
|
|
1225
|
+
// JSON.stringify chokes on (BigInt, circular). Unlike serializeTurn's `raw`, neither is
|
|
1226
|
+
// droppable: resuming without them answers wrongly or as the wrong person. So fail loudly,
|
|
1227
|
+
// naming the field that failed, rather than corrupt the resume.
|
|
1228
|
+
function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string; principal?: Principal; source?: string; target?: ResumeDeliveryTarget }): string {
|
|
1050
1229
|
try {
|
|
1051
|
-
return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by, source: o.source, target: o.target });
|
|
1230
|
+
return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by, principal: o.principal, source: o.source, target: o.target });
|
|
1052
1231
|
} catch (err) {
|
|
1232
|
+
const culprit = (["input", "principal"] as const).find((field) => {
|
|
1233
|
+
try { JSON.stringify(o[field]); return false; } catch { return true; }
|
|
1234
|
+
}) ?? "payload";
|
|
1053
1235
|
// API-neutral prefix: this serializer backs both resumeStream and resumeDelivered.
|
|
1054
|
-
throw new Error(`resume:
|
|
1236
|
+
throw new Error(`resume: ${culprit} is not JSON-serializable (${(err as Error).message}) — a resume must round-trip to the DO`);
|
|
1055
1237
|
}
|
|
1056
1238
|
}
|
|
1057
1239
|
|
|
1058
1240
|
// `trigger` is ProactiveTrigger — plain strings by construction — so only event.raw can make
|
|
1059
1241
|
// the stringify throw; the fallback that strips it stays sufficient.
|
|
1060
|
-
function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger }): string {
|
|
1061
|
-
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 };
|
|
1062
1244
|
try {
|
|
1063
1245
|
return JSON.stringify(payload);
|
|
1064
1246
|
} catch {
|
package/src/agent-native.ts
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AgentSession,
|
|
12
|
+
grantAnswer,
|
|
12
13
|
withSystem,
|
|
13
14
|
type ChannelPolicy,
|
|
14
15
|
type EventSink,
|
|
16
|
+
type InputAnnouncement,
|
|
15
17
|
type TurnEvent,
|
|
16
18
|
type Model,
|
|
17
19
|
type Msg,
|
|
@@ -91,7 +93,7 @@ class SqliteSessionStore implements SessionStore {
|
|
|
91
93
|
// generation (audit trail — never deleted), clear the live rows, status → "new".
|
|
92
94
|
// Archive tables + the generation counter are created lazily here, so existing
|
|
93
95
|
// databases stay untouched until the first reset.
|
|
94
|
-
reset(): number {
|
|
96
|
+
reset(inTx?: () => void): number {
|
|
95
97
|
return this.tx(() => {
|
|
96
98
|
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
|
|
97
99
|
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
|
|
@@ -104,6 +106,7 @@ class SqliteSessionStore implements SessionStore {
|
|
|
104
106
|
this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
|
|
105
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);
|
|
106
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)
|
|
107
110
|
return generation;
|
|
108
111
|
});
|
|
109
112
|
}
|
|
@@ -119,7 +122,68 @@ class InProcEventSink implements EventSink {
|
|
|
119
122
|
|
|
120
123
|
// `instructions` (the agent's system prompt) is injected into the model per turn
|
|
121
124
|
// by the runtime (withSystem) — single-sourced on the def, not baked into `model`.
|
|
122
|
-
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";
|
|
123
187
|
|
|
124
188
|
// The runtime-side def for an assembled AgentDefinition (#173): the tools (channel
|
|
125
189
|
// capability tools and read_skill included), the system prompt (instructions + the
|
|
@@ -133,6 +197,7 @@ export function toAgentDef(agent: AgentDefinition, model: Model): AgentDef {
|
|
|
133
197
|
tools: agent.tools,
|
|
134
198
|
instructions: buildSystemPrompt(agent),
|
|
135
199
|
...(agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {}),
|
|
200
|
+
...(agent.onInputAnnouncement ? { onInputAnnouncement: agent.onInputAnnouncement } : {}),
|
|
136
201
|
};
|
|
137
202
|
}
|
|
138
203
|
|
|
@@ -155,6 +220,8 @@ export class NativeRuntime implements Runtime {
|
|
|
155
220
|
private actors = new Map<string, { session: AgentSession; sink: InProcEventSink }>();
|
|
156
221
|
private readonly agents: Record<string, AgentDef>;
|
|
157
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(); });
|
|
158
225
|
private readonly maxSessions: number;
|
|
159
226
|
|
|
160
227
|
constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
|
|
@@ -187,7 +254,7 @@ export class NativeRuntime implements Runtime {
|
|
|
187
254
|
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
188
255
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
189
256
|
const sink = new InProcEventSink();
|
|
190
|
-
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);
|
|
191
258
|
this.evictIdle();
|
|
192
259
|
this.actors.set(key, { session, sink });
|
|
193
260
|
return session;
|
|
@@ -196,6 +263,19 @@ export class NativeRuntime implements Runtime {
|
|
|
196
263
|
// Number of memoized actors (observability / tests).
|
|
197
264
|
get sessionCount(): number { return this.actors.size; }
|
|
198
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
|
+
|
|
199
279
|
// Drop least recently used idle actors until one more fits under the cap. Runs before
|
|
200
280
|
// the new actor is inserted, so the actor being handed out is never a candidate.
|
|
201
281
|
private evictIdle() {
|
|
@@ -215,7 +295,9 @@ export async function createNativeRuntime(
|
|
|
215
295
|
path = ":memory:",
|
|
216
296
|
opts: NativeRuntimeOptions = {},
|
|
217
297
|
): Promise<NativeRuntime> {
|
|
218
|
-
|
|
298
|
+
const runtime = new NativeRuntime(agents, await openLocalSqliteSync(path), opts);
|
|
299
|
+
runtime.recoverAnnouncements(); // #260
|
|
300
|
+
return runtime;
|
|
219
301
|
}
|
|
220
302
|
|
|
221
303
|
// ── memory backend — in-process, ephemeral (no DB, no disk) ───────────────────
|
|
@@ -241,12 +323,13 @@ class MemorySessionStore implements SessionStore {
|
|
|
241
323
|
getStatus(): string { return this.status; }
|
|
242
324
|
setStatus(s: string) { this.status = s; }
|
|
243
325
|
tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
|
|
244
|
-
reset(): number {
|
|
326
|
+
reset(inTx?: () => void): number {
|
|
245
327
|
const generation = this.generation++;
|
|
246
328
|
this.archives.push({ generation, msgs: this.msgs, steps: this.steps });
|
|
247
329
|
this.msgs = [];
|
|
248
330
|
this.steps = new Map();
|
|
249
331
|
this.status = "new";
|
|
332
|
+
inTx?.(); // writes into the fresh generation (#260)
|
|
250
333
|
return generation;
|
|
251
334
|
}
|
|
252
335
|
unwrap<H = unknown>(): H { return undefined as unknown as H; }
|
|
@@ -256,6 +339,7 @@ export class MemoryRuntime implements Runtime {
|
|
|
256
339
|
private actors = new Map<string, AgentSession>();
|
|
257
340
|
private stores = new Map<string, MemorySessionStore>();
|
|
258
341
|
private readonly agents: Record<string, AgentDef>;
|
|
342
|
+
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); }); // #260
|
|
259
343
|
constructor(agents: Record<string, AgentDef>) {
|
|
260
344
|
assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
|
|
261
345
|
this.agents = agents;
|
|
@@ -271,7 +355,7 @@ export class MemoryRuntime implements Runtime {
|
|
|
271
355
|
this.stores.set(key, store);
|
|
272
356
|
// Same def handling as NativeRuntime: switching backend must not change behavior.
|
|
273
357
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
274
|
-
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);
|
|
275
359
|
this.actors.set(key, a);
|
|
276
360
|
}
|
|
277
361
|
return a;
|
|
@@ -363,7 +447,10 @@ export function mountAgent(
|
|
|
363
447
|
// HITL: answer a parked turn and stream its continuation (the approval-button path).
|
|
364
448
|
resumeStream: async function* (o) {
|
|
365
449
|
const session = runtime.session(agent.name, o.session ?? "default");
|
|
366
|
-
|
|
450
|
+
// A { policy } answerer is the app's call (#261): decided here, before the synchronous
|
|
451
|
+
// resume, so resume-then-subscribe stays free of awaits.
|
|
452
|
+
const granted = await grantAnswer(session, o, agent.authorizeAnswer);
|
|
453
|
+
const { turnId } = session.resume(o.turnId, o.inputId, o.input, { by: o.by, granted });
|
|
367
454
|
yield* observeTurnEvents(session, turnId);
|
|
368
455
|
},
|
|
369
456
|
// SESSION RESET (#129): same seam as durableChannelSurface — the in-process runtimes'
|