@ixo/oracle-runtime-workers 0.35.0 → 0.36.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/artifacts/tool.ts +7 -4
- package/src/attachments/pipeline.ts +3 -1
- package/src/attachments/route.ts +11 -0
- package/src/attachments/view.ts +5 -1
- package/src/channels/contract.ts +3 -2
- package/src/channels/turns.ts +27 -9
- package/src/core/env.ts +4 -2
- package/src/core/llm.ts +70 -1
- package/src/core/main-agent.ts +5 -0
- package/src/core/middlewares/result-cap.ts +2 -0
- package/src/core/middlewares/turn-boundary.ts +14 -0
- package/src/delivery/plan.ts +89 -20
- package/src/delivery/profile.ts +25 -20
- package/src/delivery/prompt.ts +33 -7
- package/src/delivery/schema.ts +69 -5
- package/src/delivery/shaper.ts +329 -87
- package/src/delivery/types.ts +48 -8
- package/src/do/contracts.ts +2 -0
- package/src/do/run-coordinator.ts +13 -3
- package/src/do/run-store.ts +53 -3
- package/src/do/transcript.ts +17 -1
- package/src/do/user-oracle-do.ts +51 -3
- package/src/matrix/reply-parts.ts +9 -1
- package/src/plugins/ixo-chain/names/client.ts +7 -3
- package/src/plugins/sandbox/index.ts +1 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +41 -8
- package/src/plugins/sandbox/send-file.ts +302 -0
- package/src/sqlite/blob-codec.ts +16 -31
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './run-buffer';
|
|
20
20
|
import {
|
|
21
21
|
attemptSeqBase,
|
|
22
|
+
decideOrphanRecovery,
|
|
22
23
|
decideRecovery,
|
|
23
24
|
patchRunRecord,
|
|
24
25
|
runHasCheckpointed,
|
|
@@ -813,7 +814,9 @@ export class RunCoordinator {
|
|
|
813
814
|
* boot is a new instance, so anything active that this coordinator does
|
|
814
815
|
* not hold is an orphan: queued runs are re-opened (and started when
|
|
815
816
|
* their session is idle); running ones get a recovery attempt scheduled
|
|
816
|
-
* with the backoff, or are closed as interrupted after
|
|
817
|
+
* with the backoff, or are closed as interrupted after either cap
|
|
818
|
+
* (`decideOrphanRecovery`: consecutive attempts without progress, or
|
|
819
|
+
* recoveries in all).
|
|
817
820
|
*/
|
|
818
821
|
async recoverOrphans(): Promise<void> {
|
|
819
822
|
const active = await this.host.store.listActive();
|
|
@@ -826,7 +829,12 @@ export class RunCoordinator {
|
|
|
826
829
|
continue;
|
|
827
830
|
}
|
|
828
831
|
const current = await this.host.checkpointIdOf(record.sessionId);
|
|
829
|
-
const decision =
|
|
832
|
+
const decision = decideOrphanRecovery(
|
|
833
|
+
record,
|
|
834
|
+
current,
|
|
835
|
+
now,
|
|
836
|
+
this.host.config,
|
|
837
|
+
);
|
|
830
838
|
// Everything packed so far: the cursor continues after it (the row's
|
|
831
839
|
// `lastSeq` is only written when a run ends) and the continuation note
|
|
832
840
|
// shows the model what the user already read.
|
|
@@ -844,7 +852,9 @@ export class RunCoordinator {
|
|
|
844
852
|
const startSeq = Math.max(lastPacked, attemptSeqBase(generation));
|
|
845
853
|
if (decision.action === 'interrupt') {
|
|
846
854
|
this.host.log.warn(
|
|
847
|
-
|
|
855
|
+
decision.reason === 'total'
|
|
856
|
+
? `[runs] ${record.runId}: recovered ${record.generation} time(s) in all (cap ${this.host.config.recoveryTotalAttempts}); closing as interrupted`
|
|
857
|
+
: `[runs] ${record.runId}: ${decision.attempts} recovery attempt(s) without progress; closing as interrupted`,
|
|
848
858
|
);
|
|
849
859
|
const updatedAt = await this.host.store.update(record.runId, {
|
|
850
860
|
generation,
|
package/src/do/run-store.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* closed when the run ends. It carries what a later incarnation of the
|
|
8
8
|
* object needs to resume the run after a reset (the request), how many
|
|
9
9
|
* recovery attempts were spent without progress, and the checkpoint id
|
|
10
|
-
* seen at the last attempt (progress resets the counter)
|
|
10
|
+
* seen at the last attempt (progress resets the counter), and how many
|
|
11
|
+
* recoveries it got in all (`generation`, never reset).
|
|
11
12
|
* - `turn_run_segments`: the run's output, packed by `RunBuffer` into one
|
|
12
13
|
* row per flush, so a client can re-join after any cursor and a recovery
|
|
13
14
|
* can restore exactly what the user had already seen. Deleted at cutover.
|
|
@@ -117,6 +118,7 @@ export interface RunRecord {
|
|
|
117
118
|
* are numbered from `attemptSeqBase(g)`, so a client's cursor from an
|
|
118
119
|
* earlier attempt — even one pointing at frames that were never packed
|
|
119
120
|
* before the reset — is below every frame a later attempt produces.
|
|
121
|
+
* Also the count `decideOrphanRecovery` caps (`recoveryTotalAttempts`).
|
|
120
122
|
*/
|
|
121
123
|
generation: number;
|
|
122
124
|
/** When the next recovery attempt is due (ms since epoch), while `recovering`. */
|
|
@@ -204,6 +206,14 @@ export const DEFAULT_RECOVERY_DELAYS_MS: readonly number[] = [
|
|
|
204
206
|
5_000, 15_000, 30_000, 60_000,
|
|
205
207
|
];
|
|
206
208
|
export const DEFAULT_RECOVERY_ATTEMPTS = 4;
|
|
209
|
+
/**
|
|
210
|
+
* Recoveries a run gets in all, progress or not (`generation` is never
|
|
211
|
+
* reset). Each one is a restart of the object while the run was active; a
|
|
212
|
+
* turn that resets the isolate every time it makes a step (one checkpoint,
|
|
213
|
+
* then the next model call exceeds the memory limit) would otherwise be
|
|
214
|
+
* resumed for as long as each attempt checkpoints once.
|
|
215
|
+
*/
|
|
216
|
+
export const DEFAULT_RECOVERY_TOTAL_ATTEMPTS = 8;
|
|
207
217
|
export const DEFAULT_KEEPALIVE_MS = 20_000;
|
|
208
218
|
export const DEFAULT_SEGMENT_FLUSH_MS = 2_000;
|
|
209
219
|
export const DEFAULT_SEGMENT_BYTES = 16 * 1024;
|
|
@@ -213,6 +223,8 @@ export interface RunDurabilityConfig {
|
|
|
213
223
|
segmentFlushMs: number;
|
|
214
224
|
segmentBytes: number;
|
|
215
225
|
recoveryAttempts: number;
|
|
226
|
+
/** Recoveries in all, progress or not (`DEFAULT_RECOVERY_TOTAL_ATTEMPTS`). */
|
|
227
|
+
recoveryTotalAttempts: number;
|
|
216
228
|
recoveryDelaysMs: readonly number[];
|
|
217
229
|
multitaskDefault: 'interrupt' | 'enqueue';
|
|
218
230
|
}
|
|
@@ -244,6 +256,11 @@ export function runDurabilityConfig(
|
|
|
244
256
|
DEFAULT_RECOVERY_ATTEMPTS,
|
|
245
257
|
0,
|
|
246
258
|
),
|
|
259
|
+
recoveryTotalAttempts: int(
|
|
260
|
+
'RUN_RECOVERY_TOTAL_ATTEMPTS',
|
|
261
|
+
DEFAULT_RECOVERY_TOTAL_ATTEMPTS,
|
|
262
|
+
0,
|
|
263
|
+
),
|
|
247
264
|
recoveryDelaysMs: delays.length > 0 ? delays : DEFAULT_RECOVERY_DELAYS_MS,
|
|
248
265
|
multitaskDefault: multitask === 'enqueue' ? 'enqueue' : 'interrupt',
|
|
249
266
|
};
|
|
@@ -277,7 +294,16 @@ export function runHasCheckpointed(
|
|
|
277
294
|
|
|
278
295
|
export type RecoveryDecision =
|
|
279
296
|
| { action: 'schedule'; at: number; attempts: number }
|
|
280
|
-
| {
|
|
297
|
+
| {
|
|
298
|
+
action: 'interrupt';
|
|
299
|
+
attempts: number;
|
|
300
|
+
/**
|
|
301
|
+
* `no-progress`: `recoveryAttempts` consecutive attempts left the
|
|
302
|
+
* checkpoint where it was. `total`: the run was recovered
|
|
303
|
+
* `recoveryTotalAttempts` times in all (`decideOrphanRecovery`).
|
|
304
|
+
*/
|
|
305
|
+
reason: 'no-progress' | 'total';
|
|
306
|
+
};
|
|
281
307
|
|
|
282
308
|
/**
|
|
283
309
|
* What to do with an orphaned run found at boot (or an attempt that just
|
|
@@ -294,12 +320,36 @@ export function decideRecovery(
|
|
|
294
320
|
const progressed = checkpointAdvanced(run.checkpointId, currentCheckpointId);
|
|
295
321
|
const spent = progressed ? 0 : run.attempts;
|
|
296
322
|
if (spent >= config.recoveryAttempts)
|
|
297
|
-
return { action: 'interrupt', attempts: spent };
|
|
323
|
+
return { action: 'interrupt', attempts: spent, reason: 'no-progress' };
|
|
298
324
|
const delays = config.recoveryDelaysMs;
|
|
299
325
|
const delay = delays[Math.min(spent, delays.length - 1)] ?? 0;
|
|
300
326
|
return { action: 'schedule', at: now + delay, attempts: spent + 1 };
|
|
301
327
|
}
|
|
302
328
|
|
|
329
|
+
/**
|
|
330
|
+
* What to do with a run a previous incarnation left running or recovering:
|
|
331
|
+
* `decideRecovery`, once the run is still within its recoveries in all.
|
|
332
|
+
* `generation` counts every recovery the run got and is never reset, so a
|
|
333
|
+
* run that checkpoints once per attempt and then resets the object again
|
|
334
|
+
* (progress each time, so `attempts` stays at 1) is closed after
|
|
335
|
+
* `recoveryTotalAttempts` recoveries instead of being resumed until its
|
|
336
|
+
* client gives up. A deferral (`RunAttemptDeferred`) is not a restart and
|
|
337
|
+
* is bounded by `decideRecovery` alone.
|
|
338
|
+
*/
|
|
339
|
+
export function decideOrphanRecovery(
|
|
340
|
+
run: Pick<RunRecord, 'attempts' | 'checkpointId' | 'generation'>,
|
|
341
|
+
currentCheckpointId: string | null,
|
|
342
|
+
now: number,
|
|
343
|
+
config: Pick<
|
|
344
|
+
RunDurabilityConfig,
|
|
345
|
+
'recoveryAttempts' | 'recoveryTotalAttempts' | 'recoveryDelaysMs'
|
|
346
|
+
>,
|
|
347
|
+
): RecoveryDecision {
|
|
348
|
+
if (run.generation >= config.recoveryTotalAttempts)
|
|
349
|
+
return { action: 'interrupt', attempts: run.attempts, reason: 'total' };
|
|
350
|
+
return decideRecovery(run, currentCheckpointId, now, config);
|
|
351
|
+
}
|
|
352
|
+
|
|
303
353
|
type RunRow = {
|
|
304
354
|
run_id: string;
|
|
305
355
|
session_id: string;
|
package/src/do/transcript.ts
CHANGED
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
import { SUMMARY_PREFIX } from '../core/middlewares/summarization';
|
|
17
17
|
import { ChannelOrigin, ChannelReplyReference } from '../channels/contract';
|
|
18
18
|
import { CREATE_ARTIFACT_TOOL, createArtifactParts } from '../artifacts/tool';
|
|
19
|
-
import { planText } from '../delivery/schema';
|
|
19
|
+
import { planText, planTextForModel } from '../delivery/schema';
|
|
20
|
+
import { SEND_FILE_TOOL, sentFile } from '../plugins/sandbox/send-file';
|
|
20
21
|
import type {
|
|
21
22
|
ThreadMessageAnchor,
|
|
22
23
|
ThreadMessageRow,
|
|
@@ -166,6 +167,7 @@ export async function transformTranscript(
|
|
|
166
167
|
// The first listed reply that made each tool call: a tool result folds
|
|
167
168
|
// into it (looked up, not searched, so long transcripts stay linear).
|
|
168
169
|
const callers = new Map<string, MessageDto>();
|
|
170
|
+
const filesOnly = new Set<MessageDto>();
|
|
169
171
|
for (const message of messages) {
|
|
170
172
|
const toolMsg = message.type === 'tool' ? (message as ToolMessage) : null;
|
|
171
173
|
const kw = (message.additional_kwargs ?? {}) as Record<string, unknown>;
|
|
@@ -248,6 +250,20 @@ export async function transformTranscript(
|
|
|
248
250
|
contentToText(toolMsg.content),
|
|
249
251
|
),
|
|
250
252
|
});
|
|
253
|
+
// A reply of files alone lists them by name: the link has expired.
|
|
254
|
+
if (
|
|
255
|
+
call?.name === SEND_FILE_TOOL &&
|
|
256
|
+
(filesOnly.has(el) || !el.content.trim())
|
|
257
|
+
) {
|
|
258
|
+
const sent = sentFile(toolMsg.artifact);
|
|
259
|
+
if (sent) {
|
|
260
|
+
const named = planTextForModel({
|
|
261
|
+
parts: [{ kind: 'file', file: sent.file }],
|
|
262
|
+
});
|
|
263
|
+
el.content = el.content ? `${el.content}\n\n${named}` : named;
|
|
264
|
+
filesOnly.add(el);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
251
267
|
el.toolCalls = el.toolCalls?.map((t) =>
|
|
252
268
|
t.id === toolCallId
|
|
253
269
|
? {
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -404,6 +404,7 @@ import { buildCreateArtifactTool } from '../artifacts/tool';
|
|
|
404
404
|
import { resolveDeliveryProfile } from '../delivery/profile';
|
|
405
405
|
import {
|
|
406
406
|
draftReplyPlan,
|
|
407
|
+
failedRunFiles,
|
|
407
408
|
materializeReplyPlan,
|
|
408
409
|
turnSteps,
|
|
409
410
|
} from '../delivery/plan';
|
|
@@ -6623,18 +6624,26 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6623
6624
|
completed && delivery.kind === 'chat'
|
|
6624
6625
|
? await this.replyPlanOf(live, req, capture, delivery)
|
|
6625
6626
|
: undefined;
|
|
6627
|
+
// `send_file` always needs another model step, which can fail. A
|
|
6628
|
+
// gateway reads a plan only off a `finished` run, so a run that failed
|
|
6629
|
+
// after sending files finishes with them; the row keeps the error.
|
|
6630
|
+
const filesPlan =
|
|
6631
|
+
outcome.status === 'failed' && delivery.kind === 'chat'
|
|
6632
|
+
? await this.filesOfFailedRun(live, sessionId, delivery)
|
|
6633
|
+
: null;
|
|
6626
6634
|
const text =
|
|
6627
6635
|
(live.continuation ?? '') +
|
|
6628
6636
|
(completed ? lastAiText(capture) : outcome.fullText);
|
|
6629
6637
|
return {
|
|
6630
6638
|
status:
|
|
6631
|
-
outcome.status === 'completed'
|
|
6639
|
+
outcome.status === 'completed' || filesPlan
|
|
6632
6640
|
? 'finished'
|
|
6633
6641
|
: outcome.status === 'aborted'
|
|
6634
6642
|
? 'aborted'
|
|
6635
6643
|
: 'failed',
|
|
6636
6644
|
text,
|
|
6637
6645
|
...(plan ? { plan } : {}),
|
|
6646
|
+
...(filesPlan ? { plan: filesPlan } : {}),
|
|
6638
6647
|
...(messageId ? { messageId } : {}),
|
|
6639
6648
|
toolCalls,
|
|
6640
6649
|
...(outcome.status === 'failed' ? { error: outcome.error } : {}),
|
|
@@ -6662,10 +6671,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6662
6671
|
delivery: Extract<DeliveryProfile, { kind: 'chat' }>,
|
|
6663
6672
|
): Promise<ReplyPlan> {
|
|
6664
6673
|
const artifacts = this.artifacts?.canShare ? this.artifacts : null;
|
|
6665
|
-
const { steps, toolResults } = turnSteps(capture);
|
|
6674
|
+
const { steps, toolResults, toolArtifacts } = turnSteps(capture);
|
|
6666
6675
|
const draft = draftReplyPlan({
|
|
6667
6676
|
steps,
|
|
6668
6677
|
toolResults,
|
|
6678
|
+
toolArtifacts,
|
|
6669
6679
|
continuation: live.continuation,
|
|
6670
6680
|
limits: delivery.limits,
|
|
6671
6681
|
canSpill: artifacts !== null,
|
|
@@ -6690,11 +6700,49 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6690
6700
|
});
|
|
6691
6701
|
await this.runStore!.setPlan(live.runId, JSON.stringify(plan));
|
|
6692
6702
|
console.log(
|
|
6693
|
-
`[delivery] run ${live.runId}: ${delivery.surface} reply in ${plan.parts.length} parts (${plan.parts.filter((p) => p.kind === 'artifact').length} artefacts)`,
|
|
6703
|
+
`[delivery] run ${live.runId}: ${delivery.surface} reply in ${plan.parts.length} parts (${plan.parts.filter((p) => p.kind === 'artifact').length} artefacts, ${plan.parts.filter((p) => p.kind === 'file').length} files)`,
|
|
6694
6704
|
);
|
|
6695
6705
|
return plan;
|
|
6696
6706
|
}
|
|
6697
6707
|
|
|
6708
|
+
/**
|
|
6709
|
+
* The plan of a chat run that failed after sending files, or null. It
|
|
6710
|
+
* reads the checkpoint, not `capture`: without the graph's own end,
|
|
6711
|
+
* `capture` is whatever node ended last with messages, and nothing when
|
|
6712
|
+
* an attempt failed early, which would lose an earlier attempt's files.
|
|
6713
|
+
* A read failure leaves the run failed, as it was.
|
|
6714
|
+
*/
|
|
6715
|
+
private async filesOfFailedRun(
|
|
6716
|
+
live: LiveRun,
|
|
6717
|
+
sessionId: string,
|
|
6718
|
+
delivery: Extract<DeliveryProfile, { kind: 'chat' }>,
|
|
6719
|
+
): Promise<ReplyPlan | null> {
|
|
6720
|
+
try {
|
|
6721
|
+
const tuple = await this.saver!.getTuple({
|
|
6722
|
+
configurable: { thread_id: sessionId },
|
|
6723
|
+
});
|
|
6724
|
+
const channel: unknown = tuple?.checkpoint.channel_values.messages;
|
|
6725
|
+
const marks = await this.runStore!.listMarks(live.runId);
|
|
6726
|
+
const plan = await failedRunFiles(
|
|
6727
|
+
Array.isArray(channel) ? channel.filter(isBaseMessage) : [],
|
|
6728
|
+
new Set(marks.map((mark) => mark.toolCallId)),
|
|
6729
|
+
delivery.limits,
|
|
6730
|
+
);
|
|
6731
|
+
if (plan) {
|
|
6732
|
+
await this.runStore!.setPlan(live.runId, JSON.stringify(plan));
|
|
6733
|
+
console.warn(
|
|
6734
|
+
`[delivery] run ${live.runId}: failed after sending ${plan.parts.filter((p) => p.kind === 'file').length} files; delivering them`,
|
|
6735
|
+
);
|
|
6736
|
+
}
|
|
6737
|
+
return plan;
|
|
6738
|
+
} catch (error) {
|
|
6739
|
+
console.warn(
|
|
6740
|
+
`[delivery] run ${live.runId}: could not read the files of a failed run: ${error instanceof Error ? error.name : 'unknown'}`,
|
|
6741
|
+
);
|
|
6742
|
+
return null;
|
|
6743
|
+
}
|
|
6744
|
+
}
|
|
6745
|
+
|
|
6698
6746
|
private async runDirectRead(
|
|
6699
6747
|
live: LiveRun,
|
|
6700
6748
|
req: TurnRequest,
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* plain `m.text`, exactly as before chat delivery existed. With a plan it is
|
|
5
5
|
* one `m.text` per part, in the thread, rendered to HTML with raw HTML from
|
|
6
6
|
* the model escaped. Each part has its own transaction id, so a replayed
|
|
7
|
-
* turn is deduplicated part by part.
|
|
7
|
+
* turn is deduplicated part by part. A file is a link line: rooms have no
|
|
8
|
+
* gateway to fetch it into an attachment, so it works until the link expires.
|
|
8
9
|
*/
|
|
9
10
|
import { Marked } from 'marked';
|
|
10
11
|
import type { TurnResult } from '../do/contracts';
|
|
@@ -60,6 +61,13 @@ export function replyPartContent(part: ReplyPart): {
|
|
|
60
61
|
body: part.text,
|
|
61
62
|
formattedBody: renderRoomMarkdown(part.text),
|
|
62
63
|
};
|
|
64
|
+
if (part.kind === 'file') {
|
|
65
|
+
const { filename, url, caption } = part.file;
|
|
66
|
+
return {
|
|
67
|
+
body: [caption, filename, url].filter(Boolean).join('\n'),
|
|
68
|
+
formattedBody: `<p><strong>${escapeHtml(filename)}</strong>${caption ? `<br>${escapeHtml(caption)}` : ''}<br><a href="${escapeHtml(url)}">Open file</a></p>`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
63
71
|
const { title, url } =
|
|
64
72
|
part.kind === 'action'
|
|
65
73
|
? part.action
|
|
@@ -193,8 +193,12 @@ function pause(baseUrl: string, retryAfterMs: number | undefined): number {
|
|
|
193
193
|
|
|
194
194
|
/* ── one POST ─────────────────────────────────────────────────────────── */
|
|
195
195
|
|
|
196
|
-
/**
|
|
197
|
-
|
|
196
|
+
/**
|
|
197
|
+
* A POST's own signal: no caller's abort reaches it. Made on first use:
|
|
198
|
+
* workerd refuses `new AbortController()` in global scope, so a module-level
|
|
199
|
+
* one stopped `wrangler dev` from loading the Worker.
|
|
200
|
+
*/
|
|
201
|
+
let neverAborted: AbortSignal | undefined;
|
|
198
202
|
|
|
199
203
|
/**
|
|
200
204
|
* Asks the worker for `ids` (at most {@link MAX_BATCH_IDS}); the answers it
|
|
@@ -214,7 +218,7 @@ async function post(
|
|
|
214
218
|
blocksyncUrl: '',
|
|
215
219
|
user: `names|${target.baseUrl}|${target.clientId}`,
|
|
216
220
|
deadline: Date.now() + POST_TIMEOUT_MS,
|
|
217
|
-
signal:
|
|
221
|
+
signal: (neverAborted ??= new AbortController().signal),
|
|
218
222
|
fetch: fetcher,
|
|
219
223
|
};
|
|
220
224
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ClientConfig } from '@langchain/mcp-adapters';
|
|
2
2
|
import { withCallTimeout } from '../mcp-call-timeout';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import { deliversAttachments } from '../../delivery/prompt';
|
|
4
5
|
import { OraclePlugin, type PluginEnv } from '../../plugin-api/oracle-plugin';
|
|
5
6
|
import type {
|
|
6
7
|
PluginManifest,
|
|
@@ -17,6 +18,13 @@ import {
|
|
|
17
18
|
type SandboxAuthBuilder,
|
|
18
19
|
} from './sandbox-mcp';
|
|
19
20
|
import { createSandboxWriteBlobTool } from './sandbox-write-blob';
|
|
21
|
+
import {
|
|
22
|
+
buildSendFileTool,
|
|
23
|
+
CHAT_URL_NOTE,
|
|
24
|
+
PRESIGNED_URL_TOOL,
|
|
25
|
+
stripArtifactUrls,
|
|
26
|
+
URL_RESULT_TOOLS,
|
|
27
|
+
} from './send-file';
|
|
20
28
|
import { SANDBOX_CAPABILITY } from '../delegated-capabilities';
|
|
21
29
|
|
|
22
30
|
/**
|
|
@@ -225,6 +233,11 @@ const SANDBOX_TOOL_DEFS_TTL_MS = 5 * 60 * 1000;
|
|
|
225
233
|
*
|
|
226
234
|
* Tools are discovered per request via {@link getRequestTools} because the
|
|
227
235
|
* MCP client has to be authenticated as the in-flight user.
|
|
236
|
+
*
|
|
237
|
+
* On a chat surface the model cannot hold a signed link: the upstream
|
|
238
|
+
* `artifact_get_presigned_url` is replaced by `send_file`, and the URL fields
|
|
239
|
+
* of the other `artifact_*` lookups are removed. Portal and every other
|
|
240
|
+
* surface get the upstream tools unchanged.
|
|
228
241
|
*/
|
|
229
242
|
export class SandboxPlugin extends OraclePlugin {
|
|
230
243
|
/**
|
|
@@ -554,8 +567,8 @@ export class SandboxPlugin extends OraclePlugin {
|
|
|
554
567
|
}
|
|
555
568
|
|
|
556
569
|
/**
|
|
557
|
-
* Shared tail of both paths: visibility filter,
|
|
558
|
-
* the synthetic `sandbox_write_blob` companion.
|
|
570
|
+
* Shared tail of both paths: visibility filter, chat-surface file delivery,
|
|
571
|
+
* PluginTool mapping, and the synthetic `sandbox_write_blob` companion.
|
|
559
572
|
*/
|
|
560
573
|
private toPluginTools(
|
|
561
574
|
upstream: SandboxMcpTool[],
|
|
@@ -567,12 +580,32 @@ export class SandboxPlugin extends OraclePlugin {
|
|
|
567
580
|
(t) => !t.name.startsWith(ORACLE_MANAGEMENT_TOOL_PREFIX),
|
|
568
581
|
);
|
|
569
582
|
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
583
|
+
const surface = rtCtx.session.surface;
|
|
584
|
+
const chat = surface?.kind === 'chat';
|
|
585
|
+
const upstreamTools: PluginTool[] = filtered
|
|
586
|
+
.filter((tool) => !chat || tool.name !== PRESIGNED_URL_TOOL)
|
|
587
|
+
.map((tool) =>
|
|
588
|
+
chat && URL_RESULT_TOOLS.has(tool.name)
|
|
589
|
+
? {
|
|
590
|
+
name: tool.name,
|
|
591
|
+
description: `${tool.description} ${CHAT_URL_NOTE}`,
|
|
592
|
+
schema: tool.schema,
|
|
593
|
+
handler: async (args) =>
|
|
594
|
+
stripArtifactUrls(await tool.invoke(args)),
|
|
595
|
+
}
|
|
596
|
+
: {
|
|
597
|
+
name: tool.name,
|
|
598
|
+
description: tool.description,
|
|
599
|
+
schema: tool.schema,
|
|
600
|
+
handler: async (args) => tool.invoke(args),
|
|
601
|
+
},
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
const presignTool = filtered.find((t) => t.name === PRESIGNED_URL_TOOL);
|
|
605
|
+
if (chat && presignTool)
|
|
606
|
+
upstreamTools.push(
|
|
607
|
+
buildSendFileTool(presignTool, deliversAttachments(surface.surface)),
|
|
608
|
+
);
|
|
576
609
|
|
|
577
610
|
// sandbox_write_blob — companion to mint_invocation. Synthetic wrapper
|
|
578
611
|
// that takes a server-stored blobId + a sandbox path, looks the value up
|