@ixo/oracle-runtime-workers 0.4.0 → 0.9.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/README.md +5 -1
- package/package.json +3 -3
- package/src/attachments/download.ts +43 -22
- package/src/attachments/index.ts +1 -0
- package/src/core/env.ts +19 -0
- package/src/core/main-agent.ts +56 -4
- package/src/core/middlewares/dangling-tool-calls.ts +99 -0
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/runtime-context.ts +14 -5
- package/src/core/subagent-as-tool.ts +12 -1
- package/src/do/ambient.ts +13 -4
- package/src/do/boot-dirty.ts +38 -0
- package/src/do/contracts.ts +91 -18
- package/src/do/matrix-turn-ledger.ts +126 -0
- package/src/do/reauth-prompt.ts +76 -0
- package/src/do/room-mirror.ts +111 -0
- package/src/do/sse-stream.ts +104 -2
- package/src/do/transcript.ts +15 -2
- package/src/do/turn-config.ts +24 -0
- package/src/do/user-oracle-do.ts +339 -67
- package/src/matrix/gateway-do.ts +233 -42
- package/src/matrix/inbox-store.ts +188 -0
- package/src/matrix/txn-id.ts +35 -0
- package/src/matrix/work-status.ts +15 -3
- package/src/owner-store/matrix-media-store.ts +59 -39
- package/src/owner-store/migrating-store.ts +22 -33
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/types.ts +25 -1
- package/src/plugins/portal/action-log.ts +36 -9
- package/src/plugins/tasks/tasks-tools.ts +7 -7
- package/src/shell/app.ts +80 -7
- package/src/shell/turn-body-cap.ts +19 -0
- package/src/tasks/schedule-input.ts +102 -0
- package/src/tasks/scheduler.ts +351 -54
- package/src/tasks/store.ts +198 -3
package/src/do/transcript.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isAttachmentPlaceholderText,
|
|
13
13
|
isAttachmentViewMessage,
|
|
14
14
|
} from '../attachments/retention';
|
|
15
|
+
import { SUMMARY_PREFIX } from '../core/middlewares/summarization';
|
|
15
16
|
|
|
16
17
|
export interface ToolCallDto {
|
|
17
18
|
name: string;
|
|
@@ -103,11 +104,23 @@ export function contentToText(content: BaseMessage['content']): string {
|
|
|
103
104
|
return String(content);
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* The summarization middleware's bookkeeping message — never shown to users.
|
|
109
|
+
* LangChain 1.4 writes it as a HUMAN message tagged
|
|
110
|
+
* `additional_kwargs.lc_source: 'summarization'` whose text starts with the
|
|
111
|
+
* summary prefix; older versions used a system message and other tags. All
|
|
112
|
+
* of them match here, so a condensed thread lists as the kept tail only.
|
|
113
|
+
*/
|
|
107
114
|
export function isSummarizationMessage(message: BaseMessage): boolean {
|
|
108
115
|
const kw = message.additional_kwargs as Record<string, unknown> | undefined;
|
|
109
|
-
if (
|
|
116
|
+
if (
|
|
117
|
+
kw?.lc_source === 'summarization' ||
|
|
118
|
+
kw?.lc_summary_message === true ||
|
|
119
|
+
kw?.summary === true
|
|
120
|
+
)
|
|
121
|
+
return true;
|
|
110
122
|
const text = contentToText(message.content);
|
|
123
|
+
if (text.startsWith(SUMMARY_PREFIX)) return true;
|
|
111
124
|
return message.type === 'system' && /summary of the conversation/i.test(text);
|
|
112
125
|
}
|
|
113
126
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { baseEnvSchema, TURN_RECURSION_LIMIT_DEFAULT } from '../core/env';
|
|
2
|
+
import type { OracleWorkerEnv } from './contracts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The LangGraph `recursionLimit` every turn runs with (HTTP/SSE turns, room
|
|
6
|
+
* turns and the task runner's background turns all build their config in
|
|
7
|
+
* one place). `TURN_RECURSION_LIMIT` is validated by the base env schema at
|
|
8
|
+
* boot; this re-parses the raw Worker var through the same field so the
|
|
9
|
+
* turn never sees anything but a positive integer, and falls back to the
|
|
10
|
+
* default (with a warning) if the var is somehow malformed.
|
|
11
|
+
*/
|
|
12
|
+
export function turnRecursionLimit(
|
|
13
|
+
env: Pick<OracleWorkerEnv, 'TURN_RECURSION_LIMIT'>,
|
|
14
|
+
log: Pick<Console, 'warn'> = console,
|
|
15
|
+
): number {
|
|
16
|
+
const parsed = baseEnvSchema.shape.TURN_RECURSION_LIMIT.safeParse(
|
|
17
|
+
env.TURN_RECURSION_LIMIT,
|
|
18
|
+
);
|
|
19
|
+
if (parsed.success) return parsed.data;
|
|
20
|
+
log.warn(
|
|
21
|
+
`[turn] TURN_RECURSION_LIMIT=${JSON.stringify(env.TURN_RECURSION_LIMIT)} is not a positive integer; using ${TURN_RECURSION_LIMIT_DEFAULT}`,
|
|
22
|
+
);
|
|
23
|
+
return TURN_RECURSION_LIMIT_DEFAULT;
|
|
24
|
+
}
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
type AttachmentViewSurface,
|
|
38
38
|
type MatrixMediaSource,
|
|
39
39
|
type SandboxUploadConfig,
|
|
40
|
+
MAX_FILE_SIZE,
|
|
41
|
+
readBytesCapped,
|
|
40
42
|
} from '../attachments';
|
|
41
43
|
import type { RuntimeCore } from '../core';
|
|
42
44
|
import {
|
|
@@ -103,6 +105,7 @@ import {
|
|
|
103
105
|
UNTITLED_SESSION,
|
|
104
106
|
type SessionRecord,
|
|
105
107
|
} from '../sqlite/sessions-store';
|
|
108
|
+
import { createAttachmentDecryptor } from '@ixo/matrix-bot-workers-sdk';
|
|
106
109
|
import { MatrixMediaOwnerStore } from '../owner-store/matrix-media-store';
|
|
107
110
|
import { MigratingOwnerStore } from '../owner-store/migrating-store';
|
|
108
111
|
import type { OwnerCopy } from '../owner-store/types';
|
|
@@ -135,7 +138,13 @@ import {
|
|
|
135
138
|
type TurnRequest,
|
|
136
139
|
type TurnResult,
|
|
137
140
|
type MemorySchemaDebug,
|
|
141
|
+
TURN_INTERRUPTED_MARKER,
|
|
138
142
|
} from './contracts';
|
|
143
|
+
import { turnRecursionLimit } from './turn-config';
|
|
144
|
+
import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
|
|
145
|
+
import { decideBootDirty } from './boot-dirty';
|
|
146
|
+
import { mirrorTxnId, RoomMirror } from './room-mirror';
|
|
147
|
+
import { ReauthPrompter } from './reauth-prompt';
|
|
139
148
|
import {
|
|
140
149
|
createTaskScheduler,
|
|
141
150
|
TASK_SESSION_PREFIX,
|
|
@@ -239,6 +248,16 @@ const DELEGATION_MISS_TTL_MS = 60_000;
|
|
|
239
248
|
const META_REAUTH_PROMPT_AT = 'meta:reauthPromptAt';
|
|
240
249
|
/** Node's `AgentBuilder.DEFAULT_REAUTH_THROTTLE_SECONDS`. */
|
|
241
250
|
const DEFAULT_REAUTH_THROTTLE_SECONDS = 6 * 60 * 60;
|
|
251
|
+
|
|
252
|
+
/** `UCAN_REAUTH_PROMPT_THROTTLE_SECONDS` as a positive number, else the default. */
|
|
253
|
+
function reauthThrottleSeconds(env: OracleWorkerEnv): number {
|
|
254
|
+
const raw = env.UCAN_REAUTH_PROMPT_THROTTLE_SECONDS;
|
|
255
|
+
return typeof raw === 'string' &&
|
|
256
|
+
Number.isFinite(Number(raw)) &&
|
|
257
|
+
Number(raw) > 0
|
|
258
|
+
? Number(raw)
|
|
259
|
+
: DEFAULT_REAUTH_THROTTLE_SECONDS;
|
|
260
|
+
}
|
|
242
261
|
/** Highest whole-GB size watermark already alerted to Slack (0 = none). */
|
|
243
262
|
const META_SIZE_ALERT_GB = 'meta:sizeAlertGb';
|
|
244
263
|
/** Blob re-compression bookkeeping (see sqlite/blob-compactor.ts). */
|
|
@@ -433,6 +452,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
433
452
|
|
|
434
453
|
/** In-flight Matrix turn per session (sessionId → requestId), for supersede. */
|
|
435
454
|
private readonly matrixTurns = new Map<string, string>();
|
|
455
|
+
/** What this object remembers about each room message it answered (see matrix-turn-ledger.ts). */
|
|
456
|
+
private matrixLedger: MatrixTurnLedger | null = null;
|
|
457
|
+
/** Room mirrors of HTTP turns, serialised per session and retried across gateway restarts (see room-mirror.ts). */
|
|
458
|
+
private mirror: RoomMirror | null = null;
|
|
459
|
+
/** The oracle room of each session this instance resolved: spares the mirrors a row read per send. */
|
|
460
|
+
private readonly sessionRooms = new Map<string, string>();
|
|
461
|
+
/** The throttled `delegation_required` prompt (see reauth-prompt.ts). */
|
|
462
|
+
private reauthPrompter: ReauthPrompter | null = null;
|
|
463
|
+
/** Room turns running right now, by Matrix event id: a gateway that asks again attaches instead of re-running. */
|
|
464
|
+
private readonly matrixTurnRuns = new Map<string, Promise<TurnResult>>();
|
|
436
465
|
|
|
437
466
|
/** Session-history → memory-engine indexing (built lazily, see `scheduleHistoryIndexing`). */
|
|
438
467
|
private historyIndexer: SessionHistoryIndexer | null = null;
|
|
@@ -447,6 +476,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
447
476
|
>();
|
|
448
477
|
private initPromise: Promise<void> | null = null;
|
|
449
478
|
private reloadedFromOwnerStore = false;
|
|
479
|
+
/** Set by `adoptOwnerCopy` for a legacy copy: `ready()` flushes it to the system of record right after. */
|
|
480
|
+
private pendingLegacyMigration: { bytes: number } | null = null;
|
|
450
481
|
|
|
451
482
|
// ── lifecycle ───────────────────────────────────────────────────────────
|
|
452
483
|
|
|
@@ -792,7 +823,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
792
823
|
const imported = await this.adoptOwnerCopy(db, loaded);
|
|
793
824
|
this.reloadedFromOwnerStore = true;
|
|
794
825
|
console.log(
|
|
795
|
-
`[user-do] imported ${imported} bytes from ${this.ownerStore.kind} for ${userDid}`,
|
|
826
|
+
`[user-do] imported ${imported} bytes from ${loaded.fromLegacy ? 'legacy Matrix media' : this.ownerStore.kind} for ${userDid}`,
|
|
796
827
|
);
|
|
797
828
|
}
|
|
798
829
|
} else {
|
|
@@ -926,6 +957,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
926
957
|
|
|
927
958
|
this.db ??= db;
|
|
928
959
|
const liveDb = this.db;
|
|
960
|
+
if (this.pendingLegacyMigration) {
|
|
961
|
+
const { bytes } = this.pendingLegacyMigration;
|
|
962
|
+
this.pendingLegacyMigration = null;
|
|
963
|
+
// Second half of the one-time migration: the legacy copy just
|
|
964
|
+
// imported goes to the system of record from the working copy —
|
|
965
|
+
// streamed from a snapshot like every flush, so a Node-era history of
|
|
966
|
+
// any size costs a few chunks of memory. A failure (no
|
|
967
|
+
// `ixo:filesystem` delegation yet, a VFS outage) keeps the copy dirty
|
|
968
|
+
// and retried every 10 min; the object serves the legacy history
|
|
969
|
+
// meanwhile, and the Matrix copy is only redacted once VFS holds it.
|
|
970
|
+
try {
|
|
971
|
+
const flushed = await this.flushToOwnerStore();
|
|
972
|
+
console.log(
|
|
973
|
+
`[user-do] migrated ${bytes} bytes from legacy Matrix media to ${this.ownerStore.kind} for ${userDid} (${flushed.etag ?? '-'})`,
|
|
974
|
+
);
|
|
975
|
+
} catch (err) {
|
|
976
|
+
console.warn(
|
|
977
|
+
`[user-do] VFS migration write failed for ${userDid} — serving the legacy Matrix copy for now: ${err instanceof Error ? err.message : String(err)}`,
|
|
978
|
+
);
|
|
979
|
+
// Sooner than the 24 h debounce `markDirty` armed: the system of
|
|
980
|
+
// record is still empty for this user.
|
|
981
|
+
this.requestAlarm(Date.now() + FLUSH_RETRY_DELAY_MS);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
await this.reconcileDirtyOnBoot(liveDb, userDid);
|
|
929
985
|
{
|
|
930
986
|
// One line per boot so an operator can tell what a user's object
|
|
931
987
|
// actually holds without a debug route: which store, how big, how
|
|
@@ -959,6 +1015,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
959
1015
|
await this.saver.setup();
|
|
960
1016
|
this.sessions = new SessionsStore(liveDb);
|
|
961
1017
|
await this.sessions.setup();
|
|
1018
|
+
this.matrixLedger = new MatrixTurnLedger(liveDb);
|
|
1019
|
+
await this.matrixLedger.setup();
|
|
962
1020
|
|
|
963
1021
|
const core = this.core;
|
|
964
1022
|
// Boot-time plugin hooks + collision checks, once per object (memoised).
|
|
@@ -1003,8 +1061,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1003
1061
|
logger: console,
|
|
1004
1062
|
});
|
|
1005
1063
|
this.workStatus = new WorkStatusProducer({
|
|
1006
|
-
postEvent: (roomId, type, content) =>
|
|
1007
|
-
this.gateway.sendEvent(
|
|
1064
|
+
postEvent: (roomId, type, content, opts) =>
|
|
1065
|
+
this.gateway.sendEvent(
|
|
1066
|
+
roomId,
|
|
1067
|
+
type,
|
|
1068
|
+
JSON.stringify(content),
|
|
1069
|
+
opts ?? {},
|
|
1070
|
+
),
|
|
1008
1071
|
logger: console,
|
|
1009
1072
|
});
|
|
1010
1073
|
this.ambient = createAmbientServices({
|
|
@@ -1022,6 +1085,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1022
1085
|
delegationFor: (did) => this.delegations.get(did),
|
|
1023
1086
|
events: this.events,
|
|
1024
1087
|
secrets: createSecretsAdapter(secretsService),
|
|
1088
|
+
background: (work) => this.ctx.waitUntil(work),
|
|
1025
1089
|
});
|
|
1026
1090
|
}
|
|
1027
1091
|
|
|
@@ -1094,10 +1158,25 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1094
1158
|
*/
|
|
1095
1159
|
private async adoptOwnerCopy(
|
|
1096
1160
|
db: DoSqliteDatabase,
|
|
1097
|
-
copy:
|
|
1161
|
+
copy: OwnerCopy,
|
|
1098
1162
|
): Promise<number> {
|
|
1099
1163
|
const bytes = await db.importFromStream(copy.stream);
|
|
1100
1164
|
await this.ctx.storage.put(META_OWNER_ETAG, copy.etag);
|
|
1165
|
+
if (copy.fromLegacy) {
|
|
1166
|
+
// Streamed in from the legacy Matrix media, not in the system of
|
|
1167
|
+
// record yet: forget any earlier upload markers so the flush that
|
|
1168
|
+
// `ready()` runs next (`pendingLegacyMigration`) actually sends it,
|
|
1169
|
+
// and forget "no legacy copy, stop looking" — a copy evidently
|
|
1170
|
+
// exists now, and the flush must be able to redact it.
|
|
1171
|
+
await this.ctx.storage.delete([
|
|
1172
|
+
META_LAST_CHECKSUM,
|
|
1173
|
+
META_UPLOADED_GEN,
|
|
1174
|
+
META_LEGACY_CLEARED,
|
|
1175
|
+
]);
|
|
1176
|
+
this.markDirty();
|
|
1177
|
+
this.pendingLegacyMigration = { bytes };
|
|
1178
|
+
return bytes;
|
|
1179
|
+
}
|
|
1101
1180
|
await this.ctx.storage.put(META_LAST_CHECKSUM, await db.checksum());
|
|
1102
1181
|
await this.ctx.storage.put(META_UPLOADED_GEN, db.writeGeneration);
|
|
1103
1182
|
return bytes;
|
|
@@ -1226,8 +1305,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1226
1305
|
}
|
|
1227
1306
|
|
|
1228
1307
|
// Legacy-blob compaction: one bounded batch per tick until the whole
|
|
1229
|
-
// file is codec-compressed (see sqlite/blob-compactor.ts).
|
|
1230
|
-
|
|
1308
|
+
// file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
|
|
1309
|
+
// flight holds a snapshot of the file, which the compactor's swap-in
|
|
1310
|
+
// cannot replace — a large migration flush (streamed, seconds long)
|
|
1311
|
+
// routinely overlaps the first tick, so wait for it instead of erroring.
|
|
1312
|
+
if (this.db && this.flushInFlight) {
|
|
1313
|
+
deadlines.push(now + 5000);
|
|
1314
|
+
} else if (this.db) {
|
|
1231
1315
|
try {
|
|
1232
1316
|
if (await this.compactionTick(this.db)) deadlines.push(now + 2000);
|
|
1233
1317
|
} catch (err) {
|
|
@@ -1579,42 +1663,70 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1579
1663
|
}
|
|
1580
1664
|
|
|
1581
1665
|
/**
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1666
|
+
* Ask the web app to open its "authorize for Matrix" modal in place when a
|
|
1667
|
+
* Matrix turn finds no valid delegation — the Node AgentBuilder's
|
|
1668
|
+
* `maybePromptReauth`, throttled per object
|
|
1669
|
+
* (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`, default 6 h). Never throws; the
|
|
1670
|
+
* prompt outlives the turn under `waitUntil` while it is retried across a
|
|
1671
|
+
* gateway restart (see reauth-prompt.ts for the ordering that matters).
|
|
1586
1672
|
*/
|
|
1587
|
-
private
|
|
1673
|
+
private promptReauth(userDid: string, roomId: string): void {
|
|
1674
|
+
this.reauthPrompter ??= new ReauthPrompter({
|
|
1675
|
+
throttleMs: reauthThrottleSeconds(this.env) * 1000,
|
|
1676
|
+
getStamp: () => this.ctx.storage.get<number>(META_REAUTH_PROMPT_AT),
|
|
1677
|
+
setStamp: (at) => this.ctx.storage.put(META_REAUTH_PROMPT_AT, at),
|
|
1678
|
+
send: (room, txnId) =>
|
|
1679
|
+
this.gateway.sendEvent(
|
|
1680
|
+
room,
|
|
1681
|
+
'ixo.oracle.delegation_required',
|
|
1682
|
+
JSON.stringify({
|
|
1683
|
+
oracleEntityDid: this.core.identity.entityDid,
|
|
1684
|
+
oracleDid: this.env.ORACLE_DID,
|
|
1685
|
+
}),
|
|
1686
|
+
{ txnId },
|
|
1687
|
+
),
|
|
1688
|
+
keepAlive: (work) => this.ctx.waitUntil(work),
|
|
1689
|
+
log: (message) => console.log(message),
|
|
1690
|
+
warn: (message) => console.warn(message),
|
|
1691
|
+
});
|
|
1692
|
+
void this.reauthPrompter.prompt(userDid, roomId);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** Debug route: forget when the last re-authorise prompt was posted, so a drill can trigger the next one. */
|
|
1696
|
+
async debugResetReauthThrottle(): Promise<void> {
|
|
1697
|
+
await this.ctx.storage.delete(META_REAUTH_PROMPT_AT);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* A turn that died mid-way leaves committed checkpoint steps with no dirty
|
|
1702
|
+
* mark (the mark is set at the end of a turn). Compare the file's write
|
|
1703
|
+
* generation with the last upload's on boot and mark the copy dirty when
|
|
1704
|
+
* it moved on; one batched storage read per boot (see boot-dirty.ts).
|
|
1705
|
+
*/
|
|
1706
|
+
private async reconcileDirtyOnBoot(
|
|
1707
|
+
db: DoSqliteDatabase,
|
|
1588
1708
|
userDid: string,
|
|
1589
|
-
roomId: string,
|
|
1590
1709
|
): Promise<void> {
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
oracleEntityDid: this.core.identity.entityDid,
|
|
1608
|
-
oracleDid: this.env.ORACLE_DID,
|
|
1609
|
-
}),
|
|
1610
|
-
);
|
|
1710
|
+
if (this.dirty) return;
|
|
1711
|
+
const got = await this.ctx.storage.get<unknown>([
|
|
1712
|
+
META_DIRTY,
|
|
1713
|
+
META_UPLOADED_GEN,
|
|
1714
|
+
]);
|
|
1715
|
+
const decision = decideBootDirty({
|
|
1716
|
+
dirtyInMemory: this.dirty,
|
|
1717
|
+
dirtyFlag: got.get(META_DIRTY),
|
|
1718
|
+
uploadedGen: got.get(META_UPLOADED_GEN),
|
|
1719
|
+
writeGeneration: db.writeGeneration,
|
|
1720
|
+
});
|
|
1721
|
+
if (decision === 'flagged') {
|
|
1722
|
+
// The mark is on disk already (its flush alarm too); just adopt it.
|
|
1723
|
+
this.dirty = true;
|
|
1724
|
+
this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
|
|
1725
|
+
} else if (decision === 'behind-upload') {
|
|
1611
1726
|
console.log(
|
|
1612
|
-
`[user-do]
|
|
1613
|
-
);
|
|
1614
|
-
} catch (err) {
|
|
1615
|
-
console.warn(
|
|
1616
|
-
`[user-do] delegation-required event failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1727
|
+
`[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`,
|
|
1617
1728
|
);
|
|
1729
|
+
this.markDirty();
|
|
1618
1730
|
}
|
|
1619
1731
|
}
|
|
1620
1732
|
|
|
@@ -1656,8 +1768,68 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1656
1768
|
return true;
|
|
1657
1769
|
}
|
|
1658
1770
|
|
|
1771
|
+
/**
|
|
1772
|
+
* A room turn runs at most once per Matrix event, however many times the
|
|
1773
|
+
* gateway asks (it asks again after a reset, see the inbox in
|
|
1774
|
+
* `src/matrix/gateway-do.ts`). The ledger and the in-flight map decide:
|
|
1775
|
+
* answered → stored reply; running → attach; started but lost in a reset
|
|
1776
|
+
* of this object → refused (tools may have run), the user is told to
|
|
1777
|
+
* retry; unknown → run once. HTTP and task turns carry no event id and
|
|
1778
|
+
* always run.
|
|
1779
|
+
*/
|
|
1659
1780
|
async runTurn(req: TurnRequest): Promise<TurnResult> {
|
|
1660
1781
|
await this.ready(req.identity);
|
|
1782
|
+
const eventId = req.client === 'matrix' ? req.eventId : undefined;
|
|
1783
|
+
const ledger = this.matrixLedger;
|
|
1784
|
+
if (!eventId || !ledger) return this.runTurnOnce(req);
|
|
1785
|
+
|
|
1786
|
+
const running = this.matrixTurnRuns.get(eventId);
|
|
1787
|
+
if (running) {
|
|
1788
|
+
console.log(
|
|
1789
|
+
`[user-do] turn for ${eventId} is still running; the gateway asked again and attaches to it`,
|
|
1790
|
+
);
|
|
1791
|
+
return running;
|
|
1792
|
+
}
|
|
1793
|
+
const existing = await ledger.get(eventId);
|
|
1794
|
+
switch (decideMatrixTurn(existing)) {
|
|
1795
|
+
case 'answered':
|
|
1796
|
+
console.log(
|
|
1797
|
+
`[user-do] turn for ${eventId} already answered; returning the stored reply`,
|
|
1798
|
+
);
|
|
1799
|
+
return {
|
|
1800
|
+
sessionId: req.sessionId,
|
|
1801
|
+
requestId: req.requestId,
|
|
1802
|
+
text: existing?.replyText ?? '',
|
|
1803
|
+
toolCalls: [],
|
|
1804
|
+
replayed: true,
|
|
1805
|
+
};
|
|
1806
|
+
case 'interrupted':
|
|
1807
|
+
throw new Error(`${TURN_INTERRUPTED_MARKER} (${eventId})`);
|
|
1808
|
+
case 'run':
|
|
1809
|
+
break;
|
|
1810
|
+
}
|
|
1811
|
+
await ledger.start(eventId, req.sessionId, req.requestId);
|
|
1812
|
+
const run = this.runTurnOnce(req)
|
|
1813
|
+
.then(async (result) => {
|
|
1814
|
+
await ledger.answer(eventId, result.text);
|
|
1815
|
+
return result;
|
|
1816
|
+
})
|
|
1817
|
+
.finally(() => {
|
|
1818
|
+
this.matrixTurnRuns.delete(eventId);
|
|
1819
|
+
});
|
|
1820
|
+
this.matrixTurnRuns.set(eventId, run);
|
|
1821
|
+
// The turn's I/O belongs to this RPC's request context. If the gateway
|
|
1822
|
+
// that made the call is reset, the context would be cancelled with it:
|
|
1823
|
+
// fetches started afterwards (the next model call, the next tool) never
|
|
1824
|
+
// settle, and the turn hangs forever — with a replay attached to it.
|
|
1825
|
+
// `waitUntil` keeps the context alive until the turn has ended, so the
|
|
1826
|
+
// turn finishes, the ledger records the reply, and the replayed request
|
|
1827
|
+
// returns it.
|
|
1828
|
+
this.ctx.waitUntil(run.catch(() => undefined));
|
|
1829
|
+
return run;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
private async runTurnOnce(req: TurnRequest): Promise<TurnResult> {
|
|
1661
1833
|
// Matrix turns drive a `work_status` card in the user's thread; a newer
|
|
1662
1834
|
// message on the same session supersedes the previous card (the
|
|
1663
1835
|
// previous turn itself is aborted by prepareTurn).
|
|
@@ -1809,6 +1981,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1809
1981
|
* while one runs joins it.
|
|
1810
1982
|
*/
|
|
1811
1983
|
async flushToOwnerStore(): Promise<FlushResult> {
|
|
1984
|
+
if (!this.db) {
|
|
1985
|
+
// A cold (evicted) object: boot from the persisted identity first, so
|
|
1986
|
+
// an operator's `POST /debug/storage/flush` is never a silent no-op.
|
|
1987
|
+
const userDid =
|
|
1988
|
+
this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
1989
|
+
if (userDid) await this.ready({ userDid });
|
|
1990
|
+
}
|
|
1812
1991
|
if (this.flushInFlight) return this.flushInFlight;
|
|
1813
1992
|
const run = this.flushOnce().finally(() => {
|
|
1814
1993
|
this.flushInFlight = null;
|
|
@@ -1976,6 +2155,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1976
2155
|
this.db = null;
|
|
1977
2156
|
this.saver = null;
|
|
1978
2157
|
this.sessions = null;
|
|
2158
|
+
this.matrixLedger = null;
|
|
2159
|
+
this.sessionRooms.clear();
|
|
1979
2160
|
this.ambient = null;
|
|
1980
2161
|
this.secretsService = null;
|
|
1981
2162
|
this.byo = null;
|
|
@@ -1994,9 +2175,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1994
2175
|
async debugSession(
|
|
1995
2176
|
userDid: string,
|
|
1996
2177
|
sessionId: string,
|
|
1997
|
-
): Promise<
|
|
2178
|
+
): Promise<Record<string, unknown> | null> {
|
|
1998
2179
|
await this.ready({ userDid });
|
|
1999
|
-
|
|
2180
|
+
const session = await this.sessions!.getSession(sessionId);
|
|
2181
|
+
if (!session) return null;
|
|
2182
|
+
// Whether the thread's agent context was condensed: the summarization
|
|
2183
|
+
// middleware's bookkeeping message is stored with the history but
|
|
2184
|
+
// never listed, so `GET /messages/:id` alone cannot tell.
|
|
2185
|
+
const all = await this.saver!.listThreadMessages(sessionId);
|
|
2186
|
+
return {
|
|
2187
|
+
...session,
|
|
2188
|
+
threadMessages: all.length,
|
|
2189
|
+
summaryMessages: all.filter(isSummarizationMessage).length,
|
|
2190
|
+
// Folded into their assistant messages by the listing, not shown alone.
|
|
2191
|
+
toolMessages: all.filter((m) => m.type === 'tool').length,
|
|
2192
|
+
};
|
|
2000
2193
|
}
|
|
2001
2194
|
|
|
2002
2195
|
/**
|
|
@@ -2004,6 +2197,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2004
2197
|
* this user's credentials and return `search_memory_engine`'s raw schema
|
|
2005
2198
|
* plus whether it converts to Zod. Same headers the memory plugin sends.
|
|
2006
2199
|
*/
|
|
2200
|
+
/**
|
|
2201
|
+
* Operator / testing aid behind `ORACLE_DEBUG_ROUTES`: reset this object
|
|
2202
|
+
* the way an unplanned platform reset does — in-memory state and every
|
|
2203
|
+
* in-flight turn are gone, committed storage survives, the next request
|
|
2204
|
+
* boots a fresh instance. The RPC rejects by design.
|
|
2205
|
+
*/
|
|
2206
|
+
async debugAbortObject(): Promise<void> {
|
|
2207
|
+
this.ctx.abort('debug reset requested');
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2007
2210
|
async debugMemorySchema(userDid: string): Promise<MemorySchemaDebug> {
|
|
2008
2211
|
await this.ready({ userDid });
|
|
2009
2212
|
const ambient = this.ambient;
|
|
@@ -2095,7 +2298,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2095
2298
|
};
|
|
2096
2299
|
}
|
|
2097
2300
|
|
|
2098
|
-
async tasksStatus(): Promise<{
|
|
2301
|
+
async tasksStatus(userDid?: string): Promise<{
|
|
2099
2302
|
now: number;
|
|
2100
2303
|
alarm: number | null;
|
|
2101
2304
|
schedulerActive: boolean;
|
|
@@ -2107,14 +2310,41 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2107
2310
|
lastResult: { ok: boolean; summary: string; at: string } | null;
|
|
2108
2311
|
consecutiveFailures: number;
|
|
2109
2312
|
}>;
|
|
2313
|
+
openRuns: Array<{
|
|
2314
|
+
runId: string;
|
|
2315
|
+
taskId: string;
|
|
2316
|
+
startedAt: string;
|
|
2317
|
+
state: 'running' | 'delivering';
|
|
2318
|
+
attempts: number;
|
|
2319
|
+
retryAt: number | null;
|
|
2320
|
+
}>;
|
|
2110
2321
|
}> {
|
|
2322
|
+
// A cold (evicted or reset) object has no scheduler until it boots;
|
|
2323
|
+
// reporting "no tasks" for it would be wrong, so boot like the alarm
|
|
2324
|
+
// does when the caller — or the stored identity — says whose object.
|
|
2325
|
+
if (!this.taskScheduler) {
|
|
2326
|
+
const did =
|
|
2327
|
+
userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
2328
|
+
if (did) await this.ready({ userDid: did });
|
|
2329
|
+
}
|
|
2111
2330
|
const records = this.taskScheduler
|
|
2112
2331
|
? await this.taskScheduler.surface.list()
|
|
2113
2332
|
: [];
|
|
2333
|
+
const openRuns = this.taskScheduler
|
|
2334
|
+
? await this.taskScheduler.openRuns()
|
|
2335
|
+
: [];
|
|
2114
2336
|
return {
|
|
2115
2337
|
now: Date.now(),
|
|
2116
2338
|
alarm: await this.ctx.storage.getAlarm(),
|
|
2117
2339
|
schedulerActive: this.taskScheduler !== null,
|
|
2340
|
+
openRuns: openRuns.map((r) => ({
|
|
2341
|
+
runId: r.runId,
|
|
2342
|
+
taskId: r.taskId,
|
|
2343
|
+
startedAt: r.startedAt,
|
|
2344
|
+
state: r.state,
|
|
2345
|
+
attempts: r.attempts,
|
|
2346
|
+
retryAt: r.retryAt ?? null,
|
|
2347
|
+
})),
|
|
2118
2348
|
tasks: records.map((t) => ({
|
|
2119
2349
|
id: t.id,
|
|
2120
2350
|
title: t.title,
|
|
@@ -2392,12 +2622,36 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2392
2622
|
|
|
2393
2623
|
// ── turn plumbing ──────────────────────────────────────────────────────
|
|
2394
2624
|
|
|
2395
|
-
/**
|
|
2625
|
+
/**
|
|
2626
|
+
* Matrix media access for the attachments pipeline, through the gateway.
|
|
2627
|
+
* Both transfers stream across the object boundary and are cut off at
|
|
2628
|
+
* the attachment cap; an encrypted attachment is decrypted HERE, not in
|
|
2629
|
+
* the gateway (its whole-buffer download grew the crypto WASM heap for
|
|
2630
|
+
* good, and a hash mismatch on the far side of the RPC would only reach
|
|
2631
|
+
* this object as a disconnect).
|
|
2632
|
+
*/
|
|
2396
2633
|
private matrixMediaSource(): MatrixMediaSource {
|
|
2397
2634
|
return {
|
|
2398
|
-
downloadMxc: (mxc) =>
|
|
2399
|
-
|
|
2400
|
-
|
|
2635
|
+
downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE) =>
|
|
2636
|
+
readBytesCapped(
|
|
2637
|
+
await this.gateway.downloadMxcMediaStream(mxc),
|
|
2638
|
+
maxBytes,
|
|
2639
|
+
),
|
|
2640
|
+
downloadEvent: async (roomId, eventId, maxBytes = MAX_FILE_SIZE) => {
|
|
2641
|
+
const media = await this.gateway.downloadEventMediaStream(
|
|
2642
|
+
roomId,
|
|
2643
|
+
eventId,
|
|
2644
|
+
);
|
|
2645
|
+
if (!media) return null;
|
|
2646
|
+
const plain = media.file
|
|
2647
|
+
? media.stream.pipeThrough(createAttachmentDecryptor(media.file))
|
|
2648
|
+
: media.stream;
|
|
2649
|
+
return {
|
|
2650
|
+
bytes: await readBytesCapped(plain, maxBytes),
|
|
2651
|
+
...(media.mimetype ? { mimetype: media.mimetype } : {}),
|
|
2652
|
+
...(media.filename ? { filename: media.filename } : {}),
|
|
2653
|
+
};
|
|
2654
|
+
},
|
|
2401
2655
|
};
|
|
2402
2656
|
}
|
|
2403
2657
|
|
|
@@ -2672,6 +2926,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2672
2926
|
`[user-do] no oracle room resolved for ${req.identity.userDid}; room-scoped plugins (memory) will be unavailable this turn`,
|
|
2673
2927
|
);
|
|
2674
2928
|
}
|
|
2929
|
+
if (sessionRoomId) this.sessionRooms.set(req.sessionId, sessionRoomId);
|
|
2675
2930
|
|
|
2676
2931
|
// A Matrix turn with no usable delegation: ask the web app to open its
|
|
2677
2932
|
// "authorize for Matrix" modal (throttled per user, best-effort) — the
|
|
@@ -2681,7 +2936,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2681
2936
|
sessionRoomId &&
|
|
2682
2937
|
!this.delegations.get(req.identity.userDid)?.raw
|
|
2683
2938
|
) {
|
|
2684
|
-
|
|
2939
|
+
this.promptReauth(req.identity.userDid, sessionRoomId);
|
|
2685
2940
|
}
|
|
2686
2941
|
|
|
2687
2942
|
// Per-room user preferences (tone, language, what to call whom) —
|
|
@@ -2873,7 +3128,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2873
3128
|
configurable: { thread_id: req.sessionId },
|
|
2874
3129
|
context,
|
|
2875
3130
|
signal: abortController.signal,
|
|
2876
|
-
recursionLimit:
|
|
3131
|
+
recursionLimit: turnRecursionLimit(this.env),
|
|
2877
3132
|
metadata: tracing.metadata,
|
|
2878
3133
|
...(tracing.callbacks ? { callbacks: tracing.callbacks } : {}),
|
|
2879
3134
|
};
|
|
@@ -2909,40 +3164,57 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2909
3164
|
if (!req.sessionId.startsWith('$')) return;
|
|
2910
3165
|
if (!text.trim()) return;
|
|
2911
3166
|
const label = who === 'user' ? 'user message' : 'AI response';
|
|
2912
|
-
//
|
|
2913
|
-
//
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
(async () => {
|
|
3167
|
+
// Serialised per session, retried across a gateway restart with a fixed
|
|
3168
|
+
// transaction id, kept alive past the request (room-mirror.ts).
|
|
3169
|
+
void this.roomMirror().enqueue(
|
|
3170
|
+
req.sessionId,
|
|
3171
|
+
async () => {
|
|
2918
3172
|
const roomId =
|
|
3173
|
+
this.sessionRooms.get(req.sessionId) ??
|
|
2919
3174
|
req.roomId ??
|
|
2920
3175
|
(await this.sessions?.getSession(req.sessionId))?.roomId ??
|
|
2921
|
-
(
|
|
3176
|
+
(
|
|
3177
|
+
await retryGateway(() =>
|
|
3178
|
+
this.gateway.resolveUserRoom(req.identity.userDid),
|
|
3179
|
+
)
|
|
3180
|
+
)?.roomId;
|
|
2922
3181
|
if (!roomId) throw new Error('no oracle room for this user');
|
|
3182
|
+
this.sessionRooms.set(req.sessionId, roomId);
|
|
2923
3183
|
const { body, formattedBody } = formatReplay({
|
|
2924
3184
|
message: text,
|
|
2925
3185
|
isOracle: who === 'oracle',
|
|
2926
3186
|
oracleName: this.core.identity.name,
|
|
2927
3187
|
});
|
|
2928
|
-
|
|
3188
|
+
return {
|
|
3189
|
+
roomId,
|
|
3190
|
+
body,
|
|
3191
|
+
...(formattedBody ? { formattedBody } : {}),
|
|
2929
3192
|
threadId: req.sessionId,
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
`[user-do] Matrix replay (${label}) → ${eventId} in thread ${req.sessionId}`,
|
|
2935
|
-
);
|
|
2936
|
-
})().catch((err: unknown) => {
|
|
2937
|
-
console.error(
|
|
2938
|
-
`[user-do] Matrix replay (${label}) failed — session=${req.sessionId}: ${
|
|
2939
|
-
err instanceof Error ? err.message : String(err)
|
|
2940
|
-
}`,
|
|
2941
|
-
);
|
|
2942
|
-
}),
|
|
3193
|
+
txnId: mirrorTxnId(req.sessionId, req.requestId, who),
|
|
3194
|
+
};
|
|
3195
|
+
},
|
|
3196
|
+
label,
|
|
2943
3197
|
);
|
|
2944
3198
|
}
|
|
2945
3199
|
|
|
3200
|
+
private roomMirror(): RoomMirror {
|
|
3201
|
+
this.mirror ??= new RoomMirror({
|
|
3202
|
+
sendText: (send) =>
|
|
3203
|
+
this.gateway.sendText(send.roomId, send.body, {
|
|
3204
|
+
threadId: send.threadId,
|
|
3205
|
+
...(send.formattedBody
|
|
3206
|
+
? { formattedBody: send.formattedBody }
|
|
3207
|
+
: {}),
|
|
3208
|
+
priority: 'background',
|
|
3209
|
+
txnId: send.txnId,
|
|
3210
|
+
}),
|
|
3211
|
+
keepAlive: (work) => this.ctx.waitUntil(work),
|
|
3212
|
+
log: (message) => console.log(message),
|
|
3213
|
+
warn: (message) => console.warn(message),
|
|
3214
|
+
});
|
|
3215
|
+
return this.mirror;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
2946
3218
|
private async afterTurn(
|
|
2947
3219
|
sessionId: string,
|
|
2948
3220
|
messages: BaseMessage[],
|