@ixo/oracle-runtime-workers 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/artifacts/tool.ts +7 -4
- package/src/attachments/pipeline.ts +3 -1
- package/src/attachments/route.ts +11 -0
- package/src/attachments/view.ts +5 -1
- package/src/channels/contract.ts +3 -2
- package/src/channels/turns.ts +27 -9
- package/src/core/env.ts +4 -2
- package/src/core/llm.ts +70 -1
- package/src/core/main-agent.ts +5 -0
- package/src/core/middlewares/result-cap.ts +2 -0
- package/src/core/middlewares/turn-boundary.ts +14 -0
- package/src/delivery/plan.ts +89 -20
- package/src/delivery/profile.ts +25 -20
- package/src/delivery/prompt.ts +33 -7
- package/src/delivery/schema.ts +69 -5
- package/src/delivery/shaper.ts +329 -87
- package/src/delivery/types.ts +48 -8
- package/src/do/contracts.ts +2 -0
- package/src/do/run-coordinator.ts +13 -3
- package/src/do/run-store.ts +53 -3
- package/src/do/transcript.ts +17 -1
- package/src/do/user-oracle-do.ts +51 -3
- package/src/matrix/reply-parts.ts +9 -1
- package/src/plugins/ixo-chain/names/client.ts +7 -3
- package/src/plugins/sandbox/index.ts +1 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +41 -8
- package/src/plugins/sandbox/send-file.ts +302 -0
- package/src/sqlite/blob-codec.ts +16 -31
package/src/delivery/shaper.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The deterministic shaper: one model step's Markdown → chat messages, or a
|
|
3
|
-
* spill (lead message, artefact, closing
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* spill (lead message, artefact, closing message) when the step is
|
|
4
|
+
* document-length: longer than `spillChars`, or holding a list, table or
|
|
5
|
+
* code block too long to read in chat. Everything else stays in chat: short
|
|
6
|
+
* messages are grouped toward `maxBubbles`, a step that cannot fit that many
|
|
7
|
+
* keeps more messages rather than losing text, a table is rewritten as one
|
|
8
|
+
* line per row, and no message is longer than `bubbleMax`. No model call; the same
|
|
9
|
+
* input always gives the same output, which is what makes a Reply Plan safe
|
|
10
|
+
* to rebuild on recovery.
|
|
6
11
|
*/
|
|
7
12
|
import { marked, type Token, type Tokens } from 'marked';
|
|
8
13
|
import type { ChatLimits } from './types';
|
|
@@ -11,9 +16,17 @@ export interface SpillShape {
|
|
|
11
16
|
title: string;
|
|
12
17
|
/** The whole step as the model wrote it: the artefact's content. */
|
|
13
18
|
markdown: string;
|
|
14
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* What stays in chat before the link, within `bubbleMax`: the first block
|
|
21
|
+
* whole (a paragraph over `bubbleMax` cut at a sentence), or a list's or
|
|
22
|
+
* table's lead-in and first items.
|
|
23
|
+
*/
|
|
15
24
|
lead: string;
|
|
16
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* What stays in chat after the link, within `bubbleMax`: the step's last
|
|
27
|
+
* paragraph (with the paragraph before it when both fit), or its closing
|
|
28
|
+
* list or table previewed. Its conclusion, next actions or question.
|
|
29
|
+
*/
|
|
17
30
|
closing?: string;
|
|
18
31
|
}
|
|
19
32
|
|
|
@@ -37,9 +50,13 @@ const isCode = (t: Token): t is Tokens.Code => t.type === 'code';
|
|
|
37
50
|
const isTable = (t: Token): t is Tokens.Table => t.type === 'table';
|
|
38
51
|
const isHtml = (t: Token): t is Tokens.HTML => t.type === 'html';
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
// A pinned locale, so a rebuilt plan splits the same way on every runtime.
|
|
54
|
+
const sentenceSegmenter = new Intl.Segmenter('en', {
|
|
41
55
|
granularity: 'sentence',
|
|
42
56
|
});
|
|
57
|
+
const graphemeSegmenter = new Intl.Segmenter('en', {
|
|
58
|
+
granularity: 'grapheme',
|
|
59
|
+
});
|
|
43
60
|
|
|
44
61
|
/**
|
|
45
62
|
* HTML elements a model mixes into Markdown. Anything else in angle brackets
|
|
@@ -145,19 +162,34 @@ function plainText(text: string): string {
|
|
|
145
162
|
.trim();
|
|
146
163
|
}
|
|
147
164
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.
|
|
165
|
+
/** One table row as a chat line: `- **first cell** · Header: value · …`. */
|
|
166
|
+
function rowLine(first: string, rest: string[]): string {
|
|
167
|
+
const parts = [...(first ? [`**${first}**`] : []), ...rest].filter(Boolean);
|
|
168
|
+
return parts.length ? `- ${parts.join(' · ')}` : '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A table's chat lines, one per row; a table with no body rows is its header. */
|
|
172
|
+
function tableLines(table: Tokens.Table): string[] {
|
|
173
|
+
const headers = table.header.map((cell) => cell.text.trim());
|
|
174
|
+
const lines =
|
|
175
|
+
table.rows.length === 0
|
|
176
|
+
? [rowLine(headers[0] ?? '', headers.slice(1))]
|
|
177
|
+
: table.rows.map((row) => {
|
|
178
|
+
const [first, ...rest] = row.map((cell) => cell.text.trim());
|
|
179
|
+
return rowLine(
|
|
180
|
+
first ?? '',
|
|
181
|
+
rest.map((value, i) => {
|
|
182
|
+
const header = headers[i + 1];
|
|
183
|
+
return header && value ? `${header}: ${value}` : value;
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
return lines.filter(Boolean);
|
|
159
188
|
}
|
|
160
189
|
|
|
190
|
+
const tableAsList = (table: Tokens.Table): string =>
|
|
191
|
+
tableLines(table).join('\n');
|
|
192
|
+
|
|
161
193
|
function toBlocks(markdown: string, limits: ChatLimits): Block[] {
|
|
162
194
|
const blocks: Block[] = [];
|
|
163
195
|
for (const token of marked.lexer(markdown)) {
|
|
@@ -171,11 +203,8 @@ function toBlocks(markdown: string, limits: ChatLimits): Block[] {
|
|
|
171
203
|
} else if (isCode(token)) {
|
|
172
204
|
blocks.push({ kind: 'code', md: token.raw.trim(), token });
|
|
173
205
|
} else if (isTable(token)) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
md: limits.tables ? token.raw.trim() : tableAsList(token),
|
|
177
|
-
token,
|
|
178
|
-
});
|
|
206
|
+
const md = limits.tables ? token.raw.trim() : tableAsList(token);
|
|
207
|
+
if (!isBlank(md)) blocks.push({ kind: 'table', md, token });
|
|
179
208
|
} else if (token.type === 'blockquote') {
|
|
180
209
|
blocks.push({ kind: 'quote', md: chatMarkdown(token).trim(), token });
|
|
181
210
|
} else {
|
|
@@ -228,11 +257,40 @@ export function splitText(text: string, max: number): string[] {
|
|
|
228
257
|
if (sentences.length > 1) return packPieces(sentences, '', size);
|
|
229
258
|
const words = text.split(/(?<=\s)/);
|
|
230
259
|
if (words.length > 1) return packPieces(words, '', size);
|
|
260
|
+
// The hard cut never splits a grapheme (an emoji, a surrogate pair); one
|
|
261
|
+
// grapheme longer than `size` is a piece of its own.
|
|
231
262
|
const out: string[] = [];
|
|
232
|
-
|
|
263
|
+
let rest = text;
|
|
264
|
+
while (rest) {
|
|
265
|
+
const piece = graphemePrefix(rest, size) || firstGrapheme(rest);
|
|
266
|
+
out.push(piece);
|
|
267
|
+
rest = rest.slice(piece.length);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The longest prefix of whole graphemes within `max` characters. */
|
|
273
|
+
function graphemePrefix(text: string, max: number): string {
|
|
274
|
+
let out = '';
|
|
275
|
+
for (const { segment } of graphemeSegmenter.segment(text)) {
|
|
276
|
+
if (out.length + segment.length > max) break;
|
|
277
|
+
out += segment;
|
|
278
|
+
}
|
|
233
279
|
return out;
|
|
234
280
|
}
|
|
235
281
|
|
|
282
|
+
function firstGrapheme(text: string): string {
|
|
283
|
+
for (const { segment } of graphemeSegmenter.segment(text)) return segment;
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `text` cut to `max` characters with "…", at a word when one is near. */
|
|
288
|
+
function cutWithEllipsis(text: string, max: number): string {
|
|
289
|
+
const cut = graphemePrefix(text, max - 1);
|
|
290
|
+
const space = cut.lastIndexOf(' ');
|
|
291
|
+
return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
292
|
+
}
|
|
293
|
+
|
|
236
294
|
/** A code block too long for one message is re-fenced per piece. */
|
|
237
295
|
function splitBlock(block: Block, max: number): string[] {
|
|
238
296
|
if (block.md.length <= max) return [block.md];
|
|
@@ -255,42 +313,159 @@ function trimToSentence(text: string, max: number): string {
|
|
|
255
313
|
out += sentence;
|
|
256
314
|
}
|
|
257
315
|
if (out.trim()) return out.trim();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
316
|
+
return cutWithEllipsis(text, max);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The last sentences of `text` that fit `max`, or '' when none does. */
|
|
320
|
+
function lastSentences(text: string, max: number): string {
|
|
321
|
+
let out = '';
|
|
322
|
+
for (const sentence of sentencesOf(text).reverse()) {
|
|
323
|
+
if ((sentence + out).trim().length > max) break;
|
|
324
|
+
out = sentence + out;
|
|
325
|
+
}
|
|
326
|
+
return out.trim();
|
|
261
327
|
}
|
|
262
328
|
|
|
263
329
|
function titleOf(text: string): string {
|
|
264
330
|
const plain = plainText(sentencesOf(text)[0] ?? text).replace(/[:.]$/, '');
|
|
265
331
|
if (plain.length <= TITLE_MAX) return plain;
|
|
266
|
-
|
|
267
|
-
const space = cut.lastIndexOf(' ');
|
|
268
|
-
return `${(space > TITLE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
332
|
+
return cutWithEllipsis(plain, TITLE_MAX);
|
|
269
333
|
}
|
|
270
334
|
|
|
271
335
|
function firstLine(text: string): string {
|
|
272
336
|
return (text.split('\n')[0] ?? '').trim();
|
|
273
337
|
}
|
|
274
338
|
|
|
275
|
-
|
|
339
|
+
/** A list's or a table's items as chat lines: a list item by its first line. */
|
|
340
|
+
function itemLines(block: Block): string[] {
|
|
341
|
+
if (isTable(block.token)) return tableLines(block.token);
|
|
342
|
+
if (!isList(block.token)) return [];
|
|
343
|
+
const list = block.token;
|
|
276
344
|
const start = typeof list.start === 'number' ? list.start : 1;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
345
|
+
return list.items.map(
|
|
346
|
+
(item, i) =>
|
|
347
|
+
`${list.ordered ? `${start + i}.` : '-'} ${firstLine(item.text)}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function previewText(head: string, lines: string[], total: number): string {
|
|
352
|
+
const more = total - lines.length;
|
|
353
|
+
return joinBlocks(
|
|
354
|
+
head,
|
|
355
|
+
lines.join('\n') + (more > 0 ? `\n…and ${more} more` : ''),
|
|
356
|
+
);
|
|
285
357
|
}
|
|
286
358
|
|
|
359
|
+
/**
|
|
360
|
+
* A list or table previewed within `bubbleMax`, after an optional "…:"
|
|
361
|
+
* lead-in: its first `previewItems` items and "…and N more". Fewer items
|
|
362
|
+
* are kept (down to one) until it fits; then that item, and if needed the
|
|
363
|
+
* lead-in, is cut with "…". The trailer and its count always stay.
|
|
364
|
+
*/
|
|
365
|
+
function fittedPreview(head: string, block: Block, limits: ChatLimits): string {
|
|
366
|
+
const lines = itemLines(block);
|
|
367
|
+
const max = limits.bubbleMax;
|
|
368
|
+
if (lines.length === 0)
|
|
369
|
+
return head.length <= max ? head : trimToSentence(head, max);
|
|
370
|
+
for (
|
|
371
|
+
let count = Math.min(limits.previewItems, lines.length);
|
|
372
|
+
count >= 1;
|
|
373
|
+
count--
|
|
374
|
+
) {
|
|
375
|
+
const text = previewText(head, lines.slice(0, count), lines.length);
|
|
376
|
+
if (text.length <= max) return text;
|
|
377
|
+
}
|
|
378
|
+
const more = lines.length - 1;
|
|
379
|
+
const trailer = more > 0 ? `\n…and ${more} more` : '';
|
|
380
|
+
const line = lines[0]!;
|
|
381
|
+
// Room for the item first (up to a third of the message), then the lead-in.
|
|
382
|
+
const itemRoom = Math.min(line.length, Math.floor(max / 3));
|
|
383
|
+
const headMax = max - 2 - itemRoom - trailer.length;
|
|
384
|
+
const fittedHead =
|
|
385
|
+
!head || head.length <= headMax ? head : trimToSentence(head, headMax);
|
|
386
|
+
const lineMax =
|
|
387
|
+
max - (fittedHead ? fittedHead.length + 2 : 0) - trailer.length;
|
|
388
|
+
const fittedLine =
|
|
389
|
+
line.length <= lineMax ? line : cutWithEllipsis(line, lineMax);
|
|
390
|
+
return previewText(fittedHead, [fittedLine], lines.length);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const isItems = (block: Block): boolean =>
|
|
394
|
+
block.kind === 'list' || block.kind === 'table';
|
|
395
|
+
|
|
287
396
|
const endsWithColon = (md: string): boolean => /:\s*$/.test(md);
|
|
288
397
|
const joinBlocks = (a: string, b: string): string => (a ? `${a}\n\n${b}` : b);
|
|
289
398
|
|
|
290
|
-
/**
|
|
399
|
+
/**
|
|
400
|
+
* One message being packed: `code` when it holds a fenced code block,
|
|
401
|
+
* `opens` when it must start a message (a heading or lead-in sent ahead of
|
|
402
|
+
* its body; the body may still join it).
|
|
403
|
+
*/
|
|
404
|
+
interface Unit {
|
|
405
|
+
text: string;
|
|
406
|
+
code: boolean;
|
|
407
|
+
opens?: boolean;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Units filled left to right into messages of at most `max` characters. A
|
|
412
|
+
* code unit is never joined to another, so a fenced block keeps its own
|
|
413
|
+
* message. Greedy filling gives the fewest messages for the order given.
|
|
414
|
+
*/
|
|
415
|
+
function fill(units: Unit[], max: number): Unit[] {
|
|
416
|
+
const out: Unit[] = [];
|
|
417
|
+
for (const unit of units) {
|
|
418
|
+
const last = out.at(-1);
|
|
419
|
+
if (
|
|
420
|
+
last &&
|
|
421
|
+
!last.code &&
|
|
422
|
+
!unit.code &&
|
|
423
|
+
!unit.opens &&
|
|
424
|
+
last.text.length + 2 + unit.text.length <= max
|
|
425
|
+
)
|
|
426
|
+
out[out.length - 1] = {
|
|
427
|
+
text: joinBlocks(last.text, unit.text),
|
|
428
|
+
code: false,
|
|
429
|
+
...(last.opens ? { opens: true } : {}),
|
|
430
|
+
};
|
|
431
|
+
else out.push(unit);
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Toward `maxBubbles` messages. A step already within it keeps the model's
|
|
438
|
+
* own message breaks. Otherwise units are filled up to `bubbleTarget`, and
|
|
439
|
+
* when that still leaves too many, the original units are filled again up
|
|
440
|
+
* to `bubbleMax`. A step that cannot fit stays at more messages: nothing is
|
|
441
|
+
* dropped, and nothing spills on count.
|
|
442
|
+
*/
|
|
443
|
+
function preferFewer(units: Unit[], limits: ChatLimits): Unit[] {
|
|
444
|
+
if (units.length <= limits.maxBubbles) return units;
|
|
445
|
+
const byTarget = fill(units, limits.bubbleTarget);
|
|
446
|
+
return byTarget.length <= limits.maxBubbles
|
|
447
|
+
? byTarget
|
|
448
|
+
: fill(units, limits.bubbleMax);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Blocks → messages, each within `bubbleMax`. Headings and "…:" lead-ins
|
|
453
|
+
* start the message that holds what follows them: when joining would pass
|
|
454
|
+
* `bubbleMax`, the block after them is split with the room left, so they
|
|
455
|
+
* ride on its first piece; only a lead-in too long for that (over half of
|
|
456
|
+
* `bubbleMax`) is sent ahead as messages of its own, which nothing joins
|
|
457
|
+
* from behind. Fragments under `minBubble` merge forward when the result
|
|
458
|
+
* fits, then short messages are grouped toward `maxBubbles` (`preferFewer`).
|
|
459
|
+
*/
|
|
291
460
|
function pack(blocks: Block[], limits: ChatLimits): string[] {
|
|
292
|
-
const units:
|
|
461
|
+
const units: Unit[] = [];
|
|
293
462
|
let carry = '';
|
|
463
|
+
const flush = (opens: boolean): void => {
|
|
464
|
+
for (const piece of splitText(carry, limits.bubbleMax))
|
|
465
|
+
if (!isBlank(piece))
|
|
466
|
+
units.push({ text: piece, code: false, ...(opens ? { opens } : {}) });
|
|
467
|
+
carry = '';
|
|
468
|
+
};
|
|
294
469
|
blocks.forEach((block, i) => {
|
|
295
470
|
const next = blocks[i + 1];
|
|
296
471
|
const leadIn =
|
|
@@ -302,23 +477,56 @@ function pack(blocks: Block[], limits: ChatLimits): string[] {
|
|
|
302
477
|
carry = joinBlocks(carry, block.md);
|
|
303
478
|
return;
|
|
304
479
|
}
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
480
|
+
const code = block.kind === 'code';
|
|
481
|
+
const room = limits.bubbleMax - carry.length - 2;
|
|
482
|
+
let pieces: string[];
|
|
483
|
+
if (!carry || joinBlocks(carry, block.md).length <= limits.bubbleMax)
|
|
484
|
+
pieces = splitBlock(block, limits.bubbleMax);
|
|
485
|
+
else if (room >= limits.bubbleMax / 2) pieces = splitBlock(block, room);
|
|
486
|
+
else {
|
|
487
|
+
flush(true);
|
|
488
|
+
pieces = splitBlock(block, limits.bubbleMax);
|
|
489
|
+
}
|
|
490
|
+
const [head = '', ...rest] = pieces;
|
|
491
|
+
units.push({ text: joinBlocks(carry, head), code });
|
|
492
|
+
for (const piece of rest) units.push({ text: piece, code });
|
|
309
493
|
carry = '';
|
|
310
494
|
});
|
|
311
|
-
if (carry)
|
|
312
|
-
const merged:
|
|
495
|
+
if (carry) flush(false);
|
|
496
|
+
const merged: Unit[] = [];
|
|
313
497
|
for (let i = 0; i < units.length; i++) {
|
|
314
498
|
const unit = units[i]!;
|
|
315
|
-
|
|
316
|
-
|
|
499
|
+
const next = units[i + 1];
|
|
500
|
+
const joined = next ? joinBlocks(unit.text, next.text) : '';
|
|
501
|
+
if (
|
|
502
|
+
next &&
|
|
503
|
+
unit.text.length < limits.minBubble &&
|
|
504
|
+
joined.length <= limits.bubbleMax
|
|
505
|
+
)
|
|
506
|
+
units[i + 1] = {
|
|
507
|
+
text: joined,
|
|
508
|
+
code: unit.code || next.code,
|
|
509
|
+
...(unit.opens ? { opens: true } : {}),
|
|
510
|
+
};
|
|
317
511
|
else merged.push(unit);
|
|
318
512
|
}
|
|
319
|
-
return merged;
|
|
513
|
+
return preferFewer(merged, limits).map((unit) => unit.text);
|
|
320
514
|
}
|
|
321
515
|
|
|
516
|
+
/**
|
|
517
|
+
* What a spill keeps in chat, so the conversation still carries the
|
|
518
|
+
* substance when the document cannot be opened, each within `bubbleMax`:
|
|
519
|
+
*
|
|
520
|
+
* - lead: the first block after any heading (after a leading code block,
|
|
521
|
+
* which only the artefact holds, unless that block is a lone trailing
|
|
522
|
+
* paragraph, which is the closing instead): a paragraph or quote
|
|
523
|
+
* whole when it fits, else cut at a sentence; a list or table, with its
|
|
524
|
+
* "…:" lead-in, previewed (`fittedPreview`);
|
|
525
|
+
* - closing: the last block, when the lead did not use it: a list or table
|
|
526
|
+
* (with its lead-in) previewed, or the last paragraph, joined to the
|
|
527
|
+
* paragraph before it when both fit (a conclusion and its question), or
|
|
528
|
+
* its last sentences that fit.
|
|
529
|
+
*/
|
|
322
530
|
function spillOf(
|
|
323
531
|
markdown: string,
|
|
324
532
|
blocks: Block[],
|
|
@@ -326,30 +534,63 @@ function spillOf(
|
|
|
326
534
|
): SpillShape {
|
|
327
535
|
const heading = blocks.find((b) => b.kind === 'heading');
|
|
328
536
|
const content = blocks.filter((b) => b.kind !== 'heading');
|
|
329
|
-
const
|
|
537
|
+
const max = limits.bubbleMax;
|
|
538
|
+
const used = new Set<Block>();
|
|
539
|
+
const start = content.findIndex((b) => b.kind !== 'code');
|
|
540
|
+
const first = start >= 0 ? content[start] : undefined;
|
|
541
|
+
const second = start >= 0 ? content[start + 1] : undefined;
|
|
542
|
+
// After leading code, a lone trailing paragraph ("Want me to apply it?")
|
|
543
|
+
// is the closing, after the link, not the lead.
|
|
544
|
+
const onlyClosing =
|
|
545
|
+
start > 0 && first === content.at(-1) && first?.kind === 'paragraph';
|
|
330
546
|
let lead = '';
|
|
331
|
-
if (
|
|
332
|
-
lead
|
|
333
|
-
else if (
|
|
547
|
+
if (onlyClosing) {
|
|
548
|
+
// No lead: the closing below takes the paragraph.
|
|
549
|
+
} else if (first && isItems(first)) {
|
|
550
|
+
lead = fittedPreview('', first, limits);
|
|
551
|
+
used.add(first);
|
|
552
|
+
} else if (
|
|
334
553
|
first?.kind === 'paragraph' &&
|
|
335
554
|
endsWithColon(first.md) &&
|
|
336
555
|
second &&
|
|
337
|
-
|
|
338
|
-
)
|
|
339
|
-
lead =
|
|
340
|
-
|
|
341
|
-
|
|
556
|
+
isItems(second)
|
|
557
|
+
) {
|
|
558
|
+
lead = fittedPreview(first.md, second, limits);
|
|
559
|
+
used.add(first).add(second);
|
|
560
|
+
} else if (first && (first.kind === 'paragraph' || first.kind === 'quote')) {
|
|
561
|
+
lead = first.md.length <= max ? first.md : trimToSentence(first.md, max);
|
|
562
|
+
used.add(first);
|
|
563
|
+
}
|
|
342
564
|
const last = content.at(-1);
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
last
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
565
|
+
const before = content.at(-2);
|
|
566
|
+
let closing: string | undefined;
|
|
567
|
+
if (last && !used.has(last)) {
|
|
568
|
+
if (isItems(last)) {
|
|
569
|
+
const leadIn =
|
|
570
|
+
before?.kind === 'paragraph' &&
|
|
571
|
+
endsWithColon(before.md) &&
|
|
572
|
+
!used.has(before)
|
|
573
|
+
? before.md
|
|
574
|
+
: '';
|
|
575
|
+
closing = fittedPreview(leadIn, last, limits);
|
|
576
|
+
} else if (last.kind === 'paragraph') {
|
|
577
|
+
const pair =
|
|
578
|
+
before?.kind === 'paragraph' && !used.has(before)
|
|
579
|
+
? joinBlocks(before.md, last.md)
|
|
580
|
+
: '';
|
|
581
|
+
closing =
|
|
582
|
+
pair && pair.length <= max
|
|
583
|
+
? pair
|
|
584
|
+
: last.md.length <= max
|
|
585
|
+
? last.md
|
|
586
|
+
: lastSentences(last.md, max);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (closing === lead || (closing !== undefined && isBlank(closing)))
|
|
590
|
+
closing = undefined;
|
|
351
591
|
const titleSource =
|
|
352
|
-
heading?.md ??
|
|
592
|
+
heading?.md ??
|
|
593
|
+
(content[0]?.kind === 'paragraph' ? content[0].md : undefined);
|
|
353
594
|
return {
|
|
354
595
|
title: (titleSource && titleOf(titleSource)) || 'Details',
|
|
355
596
|
markdown,
|
|
@@ -359,9 +600,14 @@ function spillOf(
|
|
|
359
600
|
}
|
|
360
601
|
|
|
361
602
|
/**
|
|
362
|
-
* Shape one model step for a chat surface.
|
|
363
|
-
*
|
|
364
|
-
*
|
|
603
|
+
* Shape one model step for a chat surface. A step spills only when it is
|
|
604
|
+
* document-length: its rendered text (blocks joined by blank lines) is over
|
|
605
|
+
* `spillChars`, a list or a table has more than `maxListItems` items or
|
|
606
|
+
* rows, or a code block more than `maxCodeLines` lines. Otherwise it stays in
|
|
607
|
+
* chat as the messages it packs into (grouped toward `maxBubbles`, never
|
|
608
|
+
* spilled on count; the run's cap is `capParts`' job). With `canSpill` false
|
|
609
|
+
* (no artefact storage configured) the step is always split into messages
|
|
610
|
+
* and nothing is dropped.
|
|
365
611
|
*/
|
|
366
612
|
export function shapeStep(
|
|
367
613
|
markdown: string,
|
|
@@ -369,21 +615,17 @@ export function shapeStep(
|
|
|
369
615
|
canSpill: boolean,
|
|
370
616
|
): StepShape {
|
|
371
617
|
const blocks = toBlocks(markdown.trim(), limits);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
b.token.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
messages.length > limits.maxBubbles
|
|
386
|
-
)
|
|
387
|
-
return { kind: 'spill', spill: spillOf(markdown.trim(), blocks, limits) };
|
|
388
|
-
return { kind: 'messages', messages };
|
|
618
|
+
if (canSpill) {
|
|
619
|
+
const length = blocks.map((b) => b.md).join('\n\n').length;
|
|
620
|
+
const tooLongForChat = blocks.some(
|
|
621
|
+
(b) =>
|
|
622
|
+
(isCode(b.token) &&
|
|
623
|
+
b.token.text.split('\n').length > limits.maxCodeLines) ||
|
|
624
|
+
(isList(b.token) && b.token.items.length > limits.maxListItems) ||
|
|
625
|
+
(isTable(b.token) && b.token.rows.length > limits.maxListItems),
|
|
626
|
+
);
|
|
627
|
+
if (length > limits.spillChars || tooLongForChat)
|
|
628
|
+
return { kind: 'spill', spill: spillOf(markdown.trim(), blocks, limits) };
|
|
629
|
+
}
|
|
630
|
+
return { kind: 'messages', messages: pack(blocks, limits) };
|
|
389
631
|
}
|
package/src/delivery/types.ts
CHANGED
|
@@ -9,30 +9,49 @@ import type { ActionReferenceV1 } from '@ixo/action-protocol';
|
|
|
9
9
|
* agent core, and IXO Channels reads the same shapes off `/channels/turn`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Size and shape limits of one chat surface. An ordinary answer stays in
|
|
14
|
+
* chat, as few messages as fit and none longer than `bubbleMax`; only a
|
|
15
|
+
* document-length step (`spillChars`, `maxListItems`, `maxCodeLines`)
|
|
16
|
+
* moves to an artefact.
|
|
17
|
+
*/
|
|
13
18
|
export interface ChatLimits {
|
|
14
|
-
/** Soft size of one message, in characters. */
|
|
19
|
+
/** Soft size of one message, in characters; the prompt asks for it. */
|
|
15
20
|
bubbleTarget: number;
|
|
16
21
|
/** Hard size of one message before it is split at a sentence boundary. */
|
|
17
22
|
bubbleMax: number;
|
|
18
23
|
/** Fragments shorter than this merge into the message that follows. */
|
|
19
24
|
minBubble: number;
|
|
20
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Preferred messages per answer: the prompt asks for it, and a step with
|
|
27
|
+
* more messages is filled left to right into messages of `bubbleTarget`,
|
|
28
|
+
* then of `bubbleMax` if needed (a code block of at least `minBubble`
|
|
29
|
+
* characters keeps its own message). A
|
|
30
|
+
* preference only: a step that cannot fit stays at more messages, and
|
|
31
|
+
* nothing is spilled or dropped to meet it.
|
|
32
|
+
*/
|
|
21
33
|
maxBubbles: number;
|
|
22
34
|
/**
|
|
23
35
|
* Parts one run delivers at most, met by merging text within `bubbleMax`
|
|
24
36
|
* and dropping spill framing. Text found nowhere else is never dropped.
|
|
25
37
|
*/
|
|
26
38
|
maxPartsPerRun: number;
|
|
27
|
-
/** A step longer than this moves to an artefact. */
|
|
39
|
+
/** A step whose rendered text is longer than this moves to an artefact. */
|
|
28
40
|
spillChars: number;
|
|
29
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* A list with more items, or a table with more rows, moves to an
|
|
43
|
+
* artefact, previewed by its first items.
|
|
44
|
+
*/
|
|
30
45
|
maxListItems: number;
|
|
31
|
-
/**
|
|
46
|
+
/** Items or rows kept in the message when a list or table moves to an artefact. */
|
|
32
47
|
previewItems: number;
|
|
33
|
-
/** A
|
|
48
|
+
/** A code block with more lines moves to an artefact. */
|
|
34
49
|
maxCodeLines: number;
|
|
35
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Whether a Markdown table may stay inside a message as a table. Otherwise
|
|
52
|
+
* it is rewritten as one line per row. Only its row count can move it to
|
|
53
|
+
* an artefact (`maxListItems`).
|
|
54
|
+
*/
|
|
36
55
|
tables: boolean;
|
|
37
56
|
}
|
|
38
57
|
|
|
@@ -65,6 +84,25 @@ export interface ArtifactRef {
|
|
|
65
84
|
expiresAt: string;
|
|
66
85
|
}
|
|
67
86
|
|
|
87
|
+
/**
|
|
88
|
+
* A file an oracle made, delivered as an attachment by a gateway (or as a link
|
|
89
|
+
* line where attachments do not exist). `url` is a signed, bearer download
|
|
90
|
+
* link: it never reaches the model, and never a log, metric or error string.
|
|
91
|
+
*/
|
|
92
|
+
export interface ReplyFile {
|
|
93
|
+
url: string;
|
|
94
|
+
/** Sanitised, from the artefact path, with its extension. */
|
|
95
|
+
filename: string;
|
|
96
|
+
/** From the extension's allow-list; `application/octet-stream` when unknown. */
|
|
97
|
+
mimetype: string;
|
|
98
|
+
/** Size, when the store reported it. */
|
|
99
|
+
bytes?: number;
|
|
100
|
+
/** Shown with the file, at most 1,024 characters. */
|
|
101
|
+
caption?: string;
|
|
102
|
+
/** When `url` stops working, ISO 8601. */
|
|
103
|
+
expiresAt?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
/**
|
|
69
107
|
* One delivery unit. `text` carries chat Markdown: bold, italic, strike,
|
|
70
108
|
* inline code, fenced code, `-` and `1.` lists, `>` quotes and links. No
|
|
@@ -73,6 +111,7 @@ export interface ArtifactRef {
|
|
|
73
111
|
export type ReplyPart =
|
|
74
112
|
| { partId: string; kind: 'text'; text: string }
|
|
75
113
|
| { partId: string; kind: 'artifact'; artifact: ArtifactRef }
|
|
114
|
+
| { partId: string; kind: 'file'; file: ReplyFile }
|
|
76
115
|
| { partId: string; kind: 'action'; action: ActionReference }
|
|
77
116
|
| {
|
|
78
117
|
partId: string;
|
|
@@ -85,6 +124,7 @@ export type ReplyPart =
|
|
|
85
124
|
export type ReplyContent =
|
|
86
125
|
| { kind: 'text'; text: string }
|
|
87
126
|
| { kind: 'artifact'; artifact: ArtifactRef }
|
|
127
|
+
| { kind: 'file'; file: ReplyFile }
|
|
88
128
|
| { kind: 'action'; action: ActionReference }
|
|
89
129
|
| { kind: 'hub-action'; actionReference: ActionReferenceV1; summary: string };
|
|
90
130
|
|
package/src/do/contracts.ts
CHANGED
|
@@ -280,6 +280,7 @@ export interface OracleWorkerEnv {
|
|
|
280
280
|
RUN_SEGMENT_FLUSH_MS?: string;
|
|
281
281
|
RUN_SEGMENT_BYTES?: string;
|
|
282
282
|
RUN_RECOVERY_ATTEMPTS?: string;
|
|
283
|
+
RUN_RECOVERY_TOTAL_ATTEMPTS?: string;
|
|
283
284
|
RUN_RECOVERY_DELAYS_MS?: string;
|
|
284
285
|
TURN_MULTITASK_DEFAULT?: string;
|
|
285
286
|
/** Context-budget knobs (docs/plans/context-budgets.md). */
|
|
@@ -581,6 +582,7 @@ export interface RunsStatus {
|
|
|
581
582
|
segmentFlushMs: number;
|
|
582
583
|
segmentBytes: number;
|
|
583
584
|
recoveryAttempts: number;
|
|
585
|
+
recoveryTotalAttempts: number;
|
|
584
586
|
recoveryDelaysMs: readonly number[];
|
|
585
587
|
multitaskDefault: 'interrupt' | 'enqueue';
|
|
586
588
|
};
|