@ixo/oracle-runtime-workers 0.37.2 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/attachments/channel-links.ts +74 -7
- package/src/attachments/channel.ts +15 -4
- package/src/attachments/content-blocks.ts +19 -16
- package/src/attachments/download.ts +173 -50
- package/src/attachments/index.ts +6 -5
- package/src/attachments/pipeline.ts +117 -49
- package/src/attachments/resize.ts +142 -0
- package/src/attachments/retention.ts +26 -70
- package/src/attachments/room-media.ts +643 -0
- package/src/attachments/route.ts +47 -4
- package/src/attachments/sandbox-archive.ts +15 -4
- package/src/attachments/sanitize.ts +24 -13
- package/src/attachments/view.ts +82 -38
- package/src/channels/replies.ts +8 -4
- package/src/core/capability-expiry.ts +62 -0
- package/src/core/capability-router.ts +126 -95
- package/src/core/context-budget.ts +116 -4
- package/src/core/domain-context/document.ts +74 -2
- package/src/core/domain-context/index.ts +1 -0
- package/src/core/domain-context/resolver.ts +9 -6
- package/src/core/domain-context/turn-context.ts +62 -13
- package/src/core/domain-context/types.ts +17 -0
- package/src/core/env.ts +21 -2
- package/src/core/index.ts +4 -0
- package/src/core/llm.ts +29 -0
- package/src/core/main-agent-types.ts +53 -4
- package/src/core/main-agent.ts +228 -125
- package/src/core/manifest.ts +109 -24
- package/src/core/mcp-result.ts +56 -0
- package/src/core/meta-tools.ts +258 -110
- package/src/core/middlewares/attachment-recall.ts +502 -0
- package/src/core/middlewares/capability-gate.ts +77 -28
- package/src/core/middlewares/context-guard.ts +154 -21
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/middlewares/result-cap.ts +1 -1
- package/src/core/middlewares/summarization.ts +9 -0
- package/src/core/middlewares/tool-marks.ts +11 -2
- package/src/core/middlewares/turn-boundary.ts +22 -12
- package/src/core/operating-guides.ts +126 -0
- package/src/core/plugins/skills/skills-tools.ts +6 -31
- package/src/core/plugins/skills/skills.plugin.ts +4 -3
- package/src/core/plugins/weather/weather.plugin.ts +1 -0
- package/src/core/prompt-composer.ts +188 -111
- package/src/core/read-result-tool.ts +48 -14
- package/src/core/registries.ts +13 -5
- package/src/core/state.ts +4 -3
- package/src/core/sub-agent-fallback.ts +1 -0
- package/src/core/subagent-as-tool.ts +4 -1
- package/src/core/tool-access.ts +3 -32
- package/src/core/wrap-plugin-tool.ts +2 -1
- package/src/do/contracts.ts +64 -24
- package/src/do/room-mirror.ts +144 -20
- package/src/do/transcript.ts +41 -12
- package/src/do/turn-attachments.ts +45 -6
- package/src/do/turn-metadata.ts +16 -4
- package/src/do/user-oracle-do.ts +639 -152
- package/src/index.ts +1 -0
- package/src/llm/provider-error.ts +5 -2
- package/src/llm/stream-liveness.ts +3 -2
- package/src/matrix/gateway-do.ts +164 -102
- package/src/matrix/ingest.ts +12 -3
- package/src/matrix/media-deadline.ts +5 -47
- package/src/memory/session-history-indexer.ts +60 -14
- package/src/plugin-api/oracle-plugin.ts +4 -5
- package/src/plugin-api/types.ts +32 -3
- package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
- package/src/plugins/agui/agui.plugin.ts +1 -0
- package/src/plugins/attachments/attachments.plugin.ts +3 -3
- package/src/plugins/attachments/view-attachment-tool.ts +6 -4
- package/src/plugins/compact-schema.ts +80 -0
- package/src/plugins/composio/composio.plugin.ts +1 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
- package/src/plugins/editor/editor.plugin.ts +4 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
- package/src/plugins/flows/flows.plugin.ts +37 -6
- package/src/plugins/flows/prompts.ts +1 -1
- package/src/plugins/flows/tools/authoring.ts +5 -7
- package/src/plugins/flows/tools/discovery.ts +1 -4
- package/src/plugins/flows/tools/forms.ts +2 -8
- package/src/plugins/flows/tools/linkage.ts +1 -4
- package/src/plugins/flows/tools/settings.ts +1 -4
- package/src/plugins/flows/translator.ts +16 -5
- package/src/plugins/flows/types.ts +27 -48
- package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
- package/src/plugins/index.ts +2 -2
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
- package/src/plugins/ixo-chain/kinds/core.ts +2 -2
- package/src/plugins/ixo-chain/kinds/types.ts +34 -5
- package/src/plugins/ixo-chain/queries.ts +10 -1
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
- package/src/plugins/matrix-group-chats/index.ts +1 -0
- package/src/plugins/memory/memory-tools.ts +4 -4
- package/src/plugins/memory/memory.plugin.ts +27 -1
- package/src/plugins/memory/oracle-document-tools.ts +24 -49
- package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
- package/src/plugins/portal/portal.plugin.ts +1 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
- package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
- package/src/plugins/tasks/manifest.ts +1 -0
- package/src/plugins/tasks/tasks-tools.ts +14 -19
- package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
- package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
- package/src/plugins/vfs/vfs-tools.ts +6 -6
- package/src/plugins/vfs/vfs.plugin.ts +4 -5
- package/src/plugins/yoma/yoma.plugin.ts +1 -0
- package/src/sqlite/inline-payloads.ts +121 -0
- package/src/sqlite/serialization.ts +12 -0
- package/src/sqlite/sqlite-saver.ts +258 -42
- package/src/tasks/schedule-input.ts +5 -22
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -123,19 +123,20 @@ import {
|
|
|
123
123
|
type BaseMessage,
|
|
124
124
|
} from '@langchain/core/messages';
|
|
125
125
|
import {
|
|
126
|
-
applyAttachmentRetention,
|
|
127
|
-
ATTACHMENT_PAYLOAD_TURNS,
|
|
128
126
|
ATTACHMENT_PLACEHOLDER_PREFIX,
|
|
127
|
+
classifyAttachment,
|
|
129
128
|
createAttachmentViewSurface,
|
|
130
129
|
findPersistedAttachmentMeta,
|
|
131
130
|
IngestTextLru,
|
|
132
|
-
INLINE_PAYLOAD_NEEDLE,
|
|
133
131
|
isAttachmentViewMessage,
|
|
134
|
-
|
|
132
|
+
loadAttachmentBytes,
|
|
135
133
|
parseAttachmentInputs,
|
|
136
134
|
prepareAttachments,
|
|
135
|
+
resizeForModel,
|
|
136
|
+
sanitizeAttachmentFilename,
|
|
137
137
|
ChannelAttachmentLinkStore,
|
|
138
138
|
type AttachmentViewSurface,
|
|
139
|
+
type AttachmentInput,
|
|
139
140
|
type MatrixMediaSource,
|
|
140
141
|
type ViewAttachmentDeps,
|
|
141
142
|
type SandboxUploadConfig,
|
|
@@ -145,6 +146,26 @@ import {
|
|
|
145
146
|
AttachmentTextCacheStore,
|
|
146
147
|
} from '../attachments';
|
|
147
148
|
import {
|
|
149
|
+
AttachmentTooLargeError,
|
|
150
|
+
boundedSignal,
|
|
151
|
+
HomeserverUnauthorizedError,
|
|
152
|
+
openHomeserverMedia,
|
|
153
|
+
untilAborted,
|
|
154
|
+
} from '../attachments/download';
|
|
155
|
+
// Not in the attachments index: it loads the bot SDK, which only workerd runs.
|
|
156
|
+
import {
|
|
157
|
+
CHANNEL_MEDIA_SEND_TIMEOUT_MS,
|
|
158
|
+
CHANNEL_MEDIA_TEXT_WAIT_MS,
|
|
159
|
+
CHANNEL_MEDIA_TURN_TIMEOUT_MS,
|
|
160
|
+
channelFilesToKeep,
|
|
161
|
+
channelMediaTxnId,
|
|
162
|
+
startChannelFileUploads,
|
|
163
|
+
type ChannelFileUpload,
|
|
164
|
+
type ThreadFileCopy,
|
|
165
|
+
} from '../attachments/room-media';
|
|
166
|
+
import {
|
|
167
|
+
channelGatewayOrigins,
|
|
168
|
+
channelThreadFiles,
|
|
148
169
|
channelTurnLinks,
|
|
149
170
|
prepareTurnAttachments,
|
|
150
171
|
turnAttachmentInputs,
|
|
@@ -165,9 +186,15 @@ import {
|
|
|
165
186
|
DEFAULT_MODEL_ID,
|
|
166
187
|
} from '../core/llm';
|
|
167
188
|
import { renderTurnTimeNote } from '../core/prompt-composer';
|
|
189
|
+
import type { RegisteredManifest } from '../core/registries';
|
|
190
|
+
import {
|
|
191
|
+
TURN_GUIDE_NOTE_KWARG,
|
|
192
|
+
USAGE_CARRIED_KWARG,
|
|
193
|
+
} from '../core/operating-guides';
|
|
168
194
|
import { TURN_TIME_NOTE_KWARG, withTurnTimeNote } from '../core/turn-time-note';
|
|
169
195
|
import {
|
|
170
196
|
capabilityRouterMode,
|
|
197
|
+
preloadThreshold,
|
|
171
198
|
createCapabilityRouter,
|
|
172
199
|
shadowAgreement,
|
|
173
200
|
type CapabilityRouter,
|
|
@@ -182,7 +209,6 @@ import type {
|
|
|
182
209
|
RequestAdmissionResult,
|
|
183
210
|
RequestDisposition,
|
|
184
211
|
} from '../plugin-api/request-admission';
|
|
185
|
-
import { bootHiddenPlugins } from '../core/tool-access';
|
|
186
212
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
187
213
|
import type { AmbientServices, LlmAdapter } from '../core/runtime-context';
|
|
188
214
|
import { chatGptBackendFromEnv } from '../llm/byo-client';
|
|
@@ -199,6 +225,8 @@ import { parseJwk } from '../secrets/jwe';
|
|
|
199
225
|
import { WorkersSecretsService } from '../secrets/secrets-service';
|
|
200
226
|
import { createSecretsAdapter } from './secrets-adapter';
|
|
201
227
|
import {
|
|
228
|
+
EDITOR_PLUGIN_NAME,
|
|
229
|
+
editorContextActive,
|
|
202
230
|
metadataBuildState,
|
|
203
231
|
metadataGraphInput,
|
|
204
232
|
parseTurnMetadata,
|
|
@@ -274,6 +302,7 @@ import {
|
|
|
274
302
|
} from './ambient';
|
|
275
303
|
import {
|
|
276
304
|
checkpointStorageKey,
|
|
305
|
+
type BotCredentials,
|
|
277
306
|
type MatrixGatewayObject,
|
|
278
307
|
type OracleWorkerEnv,
|
|
279
308
|
type SessionSummary,
|
|
@@ -314,6 +343,11 @@ import { freshStub } from './fresh-stub';
|
|
|
314
343
|
import { WorkStatusProducer } from '../matrix/work-status';
|
|
315
344
|
import { fetchUserMatrixServerName } from '../matrix/user-homeserver';
|
|
316
345
|
import { createWorkStatusMiddleware } from '../core/middlewares/work-status';
|
|
346
|
+
import {
|
|
347
|
+
RecalledImageCache,
|
|
348
|
+
type RecallImageLoader,
|
|
349
|
+
} from '../core/middlewares/attachment-recall';
|
|
350
|
+
import { bytesToBase64 } from '../plugins/base64';
|
|
317
351
|
import { UserPreferencesStore } from '../plugins/user-preferences/user-preferences-store';
|
|
318
352
|
import { SqliteUserKv } from '../sqlite/user-kv-store';
|
|
319
353
|
import {
|
|
@@ -321,6 +355,7 @@ import {
|
|
|
321
355
|
pageOfMessages,
|
|
322
356
|
SessionHistoryIndexer,
|
|
323
357
|
type HistoryEntry,
|
|
358
|
+
conversationReader,
|
|
324
359
|
type HistoryMessage,
|
|
325
360
|
type HistoryPage,
|
|
326
361
|
type IndexableSession,
|
|
@@ -428,6 +463,7 @@ import {
|
|
|
428
463
|
contentToText,
|
|
429
464
|
isSummarizationMessage,
|
|
430
465
|
pageThreadTranscript,
|
|
466
|
+
recentTurnTexts,
|
|
431
467
|
TranscriptCursorError,
|
|
432
468
|
transformTranscript,
|
|
433
469
|
type TranscriptPageOptions,
|
|
@@ -544,6 +580,15 @@ type UserContextSettle = (
|
|
|
544
580
|
signal: AbortSignal,
|
|
545
581
|
) => Promise<UserContextData | undefined>;
|
|
546
582
|
|
|
583
|
+
/**
|
|
584
|
+
* What the capability router sees of the thread: enough to route a follow-up
|
|
585
|
+
* (two exchanges, each turn cut short), read from a bounded number of rows so
|
|
586
|
+
* a long thread costs nothing extra.
|
|
587
|
+
*/
|
|
588
|
+
const ROUTER_RECENT_TURNS = 4;
|
|
589
|
+
const ROUTER_RECENT_TURN_CHARS = 300;
|
|
590
|
+
const ROUTER_RECENT_ROWS = 12;
|
|
591
|
+
|
|
547
592
|
/** How long a first turn waits on the user-context prefetch before saying so. */
|
|
548
593
|
const USER_CONTEXT_PROGRESS_GRACE_MS = 300;
|
|
549
594
|
const USER_CONTEXT_PROGRESS_STEP = 'Recalling your memories…';
|
|
@@ -1183,6 +1228,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1183
1228
|
private matrixLedger: MatrixTurnLedger | null = null;
|
|
1184
1229
|
/** Room mirrors of HTTP turns, serialised per session and retried across gateway restarts (see room-mirror.ts). */
|
|
1185
1230
|
private mirror: RoomMirror | null = null;
|
|
1231
|
+
/** The oracle's plugin device, once known (see `pluginDevice`). */
|
|
1232
|
+
private pluginCredentials: BotCredentials | null = null;
|
|
1233
|
+
/** Its lookup in flight: concurrent callers share one gateway RPC. */
|
|
1234
|
+
private pluginCredentialsLoad: Promise<BotCredentials> | null = null;
|
|
1235
|
+
/**
|
|
1236
|
+
* Channel requests with files whose person's text the mirror pass has
|
|
1237
|
+
* queued on the session chain (value without `waiter`), or whose file
|
|
1238
|
+
* events wait for that (`waiter`); see `afterChannelText`.
|
|
1239
|
+
*/
|
|
1240
|
+
private readonly channelTextQueued = new Map<
|
|
1241
|
+
string,
|
|
1242
|
+
{ at: number; waiter?: () => void }
|
|
1243
|
+
>();
|
|
1244
|
+
/** Images the recall middleware re-attached lately, kept across this instance's turns. */
|
|
1245
|
+
private readonly recallCache = new RecalledImageCache();
|
|
1186
1246
|
private channelTurns: ChannelTurns | null = null;
|
|
1187
1247
|
/** Anonymous-feedback idempotency markers and the user's submission limit. */
|
|
1188
1248
|
private feedbackMarkers: FeedbackMarkers | null = null;
|
|
@@ -4745,7 +4805,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4745
4805
|
!isAttachmentViewMessage(m) &&
|
|
4746
4806
|
!isDeliveryNoticeMessage(m),
|
|
4747
4807
|
)
|
|
4748
|
-
.map((
|
|
4808
|
+
.map(conversationReader());
|
|
4749
4809
|
}
|
|
4750
4810
|
|
|
4751
4811
|
/**
|
|
@@ -4768,6 +4828,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4768
4828
|
}
|
|
4769
4829
|
const pageRows = Math.max(1, limit);
|
|
4770
4830
|
const entries: HistoryEntry[] = [];
|
|
4831
|
+
const read = conversationReader();
|
|
4771
4832
|
for (;;) {
|
|
4772
4833
|
const rows = await saver.listThreadMessageRows(sessionId, {
|
|
4773
4834
|
direction: 'newer',
|
|
@@ -4784,7 +4845,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4784
4845
|
)
|
|
4785
4846
|
continue;
|
|
4786
4847
|
entries.push({
|
|
4787
|
-
message:
|
|
4848
|
+
message: read(m),
|
|
4788
4849
|
cursor: row.messageId,
|
|
4789
4850
|
});
|
|
4790
4851
|
}
|
|
@@ -4979,6 +5040,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4979
5040
|
prior: unknown;
|
|
4980
5041
|
client: TurnRequest['client'];
|
|
4981
5042
|
roomKind: TurnRequest['roomKind'];
|
|
5043
|
+
/** The provider's cache has expired, so the prompt may change. */
|
|
5044
|
+
coldTurn: boolean;
|
|
4982
5045
|
}): UserContextSettle | null {
|
|
4983
5046
|
// A first contact that fetches nothing still prints the one
|
|
4984
5047
|
// first-contact line (see the settle step below), with its reason, so
|
|
@@ -5033,9 +5096,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
5033
5096
|
// room answer — but only one already in the cache: an upload that
|
|
5034
5097
|
// just invalidated the room must not make the next message wait for
|
|
5035
5098
|
// a gather. On a miss the thread keeps its context and the
|
|
5036
|
-
// (single-flight) refresh warms the cache for a later turn.
|
|
5099
|
+
// (single-flight) refresh warms the cache for a later turn. The
|
|
5100
|
+
// answer sits in the system prompt, so it waits for a cold turn: a
|
|
5101
|
+
// change there re-reads the whole request at the uncached price.
|
|
5037
5102
|
const warm =
|
|
5038
|
-
input.client === 'channel'
|
|
5103
|
+
input.client === 'channel' && input.coldTurn
|
|
5039
5104
|
? fetcher.readCached(input.roomId)
|
|
5040
5105
|
: undefined;
|
|
5041
5106
|
keepGathering();
|
|
@@ -7136,13 +7201,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7136
7201
|
sessionId: string,
|
|
7137
7202
|
): Promise<SessionContextStatus | null> {
|
|
7138
7203
|
if (!(await this.sessions!.getSession(sessionId))) return null;
|
|
7139
|
-
const
|
|
7140
|
-
configurable: { thread_id: sessionId },
|
|
7141
|
-
});
|
|
7142
|
-
const channel: unknown = tuple?.checkpoint.channel_values.messages;
|
|
7143
|
-
const context: BaseMessage[] = Array.isArray(channel)
|
|
7144
|
-
? channel.filter(isBaseMessage)
|
|
7145
|
-
: [];
|
|
7204
|
+
const context = await this.contextMessages(sessionId);
|
|
7146
7205
|
const thread = await this.saver!.listThreadMessages(sessionId);
|
|
7147
7206
|
const stats = await this.ctx.storage.get<SessionContextCounters>(
|
|
7148
7207
|
contextStatsKey(sessionId),
|
|
@@ -7162,6 +7221,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7162
7221
|
};
|
|
7163
7222
|
}
|
|
7164
7223
|
|
|
7224
|
+
/** The messages the session's model calls start from (a summary, then the kept tail, once condensed). */
|
|
7225
|
+
private async contextMessages(sessionId: string): Promise<BaseMessage[]> {
|
|
7226
|
+
const tuple = await this.saver!.getTuple({
|
|
7227
|
+
configurable: { thread_id: sessionId },
|
|
7228
|
+
});
|
|
7229
|
+
const channel: unknown = tuple?.checkpoint.channel_values.messages;
|
|
7230
|
+
return Array.isArray(channel) ? channel.filter(isBaseMessage) : [];
|
|
7231
|
+
}
|
|
7232
|
+
|
|
7165
7233
|
/** Fold a guard event into the session's stored counters (ordered, never throws). */
|
|
7166
7234
|
private recordContextEvent(
|
|
7167
7235
|
sessionId: string,
|
|
@@ -7180,7 +7248,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7180
7248
|
switch (event.kind) {
|
|
7181
7249
|
case 'prune':
|
|
7182
7250
|
next.prunes += 1;
|
|
7183
|
-
if (event.stage
|
|
7251
|
+
if (event.stage === 'hard' || event.stage === 'overflow')
|
|
7252
|
+
next.hardPrunes += 1;
|
|
7184
7253
|
next.prunedResults += event.pruned;
|
|
7185
7254
|
break;
|
|
7186
7255
|
case 'overflow-retry':
|
|
@@ -7241,52 +7310,81 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7241
7310
|
// ── turn plumbing ──────────────────────────────────────────────────────
|
|
7242
7311
|
|
|
7243
7312
|
/**
|
|
7244
|
-
* Matrix media access for the attachments pipeline
|
|
7245
|
-
*
|
|
7246
|
-
* the
|
|
7247
|
-
*
|
|
7248
|
-
*
|
|
7249
|
-
*
|
|
7250
|
-
*
|
|
7251
|
-
*
|
|
7252
|
-
* from opening it to the last byte. An `AbortSignal` cannot cross the
|
|
7253
|
-
* Durable Object RPC, so the turn's abort acts on this side instead:
|
|
7254
|
-
* `readBytesCapped` cancels the stream it reads, and the cancel travels
|
|
7255
|
-
* back over the RPC stream to stop the gateway's download.
|
|
7313
|
+
* Matrix media access for the attachments pipeline. The gateway is asked
|
|
7314
|
+
* only for the media event's small JSON (`eventMediaInfo`: it alone holds
|
|
7315
|
+
* the room keys); the bytes come straight from the homeserver on the
|
|
7316
|
+
* plugin device's token (`openMatrixMedia`) and an encrypted attachment
|
|
7317
|
+
* is decrypted HERE, so no media byte crosses the shared gateway object.
|
|
7318
|
+
* Each transfer is bounded by the attachment download deadline (and the
|
|
7319
|
+
* caller's signal), from opening it to the last byte, and cut off at
|
|
7320
|
+
* `maxBytes`.
|
|
7256
7321
|
*/
|
|
7257
7322
|
private matrixMediaSource(): MatrixMediaSource {
|
|
7258
|
-
const
|
|
7323
|
+
const bounded = async <T>(
|
|
7324
|
+
signal: AbortSignal | undefined,
|
|
7325
|
+
work: (signal: AbortSignal) => Promise<T>,
|
|
7326
|
+
): Promise<T> => {
|
|
7327
|
+
const bound = boundedSignal(
|
|
7328
|
+
signal,
|
|
7329
|
+
DOWNLOAD_TIMEOUT_MS,
|
|
7330
|
+
'Matrix media download',
|
|
7331
|
+
);
|
|
7332
|
+
try {
|
|
7333
|
+
return await untilAborted(work(bound.signal), bound.signal);
|
|
7334
|
+
} finally {
|
|
7335
|
+
bound.release();
|
|
7336
|
+
}
|
|
7337
|
+
};
|
|
7259
7338
|
return {
|
|
7260
|
-
downloadMxc:
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7339
|
+
downloadMxc: (mxc, maxBytes = MAX_FILE_SIZE, signal) =>
|
|
7340
|
+
bounded(signal, async (bound) =>
|
|
7341
|
+
readBytesCapped(
|
|
7342
|
+
await this.openMatrixMedia(mxc, bound),
|
|
7343
|
+
maxBytes,
|
|
7344
|
+
bound,
|
|
7345
|
+
),
|
|
7265
7346
|
),
|
|
7266
|
-
downloadEvent:
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
bytes: await readBytesCapped(plain, maxBytes, signal),
|
|
7283
|
-
...(media.mimetype ? { mimetype: media.mimetype } : {}),
|
|
7284
|
-
...(media.filename ? { filename: media.filename } : {}),
|
|
7285
|
-
};
|
|
7286
|
-
},
|
|
7347
|
+
downloadEvent: (roomId, eventId, maxBytes = MAX_FILE_SIZE, signal) =>
|
|
7348
|
+
bounded(signal, async (bound) => {
|
|
7349
|
+
const info = await this.gateway.eventMediaInfo(roomId, eventId, {
|
|
7350
|
+
timeoutMs: DOWNLOAD_TIMEOUT_MS,
|
|
7351
|
+
});
|
|
7352
|
+
if (!info) return null;
|
|
7353
|
+
const stream = await this.openMatrixMedia(info.mxc, bound);
|
|
7354
|
+
const plain = info.file
|
|
7355
|
+
? stream.pipeThrough(createAttachmentDecryptor(info.file))
|
|
7356
|
+
: stream;
|
|
7357
|
+
return {
|
|
7358
|
+
bytes: await readBytesCapped(plain, maxBytes, bound),
|
|
7359
|
+
...(info.mimetype ? { mimetype: info.mimetype } : {}),
|
|
7360
|
+
...(info.filename ? { filename: info.filename } : {}),
|
|
7361
|
+
};
|
|
7362
|
+
}),
|
|
7287
7363
|
};
|
|
7288
7364
|
}
|
|
7289
7365
|
|
|
7366
|
+
/**
|
|
7367
|
+
* The media behind `mxc`, opened on the homeserver with the plugin
|
|
7368
|
+
* device's token; a 401 asks the gateway for fresh credentials
|
|
7369
|
+
* (`botCredentials({ rejected })`) and tries once more.
|
|
7370
|
+
*/
|
|
7371
|
+
private async openMatrixMedia(
|
|
7372
|
+
mxc: string,
|
|
7373
|
+
signal: AbortSignal,
|
|
7374
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
7375
|
+
const device = await untilAborted(this.pluginDevice(), signal);
|
|
7376
|
+
try {
|
|
7377
|
+
return await openHomeserverMedia(mxc, device, { signal });
|
|
7378
|
+
} catch (error) {
|
|
7379
|
+
if (!(error instanceof HomeserverUnauthorizedError)) throw error;
|
|
7380
|
+
const fresh = await untilAborted(
|
|
7381
|
+
this.pluginDevice({ rejected: device.accessToken }),
|
|
7382
|
+
signal,
|
|
7383
|
+
);
|
|
7384
|
+
return openHomeserverMedia(mxc, fresh, { signal });
|
|
7385
|
+
}
|
|
7386
|
+
}
|
|
7387
|
+
|
|
7290
7388
|
/**
|
|
7291
7389
|
* Session-scoped attachment access for the `view_attachment` tool: a
|
|
7292
7390
|
* reference is resolved against the attachments recorded on this
|
|
@@ -7314,6 +7412,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7314
7412
|
findPersistedAttachmentMeta(saver, input.sessionId, ref);
|
|
7315
7413
|
const viewDeps = (): ViewAttachmentDeps => ({
|
|
7316
7414
|
source: this.matrixMediaSource(),
|
|
7415
|
+
...(this.env.IMAGES ? { images: this.env.IMAGES } : {}),
|
|
7317
7416
|
channel: turnChannelMediaSource({
|
|
7318
7417
|
sessionId: input.sessionId,
|
|
7319
7418
|
turnLinks: input.channelLinks,
|
|
@@ -7339,45 +7438,76 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7339
7438
|
}
|
|
7340
7439
|
|
|
7341
7440
|
/**
|
|
7342
|
-
*
|
|
7343
|
-
*
|
|
7344
|
-
*
|
|
7345
|
-
*
|
|
7346
|
-
*
|
|
7347
|
-
*
|
|
7348
|
-
*
|
|
7349
|
-
*
|
|
7441
|
+
* Image fetch for the attachment recall middleware
|
|
7442
|
+
* (`core/middlewares/attachment-recall.ts`): one image of this session,
|
|
7443
|
+
* read through the same sources as the turn pipeline (Matrix media from
|
|
7444
|
+
* the homeserver, the session's channel links or their room copies,
|
|
7445
|
+
* http(s)), cut off at the middleware's `maxBytes` (null then), refused
|
|
7446
|
+
* unless the bytes are an image, downscaled for the model when the
|
|
7447
|
+
* oracle binds Cloudflare Images, as base64. An image the resize step
|
|
7448
|
+
* drops resolves null: its original never goes to the model.
|
|
7350
7449
|
*/
|
|
7351
|
-
private
|
|
7352
|
-
sessionId: string
|
|
7353
|
-
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7450
|
+
private recallImageLoader(input: {
|
|
7451
|
+
sessionId: string;
|
|
7452
|
+
roomId: string | undefined;
|
|
7453
|
+
/** The links of this turn's channel files (earlier ones come from the store). */
|
|
7454
|
+
channelLinks: ReturnType<typeof channelTurnLinks>;
|
|
7455
|
+
}): RecallImageLoader {
|
|
7456
|
+
return async (meta, { signal, maxBytes }) => {
|
|
7457
|
+
const attachment: AttachmentInput = {
|
|
7458
|
+
filename: meta.filename,
|
|
7459
|
+
mimetype: meta.mimetype,
|
|
7460
|
+
...(meta.size !== undefined ? { size: meta.size } : {}),
|
|
7461
|
+
...(meta.mxcUri ? { mxcUri: meta.mxcUri } : {}),
|
|
7462
|
+
...(meta.eventId ? { eventId: meta.eventId } : {}),
|
|
7463
|
+
...(meta.channelRef ? { channelRef: meta.channelRef } : {}),
|
|
7464
|
+
};
|
|
7465
|
+
const name = sanitizeAttachmentFilename(attachment.filename);
|
|
7466
|
+
const channel = turnChannelMediaSource({
|
|
7467
|
+
sessionId: input.sessionId,
|
|
7468
|
+
turnLinks: input.channelLinks,
|
|
7469
|
+
store: this.channelAttachmentLinks,
|
|
7470
|
+
channelServiceDid: this.env.CHANNEL_SERVICE_DID,
|
|
7471
|
+
});
|
|
7472
|
+
let loaded: Awaited<ReturnType<typeof loadAttachmentBytes>>;
|
|
7473
|
+
try {
|
|
7474
|
+
loaded = await loadAttachmentBytes(
|
|
7475
|
+
attachment,
|
|
7476
|
+
input.roomId,
|
|
7477
|
+
this.matrixMediaSource(),
|
|
7478
|
+
{ signal, maxBytes, channel },
|
|
7479
|
+
);
|
|
7480
|
+
} catch (error) {
|
|
7481
|
+
if (!(error instanceof AttachmentTooLargeError)) throw error;
|
|
7482
|
+
console.warn(
|
|
7483
|
+
`[attachments] recall: "${name}" is over the ${maxBytes}-byte recall limit`,
|
|
7484
|
+
);
|
|
7485
|
+
return null;
|
|
7486
|
+
}
|
|
7487
|
+
const { bytes, mimetype, sniffed } = loaded;
|
|
7488
|
+
// Bytes that are not an image never go back as an image block.
|
|
7489
|
+
const kind = classifyAttachment({
|
|
7490
|
+
mimetype,
|
|
7491
|
+
filename: sniffed ? '' : attachment.filename,
|
|
7492
|
+
});
|
|
7493
|
+
if (kind !== 'image')
|
|
7494
|
+
throw new Error(`content is ${mimetype}, not an image`);
|
|
7495
|
+
const sent = await resizeForModel(bytes, mimetype, this.env.IMAGES, {
|
|
7496
|
+
logger: console,
|
|
7497
|
+
});
|
|
7498
|
+
if (sent.dropped) {
|
|
7499
|
+
console.warn(
|
|
7500
|
+
`[attachments] recall: "${name}" not re-attached: ${sent.dropped}`,
|
|
7501
|
+
);
|
|
7502
|
+
return null;
|
|
7503
|
+
}
|
|
7504
|
+
return {
|
|
7505
|
+
kind: 'image',
|
|
7506
|
+
mimeType: sent.mimetype,
|
|
7507
|
+
base64: bytesToBase64(sent.bytes),
|
|
7508
|
+
filename: name,
|
|
7509
|
+
};
|
|
7510
|
+
};
|
|
7381
7511
|
}
|
|
7382
7512
|
|
|
7383
7513
|
/**
|
|
@@ -7618,6 +7748,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7618
7748
|
configurable: { thread_id: req.sessionId },
|
|
7619
7749
|
});
|
|
7620
7750
|
const priorState = existing?.checkpoint.channel_values ?? {};
|
|
7751
|
+
// A new thread, or quiet for longer than the provider's cache lives:
|
|
7752
|
+
// this turn pays a miss whatever it sends, so it is the one to change
|
|
7753
|
+
// the prefix on.
|
|
7754
|
+
const coldTurn =
|
|
7755
|
+
!existing ||
|
|
7756
|
+
turnAt.getTime() - Date.parse(existing.checkpoint.ts) >=
|
|
7757
|
+
contextBudget.cacheColdMs;
|
|
7621
7758
|
// What the memory engine knows about the user, for the prompt's
|
|
7622
7759
|
// context block: started now so the round-trip overlaps the rest of
|
|
7623
7760
|
// the build, settled right before the agent is built.
|
|
@@ -7631,6 +7768,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7631
7768
|
prior: priorState.userContext,
|
|
7632
7769
|
client: req.client,
|
|
7633
7770
|
roomKind: req.roomKind,
|
|
7771
|
+
coldTurn,
|
|
7634
7772
|
});
|
|
7635
7773
|
|
|
7636
7774
|
// A Matrix turn with no usable delegation: ask the web app to open its
|
|
@@ -7665,7 +7803,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7665
7803
|
// A channel turn's files are read by their `channel:` reference; their
|
|
7666
7804
|
// links stay on the server (the run request and the session's link
|
|
7667
7805
|
// store), out of every message and note.
|
|
7668
|
-
const attachmentInputs = turnAttachmentInputs(req, body.attachments);
|
|
7669
7806
|
const attachmentModel =
|
|
7670
7807
|
effectiveModel && isAllowedModel(effectiveModel)
|
|
7671
7808
|
? effectiveModel
|
|
@@ -7681,6 +7818,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7681
7818
|
: undefined;
|
|
7682
7819
|
if (turnLinks.length && !run.resumed)
|
|
7683
7820
|
await this.channelAttachmentLinks?.put(req.sessionId, turnLinks);
|
|
7821
|
+
// A channel turn's files are also posted to the session thread as
|
|
7822
|
+
// encrypted media events (attachments/room-media.ts), in the
|
|
7823
|
+
// background: the run names each by its `channel:` reference, and the
|
|
7824
|
+
// copies read on the way reach the pipeline instead of a second
|
|
7825
|
+
// download. A resumed attempt posts nothing.
|
|
7826
|
+
const threadCopies =
|
|
7827
|
+
turnLinks.length && sessionRoomId
|
|
7828
|
+
? this.postChannelFilesToThread(
|
|
7829
|
+
req,
|
|
7830
|
+
sessionRoomId,
|
|
7831
|
+
abortController.signal,
|
|
7832
|
+
run.resumed,
|
|
7833
|
+
)
|
|
7834
|
+
: null;
|
|
7835
|
+
const attachmentInputs = turnAttachmentInputs(req, body.attachments);
|
|
7684
7836
|
const { prepared, metas: turnMetas } = await prepareTurnAttachments({
|
|
7685
7837
|
inputs: attachmentInputs,
|
|
7686
7838
|
resumed: run.resumed,
|
|
@@ -7696,22 +7848,28 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7696
7848
|
{
|
|
7697
7849
|
source: this.matrixMediaSource(),
|
|
7698
7850
|
...(channelMedia ? { channel: channelMedia } : {}),
|
|
7851
|
+
...(threadCopies ? { preloaded: threadCopies } : {}),
|
|
7699
7852
|
extraction: this.extractionProvider(baseAmbient.llm, core.llm),
|
|
7700
7853
|
sandbox: await this.sandboxArchiveConfig(req.identity.userDid),
|
|
7854
|
+
...(this.env.IMAGES ? { images: this.env.IMAGES } : {}),
|
|
7701
7855
|
signal: abortController.signal,
|
|
7702
7856
|
logger: console,
|
|
7703
7857
|
},
|
|
7704
7858
|
),
|
|
7705
7859
|
});
|
|
7860
|
+
// Copies the pipeline did not take (a file it refused) go now, not
|
|
7861
|
+
// with the turn.
|
|
7862
|
+
threadCopies?.clear();
|
|
7706
7863
|
if (prepared) {
|
|
7707
7864
|
console.log(
|
|
7708
7865
|
`[user-do] attachments: ${attachmentInputs.length} attached, ${prepared.extracted.length} extracted, ${Array.isArray(prepared.content) ? prepared.content.length - 1 : 0} native`,
|
|
7709
7866
|
);
|
|
7710
7867
|
}
|
|
7711
7868
|
|
|
7712
|
-
// `view_attachment` for this session:
|
|
7713
|
-
//
|
|
7714
|
-
// `
|
|
7869
|
+
// `view_attachment` for this session: attachment bytes are never
|
|
7870
|
+
// stored with the conversation (the checkpointer keeps a placeholder,
|
|
7871
|
+
// `src/sqlite/inline-payloads.ts`); the tool fetches one again on
|
|
7872
|
+
// demand.
|
|
7715
7873
|
// Cleanups registered by tools for THIS turn (MCP clients …): run once
|
|
7716
7874
|
// the turn ends, however it ends — see `RuntimeContext.onTurnEnd`.
|
|
7717
7875
|
abortController.signal.addEventListener('abort', () => {
|
|
@@ -7739,11 +7897,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7739
7897
|
suppliedContextOnly ? {} : priorState,
|
|
7740
7898
|
);
|
|
7741
7899
|
|
|
7742
|
-
// Capability router: predict the on-demand plugin this message needs
|
|
7743
|
-
// and preload it for THIS turn only. `on` is awaited here, ahead of the
|
|
7744
|
-
// first model call; `shadow` returns at once and logs its verdict when
|
|
7745
|
-
// it lands. The preload never enters `stateInput` — the checkpointed
|
|
7746
|
-
// `loadedPlugins` channel is written by `load_capability` alone.
|
|
7747
7900
|
const priorLoaded: ReadonlySet<string> = new Set(
|
|
7748
7901
|
priorMeta.loadedPlugins ?? [],
|
|
7749
7902
|
);
|
|
@@ -7762,45 +7915,49 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7762
7915
|
client: req.client,
|
|
7763
7916
|
env: langsmithEnvFromWorkerEnv(this.env),
|
|
7764
7917
|
});
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7918
|
+
// Capability router: from what is left to load this turn (the build
|
|
7919
|
+
// works that out from the turn's tools), the plugins this message needs.
|
|
7920
|
+
// `on` is awaited in the build, ahead of the first model call, and the
|
|
7921
|
+
// plugins load on the thread like any other; `shadow` returns at once
|
|
7922
|
+
// and logs its verdict when it lands. A resumed attempt keeps what the
|
|
7923
|
+
// run started with.
|
|
7924
|
+
const router = this.capabilityRouter;
|
|
7925
|
+
const routeCapabilities =
|
|
7926
|
+
!suppliedContextOnly && !run.resumed && router
|
|
7927
|
+
? (candidates: readonly RegisteredManifest[]) =>
|
|
7928
|
+
router({
|
|
7929
|
+
mode: capabilityRouterMode(core.validatedEnv.CAPABILITY_ROUTER),
|
|
7930
|
+
candidates,
|
|
7931
|
+
...preloadThreshold(coldTurn, core.validatedEnv),
|
|
7932
|
+
text: body.message,
|
|
7933
|
+
recentTurns: async () =>
|
|
7934
|
+
recentTurnTexts(
|
|
7935
|
+
await saver.listRecentThreadMessages(
|
|
7936
|
+
req.sessionId,
|
|
7937
|
+
ROUTER_RECENT_ROWS,
|
|
7780
7938
|
),
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7939
|
+
ROUTER_RECENT_TURNS,
|
|
7940
|
+
ROUTER_RECENT_TURN_CHARS,
|
|
7941
|
+
),
|
|
7942
|
+
requestId: req.requestId,
|
|
7943
|
+
signal: abortController.signal,
|
|
7944
|
+
trace: {
|
|
7945
|
+
...(tracing.callbacks && { callbacks: tracing.callbacks }),
|
|
7946
|
+
// `thread_id` matches the graph run's, so LangSmith's thread
|
|
7947
|
+
// view groups the router span with the turn.
|
|
7948
|
+
metadata: {
|
|
7949
|
+
...tracing.metadata,
|
|
7950
|
+
thread_id: req.sessionId,
|
|
7951
|
+
request_id: req.requestId,
|
|
7952
|
+
},
|
|
7795
7953
|
},
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
})
|
|
7954
|
+
onShadowVerdict: (wouldPreload) =>
|
|
7955
|
+
this.shadowRoutes.set(req.sessionId, {
|
|
7956
|
+
requestId: req.requestId,
|
|
7957
|
+
priorLoaded,
|
|
7958
|
+
wouldPreload,
|
|
7959
|
+
}),
|
|
7960
|
+
})
|
|
7804
7961
|
: undefined;
|
|
7805
7962
|
|
|
7806
7963
|
// Host page-context / safety-guardrail hooks, resolved against this
|
|
@@ -7971,13 +8128,44 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7971
8128
|
)
|
|
7972
8129
|
: undefined;
|
|
7973
8130
|
|
|
8131
|
+
// On a cold turn the thread's history shows which capabilities it has
|
|
8132
|
+
// moved on from. The messages are read only then, and only for a
|
|
8133
|
+
// thread with something to unload; the editor stays while its context
|
|
8134
|
+
// is active, and a resumed attempt keeps what the run started with.
|
|
8135
|
+
const unloadable = (priorMeta.loadedPlugins ?? []).filter(
|
|
8136
|
+
(plugin) =>
|
|
8137
|
+
plugin !== EDITOR_PLUGIN_NAME ||
|
|
8138
|
+
!editorContextActive(meta, priorMeta),
|
|
8139
|
+
);
|
|
8140
|
+
const capabilityExpiry =
|
|
8141
|
+
coldTurn &&
|
|
8142
|
+
!run.resumed &&
|
|
8143
|
+
contextBudget.pluginIdleTurns > 0 &&
|
|
8144
|
+
unloadable.length > 0
|
|
8145
|
+
? {
|
|
8146
|
+
loaded: unloadable,
|
|
8147
|
+
maxIdleTurns: contextBudget.pluginIdleTurns,
|
|
8148
|
+
messages: await this.contextMessages(req.sessionId),
|
|
8149
|
+
}
|
|
8150
|
+
: undefined;
|
|
8151
|
+
|
|
7974
8152
|
const built = await createMainAgent({
|
|
7975
8153
|
executionProfile: req.executionProfile,
|
|
8154
|
+
...(capabilityExpiry ? { capabilityExpiry } : {}),
|
|
7976
8155
|
registries: core.registries,
|
|
7977
8156
|
identity: core.identity,
|
|
7978
8157
|
config: core.validatedEnv,
|
|
7979
8158
|
availablePlugins: core.availablePlugins,
|
|
7980
|
-
|
|
8159
|
+
...(routeCapabilities ? { routeCapabilities } : {}),
|
|
8160
|
+
promptCache: {
|
|
8161
|
+
cold: coldTurn && !run.resumed,
|
|
8162
|
+
...(priorMeta.promptGuides
|
|
8163
|
+
? { promptGuides: priorMeta.promptGuides }
|
|
8164
|
+
: {}),
|
|
8165
|
+
...(priorMeta.capabilityUsage
|
|
8166
|
+
? { capabilityUsage: priorMeta.capabilityUsage }
|
|
8167
|
+
: {}),
|
|
8168
|
+
},
|
|
7981
8169
|
byoProvider: byoTurn?.provider,
|
|
7982
8170
|
contextBudget,
|
|
7983
8171
|
turnBudget: budget,
|
|
@@ -8038,6 +8226,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8038
8226
|
}
|
|
8039
8227
|
: {}),
|
|
8040
8228
|
...(hostSafetyModel ? { safetyModel: hostSafetyModel(ambient) } : {}),
|
|
8229
|
+
// The images of the newest user turns, back in each model request
|
|
8230
|
+
// (their bytes are not stored with the conversation).
|
|
8231
|
+
...(suppliedContextOnly
|
|
8232
|
+
? {}
|
|
8233
|
+
: {
|
|
8234
|
+
attachmentRecall: {
|
|
8235
|
+
loadImage: this.recallImageLoader({
|
|
8236
|
+
sessionId: req.sessionId,
|
|
8237
|
+
roomId: sessionRoomId,
|
|
8238
|
+
channelLinks: turnLinks,
|
|
8239
|
+
}),
|
|
8240
|
+
imageInput: getModelCapabilities(attachmentModel).image,
|
|
8241
|
+
signal: abortController.signal,
|
|
8242
|
+
cache: this.recallCache,
|
|
8243
|
+
},
|
|
8244
|
+
}),
|
|
8041
8245
|
},
|
|
8042
8246
|
requestCtx: {
|
|
8043
8247
|
user: {
|
|
@@ -8085,7 +8289,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8085
8289
|
checkpointer: saver,
|
|
8086
8290
|
abortSignal: abortController.signal,
|
|
8087
8291
|
});
|
|
8088
|
-
const { agent, context } = built;
|
|
8292
|
+
const { agent, context, guideNote } = built;
|
|
8089
8293
|
for (const [name, effect] of built.toolEffects)
|
|
8090
8294
|
toolEffects.set(name, effect);
|
|
8091
8295
|
for (const [name, surface] of built.toolSurfaces)
|
|
@@ -8138,9 +8342,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8138
8342
|
const referenced = replyNote
|
|
8139
8343
|
? withTurnTimeNote(noticed, replyNote)
|
|
8140
8344
|
: noticed;
|
|
8141
|
-
const
|
|
8142
|
-
? withTurnTimeNote(referenced,
|
|
8345
|
+
const guided = guideNote
|
|
8346
|
+
? withTurnTimeNote(referenced, guideNote.text)
|
|
8143
8347
|
: referenced;
|
|
8348
|
+
const content = timeNote ? withTurnTimeNote(guided, timeNote) : guided;
|
|
8144
8349
|
const human = new HumanMessage({
|
|
8145
8350
|
content,
|
|
8146
8351
|
additional_kwargs: {
|
|
@@ -8150,6 +8355,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8150
8355
|
...(noticeNote
|
|
8151
8356
|
? { [CHANNEL_DELIVERY_NOTICE_KWARG]: noticeNote }
|
|
8152
8357
|
: {}),
|
|
8358
|
+
...(guideNote
|
|
8359
|
+
? {
|
|
8360
|
+
[TURN_GUIDE_NOTE_KWARG]: guideNote.text,
|
|
8361
|
+
[USAGE_CARRIED_KWARG]: guideNote.plugins,
|
|
8362
|
+
}
|
|
8363
|
+
: {}),
|
|
8153
8364
|
oracleName: core.identity.name,
|
|
8154
8365
|
msgFromMatrixRoom: req.client === 'matrix',
|
|
8155
8366
|
...speakerKwargs,
|
|
@@ -8180,6 +8391,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8180
8391
|
// every slot present; later turns write nothing and keep it.
|
|
8181
8392
|
...(userContext ? { userContext } : {}),
|
|
8182
8393
|
...metadataGraphInput(meta, priorMeta),
|
|
8394
|
+
...built.stateUpdate,
|
|
8183
8395
|
...surface.input,
|
|
8184
8396
|
};
|
|
8185
8397
|
const config = {
|
|
@@ -8241,6 +8453,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8241
8453
|
text: string,
|
|
8242
8454
|
who: 'user' | 'oracle',
|
|
8243
8455
|
receipt = false,
|
|
8456
|
+
): Promise<void> {
|
|
8457
|
+
const queued = this.queueReplay(req, text, who, receipt);
|
|
8458
|
+
// The turn's file events follow the person's text on the chain.
|
|
8459
|
+
if (who === 'user') this.noteChannelTextQueued(req);
|
|
8460
|
+
return queued;
|
|
8461
|
+
}
|
|
8462
|
+
|
|
8463
|
+
private queueReplay(
|
|
8464
|
+
req: TurnRequest,
|
|
8465
|
+
text: string,
|
|
8466
|
+
who: 'user' | 'oracle',
|
|
8467
|
+
receipt: boolean,
|
|
8244
8468
|
): Promise<void> {
|
|
8245
8469
|
if (req.client === 'matrix' && !receipt) return Promise.resolve();
|
|
8246
8470
|
if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX))
|
|
@@ -8308,7 +8532,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8308
8532
|
send.roomId,
|
|
8309
8533
|
'm.room.message',
|
|
8310
8534
|
JSON.stringify(mirrorEventContent(send)),
|
|
8311
|
-
{ txnId: send.txnId },
|
|
8535
|
+
{ txnId: send.txnId, priority: 'background' },
|
|
8312
8536
|
)
|
|
8313
8537
|
: this.gateway.sendText(send.roomId, send.body, {
|
|
8314
8538
|
threadId: send.threadId,
|
|
@@ -8318,6 +8542,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8318
8542
|
priority: 'background',
|
|
8319
8543
|
txnId: send.txnId,
|
|
8320
8544
|
}),
|
|
8545
|
+
sendEvent: (roomId, content, txnId) =>
|
|
8546
|
+
this.gateway.sendEvent(roomId, 'm.room.message', content, {
|
|
8547
|
+
txnId,
|
|
8548
|
+
priority: 'background',
|
|
8549
|
+
}),
|
|
8321
8550
|
keepAlive: (work) => this.ctx.waitUntil(work),
|
|
8322
8551
|
log: (message) => console.log(message),
|
|
8323
8552
|
warn: (message) => console.warn(message),
|
|
@@ -8325,6 +8554,271 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8325
8554
|
return this.mirror;
|
|
8326
8555
|
}
|
|
8327
8556
|
|
|
8557
|
+
/**
|
|
8558
|
+
* The oracle's plugin device (`botCredentials`): what channel files are
|
|
8559
|
+
* uploaded and Matrix media downloaded with. One gateway RPC per
|
|
8560
|
+
* instance, shared by concurrent callers. A caller the homeserver
|
|
8561
|
+
* answered 401 passes the token it used as `rejected`: the cache is
|
|
8562
|
+
* dropped only when it still holds that token, and the gateway
|
|
8563
|
+
* re-validates its device (or logs it in again) once for everyone.
|
|
8564
|
+
*/
|
|
8565
|
+
private pluginDevice(
|
|
8566
|
+
opts: { rejected?: string } = {},
|
|
8567
|
+
): Promise<BotCredentials> {
|
|
8568
|
+
if (
|
|
8569
|
+
opts.rejected &&
|
|
8570
|
+
this.pluginCredentials?.accessToken === opts.rejected
|
|
8571
|
+
)
|
|
8572
|
+
this.pluginCredentials = null;
|
|
8573
|
+
if (this.pluginCredentials)
|
|
8574
|
+
return Promise.resolve(this.pluginCredentials);
|
|
8575
|
+
this.pluginCredentialsLoad ??= this.gateway
|
|
8576
|
+
.botCredentials(opts.rejected ? { rejected: opts.rejected } : {})
|
|
8577
|
+
.then((credentials) => {
|
|
8578
|
+
this.pluginCredentials = credentials;
|
|
8579
|
+
return credentials;
|
|
8580
|
+
})
|
|
8581
|
+
.finally(() => {
|
|
8582
|
+
this.pluginCredentialsLoad = null;
|
|
8583
|
+
});
|
|
8584
|
+
return this.pluginCredentialsLoad;
|
|
8585
|
+
}
|
|
8586
|
+
|
|
8587
|
+
/**
|
|
8588
|
+
* The mirror pass has queued the person's text of channel request
|
|
8589
|
+
* `req` on the session chain (or found none to queue): release the
|
|
8590
|
+
* request's file events waiting for it, or remember it for a turn that
|
|
8591
|
+
* has not asked yet. Only requests with files are tracked; entries older
|
|
8592
|
+
* than the wait are dropped.
|
|
8593
|
+
*/
|
|
8594
|
+
private noteChannelTextQueued(req: TurnRequest): void {
|
|
8595
|
+
if (req.client !== 'channel' || !req.channel?.attachments?.length) return;
|
|
8596
|
+
const entry = this.channelTextQueued.get(req.requestId);
|
|
8597
|
+
if (entry?.waiter) {
|
|
8598
|
+
this.channelTextQueued.delete(req.requestId);
|
|
8599
|
+
entry.waiter();
|
|
8600
|
+
return;
|
|
8601
|
+
}
|
|
8602
|
+
const now = Date.now();
|
|
8603
|
+
for (const [requestId, noted] of this.channelTextQueued)
|
|
8604
|
+
if (!noted.waiter && now - noted.at > CHANNEL_MEDIA_TEXT_WAIT_MS)
|
|
8605
|
+
this.channelTextQueued.delete(requestId);
|
|
8606
|
+
this.channelTextQueued.set(req.requestId, { at: now });
|
|
8607
|
+
}
|
|
8608
|
+
|
|
8609
|
+
/**
|
|
8610
|
+
* `queued` resolves once the person's text of `requestId` is queued on
|
|
8611
|
+
* the session chain (true), after `CHANNEL_MEDIA_TEXT_WAIT_MS` (false),
|
|
8612
|
+
* or with null when `cancel` is called first (nothing left to post).
|
|
8613
|
+
*/
|
|
8614
|
+
private afterChannelText(requestId: string): {
|
|
8615
|
+
queued: Promise<boolean | null>;
|
|
8616
|
+
cancel: () => void;
|
|
8617
|
+
} {
|
|
8618
|
+
const noted = this.channelTextQueued.get(requestId);
|
|
8619
|
+
if (noted && !noted.waiter) {
|
|
8620
|
+
this.channelTextQueued.delete(requestId);
|
|
8621
|
+
return { queued: Promise.resolve(true), cancel: () => undefined };
|
|
8622
|
+
}
|
|
8623
|
+
let settle: (value: boolean | null) => void = () => undefined;
|
|
8624
|
+
const queued = new Promise<boolean | null>((resolve) => {
|
|
8625
|
+
settle = resolve;
|
|
8626
|
+
});
|
|
8627
|
+
const finish = (value: boolean | null): void => {
|
|
8628
|
+
clearTimeout(timer);
|
|
8629
|
+
if (this.channelTextQueued.get(requestId)?.waiter === waiter)
|
|
8630
|
+
this.channelTextQueued.delete(requestId);
|
|
8631
|
+
settle(value);
|
|
8632
|
+
};
|
|
8633
|
+
const waiter = (): void => finish(true);
|
|
8634
|
+
const timer = setTimeout(() => finish(false), CHANNEL_MEDIA_TEXT_WAIT_MS);
|
|
8635
|
+
this.channelTextQueued.set(requestId, { at: Date.now(), waiter });
|
|
8636
|
+
return { queued, cancel: () => finish(null) };
|
|
8637
|
+
}
|
|
8638
|
+
|
|
8639
|
+
/**
|
|
8640
|
+
* Post a channel turn's files as encrypted media events in the session
|
|
8641
|
+
* thread (`attachments/room-media.ts`), off the turn's critical path.
|
|
8642
|
+
* Returns at once: per file the turn may read directly
|
|
8643
|
+
* (`channelFilesToKeep`), a promise of its verified plaintext, resolved
|
|
8644
|
+
* as soon as the read passes its check, or null when no copy comes (the
|
|
8645
|
+
* pipeline then reads the gateway as before). In the background:
|
|
8646
|
+
*
|
|
8647
|
+
* - the encryption check and the plugin device, bounded with the uploads'
|
|
8648
|
+
* reads by `CHANNEL_MEDIA_TURN_TIMEOUT_MS` from now and by the run's
|
|
8649
|
+
* signal; on a failure (or a room that is not Megolm-encrypted) one
|
|
8650
|
+
* line is logged and nothing is posted;
|
|
8651
|
+
* - the uploads (`startChannelFileUploads`), kept alive by `waitUntil`;
|
|
8652
|
+
* - one event per file on the session's mirror chain, queued in message
|
|
8653
|
+
* order right after the person's text (`afterChannelText`) at
|
|
8654
|
+
* background priority, each within `CHANNEL_MEDIA_SEND_TIMEOUT_MS`;
|
|
8655
|
+
* their event ids then go to the link store (`setEventIds`), whose room
|
|
8656
|
+
* copies serve the files once their links expire.
|
|
8657
|
+
*
|
|
8658
|
+
* A resumed attempt posts nothing: its first attempt did, or lost the
|
|
8659
|
+
* uploads with the object, and the person's message is in the
|
|
8660
|
+
* checkpoint already.
|
|
8661
|
+
*/
|
|
8662
|
+
private postChannelFilesToThread(
|
|
8663
|
+
req: TurnRequest,
|
|
8664
|
+
roomId: string,
|
|
8665
|
+
runSignal: AbortSignal,
|
|
8666
|
+
resumed: boolean,
|
|
8667
|
+
): Map<string, Promise<ThreadFileCopy | null>> {
|
|
8668
|
+
const copies = new Map<string, Promise<ThreadFileCopy | null>>();
|
|
8669
|
+
const files = channelThreadFiles(req);
|
|
8670
|
+
const channel = req.channel;
|
|
8671
|
+
// A channel session is always a thread under its marker event.
|
|
8672
|
+
if (
|
|
8673
|
+
resumed ||
|
|
8674
|
+
!files.length ||
|
|
8675
|
+
!channel ||
|
|
8676
|
+
!req.sessionId.startsWith('$')
|
|
8677
|
+
)
|
|
8678
|
+
return copies;
|
|
8679
|
+
const keep = channelFilesToKeep(files);
|
|
8680
|
+
const pending = new Map<
|
|
8681
|
+
string,
|
|
8682
|
+
(copy: Promise<ThreadFileCopy | null> | null) => void
|
|
8683
|
+
>();
|
|
8684
|
+
for (const ref of keep)
|
|
8685
|
+
copies.set(
|
|
8686
|
+
ref,
|
|
8687
|
+
new Promise((resolve) => {
|
|
8688
|
+
pending.set(ref, resolve);
|
|
8689
|
+
}),
|
|
8690
|
+
);
|
|
8691
|
+
const textOrder = this.afterChannelText(req.requestId);
|
|
8692
|
+
const deadline = Date.now() + CHANNEL_MEDIA_TURN_TIMEOUT_MS;
|
|
8693
|
+
|
|
8694
|
+
const started = (async (): Promise<ChannelFileUpload[]> => {
|
|
8695
|
+
const setup = boundedSignal(
|
|
8696
|
+
runSignal,
|
|
8697
|
+
CHANNEL_MEDIA_TURN_TIMEOUT_MS,
|
|
8698
|
+
'Preparing the channel file posts',
|
|
8699
|
+
);
|
|
8700
|
+
try {
|
|
8701
|
+
const [encrypted, device] = await untilAborted(
|
|
8702
|
+
Promise.all([this.roomIsEncrypted(roomId), this.pluginDevice()]),
|
|
8703
|
+
setup.signal,
|
|
8704
|
+
);
|
|
8705
|
+
if (!encrypted) {
|
|
8706
|
+
console.warn(
|
|
8707
|
+
`[attachments] channel files of ${req.sessionId} not posted to its thread: the room is not end-to-end encrypted`,
|
|
8708
|
+
);
|
|
8709
|
+
return [];
|
|
8710
|
+
}
|
|
8711
|
+
const run = startChannelFileUploads({
|
|
8712
|
+
files,
|
|
8713
|
+
sessionId: req.sessionId,
|
|
8714
|
+
deps: {
|
|
8715
|
+
origins: channelGatewayOrigins(this.env.CHANNEL_SERVICE_DID),
|
|
8716
|
+
homeserver: {
|
|
8717
|
+
baseUrl: device.baseUrl,
|
|
8718
|
+
accessToken: device.accessToken,
|
|
8719
|
+
},
|
|
8720
|
+
keep,
|
|
8721
|
+
turnTimeoutMs: Math.max(0, deadline - Date.now()),
|
|
8722
|
+
onUnauthorized: (token) => {
|
|
8723
|
+
void this.pluginDevice({ rejected: token }).catch(
|
|
8724
|
+
() => undefined,
|
|
8725
|
+
);
|
|
8726
|
+
},
|
|
8727
|
+
log: console,
|
|
8728
|
+
},
|
|
8729
|
+
});
|
|
8730
|
+
this.ctx.waitUntil(run.done);
|
|
8731
|
+
for (const upload of run.uploads)
|
|
8732
|
+
pending.get(upload.file.ref)?.(upload.copy);
|
|
8733
|
+
return run.uploads;
|
|
8734
|
+
} catch (error) {
|
|
8735
|
+
console.warn(
|
|
8736
|
+
`[attachments] channel files of ${req.sessionId} not posted to its thread; the turn reads them from the channel gateway: ${error instanceof Error ? error.message : String(error)}`,
|
|
8737
|
+
);
|
|
8738
|
+
return [];
|
|
8739
|
+
} finally {
|
|
8740
|
+
setup.release();
|
|
8741
|
+
// A copy not handed over by now never comes (a resolved one stays).
|
|
8742
|
+
for (const resolve of pending.values()) resolve(null);
|
|
8743
|
+
}
|
|
8744
|
+
})();
|
|
8745
|
+
|
|
8746
|
+
const origin = {
|
|
8747
|
+
provider: channel.provider,
|
|
8748
|
+
bindingId: channel.bindingId,
|
|
8749
|
+
remoteMessageRef: channel.remoteMessageRef,
|
|
8750
|
+
};
|
|
8751
|
+
// Nothing to post: the events need not wait for the text.
|
|
8752
|
+
void started.then((uploads) => {
|
|
8753
|
+
if (uploads.length === 0) textOrder.cancel();
|
|
8754
|
+
});
|
|
8755
|
+
const posted = textOrder.queued.then(async (afterText) => {
|
|
8756
|
+
if (afterText === null) return;
|
|
8757
|
+
if (!afterText)
|
|
8758
|
+
console.warn(
|
|
8759
|
+
`[attachments] the text of ${req.requestId} was not queued within ${CHANNEL_MEDIA_TEXT_WAIT_MS} ms; its files are queued without it`,
|
|
8760
|
+
);
|
|
8761
|
+
// Queued synchronously, one after the other: the chain keeps the
|
|
8762
|
+
// message order whichever upload ends first.
|
|
8763
|
+
const sent = await Promise.all(
|
|
8764
|
+
files.map(async (file): Promise<[string, string] | null> => {
|
|
8765
|
+
const bound = boundedSignal(
|
|
8766
|
+
undefined,
|
|
8767
|
+
CHANNEL_MEDIA_SEND_TIMEOUT_MS,
|
|
8768
|
+
'Posting the channel file',
|
|
8769
|
+
);
|
|
8770
|
+
try {
|
|
8771
|
+
const eventId = await this.roomMirror().sendMedia(
|
|
8772
|
+
req.sessionId,
|
|
8773
|
+
async () => {
|
|
8774
|
+
const upload = (await started).find(
|
|
8775
|
+
(entry) => entry.file.ref === file.ref,
|
|
8776
|
+
);
|
|
8777
|
+
// A failed upload is logged by `startChannelFileUploads`.
|
|
8778
|
+
const media = await upload?.uploaded.catch(() => null);
|
|
8779
|
+
if (!media) return null;
|
|
8780
|
+
return {
|
|
8781
|
+
roomId,
|
|
8782
|
+
threadId: req.sessionId,
|
|
8783
|
+
txnId: await channelMediaTxnId({
|
|
8784
|
+
userDid: req.identity.userDid,
|
|
8785
|
+
bindingId: channel.bindingId,
|
|
8786
|
+
requestId: req.requestId,
|
|
8787
|
+
index: file.index,
|
|
8788
|
+
}),
|
|
8789
|
+
origin,
|
|
8790
|
+
media,
|
|
8791
|
+
};
|
|
8792
|
+
},
|
|
8793
|
+
'channel file',
|
|
8794
|
+
bound.signal,
|
|
8795
|
+
);
|
|
8796
|
+
return eventId ? [file.ref, eventId] : null;
|
|
8797
|
+
} catch (error) {
|
|
8798
|
+
console.warn(
|
|
8799
|
+
`[attachments] channel file ${file.ref} not posted to thread ${req.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
8800
|
+
);
|
|
8801
|
+
return null;
|
|
8802
|
+
} finally {
|
|
8803
|
+
bound.release();
|
|
8804
|
+
}
|
|
8805
|
+
}),
|
|
8806
|
+
);
|
|
8807
|
+
const eventIds = new Map(
|
|
8808
|
+
sent.filter((entry): entry is [string, string] => entry !== null),
|
|
8809
|
+
);
|
|
8810
|
+
await this.channelAttachmentLinks?.setEventIds(req.sessionId, eventIds);
|
|
8811
|
+
});
|
|
8812
|
+
this.ctx.waitUntil(
|
|
8813
|
+
posted.catch((error: unknown) => {
|
|
8814
|
+
console.warn(
|
|
8815
|
+
`[attachments] channel file posts of ${req.sessionId} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
8816
|
+
);
|
|
8817
|
+
}),
|
|
8818
|
+
);
|
|
8819
|
+
return copies;
|
|
8820
|
+
}
|
|
8821
|
+
|
|
8328
8822
|
private async afterTurn(
|
|
8329
8823
|
sessionId: string,
|
|
8330
8824
|
messages: BaseMessage[],
|
|
@@ -8333,13 +8827,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
8333
8827
|
): Promise<void> {
|
|
8334
8828
|
const sessions = this.sessions!;
|
|
8335
8829
|
await sessions.touchSession(sessionId);
|
|
8336
|
-
await this.retainAttachmentPayloads(sessionId, messages).catch(
|
|
8337
|
-
(err: unknown) => {
|
|
8338
|
-
console.warn(
|
|
8339
|
-
`[user-do] attachment payload retention failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
8340
|
-
);
|
|
8341
|
-
},
|
|
8342
|
-
);
|
|
8343
8830
|
if (disposition.kind === 'agent')
|
|
8344
8831
|
await this.compareShadowRoute(sessionId);
|
|
8345
8832
|
const row = await sessions.getSession(sessionId);
|