@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/contracts.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BotStatus } from '@ixo/matrix-bot-workers-sdk';
|
|
1
|
+
import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
|
|
2
2
|
import type { AttachmentInput } from '../attachments/types';
|
|
3
3
|
import type { RealtimeStatus } from '../realtime/realtime-endpoint';
|
|
4
4
|
/**
|
|
@@ -107,6 +107,8 @@ export interface OracleWorkerEnv {
|
|
|
107
107
|
/** Display markup for `GET /models` prices (default 1.6). */
|
|
108
108
|
MODEL_PRICE_MARKUP?: string;
|
|
109
109
|
MAIN_REASONING_EFFORT?: string;
|
|
110
|
+
/** LangGraph steps one turn may take before `GraphRecursionError` (default 600; Node hard-codes 200). */
|
|
111
|
+
TURN_RECURSION_LIMIT?: string;
|
|
110
112
|
/** Platform model provider. Default `openrouter`; `nebius` for self-hosted. */
|
|
111
113
|
LLM_PROVIDER?: 'openrouter' | 'nebius';
|
|
112
114
|
/** Nebius Token Factory API key — required when `LLM_PROVIDER=nebius`. */
|
|
@@ -205,6 +207,21 @@ export interface TurnResult {
|
|
|
205
207
|
messageId?: string;
|
|
206
208
|
/** Tool calls made during the turn (name + summarized status), for logging. */
|
|
207
209
|
toolCalls: Array<{ name: string; status: 'done' | 'error' }>;
|
|
210
|
+
/** The stored reply of a turn that had already finished (a gateway asked again after a reset). */
|
|
211
|
+
replayed?: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Prefix of the error a user object raises when a gateway asks again about a
|
|
216
|
+
* room message whose turn the object itself lost mid-run (see
|
|
217
|
+
* `src/do/matrix-turn-ledger.ts`): not re-run, the user is told to retry.
|
|
218
|
+
*/
|
|
219
|
+
export const TURN_INTERRUPTED_MARKER =
|
|
220
|
+
'turn interrupted by a runtime reset before it finished; not re-run';
|
|
221
|
+
|
|
222
|
+
export function isInterruptedTurnError(err: unknown): boolean {
|
|
223
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
224
|
+
return message.includes(TURN_INTERRUPTED_MARKER);
|
|
208
225
|
}
|
|
209
226
|
|
|
210
227
|
/** Row shape served by `GET /sessions`. */
|
|
@@ -341,6 +358,10 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
|
341
358
|
resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }>;
|
|
342
359
|
/** Raw upstream schema of the memory search tool as fetched right now. */
|
|
343
360
|
debugMemorySchema(userDid: string): Promise<MemorySchemaDebug>;
|
|
361
|
+
/** Forcibly reset the object like a platform host drain (debug routes only). The call itself rejects. */
|
|
362
|
+
debugAbortObject(): Promise<void>;
|
|
363
|
+
/** Forget when the last re-authorise prompt was posted (debug routes only). */
|
|
364
|
+
debugResetReauthThrottle(): Promise<void>;
|
|
344
365
|
/** Raw session row for `GET /debug/sessions/:id` (null when unknown). */
|
|
345
366
|
debugSession(
|
|
346
367
|
userDid: string,
|
|
@@ -354,7 +375,8 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
|
354
375
|
* Diagnostics: the object's current alarm and the user's task records — the
|
|
355
376
|
* pair that answers "is this task going to fire, and when?".
|
|
356
377
|
*/
|
|
357
|
-
|
|
378
|
+
/** Task records, open runs and the alarm; boots the object when cold (`userDid` = the caller). */
|
|
379
|
+
tasksStatus(userDid?: string): Promise<{
|
|
358
380
|
now: number;
|
|
359
381
|
alarm: number | null;
|
|
360
382
|
schedulerActive: boolean;
|
|
@@ -367,6 +389,15 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
|
367
389
|
consecutiveFailures: number;
|
|
368
390
|
deliveryRoomId: string | null;
|
|
369
391
|
}>;
|
|
392
|
+
/** Runs no incarnation has finished: what an alarm will re-deliver or close. */
|
|
393
|
+
openRuns: Array<{
|
|
394
|
+
runId: string;
|
|
395
|
+
taskId: string;
|
|
396
|
+
startedAt: string;
|
|
397
|
+
state: 'running' | 'delivering';
|
|
398
|
+
attempts: number;
|
|
399
|
+
retryAt: number | null;
|
|
400
|
+
}>;
|
|
370
401
|
}>;
|
|
371
402
|
}
|
|
372
403
|
|
|
@@ -388,17 +419,21 @@ export interface CreateRoomOptions {
|
|
|
388
419
|
* rest is the oracle's.
|
|
389
420
|
*/
|
|
390
421
|
export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
391
|
-
/**
|
|
392
|
-
|
|
422
|
+
/**
|
|
423
|
+
* A media event's content as a stream, AS STORED: in an E2EE room the
|
|
424
|
+
* ciphertext plus the `EncryptedFile` fields to pipe through the SDK's
|
|
425
|
+
* `createAttachmentDecryptor` on the caller's side (a stream that errors
|
|
426
|
+
* on the far side of an RPC only surfaces here as a disconnect, so the
|
|
427
|
+
* hash check belongs to the consumer). Null when unknown or redacted.
|
|
428
|
+
*/
|
|
429
|
+
downloadEventMediaStream(
|
|
393
430
|
roomId: string,
|
|
394
431
|
eventId: string,
|
|
395
|
-
): Promise<
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
/** Authenticated media download of an `mxc://` URI. */
|
|
401
|
-
downloadMxcMedia(mxc: string): Promise<Uint8Array>;
|
|
432
|
+
): Promise<MediaStream | null>;
|
|
433
|
+
/** Authenticated media download of an `mxc://` URI, as a stream. */
|
|
434
|
+
downloadMxcMediaStream(mxc: string): Promise<ReadableStream<Uint8Array>>;
|
|
435
|
+
/** Forcibly reset the gateway object like a platform host drain (debug routes only). The call itself rejects. */
|
|
436
|
+
debugAbortObject(): Promise<void>;
|
|
402
437
|
/** Stop the bot (operator/debug; `ensureStarted`/cron brings it back). */
|
|
403
438
|
stop(): Promise<void>;
|
|
404
439
|
/** Idempotent — starts the bot + keep-alive alarm if not running. */
|
|
@@ -451,8 +486,18 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
451
486
|
txnId?: string;
|
|
452
487
|
},
|
|
453
488
|
): Promise<string>;
|
|
454
|
-
/**
|
|
455
|
-
|
|
489
|
+
/**
|
|
490
|
+
* Send an arbitrary timeline event (`content` JSON-encoded). Returns the
|
|
491
|
+
* event id. `txnId` pins the transaction id, as for `sendText`: the
|
|
492
|
+
* homeserver deduplicates by it per device, so a send retried after a lost
|
|
493
|
+
* response returns the original event instead of posting a second one.
|
|
494
|
+
*/
|
|
495
|
+
sendEvent(
|
|
496
|
+
roomId: string,
|
|
497
|
+
type: string,
|
|
498
|
+
content: JsonString,
|
|
499
|
+
opts?: { txnId?: string },
|
|
500
|
+
): Promise<string>;
|
|
456
501
|
/** Resolve the canonical user↔oracle room for a user DID (alias lookup, cached). */
|
|
457
502
|
resolveUserRoom(
|
|
458
503
|
userDid: string,
|
|
@@ -465,17 +510,23 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
465
510
|
* Returns the new event id. Wire-compatible with the Node runtime so
|
|
466
511
|
* existing users migrate transparently.
|
|
467
512
|
*/
|
|
468
|
-
|
|
513
|
+
uploadUserSnapshotStream(
|
|
469
514
|
userDid: string,
|
|
470
515
|
storageKey: string,
|
|
471
|
-
|
|
516
|
+
/** The gzipped file; encrypted chunk by chunk in an E2EE room, never buffered. */
|
|
517
|
+
body: ReadableStream<Uint8Array>,
|
|
472
518
|
filename: string,
|
|
519
|
+
/** Exact byte length of `body` (the homeserver needs a Content-Length). */
|
|
520
|
+
size: number,
|
|
473
521
|
): Promise<{ eventId: string }>;
|
|
474
|
-
/**
|
|
475
|
-
|
|
522
|
+
/**
|
|
523
|
+
* The latest snapshot for `storageKey` as a stream, as stored (see
|
|
524
|
+
* `downloadEventMediaStream`), or null when none exists.
|
|
525
|
+
*/
|
|
526
|
+
downloadUserSnapshotStream(
|
|
476
527
|
userDid: string,
|
|
477
528
|
storageKey: string,
|
|
478
|
-
): Promise<
|
|
529
|
+
): Promise<SnapshotStream | null>;
|
|
479
530
|
/** Read a room state event's content, JSON-encoded (state is never E2EE). Null when absent. */
|
|
480
531
|
getRoomStateEvent(
|
|
481
532
|
roomId: string,
|
|
@@ -533,10 +584,32 @@ export interface OutboxRow {
|
|
|
533
584
|
}
|
|
534
585
|
|
|
535
586
|
/** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
|
|
587
|
+
/**
|
|
588
|
+
* Media handed across the Durable Object boundary as a stream, as stored on
|
|
589
|
+
* the homeserver: `file` is present when the event carried an encrypted
|
|
590
|
+
* attachment — pipe `stream` through the SDK's `createAttachmentDecryptor`
|
|
591
|
+
* with it; without `file` the stream is the plain upload. `size` is the
|
|
592
|
+
* sender's `info.size` when it set one.
|
|
593
|
+
*/
|
|
594
|
+
export interface MediaStream {
|
|
595
|
+
stream: ReadableStream<Uint8Array>;
|
|
596
|
+
file?: EncryptedFileInfo;
|
|
597
|
+
size?: number;
|
|
598
|
+
mimetype?: string;
|
|
599
|
+
filename?: string;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** `MediaStream` of a user's snapshot, plus the media event that carries it. */
|
|
603
|
+
export interface SnapshotStream extends MediaStream {
|
|
604
|
+
eventId: string;
|
|
605
|
+
}
|
|
606
|
+
|
|
536
607
|
export interface GatewayStatus extends BotStatus {
|
|
537
608
|
turns: { inFlight: number; waiting: number };
|
|
538
609
|
/** Debounce buffers waiting to become turns. */
|
|
539
610
|
ingestPending: number;
|
|
611
|
+
/** Room messages whose turn has not ended yet (durable; replayed after a reset). */
|
|
612
|
+
inbox: number;
|
|
540
613
|
}
|
|
541
614
|
|
|
542
615
|
/** Derive the durable-object name for a user's object. */
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the user object remembers about each room message it was asked to
|
|
3
|
+
* answer, so the gateway can ask again after a reset without the turn ever
|
|
4
|
+
* running twice (`src/matrix/inbox-store.ts` is the asking side).
|
|
5
|
+
*
|
|
6
|
+
* One row per Matrix event id, in the user's own SQLite next to the
|
|
7
|
+
* checkpoints: `started_at` is written before the turn runs, `reply_text` +
|
|
8
|
+
* `answered_at` when it has finished. A second request for the same event:
|
|
9
|
+
*
|
|
10
|
+
* - answered → the stored text is returned; no model, no tools;
|
|
11
|
+
* - running in this instance → the caller attaches to the running turn
|
|
12
|
+
* (kept in memory by the object, not here);
|
|
13
|
+
* - started, not answered, not running → the object itself reset mid-turn;
|
|
14
|
+
* tools may have run, so the turn is NOT re-run: the request is refused
|
|
15
|
+
* with `TURN_INTERRUPTED_MARKER` and the gateway tells the user to try
|
|
16
|
+
* again — the same thing that happens today when the object dies while
|
|
17
|
+
* the gateway is alive;
|
|
18
|
+
* - unknown → run once.
|
|
19
|
+
*
|
|
20
|
+
* Rows older than `RETENTION_MS` are pruned at setup; a replay that late
|
|
21
|
+
* cannot happen (the gateway inbox gives up after `MAX_TURN_REPLAYS`).
|
|
22
|
+
*/
|
|
23
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
24
|
+
|
|
25
|
+
export interface MatrixTurnRecord {
|
|
26
|
+
eventId: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
requestId: string;
|
|
29
|
+
startedAt: string;
|
|
30
|
+
answeredAt: string | null;
|
|
31
|
+
replyText: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type MatrixTurnDecision = 'run' | 'answered' | 'interrupted';
|
|
35
|
+
|
|
36
|
+
/** Rows are kept for a week; the gateway never replays anything that old. */
|
|
37
|
+
export const RETENTION_MS = 7 * 24 * 3600 * 1000;
|
|
38
|
+
|
|
39
|
+
export function decideMatrixTurn(
|
|
40
|
+
existing: MatrixTurnRecord | undefined,
|
|
41
|
+
): MatrixTurnDecision {
|
|
42
|
+
if (!existing) return 'run';
|
|
43
|
+
return existing.replyText !== null ? 'answered' : 'interrupted';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type LedgerRow = {
|
|
47
|
+
event_id: string;
|
|
48
|
+
session_id: string;
|
|
49
|
+
request_id: string;
|
|
50
|
+
started_at: string;
|
|
51
|
+
answered_at: string | null;
|
|
52
|
+
reply_text: string | null;
|
|
53
|
+
} & Record<string, string | null>;
|
|
54
|
+
|
|
55
|
+
export class MatrixTurnLedger {
|
|
56
|
+
private setupPromise: Promise<void> | undefined;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
readonly db: DoSqliteDatabase,
|
|
60
|
+
private readonly now: () => number = () => Date.now(),
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
setup(): Promise<void> {
|
|
64
|
+
this.setupPromise ??= this.doSetup().catch((error: unknown) => {
|
|
65
|
+
this.setupPromise = undefined;
|
|
66
|
+
throw error;
|
|
67
|
+
});
|
|
68
|
+
return this.setupPromise;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async doSetup(): Promise<void> {
|
|
72
|
+
await this.db.run(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS matrix_turns (
|
|
74
|
+
event_id TEXT PRIMARY KEY,
|
|
75
|
+
session_id TEXT NOT NULL,
|
|
76
|
+
request_id TEXT NOT NULL,
|
|
77
|
+
started_at TEXT NOT NULL,
|
|
78
|
+
answered_at TEXT,
|
|
79
|
+
reply_text TEXT
|
|
80
|
+
)`);
|
|
81
|
+
await this.db.run(`DELETE FROM matrix_turns WHERE started_at < ?`, [
|
|
82
|
+
new Date(this.now() - RETENTION_MS).toISOString(),
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async get(eventId: string): Promise<MatrixTurnRecord | undefined> {
|
|
87
|
+
await this.setup();
|
|
88
|
+
const row = await this.db.get<LedgerRow>(
|
|
89
|
+
`SELECT event_id, session_id, request_id, started_at, answered_at, reply_text
|
|
90
|
+
FROM matrix_turns WHERE event_id = ?`,
|
|
91
|
+
[eventId],
|
|
92
|
+
);
|
|
93
|
+
if (!row) return undefined;
|
|
94
|
+
return {
|
|
95
|
+
eventId: row.event_id,
|
|
96
|
+
sessionId: row.session_id,
|
|
97
|
+
requestId: row.request_id,
|
|
98
|
+
startedAt: row.started_at,
|
|
99
|
+
answeredAt: row.answered_at,
|
|
100
|
+
replyText: row.reply_text,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Mark the turn as started. Durable before any model or tool runs. */
|
|
105
|
+
async start(
|
|
106
|
+
eventId: string,
|
|
107
|
+
sessionId: string,
|
|
108
|
+
requestId: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
await this.setup();
|
|
111
|
+
await this.db.run(
|
|
112
|
+
`INSERT OR IGNORE INTO matrix_turns (event_id, session_id, request_id, started_at)
|
|
113
|
+
VALUES (?, ?, ?, ?)`,
|
|
114
|
+
[eventId, sessionId, requestId, new Date(this.now()).toISOString()],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Record the reply; from now on the event is answered, never re-run. */
|
|
119
|
+
async answer(eventId: string, replyText: string): Promise<void> {
|
|
120
|
+
await this.setup();
|
|
121
|
+
await this.db.run(
|
|
122
|
+
`UPDATE matrix_turns SET reply_text = ?, answered_at = ? WHERE event_id = ?`,
|
|
123
|
+
[replyText, new Date(this.now()).toISOString(), eventId],
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ixo.oracle.delegation_required` prompt: when a room message arrives
|
|
3
|
+
* and the user has no usable delegation, the object posts an event the web
|
|
4
|
+
* app listens for to open its "authorize for Matrix" modal in place. It is
|
|
5
|
+
* throttled per user (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`) so the room is
|
|
6
|
+
* not spammed while the user is away.
|
|
7
|
+
*
|
|
8
|
+
* The throttle is stamped only AFTER the homeserver accepted the event. The
|
|
9
|
+
* earlier order — stamp, then send — turned a send lost to a gateway restart
|
|
10
|
+
* into six hours of silence: the user kept messaging an oracle that could
|
|
11
|
+
* not act, and nothing told them why. The send is retried across a restart
|
|
12
|
+
* under one transaction id, so a response lost after the homeserver accepted
|
|
13
|
+
* the event is deduplicated rather than posted twice; a second turn arriving
|
|
14
|
+
* while a prompt is in flight joins it instead of posting another.
|
|
15
|
+
*/
|
|
16
|
+
import { retryTxnId } from '../matrix/txn-id';
|
|
17
|
+
import { retryGateway, type RetryGatewayOptions } from './gateway-retry';
|
|
18
|
+
|
|
19
|
+
export interface ReauthPromptDeps {
|
|
20
|
+
throttleMs: number;
|
|
21
|
+
/** When the last prompt was accepted (undefined = never). */
|
|
22
|
+
getStamp(): Promise<number | undefined>;
|
|
23
|
+
setStamp(at: number): Promise<void>;
|
|
24
|
+
/** Post the event under the transaction id; resolves to its event id. */
|
|
25
|
+
send(roomId: string, txnId: string): Promise<string>;
|
|
26
|
+
/** Keeps the (un-awaited) prompt alive past the request that started it: `ctx.waitUntil`. */
|
|
27
|
+
keepAlive(work: Promise<unknown>): void;
|
|
28
|
+
log(message: string): void;
|
|
29
|
+
warn(message: string): void;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const errorText = (err: unknown): string =>
|
|
35
|
+
err instanceof Error ? err.message : String(err);
|
|
36
|
+
|
|
37
|
+
export class ReauthPrompter {
|
|
38
|
+
private inFlight: Promise<void> | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(private readonly deps: ReauthPromptDeps) {}
|
|
41
|
+
|
|
42
|
+
/** Prompt unless throttled or already in flight. Never rejects. */
|
|
43
|
+
prompt(userDid: string, roomId: string): Promise<void> {
|
|
44
|
+
if (this.inFlight) return this.inFlight;
|
|
45
|
+
const run = this.run(userDid, roomId)
|
|
46
|
+
.catch((err: unknown) => {
|
|
47
|
+
this.deps.warn(
|
|
48
|
+
`[user-do] delegation-required event failed for ${userDid}: ${errorText(err)}`,
|
|
49
|
+
);
|
|
50
|
+
})
|
|
51
|
+
.finally(() => {
|
|
52
|
+
this.inFlight = null;
|
|
53
|
+
});
|
|
54
|
+
this.inFlight = run;
|
|
55
|
+
this.deps.keepAlive(run);
|
|
56
|
+
return run;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async run(userDid: string, roomId: string): Promise<void> {
|
|
60
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
61
|
+
const last = await this.deps.getStamp();
|
|
62
|
+
if (last !== undefined && now - last < this.deps.throttleMs) return;
|
|
63
|
+
const txnId = retryTxnId('reauth');
|
|
64
|
+
const eventId = await retryGateway(() => this.deps.send(roomId, txnId), {
|
|
65
|
+
...this.deps.retry,
|
|
66
|
+
onRetry: (err, attempt, delayMs) =>
|
|
67
|
+
this.deps.warn(
|
|
68
|
+
`[user-do] delegation-required event send failed (attempt ${attempt}) — retrying in ${delayMs} ms: ${errorText(err)}`,
|
|
69
|
+
),
|
|
70
|
+
});
|
|
71
|
+
await this.deps.setStamp(this.deps.now?.() ?? Date.now());
|
|
72
|
+
this.deps.log(
|
|
73
|
+
`[user-do] sent ixo.oracle.delegation_required (${eventId}) to ${roomId} for ${userDid}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The room mirror of HTTP chats: every Portal turn is replayed into the
|
|
3
|
+
* user's oracle room as a thread under the session's marker event, the user
|
|
4
|
+
* message when the turn starts and the reply when it ends (Node's
|
|
5
|
+
* `MessagesService`). Node fires these and forgets them; on Workers the
|
|
6
|
+
* gateway object is replaced on every deploy and can be drained at any time,
|
|
7
|
+
* so a mirror sent at that instant fails with a transport error and, unless
|
|
8
|
+
* retried, is gone for good — the room transcript then disagrees with the
|
|
9
|
+
* Portal's for ever.
|
|
10
|
+
*
|
|
11
|
+
* Two rules make the retry safe:
|
|
12
|
+
* - each send carries a transaction id derived from the session, the request
|
|
13
|
+
* and the author, so a send whose response was lost is deduplicated by the
|
|
14
|
+
* homeserver rather than posted twice;
|
|
15
|
+
* - sends are serialised per session (parallel across sessions), so a reply
|
|
16
|
+
* being retried can never overtake the message it answers, and a later
|
|
17
|
+
* turn's mirrors queue behind an earlier turn's.
|
|
18
|
+
*/
|
|
19
|
+
import { matrixTxnId } from '../matrix/txn-id';
|
|
20
|
+
import { retryGateway, type RetryGatewayOptions } from './gateway-retry';
|
|
21
|
+
|
|
22
|
+
export type MirrorAuthor = 'user' | 'oracle';
|
|
23
|
+
|
|
24
|
+
export function mirrorTxnId(
|
|
25
|
+
sessionId: string,
|
|
26
|
+
requestId: string,
|
|
27
|
+
who: MirrorAuthor,
|
|
28
|
+
): string {
|
|
29
|
+
return matrixTxnId(
|
|
30
|
+
'replay',
|
|
31
|
+
sessionId,
|
|
32
|
+
requestId,
|
|
33
|
+
who === 'user' ? 'u' : 'o',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MirrorSend {
|
|
38
|
+
roomId: string;
|
|
39
|
+
body: string;
|
|
40
|
+
formattedBody?: string;
|
|
41
|
+
/** The session's marker event — the thread the mirror is posted under. */
|
|
42
|
+
threadId: string;
|
|
43
|
+
txnId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RoomMirrorDeps {
|
|
47
|
+
sendText(send: MirrorSend): Promise<string>;
|
|
48
|
+
/** Keeps the (un-awaited) send alive past the request that started it: `ctx.waitUntil`. */
|
|
49
|
+
keepAlive(work: Promise<unknown>): void;
|
|
50
|
+
log(message: string): void;
|
|
51
|
+
warn(message: string): void;
|
|
52
|
+
/** Retry policy override (tests shrink the delays). */
|
|
53
|
+
retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const errorText = (err: unknown): string =>
|
|
57
|
+
err instanceof Error ? err.message : String(err);
|
|
58
|
+
|
|
59
|
+
export class RoomMirror {
|
|
60
|
+
/** Per session: the tail of its send chain. Removed once the chain drains. */
|
|
61
|
+
private readonly chains = new Map<string, Promise<void>>();
|
|
62
|
+
|
|
63
|
+
constructor(private readonly deps: RoomMirrorDeps) {}
|
|
64
|
+
|
|
65
|
+
/** Sessions with a mirror still in flight. */
|
|
66
|
+
get pending(): number {
|
|
67
|
+
return this.chains.size;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Queue one mirror behind the session's previous ones. `prepare` runs when
|
|
72
|
+
* the session's earlier mirrors have settled and resolves what to send
|
|
73
|
+
* (`null` = nothing to mirror, e.g. no room). Never rejects: a mirror that
|
|
74
|
+
* cannot be posted is logged and the chain moves on.
|
|
75
|
+
*/
|
|
76
|
+
enqueue(
|
|
77
|
+
sessionId: string,
|
|
78
|
+
prepare: () => Promise<MirrorSend | null>,
|
|
79
|
+
label: string,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const previous = this.chains.get(sessionId) ?? Promise.resolve();
|
|
82
|
+
const tracked: Promise<void> = previous
|
|
83
|
+
.then(() => prepare())
|
|
84
|
+
.then((send) => (send ? this.send(send, label) : undefined))
|
|
85
|
+
.catch((err: unknown) => {
|
|
86
|
+
this.deps.warn(
|
|
87
|
+
`[user-do] Matrix replay (${label}) failed — session=${sessionId}: ${errorText(err)}`,
|
|
88
|
+
);
|
|
89
|
+
})
|
|
90
|
+
.finally(() => {
|
|
91
|
+
if (this.chains.get(sessionId) === tracked)
|
|
92
|
+
this.chains.delete(sessionId);
|
|
93
|
+
});
|
|
94
|
+
this.chains.set(sessionId, tracked);
|
|
95
|
+
this.deps.keepAlive(tracked);
|
|
96
|
+
return tracked;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async send(send: MirrorSend, label: string): Promise<void> {
|
|
100
|
+
const eventId = await retryGateway(() => this.deps.sendText(send), {
|
|
101
|
+
...this.deps.retry,
|
|
102
|
+
onRetry: (err, attempt, delayMs) =>
|
|
103
|
+
this.deps.warn(
|
|
104
|
+
`[user-do] Matrix replay (${label}) send failed (attempt ${attempt}) — retrying in ${delayMs} ms with the same transaction id: ${errorText(err)}`,
|
|
105
|
+
),
|
|
106
|
+
});
|
|
107
|
+
this.deps.log(
|
|
108
|
+
`[user-do] Matrix replay (${label}) → ${eventId} in thread ${send.threadId}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/do/sse-stream.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
redactOperatorFault,
|
|
16
16
|
} from '../llm/provider-error';
|
|
17
17
|
import type { AIMessageChunk, ToolMessage } from '@langchain/core/messages';
|
|
18
|
+
import { isToolMessage } from '@langchain/core/messages';
|
|
18
19
|
|
|
19
20
|
export interface SseTurnRunnerInput {
|
|
20
21
|
/** `agent.streamEvents(stateInput, config)` async iterable. */
|
|
@@ -75,9 +76,30 @@ interface ToolCallPayload {
|
|
|
75
76
|
requestId: string;
|
|
76
77
|
toolName: string;
|
|
77
78
|
args: Record<string, unknown>;
|
|
78
|
-
status: 'isRunning' | 'done';
|
|
79
|
+
status: 'isRunning' | 'done' | 'error';
|
|
79
80
|
output?: string;
|
|
80
81
|
eventId?: string;
|
|
82
|
+
/** Set with `status: 'error'`: what the tool reported (schema rejection, retries exhausted). */
|
|
83
|
+
error?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Model calls the agent makes for itself — the summarization middleware
|
|
88
|
+
* condensing the thread, a sub-agent's inner turn — stream through the same
|
|
89
|
+
* `streamEvents` pipe as the user-facing reply. Only the outermost agent's
|
|
90
|
+
* model output belongs on the wire; everything else is bookkeeping the user
|
|
91
|
+
* never asked to read (the "Here is a summary of the conversation to date"
|
|
92
|
+
* leak). The summarizer tags its invoke with `lc_source: 'summarization'`;
|
|
93
|
+
* sub-agents run under their own run name below the main agent.
|
|
94
|
+
*/
|
|
95
|
+
export function isInternalModelEvent(evt: {
|
|
96
|
+
metadata?: Record<string, unknown>;
|
|
97
|
+
tags?: string[];
|
|
98
|
+
}): boolean {
|
|
99
|
+
const source = evt.metadata?.['lc_source'];
|
|
100
|
+
if (source === 'summarization') return true;
|
|
101
|
+
const tags = evt.tags ?? [];
|
|
102
|
+
return tags.some((t) => t === 'summarization' || t === 'internal');
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
interface ActionCallPayload {
|
|
@@ -230,6 +252,32 @@ export function createSseTurnStream(
|
|
|
230
252
|
toolCallMap.clear();
|
|
231
253
|
};
|
|
232
254
|
|
|
255
|
+
// Tool calls the main agent's model produced, by call id, until their
|
|
256
|
+
// tool run starts. A call whose arguments fail the tool's schema never
|
|
257
|
+
// starts (LangChain validates before the tool's start callback), so
|
|
258
|
+
// `on_tool_start` / `on_tool_end` never fire for it; the error tool
|
|
259
|
+
// message the tools node returns is the only trace. Anything still
|
|
260
|
+
// recorded when that node ends is reported from that message.
|
|
261
|
+
const pendingCalls = new Map<
|
|
262
|
+
string,
|
|
263
|
+
{ toolName: string; args: Record<string, unknown> }
|
|
264
|
+
>();
|
|
265
|
+
const sameArgs = (a: unknown, b: unknown) => {
|
|
266
|
+
try {
|
|
267
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const forgetStartedCall = (toolName: string, args: unknown) => {
|
|
273
|
+
for (const [id, call] of pendingCalls) {
|
|
274
|
+
if (call.toolName === toolName && sameArgs(call.args, args)) {
|
|
275
|
+
pendingCalls.delete(id);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
233
281
|
const run = async () => {
|
|
234
282
|
let fullContent = '';
|
|
235
283
|
try {
|
|
@@ -240,12 +288,59 @@ export function createSseTurnStream(
|
|
|
240
288
|
run_id: string;
|
|
241
289
|
name?: string;
|
|
242
290
|
data: unknown;
|
|
291
|
+
metadata?: Record<string, unknown>;
|
|
292
|
+
tags?: string[];
|
|
243
293
|
};
|
|
294
|
+
if (evt.event === 'on_chat_model_end') {
|
|
295
|
+
if (isInternalModelEvent(evt)) continue;
|
|
296
|
+
const output = (evt.data as { output?: AIMessageChunk })?.output;
|
|
297
|
+
for (const call of output?.tool_calls ?? []) {
|
|
298
|
+
if (call.id)
|
|
299
|
+
pendingCalls.set(call.id, {
|
|
300
|
+
toolName: call.name,
|
|
301
|
+
args: (call.args ?? {}) as Record<string, unknown>,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (evt.event === 'on_chain_end' && evt.name === 'tools') {
|
|
307
|
+
if (pendingCalls.size === 0) continue;
|
|
308
|
+
const output = (evt.data as { output?: unknown })?.output;
|
|
309
|
+
const messages: unknown[] = Array.isArray(output)
|
|
310
|
+
? output
|
|
311
|
+
: Array.isArray((output as { messages?: unknown[] })?.messages)
|
|
312
|
+
? ((output as { messages: unknown[] }).messages ?? [])
|
|
313
|
+
: [];
|
|
314
|
+
for (const message of messages) {
|
|
315
|
+
if (!isToolMessage(message)) continue;
|
|
316
|
+
const call = pendingCalls.get(message.tool_call_id);
|
|
317
|
+
if (!call) continue;
|
|
318
|
+
pendingCalls.delete(message.tool_call_id);
|
|
319
|
+
const text = toText(message.content);
|
|
320
|
+
const failed = message.status === 'error';
|
|
321
|
+
write('tool_call', {
|
|
322
|
+
sessionId,
|
|
323
|
+
requestId,
|
|
324
|
+
toolName: message.name ?? call.toolName,
|
|
325
|
+
args: {
|
|
326
|
+
...call.args,
|
|
327
|
+
toolName: message.name ?? call.toolName,
|
|
328
|
+
},
|
|
329
|
+
status: failed ? 'error' : 'done',
|
|
330
|
+
output: text,
|
|
331
|
+
...(failed && { error: text || 'Tool call rejected' }),
|
|
332
|
+
eventId: message.tool_call_id,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
pendingCalls.clear();
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
244
338
|
if (evt.event === 'on_tool_start') {
|
|
245
339
|
const toolName = evt.name ?? 'tool';
|
|
246
340
|
const args = extractToolArgs(
|
|
247
341
|
(evt.data as { input?: unknown })?.input,
|
|
248
342
|
);
|
|
343
|
+
forgetStartedCall(toolName, args);
|
|
249
344
|
if (input.agActionNames?.has(toolName)) {
|
|
250
345
|
const payload: ActionCallPayload = {
|
|
251
346
|
requestId,
|
|
@@ -295,10 +390,16 @@ export function createSseTurnStream(
|
|
|
295
390
|
}
|
|
296
391
|
const tool = toolCallMap.get(evt.run_id);
|
|
297
392
|
if (tool) {
|
|
393
|
+
// A tool whose call was rejected (schema mismatch) or whose
|
|
394
|
+
// retries ran out comes back as a ToolMessage with
|
|
395
|
+
// `status: 'error'`, not as `on_tool_error`; the client must
|
|
396
|
+
// see it as failed, not as a finished call with odd output.
|
|
397
|
+
const failed = output?.status === 'error';
|
|
298
398
|
write('tool_call', {
|
|
299
399
|
...tool,
|
|
300
|
-
status: 'done',
|
|
400
|
+
status: failed ? 'error' : 'done',
|
|
301
401
|
output: text,
|
|
402
|
+
...(failed && { error: text || 'Tool failed' }),
|
|
302
403
|
eventId: evt.run_id,
|
|
303
404
|
args: {
|
|
304
405
|
...tool.args,
|
|
@@ -342,6 +443,7 @@ export function createSseTurnStream(
|
|
|
342
443
|
continue;
|
|
343
444
|
}
|
|
344
445
|
if (evt.event === 'on_chat_model_stream') {
|
|
446
|
+
if (isInternalModelEvent(evt)) continue;
|
|
345
447
|
const chunk = (evt.data as { chunk?: AIMessageChunk })?.chunk;
|
|
346
448
|
if (!chunk) continue;
|
|
347
449
|
const raw = chunk.additional_kwargs?.__raw_response as
|