@ixo/oracle-runtime-workers 0.35.1 → 0.36.1
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 +1 -1
- package/src/artifacts/tool.ts +7 -4
- 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 +34 -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 +315 -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.1",
|
|
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": {
|
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
|
},
|
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',
|
package/src/delivery/prompt.ts
CHANGED
|
@@ -1,26 +1,53 @@
|
|
|
1
|
+
import { SENDABLE_FILE_TYPES } from '../plugins/sandbox/send-file';
|
|
1
2
|
import type { DeliveryProfile } from './types';
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Whether the surface delivers a file as an attachment. A Matrix room has no
|
|
6
|
+
* gateway to fetch it, so a file arrives there as a link line.
|
|
7
|
+
*/
|
|
8
|
+
export function deliversAttachments(surface: string): boolean {
|
|
9
|
+
return surface !== 'matrix';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `maxBubbles` as the prompt asks for it: "two or three messages". */
|
|
13
|
+
function preferredMessages(maxBubbles: number): string {
|
|
14
|
+
if (maxBubbles <= 1) return 'one message';
|
|
15
|
+
if (maxBubbles === 2) return 'one or two messages';
|
|
16
|
+
if (maxBubbles === 3) return 'two or three messages';
|
|
17
|
+
return `up to ${maxBubbles} messages`;
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
/**
|
|
4
21
|
* The prompt section a chat turn gets: write for the surface. Empty on the
|
|
5
|
-
* Portal. `artifacts` says whether `create_artifact` is bound this turn
|
|
22
|
+
* Portal. `artifacts` says whether `create_artifact` is bound this turn,
|
|
23
|
+
* `files` whether `send_file` is. Agrees with the shaper and the tools' own
|
|
24
|
+
* descriptions: ordinary answers stay in chat, only document-length output
|
|
25
|
+
* becomes an artefact, and a made file reaches the user as an attachment.
|
|
6
26
|
*/
|
|
7
27
|
export function renderSurfaceSection(
|
|
8
28
|
profile: DeliveryProfile | undefined,
|
|
9
29
|
artifacts: boolean,
|
|
30
|
+
files: boolean,
|
|
10
31
|
): string {
|
|
11
32
|
if (!profile || profile.kind !== 'chat') return '';
|
|
12
|
-
const { label, limits } = profile;
|
|
33
|
+
const { label, limits, surface } = profile;
|
|
34
|
+
const preferred = preferredMessages(limits.maxBubbles);
|
|
13
35
|
return [
|
|
14
36
|
'## Where this conversation is happening',
|
|
15
37
|
'',
|
|
16
|
-
`You are replying in ${label}. The user reads your reply as chat messages,
|
|
38
|
+
`You are replying in ${label}. The user reads your reply as chat messages, often on a phone with a chat-only data plan and no browser, so the answer itself belongs in the messages.`,
|
|
17
39
|
'',
|
|
18
40
|
'- Lead with the answer. Write like a sharp person texting: short sentences, no preamble, no sign-off.',
|
|
19
|
-
`-
|
|
20
|
-
`- No headings
|
|
41
|
+
`- Prefer ${preferred}. A blank line marks where a message may break; short paragraphs are grouped so the reply reads as ${preferred}. Keep each paragraph under about ${limits.bubbleTarget} characters. Use more messages when the answer needs them; never leave content out to fit.`,
|
|
42
|
+
`- Ordinary answers stay in chat, including explanations, comparisons, short plans and step-by-step instructions. No headings or horizontal rules. Lists up to ${limits.maxListItems} items, code up to ${limits.maxCodeLines} lines and **bold** for the key fact are fine in chat. Write a small comparison as one short line per item, not a table.`,
|
|
43
|
+
...(files
|
|
44
|
+
? [
|
|
45
|
+
`- Deliver every file meant for the user with \`send_file\` (${SENDABLE_FILE_TYPES} only): ${deliversAttachments(surface) ? 'the user receives the file itself here, as an attachment' : 'a link to the file is posted here for you'}. When the user asks for a file in a particular format, build it yourself in the sandbox and send it this way. Do not send a file you downloaded from the internet unless the user asked for that exact file. Never paste a download link, and do not describe how to open or download it. Do not send helper scripts or intermediate files. Call it once per file, after the sandbox call that saved the file has finished.`,
|
|
46
|
+
]
|
|
47
|
+
: []),
|
|
21
48
|
artifacts
|
|
22
|
-
? '
|
|
23
|
-
: '-
|
|
49
|
+
? `- Use \`create_artifact\` ${files ? 'for a document with no particular file format' : 'only when the user asks for a document or file'}, or when the output is genuinely document-length: several screens of text, long code, a long report. Even then, its message carries the conclusions, decisions and next actions, and the document holds the detail. Call it last: the user gets the message, the document, then at most one follow-up question.`
|
|
50
|
+
: '- Everything you write is delivered as messages; when something is genuinely long, put the essential part first.',
|
|
24
51
|
'- The user sees a typing indicator while you work. Do not narrate your steps or announce tool calls.',
|
|
25
52
|
'- End with at most one question.',
|
|
26
53
|
].join('\n');
|