@ixo/oracle-runtime-workers 0.11.0 → 0.12.0
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/package.json +1 -1
- package/src/core/main-agent.ts +28 -1
- package/src/core/prompt-composer.ts +42 -3
- package/src/do/contracts.ts +2 -0
- package/src/do/flush-schedule.ts +40 -0
- package/src/do/run-request.ts +47 -11
- package/src/do/user-oracle-do.ts +88 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
package/src/core/main-agent.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
composePrompt,
|
|
47
47
|
formatTimeContext,
|
|
48
48
|
formatUserPreferences,
|
|
49
|
+
PORTAL_CAPABILITY,
|
|
49
50
|
} from './prompt-composer';
|
|
50
51
|
import { formatByPlugin, type ManifestRegistry } from './registries';
|
|
51
52
|
import {
|
|
@@ -54,7 +55,7 @@ import {
|
|
|
54
55
|
type RunConfig,
|
|
55
56
|
type RuntimeStateInput,
|
|
56
57
|
} from './runtime-context';
|
|
57
|
-
import { MainAgentGraphState } from './state';
|
|
58
|
+
import { MainAgentGraphState, type BrowserToolCall } from './state';
|
|
58
59
|
import { toolEffectOf } from './middlewares/tool-marks';
|
|
59
60
|
import { collectSubAgentsWithFallback } from './sub-agent-fallback';
|
|
60
61
|
import { computeSubAgentToolName } from './subagent-as-tool';
|
|
@@ -97,6 +98,21 @@ function selectByVisibility(
|
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Names of the browser tools the client declared on this request. The array
|
|
103
|
+
* comes straight off the wire, so an entry without a usable name is skipped
|
|
104
|
+
* rather than rendered as "undefined".
|
|
105
|
+
*/
|
|
106
|
+
function browserToolNames(
|
|
107
|
+
browserTools: BrowserToolCall[] | undefined,
|
|
108
|
+
): string[] {
|
|
109
|
+
return (browserTools ?? []).flatMap((entry) =>
|
|
110
|
+
typeof entry?.name === 'string' && entry.name.length > 0
|
|
111
|
+
? [entry.name]
|
|
112
|
+
: [],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
/**
|
|
101
117
|
* Build a compiled main agent from the runtime's registries plus per-request
|
|
102
118
|
* context. Same pipeline as the Node runtime — tools from the registries
|
|
@@ -472,9 +488,20 @@ export async function createMainAgent(
|
|
|
472
488
|
|
|
473
489
|
const customInstructions = identity.prompt?.customInstructions?.trim() ?? '';
|
|
474
490
|
|
|
491
|
+
// Browser tools never surface through `search_skills` / `list_capabilities`,
|
|
492
|
+
// so the prompt names them per turn and says whether the capability gate
|
|
493
|
+
// still hides them (portal is on-demand by default).
|
|
494
|
+
const browserTools = {
|
|
495
|
+
names: browserToolNames(state.browserTools),
|
|
496
|
+
bound:
|
|
497
|
+
manifestViz.get(PORTAL_CAPABILITY) === 'always' ||
|
|
498
|
+
loadedSet.has(PORTAL_CAPABILITY),
|
|
499
|
+
};
|
|
500
|
+
|
|
475
501
|
const systemPrompt = await composePrompt({
|
|
476
502
|
identity,
|
|
477
503
|
capabilityBlock: tier1.block,
|
|
504
|
+
browserTools,
|
|
478
505
|
customInstructions,
|
|
479
506
|
operationalMode: hooks?.operationalMode ?? DEFAULT_OPERATIONAL_MODE,
|
|
480
507
|
userPreferencesContext: formatUserPreferences(state.userPreferences),
|
|
@@ -76,6 +76,13 @@ export interface ComposePromptInput {
|
|
|
76
76
|
* mandate a discovery flow the model cannot run.
|
|
77
77
|
*/
|
|
78
78
|
capabilityDiscovery?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Browser tools the client declared for this turn (`state.browserTools`),
|
|
81
|
+
* by name, plus whether they are already callable: `bound` is true when the
|
|
82
|
+
* portal plugin is `always` visible or has been loaded on this thread.
|
|
83
|
+
* Omit (or pass empty `names`) on non-Portal turns — nothing renders.
|
|
84
|
+
*/
|
|
85
|
+
browserTools?: { names: string[]; bound: boolean };
|
|
79
86
|
/** User preferences rendered as bullets. Empty when not set. */
|
|
80
87
|
userPreferencesContext: string;
|
|
81
88
|
/** Memory-engine context blocks. */
|
|
@@ -331,13 +338,40 @@ export function buildOracleSection(input: {
|
|
|
331
338
|
return `You are ${oracleName}, an AI agent built on QiForge.`;
|
|
332
339
|
}
|
|
333
340
|
|
|
341
|
+
/** The plugin that binds the client-declared browser tools. */
|
|
342
|
+
export const PORTAL_CAPABILITY = 'portal';
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Render the per-turn "Browser tools this turn" section. Empty string when the
|
|
346
|
+
* client declared no browser tools, so non-Portal turns pay nothing for it.
|
|
347
|
+
* The load line appears only while the tools sit behind the capability gate
|
|
348
|
+
* (portal is `on-demand` and not yet in `loadedPlugins`).
|
|
349
|
+
*/
|
|
350
|
+
function buildBrowserToolsBlock(
|
|
351
|
+
browserTools: ComposePromptInput['browserTools'],
|
|
352
|
+
): string {
|
|
353
|
+
if (!browserTools || browserTools.names.length === 0) return '';
|
|
354
|
+
const lines = [
|
|
355
|
+
'## Browser tools this turn',
|
|
356
|
+
'',
|
|
357
|
+
`The Portal exposed these browser-side tools for this turn (they act on the user's screen): ${browserTools.names.join(', ')}`,
|
|
358
|
+
];
|
|
359
|
+
if (!browserTools.bound) {
|
|
360
|
+
lines.push(
|
|
361
|
+
`They are bound behind the \`${PORTAL_CAPABILITY}\` capability — call \`load_capability({ names: ['${PORTAL_CAPABILITY}'] })\` once before using them.`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return lines.join('\n');
|
|
365
|
+
}
|
|
366
|
+
|
|
334
367
|
/**
|
|
335
368
|
* The discovery mandate. Rendered on every turn whose surface can actually
|
|
336
369
|
* grow — i.e. wherever the meta-tools are bound.
|
|
337
370
|
*/
|
|
338
|
-
const DISCOVERY_PRINCIPLE = `- **Search first, build second.** Before doing any non-trivial task, you MUST run discovery before producing the answer yourself.
|
|
339
|
-
1.
|
|
340
|
-
2.
|
|
371
|
+
const DISCOVERY_PRINCIPLE = `- **Search first, build second.** Before doing any non-trivial task, you MUST run discovery before producing the answer yourself. Capabilities live in three lanes, each found differently:
|
|
372
|
+
1. **Packaged skills** — call \`search_skills\` against the user's request whenever a skill could plausibly do the job (generating a file, document, report, or page; calculations; lookups; multi-step workflows).
|
|
373
|
+
2. **Server-side plugins and third-party integrations** (memory, sandbox, email, calendar, web, files, external services) — call \`list_capabilities\` to scan loaded + on-demand plugins, then call \`load_capability({ names: [...] })\` ONCE with everything you need — it accepts an array, so never make multiple \`load_capability\` calls in the same turn.
|
|
374
|
+
3. **Browser tools** — the \`[Portal]\`-prefixed tools already in your tool list. They run on the user's screen inside the Portal: navigate, open a URL or page, create pages/rooms, fill forms, grant access, change what the user sees. Use them for anything that happens on the user's side. They NEVER appear in \`search_skills\` or \`list_capabilities\` results — check your tool list (and the "Browser tools this turn" section when present) directly.
|
|
341
375
|
Do this proactively — even when the task wording doesn't literally match a capability's \`whenToUse\`. Tell the user in ONE short sentence what you're checking ("Checking the skills registry for an invoice generator…") before the search call, not after. If something fits, use it (load + run). If nothing fits after the search, say so in one short sentence and only THEN build from scratch. Reusing a vetted capability is almost always better than reinventing it; silently skipping discovery is the worst failure mode.`;
|
|
342
376
|
|
|
343
377
|
/**
|
|
@@ -357,6 +391,10 @@ const TEMPLATE = `{{{ORACLE_SECTION}}}
|
|
|
357
391
|
{{{CAPABILITY_BLOCK}}}
|
|
358
392
|
|
|
359
393
|
{{/CAPABILITY_BLOCK}}
|
|
394
|
+
{{#BROWSER_TOOLS_BLOCK}}
|
|
395
|
+
{{{BROWSER_TOOLS_BLOCK}}}
|
|
396
|
+
|
|
397
|
+
{{/BROWSER_TOOLS_BLOCK}}
|
|
360
398
|
## Operating principles
|
|
361
399
|
|
|
362
400
|
- The user's current message is your primary instruction. Background context (what you already know about them) is for adapting tone and suggestions, not for overriding their intent.
|
|
@@ -436,6 +474,7 @@ export async function composePrompt(
|
|
|
436
474
|
ORACLE_SECTION: oracleSection,
|
|
437
475
|
CAPABILITIES_NOTE: capabilitiesNote,
|
|
438
476
|
CAPABILITY_BLOCK: input.capabilityBlock,
|
|
477
|
+
BROWSER_TOOLS_BLOCK: buildBrowserToolsBlock(input.browserTools),
|
|
439
478
|
DISCOVERY_PRINCIPLE:
|
|
440
479
|
input.capabilityDiscovery === false ? '' : DISCOVERY_PRINCIPLE,
|
|
441
480
|
CUSTOM_INSTRUCTIONS: input.customInstructions,
|
package/src/do/contracts.ts
CHANGED
|
@@ -439,6 +439,8 @@ export interface StorageStatus {
|
|
|
439
439
|
pages: number;
|
|
440
440
|
ownerEtag?: string;
|
|
441
441
|
lastFlushAt?: number;
|
|
442
|
+
/** When the dirty copy is due to be uploaded (absent when clean). */
|
|
443
|
+
nextFlushAt?: number;
|
|
442
444
|
dirty: boolean;
|
|
443
445
|
flushInFlight: boolean;
|
|
444
446
|
/** VFS write generation now vs. at the last successful upload. */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a housekeeping wake should upload the working copy to the owner
|
|
3
|
+
* store. The object's single alarm serves several clients (realtime
|
|
4
|
+
* heartbeats, the durable-run keep-alive, task runs, compaction, the R2
|
|
5
|
+
* tier, idle eviction), so a wake says nothing about WHY it happened. The
|
|
6
|
+
* flush has its own persisted deadline (`meta:flushAt`, armed by the first
|
|
7
|
+
* write after an upload and replaced by the retry delay after a failure)
|
|
8
|
+
* and runs only when that deadline is due — every other wake leaves a
|
|
9
|
+
* dirty copy alone. Two exceptions upload right away: a copy about to be
|
|
10
|
+
* evicted (the wipe must never drop unsaved turns) and a copy marked dirty
|
|
11
|
+
* before the deadline existed (older objects; one upload, then the
|
|
12
|
+
* debounce applies).
|
|
13
|
+
*/
|
|
14
|
+
export interface FlushScheduleInput {
|
|
15
|
+
/** The object holds writes the owner store has not seen (and a database is open). */
|
|
16
|
+
dirty: boolean;
|
|
17
|
+
/** The persisted flush deadline (undefined = none recorded). */
|
|
18
|
+
flushAt: unknown;
|
|
19
|
+
now: number;
|
|
20
|
+
/** The idle-eviction path is about to wipe the working copy. */
|
|
21
|
+
idle: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FlushScheduleDecision =
|
|
25
|
+
| { action: 'skip'; reason: 'clean' }
|
|
26
|
+
| { action: 'flush'; reason: 'due' | 'idle' | 'no-deadline' }
|
|
27
|
+
| { action: 'wait'; at: number };
|
|
28
|
+
|
|
29
|
+
/** Alarms fire a hair early; a deadline this close counts as due. */
|
|
30
|
+
export const FLUSH_DUE_SLACK_MS = 1000;
|
|
31
|
+
|
|
32
|
+
export function decideFlush(input: FlushScheduleInput): FlushScheduleDecision {
|
|
33
|
+
if (!input.dirty) return { action: 'skip', reason: 'clean' };
|
|
34
|
+
if (input.idle) return { action: 'flush', reason: 'idle' };
|
|
35
|
+
if (typeof input.flushAt !== 'number' || !Number.isFinite(input.flushAt))
|
|
36
|
+
return { action: 'flush', reason: 'no-deadline' };
|
|
37
|
+
if (input.flushAt <= input.now + FLUSH_DUE_SLACK_MS)
|
|
38
|
+
return { action: 'flush', reason: 'due' };
|
|
39
|
+
return { action: 'wait', at: input.flushAt };
|
|
40
|
+
}
|
package/src/do/run-request.ts
CHANGED
|
@@ -1,39 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What a run row stores so a later incarnation of the user object can
|
|
3
3
|
* rebuild the attempt: the `TurnRequest` (minus attachments — they are in
|
|
4
|
-
* the checkpointed first message)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* the checkpointed first message), the client-declared tool surface of the
|
|
5
|
+
* turn (browser tools + AG-UI actions: the portal and agui plugins bind
|
|
6
|
+
* them at build time, so an attempt cannot recover them from anywhere
|
|
7
|
+
* else), and the stream options the frame producer needs.
|
|
8
8
|
*/
|
|
9
9
|
import type { RunSummary, TurnRequest } from './contracts';
|
|
10
10
|
import type { RunRecord } from './run-store';
|
|
11
|
+
import type { TurnBody } from './turn-body';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/** The client-declared surface of a turn, as the body carries it. */
|
|
14
|
+
export type ClientSurfaceBody = Pick<TurnBody, 'tools' | 'agActions'>;
|
|
15
|
+
|
|
16
|
+
export interface StoredRunRequest extends ClientSurfaceBody {
|
|
13
17
|
turn: Omit<TurnRequest, 'attachments'> & {
|
|
14
18
|
attachments?: TurnRequest['attachments'];
|
|
15
19
|
};
|
|
16
20
|
timezone?: string;
|
|
17
|
-
/** Names the client declared as AG-UI actions (rendered as `action_call`). */
|
|
18
|
-
agActionNames?: string[];
|
|
19
21
|
stream?: boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function storedRunRequest(
|
|
23
25
|
req: TurnRequest,
|
|
24
|
-
extra: Pick<
|
|
26
|
+
extra: Pick<
|
|
27
|
+
StoredRunRequest,
|
|
28
|
+
'timezone' | 'tools' | 'agActions' | 'stream'
|
|
29
|
+
> = {},
|
|
25
30
|
): StoredRunRequest {
|
|
26
31
|
const { attachments, ...turn } = req;
|
|
27
32
|
return {
|
|
28
33
|
turn: { ...turn, ...(attachments?.length ? { attachments } : {}) },
|
|
29
34
|
...(extra.timezone ? { timezone: extra.timezone } : {}),
|
|
30
|
-
...(extra.
|
|
31
|
-
|
|
32
|
-
: {}),
|
|
35
|
+
...(extra.tools?.length ? { tools: extra.tools } : {}),
|
|
36
|
+
...(extra.agActions?.length ? { agActions: extra.agActions } : {}),
|
|
33
37
|
...(extra.stream !== undefined ? { stream: extra.stream } : {}),
|
|
34
38
|
};
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
/** The surface as the graph state stores it (`browserTools` / `agActions`). */
|
|
42
|
+
export interface ClientSurfaceState {
|
|
43
|
+
browserTools?: TurnBody['tools'];
|
|
44
|
+
agActions?: TurnBody['agActions'];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The client-declared surface of one attempt, by the Node agent-builder's
|
|
49
|
+
* rule: what the body carries, else what the thread's checkpoint holds.
|
|
50
|
+
*
|
|
51
|
+
* `state` feeds the agent build (the portal / agui plugins read it from
|
|
52
|
+
* `history.state`, so it must be complete). `input` is what the graph input
|
|
53
|
+
* writes back to the checkpoint: only what THIS body declared, so a turn
|
|
54
|
+
* without a catalogue (a resumed attempt, a Matrix turn, a task run) never
|
|
55
|
+
* erases the one the client sent earlier.
|
|
56
|
+
*/
|
|
57
|
+
export function clientSurfaceFor(
|
|
58
|
+
body: ClientSurfaceBody,
|
|
59
|
+
prior: ClientSurfaceState,
|
|
60
|
+
): { state: Required<ClientSurfaceState>; input: ClientSurfaceState } {
|
|
61
|
+
return {
|
|
62
|
+
state: {
|
|
63
|
+
browserTools: body.tools ?? prior.browserTools ?? [],
|
|
64
|
+
agActions: body.agActions ?? prior.agActions ?? [],
|
|
65
|
+
},
|
|
66
|
+
input: {
|
|
67
|
+
...(body.tools !== undefined ? { browserTools: body.tools } : {}),
|
|
68
|
+
...(body.agActions !== undefined ? { agActions: body.agActions } : {}),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
37
73
|
export function runSummaryOf(record: RunRecord): RunSummary {
|
|
38
74
|
return {
|
|
39
75
|
runId: record.runId,
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -156,6 +156,7 @@ import {
|
|
|
156
156
|
import { turnRecursionLimit } from './turn-config';
|
|
157
157
|
import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
|
|
158
158
|
import { decideBootDirty } from './boot-dirty';
|
|
159
|
+
import { decideFlush } from './flush-schedule';
|
|
159
160
|
import { mirrorTxnId, RoomMirror } from './room-mirror';
|
|
160
161
|
import { prefixSpeaker } from './speaker-prefix';
|
|
161
162
|
import {
|
|
@@ -199,6 +200,7 @@ import {
|
|
|
199
200
|
} from './run-store';
|
|
200
201
|
import {
|
|
201
202
|
runSummaryOf,
|
|
203
|
+
clientSurfaceFor,
|
|
202
204
|
storedRunRequest,
|
|
203
205
|
type StoredRunRequest,
|
|
204
206
|
} from './run-request';
|
|
@@ -320,6 +322,15 @@ const TIER_PASS_INTERVAL_MS = 6 * 60 * 60_000;
|
|
|
320
322
|
* silently stale.
|
|
321
323
|
*/
|
|
322
324
|
const META_DIRTY = 'meta:dirty';
|
|
325
|
+
/**
|
|
326
|
+
* When the dirty working copy is due to be uploaded. Armed by the first
|
|
327
|
+
* write after an upload (`FLUSH_DEBOUNCE_MS` later), replaced by the retry
|
|
328
|
+
* delay after a failed upload, cleared with the dirty mark. The alarm is
|
|
329
|
+
* shared with the realtime heartbeat, the durable-run keep-alive, task runs
|
|
330
|
+
* and compaction, so a wake alone never means "upload now" — the tick
|
|
331
|
+
* consults this deadline (`do/flush-schedule.ts`).
|
|
332
|
+
*/
|
|
333
|
+
const META_FLUSH_AT = 'meta:flushAt';
|
|
323
334
|
const META_OWNER_ETAG = 'meta:ownerEtag';
|
|
324
335
|
const META_LAST_FLUSH = 'meta:lastFlushAt';
|
|
325
336
|
const META_LAST_CHECKSUM = 'meta:lastChecksum';
|
|
@@ -1083,9 +1094,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1083
1094
|
console.warn(
|
|
1084
1095
|
`[user-do] VFS migration write failed for ${userDid} — serving the legacy Matrix copy for now: ${err instanceof Error ? err.message : String(err)}`,
|
|
1085
1096
|
);
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
this.
|
|
1097
|
+
// The failed upload armed the 10-minute retry (`flushOnce`), well
|
|
1098
|
+
// before the 24 h debounce `markDirty` set: the system of record
|
|
1099
|
+
// is still empty for this user.
|
|
1089
1100
|
}
|
|
1090
1101
|
}
|
|
1091
1102
|
await this.reconcileDirtyOnBoot(liveDb, userDid);
|
|
@@ -1278,6 +1289,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1278
1289
|
META_OWNER_ETAG,
|
|
1279
1290
|
META_LAST_CHECKSUM,
|
|
1280
1291
|
META_LAST_FLUSH,
|
|
1292
|
+
META_FLUSH_AT,
|
|
1281
1293
|
META_UPLOADED_GEN,
|
|
1282
1294
|
META_FLUSH_FAILURES,
|
|
1283
1295
|
META_LAST_VACUUM,
|
|
@@ -1383,7 +1395,30 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1383
1395
|
private markDirty(): void {
|
|
1384
1396
|
this.dirty = true;
|
|
1385
1397
|
void this.ctx.storage.put(META_DIRTY, true);
|
|
1386
|
-
this.
|
|
1398
|
+
void this.armFlush(Date.now() + FLUSH_DEBOUNCE_MS);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Record the flush deadline and arm the alarm for it. An earlier
|
|
1403
|
+
* deadline already on disk wins, so a burst of writes uploads once,
|
|
1404
|
+
* `FLUSH_DEBOUNCE_MS` after the first of them, never later.
|
|
1405
|
+
*/
|
|
1406
|
+
private async armFlush(at: number): Promise<void> {
|
|
1407
|
+
const existing = await this.ctx.storage.get<number>(META_FLUSH_AT);
|
|
1408
|
+
const deadline =
|
|
1409
|
+
typeof existing === 'number' && existing <= at ? existing : at;
|
|
1410
|
+
if (deadline !== existing)
|
|
1411
|
+
await this.ctx.storage.put(META_FLUSH_AT, deadline);
|
|
1412
|
+
this.requestAlarm(deadline);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* After a failed upload: the next attempt is `at`, replacing the deadline
|
|
1417
|
+
* that just came due (which would otherwise make every wake retry).
|
|
1418
|
+
*/
|
|
1419
|
+
private async scheduleFlushRetry(at: number): Promise<void> {
|
|
1420
|
+
await this.ctx.storage.put(META_FLUSH_AT, at);
|
|
1421
|
+
this.requestAlarm(at);
|
|
1387
1422
|
}
|
|
1388
1423
|
|
|
1389
1424
|
/** Arm the object's single alarm no later than `at` (multiplexed). */
|
|
@@ -1481,15 +1516,35 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1481
1516
|
deadlines.push(Math.max(nextRecovery, now + 1000));
|
|
1482
1517
|
}
|
|
1483
1518
|
|
|
1484
|
-
|
|
1519
|
+
const lastAccess =
|
|
1520
|
+
(await this.ctx.storage.get<number>(META_LAST_ACCESS)) ?? now;
|
|
1521
|
+
const idle = now - lastAccess > IDLE_EVICT_MS;
|
|
1522
|
+
|
|
1523
|
+
// Owner-store flush: only when its own deadline is due, or the copy
|
|
1524
|
+
// is about to be evicted below. A wake for anything else (a run's
|
|
1525
|
+
// keep-alive, a heartbeat round that fell through, a compaction or
|
|
1526
|
+
// tier re-arm) leaves a dirty copy alone — the upload is a full-file
|
|
1527
|
+
// transfer and is meant to happen once a day, not once a turn.
|
|
1528
|
+
const flush = decideFlush({
|
|
1529
|
+
dirty: this.dirty && this.db !== null,
|
|
1530
|
+
flushAt: await this.ctx.storage.get<number>(META_FLUSH_AT),
|
|
1531
|
+
now,
|
|
1532
|
+
idle,
|
|
1533
|
+
});
|
|
1534
|
+
if (flush.action === 'flush') {
|
|
1485
1535
|
try {
|
|
1486
1536
|
await this.flushToOwnerStore();
|
|
1487
1537
|
} catch (err) {
|
|
1488
1538
|
console.error(
|
|
1489
1539
|
`[user-do] flush failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1490
1540
|
);
|
|
1541
|
+
// `flushOnce` arms the retry on an upload failure; anything that
|
|
1542
|
+
// failed before the upload gets the same retry window here.
|
|
1543
|
+
await this.scheduleFlushRetry(now + FLUSH_RETRY_DELAY_MS);
|
|
1491
1544
|
deadlines.push(now + FLUSH_RETRY_DELAY_MS);
|
|
1492
1545
|
}
|
|
1546
|
+
} else if (flush.action === 'wait') {
|
|
1547
|
+
deadlines.push(Math.max(flush.at, now + 1000));
|
|
1493
1548
|
}
|
|
1494
1549
|
|
|
1495
1550
|
if (this.taskScheduler) {
|
|
@@ -1522,9 +1577,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1522
1577
|
}
|
|
1523
1578
|
}
|
|
1524
1579
|
|
|
1525
|
-
const lastAccess =
|
|
1526
|
-
(await this.ctx.storage.get<number>(META_LAST_ACCESS)) ?? now;
|
|
1527
|
-
|
|
1528
1580
|
// Free-page reclaim on a quiet object (policy in sqlite/vacuum-policy.ts):
|
|
1529
1581
|
// runs after the flush above so the upload holds the pre-rebuild file,
|
|
1530
1582
|
// and marks dirty so the smaller file goes up on the next tick.
|
|
@@ -1551,7 +1603,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1551
1603
|
// evict while tasks are scheduled: their runs ARE activity. The flush
|
|
1552
1604
|
// above ran first; the wipe additionally verifies the upstream copy
|
|
1553
1605
|
// matches the working copy, else the copy stays until it does.
|
|
1554
|
-
const idle = now - lastAccess > IDLE_EVICT_MS;
|
|
1555
1606
|
if (
|
|
1556
1607
|
idle &&
|
|
1557
1608
|
!this.dirty &&
|
|
@@ -2052,9 +2103,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2052
2103
|
writeGeneration: db.writeGeneration,
|
|
2053
2104
|
});
|
|
2054
2105
|
if (decision === 'flagged') {
|
|
2055
|
-
// The mark is on disk already (its
|
|
2106
|
+
// The mark is on disk already (its deadline too); adopt it and make
|
|
2107
|
+
// sure the alarm is armed for that deadline.
|
|
2056
2108
|
this.dirty = true;
|
|
2057
|
-
this.
|
|
2109
|
+
void this.armFlush(Date.now() + FLUSH_DEBOUNCE_MS);
|
|
2058
2110
|
} else if (decision === 'behind-upload') {
|
|
2059
2111
|
console.log(
|
|
2060
2112
|
`[user-do] working copy of ${userDid} is at generation ${db.writeGeneration}, last upload at ${String(got.get(META_UPLOADED_GEN))} — a turn ended without marking it; flushing`,
|
|
@@ -2431,6 +2483,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2431
2483
|
const line = `[user-do] owner-store flush failed (${failures} in a row) for ${this.userDid} — working copy kept dirty, retrying in ${FLUSH_RETRY_DELAY_MS / 60_000} min: ${detail}`;
|
|
2432
2484
|
if (failures >= FLUSH_FAILURES_ERROR_THRESHOLD) console.error(line);
|
|
2433
2485
|
else console.warn(line);
|
|
2486
|
+
await this.scheduleFlushRetry(Date.now() + FLUSH_RETRY_DELAY_MS);
|
|
2434
2487
|
throw err;
|
|
2435
2488
|
}
|
|
2436
2489
|
await this.ctx.storage.put(META_OWNER_ETAG, saved.etag);
|
|
@@ -2510,18 +2563,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2510
2563
|
}
|
|
2511
2564
|
|
|
2512
2565
|
private async clearDirty(): Promise<void> {
|
|
2513
|
-
// A write that landed while the snapshot was being uploaded
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
2566
|
+
// A write that landed while the snapshot was being uploaded keeps the
|
|
2567
|
+
// copy dirty; its upload is a full debounce away (the deadline the
|
|
2568
|
+
// write found on disk was the one that just ran).
|
|
2516
2569
|
const uploadedGen = await this.ctx.storage.get<number>(META_UPLOADED_GEN);
|
|
2517
2570
|
if (
|
|
2518
2571
|
this.db &&
|
|
2519
2572
|
uploadedGen !== undefined &&
|
|
2520
2573
|
uploadedGen !== this.db.writeGeneration
|
|
2521
|
-
)
|
|
2574
|
+
) {
|
|
2575
|
+
const at = Date.now() + FLUSH_DEBOUNCE_MS;
|
|
2576
|
+
await this.ctx.storage.put(META_FLUSH_AT, at);
|
|
2577
|
+
this.requestAlarm(at);
|
|
2522
2578
|
return;
|
|
2579
|
+
}
|
|
2523
2580
|
this.dirty = false;
|
|
2524
|
-
await this.ctx.storage.delete(META_DIRTY);
|
|
2581
|
+
await this.ctx.storage.delete([META_DIRTY, META_FLUSH_AT]);
|
|
2525
2582
|
}
|
|
2526
2583
|
|
|
2527
2584
|
async resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }> {
|
|
@@ -2650,6 +2707,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2650
2707
|
pages: Math.ceil(fileBytes / 4096),
|
|
2651
2708
|
ownerEtag: await this.ctx.storage.get<string>(META_OWNER_ETAG),
|
|
2652
2709
|
lastFlushAt: await this.ctx.storage.get<number>(META_LAST_FLUSH),
|
|
2710
|
+
nextFlushAt: await this.ctx.storage.get<number>(META_FLUSH_AT),
|
|
2653
2711
|
dirty: this.dirty,
|
|
2654
2712
|
flushInFlight: this.flushInFlight !== null,
|
|
2655
2713
|
writeGeneration: db?.writeGeneration,
|
|
@@ -2954,7 +3012,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2954
3012
|
// (docs/plans/durable-runs.md). The response is one subscriber of the
|
|
2955
3013
|
// run's buffer; the run itself outlives it.
|
|
2956
3014
|
this.replayToRoom(req, body.message, 'user');
|
|
2957
|
-
const agActionNames = (body.agActions ?? []).map((a) => a.name);
|
|
2958
3015
|
const { live, queued } = await this.runs!.begin({
|
|
2959
3016
|
runId: crypto.randomUUID(),
|
|
2960
3017
|
sessionId,
|
|
@@ -2963,7 +3020,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2963
3020
|
request: JSON.stringify(
|
|
2964
3021
|
storedRunRequest(req, {
|
|
2965
3022
|
timezone: body.timezone,
|
|
2966
|
-
|
|
3023
|
+
tools: body.tools,
|
|
3024
|
+
agActions: body.agActions,
|
|
2967
3025
|
stream: true,
|
|
2968
3026
|
}),
|
|
2969
3027
|
),
|
|
@@ -3081,6 +3139,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3081
3139
|
message: req.message,
|
|
3082
3140
|
timezone: stored.timezone ?? req.identity.timezone,
|
|
3083
3141
|
model: req.model,
|
|
3142
|
+
tools: stored.tools,
|
|
3143
|
+
agActions: stored.agActions,
|
|
3084
3144
|
...(resumed ? {} : { attachments: req.attachments }),
|
|
3085
3145
|
},
|
|
3086
3146
|
{
|
|
@@ -3121,7 +3181,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3121
3181
|
abortController: live.abort,
|
|
3122
3182
|
mirror: (eventName, payload) =>
|
|
3123
3183
|
this.events.emitToTaps(eventName, payload),
|
|
3124
|
-
agActionNames: new Set(stored.
|
|
3184
|
+
agActionNames: new Set((stored.agActions ?? []).map((a) => a.name)),
|
|
3125
3185
|
byoProvider,
|
|
3126
3186
|
toolOutputCapChars,
|
|
3127
3187
|
messageIdOf: () => lastAiMessageId(capture),
|
|
@@ -3801,20 +3861,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3801
3861
|
const hostRoomTitle = opts.hooks?.getRoomTitle;
|
|
3802
3862
|
const hostSafetyModel = opts.hooks?.safetyModel;
|
|
3803
3863
|
|
|
3804
|
-
//
|
|
3805
|
-
// checkpointed
|
|
3806
|
-
const
|
|
3807
|
-
browserTools?: TurnBody['tools'];
|
|
3808
|
-
agActions?: TurnBody['agActions'];
|
|
3809
|
-
};
|
|
3810
|
-
const browserTools =
|
|
3811
|
-
body.tools ??
|
|
3812
|
-
(run.resumed ? priorSurface.browserTools : undefined) ??
|
|
3813
|
-
[];
|
|
3814
|
-
const agActions =
|
|
3815
|
-
body.agActions ??
|
|
3816
|
-
(run.resumed ? priorSurface.agActions : undefined) ??
|
|
3817
|
-
[];
|
|
3864
|
+
// The client-declared tool surface: this body's, else the thread's
|
|
3865
|
+
// checkpointed one (run-request.ts).
|
|
3866
|
+
const surface = clientSurfaceFor(body, priorState);
|
|
3818
3867
|
|
|
3819
3868
|
// Write-ahead tool marks + the resume policy (tool-marks.ts). The
|
|
3820
3869
|
// effect map is filled from the build below; the closure reads it at
|
|
@@ -3908,12 +3957,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3908
3957
|
...priorState,
|
|
3909
3958
|
userPreferences,
|
|
3910
3959
|
...metadataBuildState(meta, priorMeta),
|
|
3911
|
-
// The client-declared surface
|
|
3912
|
-
//
|
|
3913
|
-
//
|
|
3914
|
-
//
|
|
3915
|
-
|
|
3916
|
-
agActions,
|
|
3960
|
+
// The client-declared surface: the portal and AG-UI plugins turn
|
|
3961
|
+
// these into tools at build time (`getRequestTools` reads
|
|
3962
|
+
// `history.state`), so they must be here and not only in the
|
|
3963
|
+
// graph input.
|
|
3964
|
+
...surface.state,
|
|
3917
3965
|
},
|
|
3918
3966
|
checkpointer: saver,
|
|
3919
3967
|
abortSignal: abortController.signal,
|
|
@@ -3979,8 +4027,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3979
4027
|
config: { did: req.identity.userDid },
|
|
3980
4028
|
client: req.client,
|
|
3981
4029
|
...metadataGraphInput(meta, priorMeta),
|
|
3982
|
-
|
|
3983
|
-
agActions,
|
|
4030
|
+
...surface.input,
|
|
3984
4031
|
};
|
|
3985
4032
|
// LangSmith: metadata is attached unconditionally (inert without a
|
|
3986
4033
|
// tracer); the explicit tracer only when this turn is traced (global
|