@ixo/oracle-runtime-workers 0.35.1 → 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 +3 -3
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "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/ucan": "2.3.0",
|
|
70
71
|
"@ixo/common": "1.7.2",
|
|
71
|
-
"@ixo/ixo-transaction": "0.2.0"
|
|
72
|
-
"@ixo/ucan": "2.3.0"
|
|
72
|
+
"@ixo/ixo-transaction": "0.2.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
package/src/artifacts/tool.ts
CHANGED
|
@@ -21,16 +21,19 @@ export const CreateArtifactArgs = z.object({
|
|
|
21
21
|
.string()
|
|
22
22
|
.trim()
|
|
23
23
|
.min(1)
|
|
24
|
-
.
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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
|
);
|
package/src/attachments/route.ts
CHANGED
|
@@ -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';
|
package/src/attachments/view.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/channels/contract.ts
CHANGED
|
@@ -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
|
|
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[];
|
package/src/channels/turns.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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<{
|
|
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)
|
|
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:
|
|
1148
|
-
|
|
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.
|
|
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
|
|
314
|
-
// 5/15/30/60 s, `interrupt` as
|
|
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({
|
|
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;
|
package/src/core/main-agent.ts
CHANGED
|
@@ -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;
|
package/src/delivery/plan.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
220
|
-
* exists nowhere else is
|
|
221
|
-
* long one without artefact storage, can
|
|
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 === '
|
|
265
|
-
? { kind: '
|
|
266
|
-
:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
package/src/delivery/profile.ts
CHANGED
|
@@ -19,46 +19,51 @@ const GENERIC: ChatLimits = {
|
|
|
19
19
|
bubbleMax: 1500,
|
|
20
20
|
minBubble: 60,
|
|
21
21
|
maxBubbles: 3,
|
|
22
|
-
maxPartsPerRun:
|
|
23
|
-
spillChars:
|
|
24
|
-
maxListItems:
|
|
22
|
+
maxPartsPerRun: 8,
|
|
23
|
+
spillChars: 4500,
|
|
24
|
+
maxListItems: 15,
|
|
25
25
|
previewItems: 3,
|
|
26
|
-
maxCodeLines:
|
|
26
|
+
maxCodeLines: 40,
|
|
27
27
|
tables: false,
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
47
|
+
spillChars: 6000,
|
|
51
48
|
},
|
|
52
49
|
matrix: {
|
|
53
50
|
...GENERIC,
|
|
54
51
|
bubbleTarget: 1200,
|
|
55
52
|
bubbleMax: 4000,
|
|
56
|
-
spillChars:
|
|
53
|
+
spillChars: 8000,
|
|
57
54
|
},
|
|
58
55
|
};
|
|
59
56
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
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',
|