@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { ChannelTurns } from '../channels/turns';
|
|
2
|
+
import { assertChannelAttemptAllowed } from '../channels/auth';
|
|
3
|
+
import {
|
|
4
|
+
ChannelError,
|
|
5
|
+
channelRequestHash,
|
|
6
|
+
channelOrigin,
|
|
7
|
+
delegationRequiredError,
|
|
8
|
+
requireChannelDelegation,
|
|
9
|
+
type ChannelTurnInput,
|
|
10
|
+
type ChannelTurnOutcome,
|
|
11
|
+
} from '../channels/contract';
|
|
1
12
|
/* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
|
|
2
13
|
/**
|
|
3
14
|
* `UserOracleDO` — one Durable Object per (user DID, oracle).
|
|
@@ -16,6 +27,12 @@
|
|
|
16
27
|
* A Durable Object is single-threaded, which replaces the Node runtime's
|
|
17
28
|
* per-user ref-counting, busy timeouts and cron locks outright.
|
|
18
29
|
*/
|
|
30
|
+
import {
|
|
31
|
+
TopicOperationId,
|
|
32
|
+
TopicDeliverableRequestSchema,
|
|
33
|
+
type TopicDeliverableCommand,
|
|
34
|
+
type TopicDeliverableResult,
|
|
35
|
+
} from '../tasks/topic-deliverables';
|
|
19
36
|
import { DurableObject } from 'cloudflare:workers';
|
|
20
37
|
import {
|
|
21
38
|
AIMessage,
|
|
@@ -38,8 +55,10 @@ import {
|
|
|
38
55
|
type AttachmentViewSurface,
|
|
39
56
|
type MatrixMediaSource,
|
|
40
57
|
type SandboxUploadConfig,
|
|
58
|
+
DOWNLOAD_TIMEOUT_MS,
|
|
41
59
|
MAX_FILE_SIZE,
|
|
42
60
|
readBytesCapped,
|
|
61
|
+
AttachmentTextCacheStore,
|
|
43
62
|
} from '../attachments';
|
|
44
63
|
import type { RuntimeCore } from '../core';
|
|
45
64
|
import {
|
|
@@ -50,15 +69,35 @@ import {
|
|
|
50
69
|
langsmithEnvFromWorkerEnv,
|
|
51
70
|
llmEnvFromWorkerEnv,
|
|
52
71
|
resolveLangsmithTracing,
|
|
72
|
+
type LangsmithTracingDecision,
|
|
53
73
|
type OpenRouterLlmAdapter,
|
|
54
74
|
DEFAULT_MODEL_ID,
|
|
75
|
+
OPENROUTER_MODEL_MAP,
|
|
55
76
|
} from '../core/llm';
|
|
77
|
+
import { renderTurnTimeNote } from '../core/prompt-composer';
|
|
78
|
+
import { TURN_TIME_NOTE_KWARG, withTurnTimeNote } from '../core/turn-time-note';
|
|
79
|
+
import {
|
|
80
|
+
capabilityRouterMode,
|
|
81
|
+
createCapabilityRouter,
|
|
82
|
+
shadowAgreement,
|
|
83
|
+
type CapabilityRouter,
|
|
84
|
+
} from '../core/capability-router';
|
|
56
85
|
import { createMainAgent } from '../core/main-agent';
|
|
86
|
+
import {
|
|
87
|
+
REQUEST_ADMISSION_TIMEOUT_MS_DEFAULT,
|
|
88
|
+
admitRequest,
|
|
89
|
+
admissionMetadata,
|
|
90
|
+
} from '../core/request-admission';
|
|
91
|
+
import type {
|
|
92
|
+
RequestAdmissionResult,
|
|
93
|
+
RequestDisposition,
|
|
94
|
+
} from '../plugin-api/request-admission';
|
|
95
|
+
import { bootHiddenPlugins } from '../core/tool-access';
|
|
57
96
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
58
97
|
import type { AmbientServices, LlmAdapter } from '../core/runtime-context';
|
|
59
98
|
import { chatGptBackendFromEnv } from '../llm/byo-client';
|
|
60
99
|
import { createByoLlmAdapter } from '../llm/byo-adapter';
|
|
61
|
-
import { isByoModelId } from '../llm/byo-catalog';
|
|
100
|
+
import { byoModelForRole, isByoModelId } from '../llm/byo-catalog';
|
|
62
101
|
import { handleByoRequest } from '../llm/byo-routes';
|
|
63
102
|
import {
|
|
64
103
|
WorkersByoService,
|
|
@@ -79,7 +118,7 @@ import {
|
|
|
79
118
|
RealtimeEndpoint,
|
|
80
119
|
type RealtimeStatus,
|
|
81
120
|
} from '../realtime/realtime-endpoint';
|
|
82
|
-
import { authenticate } from '../shell/auth';
|
|
121
|
+
import { authConfigFromEnv, authenticate } from '../shell/auth';
|
|
83
122
|
import {
|
|
84
123
|
compactStep,
|
|
85
124
|
finishCompaction,
|
|
@@ -135,7 +174,11 @@ import {
|
|
|
135
174
|
VFS_DEFAULT_BASE_URLS,
|
|
136
175
|
} from '../owner-store/ixo-vfs-store';
|
|
137
176
|
import type { OwnerStore } from '../owner-store/types';
|
|
138
|
-
import {
|
|
177
|
+
import {
|
|
178
|
+
createAmbientServices,
|
|
179
|
+
SessionEventRouter,
|
|
180
|
+
sweepExpiredBlobs,
|
|
181
|
+
} from './ambient';
|
|
139
182
|
import {
|
|
140
183
|
checkpointStorageKey,
|
|
141
184
|
type MatrixGatewayObject,
|
|
@@ -157,7 +200,7 @@ import { turnRecursionLimit } from './turn-config';
|
|
|
157
200
|
import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
|
|
158
201
|
import { decideBootDirty } from './boot-dirty';
|
|
159
202
|
import { decideFlush } from './flush-schedule';
|
|
160
|
-
import { mirrorTxnId, RoomMirror } from './room-mirror';
|
|
203
|
+
import { mirrorTxnId, mirrorEventContent, RoomMirror } from './room-mirror';
|
|
161
204
|
import { prefixSpeaker } from './speaker-prefix';
|
|
162
205
|
import {
|
|
163
206
|
type ObservedMessage,
|
|
@@ -176,6 +219,7 @@ import { WorkStatusProducer } from '../matrix/work-status';
|
|
|
176
219
|
import { fetchUserMatrixServerName } from '../matrix/user-homeserver';
|
|
177
220
|
import { createWorkStatusMiddleware } from '../core/middlewares/work-status';
|
|
178
221
|
import { UserPreferencesStore } from '../plugins/user-preferences/user-preferences-store';
|
|
222
|
+
import { SqliteUserKv } from '../sqlite/user-kv-store';
|
|
179
223
|
import {
|
|
180
224
|
SessionHistoryIndexer,
|
|
181
225
|
type HistoryMessage,
|
|
@@ -210,6 +254,15 @@ import {
|
|
|
210
254
|
type ToolEffect,
|
|
211
255
|
} from '../core/middlewares/tool-marks';
|
|
212
256
|
import { createResultCapMiddleware } from '../core/middlewares/result-cap';
|
|
257
|
+
import { createToolExecutionMiddleware } from '../core/middlewares/tool-execution';
|
|
258
|
+
import { budgetedLlm } from '../core/budgeted-llm';
|
|
259
|
+
import { ToolScheduler, type ToolLane } from '../core/tool-scheduler';
|
|
260
|
+
import {
|
|
261
|
+
HarnessLimitError,
|
|
262
|
+
TurnBudget,
|
|
263
|
+
turnLimitsFromEnv,
|
|
264
|
+
type TurnUsage,
|
|
265
|
+
} from '../core/turn-budget';
|
|
213
266
|
import { READ_RESULT_TOOL_NAME } from '../core/read-result-tool';
|
|
214
267
|
import {
|
|
215
268
|
ContextWindowResolver,
|
|
@@ -222,6 +275,21 @@ import {
|
|
|
222
275
|
} from '../core/middlewares/context-guard';
|
|
223
276
|
import { fetchOpenRouterContextLengths } from '../core/openrouter-pricing';
|
|
224
277
|
import { ResultStore, resultStoreKnobs } from './result-store';
|
|
278
|
+
import { artifactStorageConfig } from '../artifacts/config';
|
|
279
|
+
import {
|
|
280
|
+
ArtifactStore,
|
|
281
|
+
artifactIdFor,
|
|
282
|
+
type OwnedArtifact,
|
|
283
|
+
} from '../artifacts/store';
|
|
284
|
+
import { buildCreateArtifactTool } from '../artifacts/tool';
|
|
285
|
+
import { resolveDeliveryProfile } from '../delivery/profile';
|
|
286
|
+
import {
|
|
287
|
+
draftReplyPlan,
|
|
288
|
+
materializeReplyPlan,
|
|
289
|
+
turnSteps,
|
|
290
|
+
} from '../delivery/plan';
|
|
291
|
+
import { parseReplyPlan } from '../delivery/schema';
|
|
292
|
+
import type { DeliveryProfile, ReplyPlan } from '../delivery/types';
|
|
225
293
|
import { formatReplay } from '../matrix/replay-format';
|
|
226
294
|
import { retryGateway } from './gateway-retry';
|
|
227
295
|
import {
|
|
@@ -235,8 +303,59 @@ import {
|
|
|
235
303
|
TranscriptCursorError,
|
|
236
304
|
transformTranscript,
|
|
237
305
|
type TranscriptPageOptions,
|
|
306
|
+
visibleText,
|
|
238
307
|
} from './transcript';
|
|
239
|
-
import {
|
|
308
|
+
import { evictIdleWorkingCopy } from './idle-eviction';
|
|
309
|
+
import { FeedbackMarkers, reserveFeedback } from '../feedback/reservation';
|
|
310
|
+
import type {
|
|
311
|
+
FeedbackReservation,
|
|
312
|
+
FeedbackSettlement,
|
|
313
|
+
FeedbackTarget,
|
|
314
|
+
} from '../feedback/contract';
|
|
315
|
+
import {
|
|
316
|
+
readDelegation,
|
|
317
|
+
resolveTurnDelegation,
|
|
318
|
+
WorkersUcanService,
|
|
319
|
+
} from './ucan-service';
|
|
320
|
+
import type { UcanDelegation } from '../plugin-api/types';
|
|
321
|
+
|
|
322
|
+
/** What one attempt's agent build reads from the request body. */
|
|
323
|
+
type TurnBuildBody = Pick<
|
|
324
|
+
TurnBody,
|
|
325
|
+
'message' | 'timezone' | 'model' | 'tools' | 'agActions' | 'attachments'
|
|
326
|
+
>;
|
|
327
|
+
|
|
328
|
+
/** The run an attempt's agent build belongs to. */
|
|
329
|
+
interface TurnBuildRun {
|
|
330
|
+
runId: string;
|
|
331
|
+
abortController: AbortController;
|
|
332
|
+
/** A recovery attempt: no new input, the graph continues from the checkpoint. */
|
|
333
|
+
resumed: boolean;
|
|
334
|
+
/** The reply text the user already received (resumed attempts). */
|
|
335
|
+
continuation: string | null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Whether a turn is offered to the plugins' admission handlers. Not for a
|
|
340
|
+
* turn with attachments, a scheduled task run, or a Matrix group room: a
|
|
341
|
+
* direct read is posted into the room, so one user's authorized read would
|
|
342
|
+
* be shown to every member.
|
|
343
|
+
*/
|
|
344
|
+
function admissionApplies(req: TurnRequest): boolean {
|
|
345
|
+
return (
|
|
346
|
+
!req.attachments?.length &&
|
|
347
|
+
!req.taskRunId &&
|
|
348
|
+
!req.sessionId.startsWith(TASK_SESSION_PREFIX) &&
|
|
349
|
+
req.roomKind !== 'group'
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function admissionTimeoutMs(env: Record<string, unknown>): number {
|
|
354
|
+
const ms = env.REQUEST_ADMISSION_TIMEOUT_MS;
|
|
355
|
+
return typeof ms === 'number' && Number.isFinite(ms) && ms > 0
|
|
356
|
+
? ms
|
|
357
|
+
: REQUEST_ADMISSION_TIMEOUT_MS_DEFAULT;
|
|
358
|
+
}
|
|
240
359
|
|
|
241
360
|
const DB_FILE = 'oracle.db';
|
|
242
361
|
/**
|
|
@@ -261,8 +380,31 @@ const FLUSH_FAILURES_ERROR_THRESHOLD = 3;
|
|
|
261
380
|
* more for anyone who comes back within that window.
|
|
262
381
|
*/
|
|
263
382
|
const IDLE_EVICT_MS = 5 * 24 * 60 * 60 * 1000;
|
|
383
|
+
/**
|
|
384
|
+
* A stream of requests to a loaded object persists its last access at most
|
|
385
|
+
* this often. The idle checks read the in-memory value as well, so what an
|
|
386
|
+
* instance saw is never lost to the throttle while it lives; an instance
|
|
387
|
+
* that unloads takes at most this much of its last access with it, against
|
|
388
|
+
* the five-day idle horizon.
|
|
389
|
+
*/
|
|
390
|
+
const ACCESS_PERSIST_INTERVAL_MS = 60 * 60_000;
|
|
391
|
+
/**
|
|
392
|
+
* The session title is a separate model call after the reply; it runs in
|
|
393
|
+
* the background and is abandoned after this long.
|
|
394
|
+
*/
|
|
395
|
+
const TITLE_TIMEOUT_MS = 15_000;
|
|
396
|
+
/** A new sweep of expired ambient blobs starts at most this often per instance. */
|
|
397
|
+
const BLOB_SWEEP_INTERVAL_MS = 60 * 60_000;
|
|
398
|
+
/** The Blocksync lookup of the user's homeserver is abandoned after this long (the fallback applies). */
|
|
399
|
+
const HOMESERVER_LOOKUP_TIMEOUT_MS = 3_000;
|
|
264
400
|
/** Set once the legacy Matrix copy has been removed (or found absent): no more lookups. */
|
|
265
401
|
const META_LEGACY_CLEARED = 'meta:legacyCleared';
|
|
402
|
+
/**
|
|
403
|
+
* Set once a zero-turn working copy looked for a legacy Matrix copy to adopt
|
|
404
|
+
* and found none, or one without turns: later boots skip the gateway round
|
|
405
|
+
* trips. Cleared when a legacy copy is imported after all.
|
|
406
|
+
*/
|
|
407
|
+
const META_LEGACY_UNUSABLE = 'meta:legacyUnusable';
|
|
266
408
|
const META_USER_DID = 'meta:userDid';
|
|
267
409
|
|
|
268
410
|
/** KV key of a session's context-guard counters (`GET /debug/context?session=`). */
|
|
@@ -403,6 +545,17 @@ interface StoredDelegation {
|
|
|
403
545
|
at: number;
|
|
404
546
|
}
|
|
405
547
|
|
|
548
|
+
/** The user's delegation as the object holds it in memory. */
|
|
549
|
+
interface CachedDelegation {
|
|
550
|
+
raw: string;
|
|
551
|
+
/**
|
|
552
|
+
* Unix seconds: the delegation's effective expiry. Taken from where the
|
|
553
|
+
* delegation arrived (the shell's validation, the deposit record) or read
|
|
554
|
+
* off the token itself; absent only when the token cannot be parsed.
|
|
555
|
+
*/
|
|
556
|
+
expiration?: number;
|
|
557
|
+
}
|
|
558
|
+
|
|
406
559
|
interface FlushResult {
|
|
407
560
|
uploaded: boolean;
|
|
408
561
|
/** Raw file size (skips) or bytes sent upstream (uploads). */
|
|
@@ -466,6 +619,62 @@ function lastAiText(messages: BaseMessage[]): string {
|
|
|
466
619
|
}
|
|
467
620
|
|
|
468
621
|
/** Id of the final assistant message (what `POST /messages` reports as `message.id`). */
|
|
622
|
+
/** `turn_runs.usage` for `/debug/runs`; a row written before the column existed, or an unreadable one, is null. */
|
|
623
|
+
function parseUsage(raw: string | null): TurnUsage | null {
|
|
624
|
+
if (!raw) return null;
|
|
625
|
+
try {
|
|
626
|
+
const parsed: unknown = JSON.parse(raw);
|
|
627
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
628
|
+
const num = (key: string): number | undefined => {
|
|
629
|
+
const value: unknown = Reflect.get(parsed, key);
|
|
630
|
+
return typeof value === 'number' ? value : undefined;
|
|
631
|
+
};
|
|
632
|
+
const limits: unknown = Reflect.get(parsed, 'limits');
|
|
633
|
+
const limit = (key: string): number | undefined => {
|
|
634
|
+
if (!limits || typeof limits !== 'object') return undefined;
|
|
635
|
+
const value: unknown = Reflect.get(limits, key);
|
|
636
|
+
return typeof value === 'number' ? value : undefined;
|
|
637
|
+
};
|
|
638
|
+
const usage = {
|
|
639
|
+
tokens: num('tokens'),
|
|
640
|
+
reportedTokens: num('reportedTokens'),
|
|
641
|
+
modelCalls: num('modelCalls'),
|
|
642
|
+
toolAttempts: num('toolAttempts'),
|
|
643
|
+
elapsedMs: num('elapsedMs'),
|
|
644
|
+
limits: {
|
|
645
|
+
tokens: limit('tokens'),
|
|
646
|
+
tools: limit('tools'),
|
|
647
|
+
durationMs: limit('durationMs'),
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
if (
|
|
651
|
+
usage.tokens === undefined ||
|
|
652
|
+
usage.reportedTokens === undefined ||
|
|
653
|
+
usage.modelCalls === undefined ||
|
|
654
|
+
usage.toolAttempts === undefined ||
|
|
655
|
+
usage.elapsedMs === undefined ||
|
|
656
|
+
usage.limits.tokens === undefined ||
|
|
657
|
+
usage.limits.tools === undefined ||
|
|
658
|
+
usage.limits.durationMs === undefined
|
|
659
|
+
)
|
|
660
|
+
return null;
|
|
661
|
+
return {
|
|
662
|
+
tokens: usage.tokens,
|
|
663
|
+
reportedTokens: usage.reportedTokens,
|
|
664
|
+
modelCalls: usage.modelCalls,
|
|
665
|
+
toolAttempts: usage.toolAttempts,
|
|
666
|
+
elapsedMs: usage.elapsedMs,
|
|
667
|
+
limits: {
|
|
668
|
+
tokens: usage.limits.tokens,
|
|
669
|
+
tools: usage.limits.tools,
|
|
670
|
+
durationMs: usage.limits.durationMs,
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
} catch {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
469
678
|
function lastAiMessageId(messages: BaseMessage[]): string | undefined {
|
|
470
679
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
471
680
|
const m = messages[i];
|
|
@@ -510,14 +719,38 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
510
719
|
if (this.ctx.getWebSockets().length > 0) this.realtime.nextPingAt();
|
|
511
720
|
}
|
|
512
721
|
private readonly aborts = new Map<string, AbortController>();
|
|
722
|
+
/** Capability router (core/capability-router.ts); built with the ambient services. */
|
|
723
|
+
private capabilityRouter: CapabilityRouter | null = null;
|
|
724
|
+
/**
|
|
725
|
+
* Shadow-mode predictions awaiting the turn's end, by session: what the
|
|
726
|
+
* router would have preloaded, against the plugins loaded before the turn.
|
|
727
|
+
*/
|
|
728
|
+
private readonly shadowRoutes = new Map<
|
|
729
|
+
string,
|
|
730
|
+
{
|
|
731
|
+
requestId: string;
|
|
732
|
+
priorLoaded: ReadonlySet<string>;
|
|
733
|
+
wouldPreload: readonly string[];
|
|
734
|
+
}
|
|
735
|
+
>();
|
|
513
736
|
/** Durable runs (docs/plans/durable-runs.md): the rows, and the live side. */
|
|
514
737
|
private runStore: RunStore | null = null;
|
|
738
|
+
/**
|
|
739
|
+
* Tool concurrency of this user object (tool-scheduler.ts): writes one
|
|
740
|
+
* at a time across every session and turn, reads and sub-agents in
|
|
741
|
+
* bounded lanes. Lives as long as the object.
|
|
742
|
+
*/
|
|
743
|
+
private readonly toolScheduler = new ToolScheduler();
|
|
515
744
|
private runs: RunCoordinator | null = null;
|
|
516
745
|
private readonly runConfig: RunDurabilityConfig = runDurabilityConfig(
|
|
517
746
|
this.env,
|
|
518
747
|
);
|
|
519
748
|
/** Whole tool results the result cap saved (docs/plans/context-budgets.md). */
|
|
520
749
|
private resultStore: ResultStore | null = null;
|
|
750
|
+
/** Chat delivery's artefacts; null when no bucket is bound (see `canShare` for new ones). */
|
|
751
|
+
private artifacts: ArtifactStore | null = null;
|
|
752
|
+
/** Text `view_attachment` extracted, per session (`attachments/view-cache.ts`). */
|
|
753
|
+
private attachmentTextCache: AttachmentTextCacheStore | null = null;
|
|
521
754
|
/**
|
|
522
755
|
* Per-model context windows; learned limits persist in this object's KV
|
|
523
756
|
* storage (`ctxwin:<model>`), so a provider's rejection is remembered
|
|
@@ -529,6 +762,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
529
762
|
fetchOpenRouterContextLengths({
|
|
530
763
|
apiKey: this.env.OPEN_ROUTER_API_KEY,
|
|
531
764
|
logger: console,
|
|
765
|
+
// A background refresh of the catalogue outlives the request.
|
|
766
|
+
waitUntil: (work) => this.ctx.waitUntil(work),
|
|
532
767
|
}),
|
|
533
768
|
learned: {
|
|
534
769
|
get: (model) => this.ctx.storage.get<number>(`ctxwin:${model}`),
|
|
@@ -542,10 +777,32 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
542
777
|
* events of one request (a soft then a hard prune) never race.
|
|
543
778
|
*/
|
|
544
779
|
private contextStatsWrites: Promise<void> = Promise.resolve();
|
|
545
|
-
private readonly delegations = new Map<string,
|
|
780
|
+
private readonly delegations = new Map<string, CachedDelegation>();
|
|
546
781
|
/** Epoch-ms until which a room-state delegation lookup is not retried. */
|
|
547
782
|
private delegationMissUntil = 0;
|
|
548
783
|
private dirty = false;
|
|
784
|
+
/**
|
|
785
|
+
* `META_FLUSH_AT` as this instance last wrote or read it (null =
|
|
786
|
+
* unknown). With `dirty`, it tells `markDirty` that the upload is
|
|
787
|
+
* already scheduled, so a turn's mark costs no storage operation.
|
|
788
|
+
*/
|
|
789
|
+
private flushAt: number | null = null;
|
|
790
|
+
/** The newest real request this instance served (null = none yet). */
|
|
791
|
+
private lastAccessAt: number | null = null;
|
|
792
|
+
/** The `META_LAST_ACCESS` this instance last wrote (null = none yet). */
|
|
793
|
+
private persistedAccessAt: number | null = null;
|
|
794
|
+
/** The user's Matrix id as resolved (or read from storage) by this instance. */
|
|
795
|
+
private matrixUserIdCache: MatrixUserIdCache | null = null;
|
|
796
|
+
/**
|
|
797
|
+
* The earliest housekeeping deadline armed since the current full alarm
|
|
798
|
+
* tick began: the tick's own `META_HOUSEKEEPING_AT` write never rises
|
|
799
|
+
* above a deadline armed while it ran.
|
|
800
|
+
*/
|
|
801
|
+
private housekeepingFloor = Infinity;
|
|
802
|
+
/** Where the expired-blob sweep continues on the next tick (null = from the start). */
|
|
803
|
+
private blobSweepCursor: string | null = null;
|
|
804
|
+
/** When this instance last swept from the start (the sweep reads whole values). */
|
|
805
|
+
private lastBlobSweepAt: number | null = null;
|
|
549
806
|
/** The flush in progress, if any (single-flight; a VFS snapshot is open while it runs). */
|
|
550
807
|
private flushInFlight: Promise<FlushResult> | null = null;
|
|
551
808
|
|
|
@@ -572,6 +829,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
572
829
|
private matrixLedger: MatrixTurnLedger | null = null;
|
|
573
830
|
/** Room mirrors of HTTP turns, serialised per session and retried across gateway restarts (see room-mirror.ts). */
|
|
574
831
|
private mirror: RoomMirror | null = null;
|
|
832
|
+
private channelTurns: ChannelTurns | null = null;
|
|
833
|
+
/** Anonymous-feedback idempotency markers and the user's submission limit. */
|
|
834
|
+
private feedbackMarkers: FeedbackMarkers | null = null;
|
|
575
835
|
/** The oracle room of each session this instance resolved: spares the mirrors a row read per send. */
|
|
576
836
|
private readonly sessionRooms = new Map<string, string>();
|
|
577
837
|
/** The user's main oracle room, once resolved (the session list is scoped to it). */
|
|
@@ -593,6 +853,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
593
853
|
{ session: IndexableSession; messages: HistoryMessage[] }
|
|
594
854
|
>();
|
|
595
855
|
private initPromise: Promise<void> | null = null;
|
|
856
|
+
/** An idle eviction deleting the working copy; a boot waits for it. */
|
|
857
|
+
private evicting: Promise<void> | null = null;
|
|
596
858
|
private reloadedFromOwnerStore = false;
|
|
597
859
|
/** Set by `adoptOwnerCopy` for a legacy copy: `ready()` flushes it to the system of record right after. */
|
|
598
860
|
private pendingLegacyMigration: { bytes: number } | null = null;
|
|
@@ -617,22 +879,44 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
617
879
|
userDid: string,
|
|
618
880
|
): Promise<string | undefined> {
|
|
619
881
|
if (!userDid.startsWith('did:ixo:')) return undefined;
|
|
882
|
+
const fresh = (
|
|
883
|
+
entry: MatrixUserIdCache | null | undefined,
|
|
884
|
+
): entry is MatrixUserIdCache =>
|
|
885
|
+
!!entry &&
|
|
886
|
+
entry.userDid === userDid &&
|
|
887
|
+
(entry.resolved ||
|
|
888
|
+
Date.now() - entry.at < MATRIX_USER_ID_FALLBACK_TTL_MS);
|
|
889
|
+
if (fresh(this.matrixUserIdCache))
|
|
890
|
+
return this.matrixUserIdCache.matrixUserId;
|
|
620
891
|
const cached =
|
|
621
892
|
await this.ctx.storage.get<MatrixUserIdCache>(META_MATRIX_USER_ID);
|
|
622
|
-
if (
|
|
623
|
-
cached
|
|
624
|
-
cached.userDid === userDid &&
|
|
625
|
-
(cached.resolved ||
|
|
626
|
-
Date.now() - cached.at < MATRIX_USER_ID_FALLBACK_TTL_MS)
|
|
627
|
-
) {
|
|
893
|
+
if (fresh(cached)) {
|
|
894
|
+
this.matrixUserIdCache = cached;
|
|
628
895
|
return cached.matrixUserId;
|
|
629
896
|
}
|
|
630
897
|
let server: string | undefined;
|
|
631
898
|
let resolved = false;
|
|
632
899
|
const blocksync = this.env.BLOCKSYNC_GRAPHQL_URL;
|
|
633
900
|
if (blocksync) {
|
|
901
|
+
// Bounded: this lookup sits on the boot path and on every turn
|
|
902
|
+
// start. The timer is cleared however the lookup ends.
|
|
903
|
+
const lookup = new AbortController();
|
|
904
|
+
const timer = setTimeout(
|
|
905
|
+
() =>
|
|
906
|
+
lookup.abort(
|
|
907
|
+
new Error(
|
|
908
|
+
`Blocksync lookup timed out after ${HOMESERVER_LOOKUP_TIMEOUT_MS} ms`,
|
|
909
|
+
),
|
|
910
|
+
),
|
|
911
|
+
HOMESERVER_LOOKUP_TIMEOUT_MS,
|
|
912
|
+
);
|
|
634
913
|
try {
|
|
635
|
-
const fromDid = await fetchUserMatrixServerName(
|
|
914
|
+
const fromDid = await fetchUserMatrixServerName(
|
|
915
|
+
blocksync,
|
|
916
|
+
userDid,
|
|
917
|
+
(input, init) =>
|
|
918
|
+
globalThis.fetch(input, { ...init, signal: lookup.signal }),
|
|
919
|
+
);
|
|
636
920
|
if (fromDid) {
|
|
637
921
|
server = fromDid;
|
|
638
922
|
resolved = true;
|
|
@@ -643,6 +927,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
643
927
|
err instanceof Error ? err.message : String(err)
|
|
644
928
|
}`,
|
|
645
929
|
);
|
|
930
|
+
} finally {
|
|
931
|
+
clearTimeout(timer);
|
|
646
932
|
}
|
|
647
933
|
}
|
|
648
934
|
server ??=
|
|
@@ -651,12 +937,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
651
937
|
: undefined;
|
|
652
938
|
if (!server) return undefined;
|
|
653
939
|
const matrixUserId = `@${userDid.replace(/:/g, '-')}:${server}`;
|
|
654
|
-
|
|
940
|
+
const entry: MatrixUserIdCache = {
|
|
655
941
|
userDid,
|
|
656
942
|
matrixUserId,
|
|
657
943
|
resolved,
|
|
658
944
|
at: Date.now(),
|
|
659
|
-
}
|
|
945
|
+
};
|
|
946
|
+
this.matrixUserIdCache = entry;
|
|
947
|
+
await this.ctx.storage.put<MatrixUserIdCache>(META_MATRIX_USER_ID, entry);
|
|
660
948
|
return matrixUserId;
|
|
661
949
|
}
|
|
662
950
|
|
|
@@ -673,8 +961,28 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
673
961
|
);
|
|
674
962
|
}
|
|
675
963
|
|
|
676
|
-
/**
|
|
677
|
-
private async
|
|
964
|
+
/** The delegation a turn runs under (`resolveTurnDelegation`). */
|
|
965
|
+
private async turnDelegation(
|
|
966
|
+
identity: TurnIdentity,
|
|
967
|
+
): Promise<UcanDelegation> {
|
|
968
|
+
return resolveTurnDelegation(
|
|
969
|
+
identity.ucanDelegation,
|
|
970
|
+
this.delegations.get(identity.userDid)?.raw,
|
|
971
|
+
this.ucan,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Idempotent boot: bind user, open SQLite (importing the owner copy on a
|
|
977
|
+
* cold object). A request from the user records the access the idle
|
|
978
|
+
* eviction reads; the object's own wakes (the alarm, a flush, debug and
|
|
979
|
+
* operator calls) pass `recordAccess: false`, or an idle object would
|
|
980
|
+
* keep itself alive.
|
|
981
|
+
*/
|
|
982
|
+
private async ready(
|
|
983
|
+
identity: TurnIdentity,
|
|
984
|
+
options: { recordAccess?: boolean } = {},
|
|
985
|
+
): Promise<void> {
|
|
678
986
|
this.installDebugTimerTracker();
|
|
679
987
|
this.adoptHibernatedSockets();
|
|
680
988
|
let delegationReplaced = false;
|
|
@@ -685,20 +993,33 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
685
993
|
this.delegations.get(identity.userDid)?.raw ??
|
|
686
994
|
(await this.ctx.storage.get<StoredDelegation>(META_DELEGATION))?.raw;
|
|
687
995
|
delegationReplaced = known !== identity.ucanDelegation;
|
|
688
|
-
this.delegations.
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
996
|
+
const cached = this.delegations.get(identity.userDid);
|
|
997
|
+
if (
|
|
998
|
+
cached?.raw !== identity.ucanDelegation ||
|
|
999
|
+
typeof cached.expiration !== 'number'
|
|
1000
|
+
)
|
|
1001
|
+
this.delegations.set(
|
|
1002
|
+
identity.userDid,
|
|
1003
|
+
await this.delegationRecord(
|
|
1004
|
+
identity.ucanDelegation,
|
|
1005
|
+
identity.ucanDelegationExpiration,
|
|
1006
|
+
),
|
|
1007
|
+
);
|
|
1008
|
+
// Remember a new one for turns that arrive without the header.
|
|
1009
|
+
if (delegationReplaced)
|
|
1010
|
+
await this.ctx.storage.put(META_DELEGATION, {
|
|
1011
|
+
raw: identity.ucanDelegation,
|
|
1012
|
+
at: Date.now(),
|
|
1013
|
+
...(typeof identity.ucanDelegationExpiration === 'number'
|
|
1014
|
+
? { expiration: identity.ucanDelegationExpiration }
|
|
1015
|
+
: {}),
|
|
1016
|
+
} satisfies StoredDelegation);
|
|
699
1017
|
} else if (!this.delegations.has(identity.userDid)) {
|
|
700
1018
|
await this.hydrateDelegation(identity.userDid);
|
|
701
1019
|
}
|
|
1020
|
+
// A request that arrived while an idle eviction deletes the working
|
|
1021
|
+
// copy boots afresh from the owner copy once it is gone.
|
|
1022
|
+
while (this.evicting) await this.evicting;
|
|
702
1023
|
if (!this.initPromise) {
|
|
703
1024
|
this.initPromise = this.boot(identity.userDid).catch((err) => {
|
|
704
1025
|
this.initPromise = null;
|
|
@@ -711,10 +1032,59 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
711
1032
|
`UserOracleDO bound to ${this.userDid} received a request for ${identity.userDid}`,
|
|
712
1033
|
);
|
|
713
1034
|
}
|
|
714
|
-
await this.
|
|
1035
|
+
if (options.recordAccess !== false) await this.recordAccess();
|
|
715
1036
|
if (delegationReplaced) await this.onDelegationReplaced();
|
|
716
1037
|
}
|
|
717
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* A request from the user: remember it in memory, and in storage on this
|
|
1041
|
+
* instance's first request and then at most every
|
|
1042
|
+
* `ACCESS_PERSIST_INTERVAL_MS`.
|
|
1043
|
+
*/
|
|
1044
|
+
private async recordAccess(): Promise<void> {
|
|
1045
|
+
const now = Date.now();
|
|
1046
|
+
this.lastAccessAt = now;
|
|
1047
|
+
if (
|
|
1048
|
+
typeof this.persistedAccessAt === 'number' &&
|
|
1049
|
+
now - this.persistedAccessAt < ACCESS_PERSIST_INTERVAL_MS
|
|
1050
|
+
)
|
|
1051
|
+
return;
|
|
1052
|
+
this.persistedAccessAt = now;
|
|
1053
|
+
await this.ctx.storage.put(META_LAST_ACCESS, now);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** The in-memory last access, when it is newer than the stored one. */
|
|
1057
|
+
private async persistAccess(): Promise<void> {
|
|
1058
|
+
const inMemory = this.lastAccessAt;
|
|
1059
|
+
if (
|
|
1060
|
+
typeof inMemory !== 'number' ||
|
|
1061
|
+
(typeof this.persistedAccessAt === 'number' &&
|
|
1062
|
+
this.persistedAccessAt >= inMemory)
|
|
1063
|
+
)
|
|
1064
|
+
return;
|
|
1065
|
+
this.persistedAccessAt = inMemory;
|
|
1066
|
+
await this.ctx.storage.put(META_LAST_ACCESS, inMemory);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The user's last request: the newer of what storage holds and what
|
|
1071
|
+
* this instance saw (undefined when neither knows of one).
|
|
1072
|
+
*/
|
|
1073
|
+
private async knownLastAccess(): Promise<number | undefined> {
|
|
1074
|
+
const stored = await this.ctx.storage.get<number>(META_LAST_ACCESS);
|
|
1075
|
+
const inMemory = this.lastAccessAt;
|
|
1076
|
+
const known = Math.max(
|
|
1077
|
+
typeof stored === 'number' ? stored : -Infinity,
|
|
1078
|
+
typeof inMemory === 'number' ? inMemory : -Infinity,
|
|
1079
|
+
);
|
|
1080
|
+
return Number.isFinite(known) ? known : undefined;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** `knownLastAccess`, or `now` when there is none (never idle). */
|
|
1084
|
+
private async lastAccess(now: number): Promise<number> {
|
|
1085
|
+
return (await this.knownLastAccess()) ?? now;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
718
1088
|
/**
|
|
719
1089
|
* The mnemonic the oracle signs downstream UCAN invocations with:
|
|
720
1090
|
* `ORACLE_SIGNING_MNEMONIC` when set, otherwise the one the Node runtime
|
|
@@ -745,10 +1115,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
745
1115
|
* The object holds a delegation it had not seen before (a header on this
|
|
746
1116
|
* request, or a deposit through the shell). A fresh delegation is the one
|
|
747
1117
|
* thing that turns a "no file-storage grant" flush failure into a
|
|
748
|
-
* success: forget the
|
|
749
|
-
* instead of at the next 10-minute retry.
|
|
1118
|
+
* success: after a recorded failure, forget the streak and, with unsaved
|
|
1119
|
+
* turns, flush now instead of at the next 10-minute retry. Without one
|
|
1120
|
+
* the debounce stands: a user on two clients alternates two delegations,
|
|
1121
|
+
* and an upload per request would replace the daily one.
|
|
750
1122
|
*/
|
|
751
1123
|
private async onDelegationReplaced(): Promise<void> {
|
|
1124
|
+
const failures =
|
|
1125
|
+
(await this.ctx.storage.get<number>(META_FLUSH_FAILURES)) ?? 0;
|
|
1126
|
+
if (failures === 0) return;
|
|
752
1127
|
await this.ctx.storage.delete(META_FLUSH_FAILURES);
|
|
753
1128
|
if (!this.db) return;
|
|
754
1129
|
if (
|
|
@@ -775,7 +1150,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
775
1150
|
const cached =
|
|
776
1151
|
await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
|
|
777
1152
|
if (live(cached)) {
|
|
778
|
-
this.delegations.set(
|
|
1153
|
+
this.delegations.set(
|
|
1154
|
+
userDid,
|
|
1155
|
+
await this.delegationRecord(cached.raw, cached.expiration),
|
|
1156
|
+
);
|
|
779
1157
|
return;
|
|
780
1158
|
}
|
|
781
1159
|
if (Date.now() < this.delegationMissUntil) return;
|
|
@@ -806,7 +1184,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
806
1184
|
}
|
|
807
1185
|
: null;
|
|
808
1186
|
if (live(deposited)) {
|
|
809
|
-
this.delegations.set(
|
|
1187
|
+
this.delegations.set(
|
|
1188
|
+
userDid,
|
|
1189
|
+
await this.delegationRecord(deposited.raw, deposited.expiration),
|
|
1190
|
+
);
|
|
810
1191
|
await this.ctx.storage.put(META_DELEGATION, deposited);
|
|
811
1192
|
console.log(
|
|
812
1193
|
`[user-do] delegation for ${userDid} loaded from the deposited room state (header-less turn)`,
|
|
@@ -821,6 +1202,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
821
1202
|
this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
|
|
822
1203
|
}
|
|
823
1204
|
|
|
1205
|
+
/**
|
|
1206
|
+
* The in-memory record of a delegation: the expiry where it arrived
|
|
1207
|
+
* stated one (the shell validated the token and read it off the chain),
|
|
1208
|
+
* else the token's own effective expiry along its proof chain. A token
|
|
1209
|
+
* that cannot be parsed keeps no expiry; channel turns refuse it.
|
|
1210
|
+
*/
|
|
1211
|
+
private async delegationRecord(
|
|
1212
|
+
raw: string,
|
|
1213
|
+
expiration: number | undefined,
|
|
1214
|
+
): Promise<CachedDelegation> {
|
|
1215
|
+
if (typeof expiration === 'number' && Number.isFinite(expiration))
|
|
1216
|
+
return { raw, expiration };
|
|
1217
|
+
try {
|
|
1218
|
+
const grant = await readDelegation(raw);
|
|
1219
|
+
return typeof grant.expiration === 'number'
|
|
1220
|
+
? { raw, expiration: grant.expiration }
|
|
1221
|
+
: { raw };
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
console.warn(
|
|
1224
|
+
`[user-do] could not read the expiry of a delegation: ${err instanceof Error ? err.message : String(err)}`,
|
|
1225
|
+
);
|
|
1226
|
+
return { raw };
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
824
1230
|
private async boot(userDid: string): Promise<void> {
|
|
825
1231
|
const stored = await this.ctx.storage.get<string>(META_USER_DID);
|
|
826
1232
|
if (stored && stored !== userDid)
|
|
@@ -900,6 +1306,45 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
900
1306
|
DB_FILE,
|
|
901
1307
|
this.sqliteOpenOptions(),
|
|
902
1308
|
);
|
|
1309
|
+
try {
|
|
1310
|
+
await this.bootWorkingCopy(
|
|
1311
|
+
db,
|
|
1312
|
+
userDid,
|
|
1313
|
+
this.ownerStore,
|
|
1314
|
+
this.ucan,
|
|
1315
|
+
secretsService,
|
|
1316
|
+
);
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
// Nothing of a failed boot stays behind: the connection (and the
|
|
1319
|
+
// one a dropped zero-turn copy was reopened into) is closed and the
|
|
1320
|
+
// handles forgotten, so the next request or alarm boots afresh on
|
|
1321
|
+
// one connection instead of finding a half-built object.
|
|
1322
|
+
const reopened = this.db;
|
|
1323
|
+
this.dropInMemoryState();
|
|
1324
|
+
await db.close().catch(() => undefined);
|
|
1325
|
+
if (reopened && reopened !== db)
|
|
1326
|
+
await reopened.close().catch(() => undefined);
|
|
1327
|
+
throw err;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* The fallible part of `boot()`, from the open database on: reconcile it
|
|
1333
|
+
* with the owner copy, then build the stores and services over it.
|
|
1334
|
+
*/
|
|
1335
|
+
private async bootWorkingCopy(
|
|
1336
|
+
db: DoSqliteDatabase,
|
|
1337
|
+
userDid: string,
|
|
1338
|
+
ownerStore: OwnerStore,
|
|
1339
|
+
ucan: WorkersUcanService,
|
|
1340
|
+
secretsService: WorkersSecretsService,
|
|
1341
|
+
): Promise<void> {
|
|
1342
|
+
const env = this.env;
|
|
1343
|
+
// Whether the working copy holds a turn: one existence query, asked
|
|
1344
|
+
// again only after an import or a wipe changed the file.
|
|
1345
|
+
let turns: boolean | undefined;
|
|
1346
|
+
const hasTurns = async (target: DoSqliteDatabase): Promise<boolean> =>
|
|
1347
|
+
(turns ??= await this.hasLocalTurns(target));
|
|
903
1348
|
// Cold object (no pages yet) → pull the user's file if they have one.
|
|
904
1349
|
// `null` means the owner store PROVABLY holds no file (a new user): an
|
|
905
1350
|
// empty working copy is right. A load that FAILS is different: there is
|
|
@@ -908,7 +1353,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
908
1353
|
// real copy. So the request fails instead; the boot is not memoised on
|
|
909
1354
|
// failure, and the next request simply retries the load.
|
|
910
1355
|
if (db.fileSize === 0) {
|
|
911
|
-
const store =
|
|
1356
|
+
const store = ownerStore;
|
|
912
1357
|
let loaded: OwnerCopy | null;
|
|
913
1358
|
let attempts = 0;
|
|
914
1359
|
try {
|
|
@@ -938,24 +1383,35 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
938
1383
|
throw toRpcError(failure);
|
|
939
1384
|
}
|
|
940
1385
|
if (loaded) {
|
|
941
|
-
|
|
1386
|
+
let imported: number;
|
|
1387
|
+
try {
|
|
1388
|
+
imported = await this.adoptOwnerCopy(db, loaded);
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
// The object was empty, so nothing is lost by emptying it
|
|
1391
|
+
// again: a copy that is not a database (a short or truncated
|
|
1392
|
+
// file passes the import's header check) must not stay behind,
|
|
1393
|
+
// or every later boot would open it and fail.
|
|
1394
|
+
await this.wipeWorkingCopy(db).catch(() => undefined);
|
|
1395
|
+
throw err;
|
|
1396
|
+
}
|
|
942
1397
|
this.reloadedFromOwnerStore = true;
|
|
943
1398
|
console.log(
|
|
944
|
-
`[user-do] imported ${imported} bytes from ${loaded.fromLegacy ? 'legacy Matrix media' :
|
|
1399
|
+
`[user-do] imported ${imported} bytes from ${loaded.fromLegacy ? 'legacy Matrix media' : ownerStore.kind} for ${userDid}`,
|
|
945
1400
|
);
|
|
946
1401
|
}
|
|
947
1402
|
} else {
|
|
948
1403
|
// Warm object: notice if the user replaced or deleted their file
|
|
949
1404
|
// upstream. A `head()` that FAILS (a VFS error/timeout, or our own
|
|
950
|
-
//
|
|
951
|
-
//
|
|
1405
|
+
// replace window between moving the old file aside and moving the
|
|
1406
|
+
// new one in, where the real path momentarily does not exist) must
|
|
1407
|
+
// NEVER be read as "the file is gone" — swallowing
|
|
952
1408
|
// the error to null and wiping the working copy on it would destroy
|
|
953
1409
|
// the durable local history over a transient blip. So distinguish a
|
|
954
1410
|
// proven absence (null) from an unknown (the call threw).
|
|
955
1411
|
const knownEtag = await this.ctx.storage.get<string>(META_OWNER_ETAG);
|
|
956
1412
|
let head: { etag: string } | null | undefined;
|
|
957
1413
|
try {
|
|
958
|
-
head = await
|
|
1414
|
+
head = await ownerStore.head();
|
|
959
1415
|
} catch (err) {
|
|
960
1416
|
head = undefined; // unknown — leave the working copy untouched
|
|
961
1417
|
console.warn(
|
|
@@ -971,11 +1427,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
971
1427
|
// re-flushed, re-establishing the owner copy; a genuine "forget me"
|
|
972
1428
|
// goes through the explicit `remove()` path, never through an
|
|
973
1429
|
// auto-wipe that could fire on a mid-replace crash or a stale list.
|
|
974
|
-
if ((await
|
|
1430
|
+
if (!(await hasTurns(db))) {
|
|
975
1431
|
console.warn(
|
|
976
1432
|
`[user-do] owner copy for ${userDid} is gone upstream and the working copy is empty — dropping it`,
|
|
977
1433
|
);
|
|
978
1434
|
await this.wipeWorkingCopy(db);
|
|
1435
|
+
turns = undefined;
|
|
979
1436
|
this.db = await DoSqliteDatabase.open(
|
|
980
1437
|
this.ctx,
|
|
981
1438
|
DB_FILE,
|
|
@@ -988,13 +1445,27 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
988
1445
|
this.markDirty();
|
|
989
1446
|
}
|
|
990
1447
|
} else if (head && knownEtag && head.etag !== knownEtag) {
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1448
|
+
// The file changed upstream. Re-import it only when every local
|
|
1449
|
+
// write already went up: up to a day of turns waits for the
|
|
1450
|
+
// debounced flush, and an import would drop them. With unflushed
|
|
1451
|
+
// turns neither side can win automatically, so keep local (the
|
|
1452
|
+
// next flush replaces the upstream file) and say so loudly.
|
|
1453
|
+
const unflushed = await this.unflushedLocalWrites(db);
|
|
1454
|
+
if (unflushed) {
|
|
1455
|
+
console.warn(
|
|
1456
|
+
`[user-do] owner copy for ${userDid} changed upstream (${knownEtag} → ${head.etag}) but the working copy holds writes not uploaded yet — keeping the local copy`,
|
|
997
1457
|
);
|
|
1458
|
+
this.markDirty();
|
|
1459
|
+
} else {
|
|
1460
|
+
const loaded = await ownerStore.load().catch(() => null);
|
|
1461
|
+
if (loaded) {
|
|
1462
|
+
await this.adoptOwnerCopy(db, loaded);
|
|
1463
|
+
turns = undefined;
|
|
1464
|
+
this.reloadedFromOwnerStore = true;
|
|
1465
|
+
console.log(
|
|
1466
|
+
`[user-do] owner copy changed upstream (${knownEtag} → ${head.etag}); re-imported`,
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
998
1469
|
}
|
|
999
1470
|
} else if (head && !knownEtag) {
|
|
1000
1471
|
// This object first booted before the user's owner file became
|
|
@@ -1005,9 +1476,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1005
1476
|
// empty session, but no turn ever ran → no checkpoints); once local
|
|
1006
1477
|
// turns exist neither side can win automatically, so keep local
|
|
1007
1478
|
// and say so loudly (operators resolve via /debug/storage/reset).
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const loaded = await this.ownerStore.load().catch((err) => {
|
|
1479
|
+
if (!(await hasTurns(db))) {
|
|
1480
|
+
const loaded = await ownerStore.load().catch((err) => {
|
|
1011
1481
|
console.warn(
|
|
1012
1482
|
`[user-do] late owner copy load failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1013
1483
|
);
|
|
@@ -1015,14 +1485,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1015
1485
|
});
|
|
1016
1486
|
if (loaded) {
|
|
1017
1487
|
const imported = await this.adoptOwnerCopy(db, loaded);
|
|
1488
|
+
turns = undefined;
|
|
1018
1489
|
this.reloadedFromOwnerStore = true;
|
|
1019
1490
|
console.log(
|
|
1020
|
-
`[user-do] owner copy appeared upstream after first boot; imported ${imported} bytes from ${
|
|
1491
|
+
`[user-do] owner copy appeared upstream after first boot; imported ${imported} bytes from ${ownerStore.kind} for ${userDid}`,
|
|
1021
1492
|
);
|
|
1022
1493
|
}
|
|
1023
1494
|
} else {
|
|
1024
1495
|
console.warn(
|
|
1025
|
-
`[user-do] owner copy exists upstream (${head.etag}) but was never imported and
|
|
1496
|
+
`[user-do] owner copy exists upstream (${head.etag}) but was never imported and local checkpoints already exist for ${userDid} — keeping the local copy`,
|
|
1026
1497
|
);
|
|
1027
1498
|
}
|
|
1028
1499
|
}
|
|
@@ -1036,21 +1507,39 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1036
1507
|
// the working copy has zero turns and a legacy (Matrix) copy WITH turns
|
|
1037
1508
|
// exists, adopt it and flush it over the empty file. Guard on the legacy
|
|
1038
1509
|
// copy actually having turns so a genuinely new user (empty everywhere)
|
|
1039
|
-
// is left empty, not churned.
|
|
1510
|
+
// is left empty, not churned. A probe that proved there is nothing
|
|
1511
|
+
// usable is remembered (`META_LEGACY_UNUSABLE`), as is a legacy copy
|
|
1512
|
+
// already removed: later boots of a still zero-turn copy skip the
|
|
1513
|
+
// gateway. A probe that failed proves nothing and runs again on the
|
|
1514
|
+
// next boot.
|
|
1040
1515
|
const activeDb = this.db ?? db;
|
|
1041
|
-
if (
|
|
1042
|
-
this.
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1516
|
+
if (ownerStore.loadLegacy && !(await hasTurns(activeDb))) {
|
|
1517
|
+
const markers = await this.ctx.storage.get<boolean>([
|
|
1518
|
+
META_LEGACY_CLEARED,
|
|
1519
|
+
META_LEGACY_UNUSABLE,
|
|
1520
|
+
]);
|
|
1521
|
+
const settled =
|
|
1522
|
+
markers.get(META_LEGACY_CLEARED) === true ||
|
|
1523
|
+
markers.get(META_LEGACY_UNUSABLE) === true;
|
|
1524
|
+
let legacy: OwnerCopy | null = null;
|
|
1525
|
+
if (!settled) {
|
|
1526
|
+
try {
|
|
1527
|
+
legacy = await ownerStore.loadLegacy();
|
|
1528
|
+
if (!legacy) await this.ctx.storage.put(META_LEGACY_UNUSABLE, true);
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
console.warn(
|
|
1531
|
+
`[user-do] legacy Matrix copy of ${userDid} could not be read; checking again on the next boot: ${err instanceof Error ? err.message : String(err)}`,
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1046
1535
|
if (legacy) {
|
|
1047
|
-
// Trial import: only the
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1536
|
+
// Trial import: only the checkpoints tell us whether the legacy
|
|
1537
|
+
// copy holds history, so keep the (tiny, zero-turn) working copy
|
|
1538
|
+
// to put back if it does not.
|
|
1050
1539
|
const before = await activeDb.export();
|
|
1051
1540
|
const adoptedBytes = await activeDb.importFromStream(legacy.stream);
|
|
1052
|
-
|
|
1053
|
-
if (
|
|
1541
|
+
turns = undefined;
|
|
1542
|
+
if (await hasTurns(activeDb)) {
|
|
1054
1543
|
// The adopted bytes are NOT what the system of record holds:
|
|
1055
1544
|
// forget the last upload so the flush below actually sends them.
|
|
1056
1545
|
await this.ctx.storage.delete([
|
|
@@ -1062,10 +1551,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1062
1551
|
// record so the next cold boot imports it directly.
|
|
1063
1552
|
this.markDirty();
|
|
1064
1553
|
console.log(
|
|
1065
|
-
`[user-do] working copy had no turns; adopted the legacy Matrix copy (${adoptedBytes} bytes
|
|
1554
|
+
`[user-do] working copy had no turns; adopted the legacy Matrix copy (${adoptedBytes} bytes) for ${userDid} — flushing it to the system of record`,
|
|
1066
1555
|
);
|
|
1067
1556
|
} else {
|
|
1068
1557
|
await activeDb.import(before);
|
|
1558
|
+
turns = undefined;
|
|
1559
|
+
await this.ctx.storage.put(META_LEGACY_UNUSABLE, true);
|
|
1069
1560
|
console.log(
|
|
1070
1561
|
`[user-do] legacy Matrix copy for ${userDid} holds no turns either; working copy left as is`,
|
|
1071
1562
|
);
|
|
@@ -1088,7 +1579,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1088
1579
|
try {
|
|
1089
1580
|
const flushed = await this.flushToOwnerStore();
|
|
1090
1581
|
console.log(
|
|
1091
|
-
`[user-do] migrated ${bytes} bytes from legacy Matrix media to ${
|
|
1582
|
+
`[user-do] migrated ${bytes} bytes from legacy Matrix media to ${ownerStore.kind} for ${userDid} (${flushed.etag ?? '-'})`,
|
|
1092
1583
|
);
|
|
1093
1584
|
} catch (err) {
|
|
1094
1585
|
console.warn(
|
|
@@ -1115,16 +1606,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1115
1606
|
// Redacting on etag alone would drop the last backstop while the VFS
|
|
1116
1607
|
// copy could still be a smaller or older export. A dirty or
|
|
1117
1608
|
// not-yet-verified copy waits: the flush that lands it does the
|
|
1118
|
-
// removal itself.
|
|
1609
|
+
// removal itself. The verification hashes the whole file, so it
|
|
1610
|
+
// only runs while a legacy copy may still exist.
|
|
1611
|
+
const ownerEtag = await this.ctx.storage.get<string>(META_OWNER_ETAG);
|
|
1119
1612
|
if (
|
|
1120
|
-
|
|
1613
|
+
ownerEtag &&
|
|
1614
|
+
ownerStore.removeLegacyCopy &&
|
|
1615
|
+
(await this.ctx.storage.get<boolean>(META_LEGACY_CLEARED)) !== true &&
|
|
1616
|
+
(await hasTurns(liveDb)) &&
|
|
1121
1617
|
(await this.ownerCopyIsCurrent(liveDb))
|
|
1122
|
-
)
|
|
1123
|
-
|
|
1124
|
-
if (ownerEtag) await this.removeLegacyCopyIfAny(ownerEtag);
|
|
1125
|
-
}
|
|
1618
|
+
)
|
|
1619
|
+
await this.removeLegacyCopyIfAny(ownerEtag);
|
|
1126
1620
|
console.log(
|
|
1127
|
-
`[user-do] ready ${userDid}: store=${
|
|
1621
|
+
`[user-do] ready ${userDid}: store=${ownerStore.kind} legacy=${ownerStore.loadLegacy ? 'yes' : 'no'} file=${liveDb.fileSize}B turns=${(await hasTurns(liveDb)) ? 'yes' : 'none'} sessions=${sessionRow?.n ?? 0} ownerEtag=${ownerEtag ?? '-'} reloaded=${this.reloadedFromOwnerStore}`,
|
|
1128
1622
|
);
|
|
1129
1623
|
}
|
|
1130
1624
|
this.saver = new SqliteSaver(liveDb, undefined, {
|
|
@@ -1137,6 +1631,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1137
1631
|
await this.matrixLedger.setup();
|
|
1138
1632
|
this.runStore = new RunStore(liveDb);
|
|
1139
1633
|
await this.runStore.setup();
|
|
1634
|
+
this.feedbackMarkers = new FeedbackMarkers(liveDb);
|
|
1635
|
+
await this.feedbackMarkers.setup();
|
|
1140
1636
|
this.resultStore = new ResultStore(liveDb, {
|
|
1141
1637
|
...(this.env.TIER_BUCKET ? { bucket: this.env.TIER_BUCKET } : {}),
|
|
1142
1638
|
prefix: this.ctx.id.toString(),
|
|
@@ -1144,6 +1640,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1144
1640
|
logger: console,
|
|
1145
1641
|
});
|
|
1146
1642
|
await this.resultStore.setup();
|
|
1643
|
+
this.attachmentTextCache = new AttachmentTextCacheStore(liveDb, console);
|
|
1644
|
+
// The store exists whenever the bucket is bound, so artefacts made
|
|
1645
|
+
// earlier stay readable, revocable and deleted with their session after
|
|
1646
|
+
// the public origin is removed; only new ones need it.
|
|
1647
|
+
const artifactConfig = artifactStorageConfig(this.env, (message) =>
|
|
1648
|
+
console.warn(message),
|
|
1649
|
+
);
|
|
1650
|
+
if (artifactConfig) {
|
|
1651
|
+
this.artifacts = new ArtifactStore(liveDb, artifactConfig);
|
|
1652
|
+
await this.artifacts.setup();
|
|
1653
|
+
}
|
|
1147
1654
|
// Threads opened before the runtime adopted Node's session rule
|
|
1148
1655
|
// (session id = thread root event id) are renamed to it, once.
|
|
1149
1656
|
const renamed = await migrateThreadSessionIds(liveDb, console);
|
|
@@ -1153,12 +1660,96 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1153
1660
|
config: this.runConfig,
|
|
1154
1661
|
instanceId: this.instanceId,
|
|
1155
1662
|
log: console,
|
|
1156
|
-
|
|
1663
|
+
// Keep-alive and recovery deadlines alike: after a reset, the
|
|
1664
|
+
// keep-alive deadline is the wake that boots the object and
|
|
1665
|
+
// recovers its runs, so the heartbeat fast path must not skip it.
|
|
1666
|
+
requestAlarm: (at) => this.ctx.waitUntil(this.requestHousekeeping(at)),
|
|
1157
1667
|
runAttempt: (live, resumed) => this.runAttempt(live, resumed),
|
|
1158
1668
|
checkpointIdOf: (sessionId) => this.checkpointIdOf(sessionId),
|
|
1159
1669
|
onRunEnded: (record, outcome) => this.onRunEnded(record, outcome),
|
|
1160
1670
|
});
|
|
1161
1671
|
|
|
1672
|
+
this.channelTurns = new ChannelTurns(liveDb, {
|
|
1673
|
+
createSession: async (identity, markerTxnId) => {
|
|
1674
|
+
const room = await this.gateway.resolveUserRoom(identity.userDid);
|
|
1675
|
+
if (!room)
|
|
1676
|
+
throw new ChannelError(
|
|
1677
|
+
428,
|
|
1678
|
+
'Companion room is not ready',
|
|
1679
|
+
'room_not_ready',
|
|
1680
|
+
);
|
|
1681
|
+
return (
|
|
1682
|
+
await this.createSession(identity, {
|
|
1683
|
+
roomId: room.roomId,
|
|
1684
|
+
markerTxnId,
|
|
1685
|
+
})
|
|
1686
|
+
).sessionId;
|
|
1687
|
+
},
|
|
1688
|
+
assertSession: async (identity, sessionId) => {
|
|
1689
|
+
const session = await this.sessions!.getSession(sessionId);
|
|
1690
|
+
const room = await this.gateway.resolveUserRoom(identity.userDid);
|
|
1691
|
+
if (
|
|
1692
|
+
!session ||
|
|
1693
|
+
!sessionId.startsWith('$') ||
|
|
1694
|
+
!room ||
|
|
1695
|
+
session.roomId !== room.roomId
|
|
1696
|
+
)
|
|
1697
|
+
throw new ChannelError(
|
|
1698
|
+
404,
|
|
1699
|
+
'Companion session not found',
|
|
1700
|
+
'session_not_found',
|
|
1701
|
+
);
|
|
1702
|
+
const encryption = await this.gateway.getRoomStateEvent(
|
|
1703
|
+
room.roomId,
|
|
1704
|
+
'm.room.encryption',
|
|
1705
|
+
);
|
|
1706
|
+
const parsed: unknown = encryption ? JSON.parse(encryption) : null;
|
|
1707
|
+
if (
|
|
1708
|
+
!parsed ||
|
|
1709
|
+
typeof parsed !== 'object' ||
|
|
1710
|
+
!('algorithm' in parsed) ||
|
|
1711
|
+
parsed.algorithm !== 'm.megolm.v1.aes-sha2'
|
|
1712
|
+
)
|
|
1713
|
+
throw new ChannelError(
|
|
1714
|
+
409,
|
|
1715
|
+
'Companion room must be encrypted',
|
|
1716
|
+
'room_not_encrypted',
|
|
1717
|
+
);
|
|
1718
|
+
},
|
|
1719
|
+
// Checked again as the run starts: admission may have waited.
|
|
1720
|
+
requireDelegation: async (identity) =>
|
|
1721
|
+
requireChannelDelegation(
|
|
1722
|
+
this.delegations.get(identity.userDid),
|
|
1723
|
+
Math.floor(Date.now() / 1000),
|
|
1724
|
+
),
|
|
1725
|
+
getRun: (runId) => this.runStore!.get(runId),
|
|
1726
|
+
getPlan: (runId) => this.runStore!.getPlan(runId),
|
|
1727
|
+
wasPruned: (runId) => this.runStore!.wasChannelRunPruned(runId),
|
|
1728
|
+
begin: async (runId, request) => {
|
|
1729
|
+
const { live } = await this.runs!.begin({
|
|
1730
|
+
runId,
|
|
1731
|
+
sessionId: request.sessionId,
|
|
1732
|
+
requestId: request.requestId,
|
|
1733
|
+
client: 'channel',
|
|
1734
|
+
request: this.runRequestJson(request, storedRunRequest(request)),
|
|
1735
|
+
multitask: 'enqueue',
|
|
1736
|
+
});
|
|
1737
|
+
this.markDirty();
|
|
1738
|
+
await this.requestHousekeeping(
|
|
1739
|
+
Date.now() + this.runConfig.keepAliveMs,
|
|
1740
|
+
);
|
|
1741
|
+
await this.ctx.storage.sync();
|
|
1742
|
+
this.ctx.waitUntil(live.done.catch(() => undefined));
|
|
1743
|
+
return live.record;
|
|
1744
|
+
},
|
|
1745
|
+
mirror: (request, text, author) =>
|
|
1746
|
+
this.replayToRoom(request, text, author),
|
|
1747
|
+
changed: async () => {
|
|
1748
|
+
this.markDirty();
|
|
1749
|
+
await this.ctx.storage.sync();
|
|
1750
|
+
},
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1162
1753
|
const core = this.core;
|
|
1163
1754
|
// Boot-time plugin hooks + collision checks, once per object (memoised).
|
|
1164
1755
|
await core.warm();
|
|
@@ -1173,7 +1764,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1173
1764
|
matrixUserId: await this.resolveMatrixUserId(userDid),
|
|
1174
1765
|
gateway: this.gateway,
|
|
1175
1766
|
runTurn: (req) => this.runTurn(req),
|
|
1176
|
-
|
|
1767
|
+
abortTurn: (sessionId) => this.abortTurn(sessionId),
|
|
1768
|
+
requestAlarm: (at) => this.requestHousekeeping(at),
|
|
1177
1769
|
turnRunLive: (taskRunId) => Boolean(this.runs?.byTaskRunId(taskRunId)),
|
|
1178
1770
|
log: console,
|
|
1179
1771
|
...(Number.isFinite(maxTasks) ? { maxTasksPerUser: maxTasks } : {}),
|
|
@@ -1181,7 +1773,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1181
1773
|
});
|
|
1182
1774
|
if (this.taskScheduler) {
|
|
1183
1775
|
const next = await this.taskScheduler.nextWakeAt().catch(() => null);
|
|
1184
|
-
if (next !== null)
|
|
1776
|
+
if (next !== null)
|
|
1777
|
+
await this.requestHousekeeping(Math.max(next, Date.now() + 1000));
|
|
1185
1778
|
}
|
|
1186
1779
|
|
|
1187
1780
|
// Runs a previous incarnation left in flight: schedule their recovery
|
|
@@ -1198,7 +1791,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1198
1791
|
// imported from the Node runtime arrive with uncompressed blobs.
|
|
1199
1792
|
this.compactDone ??=
|
|
1200
1793
|
(await this.ctx.storage.get<boolean>(META_COMPACT_DONE)) ?? false;
|
|
1201
|
-
if (!this.compactDone) this.
|
|
1794
|
+
if (!this.compactDone) await this.requestHousekeeping(Date.now() + 3000);
|
|
1202
1795
|
|
|
1203
1796
|
// Provider selection: `core.llm` is the OpenRouter adapter built from
|
|
1204
1797
|
// the validated base env; `LLM_PROVIDER=nebius` (read from the raw
|
|
@@ -1225,20 +1818,27 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1225
1818
|
this.ambient = createAmbientServices({
|
|
1226
1819
|
...(this.taskScheduler ? { tasks: this.taskScheduler.surface } : {}),
|
|
1227
1820
|
preferences: this.preferences,
|
|
1821
|
+
kv: new SqliteUserKv(liveDb),
|
|
1228
1822
|
frontend: this.realtime.frontend,
|
|
1229
1823
|
config: core.validatedEnv,
|
|
1230
1824
|
identity: core.identity,
|
|
1231
1825
|
availablePlugins: core.availablePlugins,
|
|
1232
1826
|
llm,
|
|
1827
|
+
decisions: core.decisions,
|
|
1233
1828
|
logger: console,
|
|
1234
1829
|
storage: this.ctx.storage,
|
|
1235
1830
|
gateway: this.gateway,
|
|
1236
|
-
ucan
|
|
1831
|
+
ucan,
|
|
1237
1832
|
delegationFor: (did) => this.delegations.get(did),
|
|
1238
1833
|
events: this.events,
|
|
1239
1834
|
secrets: createSecretsAdapter(secretsService),
|
|
1240
1835
|
background: (work) => this.ctx.waitUntil(work),
|
|
1241
1836
|
});
|
|
1837
|
+
this.capabilityRouter = createCapabilityRouter({
|
|
1838
|
+
evaluator: core.decisions,
|
|
1839
|
+
logger: console,
|
|
1840
|
+
background: (work) => this.ctx.waitUntil(work),
|
|
1841
|
+
});
|
|
1242
1842
|
}
|
|
1243
1843
|
|
|
1244
1844
|
/**
|
|
@@ -1278,6 +1878,50 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1278
1878
|
});
|
|
1279
1879
|
}
|
|
1280
1880
|
|
|
1881
|
+
/**
|
|
1882
|
+
* Forget every handle on the working copy, so the next request boots
|
|
1883
|
+
* afresh (and imports the owner copy into an empty file).
|
|
1884
|
+
*/
|
|
1885
|
+
private dropInMemoryState(): void {
|
|
1886
|
+
this.db = null;
|
|
1887
|
+
this.saver = null;
|
|
1888
|
+
this.sessions = null;
|
|
1889
|
+
this.matrixLedger = null;
|
|
1890
|
+
this.runStore = null;
|
|
1891
|
+
this.channelTurns = null;
|
|
1892
|
+
this.feedbackMarkers = null;
|
|
1893
|
+
this.runs = null;
|
|
1894
|
+
this.resultStore = null;
|
|
1895
|
+
this.artifacts = null;
|
|
1896
|
+
this.attachmentTextCache = null;
|
|
1897
|
+
// Its store is bound to the database just dropped: a caller that sees
|
|
1898
|
+
// no scheduler boots again instead of reading a closed connection.
|
|
1899
|
+
this.taskScheduler = null;
|
|
1900
|
+
this.sessionRooms.clear();
|
|
1901
|
+
this.mainRoomId = null;
|
|
1902
|
+
this.ambient = null;
|
|
1903
|
+
this.secretsService = null;
|
|
1904
|
+
this.byo = null;
|
|
1905
|
+
this.initPromise = null;
|
|
1906
|
+
this.reloadedFromOwnerStore = false;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/**
|
|
1910
|
+
* Still idle after the idle tick's awaits: the same working copy, nothing
|
|
1911
|
+
* written or uploading, no run, and no request since (every request
|
|
1912
|
+
* records its access in `ready()`, in memory at once).
|
|
1913
|
+
*/
|
|
1914
|
+
private async stillIdle(db: DoSqliteDatabase): Promise<boolean> {
|
|
1915
|
+
const lastAccess = await this.lastAccess(Date.now());
|
|
1916
|
+
return (
|
|
1917
|
+
this.db === db &&
|
|
1918
|
+
!this.dirty &&
|
|
1919
|
+
!this.flushInFlight &&
|
|
1920
|
+
(this.runs?.size ?? 0) === 0 &&
|
|
1921
|
+
Date.now() - lastAccess > IDLE_EVICT_MS
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1281
1925
|
/** Drop the working copy entirely; the caller reopens/reboots afterwards. */
|
|
1282
1926
|
private async wipeWorkingCopy(db: DoSqliteDatabase | null): Promise<void> {
|
|
1283
1927
|
if (db) {
|
|
@@ -1294,6 +1938,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1294
1938
|
META_FLUSH_FAILURES,
|
|
1295
1939
|
META_LAST_VACUUM,
|
|
1296
1940
|
]);
|
|
1941
|
+
this.flushAt = null;
|
|
1297
1942
|
}
|
|
1298
1943
|
|
|
1299
1944
|
/**
|
|
@@ -1354,6 +1999,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1354
1999
|
META_LAST_CHECKSUM,
|
|
1355
2000
|
META_UPLOADED_GEN,
|
|
1356
2001
|
META_LEGACY_CLEARED,
|
|
2002
|
+
META_LEGACY_UNUSABLE,
|
|
1357
2003
|
]);
|
|
1358
2004
|
this.markDirty();
|
|
1359
2005
|
this.pendingLegacyMigration = { bytes };
|
|
@@ -1375,8 +2021,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1375
2021
|
const uploadedGen = await this.ctx.storage.get<number>(META_UPLOADED_GEN);
|
|
1376
2022
|
const lastChecksum =
|
|
1377
2023
|
await this.ctx.storage.get<string>(META_LAST_CHECKSUM);
|
|
1378
|
-
if (lastChecksum === undefined)
|
|
1379
|
-
return (await this.localTurnCount(db)) === 0;
|
|
2024
|
+
if (lastChecksum === undefined) return !(await this.hasLocalTurns(db));
|
|
1380
2025
|
// Objects that last flushed before generations were recorded verify
|
|
1381
2026
|
// by hash alone; everyone else gets the cheap generation gate first.
|
|
1382
2027
|
if (uploadedGen !== undefined && uploadedGen !== db.writeGeneration)
|
|
@@ -1384,17 +2029,47 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1384
2029
|
return (await db.checksum()) === lastChecksum;
|
|
1385
2030
|
}
|
|
1386
2031
|
|
|
1387
|
-
/**
|
|
1388
|
-
private async
|
|
2032
|
+
/** Whether the working copy holds a checkpoint — none means no turn ever ran here. */
|
|
2033
|
+
private async hasLocalTurns(db: DoSqliteDatabase): Promise<boolean> {
|
|
1389
2034
|
const row = await db
|
|
1390
|
-
.get<{
|
|
2035
|
+
.get<{ one: number }>('SELECT 1 AS one FROM checkpoints LIMIT 1')
|
|
1391
2036
|
.catch(() => undefined);
|
|
1392
|
-
return row
|
|
2037
|
+
return row !== undefined;
|
|
1393
2038
|
}
|
|
1394
2039
|
|
|
2040
|
+
/**
|
|
2041
|
+
* Whether the working copy holds writes the owner store has not seen:
|
|
2042
|
+
* marked dirty (in memory or on disk), or written past the generation
|
|
2043
|
+
* of the last upload (`decideBootDirty`).
|
|
2044
|
+
*/
|
|
2045
|
+
private async unflushedLocalWrites(db: DoSqliteDatabase): Promise<boolean> {
|
|
2046
|
+
const got = await this.ctx.storage.get<unknown>([
|
|
2047
|
+
META_DIRTY,
|
|
2048
|
+
META_UPLOADED_GEN,
|
|
2049
|
+
]);
|
|
2050
|
+
const decision = decideBootDirty({
|
|
2051
|
+
dirtyInMemory: this.dirty,
|
|
2052
|
+
dirtyFlag: got.get(META_DIRTY),
|
|
2053
|
+
uploadedGen: got.get(META_UPLOADED_GEN),
|
|
2054
|
+
writeGeneration: db.writeGeneration,
|
|
2055
|
+
});
|
|
2056
|
+
return (
|
|
2057
|
+
decision === 'already-dirty' ||
|
|
2058
|
+
decision === 'flagged' ||
|
|
2059
|
+
decision === 'behind-upload'
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* The working copy holds writes the owner store has not seen: persist the
|
|
2065
|
+
* mark and make sure the upload is scheduled. Free once the copy is dirty
|
|
2066
|
+
* with a known deadline — the alarm armed for it only ever moves earlier,
|
|
2067
|
+
* and the tick that serves it re-arms whatever it leaves pending.
|
|
2068
|
+
*/
|
|
1395
2069
|
private markDirty(): void {
|
|
2070
|
+
if (this.dirty && typeof this.flushAt === 'number') return;
|
|
2071
|
+
if (!this.dirty) void this.ctx.storage.put(META_DIRTY, true);
|
|
1396
2072
|
this.dirty = true;
|
|
1397
|
-
void this.ctx.storage.put(META_DIRTY, true);
|
|
1398
2073
|
void this.armFlush(Date.now() + FLUSH_DEBOUNCE_MS);
|
|
1399
2074
|
}
|
|
1400
2075
|
|
|
@@ -1404,12 +2079,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1404
2079
|
* `FLUSH_DEBOUNCE_MS` after the first of them, never later.
|
|
1405
2080
|
*/
|
|
1406
2081
|
private async armFlush(at: number): Promise<void> {
|
|
1407
|
-
const existing =
|
|
2082
|
+
const existing =
|
|
2083
|
+
typeof this.flushAt === 'number'
|
|
2084
|
+
? this.flushAt
|
|
2085
|
+
: await this.ctx.storage.get<number>(META_FLUSH_AT);
|
|
1408
2086
|
const deadline =
|
|
1409
2087
|
typeof existing === 'number' && existing <= at ? existing : at;
|
|
2088
|
+
this.flushAt = deadline;
|
|
1410
2089
|
if (deadline !== existing)
|
|
1411
2090
|
await this.ctx.storage.put(META_FLUSH_AT, deadline);
|
|
1412
|
-
this.
|
|
2091
|
+
await this.requestHousekeeping(deadline);
|
|
1413
2092
|
}
|
|
1414
2093
|
|
|
1415
2094
|
/**
|
|
@@ -1417,17 +2096,47 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1417
2096
|
* that just came due (which would otherwise make every wake retry).
|
|
1418
2097
|
*/
|
|
1419
2098
|
private async scheduleFlushRetry(at: number): Promise<void> {
|
|
2099
|
+
this.flushAt = at;
|
|
1420
2100
|
await this.ctx.storage.put(META_FLUSH_AT, at);
|
|
1421
|
-
this.
|
|
2101
|
+
await this.requestHousekeeping(at);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
private alarmArm: Promise<void> = Promise.resolve();
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* Arm the alarm for a deadline the full housekeeping tick serves (a
|
|
2108
|
+
* flush, its retry, a task, compaction, a run's keep-alive or recovery),
|
|
2109
|
+
* and lower the stored housekeeping deadline to it: the heartbeat fast
|
|
2110
|
+
* path trusts that value, and a deadline it does not know of would wait
|
|
2111
|
+
* for as long as a socket stays attached. Serialised with every other
|
|
2112
|
+
* arm.
|
|
2113
|
+
*/
|
|
2114
|
+
private requestHousekeeping(at: number): Promise<void> {
|
|
2115
|
+
this.housekeepingFloor = Math.min(this.housekeepingFloor, at);
|
|
2116
|
+
this.alarmArm = this.alarmArm
|
|
2117
|
+
.catch(() => {})
|
|
2118
|
+
.then(async () => {
|
|
2119
|
+
const stored =
|
|
2120
|
+
await this.ctx.storage.get<number>(META_HOUSEKEEPING_AT);
|
|
2121
|
+
if (typeof stored === 'number' && stored > at)
|
|
2122
|
+
await this.ctx.storage.put(META_HOUSEKEEPING_AT, at);
|
|
2123
|
+
const existing = await this.ctx.storage.getAlarm();
|
|
2124
|
+
if (existing === null || existing > at)
|
|
2125
|
+
await this.ctx.storage.setAlarm(at);
|
|
2126
|
+
});
|
|
2127
|
+
return this.alarmArm;
|
|
1422
2128
|
}
|
|
1423
2129
|
|
|
1424
2130
|
/** Arm the object's single alarm no later than `at` (multiplexed). */
|
|
1425
|
-
private requestAlarm(at: number): void {
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
2131
|
+
private requestAlarm(at: number): Promise<void> {
|
|
2132
|
+
this.alarmArm = this.alarmArm
|
|
2133
|
+
.catch(() => {})
|
|
2134
|
+
.then(async () => {
|
|
2135
|
+
const existing = await this.ctx.storage.getAlarm();
|
|
2136
|
+
if (existing === null || existing > at)
|
|
2137
|
+
await this.ctx.storage.setAlarm(at);
|
|
2138
|
+
});
|
|
2139
|
+
return this.alarmArm;
|
|
1431
2140
|
}
|
|
1432
2141
|
|
|
1433
2142
|
/**
|
|
@@ -1452,8 +2161,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1452
2161
|
// exists once the object booted).
|
|
1453
2162
|
const keepAliveAt = this.runs?.keepAliveDeadline(wakeAt) ?? null;
|
|
1454
2163
|
const recoveryAt = this.runs?.nextRecoveryAt() ?? null;
|
|
1455
|
-
if (this.runs && recoveryAt !== null && recoveryAt <= wakeAt + 1000)
|
|
1456
|
-
|
|
2164
|
+
if (this.runs && recoveryAt !== null && recoveryAt <= wakeAt + 1000) {
|
|
2165
|
+
// A store error here must not reject the alarm: the tick below (or
|
|
2166
|
+
// the re-arm) still has to run.
|
|
2167
|
+
try {
|
|
2168
|
+
await this.runs.resumeDue(wakeAt);
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
console.error(
|
|
2171
|
+
`[user-do] run recovery tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
1457
2175
|
if (
|
|
1458
2176
|
nextPingAt !== null &&
|
|
1459
2177
|
housekeepingAt !== undefined &&
|
|
@@ -1461,28 +2179,33 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1461
2179
|
keepAliveAt === null &&
|
|
1462
2180
|
(recoveryAt === null || recoveryAt > wakeAt + 1000)
|
|
1463
2181
|
) {
|
|
1464
|
-
await this.
|
|
2182
|
+
await this.requestAlarm(
|
|
1465
2183
|
Math.min(nextPingAt, housekeepingAt, recoveryAt ?? Infinity),
|
|
1466
2184
|
);
|
|
1467
2185
|
return;
|
|
1468
2186
|
}
|
|
1469
2187
|
|
|
2188
|
+
// A full tick from here on. Deadlines armed while it runs (the boot
|
|
2189
|
+
// below included) are kept by the housekeeping write at its end.
|
|
2190
|
+
this.housekeepingFloor = Infinity;
|
|
2191
|
+
|
|
1470
2192
|
// Alarms routinely wake an EVICTED object: no db, no task scheduler,
|
|
1471
2193
|
// dirty flag gone. Boot from the persisted identity first — a tick on
|
|
1472
2194
|
// the un-booted object would skip every client and re-arm at the idle
|
|
1473
|
-
// horizon, silently dropping pending task runs and flushes.
|
|
2195
|
+
// horizon, silently dropping pending task runs and flushes. The boot
|
|
2196
|
+
// is not a request: it records no access.
|
|
1474
2197
|
if (!this.db) {
|
|
1475
2198
|
const storedDid = await this.ctx.storage.get<string>(META_USER_DID);
|
|
1476
2199
|
if (storedDid) {
|
|
1477
2200
|
try {
|
|
1478
|
-
await this.ready({ userDid: storedDid });
|
|
2201
|
+
await this.ready({ userDid: storedDid }, { recordAccess: false });
|
|
1479
2202
|
} catch (err) {
|
|
1480
2203
|
console.error(
|
|
1481
2204
|
`[user-do] alarm boot failed for ${storedDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1482
2205
|
);
|
|
1483
2206
|
// State exists but could not be opened — retry soon instead of
|
|
1484
2207
|
// falling through to a schedulerless re-arm.
|
|
1485
|
-
await this.
|
|
2208
|
+
await this.requestHousekeeping(Date.now() + 60_000);
|
|
1486
2209
|
return;
|
|
1487
2210
|
}
|
|
1488
2211
|
}
|
|
@@ -1516,8 +2239,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1516
2239
|
deadlines.push(Math.max(nextRecovery, now + 1000));
|
|
1517
2240
|
}
|
|
1518
2241
|
|
|
1519
|
-
|
|
1520
|
-
|
|
2242
|
+
// The last real request, stored for the next instance before anything
|
|
2243
|
+
// is decided on it.
|
|
2244
|
+
await this.persistAccess();
|
|
2245
|
+
const lastAccess = await this.lastAccess(now);
|
|
1521
2246
|
const idle = now - lastAccess > IDLE_EVICT_MS;
|
|
1522
2247
|
|
|
1523
2248
|
// Owner-store flush: only when its own deadline is due, or the copy
|
|
@@ -1548,14 +2273,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1548
2273
|
}
|
|
1549
2274
|
|
|
1550
2275
|
if (this.taskScheduler) {
|
|
2276
|
+
let next: number | null;
|
|
1551
2277
|
try {
|
|
1552
|
-
await this.taskScheduler.onAlarm(now);
|
|
2278
|
+
next = await this.taskScheduler.onAlarm(now);
|
|
1553
2279
|
} catch (err) {
|
|
1554
2280
|
console.error(
|
|
1555
2281
|
`[user-do] task scheduler tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1556
2282
|
);
|
|
2283
|
+
next = await this.taskScheduler.nextWakeAt().catch(() => null);
|
|
1557
2284
|
}
|
|
1558
|
-
const next = await this.taskScheduler.nextWakeAt().catch(() => null);
|
|
1559
2285
|
if (next !== null) deadlines.push(Math.max(next, now + 1000));
|
|
1560
2286
|
}
|
|
1561
2287
|
|
|
@@ -1585,24 +2311,39 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1585
2311
|
if (next !== null) deadlines.push(next);
|
|
1586
2312
|
}
|
|
1587
2313
|
|
|
1588
|
-
//
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
2314
|
+
// Expired ambient blobs (`blob:` keys), one page per tick while a
|
|
2315
|
+
// sweep is under way, a new sweep at most hourly (a page read loads
|
|
2316
|
+
// the whole values, and blobs live a day at most). Its follow-up is
|
|
2317
|
+
// no reason to keep an idle copy: it is armed after the eviction
|
|
2318
|
+
// decision below.
|
|
2319
|
+
let blobSweepPending = false;
|
|
1592
2320
|
if (
|
|
1593
|
-
this.
|
|
1594
|
-
|
|
1595
|
-
|
|
2321
|
+
this.blobSweepCursor !== null ||
|
|
2322
|
+
this.lastBlobSweepAt === null ||
|
|
2323
|
+
now - this.lastBlobSweepAt >= BLOB_SWEEP_INTERVAL_MS
|
|
1596
2324
|
) {
|
|
1597
|
-
|
|
1598
|
-
|
|
2325
|
+
if (this.blobSweepCursor === null) this.lastBlobSweepAt = now;
|
|
2326
|
+
try {
|
|
2327
|
+
const swept = await sweepExpiredBlobs(
|
|
2328
|
+
this.ctx.storage,
|
|
2329
|
+
this.blobSweepCursor ? { startAfter: this.blobSweepCursor } : {},
|
|
2330
|
+
);
|
|
2331
|
+
this.blobSweepCursor = swept.next;
|
|
2332
|
+
blobSweepPending = swept.next !== null;
|
|
2333
|
+
} catch (err) {
|
|
2334
|
+
console.warn(
|
|
2335
|
+
`[user-do] blob sweep failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2336
|
+
);
|
|
2337
|
+
}
|
|
1599
2338
|
}
|
|
1600
2339
|
|
|
1601
|
-
// Idle housekeeping: a user silent for
|
|
1602
|
-
// their file is safe upstream, and we stop paying to store a
|
|
1603
|
-
// evict while tasks are scheduled: their runs ARE
|
|
1604
|
-
// above ran first; the wipe additionally verifies
|
|
1605
|
-
// matches the working copy, else the copy stays
|
|
2340
|
+
// Idle housekeeping: a user silent for five days loses the cached
|
|
2341
|
+
// pages — their file is safe upstream, and we stop paying to store a
|
|
2342
|
+
// copy. Never evict while tasks are scheduled: their runs ARE
|
|
2343
|
+
// activity. The flush above ran first; the wipe additionally verifies
|
|
2344
|
+
// the upstream copy matches the working copy, else the copy stays
|
|
2345
|
+
// until it does. The R2 tier's next pass is no reason to stay: the
|
|
2346
|
+
// wipe deletes the object's R2 prefix with the working copy.
|
|
1606
2347
|
if (
|
|
1607
2348
|
idle &&
|
|
1608
2349
|
!this.dirty &&
|
|
@@ -1610,27 +2351,82 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1610
2351
|
deadlines.length === 0 &&
|
|
1611
2352
|
(this.runs?.size ?? 0) === 0
|
|
1612
2353
|
) {
|
|
1613
|
-
|
|
1614
|
-
|
|
2354
|
+
const db = this.db;
|
|
2355
|
+
const outcome = await evictIdleWorkingCopy({
|
|
2356
|
+
ownerCopyIsCurrent: () => this.ownerCopyIsCurrent(db),
|
|
2357
|
+
stillIdle: () => this.stillIdle(db),
|
|
2358
|
+
dropHandles: () => this.dropInMemoryState(),
|
|
2359
|
+
wipe: () => {
|
|
2360
|
+
const wipe = this.wipeWorkingCopy(db);
|
|
2361
|
+
this.evicting = wipe
|
|
2362
|
+
.catch(() => undefined)
|
|
2363
|
+
.finally(() => {
|
|
2364
|
+
this.evicting = null;
|
|
2365
|
+
});
|
|
2366
|
+
return wipe;
|
|
2367
|
+
},
|
|
2368
|
+
});
|
|
2369
|
+
if (outcome === 'evicted') {
|
|
1615
2370
|
await this.ctx.storage.delete(META_HOUSEKEEPING_AT);
|
|
1616
2371
|
// An attached (idle) socket still needs its heartbeat rounds.
|
|
1617
|
-
if (nextPingAt !== null) await this.
|
|
1618
|
-
|
|
2372
|
+
if (nextPingAt !== null) await this.requestAlarm(nextPingAt);
|
|
2373
|
+
// A request that waited on the wipe has booted the object afresh
|
|
2374
|
+
// and armed its own deadlines (a task Start's run): keep them.
|
|
2375
|
+
else if (this.initPromise === null)
|
|
2376
|
+
await this.ctx.storage.deleteAlarm();
|
|
1619
2377
|
return;
|
|
1620
2378
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2379
|
+
if (outcome === 'not-current') {
|
|
2380
|
+
console.warn(
|
|
2381
|
+
`[user-do] ${this.userDid} is idle but the owner copy is not verified current — keeping the working copy`,
|
|
2382
|
+
);
|
|
2383
|
+
deadlines.push(now + FLUSH_RETRY_DELAY_MS);
|
|
2384
|
+
} else {
|
|
2385
|
+
console.log(
|
|
2386
|
+
`[user-do] ${this.userDid} became active during the idle check — keeping the working copy`,
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
1625
2389
|
}
|
|
2390
|
+
|
|
2391
|
+
// R2 page tier: move chunks no turn touched for a couple of days out
|
|
2392
|
+
// of DO storage (see sqlite/page-tier.ts). After the flush above (the
|
|
2393
|
+
// upload reads hot rows, not R2) and never over an in-flight one (it
|
|
2394
|
+
// holds a snapshot). A pass is capped; more work re-arms soon.
|
|
2395
|
+
if (
|
|
2396
|
+
this.db?.tierEnabled &&
|
|
2397
|
+
!this.flushInFlight &&
|
|
2398
|
+
(this.runs?.activeCount ?? 0) === 0
|
|
2399
|
+
) {
|
|
2400
|
+
const next = await this.tierTick(this.db, now);
|
|
2401
|
+
if (next !== null) deadlines.push(next);
|
|
2402
|
+
}
|
|
2403
|
+
if (blobSweepPending) deadlines.push(now + 60_000);
|
|
2404
|
+
|
|
1626
2405
|
deadlines.push(now + IDLE_EVICT_MS);
|
|
1627
2406
|
const housekeeping = Math.min(...deadlines);
|
|
1628
|
-
await this.
|
|
1629
|
-
await this.
|
|
2407
|
+
await this.storeHousekeeping(housekeeping);
|
|
2408
|
+
await this.requestAlarm(
|
|
1630
2409
|
nextPingAt === null ? housekeeping : Math.min(housekeeping, nextPingAt),
|
|
1631
2410
|
);
|
|
1632
2411
|
}
|
|
1633
2412
|
|
|
2413
|
+
/**
|
|
2414
|
+
* Record the next housekeeping deadline at the end of a full tick: the
|
|
2415
|
+
* earliest the tick found, lowered by any deadline armed while it ran
|
|
2416
|
+
* (an arm that lands after this write lowers it itself). Serialised
|
|
2417
|
+
* with the arms, so neither overwrites the other.
|
|
2418
|
+
*/
|
|
2419
|
+
private storeHousekeeping(at: number): Promise<void> {
|
|
2420
|
+
this.alarmArm = this.alarmArm
|
|
2421
|
+
.catch(() => {})
|
|
2422
|
+
.then(async () => {
|
|
2423
|
+
const deadline = Math.min(at, this.housekeepingFloor);
|
|
2424
|
+
this.housekeepingFloor = Infinity;
|
|
2425
|
+
await this.ctx.storage.put(META_HOUSEKEEPING_AT, deadline);
|
|
2426
|
+
});
|
|
2427
|
+
return this.alarmArm;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
1634
2430
|
/**
|
|
1635
2431
|
* One page-tier eviction pass, at most once per `TIER_PASS_INTERVAL_MS`
|
|
1636
2432
|
* (a pass that hit its segment cap re-arms in a minute). Returns the next
|
|
@@ -1687,7 +2483,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1687
2483
|
if (!this.db) {
|
|
1688
2484
|
const userDid =
|
|
1689
2485
|
this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
1690
|
-
if (userDid) await this.ready({ userDid });
|
|
2486
|
+
if (userDid) await this.ready({ userDid }, { recordAccess: false });
|
|
1691
2487
|
}
|
|
1692
2488
|
if (!this.db) throw new Error('no working copy');
|
|
1693
2489
|
if (this.flushInFlight) await this.flushInFlight.catch(() => undefined);
|
|
@@ -1747,14 +2543,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1747
2543
|
|
|
1748
2544
|
async createSession(
|
|
1749
2545
|
identity: TurnIdentity,
|
|
1750
|
-
o: { roomId?: string; sessionId?: string } = {},
|
|
2546
|
+
o: { roomId?: string; sessionId?: string; markerTxnId?: string } = {},
|
|
1751
2547
|
): Promise<SessionSummary> {
|
|
1752
2548
|
await this.ready(identity);
|
|
1753
2549
|
const sessions = this.sessions!;
|
|
1754
2550
|
// Like the Node runtime: creating a session sends the MOST RECENT
|
|
1755
2551
|
// previous session's transcript to the memory engine, in the
|
|
1756
|
-
// background — the new session never waits on it.
|
|
1757
|
-
|
|
2552
|
+
// background — the new session never waits on it. Task runs are not
|
|
2553
|
+
// conversations and are never indexed (`scheduleHistoryIndexing`).
|
|
2554
|
+
const { sessions: recent } = await sessions.listSessions(
|
|
2555
|
+
undefined,
|
|
2556
|
+
1,
|
|
2557
|
+
0,
|
|
2558
|
+
TASK_SESSION_PREFIX,
|
|
2559
|
+
);
|
|
1758
2560
|
const previous = recent[0];
|
|
1759
2561
|
if (previous) this.scheduleHistoryIndexing(previous.sessionId);
|
|
1760
2562
|
let sessionId = o.sessionId;
|
|
@@ -1782,7 +2584,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1782
2584
|
// the same transaction id across the restart window, which the
|
|
1783
2585
|
// homeserver deduplicates. Node fails the request on send failure;
|
|
1784
2586
|
// so do we — no silent local id when the user has an oracle room.
|
|
1785
|
-
const txnId = `marker-${crypto.randomUUID()}`;
|
|
2587
|
+
const txnId = o.markerTxnId ?? `marker-${crypto.randomUUID()}`;
|
|
1786
2588
|
sessionId = await retryGateway(
|
|
1787
2589
|
() =>
|
|
1788
2590
|
this.gateway.sendText(room.roomId, NEW_CONVERSATION_TEXT, {
|
|
@@ -1802,6 +2604,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1802
2604
|
}
|
|
1803
2605
|
}
|
|
1804
2606
|
sessionId ??= `s_${crypto.randomUUID()}`;
|
|
2607
|
+
if (o.markerTxnId) {
|
|
2608
|
+
const existing = await sessions.getSession(sessionId);
|
|
2609
|
+
if (existing) return toSummary(existing);
|
|
2610
|
+
}
|
|
1805
2611
|
const row = await sessions.createSession({
|
|
1806
2612
|
sessionId,
|
|
1807
2613
|
roomId,
|
|
@@ -1813,6 +2619,66 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1813
2619
|
return toSummary(row);
|
|
1814
2620
|
}
|
|
1815
2621
|
|
|
2622
|
+
async channelTurn(
|
|
2623
|
+
identity: TurnIdentity,
|
|
2624
|
+
input: ChannelTurnInput,
|
|
2625
|
+
requestHash: string,
|
|
2626
|
+
): Promise<ChannelTurnOutcome> {
|
|
2627
|
+
try {
|
|
2628
|
+
if (identity.ucanDelegation)
|
|
2629
|
+
throw new ChannelError(
|
|
2630
|
+
403,
|
|
2631
|
+
'Channel grants cannot become tool authority',
|
|
2632
|
+
'channel_grant_not_tool_authority',
|
|
2633
|
+
);
|
|
2634
|
+
// The shell (the only caller) checked the binding just before.
|
|
2635
|
+
// The delegation is checked BEFORE the object boots: a cold boot
|
|
2636
|
+
// loads the owner copy under it, and a user without a usable one
|
|
2637
|
+
// must get `delegation_required`, not a boot failure. Hydration
|
|
2638
|
+
// reads the cached copy, then the deposited room state (throttled
|
|
2639
|
+
// after a miss); an in-memory one that no longer passes is
|
|
2640
|
+
// re-read too, in case a newer deposit did not reach this object.
|
|
2641
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
2642
|
+
try {
|
|
2643
|
+
requireChannelDelegation(
|
|
2644
|
+
this.delegations.get(identity.userDid),
|
|
2645
|
+
nowSeconds,
|
|
2646
|
+
);
|
|
2647
|
+
} catch {
|
|
2648
|
+
await this.hydrateDelegation(identity.userDid);
|
|
2649
|
+
requireChannelDelegation(
|
|
2650
|
+
this.delegations.get(identity.userDid),
|
|
2651
|
+
nowSeconds,
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
try {
|
|
2655
|
+
await this.ready(identity);
|
|
2656
|
+
} catch (error) {
|
|
2657
|
+
// A delegation that lacks the file-storage grant over
|
|
2658
|
+
// `/.oracles` cannot load the owner copy: the user must
|
|
2659
|
+
// authorize again, exactly as without a delegation. (Boot throws
|
|
2660
|
+
// the failure as its RPC envelope, so it is parsed, not matched
|
|
2661
|
+
// by class.)
|
|
2662
|
+
if (parseOwnerCopyFailure(error)?.code === 'NO_VFS_DELEGATION')
|
|
2663
|
+
throw delegationRequiredError();
|
|
2664
|
+
throw error;
|
|
2665
|
+
}
|
|
2666
|
+
return {
|
|
2667
|
+
ok: true,
|
|
2668
|
+
result: await this.channelTurns!.submit(identity, input, requestHash),
|
|
2669
|
+
};
|
|
2670
|
+
} catch (error) {
|
|
2671
|
+
if (error instanceof ChannelError)
|
|
2672
|
+
return {
|
|
2673
|
+
ok: false,
|
|
2674
|
+
status: error.status,
|
|
2675
|
+
code: error.code,
|
|
2676
|
+
message: error.message,
|
|
2677
|
+
};
|
|
2678
|
+
throw error;
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
1816
2682
|
async listSessions(
|
|
1817
2683
|
identity: TurnIdentity,
|
|
1818
2684
|
o: { limit?: number; offset?: number } = {},
|
|
@@ -1853,12 +2719,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1853
2719
|
sessionId: string,
|
|
1854
2720
|
): Promise<boolean> {
|
|
1855
2721
|
await this.ready(identity);
|
|
2722
|
+
// End every run of the session first: the executing one is aborted
|
|
2723
|
+
// and waited for (up to the supersede grace), queued and recovering
|
|
2724
|
+
// ones are closed — none may start a turn on the deleted thread, or
|
|
2725
|
+
// recreate its session row, after the rows below are gone.
|
|
2726
|
+
await this.runs?.abortAllForSession(sessionId);
|
|
1856
2727
|
this.aborts.get(sessionId)?.abort();
|
|
1857
2728
|
// Node indexes a session into the memory engine as it is deleted.
|
|
1858
2729
|
// The rows are gone once this RPC returns, so capture the transcript
|
|
1859
2730
|
// now and let the background indexer read the snapshot.
|
|
1860
2731
|
const doomed = await this.sessions!.getSession(sessionId);
|
|
1861
|
-
if (doomed) {
|
|
2732
|
+
if (doomed && !sessionId.startsWith(TASK_SESSION_PREFIX)) {
|
|
1862
2733
|
this.historySnapshots.set(sessionId, {
|
|
1863
2734
|
session: doomed,
|
|
1864
2735
|
messages: await this.historyMessages(sessionId),
|
|
@@ -1868,6 +2739,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1868
2739
|
const deleted = await this.sessions!.deleteSession(sessionId);
|
|
1869
2740
|
if (deleted) {
|
|
1870
2741
|
await this.saver!.deleteThread(sessionId);
|
|
2742
|
+
await this.attachmentTextCache
|
|
2743
|
+
?.forgetSession(sessionId)
|
|
2744
|
+
.catch((err: unknown) => {
|
|
2745
|
+
console.warn(
|
|
2746
|
+
`[user-do] could not delete the cached attachment text of ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2747
|
+
);
|
|
2748
|
+
});
|
|
1871
2749
|
await this.resultStore
|
|
1872
2750
|
?.deleteForSession(sessionId)
|
|
1873
2751
|
.catch((err: unknown) => {
|
|
@@ -1875,12 +2753,60 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1875
2753
|
`[user-do] could not delete the saved tool results of ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1876
2754
|
);
|
|
1877
2755
|
});
|
|
2756
|
+
await this.artifacts
|
|
2757
|
+
?.deleteForSession(sessionId)
|
|
2758
|
+
.catch((err: unknown) => {
|
|
2759
|
+
console.warn(
|
|
2760
|
+
`[user-do] could not delete the artefacts of ${sessionId}; their links expire on schedule: ${err instanceof Error ? err.message : String(err)}`,
|
|
2761
|
+
);
|
|
2762
|
+
});
|
|
1878
2763
|
await this.ctx.storage.delete(contextStatsKey(sessionId));
|
|
2764
|
+
await this.feedbackMarkers!.forgetSession(sessionId);
|
|
1879
2765
|
this.markDirty();
|
|
1880
2766
|
}
|
|
2767
|
+
// A channel binding that pointed at the session opens a new one on
|
|
2768
|
+
// its next message instead of failing the session check for ever.
|
|
2769
|
+
if (await this.channelTurns!.forgetSession(sessionId)) this.markDirty();
|
|
1881
2770
|
return deleted;
|
|
1882
2771
|
}
|
|
1883
2772
|
|
|
2773
|
+
/**
|
|
2774
|
+
* `GET /artifacts/:id` — the canonical copy, for its owner. `url` is
|
|
2775
|
+
* present only while the share link still works.
|
|
2776
|
+
*/
|
|
2777
|
+
async artifact(
|
|
2778
|
+
identity: TurnIdentity,
|
|
2779
|
+
artifactId: string,
|
|
2780
|
+
): Promise<OwnedArtifact | null> {
|
|
2781
|
+
await this.ready(identity);
|
|
2782
|
+
const stored = await this.artifacts?.get(artifactId);
|
|
2783
|
+
if (!stored) return null;
|
|
2784
|
+
const { url, ...ref } = stored.ref;
|
|
2785
|
+
const live =
|
|
2786
|
+
url !== undefined &&
|
|
2787
|
+
!stored.revoked &&
|
|
2788
|
+
Date.parse(ref.expiresAt) > Date.now();
|
|
2789
|
+
return {
|
|
2790
|
+
...ref,
|
|
2791
|
+
...(live ? { url } : {}),
|
|
2792
|
+
sessionId: stored.sessionId,
|
|
2793
|
+
createdAt: stored.createdAt,
|
|
2794
|
+
revoked: stored.revoked,
|
|
2795
|
+
content: stored.content,
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/** `DELETE /artifacts/:id` — revoke the share link; the canonical copy stays. */
|
|
2800
|
+
async revokeArtifact(
|
|
2801
|
+
identity: TurnIdentity,
|
|
2802
|
+
artifactId: string,
|
|
2803
|
+
): Promise<boolean> {
|
|
2804
|
+
await this.ready(identity);
|
|
2805
|
+
const revoked = (await this.artifacts?.revoke(artifactId)) ?? false;
|
|
2806
|
+
if (revoked) this.markDirty();
|
|
2807
|
+
return revoked;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
1884
2810
|
/** The session transcript as the indexer wants it (summarisation bookkeeping removed). */
|
|
1885
2811
|
private async historyMessages(
|
|
1886
2812
|
sessionId: string,
|
|
@@ -1890,7 +2816,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1890
2816
|
.filter(
|
|
1891
2817
|
(m) => !isSummarizationMessage(m) && !isAttachmentViewMessage(m),
|
|
1892
2818
|
)
|
|
1893
|
-
.map((m) => ({ type: m.type, content:
|
|
2819
|
+
.map((m) => ({ type: m.type, content: visibleText(m) }));
|
|
1894
2820
|
}
|
|
1895
2821
|
|
|
1896
2822
|
/**
|
|
@@ -1899,8 +2825,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1899
2825
|
* seeded from the row's `lastProcessedCount` for files migrated from Node;
|
|
1900
2826
|
* `touchSession` is not used because it bumps `last_updated_at` and
|
|
1901
2827
|
* would reorder the session list.
|
|
2828
|
+
*
|
|
2829
|
+
* Task-run sessions (`task:<id>`) are never indexed: a run's transcript
|
|
2830
|
+
* is the task's instructions and its result, not something the user
|
|
2831
|
+
* said, and a supplied-context task's source text must not become
|
|
2832
|
+
* long-term memory that ordinary, tool-enabled turns can retrieve.
|
|
1902
2833
|
*/
|
|
1903
2834
|
private scheduleHistoryIndexing(sessionId: string): void {
|
|
2835
|
+
if (sessionId.startsWith(TASK_SESSION_PREFIX)) return;
|
|
1904
2836
|
const ambient = this.ambient;
|
|
1905
2837
|
const userDid = this.userDid;
|
|
1906
2838
|
if (!ambient || !this.sessions || !userDid) return;
|
|
@@ -1984,6 +2916,57 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1984
2916
|
return JSON.stringify(dtos);
|
|
1985
2917
|
}
|
|
1986
2918
|
|
|
2919
|
+
/**
|
|
2920
|
+
* Anonymous feedback, step one (src/feedback/submit.ts): the message must
|
|
2921
|
+
* be a completed Agent reply in this user's own session; then the
|
|
2922
|
+
* idempotency marker and the user's submission limit decide. The shell
|
|
2923
|
+
* holds the feedback text; it never reaches this object.
|
|
2924
|
+
*/
|
|
2925
|
+
async reserveMessageFeedback(
|
|
2926
|
+
identity: TurnIdentity,
|
|
2927
|
+
target: FeedbackTarget,
|
|
2928
|
+
): Promise<FeedbackReservation> {
|
|
2929
|
+
await this.ready(identity);
|
|
2930
|
+
const reservation = await reserveFeedback(
|
|
2931
|
+
{
|
|
2932
|
+
sessionExists: async (sessionId) =>
|
|
2933
|
+
(await this.sessions!.getSession(sessionId)) !== undefined,
|
|
2934
|
+
transcript: async (sessionId) =>
|
|
2935
|
+
(
|
|
2936
|
+
await transformTranscript(
|
|
2937
|
+
(await this.saver!.listThreadMessages(sessionId)).filter(
|
|
2938
|
+
(m) => !isSummarizationMessage(m),
|
|
2939
|
+
),
|
|
2940
|
+
)
|
|
2941
|
+
).messages,
|
|
2942
|
+
// A queued run has not written its user message yet, so it does
|
|
2943
|
+
// not make the latest reply incomplete; a running one has.
|
|
2944
|
+
runActive: async (sessionId) => {
|
|
2945
|
+
const status = (
|
|
2946
|
+
this.runs?.activeForSession(sessionId)?.record ??
|
|
2947
|
+
(await this.runStore?.activeForSession(sessionId))
|
|
2948
|
+
)?.status;
|
|
2949
|
+
return status === 'running' || status === 'recovering';
|
|
2950
|
+
},
|
|
2951
|
+
markers: this.feedbackMarkers!,
|
|
2952
|
+
},
|
|
2953
|
+
target,
|
|
2954
|
+
);
|
|
2955
|
+
if (reservation.kind === 'reserved') this.markDirty();
|
|
2956
|
+
return reservation;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/** Anonymous feedback, step two: settle the reservation (`FeedbackMarkers.settle`). */
|
|
2960
|
+
async settleMessageFeedback(
|
|
2961
|
+
identity: TurnIdentity,
|
|
2962
|
+
target: FeedbackTarget,
|
|
2963
|
+
outcome: FeedbackSettlement,
|
|
2964
|
+
): Promise<void> {
|
|
2965
|
+
await this.ready(identity);
|
|
2966
|
+
await this.feedbackMarkers!.settle(target, outcome);
|
|
2967
|
+
this.markDirty();
|
|
2968
|
+
}
|
|
2969
|
+
|
|
1987
2970
|
async listMessagesPage(
|
|
1988
2971
|
identity: TurnIdentity,
|
|
1989
2972
|
sessionId: string,
|
|
@@ -2015,7 +2998,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2015
2998
|
raw: string,
|
|
2016
2999
|
expiration?: number,
|
|
2017
3000
|
): Promise<void> {
|
|
2018
|
-
this.delegations.set(
|
|
3001
|
+
this.delegations.set(
|
|
3002
|
+
userDid,
|
|
3003
|
+
await this.delegationRecord(raw, expiration),
|
|
3004
|
+
);
|
|
2019
3005
|
this.delegationMissUntil = 0;
|
|
2020
3006
|
await this.ctx.storage.put(META_DELEGATION, {
|
|
2021
3007
|
raw,
|
|
@@ -2125,12 +3111,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2125
3111
|
await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
|
|
2126
3112
|
const inMemory = this.delegations.get(userDid);
|
|
2127
3113
|
if (inMemory) {
|
|
3114
|
+
const expiration = inMemory.expiration ?? stored?.expiration;
|
|
2128
3115
|
return {
|
|
2129
3116
|
present: true,
|
|
2130
3117
|
source: 'memory',
|
|
2131
|
-
...(typeof
|
|
2132
|
-
? { expiration: stored.expiration }
|
|
2133
|
-
: {}),
|
|
3118
|
+
...(typeof expiration === 'number' ? { expiration } : {}),
|
|
2134
3119
|
};
|
|
2135
3120
|
}
|
|
2136
3121
|
if (stored?.raw) {
|
|
@@ -2192,6 +3177,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2192
3177
|
sessionId: req.sessionId,
|
|
2193
3178
|
requestId: req.requestId,
|
|
2194
3179
|
text: existing?.replyText ?? '',
|
|
3180
|
+
...(existing?.replyPlan ? { plan: existing.replyPlan } : {}),
|
|
2195
3181
|
toolCalls: [],
|
|
2196
3182
|
replayed: true,
|
|
2197
3183
|
};
|
|
@@ -2211,7 +3197,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2211
3197
|
await ledger.start(eventId, req.sessionId, req.requestId);
|
|
2212
3198
|
const run = this.runTurnOnce(req)
|
|
2213
3199
|
.then(async (result) => {
|
|
2214
|
-
await ledger.answer(eventId, result.text);
|
|
3200
|
+
await ledger.answer(eventId, result.text, result.plan);
|
|
2215
3201
|
return result;
|
|
2216
3202
|
})
|
|
2217
3203
|
.finally(() => {
|
|
@@ -2259,7 +3245,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2259
3245
|
sessionId: req.sessionId,
|
|
2260
3246
|
requestId: req.requestId,
|
|
2261
3247
|
client: req.client,
|
|
2262
|
-
request:
|
|
3248
|
+
request: this.runRequestJson(
|
|
3249
|
+
req,
|
|
2263
3250
|
storedRunRequest(req, { timezone: req.identity.timezone }),
|
|
2264
3251
|
),
|
|
2265
3252
|
multitask: req.multitask ?? this.runConfig.multitaskDefault,
|
|
@@ -2280,6 +3267,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2280
3267
|
sessionId: req.sessionId,
|
|
2281
3268
|
requestId: req.requestId,
|
|
2282
3269
|
text: outcome.text,
|
|
3270
|
+
...(outcome.plan ? { plan: JSON.stringify(outcome.plan) } : {}),
|
|
2283
3271
|
...(outcome.messageId !== undefined
|
|
2284
3272
|
? { messageId: outcome.messageId }
|
|
2285
3273
|
: {}),
|
|
@@ -2306,6 +3294,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2306
3294
|
sessionId: req.sessionId,
|
|
2307
3295
|
requestId: req.requestId,
|
|
2308
3296
|
text: outcome.text,
|
|
3297
|
+
...(outcome.plan ? { plan: JSON.stringify(outcome.plan) } : {}),
|
|
2309
3298
|
...(outcome.messageId ? { messageId: outcome.messageId } : {}),
|
|
2310
3299
|
toolCalls: outcome.toolCalls ?? [],
|
|
2311
3300
|
replayed: true,
|
|
@@ -2313,10 +3302,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2313
3302
|
}
|
|
2314
3303
|
const record = await this.runStore?.getByRequestId(requestId);
|
|
2315
3304
|
if (record?.status === 'finished') {
|
|
3305
|
+
const plan = parseReplyPlan(await this.runStore!.getPlan(record.runId));
|
|
2316
3306
|
return {
|
|
2317
3307
|
sessionId: req.sessionId,
|
|
2318
3308
|
requestId: req.requestId,
|
|
2319
3309
|
text: record.partialText ?? '',
|
|
3310
|
+
...(plan ? { plan: JSON.stringify(plan) } : {}),
|
|
2320
3311
|
...(record.messageId ? { messageId: record.messageId } : {}),
|
|
2321
3312
|
toolCalls: [],
|
|
2322
3313
|
replayed: true,
|
|
@@ -2359,6 +3350,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2359
3350
|
// One export at the end, not per batch — flushing a multi-GB file
|
|
2360
3351
|
// after every 300 rows would dwarf the savings.
|
|
2361
3352
|
this.markDirty();
|
|
3353
|
+
} else if (await this.unflushedLocalWrites(db)) {
|
|
3354
|
+
// Nothing saved, but the closing vacuum rewrote the file: an
|
|
3355
|
+
// unmarked write would keep the owner copy unverifiable, and the
|
|
3356
|
+
// idle wipe waiting on it, until some later boot noticed.
|
|
3357
|
+
this.markDirty();
|
|
2362
3358
|
}
|
|
2363
3359
|
return false;
|
|
2364
3360
|
}
|
|
@@ -2421,7 +3417,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2421
3417
|
// an operator's `POST /debug/storage/flush` is never a silent no-op.
|
|
2422
3418
|
const userDid =
|
|
2423
3419
|
this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
2424
|
-
if (userDid) await this.ready({ userDid });
|
|
3420
|
+
if (userDid) await this.ready({ userDid }, { recordAccess: false });
|
|
2425
3421
|
}
|
|
2426
3422
|
if (this.flushInFlight) return this.flushInFlight;
|
|
2427
3423
|
const run = this.flushOnce().finally(() => {
|
|
@@ -2521,7 +3517,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2521
3517
|
// Defence in depth: never drop the backstop for an empty working copy —
|
|
2522
3518
|
// a zero-turn file is not worth confirming against, and redacting here
|
|
2523
3519
|
// would strip a migrated user's only history if VFS held a stub.
|
|
2524
|
-
if (this.db && (await this.
|
|
3520
|
+
if (this.db && !(await this.hasLocalTurns(this.db))) return false;
|
|
2525
3521
|
try {
|
|
2526
3522
|
const removed = await store.removeLegacyCopy(etag);
|
|
2527
3523
|
await this.ctx.storage.put(META_LEGACY_CLEARED, true);
|
|
@@ -2573,21 +3569,36 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2573
3569
|
uploadedGen !== this.db.writeGeneration
|
|
2574
3570
|
) {
|
|
2575
3571
|
const at = Date.now() + FLUSH_DEBOUNCE_MS;
|
|
3572
|
+
this.flushAt = at;
|
|
2576
3573
|
await this.ctx.storage.put(META_FLUSH_AT, at);
|
|
2577
|
-
this.
|
|
3574
|
+
await this.requestHousekeeping(at);
|
|
2578
3575
|
return;
|
|
2579
3576
|
}
|
|
2580
3577
|
this.dirty = false;
|
|
3578
|
+
this.flushAt = null;
|
|
2581
3579
|
await this.ctx.storage.delete([META_DIRTY, META_FLUSH_AT]);
|
|
2582
3580
|
}
|
|
2583
3581
|
|
|
2584
|
-
|
|
3582
|
+
/**
|
|
3583
|
+
* `POST /debug/storage/reset`: upload what is unsaved, drop the working
|
|
3584
|
+
* copy and boot again from the owner copy. `identity` is the caller the
|
|
3585
|
+
* shell authenticated, with the delegation the request carried: an
|
|
3586
|
+
* object that never booted boots for it (the owner store needs that
|
|
3587
|
+
* delegation), and an object bound to another DID refuses before
|
|
3588
|
+
* anything is dropped.
|
|
3589
|
+
*/
|
|
3590
|
+
async resetWorkingCopy(
|
|
3591
|
+
identity?: TurnIdentity,
|
|
3592
|
+
): Promise<{ reloadedFromOwnerStore: boolean }> {
|
|
2585
3593
|
const userDid =
|
|
2586
|
-
|
|
3594
|
+
identity?.userDid ??
|
|
3595
|
+
this.userDid ??
|
|
3596
|
+
(await this.ctx.storage.get<string>(META_USER_DID));
|
|
3597
|
+
const caller = identity ?? (userDid ? { userDid } : undefined);
|
|
2587
3598
|
// On a cold (evicted) object the in-memory dirty flag is gone — boot and
|
|
2588
3599
|
// consult the persisted marker, or the wipe below discards changes the
|
|
2589
|
-
// owner store never received.
|
|
2590
|
-
if (
|
|
3600
|
+
// owner store never received. A booted object only checks the caller.
|
|
3601
|
+
if (caller) await this.ready(caller, { recordAccess: false });
|
|
2591
3602
|
if (
|
|
2592
3603
|
this.dirty ||
|
|
2593
3604
|
(await this.ctx.storage.get<boolean>(META_DIRTY)) === true
|
|
@@ -2596,21 +3607,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2596
3607
|
await this.flushToOwnerStore();
|
|
2597
3608
|
}
|
|
2598
3609
|
await this.wipeWorkingCopy(this.db);
|
|
2599
|
-
this.
|
|
2600
|
-
this.
|
|
2601
|
-
this.sessions = null;
|
|
2602
|
-
this.matrixLedger = null;
|
|
2603
|
-
this.runStore = null;
|
|
2604
|
-
this.runs = null;
|
|
2605
|
-
this.resultStore = null;
|
|
2606
|
-
this.sessionRooms.clear();
|
|
2607
|
-
this.mainRoomId = null;
|
|
2608
|
-
this.ambient = null;
|
|
2609
|
-
this.secretsService = null;
|
|
2610
|
-
this.byo = null;
|
|
2611
|
-
this.initPromise = null;
|
|
2612
|
-
this.reloadedFromOwnerStore = false;
|
|
2613
|
-
if (userDid) await this.ready({ userDid });
|
|
3610
|
+
this.dropInMemoryState();
|
|
3611
|
+
if (caller) await this.ready(caller, { recordAccess: false });
|
|
2614
3612
|
return { reloadedFromOwnerStore: this.reloadedFromOwnerStore };
|
|
2615
3613
|
}
|
|
2616
3614
|
|
|
@@ -2624,7 +3622,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2624
3622
|
userDid: string,
|
|
2625
3623
|
sessionId: string,
|
|
2626
3624
|
): Promise<Record<string, unknown> | null> {
|
|
2627
|
-
await this.ready({ userDid });
|
|
3625
|
+
await this.ready({ userDid }, { recordAccess: false });
|
|
2628
3626
|
const session = await this.sessions!.getSession(sessionId);
|
|
2629
3627
|
if (!session) return null;
|
|
2630
3628
|
// Whether the thread's agent context was condensed: the summarization
|
|
@@ -2656,7 +3654,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2656
3654
|
}
|
|
2657
3655
|
|
|
2658
3656
|
async debugMemorySchema(userDid: string): Promise<MemorySchemaDebug> {
|
|
2659
|
-
await this.ready({ userDid });
|
|
3657
|
+
await this.ready({ userDid }, { recordAccess: false });
|
|
2660
3658
|
const ambient = this.ambient;
|
|
2661
3659
|
const url = this.core.validatedEnv['MEMORY_MCP_URL'];
|
|
2662
3660
|
if (!ambient || typeof url !== 'string' || url.length === 0) {
|
|
@@ -2719,7 +3717,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2719
3717
|
lastVacuumAt: await this.ctx.storage.get<number>(META_LAST_VACUUM),
|
|
2720
3718
|
legacyCleared:
|
|
2721
3719
|
(await this.ctx.storage.get<boolean>(META_LEGACY_CLEARED)) === true,
|
|
2722
|
-
lastAccessAt: await this.
|
|
3720
|
+
lastAccessAt: await this.knownLastAccess(),
|
|
2723
3721
|
indexingInFlight: this.historyIndexer?.inFlightCount ?? 0,
|
|
2724
3722
|
activeTurns: this.aborts.size,
|
|
2725
3723
|
pendingTimers: timerTrackerInstalled()
|
|
@@ -2754,6 +3752,49 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2754
3752
|
};
|
|
2755
3753
|
}
|
|
2756
3754
|
|
|
3755
|
+
async topicDeliverable(
|
|
3756
|
+
identity: TurnIdentity,
|
|
3757
|
+
operationId: string,
|
|
3758
|
+
command: TopicDeliverableCommand,
|
|
3759
|
+
): Promise<TopicDeliverableResult> {
|
|
3760
|
+
if (this.env.TOPIC_DELIVERABLES_ENABLED !== 'true')
|
|
3761
|
+
return { ok: false, status: 404, message: 'Not found.' };
|
|
3762
|
+
TopicOperationId.parse(operationId);
|
|
3763
|
+
// Like every request: waits out an idle eviction in progress and
|
|
3764
|
+
// records the access, so the idle tick re-reads this as activity.
|
|
3765
|
+
await this.ready(identity);
|
|
3766
|
+
const scheduler = this.taskScheduler;
|
|
3767
|
+
const db = this.db;
|
|
3768
|
+
if (!scheduler || !db) throw new Error('Task scheduler unavailable');
|
|
3769
|
+
// Start and cancel write the task row outside any turn; nothing else
|
|
3770
|
+
// would schedule their upload (and an unmarked write keeps the owner
|
|
3771
|
+
// copy "not current", which blocks eviction for good). A write that
|
|
3772
|
+
// committed before a later step threw (the alarm arm) counts too.
|
|
3773
|
+
const execute = (): Promise<TopicDeliverableResult> => {
|
|
3774
|
+
switch (command.action) {
|
|
3775
|
+
case 'start':
|
|
3776
|
+
return scheduler.startTopicDeliverable(
|
|
3777
|
+
operationId,
|
|
3778
|
+
TopicDeliverableRequestSchema.parse(command.request),
|
|
3779
|
+
);
|
|
3780
|
+
case 'read':
|
|
3781
|
+
return scheduler.readTopicDeliverable(operationId);
|
|
3782
|
+
case 'cancel':
|
|
3783
|
+
return scheduler.cancelTopicDeliverable(
|
|
3784
|
+
operationId,
|
|
3785
|
+
TopicDeliverableRequestSchema.parse(command.request),
|
|
3786
|
+
);
|
|
3787
|
+
}
|
|
3788
|
+
};
|
|
3789
|
+
const generation = db.writeGeneration;
|
|
3790
|
+
try {
|
|
3791
|
+
return await execute();
|
|
3792
|
+
} finally {
|
|
3793
|
+
if (this.db === db && db.writeGeneration !== generation)
|
|
3794
|
+
this.markDirty();
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
|
|
2757
3798
|
async tasksStatus(userDid?: string): Promise<{
|
|
2758
3799
|
now: number;
|
|
2759
3800
|
alarm: number | null;
|
|
@@ -2781,7 +3822,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2781
3822
|
if (!this.taskScheduler) {
|
|
2782
3823
|
const did =
|
|
2783
3824
|
userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
2784
|
-
if (did) await this.ready({ userDid: did });
|
|
3825
|
+
if (did) await this.ready({ userDid: did }, { recordAccess: false });
|
|
2785
3826
|
}
|
|
2786
3827
|
const records = this.taskScheduler
|
|
2787
3828
|
? await this.taskScheduler.surface.list()
|
|
@@ -2831,13 +3872,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2831
3872
|
}
|
|
2832
3873
|
if (auth.ucanDelegation)
|
|
2833
3874
|
headers.set('x-ucan-delegation', auth.ucanDelegation);
|
|
2834
|
-
return authenticate(headers,
|
|
2835
|
-
oracleDid: this.env.ORACLE_DID,
|
|
2836
|
-
blocksyncUri: this.env.BLOCKSYNC_GRAPHQL_URL,
|
|
2837
|
-
maxTtlSeconds: this.env.UCAN_AUTH_MAX_TTL_SECONDS
|
|
2838
|
-
? Number(this.env.UCAN_AUTH_MAX_TTL_SECONDS)
|
|
2839
|
-
: undefined,
|
|
2840
|
-
});
|
|
3875
|
+
return authenticate(headers, authConfigFromEnv(this.env));
|
|
2841
3876
|
},
|
|
2842
3877
|
sessionExists: async (userDid, sessionId) => {
|
|
2843
3878
|
await this.ready({ userDid });
|
|
@@ -2845,7 +3880,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2845
3880
|
},
|
|
2846
3881
|
router: this.events,
|
|
2847
3882
|
logger: console,
|
|
2848
|
-
requestAlarm: (at) => this.requestAlarm(at),
|
|
3883
|
+
requestAlarm: (at) => this.ctx.waitUntil(this.requestAlarm(at)),
|
|
2849
3884
|
// Node indexes a session into the memory engine when its last socket
|
|
2850
3885
|
// disconnects (`WsService.removeClientConnection`); same trigger here.
|
|
2851
3886
|
onSessionDrained: (sessionId) =>
|
|
@@ -2975,10 +4010,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2975
4010
|
};
|
|
2976
4011
|
const stream = body.stream !== false;
|
|
2977
4012
|
if (!stream) {
|
|
2978
|
-
this.replayToRoom(req, body.message, 'user');
|
|
4013
|
+
void this.replayToRoom(req, body.message, 'user');
|
|
2979
4014
|
try {
|
|
2980
4015
|
const result = await this.runTurn(req);
|
|
2981
|
-
this.replayToRoom(req, result.text, 'oracle');
|
|
4016
|
+
void this.replayToRoom(req, result.text, 'oracle');
|
|
2982
4017
|
// Node's `SendMessageResponse.message` is `{ type, content, id }`.
|
|
2983
4018
|
const payload: Record<string, unknown> = {
|
|
2984
4019
|
message: {
|
|
@@ -3011,13 +4046,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3011
4046
|
// A streamed turn is a durable run: recorded, kept alive and resumable
|
|
3012
4047
|
// (docs/plans/durable-runs.md). The response is one subscriber of the
|
|
3013
4048
|
// run's buffer; the run itself outlives it.
|
|
3014
|
-
this.replayToRoom(req, body.message, 'user');
|
|
4049
|
+
void this.replayToRoom(req, body.message, 'user');
|
|
3015
4050
|
const { live, queued } = await this.runs!.begin({
|
|
3016
4051
|
runId: crypto.randomUUID(),
|
|
3017
4052
|
sessionId,
|
|
3018
4053
|
requestId,
|
|
3019
4054
|
client: 'portal',
|
|
3020
|
-
request:
|
|
4055
|
+
request: this.runRequestJson(
|
|
4056
|
+
req,
|
|
3021
4057
|
storedRunRequest(req, {
|
|
3022
4058
|
timezone: body.timezone,
|
|
3023
4059
|
tools: body.tools,
|
|
@@ -3112,6 +4148,23 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3112
4148
|
});
|
|
3113
4149
|
}
|
|
3114
4150
|
|
|
4151
|
+
/**
|
|
4152
|
+
* The stored request of a new run. When no admission handler can see the
|
|
4153
|
+
* turn (no plugin declares one, or the turn is out of admission's scope)
|
|
4154
|
+
* it is recorded as an agent turn from the start, which spares the first
|
|
4155
|
+
* attempt both rewrites of the row (`admitting`, then `agent`). Recovery
|
|
4156
|
+
* stays correct: a run the graph never checkpointed is retried with its
|
|
4157
|
+
* input whatever its disposition.
|
|
4158
|
+
*/
|
|
4159
|
+
private runRequestJson(req: TurnRequest, stored: StoredRunRequest): string {
|
|
4160
|
+
const admissible =
|
|
4161
|
+
admissionApplies(req) &&
|
|
4162
|
+
this.core.plugins.some((plugin) => plugin.getRequestAdmission);
|
|
4163
|
+
return JSON.stringify(
|
|
4164
|
+
admissible ? stored : { ...stored, disposition: { kind: 'agent' } },
|
|
4165
|
+
);
|
|
4166
|
+
}
|
|
4167
|
+
|
|
3115
4168
|
/**
|
|
3116
4169
|
* One attempt of a run (the coordinator's `runAttempt` host hook): build
|
|
3117
4170
|
* the agent for the stored request, stream the turn's frames into the
|
|
@@ -3125,6 +4178,101 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3125
4178
|
): Promise<RunOutcome> {
|
|
3126
4179
|
const stored = JSON.parse(live.record.request) as StoredRunRequest;
|
|
3127
4180
|
const req = stored.turn;
|
|
4181
|
+
if (req.client === 'channel') {
|
|
4182
|
+
await this.ctx.storage.sync();
|
|
4183
|
+
// The binding was checked as the request was admitted; a queued or
|
|
4184
|
+
// recovered attempt starts later and checks it again.
|
|
4185
|
+
if (live.attemptSource !== 'begin')
|
|
4186
|
+
await assertChannelAttemptAllowed(req.identity, this.env);
|
|
4187
|
+
requireChannelDelegation(
|
|
4188
|
+
this.delegations.get(req.identity.userDid),
|
|
4189
|
+
Math.floor(Date.now() / 1000),
|
|
4190
|
+
);
|
|
4191
|
+
}
|
|
4192
|
+
// An attempt that starts from `admitting` never reached the agent (the
|
|
4193
|
+
// previous incarnation stopped during admission), so the user's message
|
|
4194
|
+
// is not in the graph: the agent runs with its input, not from the
|
|
4195
|
+
// checkpoint.
|
|
4196
|
+
const freshInput = !resumed || stored.disposition?.kind === 'admitting';
|
|
4197
|
+
let announced = resumed;
|
|
4198
|
+
const announce = (): void => {
|
|
4199
|
+
if (announced) return;
|
|
4200
|
+
announced = true;
|
|
4201
|
+
live.buffer.push('run', {
|
|
4202
|
+
runId: live.runId,
|
|
4203
|
+
sessionId: req.sessionId,
|
|
4204
|
+
requestId: req.requestId,
|
|
4205
|
+
});
|
|
4206
|
+
};
|
|
4207
|
+
if (!stored.disposition && !resumed) {
|
|
4208
|
+
stored.disposition = { kind: 'admitting' };
|
|
4209
|
+
const request = JSON.stringify(stored);
|
|
4210
|
+
await this.runStore!.update(live.runId, { request });
|
|
4211
|
+
live.record.request = request;
|
|
4212
|
+
}
|
|
4213
|
+
if (!stored.disposition || stored.disposition.kind === 'admitting') {
|
|
4214
|
+
let admission: RequestAdmissionResult = { kind: 'pass' };
|
|
4215
|
+
try {
|
|
4216
|
+
if (stored.disposition?.kind === 'admitting' && admissionApplies(req))
|
|
4217
|
+
admission = await admitRequest(
|
|
4218
|
+
this.core.plugins,
|
|
4219
|
+
{
|
|
4220
|
+
user: {
|
|
4221
|
+
did: req.identity.userDid,
|
|
4222
|
+
matrixUserId: req.identity.matrixUserId ?? '',
|
|
4223
|
+
ucanDelegation: { raw: req.identity.ucanDelegation ?? '' },
|
|
4224
|
+
timezone: req.identity.timezone,
|
|
4225
|
+
currentTime: new Date().toISOString(),
|
|
4226
|
+
},
|
|
4227
|
+
session: {
|
|
4228
|
+
id: req.sessionId,
|
|
4229
|
+
client: req.client,
|
|
4230
|
+
requestId: req.requestId,
|
|
4231
|
+
roomId:
|
|
4232
|
+
req.roomId ??
|
|
4233
|
+
(await this.sessions!.getSession(req.sessionId))?.roomId,
|
|
4234
|
+
...(req.roomKind ? { roomKind: req.roomKind } : {}),
|
|
4235
|
+
...(req.eventId ? { eventId: req.eventId } : {}),
|
|
4236
|
+
...(req.threadId ? { threadId: req.threadId } : {}),
|
|
4237
|
+
},
|
|
4238
|
+
message: req.message,
|
|
4239
|
+
metadata: admissionMetadata(req.metadata),
|
|
4240
|
+
signal: live.abort.signal,
|
|
4241
|
+
},
|
|
4242
|
+
{
|
|
4243
|
+
env: this.core.validatedEnv,
|
|
4244
|
+
timeoutMs: admissionTimeoutMs(this.core.validatedEnv),
|
|
4245
|
+
warn: (m) => console.warn(`[user-do] ${m}`),
|
|
4246
|
+
},
|
|
4247
|
+
);
|
|
4248
|
+
} catch (error) {
|
|
4249
|
+
return this.refuseTurn(live, req, error, announce, 'admission');
|
|
4250
|
+
}
|
|
4251
|
+
stored.disposition =
|
|
4252
|
+
admission.kind === 'handled'
|
|
4253
|
+
? {
|
|
4254
|
+
kind: 'direct-read',
|
|
4255
|
+
text: admission.text,
|
|
4256
|
+
title: admission.title,
|
|
4257
|
+
messageId: `direct:${req.sessionId}:${req.requestId}:ai`,
|
|
4258
|
+
}
|
|
4259
|
+
: { kind: 'agent' };
|
|
4260
|
+
const request = JSON.stringify(stored);
|
|
4261
|
+
await this.runStore!.update(live.runId, { request });
|
|
4262
|
+
live.record.request = request;
|
|
4263
|
+
}
|
|
4264
|
+
if (stored.disposition.kind === 'direct-read') {
|
|
4265
|
+
try {
|
|
4266
|
+
return await this.runDirectRead(
|
|
4267
|
+
live,
|
|
4268
|
+
req,
|
|
4269
|
+
stored.disposition,
|
|
4270
|
+
announce,
|
|
4271
|
+
);
|
|
4272
|
+
} catch (error) {
|
|
4273
|
+
return this.refuseTurn(live, req, error, announce, 'direct read');
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
3128
4276
|
const {
|
|
3129
4277
|
agent,
|
|
3130
4278
|
stateInput,
|
|
@@ -3133,6 +4281,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3133
4281
|
byoNotice,
|
|
3134
4282
|
byoProvider,
|
|
3135
4283
|
toolOutputCapChars,
|
|
4284
|
+
delivery,
|
|
4285
|
+
usage,
|
|
3136
4286
|
} = await this.prepareTurn(
|
|
3137
4287
|
req,
|
|
3138
4288
|
{
|
|
@@ -3141,13 +4291,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3141
4291
|
model: req.model,
|
|
3142
4292
|
tools: stored.tools,
|
|
3143
4293
|
agActions: stored.agActions,
|
|
3144
|
-
...(
|
|
4294
|
+
...(freshInput ? { attachments: req.attachments } : {}),
|
|
3145
4295
|
},
|
|
3146
4296
|
{
|
|
3147
4297
|
runId: live.runId,
|
|
3148
4298
|
abortController: live.abort,
|
|
3149
|
-
resumed,
|
|
3150
|
-
continuation:
|
|
4299
|
+
resumed: !freshInput,
|
|
4300
|
+
continuation: freshInput ? null : live.continuation,
|
|
3151
4301
|
},
|
|
3152
4302
|
);
|
|
3153
4303
|
const sessionId = req.sessionId;
|
|
@@ -3157,20 +4307,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3157
4307
|
live.buffer.push(eventName, payload);
|
|
3158
4308
|
},
|
|
3159
4309
|
};
|
|
3160
|
-
this.events.register(sessionId, sink);
|
|
3161
|
-
if (byoNotice)
|
|
3162
|
-
sink.emit('error', {
|
|
3163
|
-
...byoNotice,
|
|
3164
|
-
sessionId,
|
|
3165
|
-
requestId: req.requestId,
|
|
3166
|
-
});
|
|
3167
|
-
const events = agent.streamEvents(resumed ? null : stateInput, {
|
|
3168
|
-
...config,
|
|
3169
|
-
version: 'v2',
|
|
3170
|
-
});
|
|
3171
4310
|
const capture: BaseMessage[] = [];
|
|
3172
4311
|
let completed = false;
|
|
4312
|
+
// Everything after `prepareTurn` runs inside the try: its cleanups
|
|
4313
|
+
// (the turn's deadline timer among them) run however the attempt ends.
|
|
3173
4314
|
try {
|
|
4315
|
+
this.events.register(sessionId, sink);
|
|
4316
|
+
if (byoNotice)
|
|
4317
|
+
sink.emit('error', {
|
|
4318
|
+
...byoNotice,
|
|
4319
|
+
sessionId,
|
|
4320
|
+
requestId: req.requestId,
|
|
4321
|
+
});
|
|
4322
|
+
const events = agent.streamEvents(freshInput ? stateInput : null, {
|
|
4323
|
+
...config,
|
|
4324
|
+
version: 'v2',
|
|
4325
|
+
});
|
|
3174
4326
|
const outcome = await runTurnFrames({
|
|
3175
4327
|
events: tapMessages(events, capture),
|
|
3176
4328
|
sink: live.buffer,
|
|
@@ -3188,8 +4340,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3188
4340
|
onFrame: () => this.runs?.touchKeepAlive(),
|
|
3189
4341
|
onComplete: async () => {
|
|
3190
4342
|
completed = true;
|
|
3191
|
-
|
|
3192
|
-
|
|
4343
|
+
if (req.client !== 'channel')
|
|
4344
|
+
void this.replayToRoom(req, lastAiText(capture), 'oracle');
|
|
4345
|
+
await this.afterTurn(
|
|
4346
|
+
sessionId,
|
|
4347
|
+
capture,
|
|
4348
|
+
{ kind: 'agent' },
|
|
4349
|
+
live.abort.signal,
|
|
4350
|
+
);
|
|
3193
4351
|
},
|
|
3194
4352
|
onError: (err) => {
|
|
3195
4353
|
console.error(`[user-do] turn ${req.requestId} failed:`, err);
|
|
@@ -3204,8 +4362,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3204
4362
|
status: 'done' as const,
|
|
3205
4363
|
})),
|
|
3206
4364
|
);
|
|
3207
|
-
const text = completed ? lastAiText(capture) : outcome.fullText;
|
|
3208
4365
|
const messageId = completed ? lastAiMessageId(capture) : undefined;
|
|
4366
|
+
// A chat surface also gets its reply as a plan. `text` stays the
|
|
4367
|
+
// model's own text on every surface: stored results (task runs, the
|
|
4368
|
+
// run record, the Matrix ledger) never hold a chat rendering whose
|
|
4369
|
+
// links expire.
|
|
4370
|
+
const plan =
|
|
4371
|
+
completed && delivery.kind === 'chat'
|
|
4372
|
+
? await this.replyPlanOf(live, req, capture, delivery)
|
|
4373
|
+
: undefined;
|
|
4374
|
+
const text =
|
|
4375
|
+
(live.continuation ?? '') +
|
|
4376
|
+
(completed ? lastAiText(capture) : outcome.fullText);
|
|
3209
4377
|
return {
|
|
3210
4378
|
status:
|
|
3211
4379
|
outcome.status === 'completed'
|
|
@@ -3213,10 +4381,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3213
4381
|
: outcome.status === 'aborted'
|
|
3214
4382
|
? 'aborted'
|
|
3215
4383
|
: 'failed',
|
|
3216
|
-
text
|
|
4384
|
+
text,
|
|
4385
|
+
...(plan ? { plan } : {}),
|
|
3217
4386
|
...(messageId ? { messageId } : {}),
|
|
3218
4387
|
toolCalls,
|
|
3219
4388
|
...(outcome.status === 'failed' ? { error: outcome.error } : {}),
|
|
4389
|
+
// Written with the terminal status: one row update per run.
|
|
4390
|
+
usage: usage(),
|
|
3220
4391
|
};
|
|
3221
4392
|
} finally {
|
|
3222
4393
|
this.events.unregister(sessionId, sink);
|
|
@@ -3226,6 +4397,178 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3226
4397
|
}
|
|
3227
4398
|
}
|
|
3228
4399
|
|
|
4400
|
+
/**
|
|
4401
|
+
* The Reply Plan of a finished chat-surface run, built once from the
|
|
4402
|
+
* turn's messages and kept with the run (`/channels/turn` polls read it
|
|
4403
|
+
* back). A long step becomes an artefact when storage is configured; an
|
|
4404
|
+
* artefact that fails falls back to plain messages.
|
|
4405
|
+
*/
|
|
4406
|
+
private async replyPlanOf(
|
|
4407
|
+
live: LiveRun,
|
|
4408
|
+
req: TurnRequest,
|
|
4409
|
+
capture: BaseMessage[],
|
|
4410
|
+
delivery: Extract<DeliveryProfile, { kind: 'chat' }>,
|
|
4411
|
+
): Promise<ReplyPlan> {
|
|
4412
|
+
const artifacts = this.artifacts?.canShare ? this.artifacts : null;
|
|
4413
|
+
const { steps, toolResults } = turnSteps(capture);
|
|
4414
|
+
const draft = draftReplyPlan({
|
|
4415
|
+
steps,
|
|
4416
|
+
toolResults,
|
|
4417
|
+
continuation: live.continuation,
|
|
4418
|
+
limits: delivery.limits,
|
|
4419
|
+
canSpill: artifacts !== null,
|
|
4420
|
+
});
|
|
4421
|
+
const plan = await materializeReplyPlan(draft, {
|
|
4422
|
+
limits: delivery.limits,
|
|
4423
|
+
createSpill: async (key, spill) => {
|
|
4424
|
+
if (!artifacts)
|
|
4425
|
+
throw new Error('artifacts: storage is not configured');
|
|
4426
|
+
return artifacts.create({
|
|
4427
|
+
artifactId: await artifactIdFor(live.runId, `spill:${key}`),
|
|
4428
|
+
sessionId: req.sessionId,
|
|
4429
|
+
runId: live.runId,
|
|
4430
|
+
title: spill.title,
|
|
4431
|
+
content: spill.markdown,
|
|
4432
|
+
});
|
|
4433
|
+
},
|
|
4434
|
+
onSpillError: (error) =>
|
|
4435
|
+
console.warn(
|
|
4436
|
+
`[delivery] run ${live.runId}: could not store a long reply as an artefact; sending it as messages: ${error instanceof Error ? error.message : String(error)}`,
|
|
4437
|
+
),
|
|
4438
|
+
});
|
|
4439
|
+
await this.runStore!.setPlan(live.runId, JSON.stringify(plan));
|
|
4440
|
+
console.log(
|
|
4441
|
+
`[delivery] run ${live.runId}: ${delivery.surface} reply in ${plan.parts.length} parts (${plan.parts.filter((p) => p.kind === 'artifact').length} artefacts)`,
|
|
4442
|
+
);
|
|
4443
|
+
return plan;
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4446
|
+
private async runDirectRead(
|
|
4447
|
+
live: LiveRun,
|
|
4448
|
+
req: TurnRequest,
|
|
4449
|
+
disposition: Extract<RequestDisposition, { kind: 'direct-read' }>,
|
|
4450
|
+
announce: () => void,
|
|
4451
|
+
): Promise<RunOutcome> {
|
|
4452
|
+
live.abort.signal.throwIfAborted();
|
|
4453
|
+
announce();
|
|
4454
|
+
const sessions = this.sessions!;
|
|
4455
|
+
if (
|
|
4456
|
+
req.client === 'matrix' &&
|
|
4457
|
+
!(await sessions.getSession(req.sessionId))
|
|
4458
|
+
) {
|
|
4459
|
+
await sessions.createSession({
|
|
4460
|
+
sessionId: req.sessionId,
|
|
4461
|
+
roomId: req.roomId,
|
|
4462
|
+
oracleName: this.core.identity.name,
|
|
4463
|
+
oracleDid: this.env.ORACLE_DID,
|
|
4464
|
+
oracleEntityDid: this.core.identity.entityDid,
|
|
4465
|
+
});
|
|
4466
|
+
}
|
|
4467
|
+
const kwargs = {
|
|
4468
|
+
timestamp: new Date().toISOString(),
|
|
4469
|
+
oracleName: this.core.identity.name,
|
|
4470
|
+
msgFromMatrixRoom: req.client === 'matrix',
|
|
4471
|
+
...(req.client === 'matrix'
|
|
4472
|
+
? {
|
|
4473
|
+
senderDid: req.identity.userDid,
|
|
4474
|
+
senderMatrixUserId: req.identity.matrixUserId,
|
|
4475
|
+
senderDisplayName: req.senderDisplayName ?? req.identity.userDid,
|
|
4476
|
+
threadId: req.sessionId,
|
|
4477
|
+
eventId: req.eventId,
|
|
4478
|
+
}
|
|
4479
|
+
: {}),
|
|
4480
|
+
};
|
|
4481
|
+
const messages = [
|
|
4482
|
+
new HumanMessage({
|
|
4483
|
+
id: `direct:${req.sessionId}:${req.requestId}:human`,
|
|
4484
|
+
content:
|
|
4485
|
+
req.client === 'matrix' && req.roomKind === 'group'
|
|
4486
|
+
? prefixSpeaker(
|
|
4487
|
+
req.message,
|
|
4488
|
+
req.senderDisplayName ??
|
|
4489
|
+
req.identity.matrixUserId ??
|
|
4490
|
+
req.identity.userDid,
|
|
4491
|
+
)
|
|
4492
|
+
: req.message,
|
|
4493
|
+
additional_kwargs: kwargs,
|
|
4494
|
+
}),
|
|
4495
|
+
new AIMessage({
|
|
4496
|
+
id: disposition.messageId,
|
|
4497
|
+
content: disposition.text,
|
|
4498
|
+
additional_kwargs: {
|
|
4499
|
+
timestamp: kwargs.timestamp,
|
|
4500
|
+
oracleName: kwargs.oracleName,
|
|
4501
|
+
msgFromMatrixRoom: kwargs.msgFromMatrixRoom,
|
|
4502
|
+
},
|
|
4503
|
+
}),
|
|
4504
|
+
];
|
|
4505
|
+
await this.saver!.appendTurnMessages(req.sessionId, messages);
|
|
4506
|
+
// An abort that lands now ends the run as `aborted`; the reply stays
|
|
4507
|
+
// in the transcript (the Matrix mirror and the title are skipped).
|
|
4508
|
+
live.abort.signal.throwIfAborted();
|
|
4509
|
+
await this.afterTurn(req.sessionId, messages, disposition);
|
|
4510
|
+
// A channel's reply reaches the room through the channel's own
|
|
4511
|
+
// delivery (`onRunEnded`), as on the agent path; its mirror is the
|
|
4512
|
+
// required kind, whose failure would surface here unhandled.
|
|
4513
|
+
if (req.client !== 'channel')
|
|
4514
|
+
void this.replayToRoom(req, disposition.text, 'oracle');
|
|
4515
|
+
const delivered = live.continuation ?? '';
|
|
4516
|
+
const content = disposition.text.startsWith(delivered)
|
|
4517
|
+
? disposition.text.slice(delivered.length)
|
|
4518
|
+
: disposition.text;
|
|
4519
|
+
if (content)
|
|
4520
|
+
live.buffer.push('message', { content, timestamp: kwargs.timestamp });
|
|
4521
|
+
live.buffer.push('done', {
|
|
4522
|
+
runId: live.runId,
|
|
4523
|
+
messageId: disposition.messageId,
|
|
4524
|
+
});
|
|
4525
|
+
return {
|
|
4526
|
+
status: 'finished',
|
|
4527
|
+
text: disposition.text,
|
|
4528
|
+
messageId: disposition.messageId,
|
|
4529
|
+
toolCalls: [],
|
|
4530
|
+
};
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
/**
|
|
4534
|
+
* End a turn whose admission or direct read did not complete. The client
|
|
4535
|
+
* gets the same terminal frames as a failed agent turn (`error`, then
|
|
4536
|
+
* `done`) or, after a user abort or a superseding message, `done` with
|
|
4537
|
+
* `aborted`. The handler's own message goes to the log only.
|
|
4538
|
+
*/
|
|
4539
|
+
private refuseTurn(
|
|
4540
|
+
live: LiveRun,
|
|
4541
|
+
req: TurnRequest,
|
|
4542
|
+
error: unknown,
|
|
4543
|
+
announce: () => void,
|
|
4544
|
+
stage: 'admission' | 'direct read',
|
|
4545
|
+
): RunOutcome {
|
|
4546
|
+
announce();
|
|
4547
|
+
if (live.abort.signal.aborted) {
|
|
4548
|
+
live.buffer.push('done', { runId: live.runId, aborted: true });
|
|
4549
|
+
return { status: 'aborted', text: live.continuation ?? '' };
|
|
4550
|
+
}
|
|
4551
|
+
console.error(
|
|
4552
|
+
`[user-do] turn ${req.requestId}: ${stage} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
4553
|
+
);
|
|
4554
|
+
live.buffer.push('error', {
|
|
4555
|
+
error: 'This request could not be answered. Please try again.',
|
|
4556
|
+
kind: 'request_admission',
|
|
4557
|
+
source: 'platform',
|
|
4558
|
+
retryable: true,
|
|
4559
|
+
sessionId: req.sessionId,
|
|
4560
|
+
requestId: req.requestId,
|
|
4561
|
+
runId: live.runId,
|
|
4562
|
+
timestamp: new Date().toISOString(),
|
|
4563
|
+
});
|
|
4564
|
+
live.buffer.push('done', { runId: live.runId, failed: true });
|
|
4565
|
+
return {
|
|
4566
|
+
status: 'failed',
|
|
4567
|
+
text: live.continuation ?? '',
|
|
4568
|
+
error: new Error(`request ${stage} failed`),
|
|
4569
|
+
};
|
|
4570
|
+
}
|
|
4571
|
+
|
|
3229
4572
|
/** Latest checkpoint id of a session — recovery's progress marker. */
|
|
3230
4573
|
private async checkpointIdOf(sessionId: string): Promise<string | null> {
|
|
3231
4574
|
const tuple = await this.saver
|
|
@@ -3243,6 +4586,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3243
4586
|
record: RunRecord,
|
|
3244
4587
|
outcome: RunOutcome,
|
|
3245
4588
|
): Promise<void> {
|
|
4589
|
+
if (record.client === 'channel' && outcome.status === 'finished') {
|
|
4590
|
+
const stored = JSON.parse(record.request) as StoredRunRequest;
|
|
4591
|
+
// Without the handle (a reset dropped it mid-run) the next poll of
|
|
4592
|
+
// the request delivers the reply instead.
|
|
4593
|
+
await this.channelTurns?.deliverReply(
|
|
4594
|
+
stored.turn,
|
|
4595
|
+
record.runId,
|
|
4596
|
+
outcome.text,
|
|
4597
|
+
);
|
|
4598
|
+
}
|
|
3246
4599
|
if (!record.taskRunId || !this.taskScheduler) return;
|
|
3247
4600
|
if (this.taskScheduler.isRunActive(record.taskRunId)) return;
|
|
3248
4601
|
if (outcome.status === 'finished')
|
|
@@ -3288,7 +4641,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3288
4641
|
const window = await this.contextWindows.resolve(modelId, {
|
|
3289
4642
|
...(byoProvider ? { byoProvider } : {}),
|
|
3290
4643
|
});
|
|
3291
|
-
const budget = contextBudgetFor(
|
|
4644
|
+
const budget = contextBudgetFor(
|
|
4645
|
+
window,
|
|
4646
|
+
contextKnobs(this.env, console),
|
|
4647
|
+
await this.contextWindows.resolve(
|
|
4648
|
+
llm && isProviderAdapter(llm)
|
|
4649
|
+
? llm.modelForRole('routing')
|
|
4650
|
+
: OPENROUTER_MODEL_MAP.routing,
|
|
4651
|
+
),
|
|
4652
|
+
);
|
|
3292
4653
|
const stats = (await this.resultStore?.stats()) ?? {
|
|
3293
4654
|
rows: 0,
|
|
3294
4655
|
sqliteBytes: 0,
|
|
@@ -3403,7 +4764,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3403
4764
|
async runsStatus(): Promise<RunsStatus> {
|
|
3404
4765
|
const userDid =
|
|
3405
4766
|
this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
3406
|
-
if (!this.db && userDid)
|
|
4767
|
+
if (!this.db && userDid)
|
|
4768
|
+
await this.ready({ userDid }, { recordAccess: false });
|
|
3407
4769
|
const store = this.runStore;
|
|
3408
4770
|
const records = store ? await store.listRecent(50) : [];
|
|
3409
4771
|
const runs: RunsStatus['runs'] = [];
|
|
@@ -3425,12 +4787,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3425
4787
|
nextAttemptAt: record.nextAttemptAt,
|
|
3426
4788
|
error: record.error,
|
|
3427
4789
|
taskRunId: record.taskRunId,
|
|
4790
|
+
usage: parseUsage(record.usage),
|
|
3428
4791
|
});
|
|
3429
4792
|
}
|
|
3430
4793
|
return {
|
|
3431
4794
|
config: { ...this.runConfig },
|
|
3432
4795
|
live: this.runs?.snapshot() ?? [],
|
|
3433
4796
|
runs,
|
|
4797
|
+
claims: store ? await store.listClaims() : [],
|
|
3434
4798
|
};
|
|
3435
4799
|
}
|
|
3436
4800
|
|
|
@@ -3443,25 +4807,39 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3443
4807
|
* the gateway (its whole-buffer download grew the crypto WASM heap for
|
|
3444
4808
|
* good, and a hash mismatch on the far side of the RPC would only reach
|
|
3445
4809
|
* this object as a disconnect).
|
|
4810
|
+
*
|
|
4811
|
+
* The gateway bounds each transfer to the attachment download deadline,
|
|
4812
|
+
* from opening it to the last byte. An `AbortSignal` cannot cross the
|
|
4813
|
+
* Durable Object RPC, so the turn's abort acts on this side instead:
|
|
4814
|
+
* `readBytesCapped` cancels the stream it reads, and the cancel travels
|
|
4815
|
+
* back over the RPC stream to stop the gateway's download.
|
|
3446
4816
|
*/
|
|
3447
4817
|
private matrixMediaSource(): MatrixMediaSource {
|
|
4818
|
+
const deadline = { timeoutMs: DOWNLOAD_TIMEOUT_MS };
|
|
3448
4819
|
return {
|
|
3449
|
-
downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE) =>
|
|
4820
|
+
downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE, signal) =>
|
|
3450
4821
|
readBytesCapped(
|
|
3451
|
-
await this.gateway.downloadMxcMediaStream(mxc),
|
|
4822
|
+
await this.gateway.downloadMxcMediaStream(mxc, deadline),
|
|
3452
4823
|
maxBytes,
|
|
4824
|
+
signal,
|
|
3453
4825
|
),
|
|
3454
|
-
downloadEvent: async (
|
|
4826
|
+
downloadEvent: async (
|
|
4827
|
+
roomId,
|
|
4828
|
+
eventId,
|
|
4829
|
+
maxBytes = MAX_FILE_SIZE,
|
|
4830
|
+
signal,
|
|
4831
|
+
) => {
|
|
3455
4832
|
const media = await this.gateway.downloadEventMediaStream(
|
|
3456
4833
|
roomId,
|
|
3457
4834
|
eventId,
|
|
4835
|
+
deadline,
|
|
3458
4836
|
);
|
|
3459
4837
|
if (!media) return null;
|
|
3460
4838
|
const plain = media.file
|
|
3461
4839
|
? media.stream.pipeThrough(createAttachmentDecryptor(media.file))
|
|
3462
4840
|
: media.stream;
|
|
3463
4841
|
return {
|
|
3464
|
-
bytes: await readBytesCapped(plain, maxBytes),
|
|
4842
|
+
bytes: await readBytesCapped(plain, maxBytes, signal),
|
|
3465
4843
|
...(media.mimetype ? { mimetype: media.mimetype } : {}),
|
|
3466
4844
|
...(media.filename ? { filename: media.filename } : {}),
|
|
3467
4845
|
};
|
|
@@ -3523,6 +4901,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3523
4901
|
roomId: input.roomId,
|
|
3524
4902
|
signal: input.signal,
|
|
3525
4903
|
logger: console,
|
|
4904
|
+
...(this.attachmentTextCache
|
|
4905
|
+
? { cache: this.attachmentTextCache.forSession(input.sessionId) }
|
|
4906
|
+
: {}),
|
|
3526
4907
|
});
|
|
3527
4908
|
return { meta, view };
|
|
3528
4909
|
},
|
|
@@ -3626,23 +5007,73 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3626
5007
|
};
|
|
3627
5008
|
}
|
|
3628
5009
|
|
|
5010
|
+
/**
|
|
5011
|
+
* The model that summarises the turn's history (the `routing` role): its
|
|
5012
|
+
* window bounds what the summariser is handed.
|
|
5013
|
+
*/
|
|
5014
|
+
private summaryModelId(
|
|
5015
|
+
platform: LlmAdapter,
|
|
5016
|
+
byoTurn: ByoTurnState | null,
|
|
5017
|
+
): string {
|
|
5018
|
+
if (byoTurn)
|
|
5019
|
+
return (
|
|
5020
|
+
byoModelForRole(
|
|
5021
|
+
byoTurn.credential.provider,
|
|
5022
|
+
'routing',
|
|
5023
|
+
byoTurn.mainModelId,
|
|
5024
|
+
) ?? OPENROUTER_MODEL_MAP.routing
|
|
5025
|
+
);
|
|
5026
|
+
return isProviderAdapter(platform)
|
|
5027
|
+
? platform.modelForRole('routing')
|
|
5028
|
+
: OPENROUTER_MODEL_MAP.routing;
|
|
5029
|
+
}
|
|
5030
|
+
|
|
5031
|
+
/**
|
|
5032
|
+
* Build one attempt's agent and input (`buildTurn`). A build that fails
|
|
5033
|
+
* part-way leaves nothing behind: the cleanups it registered run (the
|
|
5034
|
+
* turn's deadline timer would otherwise keep the object resident for
|
|
5035
|
+
* the whole turn limit and abort a finished run later) and the session's
|
|
5036
|
+
* abort entry is released.
|
|
5037
|
+
*/
|
|
3629
5038
|
private async prepareTurn(
|
|
3630
5039
|
req: TurnRequest,
|
|
3631
|
-
body:
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
}
|
|
5040
|
+
body: TurnBuildBody,
|
|
5041
|
+
run: TurnBuildRun,
|
|
5042
|
+
) {
|
|
5043
|
+
const turnDisposables = new Set<() => void | Promise<void>>();
|
|
5044
|
+
try {
|
|
5045
|
+
return await this.buildTurn(req, body, run, turnDisposables);
|
|
5046
|
+
} catch (err) {
|
|
5047
|
+
await this.runTurnDisposables(turnDisposables);
|
|
5048
|
+
if (this.aborts.get(req.sessionId) === run.abortController)
|
|
5049
|
+
this.aborts.delete(req.sessionId);
|
|
5050
|
+
throw err;
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
|
|
5054
|
+
private async buildTurn(
|
|
5055
|
+
req: TurnRequest,
|
|
5056
|
+
body: TurnBuildBody,
|
|
5057
|
+
run: TurnBuildRun,
|
|
5058
|
+
turnDisposables: Set<() => void | Promise<void>>,
|
|
3643
5059
|
) {
|
|
5060
|
+
if (req.executionProfile && !this.taskScheduler)
|
|
5061
|
+
throw new Error('Task scheduler unavailable');
|
|
5062
|
+
await this.taskScheduler?.assertTurnProfile(req);
|
|
5063
|
+
const suppliedContextOnly =
|
|
5064
|
+
req.executionProfile === 'supplied-context-markdown';
|
|
5065
|
+
if (
|
|
5066
|
+
suppliedContextOnly &&
|
|
5067
|
+
(body.attachments?.length ||
|
|
5068
|
+
body.tools?.length ||
|
|
5069
|
+
body.agActions?.length)
|
|
5070
|
+
) {
|
|
5071
|
+
throw new Error('Supplied-context task accepts plain text only');
|
|
5072
|
+
}
|
|
3644
5073
|
const core = this.core;
|
|
3645
5074
|
const baseAmbient = this.ambient!;
|
|
5075
|
+
// One instant per turn: the prompt's date and the message's time note.
|
|
5076
|
+
const turnAt = new Date();
|
|
3646
5077
|
const saver = this.saver!;
|
|
3647
5078
|
const sessions = this.sessions!;
|
|
3648
5079
|
|
|
@@ -3729,6 +5160,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3729
5160
|
const contextBudget = contextBudgetFor(
|
|
3730
5161
|
windowResolution,
|
|
3731
5162
|
contextKnobs(this.env, console),
|
|
5163
|
+
await this.contextWindows.resolve(
|
|
5164
|
+
this.summaryModelId(baseAmbient.llm, byoTurn),
|
|
5165
|
+
byoTurn ? { byoProvider: byoTurn.provider } : {},
|
|
5166
|
+
),
|
|
3732
5167
|
);
|
|
3733
5168
|
if (
|
|
3734
5169
|
req.client === 'matrix' &&
|
|
@@ -3753,15 +5188,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3753
5188
|
// memory engine rejects calls that carry no `x-room-id` (as a generic
|
|
3754
5189
|
// "invalid token"). Matrix-ingress turns bring the room; HTTP turns
|
|
3755
5190
|
// take it from the session row or resolve the user↔oracle room alias.
|
|
3756
|
-
const sessionRoomId =
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
.
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
5191
|
+
const sessionRoomId = suppliedContextOnly
|
|
5192
|
+
? undefined
|
|
5193
|
+
: (req.roomId ??
|
|
5194
|
+
(await sessions.getSession(req.sessionId))?.roomId ??
|
|
5195
|
+
(
|
|
5196
|
+
await this.gateway
|
|
5197
|
+
.resolveUserRoom(req.identity.userDid)
|
|
5198
|
+
.catch(() => null)
|
|
5199
|
+
)?.roomId);
|
|
5200
|
+
if (!suppliedContextOnly && !sessionRoomId) {
|
|
3765
5201
|
console.warn(
|
|
3766
5202
|
`[user-do] no oracle room resolved for ${req.identity.userDid}; room-scoped plugins (memory) will be unavailable this turn`,
|
|
3767
5203
|
);
|
|
@@ -3784,7 +5220,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3784
5220
|
// on turn 1, exactly like the Node AgentBuilder. Best-effort: a
|
|
3785
5221
|
// preferences read failure never fails the turn.
|
|
3786
5222
|
const userPreferences =
|
|
3787
|
-
sessionRoomId && this.preferences
|
|
5223
|
+
!suppliedContextOnly && sessionRoomId && this.preferences
|
|
3788
5224
|
? await this.preferences.get(sessionRoomId).catch((err: unknown) => {
|
|
3789
5225
|
console.warn(
|
|
3790
5226
|
`[user-do] could not load user preferences for ${sessionRoomId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -3834,18 +5270,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3834
5270
|
// `retainAttachmentPayloads`); the tool fetches one again on demand.
|
|
3835
5271
|
// Cleanups registered by tools for THIS turn (MCP clients …): run once
|
|
3836
5272
|
// the turn ends, however it ends — see `RuntimeContext.onTurnEnd`.
|
|
3837
|
-
const turnDisposables = new Set<() => void | Promise<void>>();
|
|
3838
5273
|
abortController.signal.addEventListener('abort', () => {
|
|
3839
5274
|
void this.runTurnDisposables(turnDisposables);
|
|
3840
5275
|
});
|
|
3841
|
-
const attachmentAccess =
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
5276
|
+
const attachmentAccess = suppliedContextOnly
|
|
5277
|
+
? undefined
|
|
5278
|
+
: await this.attachmentViewSurface({
|
|
5279
|
+
sessionId: req.sessionId,
|
|
5280
|
+
roomId: sessionRoomId,
|
|
5281
|
+
model: attachmentModel,
|
|
5282
|
+
ambientLlm: baseAmbient.llm,
|
|
5283
|
+
platform: core.llm,
|
|
5284
|
+
signal: abortController.signal,
|
|
5285
|
+
});
|
|
3849
5286
|
|
|
3850
5287
|
const existing = await saver.getTupleWithoutMessages({
|
|
3851
5288
|
configurable: { thread_id: req.sessionId },
|
|
@@ -3853,29 +5290,153 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3853
5290
|
const priorState = existing?.checkpoint.channel_values ?? {};
|
|
3854
5291
|
// Request metadata (editor room, space, session run, entity) → state,
|
|
3855
5292
|
// by the Node agent-builder's rules (see turn-metadata.ts).
|
|
3856
|
-
const meta = parseTurnMetadata(
|
|
3857
|
-
|
|
5293
|
+
const meta = parseTurnMetadata(
|
|
5294
|
+
suppliedContextOnly ? undefined : req.metadata,
|
|
5295
|
+
);
|
|
5296
|
+
const priorMeta = priorMetadataState(
|
|
5297
|
+
suppliedContextOnly ? {} : priorState,
|
|
5298
|
+
);
|
|
5299
|
+
|
|
5300
|
+
// Capability router: predict the on-demand plugin this message needs
|
|
5301
|
+
// and preload it for THIS turn only. `on` is awaited here, ahead of the
|
|
5302
|
+
// first model call; `shadow` returns at once and logs its verdict when
|
|
5303
|
+
// it lands. The preload never enters `stateInput` — the checkpointed
|
|
5304
|
+
// `loadedPlugins` channel is written by `load_capability` alone.
|
|
5305
|
+
const priorLoaded: ReadonlySet<string> = new Set(
|
|
5306
|
+
priorMeta.loadedPlugins ?? [],
|
|
5307
|
+
);
|
|
5308
|
+
this.shadowRoutes.delete(req.sessionId);
|
|
5309
|
+
// LangSmith: resolved before the router so its Decision, which runs
|
|
5310
|
+
// ahead of the graph, lands on the turn's tracer under the same
|
|
5311
|
+
// allowlist gate. Metadata is attached unconditionally (inert without a
|
|
5312
|
+
// tracer); the explicit tracer only when this turn is traced (global
|
|
5313
|
+
// switch or per-DID allowlist — see `resolveLangsmithTracing`).
|
|
5314
|
+
// A supplied-context turn is never traced: its source and output stay
|
|
5315
|
+
// off third-party services whatever the tracing switches say.
|
|
5316
|
+
const tracing: LangsmithTracingDecision = suppliedContextOnly
|
|
5317
|
+
? { metadata: {} }
|
|
5318
|
+
: resolveLangsmithTracing({
|
|
5319
|
+
userDid: req.identity.userDid,
|
|
5320
|
+
client: req.client,
|
|
5321
|
+
env: langsmithEnvFromWorkerEnv(this.env),
|
|
5322
|
+
});
|
|
5323
|
+
// The delegation the whole turn reads: the router's candidates here,
|
|
5324
|
+
// every capability check of the build and its tools below.
|
|
5325
|
+
const ucanDelegation = await this.turnDelegation(req.identity);
|
|
5326
|
+
const preloadedPlugins =
|
|
5327
|
+
!suppliedContextOnly && this.capabilityRouter
|
|
5328
|
+
? await this.capabilityRouter({
|
|
5329
|
+
mode: capabilityRouterMode(core.validatedEnv.CAPABILITY_ROUTER),
|
|
5330
|
+
manifests: core.registries.manifests.collect(),
|
|
5331
|
+
loaded: priorLoaded,
|
|
5332
|
+
hidden: () =>
|
|
5333
|
+
bootHiddenPlugins({
|
|
5334
|
+
registries: core.registries,
|
|
5335
|
+
buildCtx: core.buildCtx(),
|
|
5336
|
+
has: (resource, action) =>
|
|
5337
|
+
ambient.ucan.hasCapability(
|
|
5338
|
+
ucanDelegation,
|
|
5339
|
+
resource,
|
|
5340
|
+
action,
|
|
5341
|
+
),
|
|
5342
|
+
}),
|
|
5343
|
+
hasCapability: (resource, action) =>
|
|
5344
|
+
ambient.ucan.hasCapability(ucanDelegation, resource, action),
|
|
5345
|
+
text: body.message,
|
|
5346
|
+
requestId: req.requestId,
|
|
5347
|
+
signal: abortController.signal,
|
|
5348
|
+
trace: {
|
|
5349
|
+
...(tracing.callbacks && { callbacks: tracing.callbacks }),
|
|
5350
|
+
// `thread_id` matches the graph run's, so LangSmith's thread
|
|
5351
|
+
// view groups the router span with the turn.
|
|
5352
|
+
metadata: {
|
|
5353
|
+
...tracing.metadata,
|
|
5354
|
+
thread_id: req.sessionId,
|
|
5355
|
+
request_id: req.requestId,
|
|
5356
|
+
},
|
|
5357
|
+
},
|
|
5358
|
+
onShadowVerdict: (wouldPreload) =>
|
|
5359
|
+
this.shadowRoutes.set(req.sessionId, {
|
|
5360
|
+
requestId: req.requestId,
|
|
5361
|
+
priorLoaded,
|
|
5362
|
+
wouldPreload,
|
|
5363
|
+
}),
|
|
5364
|
+
})
|
|
5365
|
+
: undefined;
|
|
3858
5366
|
|
|
3859
5367
|
// Host page-context / safety-guardrail hooks, resolved against this
|
|
3860
5368
|
// object's ambient services (see `OracleWorkerHooks`).
|
|
3861
|
-
const hostRoomTitle =
|
|
3862
|
-
|
|
5369
|
+
const hostRoomTitle = suppliedContextOnly
|
|
5370
|
+
? undefined
|
|
5371
|
+
: opts.hooks?.getRoomTitle;
|
|
5372
|
+
const hostSafetyModel = suppliedContextOnly
|
|
5373
|
+
? undefined
|
|
5374
|
+
: opts.hooks?.safetyModel;
|
|
3863
5375
|
|
|
3864
5376
|
// The client-declared tool surface: this body's, else the thread's
|
|
3865
5377
|
// checkpointed one (run-request.ts).
|
|
3866
|
-
const surface =
|
|
5378
|
+
const surface = suppliedContextOnly
|
|
5379
|
+
? clientSurfaceFor({}, {})
|
|
5380
|
+
: clientSurfaceFor(body, priorState);
|
|
3867
5381
|
|
|
3868
5382
|
// Write-ahead tool marks + the resume policy (tool-marks.ts). The
|
|
3869
5383
|
// effect map is filled from the build below; the closure reads it at
|
|
3870
5384
|
// call time, so the middleware can be created before the agent.
|
|
3871
5385
|
const toolEffects = new Map<string, ToolEffect>();
|
|
5386
|
+
const subAgentToolNames = new Set<string>();
|
|
5387
|
+
const effectOf = (name: string): ToolEffect =>
|
|
5388
|
+
toolEffects.get(name) ?? toolEffectOf({ name });
|
|
3872
5389
|
const marksMiddleware = createToolMarksMiddleware({
|
|
3873
5390
|
runId: run.runId,
|
|
3874
5391
|
store: this.runStore!,
|
|
3875
|
-
effectOf
|
|
5392
|
+
effectOf,
|
|
3876
5393
|
continuation: run.continuation,
|
|
3877
5394
|
logger: console,
|
|
3878
5395
|
});
|
|
5396
|
+
// The turn's budget (turn-budget.ts): model calls are charged by the
|
|
5397
|
+
// metered adapter every model of this turn comes from, tool calls by
|
|
5398
|
+
// the execution middleware below, and the deadline aborts the run
|
|
5399
|
+
// with the limit as the reason (the stream reports it as a terminal
|
|
5400
|
+
// error, not as a user abort).
|
|
5401
|
+
const turnLimits = turnLimitsFromEnv(core.validatedEnv);
|
|
5402
|
+
const budget = new TurnBudget(turnLimits);
|
|
5403
|
+
const meteredLlm = budgetedLlm(ambient.llm, {
|
|
5404
|
+
budget,
|
|
5405
|
+
outputReserveTokens: contextBudget.outputReserveTokens,
|
|
5406
|
+
signal: abortController.signal,
|
|
5407
|
+
});
|
|
5408
|
+
const deadline = setTimeout(() => {
|
|
5409
|
+
if (abortController.signal.aborted) return;
|
|
5410
|
+
console.warn(
|
|
5411
|
+
`[harness] turn ${req.requestId} reached its deadline (${turnLimits.durationMs} ms); aborting`,
|
|
5412
|
+
);
|
|
5413
|
+
abortController.abort(
|
|
5414
|
+
new HarnessLimitError(
|
|
5415
|
+
'budget_exhausted',
|
|
5416
|
+
'time',
|
|
5417
|
+
'The turn reached its time limit. Completed work is preserved; no further work was started.',
|
|
5418
|
+
),
|
|
5419
|
+
);
|
|
5420
|
+
}, turnLimits.durationMs);
|
|
5421
|
+
turnDisposables.add(async () => {
|
|
5422
|
+
clearTimeout(deadline);
|
|
5423
|
+
const usage = budget.snapshot();
|
|
5424
|
+
console.log(
|
|
5425
|
+
`[harness] turn ${req.requestId} usage: ~${usage.tokens} tokens (${usage.reportedTokens} reported over ${usage.modelCalls} model calls), ${usage.toolAttempts} tool attempts, ${usage.elapsedMs} ms`,
|
|
5426
|
+
);
|
|
5427
|
+
});
|
|
5428
|
+
const laneOf = (name: string): ToolLane =>
|
|
5429
|
+
subAgentToolNames.has(name) ? 'subagent' : effectOf(name);
|
|
5430
|
+
const executionMiddleware = createToolExecutionMiddleware({
|
|
5431
|
+
budget,
|
|
5432
|
+
scheduler: this.toolScheduler,
|
|
5433
|
+
laneOf,
|
|
5434
|
+
runId: run.runId,
|
|
5435
|
+
sessionId: req.sessionId,
|
|
5436
|
+
claims: this.runStore!,
|
|
5437
|
+
signal: abortController.signal,
|
|
5438
|
+
logger: console,
|
|
5439
|
+
});
|
|
3879
5440
|
// Every tool result above the budget's cap is saved whole and handed
|
|
3880
5441
|
// to the model as head + tail + handle (result-cap.ts); applied to
|
|
3881
5442
|
// sub-agents too through `toolMiddlewares`.
|
|
@@ -3892,15 +5453,45 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3892
5453
|
// inside the wrapper (already under the cap by the time it runs).
|
|
3893
5454
|
const capMiddleware = createResultCapMiddleware(resultCap);
|
|
3894
5455
|
|
|
5456
|
+
// Chat delivery: a channel or Matrix turn replies as short messages and
|
|
5457
|
+
// may hand long content to `create_artifact` (bound only when artefact
|
|
5458
|
+
// storage is configured). The Portal streams as it always has.
|
|
5459
|
+
const delivery = resolveDeliveryProfile(req, core.delivery);
|
|
5460
|
+
const artifacts =
|
|
5461
|
+
delivery.kind === 'chat' && this.artifacts?.canShare
|
|
5462
|
+
? this.artifacts
|
|
5463
|
+
: null;
|
|
5464
|
+
const turnTools = artifacts
|
|
5465
|
+
? [
|
|
5466
|
+
{
|
|
5467
|
+
tool: buildCreateArtifactTool(async (input) =>
|
|
5468
|
+
artifacts.create({
|
|
5469
|
+
artifactId: await artifactIdFor(run.runId, input.source),
|
|
5470
|
+
sessionId: req.sessionId,
|
|
5471
|
+
runId: run.runId,
|
|
5472
|
+
title: input.title,
|
|
5473
|
+
content: input.content,
|
|
5474
|
+
}),
|
|
5475
|
+
),
|
|
5476
|
+
returnDirect: true,
|
|
5477
|
+
},
|
|
5478
|
+
]
|
|
5479
|
+
: [];
|
|
5480
|
+
|
|
3895
5481
|
const built = await createMainAgent({
|
|
5482
|
+
executionProfile: req.executionProfile,
|
|
3896
5483
|
registries: core.registries,
|
|
3897
5484
|
identity: core.identity,
|
|
3898
5485
|
config: core.validatedEnv,
|
|
3899
5486
|
availablePlugins: core.availablePlugins,
|
|
5487
|
+
preloadedPlugins,
|
|
3900
5488
|
byoProvider: byoTurn?.provider,
|
|
3901
5489
|
contextBudget,
|
|
5490
|
+
turnBudget: budget,
|
|
5491
|
+
delivery,
|
|
3902
5492
|
ambient: {
|
|
3903
5493
|
...ambient,
|
|
5494
|
+
llm: meteredLlm,
|
|
3904
5495
|
attachments: attachmentAccess,
|
|
3905
5496
|
onTurnEnd: (dispose) => turnDisposables.add(dispose),
|
|
3906
5497
|
},
|
|
@@ -3913,7 +5504,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3913
5504
|
// Applied to the main agent AND every sub-agent: a sub-agent's
|
|
3914
5505
|
// own tool calls are marked and capped too.
|
|
3915
5506
|
toolMiddlewares: [marksMiddleware, capMiddleware],
|
|
5507
|
+
// Budget, scheduling and write claims, innermost around each tool
|
|
5508
|
+
// call (main agent and sub-agents alike).
|
|
5509
|
+
toolExecution: executionMiddleware,
|
|
3916
5510
|
resultCap,
|
|
5511
|
+
turnTools,
|
|
3917
5512
|
onContextOverflow: (error) =>
|
|
3918
5513
|
this.contextWindows.learnFromError(mainModelId, error, {
|
|
3919
5514
|
...(byoTurn ? { byoProvider: byoTurn.provider } : {}),
|
|
@@ -3941,9 +5536,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3941
5536
|
req.identity.matrixUserId ??
|
|
3942
5537
|
(await this.resolveMatrixUserId(req.identity.userDid)) ??
|
|
3943
5538
|
'',
|
|
3944
|
-
ucanDelegation
|
|
5539
|
+
ucanDelegation,
|
|
3945
5540
|
timezone: req.identity.timezone,
|
|
3946
|
-
currentTime:
|
|
5541
|
+
currentTime: turnAt.toISOString(),
|
|
3947
5542
|
},
|
|
3948
5543
|
session: {
|
|
3949
5544
|
id: req.sessionId,
|
|
@@ -3954,7 +5549,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3954
5549
|
model: effectiveModel,
|
|
3955
5550
|
},
|
|
3956
5551
|
state: {
|
|
3957
|
-
...priorState,
|
|
5552
|
+
...(suppliedContextOnly ? {} : priorState),
|
|
3958
5553
|
userPreferences,
|
|
3959
5554
|
...metadataBuildState(meta, priorMeta),
|
|
3960
5555
|
// The client-declared surface: the portal and AG-UI plugins turn
|
|
@@ -3969,6 +5564,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3969
5564
|
const { agent, context } = built;
|
|
3970
5565
|
for (const [name, effect] of built.toolEffects)
|
|
3971
5566
|
toolEffects.set(name, effect);
|
|
5567
|
+
for (const name of built.subAgentToolNames) subAgentToolNames.add(name);
|
|
3972
5568
|
|
|
3973
5569
|
const attachmentKwargs =
|
|
3974
5570
|
prepared && prepared.metas.length > 0
|
|
@@ -3994,19 +5590,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3994
5590
|
}
|
|
3995
5591
|
: {};
|
|
3996
5592
|
const rawContent = prepared ? prepared.content : body.message;
|
|
3997
|
-
const
|
|
5593
|
+
const spoken =
|
|
3998
5594
|
req.client === 'matrix' && req.roomKind === 'group'
|
|
3999
5595
|
? prefixSpeaker(rawContent, speakerName)
|
|
4000
5596
|
: rawContent;
|
|
5597
|
+
// The exact time rides on the message (outermost, after the speaker
|
|
5598
|
+
// prefix and the attachment blocks), so the system prompt carries the
|
|
5599
|
+
// date only and stays identical across the session's turns. A
|
|
5600
|
+
// supplied-context task's input stays exactly what was supplied.
|
|
5601
|
+
const timeNote = suppliedContextOnly
|
|
5602
|
+
? undefined
|
|
5603
|
+
: renderTurnTimeNote(turnAt, req.identity.timezone);
|
|
5604
|
+
const content = timeNote ? withTurnTimeNote(spoken, timeNote) : spoken;
|
|
4001
5605
|
const stateInput = {
|
|
4002
5606
|
messages: [
|
|
4003
5607
|
new HumanMessage({
|
|
4004
5608
|
content,
|
|
4005
5609
|
additional_kwargs: {
|
|
4006
|
-
timestamp:
|
|
5610
|
+
timestamp: turnAt.toISOString(),
|
|
5611
|
+
...(timeNote ? { [TURN_TIME_NOTE_KWARG]: timeNote } : {}),
|
|
4007
5612
|
oracleName: core.identity.name,
|
|
4008
5613
|
msgFromMatrixRoom: req.client === 'matrix',
|
|
4009
5614
|
...speakerKwargs,
|
|
5615
|
+
...(req.channel
|
|
5616
|
+
? { 'org.ixo.qi.origin': channelOrigin(req.channel) }
|
|
5617
|
+
: {}),
|
|
4010
5618
|
...attachmentKwargs,
|
|
4011
5619
|
},
|
|
4012
5620
|
}),
|
|
@@ -4029,22 +5637,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4029
5637
|
...metadataGraphInput(meta, priorMeta),
|
|
4030
5638
|
...surface.input,
|
|
4031
5639
|
};
|
|
4032
|
-
// LangSmith: metadata is attached unconditionally (inert without a
|
|
4033
|
-
// tracer); the explicit tracer only when this turn is traced (global
|
|
4034
|
-
// switch or per-DID allowlist — see `resolveLangsmithTracing`).
|
|
4035
|
-
const tracing = resolveLangsmithTracing({
|
|
4036
|
-
userDid: req.identity.userDid,
|
|
4037
|
-
client: req.client,
|
|
4038
|
-
env: langsmithEnvFromWorkerEnv(this.env),
|
|
4039
|
-
});
|
|
4040
|
-
|
|
4041
5640
|
const config = {
|
|
4042
5641
|
configurable: { thread_id: req.sessionId },
|
|
4043
5642
|
context,
|
|
4044
5643
|
signal: abortController.signal,
|
|
4045
5644
|
recursionLimit: turnRecursionLimit(this.env),
|
|
4046
5645
|
metadata: tracing.metadata,
|
|
4047
|
-
|
|
5646
|
+
callbacks: [meteredLlm.callback, ...(tracing.callbacks ?? [])],
|
|
4048
5647
|
};
|
|
4049
5648
|
return {
|
|
4050
5649
|
agent,
|
|
@@ -4055,6 +5654,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4055
5654
|
byoProvider: byoTurn?.provider,
|
|
4056
5655
|
turnDisposables,
|
|
4057
5656
|
toolOutputCapChars: contextBudget.resultCapChars,
|
|
5657
|
+
/** The budget's usage so far (JSON), stored with the run's end. */
|
|
5658
|
+
usage: (): string => JSON.stringify(budget.snapshot()),
|
|
5659
|
+
delivery,
|
|
4058
5660
|
};
|
|
4059
5661
|
}
|
|
4060
5662
|
|
|
@@ -4070,18 +5672,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4070
5672
|
req: TurnRequest,
|
|
4071
5673
|
text: string,
|
|
4072
5674
|
who: 'user' | 'oracle',
|
|
4073
|
-
): void {
|
|
4074
|
-
if (req.client === 'matrix') return;
|
|
4075
|
-
if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX))
|
|
5675
|
+
): Promise<void> {
|
|
5676
|
+
if (req.client === 'matrix') return Promise.resolve();
|
|
5677
|
+
if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX))
|
|
5678
|
+
return Promise.resolve();
|
|
4076
5679
|
// A local session id has no marker event to thread on: the user had
|
|
4077
5680
|
// no oracle room when the session was created, so there is no room
|
|
4078
5681
|
// transcript to mirror either.
|
|
4079
|
-
if (!req.sessionId.startsWith('$')) return;
|
|
4080
|
-
if (!text.trim()) return;
|
|
5682
|
+
if (!req.sessionId.startsWith('$')) return Promise.resolve();
|
|
5683
|
+
if (!text.trim()) return Promise.resolve();
|
|
4081
5684
|
const label = who === 'user' ? 'user message' : 'AI response';
|
|
4082
5685
|
// Serialised per session, retried across a gateway restart with a fixed
|
|
4083
5686
|
// transaction id, kept alive past the request (room-mirror.ts).
|
|
4084
|
-
|
|
5687
|
+
return this.roomMirror().enqueue(
|
|
4085
5688
|
req.sessionId,
|
|
4086
5689
|
async () => {
|
|
4087
5690
|
const roomId =
|
|
@@ -4105,24 +5708,35 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4105
5708
|
body,
|
|
4106
5709
|
...(formattedBody ? { formattedBody } : {}),
|
|
4107
5710
|
threadId: req.sessionId,
|
|
4108
|
-
txnId:
|
|
5711
|
+
txnId: req.channel
|
|
5712
|
+
? `channel-${await channelRequestHash(JSON.stringify([req.identity.userDid, req.channel.bindingId, req.requestId, who]))}`
|
|
5713
|
+
: mirrorTxnId(req.sessionId, req.requestId, who),
|
|
5714
|
+
...(req.channel ? { origin: req.channel } : {}),
|
|
4109
5715
|
};
|
|
4110
5716
|
},
|
|
4111
5717
|
label,
|
|
5718
|
+
req.client === 'channel',
|
|
4112
5719
|
);
|
|
4113
5720
|
}
|
|
4114
5721
|
|
|
4115
5722
|
private roomMirror(): RoomMirror {
|
|
4116
5723
|
this.mirror ??= new RoomMirror({
|
|
4117
5724
|
sendText: (send) =>
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
5725
|
+
send.origin
|
|
5726
|
+
? this.gateway.sendEvent(
|
|
5727
|
+
send.roomId,
|
|
5728
|
+
'm.room.message',
|
|
5729
|
+
JSON.stringify(mirrorEventContent(send)),
|
|
5730
|
+
{ txnId: send.txnId },
|
|
5731
|
+
)
|
|
5732
|
+
: this.gateway.sendText(send.roomId, send.body, {
|
|
5733
|
+
threadId: send.threadId,
|
|
5734
|
+
...(send.formattedBody
|
|
5735
|
+
? { formattedBody: send.formattedBody }
|
|
5736
|
+
: {}),
|
|
5737
|
+
priority: 'background',
|
|
5738
|
+
txnId: send.txnId,
|
|
5739
|
+
}),
|
|
4126
5740
|
keepAlive: (work) => this.ctx.waitUntil(work),
|
|
4127
5741
|
log: (message) => console.log(message),
|
|
4128
5742
|
warn: (message) => console.warn(message),
|
|
@@ -4133,6 +5747,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4133
5747
|
private async afterTurn(
|
|
4134
5748
|
sessionId: string,
|
|
4135
5749
|
messages: BaseMessage[],
|
|
5750
|
+
disposition: RequestDisposition = { kind: 'agent' },
|
|
5751
|
+
signal?: AbortSignal,
|
|
4136
5752
|
): Promise<void> {
|
|
4137
5753
|
const sessions = this.sessions!;
|
|
4138
5754
|
await sessions.touchSession(sessionId);
|
|
@@ -4143,11 +5759,25 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4143
5759
|
);
|
|
4144
5760
|
},
|
|
4145
5761
|
);
|
|
5762
|
+
if (disposition.kind === 'agent')
|
|
5763
|
+
await this.compareShadowRoute(sessionId);
|
|
4146
5764
|
const row = await sessions.getSession(sessionId);
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
5765
|
+
// The title model is the platform adapter, outside the turn's metered
|
|
5766
|
+
// model and budget: a supplied-context task's source and result never
|
|
5767
|
+
// reach it. A direct read brings its own deterministic title.
|
|
5768
|
+
// A generated title is a model call of its own: it runs in the
|
|
5769
|
+
// background, so the reply's `done` (and a Matrix answer) never waits
|
|
5770
|
+
// for it.
|
|
5771
|
+
if (
|
|
5772
|
+
row &&
|
|
5773
|
+
(!row.title || row.title === UNTITLED_SESSION) &&
|
|
5774
|
+
!(await this.isRestrictedTaskSession(sessionId))
|
|
5775
|
+
) {
|
|
5776
|
+
if (disposition.kind === 'direct-read')
|
|
5777
|
+
await sessions.setTitle(sessionId, disposition.title, {
|
|
5778
|
+
onlyIfUntitled: true,
|
|
5779
|
+
});
|
|
5780
|
+
else this.ctx.waitUntil(this.titleSession(sessionId, messages, signal));
|
|
4151
5781
|
}
|
|
4152
5782
|
this.markDirty();
|
|
4153
5783
|
// Which chunks this turn touched, for the page tier's eviction policy
|
|
@@ -4161,6 +5791,51 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4161
5791
|
}
|
|
4162
5792
|
}
|
|
4163
5793
|
|
|
5794
|
+
/**
|
|
5795
|
+
* Whether a session belongs to a supplied-context task, read from the
|
|
5796
|
+
* task row (never from the request). A task session with no scheduler to
|
|
5797
|
+
* read the row from is treated as restricted.
|
|
5798
|
+
*/
|
|
5799
|
+
private async isRestrictedTaskSession(sessionId: string): Promise<boolean> {
|
|
5800
|
+
if (!sessionId.startsWith(TASK_SESSION_PREFIX)) return false;
|
|
5801
|
+
return this.taskScheduler
|
|
5802
|
+
? this.taskScheduler.isRestrictedSession(sessionId)
|
|
5803
|
+
: true;
|
|
5804
|
+
}
|
|
5805
|
+
|
|
5806
|
+
/**
|
|
5807
|
+
* Shadow-mode follow-up: once the turn has ended, compare what the router
|
|
5808
|
+
* would have preloaded with what `load_capability` actually added to the
|
|
5809
|
+
* thread. Best effort — one checkpoint read, and it never fails the turn.
|
|
5810
|
+
* Nothing to compare when the shadow verdict has not landed yet.
|
|
5811
|
+
*/
|
|
5812
|
+
private async compareShadowRoute(sessionId: string): Promise<void> {
|
|
5813
|
+
const shadow = this.shadowRoutes.get(sessionId);
|
|
5814
|
+
if (!shadow) return;
|
|
5815
|
+
this.shadowRoutes.delete(sessionId);
|
|
5816
|
+
try {
|
|
5817
|
+
const tuple = await this.saver?.getTupleWithoutMessages({
|
|
5818
|
+
configurable: { thread_id: sessionId },
|
|
5819
|
+
});
|
|
5820
|
+
const loadedAfter = new Set(
|
|
5821
|
+
priorMetadataState(tuple?.checkpoint.channel_values ?? {})
|
|
5822
|
+
.loadedPlugins ?? [],
|
|
5823
|
+
);
|
|
5824
|
+
const { loadedDuringTurn, agree } = shadowAgreement({
|
|
5825
|
+
priorLoaded: shadow.priorLoaded,
|
|
5826
|
+
loadedAfter,
|
|
5827
|
+
wouldPreload: shadow.wouldPreload,
|
|
5828
|
+
});
|
|
5829
|
+
console.log(
|
|
5830
|
+
`[capability-router-shadow] request=${shadow.requestId} loadedDuringTurn=[${loadedDuringTurn.join(', ')}] agree=${agree}`,
|
|
5831
|
+
);
|
|
5832
|
+
} catch (err: unknown) {
|
|
5833
|
+
console.warn(
|
|
5834
|
+
`[capability-router-shadow] request=${shadow.requestId} comparison failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
5835
|
+
);
|
|
5836
|
+
}
|
|
5837
|
+
}
|
|
5838
|
+
|
|
4164
5839
|
/**
|
|
4165
5840
|
* Channel-memory compaction for the gateway's group rooms
|
|
4166
5841
|
* (`src/matrix/group-chat.ts`): the platform's small model distils a
|
|
@@ -4181,8 +5856,47 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4181
5856
|
);
|
|
4182
5857
|
}
|
|
4183
5858
|
|
|
5859
|
+
/**
|
|
5860
|
+
* Title an untitled session from its first exchange, in the background:
|
|
5861
|
+
* bounded by `TITLE_TIMEOUT_MS` and by the turn's abort, never throws.
|
|
5862
|
+
* The timer is cleared however the call ends.
|
|
5863
|
+
*/
|
|
5864
|
+
private async titleSession(
|
|
5865
|
+
sessionId: string,
|
|
5866
|
+
messages: BaseMessage[],
|
|
5867
|
+
turnSignal: AbortSignal | undefined,
|
|
5868
|
+
): Promise<void> {
|
|
5869
|
+
const bound = new AbortController();
|
|
5870
|
+
const onAbort = (): void => bound.abort(turnSignal?.reason);
|
|
5871
|
+
if (turnSignal?.aborted) return;
|
|
5872
|
+
turnSignal?.addEventListener('abort', onAbort, { once: true });
|
|
5873
|
+
const timer = setTimeout(
|
|
5874
|
+
() =>
|
|
5875
|
+
bound.abort(
|
|
5876
|
+
new Error(`session title timed out after ${TITLE_TIMEOUT_MS} ms`),
|
|
5877
|
+
),
|
|
5878
|
+
TITLE_TIMEOUT_MS,
|
|
5879
|
+
);
|
|
5880
|
+
try {
|
|
5881
|
+
const title = await this.generateTitle(messages, bound.signal);
|
|
5882
|
+
// A session deleted (or reset away) meanwhile is not titled.
|
|
5883
|
+
const sessions = this.sessions;
|
|
5884
|
+
if (!title || !sessions || bound.signal.aborted) return;
|
|
5885
|
+
if (await sessions.setTitle(sessionId, title, { onlyIfUntitled: true }))
|
|
5886
|
+
this.markDirty();
|
|
5887
|
+
} catch (err) {
|
|
5888
|
+
console.warn(
|
|
5889
|
+
`[user-do] no title for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
5890
|
+
);
|
|
5891
|
+
} finally {
|
|
5892
|
+
clearTimeout(timer);
|
|
5893
|
+
turnSignal?.removeEventListener('abort', onAbort);
|
|
5894
|
+
}
|
|
5895
|
+
}
|
|
5896
|
+
|
|
4184
5897
|
private async generateTitle(
|
|
4185
5898
|
messages: BaseMessage[],
|
|
5899
|
+
signal: AbortSignal,
|
|
4186
5900
|
): Promise<string | null> {
|
|
4187
5901
|
const human = messages.find((m) => m.type === 'human');
|
|
4188
5902
|
const ai = [...messages].reverse().find((m) => m.type === 'ai');
|
|
@@ -4192,9 +5906,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4192
5906
|
const model = (this.ambient?.llm ?? this.core.llm).get('session-title');
|
|
4193
5907
|
const prompt =
|
|
4194
5908
|
'Write a 3–6 word title for this conversation. Reply with the title only, no quotes.\n\n' +
|
|
4195
|
-
`User: ${
|
|
5909
|
+
`User: ${visibleText(human).slice(0, 500)}\n` +
|
|
4196
5910
|
(ai ? `Assistant: ${contentToText(ai.content).slice(0, 500)}` : '');
|
|
4197
|
-
|
|
5911
|
+
// Raced as well as signalled: a provider that ignores the signal
|
|
5912
|
+
// still cannot hold the call past it.
|
|
5913
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
5914
|
+
const fail = (): void =>
|
|
5915
|
+
reject(
|
|
5916
|
+
signal.reason instanceof Error
|
|
5917
|
+
? signal.reason
|
|
5918
|
+
: new Error('session title aborted'),
|
|
5919
|
+
);
|
|
5920
|
+
if (signal.aborted) fail();
|
|
5921
|
+
else signal.addEventListener('abort', fail, { once: true });
|
|
5922
|
+
});
|
|
5923
|
+
const res = await Promise.race([
|
|
5924
|
+
model.invoke(prompt, { signal }),
|
|
5925
|
+
aborted,
|
|
5926
|
+
]);
|
|
4198
5927
|
const title = contentToText(res.content)
|
|
4199
5928
|
.trim()
|
|
4200
5929
|
.replace(/^["']|["']$/g, '')
|