@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -67,9 +67,9 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
- "@ixo/ixo-transaction": "0.2.0",
71
- "@ixo/common": "1.7.1",
72
- "@ixo/ucan": "2.3.0"
70
+ "@ixo/ucan": "2.3.0",
71
+ "@ixo/common": "1.7.2",
72
+ "@ixo/ixo-transaction": "0.2.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -21,16 +21,19 @@ export const CreateArtifactArgs = z.object({
21
21
  .string()
22
22
  .trim()
23
23
  .min(1)
24
- .max(600)
24
+ // Room for the conclusions, decisions and next actions. It is delivered
25
+ // as its own text part before the link (a gateway splits a text part at
26
+ // its provider's limit, 4,096 on WhatsApp), never inside the link card.
27
+ .max(1000)
25
28
  .describe(
26
- 'One or two short sentences sent as the chat message before the link: the answer or the gist, not "here is the document".',
29
+ 'The chat message sent before the document: the conclusions, decisions and next actions, so the user has them without opening the document. Not "here is the document".',
27
30
  ),
28
31
  followUp: z
29
32
  .string()
30
33
  .trim()
31
34
  .max(300)
32
35
  .optional()
33
- .describe('At most one short question, sent after the link.'),
36
+ .describe('At most one short question, sent after the document.'),
34
37
  });
35
38
 
36
39
  /** What the tool returns; the Reply Plan reads it back off the ToolMessage. */
@@ -95,7 +98,7 @@ export function buildCreateArtifactTool(
95
98
  {
96
99
  name: CREATE_ARTIFACT_TOOL,
97
100
  description:
98
- 'Send the user a document they open in their browser, together with a short chat message. Use it for anything too long or too structured for chat: a plan, report, comparison, table, draft, list of more than a few items, or code. Put the full Markdown in `content`, the answer or gist in `message`, and at most one question in `followUp`. Call it last and on its own: the reply ends after it, and the user receives the message, the link, then the question.',
101
+ 'Send the user a document, with a short chat message, ONLY when they asked for a document with no particular file format (or for any file, when `send_file` is not available), or when the output is genuinely document-length (a long report, long code, several screens of text). Ordinary answers, comparisons, short plans and lists stay in chat as messages: many users read chat on a data plan with no browser. Put the full Markdown in `content`; put the conclusions, decisions and next actions in `message`, so the user has them without opening the document; at most one question in `followUp`. Call it last and on its own: the reply ends after it, and the user receives the message, the document, then the question. A file in a particular format (Word, PDF, a spreadsheet, an image) is built in the sandbox and goes through `send_file` when that tool is available.',
99
102
  schema: CreateArtifactArgs,
100
103
  effect: 'read',
101
104
  },
@@ -443,7 +443,9 @@ export async function prepareAttachments(
443
443
  }> = [];
444
444
  for (const attachment of params.attachments) {
445
445
  const kind = classifyAttachment(attachment);
446
- const strategy = routeAttachment(kind, params.caps);
446
+ const strategy = routeAttachment(kind, params.caps, {
447
+ channelFile: Boolean(attachment.channelRef),
448
+ });
447
449
  deps.logger.log(
448
450
  `[attachments] "${attachment.filename}" (${attachment.mimetype}) kind=${kind} model=${params.model} → ${strategy}`,
449
451
  );
@@ -18,11 +18,22 @@ export type AttachmentStrategy =
18
18
  /**
19
19
  * Decide, for one attachment, how to get it to the selected model — the single
20
20
  * cost-aware, capability-aware routing rule. Pure and total.
21
+ *
22
+ * `channelFile`: a file from a chat channel (WhatsApp) never goes as raw
23
+ * bytes. Its message is replayed to the model on every later turn and kept in
24
+ * the user's checkpoint, so a native PDF or photo there is base64 the size of
25
+ * the file, counted as tokens at chars/4 and held several times over in the
26
+ * object's 128 MB isolate (a 600 KB PDF reset the isolate on every attempt).
27
+ * The helper model turns it into text instead; tools still read the file
28
+ * itself through `ctx.session.channel.attachments`.
21
29
  */
22
30
  export function routeAttachment(
23
31
  kind: AttachmentKind,
24
32
  caps: ModelInputCapabilities,
33
+ options: { channelFile?: boolean } = {},
25
34
  ): AttachmentStrategy {
35
+ if (options.channelFile)
36
+ return kind === 'text' ? 'parse-local' : 'model-extract';
26
37
  switch (kind) {
27
38
  case 'text':
28
39
  return 'parse-local';
@@ -129,7 +129,11 @@ export async function viewAttachment(
129
129
  ): Promise<AttachmentView> {
130
130
  const attachment = toInput(meta);
131
131
  const kind = classifyAttachment(attachment);
132
- const strategy = routeAttachment(kind, deps.caps);
132
+ // A channel file comes back as text too: re-attaching its bytes would put
133
+ // them in the history again (see `routeAttachment`).
134
+ const strategy = routeAttachment(kind, deps.caps, {
135
+ channelFile: Boolean(attachment.channelRef),
136
+ });
133
137
  if (strategy === 'send-native') {
134
138
  const { bytes, mimetype, sniffed } = await loadAttachmentBytes(
135
139
  attachment,
@@ -246,8 +246,9 @@ export interface ChannelTurnResponse {
246
246
  text?: string;
247
247
  /**
248
248
  * A finished turn's Reply Plan: the parts to deliver in order (short
249
- * messages and artefact links). `text` stays the whole reply as one
250
- * message for gateways that predate plans.
249
+ * messages, artefact links and files). `text` stays the whole reply as one
250
+ * message for gateways that predate plans, or that cannot read one of its
251
+ * parts: a file is a link line there.
251
252
  */
252
253
  plan?: ReplyPlan;
253
254
  actionOutcomes?: ActionOutcomeWire[];
@@ -4,7 +4,7 @@ import type { DoSqliteDatabase } from '../sqlite/database';
4
4
  import { UNTITLED_SESSION } from '../sqlite/sessions-store';
5
5
  import type { RunRecord } from '../do/run-store';
6
6
  import type { TurnIdentity, TurnRequest } from '../do/contracts';
7
- import { parseReplyPlan, planText } from '../delivery/schema';
7
+ import { parseReplyPlan, planText, planTextForModel } from '../delivery/schema';
8
8
  import type { ReplyPlan } from '../delivery/types';
9
9
  import {
10
10
  ChannelError,
@@ -941,7 +941,10 @@ export class ChannelTurns {
941
941
  : undefined))
942
942
  : undefined;
943
943
  if (reference.partId && reply.plan && !matched) return unknown;
944
- text = matched ? planText({ parts: [matched] }) : reply.rendered;
944
+ // The model reads this: a file part is named, never linked.
945
+ text = matched
946
+ ? planTextForModel({ parts: [matched] })
947
+ : reply.withFileNames;
945
948
  }
946
949
  return {
947
950
  kind: 'resolved',
@@ -1100,11 +1103,20 @@ export class ChannelTurns {
1100
1103
  while (this.draining) await this.draining;
1101
1104
  }
1102
1105
 
1103
- /** A finished run's Reply Plan, and the reply as the channel user sees it. */
1106
+ /**
1107
+ * A finished run's Reply Plan, the reply as a gateway that predates plans
1108
+ * sends it, and the reply with each file named instead of linked: what the
1109
+ * model reads when the user quotes it, and what the room mirror keeps,
1110
+ * since a signed link expires.
1111
+ */
1104
1112
  private async reply(
1105
1113
  runId: string,
1106
1114
  text: string,
1107
- ): Promise<{ plan: ReplyPlan | null; rendered: string }> {
1115
+ ): Promise<{
1116
+ plan: ReplyPlan | null;
1117
+ rendered: string;
1118
+ withFileNames: string;
1119
+ }> {
1108
1120
  const plan = parseReplyPlan(await this.host.getPlan(runId));
1109
1121
  const row = await this.db.get<{ routing_json: string | null }>(
1110
1122
  'SELECT routing_json FROM channel_requests WHERE run_id=?',
@@ -1127,7 +1139,12 @@ export class ChannelTurns {
1127
1139
  const to = await this.boldTitle(route.destination.sessionId);
1128
1140
  notice = `Back to ${to ? `*${to}*` : 'an earlier topic'}. Send /resume to pick another topic.`;
1129
1141
  }
1130
- if (!notice) return { plan, rendered: plan ? planText(plan) : text };
1142
+ if (!notice)
1143
+ return {
1144
+ plan,
1145
+ rendered: plan ? planText(plan) : text,
1146
+ withFileNames: plan ? planTextForModel(plan) : text,
1147
+ };
1131
1148
  const decorated =
1132
1149
  plan && plan.parts.length < 999
1133
1150
  ? {
@@ -1142,11 +1159,12 @@ export class ChannelTurns {
1142
1159
  ],
1143
1160
  }
1144
1161
  : null;
1162
+ const withNotice = (show: (plan: ReplyPlan) => string): string =>
1163
+ decorated ? show(decorated) : `${notice}\n\n${plan ? show(plan) : text}`;
1145
1164
  return {
1146
1165
  plan: decorated,
1147
- rendered: decorated
1148
- ? planText(decorated)
1149
- : `${notice}\n\n${plan ? planText(plan) : text}`,
1166
+ rendered: withNotice(planText),
1167
+ withFileNames: withNotice(planTextForModel),
1150
1168
  };
1151
1169
  }
1152
1170
 
@@ -2021,7 +2039,7 @@ export class ChannelTurns {
2021
2039
  record.runId,
2022
2040
  record.partialText ?? '',
2023
2041
  );
2024
- await this.host.mirror(request, reply.rendered, 'oracle');
2042
+ await this.host.mirror(request, reply.withFileNames, 'oracle');
2025
2043
  await this.settleMirror(
2026
2044
  row.run_id,
2027
2045
  'reply_mirrored = 1, mirror_pending = 0',
package/src/core/env.ts CHANGED
@@ -310,12 +310,14 @@ export const baseEnvSchema = z.object({
310
310
 
311
311
  // --- durable runs (docs/plans/durable-runs.md) ---------------------------
312
312
  // Parsed by `runDurabilityConfig` (src/do/run-store.ts) with defaults:
313
- // keep-alive 20 s, segment pack every 2 s / 16 KiB, 4 recovery attempts at
314
- // 5/15/30/60 s, `interrupt` as the multitask rule.
313
+ // keep-alive 20 s, segment pack every 2 s / 16 KiB, 4 recovery attempts
314
+ // without progress at 5/15/30/60 s, 8 recoveries in all, `interrupt` as
315
+ // the multitask rule.
315
316
  RUN_KEEPALIVE_MS: z.string().optional(),
316
317
  RUN_SEGMENT_FLUSH_MS: z.string().optional(),
317
318
  RUN_SEGMENT_BYTES: z.string().optional(),
318
319
  RUN_RECOVERY_ATTEMPTS: z.string().optional(),
320
+ RUN_RECOVERY_TOTAL_ATTEMPTS: z.string().optional(),
319
321
  RUN_RECOVERY_DELAYS_MS: z.string().optional(),
320
322
  TURN_MULTITASK_DEFAULT: z.enum(['interrupt', 'enqueue']).optional(),
321
323
 
package/src/core/llm.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from './env';
19
19
  import { fetchOpenRouterPrices } from './openrouter-pricing';
20
20
  import type { LlmAdapter } from './runtime-context';
21
+ import { stringify } from '../sqlite/serialization';
21
22
  import { NOOP_LOGGER } from './utils';
22
23
 
23
24
  // ── Catalog ─────────────────────────────────────────────────────────────────
@@ -1014,6 +1015,70 @@ export interface LangsmithTracingDecision {
1014
1015
  callbacks?: [LangChainTracer];
1015
1016
  }
1016
1017
 
1018
+ /** The signature of a signed storage link, whatever carries the link. */
1019
+ const SIGNED_LINK_SIGNATURE = /(X-Amz-Signature=)[0-9a-f]+/gi;
1020
+
1021
+ /** What `SIGNED_LINK_SIGNATURE` needs to match somewhere: the marker and one hex digit. */
1022
+ const SIGNED_LINK_MARKER = /X-Amz-Signature=[0-9a-f]/i;
1023
+
1024
+ /**
1025
+ * Whether `values` needs the full pass of `redactSignedLinks`: some string
1026
+ * its JSON would carry (a value or a key, through `toJSON` like
1027
+ * `JSON.stringify`) holds a signature, or the walk cannot tell (a BigInt, a
1028
+ * getter or `toJSON` that throws: the full pass decides what is sent).
1029
+ * JSON escaping never touches the marker's characters, so the marker is in
1030
+ * the JSON exactly when it is in one of those strings. Walks without
1031
+ * serializing: every run of a traced turn carries the whole history, and a
1032
+ * stringify per run was a copy of it per run.
1033
+ */
1034
+ function needsSignedLinkPass(values: unknown): boolean {
1035
+ const pending: unknown[] = [values];
1036
+ const seen = new Set<object>();
1037
+ try {
1038
+ while (pending.length > 0) {
1039
+ const value = pending.pop();
1040
+ if (typeof value === 'string') {
1041
+ if (SIGNED_LINK_MARKER.test(value)) return true;
1042
+ continue;
1043
+ }
1044
+ if (typeof value === 'bigint') return true;
1045
+ if (value === null || typeof value !== 'object') continue;
1046
+ // A shared object adds no string a first visit did not; a cycle none.
1047
+ if (seen.has(value)) continue;
1048
+ seen.add(value);
1049
+ const toJSON: unknown = Reflect.get(value, 'toJSON');
1050
+ if (typeof toJSON === 'function') {
1051
+ pending.push(toJSON.call(value, ''));
1052
+ continue;
1053
+ }
1054
+ for (const key of Object.keys(value)) {
1055
+ if (SIGNED_LINK_MARKER.test(key)) return true;
1056
+ pending.push(Reflect.get(value, key));
1057
+ }
1058
+ }
1059
+ } catch {
1060
+ return true;
1061
+ }
1062
+ return false;
1063
+ }
1064
+
1065
+ /**
1066
+ * What a trace may keep of a run's inputs and outputs. A signed link is a
1067
+ * bearer credential, and a trace holds every `ToolMessage.artifact`, so its
1068
+ * signature goes. A value that cannot be read is not sent. A payload with
1069
+ * no signature anywhere is handed back as it is, without serializing it.
1070
+ */
1071
+ export function redactSignedLinks(
1072
+ values: Record<string, unknown>,
1073
+ ): Record<string, unknown> {
1074
+ if (!needsSignedLinkPass(values)) return values;
1075
+ const json = stringify(values);
1076
+ const redacted = json.replace(SIGNED_LINK_SIGNATURE, '$1[redacted]');
1077
+ if (redacted !== json) return JSON.parse(redacted) as Record<string, unknown>;
1078
+ // `stringify` answers with a bare string when it cannot serialize.
1079
+ return json.startsWith('{') ? values : {};
1080
+ }
1081
+
1017
1082
  /**
1018
1083
  * One `langsmith` Client per (apiKey, endpoint) per isolate. Tracer instances
1019
1084
  * are per-turn and cheap; the client owns the batched upload queue, so
@@ -1025,7 +1090,11 @@ function langsmithClientFor(apiKey: string, endpoint?: string): Client {
1025
1090
  const key = `${apiKey}\n${endpoint ?? ''}`;
1026
1091
  let client = langsmithClients.get(key);
1027
1092
  if (!client) {
1028
- client = new Client({ apiKey, ...(endpoint && { apiUrl: endpoint }) });
1093
+ client = new Client({
1094
+ apiKey,
1095
+ ...(endpoint && { apiUrl: endpoint }),
1096
+ anonymizer: redactSignedLinks,
1097
+ });
1029
1098
  langsmithClients.set(key, client);
1030
1099
  }
1031
1100
  return client;
@@ -80,6 +80,7 @@ import { wrapPluginTool } from './wrap-plugin-tool';
80
80
  import { renderSurfaceSection } from '../delivery/prompt';
81
81
  import { createReturnDirectFirstMiddleware } from './middlewares/return-direct-first';
82
82
  import { CREATE_ARTIFACT_TOOL } from '../artifacts/tool';
83
+ import { SEND_FILE_TOOL } from '../plugins/sandbox/send-file';
83
84
  import type { SessionSurface } from '../plugin-api/types';
84
85
 
85
86
  import { taskExecutionProfile } from './execution-profile';
@@ -927,6 +928,10 @@ export async function createMainAgent(
927
928
  surfaceBlock: renderSurfaceSection(
928
929
  delivery,
929
930
  turnTools.some(({ tool }) => tool.name === CREATE_ARTIFACT_TOOL),
931
+ allTools.some(
932
+ ({ pluginName, tool }) =>
933
+ tool.name === SEND_FILE_TOOL && !unmetRequirements.has(pluginName),
934
+ ),
930
935
  ),
931
936
  operatingGuides,
932
937
  });
@@ -158,6 +158,8 @@ export function createResultCapMiddleware(
158
158
  name: toolName,
159
159
  status: output.status,
160
160
  content: capped.text,
161
+ // What the model never sees is not part of what was capped.
162
+ ...(output.artifact !== undefined ? { artifact: output.artifact } : {}),
161
163
  additional_kwargs: {
162
164
  ...output.additional_kwargs,
163
165
  [CAPPED_META_KEY]: capped.meta,
@@ -23,6 +23,7 @@ import {
23
23
  import { isAttachmentViewMessage } from '../../attachments/retention';
24
24
  import { isCapabilityGateRefusal } from './capability-gate';
25
25
  import { isToolNotRun } from '../tool-not-run';
26
+ import { SEND_FILE_TOOL } from '../../plugins/sandbox/send-file';
26
27
 
27
28
  export const SUMMARY_PREFIX = 'Here is a summary of the conversation to date:';
28
29
 
@@ -45,6 +46,12 @@ export interface CarriedToolCall {
45
46
  status: 'success' | 'error';
46
47
  /** The start of its result, for the repetition guard to quote. */
47
48
  result: string;
49
+ /**
50
+ * A `send_file` call's `ToolMessage.artifact` and tool call id: the Reply
51
+ * Plan still owes the user that file after the summary removed the call.
52
+ */
53
+ artifact?: unknown;
54
+ id?: string;
48
55
  }
49
56
 
50
57
  /** `additional_kwargs` key of the calls a mid-turn summary carries. */
@@ -65,6 +72,8 @@ export function turnCarryOf(
65
72
  const name: unknown = Reflect.get(entry, 'name');
66
73
  const status: unknown = Reflect.get(entry, 'status');
67
74
  const result: unknown = Reflect.get(entry, 'result');
75
+ const artifact: unknown = Reflect.get(entry, 'artifact');
76
+ const id: unknown = Reflect.get(entry, 'id');
68
77
  if (typeof name !== 'string') return [];
69
78
  if (status !== 'success' && status !== 'error') return [];
70
79
  return [
@@ -73,6 +82,8 @@ export function turnCarryOf(
73
82
  args: Reflect.get(entry, 'args'),
74
83
  status,
75
84
  result: typeof result === 'string' ? result : '',
85
+ ...(artifact !== undefined ? { artifact } : {}),
86
+ ...(typeof id === 'string' ? { id } : {}),
76
87
  },
77
88
  ];
78
89
  });
@@ -154,6 +165,9 @@ export function carriedCallsOfTurn(
154
165
  args: call.args,
155
166
  status: message.status === 'error' ? 'error' : 'success',
156
167
  result: resultText(message),
168
+ ...(call.name === SEND_FILE_TOOL && message.artifact !== undefined
169
+ ? { artifact: message.artifact, id: message.tool_call_id }
170
+ : {}),
157
171
  });
158
172
  }
159
173
  return carried;
@@ -11,7 +11,9 @@ import { parseReference } from '@ixo/action-protocol';
11
11
  * the typing indicator already says so — a step whose short text is the
12
12
  * whole answer ("Your flight is 9:40." + a memory save) keeps it;
13
13
  * - each step goes through the shaper, which may spill it to an artefact;
14
- * - a `create_artifact` call becomes its message, the link, its question.
14
+ * - a `create_artifact` call becomes its message, the link, its question;
15
+ * - a `send_file` call becomes a file part, where the call stands in the
16
+ * turn; a file sent twice is delivered once.
15
17
  */
16
18
  import {
17
19
  AIMessage,
@@ -20,6 +22,8 @@ import {
20
22
  } from '@langchain/core/messages';
21
23
  import { contentToText } from '../do/transcript';
22
24
  import { CREATE_ARTIFACT_TOOL, createArtifactParts } from '../artifacts/tool';
25
+ import { turnCarryOf, turnStart } from '../core/middlewares/turn-boundary';
26
+ import { SEND_FILE_TOOL, sentFile } from '../plugins/sandbox/send-file';
23
27
  import { isBlank, shapeStep, type SpillShape } from './shaper';
24
28
  import type {
25
29
  ArtifactRef,
@@ -40,23 +44,38 @@ export type DraftPart =
40
44
 
41
45
  const NARRATION_MAX_CHARS = 200;
42
46
 
43
- /** The model steps of the turn that ended the thread, with its tool results. */
47
+ /**
48
+ * The model steps of the turn that ended the thread, with its tool results.
49
+ * `toolArtifacts` holds each `ToolMessage.artifact`: what a tool hands the
50
+ * plan without the model seeing it.
51
+ */
44
52
  export function turnSteps(messages: readonly BaseMessage[]): {
45
53
  steps: TurnStep[];
46
54
  toolResults: Map<string, string>;
55
+ toolArtifacts: Map<string, unknown>;
47
56
  } {
48
- let start = 0;
49
- for (let i = messages.length - 1; i >= 0; i--) {
50
- if (messages[i]?.type === 'human') {
51
- start = i + 1;
52
- break;
53
- }
54
- }
57
+ const opening = turnStart(messages);
58
+ const start = messages[opening]?.type === 'human' ? opening + 1 : 0;
55
59
  const steps: TurnStep[] = [];
56
60
  const toolResults = new Map<string, string>();
61
+ const toolArtifacts = new Map<string, unknown>();
62
+ // A mid-turn summary condensed the turn's earlier steps away. It carries
63
+ // their calls; a file already sent among them is still delivered, first.
64
+ const carried = start > 0 ? turnCarryOf(messages[opening]!) : undefined;
65
+ for (const [i, call] of (carried ?? []).entries()) {
66
+ if (call.name !== SEND_FILE_TOOL || call.artifact === undefined) continue;
67
+ const id = call.id ?? `carried-${i}`;
68
+ steps.push({
69
+ text: '',
70
+ toolCalls: [{ id, name: call.name, args: call.args }],
71
+ });
72
+ toolArtifacts.set(id, call.artifact);
73
+ }
57
74
  for (const message of messages.slice(start)) {
58
75
  if (ToolMessage.isInstance(message)) {
59
76
  toolResults.set(message.tool_call_id, contentToText(message.content));
77
+ if (message.artifact !== undefined)
78
+ toolArtifacts.set(message.tool_call_id, message.artifact);
60
79
  continue;
61
80
  }
62
81
  // Extraction-lane attachment text rides as hidden AI messages.
@@ -71,7 +90,7 @@ export function turnSteps(messages: readonly BaseMessage[]): {
71
90
  })),
72
91
  });
73
92
  }
74
- return { steps, toolResults };
93
+ return { steps, toolResults, toolArtifacts };
75
94
  }
76
95
 
77
96
  /** One short paragraph of prose: what a model says before it calls a tool. */
@@ -116,11 +135,13 @@ function pendingContinuation(
116
135
  export function draftReplyPlan(input: {
117
136
  steps: TurnStep[];
118
137
  toolResults: ReadonlyMap<string, string>;
138
+ toolArtifacts?: ReadonlyMap<string, unknown>;
119
139
  continuation?: string | null;
120
140
  limits: ChatLimits;
121
141
  canSpill: boolean;
122
142
  }): DraftPart[] {
123
143
  const parts: DraftPart[] = [];
144
+ const sentPaths = new Set<string>();
124
145
  const steps =
125
146
  input.steps.length > 0 || !input.continuation
126
147
  ? input.steps
@@ -202,6 +223,13 @@ export function draftReplyPlan(input: {
202
223
  parts.push(
203
224
  ...createArtifactParts(call.args, input.toolResults.get(call.id)),
204
225
  );
226
+ if (call.name === SEND_FILE_TOOL) {
227
+ const sent = sentFile(input.toolArtifacts?.get(call.id));
228
+ if (sent && !sentPaths.has(sent.path)) {
229
+ sentPaths.add(sent.path);
230
+ parts.push({ kind: 'file', file: sent.file });
231
+ }
232
+ }
205
233
  }
206
234
  });
207
235
  return parts;
@@ -216,9 +244,10 @@ type PlanPart = ReplyContent & { framing?: boolean };
216
244
  /**
217
245
  * Over the run's cap: merge the shortest neighbouring messages while the
218
246
  * result stays within `bubbleMax`, then drop spill framing, keeping the
219
- * reply's last part for as long as possible. Order is kept, and text that
220
- * exists nowhere else is never dropped: a reply with several documents, or a
221
- * long one without artefact storage, can stay over the cap.
247
+ * reply's last part for as long as possible. Order is kept, and neither a
248
+ * file nor text that exists nowhere else is ever dropped: a reply with
249
+ * several documents or files, or a long one without artefact storage, can
250
+ * stay over the cap.
222
251
  */
223
252
  function capParts(parts: PlanPart[], limits: ChatLimits): ReplyContent[] {
224
253
  const out = [...parts];
@@ -261,13 +290,53 @@ function capParts(parts: PlanPart[], limits: ChatLimits): ReplyContent[] {
261
290
  ? { kind: 'text', text: part.text }
262
291
  : part.kind === 'artifact'
263
292
  ? { kind: 'artifact', artifact: part.artifact }
264
- : part.kind === 'action'
265
- ? { kind: 'action', action: part.action }
266
- : {
267
- kind: 'hub-action',
268
- actionReference: part.actionReference,
269
- summary: part.summary,
270
- },
293
+ : part.kind === 'file'
294
+ ? { kind: 'file', file: part.file }
295
+ : part.kind === 'action'
296
+ ? { kind: 'action', action: part.action }
297
+ : {
298
+ kind: 'hub-action',
299
+ actionReference: part.actionReference,
300
+ summary: part.summary,
301
+ },
302
+ );
303
+ }
304
+
305
+ /** Follows the files of a run that failed after sending them. */
306
+ export const FAILED_RUN_NOTICE =
307
+ 'What I attached above is complete, but something went wrong before I could finish this reply. Send your message again if you need more.';
308
+
309
+ /**
310
+ * The reply of a chat run that failed after it sent files: those files, then
311
+ * a line saying the rest is missing. `calls` are this run's tool call ids,
312
+ * because the messages come from the checkpoint and may end in an earlier
313
+ * turn when the run failed before it wrote anything. Null when it sent none.
314
+ */
315
+ export async function failedRunFiles(
316
+ messages: readonly BaseMessage[],
317
+ calls: ReadonlySet<string>,
318
+ limits: ChatLimits,
319
+ ): Promise<ReplyPlan | null> {
320
+ const { steps, toolResults, toolArtifacts } = turnSteps(messages);
321
+ const files = draftReplyPlan({
322
+ steps: steps.map((step) => ({
323
+ text: '',
324
+ toolCalls: step.toolCalls.filter(
325
+ (call) => call.name === SEND_FILE_TOOL && calls.has(call.id),
326
+ ),
327
+ })),
328
+ toolResults,
329
+ toolArtifacts,
330
+ limits,
331
+ canSpill: false,
332
+ });
333
+ if (files.length === 0) return null;
334
+ return materializeReplyPlan(
335
+ [...files, { kind: 'text', text: FAILED_RUN_NOTICE }],
336
+ {
337
+ limits,
338
+ createSpill: () => Promise.reject(new Error('nothing here spills')),
339
+ },
271
340
  );
272
341
  }
273
342
 
@@ -19,46 +19,51 @@ const GENERIC: ChatLimits = {
19
19
  bubbleMax: 1500,
20
20
  minBubble: 60,
21
21
  maxBubbles: 3,
22
- maxPartsPerRun: 5,
23
- spillChars: 1800,
24
- maxListItems: 7,
22
+ maxPartsPerRun: 8,
23
+ spillChars: 4500,
24
+ maxListItems: 15,
25
25
  previewItems: 3,
26
- maxCodeLines: 12,
26
+ maxCodeLines: 40,
27
27
  tables: false,
28
28
  };
29
29
 
30
30
  /**
31
- * Well under each provider's hard limit (WhatsApp and Telegram 4,096
32
- * characters, Slack 12,000 per message, Matrix 64 KiB per event): the
33
- * gateway still enforces the hard limit, and never truncates.
31
+ * Chat first: many users read on WhatsApp alone, without a browser, so an
32
+ * ordinary answer (several paragraphs, a list, a small comparison) stays in
33
+ * the conversation and only a document-length step moves to an artefact.
34
+ * Message sizes stay well under each provider's hard limit (WhatsApp and
35
+ * Telegram 4,096 characters, Slack 12,000 per message, Matrix 64 KiB per
36
+ * event): the gateway still enforces the hard limit, and never truncates.
34
37
  */
35
38
  const DEFAULT_LIMITS: Readonly<Record<string, ChatLimits>> = {
36
39
  generic: GENERIC,
37
- whatsapp: { ...GENERIC, maxBubbles: 4, maxPartsPerRun: 6 },
38
- telegram: {
39
- ...GENERIC,
40
- bubbleTarget: 800,
41
- bubbleMax: 2000,
42
- maxBubbles: 4,
43
- maxPartsPerRun: 6,
44
- spillChars: 2400,
45
- },
40
+ // The generic limits are WhatsApp's: each entry below lists only what differs.
41
+ whatsapp: { ...GENERIC },
42
+ telegram: { ...GENERIC, bubbleTarget: 800, bubbleMax: 2000 },
46
43
  slack: {
47
44
  ...GENERIC,
48
45
  bubbleTarget: 1200,
49
46
  bubbleMax: 3000,
50
- spillChars: 3500,
47
+ spillChars: 6000,
51
48
  },
52
49
  matrix: {
53
50
  ...GENERIC,
54
51
  bubbleTarget: 1200,
55
52
  bubbleMax: 4000,
56
- spillChars: 4000,
53
+ spillChars: 8000,
57
54
  },
58
55
  };
59
56
 
60
- /** A group room hears one short answer, not a series. */
61
- const GROUP_LIMITS: Partial<ChatLimits> = { maxBubbles: 2, maxPartsPerRun: 3 };
57
+ /**
58
+ * A group room hears one short answer, not a series: one or two messages,
59
+ * the run capped at three parts, and a step over 2,400 characters moves to
60
+ * an artefact.
61
+ */
62
+ const GROUP_LIMITS: Partial<ChatLimits> = {
63
+ maxBubbles: 2,
64
+ maxPartsPerRun: 3,
65
+ spillChars: 2400,
66
+ };
62
67
 
63
68
  const COUNTS = [
64
69
  'bubbleTarget',