@animalabs/agent-framework 0.24.1 → 0.26.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.
Files changed (45) hide show
  1. package/dist/src/agent.d.ts.map +1 -1
  2. package/dist/src/agent.js +21 -8
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/framework.d.ts +18 -1
  5. package/dist/src/framework.d.ts.map +1 -1
  6. package/dist/src/framework.js +87 -37
  7. package/dist/src/framework.js.map +1 -1
  8. package/dist/src/mcpl/references.d.ts +4 -1
  9. package/dist/src/mcpl/references.d.ts.map +1 -1
  10. package/dist/src/mcpl/references.js +5 -2
  11. package/dist/src/mcpl/references.js.map +1 -1
  12. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  13. package/dist/src/modules/workspace/index.js +74 -20
  14. package/dist/src/modules/workspace/index.js.map +1 -1
  15. package/dist/src/modules/workspace/types.d.ts +6 -2
  16. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  17. package/dist/src/modules/workspace/types.js.map +1 -1
  18. package/dist/src/text-as-stored.d.ts +31 -0
  19. package/dist/src/text-as-stored.d.ts.map +1 -0
  20. package/dist/src/text-as-stored.js +70 -0
  21. package/dist/src/text-as-stored.js.map +1 -0
  22. package/dist/src/tool-image-ledger.d.ts +26 -3
  23. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  24. package/dist/src/tool-image-ledger.js +31 -4
  25. package/dist/src/tool-image-ledger.js.map +1 -1
  26. package/dist/src/tool-presentation.d.ts.map +1 -1
  27. package/dist/src/tool-presentation.js +9 -1
  28. package/dist/src/tool-presentation.js.map +1 -1
  29. package/dist/src/tool-result-history.d.ts +15 -5
  30. package/dist/src/tool-result-history.d.ts.map +1 -1
  31. package/dist/src/tool-result-history.js +33 -9
  32. package/dist/src/tool-result-history.js.map +1 -1
  33. package/dist/src/types/events.d.ts +8 -2
  34. package/dist/src/types/events.d.ts.map +1 -1
  35. package/package.json +3 -3
  36. package/src/agent.ts +21 -8
  37. package/src/framework.ts +92 -36
  38. package/src/mcpl/references.ts +5 -2
  39. package/src/modules/workspace/index.ts +74 -20
  40. package/src/modules/workspace/types.ts +6 -2
  41. package/src/text-as-stored.ts +73 -0
  42. package/src/tool-image-ledger.ts +31 -4
  43. package/src/tool-presentation.ts +8 -1
  44. package/src/tool-result-history.ts +33 -8
  45. package/src/types/events.ts +8 -2
@@ -1851,11 +1851,12 @@ export class WorkspaceModule implements Module {
1851
1851
  },
1852
1852
  {
1853
1853
  name: 'glob',
1854
- description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.' + globDirect,
1854
+ description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes. '
1855
+ + 'The pattern is relative to path (or to each mount\'s root). In the mount its first segment names, it is read mount-prefixed instead, like the paths it returns, within path if one is given; in every other mount, as written.' + globDirect,
1855
1856
  inputSchema: {
1856
1857
  type: 'object' as const,
1857
1858
  properties: {
1858
- pattern: { type: 'string', description: 'Glob pattern (e.g., "**/*.ts")' },
1859
+ pattern: { type: 'string', description: 'Glob pattern, relative (e.g., "**/*.ts") or mount-prefixed (e.g., "project/src/**/*.ts")' },
1859
1860
  path: { type: 'string', description: 'Directory to search in (mount-prefixed, optional)' },
1860
1861
  },
1861
1862
  required: ['pattern'],
@@ -1873,7 +1874,7 @@ export class WorkspaceModule implements Module {
1873
1874
  properties: {
1874
1875
  pattern: { type: 'string', description: 'Regular expression pattern' },
1875
1876
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
1876
- glob: { type: 'string', description: 'Glob pattern to filter files' },
1877
+ glob: { type: 'string', description: 'Glob pattern to filter files, relative to the mount root (e.g., "src/**/*.ts"); in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), and in every other mount, as written' },
1877
1878
  contextBefore: { type: 'integer', description: 'Context lines before match' },
1878
1879
  contextAfter: { type: 'integer', description: 'Context lines after match' },
1879
1880
  },
@@ -4616,11 +4617,23 @@ export class WorkspaceModule implements Module {
4616
4617
 
4617
4618
  // Every match lies beneath the pattern's literal directory, so the walk
4618
4619
  // starts there: past the walk's cap, a walk of the whole scope can stop
4619
- // before it gets there.
4620
+ // before it gets there. In the mount a pattern's first segment names, the
4621
+ // pattern is read mount-prefixed (prefixedGlob), from its rest's directory
4622
+ // within `path`.
4620
4623
  const patternDir = literalDirOf(input.pattern);
4621
4624
  for (const { mount, relativePath } of mountsToSearch) {
4622
- const start = await this.walkStart(mount, relativePath, patternDir);
4623
- const matched = (path: string) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path);
4625
+ const prefixed = prefixedGlob(input.pattern, mount.config.name);
4626
+ let start: string;
4627
+ let matched: (path: string) => boolean;
4628
+ if (prefixed) {
4629
+ const dir = deeperOf(relativePath, prefixed.dir);
4630
+ if (dir === null) continue;
4631
+ start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
4632
+ matched = (path: string) => prefixed.regex.test(path);
4633
+ } else {
4634
+ start = await this.walkStart(mount, relativePath, patternDir);
4635
+ matched = (path: string) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path);
4636
+ }
4624
4637
  const listing = await this.listingOf(mount, start, true, matched);
4625
4638
  for (const { path, ...view } of listing.files) matches.push({ path: `${mount.config.name}/${path}`, ...view });
4626
4639
  for (const region of listing.incomplete) {
@@ -4651,7 +4664,8 @@ export class WorkspaceModule implements Module {
4651
4664
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
4652
4665
  // The filter matches mount-relative paths, so every file it lets through
4653
4666
  // lies beneath its literal directory, and the walk starts at the deeper of
4654
- // that and `path`.
4667
+ // that and `path`. In the mount a filter's first segment names, its
4668
+ // mount-prefixed reading takes the filter's place (below).
4655
4669
  const filterDir = input.glob ? literalDirOf(input.glob) : '';
4656
4670
  const contextBefore = input.contextBefore ?? 0;
4657
4671
  const contextAfter = input.contextAfter ?? 0;
@@ -4699,16 +4713,21 @@ export class WorkspaceModule implements Module {
4699
4713
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
4700
4714
  continue;
4701
4715
  }
4702
- // A `path` and a filter whose directories don't overlap leave no file
4703
- // the filter can let through, so nothing is searched.
4704
- const deeper = deeperOf(relativePath, filterDir);
4716
+ // In the mount a filter's first segment names, the filter is read
4717
+ // mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
4718
+ // a filter whose directories don't overlap leave no file the filter can
4719
+ // let through, so nothing is searched.
4720
+ const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
4721
+ const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
4705
4722
  if (deeper === null) continue;
4706
4723
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
4707
4724
  const searched = (text: GrepText): void => {
4708
4725
  const matches = search(text.text);
4709
4726
  if (matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
4710
4727
  };
4711
- const kept = (path: string): boolean => fileGlob === null || fileGlob.test(path);
4728
+ const kept = prefixed
4729
+ ? (path: string): boolean => prefixed.regex.test(path)
4730
+ : (path: string): boolean => fileGlob === null || fileGlob.test(path);
4712
4731
  const found = mount.direct
4713
4732
  ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
4714
4733
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
@@ -5920,6 +5939,28 @@ function inputProblem(
5920
5939
  return null;
5921
5940
  }
5922
5941
 
5942
+ /**
5943
+ * A glob's reading in the mount its first segment names, in place of the
5944
+ * pattern as written there. Every path the tools take and return is
5945
+ * mount-prefixed, so `notes/diary/*.md` is the natural way to write a
5946
+ * pattern, and tested as written against a mount-relative path it could
5947
+ * never match — a glob answered "no matches" for files that were there.
5948
+ * `regex` matches the rest of the pattern against the mount-relative path,
5949
+ * and `dir` is the rest's literal directory (literalDirOf), from the mount's
5950
+ * root. A pattern that names another mount, or none, has no such reading
5951
+ * (null), and is read as written, relative to the search root. So in a mount
5952
+ * holding a directory named like itself, `work/z/*.ts` names the mount's own
5953
+ * `z`, and `work/work/z/*.ts`, as the tools show it, names the directory's.
5954
+ * The rest's brace groups are the pattern's, so a pattern globToRegex took
5955
+ * doesn't throw here.
5956
+ */
5957
+ function prefixedGlob(pattern: string, mountName: string): { regex: RegExp; dir: string } | null {
5958
+ const slash = pattern.indexOf('/');
5959
+ if (slash <= 0 || pattern.slice(0, slash) !== mountName) return null;
5960
+ const rest = pattern.slice(slash + 1);
5961
+ return { regex: globToRegex(rest), dir: literalDirOf(rest) };
5962
+ }
5963
+
5923
5964
  /**
5924
5965
  * Convert a glob pattern to a RegExp. Throws, naming the pattern, for a brace
5925
5966
  * group it can't read: a "{" that never closes, a group inside another, or a
@@ -5954,9 +5995,14 @@ function globToRegex(pattern: string): RegExp {
5954
5995
  if (!group.includes(',')) {
5955
5996
  throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
5956
5997
  }
5957
- const alternatives = group.split(',');
5958
- regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
5959
- i = closeIdx + 1;
5998
+ // A `/` right after the group goes into each alternative, as `{a,b}/x`
5999
+ // and `{a/,b/}x` name the same paths, so an alternative's own `**`
6000
+ // meets it and reads as a `**/` does, as in the group's expansion:
6001
+ // `{src/**,lib}/*.ts` is `src/**/*.ts` or `lib/*.ts`.
6002
+ const slash = pattern[closeIdx + 1] === '/';
6003
+ const alternatives = group.split(',').map((alternative) => (slash ? alternative + '/' : alternative));
6004
+ regex += '(?:' + alternatives.map(globPartToRegex).join('|') + ')';
6005
+ i = closeIdx + (slash ? 2 : 1);
5960
6006
  continue;
5961
6007
  }
5962
6008
  // Accumulate non-brace characters, convert as a chunk
@@ -6002,11 +6048,19 @@ function braceRefusal(pattern: string, problem: string): string {
6002
6048
  return `glob pattern ${JSON.stringify(pattern)} ${problem}. To match a "{" in a name, put "?" in its place.`;
6003
6049
  }
6004
6050
 
6051
+ // What each glob token becomes. A `**/` at the start of a path segment matches
6052
+ // zero or more whole directories, so `src/**/*.ts` also matches `src/main.ts`;
6053
+ // any other `**` matches any run of characters.
6054
+ const GLOB_TOKENS: Record<string, string> = { '**/': '(?:.*/)?', '**': '.*', '*': '[^/]*', '?': '[^/]' };
6055
+
6056
+ // One part of a pattern, between brace groups or inside one. Whether a `**/`
6057
+ // at its start starts a path segment depends on what comes before it in the
6058
+ // path, which after a brace group is whichever alternative matched: so it
6059
+ // reads as GLOB_TOKENS' `**/` where the path so far is empty or ends with a
6060
+ // `/`, as in `{src/,lib/}**/`, and otherwise as any run of characters and
6061
+ // then a directory, as in `draft{s,ing}**/`.
6062
+ const PART_START_GLOBSTAR = '(?:(?<=^|/)(?:.*/)?|(?<!^|/).*/)';
6005
6063
  function globPartToRegex(part: string): string {
6006
- return part
6007
- .replace(/[.+^$()|[\]\\]/g, '\\$&')
6008
- .replace(/\*\*/g, '__DOUBLESTAR__')
6009
- .replace(/\*/g, '[^/]*')
6010
- .replace(/__DOUBLESTAR__/g, '.*')
6011
- .replace(/\?/g, '[^/]');
6064
+ return part.replace(/(?<=^|\/)\*\*\/|\*\*|[*?]|[.+^$()|[\]\\]/g, (m, offset: number) =>
6065
+ m === '**/' && offset === 0 ? PART_START_GLOBSTAR : GLOB_TOKENS[m] ?? `\\${m}`);
6012
6066
  }
@@ -259,8 +259,12 @@ export interface MountConfig {
259
259
  * `glob`, `grep`) take up to it from their scope's start every time:
260
260
  * `glob`'s is the directory its pattern's segments before the first
261
261
  * wildcard name beneath its `path`, and `grep`'s the deeper of its `path`
262
- * and its `glob` filter's directory, as far as disk lists each segment
263
- * with that spelling as a directory, not a link. Each directory's listing
262
+ * and its `glob` filter's directory. For a pattern or filter whose first
263
+ * segment names the mount, either tool starts at the deeper of its `path`
264
+ * and the directory the rest names from the mount's root, and walks
265
+ * nothing where neither holds the other. Either way, the walk descends as
266
+ * far as disk lists each segment with that spelling as a directory, not a
267
+ * link. Each directory's listing
264
268
  * is read whole, so a deletion is seen wherever the walk lists, whatever
265
269
  * it took.
266
270
  *
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A request carries only text the context store would keep. Apart from the
3
+ * agent's own configuration, such as its name and system prompt, what a
4
+ * request compiles comes from the store, which writes a lone UTF-16
5
+ * surrogate as U+FFFD, and a provider refuses a request carrying one (#695).
6
+ * Two things reach a request without the store, and each is made well formed
7
+ * where it leaves for it: context injections, as an Agent compiles them into
8
+ * a request, and the copies a tool boundary injects into the live round, as
9
+ * they go to the stream.
10
+ */
11
+
12
+ import type { ContentBlock } from '@animalabs/membrane';
13
+ import type { ContextInjection } from '@animalabs/context-manager';
14
+
15
+ /** A value with every string in it as the context store keeps it, through
16
+ * arrays and plain objects. Keys, and objects that aren't plain, pass as
17
+ * they are: content blocks are plain JSON data. Whatever is already well
18
+ * formed keeps its identity. */
19
+ function stringsAsStored<T>(value: T): T {
20
+ if (typeof value === 'string') return (value.isWellFormed() ? value : value.toWellFormed()) as T;
21
+ if (Array.isArray(value)) {
22
+ const out = value.map(stringsAsStored);
23
+ return (out.every((v, i) => v === value[i]) ? value : out) as T;
24
+ }
25
+ if (value !== null && typeof value === 'object') {
26
+ const proto = Object.getPrototypeOf(value);
27
+ if (proto !== Object.prototype && proto !== null) return value;
28
+ let changed = false;
29
+ const out: Record<string, unknown> = {};
30
+ for (const [k, v] of Object.entries(value)) {
31
+ const s = stringsAsStored(v);
32
+ if (s !== v) changed = true;
33
+ out[k] = s;
34
+ }
35
+ return (changed ? out : value) as T;
36
+ }
37
+ return value;
38
+ }
39
+
40
+ /** Content with every string in it well formed, as the context store keeps
41
+ * a text block's text or an image's link. */
42
+ export function contentAsStored(content: ContentBlock[]): ContentBlock[] {
43
+ return stringsAsStored(content);
44
+ }
45
+
46
+ /** A sender's name as the context store keeps it, and as a tool boundary
47
+ * sends it: a lone UTF-16 surrogate becomes U+FFFD. A name that isn't a
48
+ * string, from an untyped module, passes as it did: thrown here, inside the
49
+ * boundary, it would keep the round's results from the stream, where it
50
+ * would otherwise fail at the next compile. */
51
+ export function nameAsStored(participant: string): string {
52
+ return typeof participant === 'string' ? participant.toWellFormed() : participant;
53
+ }
54
+
55
+ /** Injections with their text as the context store would keep it: each one's
56
+ * content, and its namespace, which context-manager writes into a beforeUser
57
+ * or afterUser injection's participant name by its text form. A namespace
58
+ * that isn't a string, which MCPL §10.4 doesn't allow but the hook
59
+ * orchestrator keeps when it can be read as text, is taken by that form.
60
+ * They're scoped to a conversation before this, by the namespace as given,
61
+ * since channel ids are compared as given. One that's already well formed
62
+ * keeps its identity. */
63
+ export function injectionsAsStored(injections: ContextInjection[] | undefined): ContextInjection[] | undefined {
64
+ return injections?.map((inj) => {
65
+ const ns: unknown = inj.namespace;
66
+ const named = typeof ns === 'string' || ns === undefined || ns === null ? ns : String(ns);
67
+ const namespace = typeof named === 'string' && !named.isWellFormed() ? named.toWellFormed() : ns;
68
+ const content = contentAsStored(inj.content);
69
+ return namespace === ns && content.every((b, i) => b === inj.content[i])
70
+ ? inj
71
+ : { ...inj, namespace: namespace as string, content };
72
+ });
73
+ }
@@ -428,6 +428,19 @@ export const TOOL_RESULT_NOTICE_ROOM = 640;
428
428
  * share. `keptCap` is where a withheld stub's kept fields are cut, leaving
429
429
  * the stub's notice its room (tool-result-guard.ts). With no bound, each
430
430
  * keeps the cap.
431
+ *
432
+ * Each result is then stored within its share, or at its least where its
433
+ * notice is longer than the share: whole where it's no longer than the
434
+ * notice (cutStoredToolResultToFit), otherwise its notice alone. So a reader's
435
+ * cut reaches a notice only when the turn, each result at its least, passes
436
+ * the bound. One edge: notices differ in length (by a few characters for
437
+ * the content's digits, more where call ids differ in length), and where a
438
+ * share falls between two of them, a result between its own notice and the
439
+ * share stays whole above its least, by up to that difference.
440
+ * A turn that then passes the bound by those characters loses the end of
441
+ * its last notice to the reader's cut, under context-manager's mark (whose
442
+ * token count is the notice's, not the result's); the notice's path and read
443
+ * call come before that end.
431
444
  */
432
445
  export function toolResultShare(cap: number, bound: number | undefined, n: number): { cap: number; bound: number | undefined; keptCap: number } {
433
446
  if (bound === undefined) return { cap, bound, keptCap: cap };
@@ -447,8 +460,17 @@ export function toolResultShare(cap: number, bound: number | undefined, n: numbe
447
460
  * the rest left out and uncounted, as past MAX_KEPT_IMAGE_SLOTS. A bound
448
461
  * under the notice's room can't hold the notice. With no bound, or where the
449
462
  * cut at the cap already fits, the cut is cutStoredToolResult's.
463
+ *
464
+ * `notice`, given where `bound` is a text's share of one turn's bound, is the
465
+ * length of the notice a cut that keeps no head would carry
466
+ * (spillNoticeLength). A text no longer than what that cut would store in
467
+ * its place, the notice and, where it keeps no slot, the note on the slots it
468
+ * leaves out, is kept whole: a cut would only lengthen it. Only a cut that
469
+ * keeps no head can meet that, since one that keeps a head is cut past the
470
+ * notice's room. A cut that keeps slots is measured by its notice alone, as
471
+ * the slots it keeps are the text's own.
450
472
  */
451
- export function cutStoredToolResultToFit(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined): StoredCut {
473
+ export function cutStoredToolResultToFit(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined, notice?: number): StoredCut {
452
474
  let cut = cutStoredToolResult(text, cap, shown);
453
475
  if (bound === undefined || text.length <= cap) return cut;
454
476
  const room = bound - TOOL_RESULT_NOTICE_ROOM;
@@ -456,6 +478,10 @@ export function cutStoredToolResultToFit(text: string, cap: number, shown: Shown
456
478
  cut = cutStoredToolResult(text, Math.max(0, room - formatKeptImageSlots(cut).length), shown);
457
479
  }
458
480
  while (cut.kept.length > 0 && formatKeptImageSlots(cut).length > room) cut = { ...cut, kept: cut.kept.slice(1) };
481
+ if (notice !== undefined && cut.head.length === 0
482
+ && text.length <= notice + (cut.kept.length === 0 ? formatKeptImageSlots(cut).length : 0)) {
483
+ return { head: text, kept: [], fell: 0 };
484
+ }
459
485
  return cut;
460
486
  }
461
487
 
@@ -464,10 +490,11 @@ export function cutStoredToolResultToFit(text: string, cap: number, shown: Shown
464
490
  * text is longer than `cap`, those the cut keeps, in head and after the
465
491
  * notice (cutStoredToolResultToFit, under the readers' `bound`). The notice
466
492
  * itself holds none. For counting a result before its stored copy is
467
- * written, as it will count after.
493
+ * written, as it will count after, so `notice` is the one spillOrTruncate
494
+ * gives the cut (spillNoticeLength).
468
495
  */
469
- export function storedImageSlots(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined): ParsedImagePlaceholder[] {
470
- const cut = cutStoredToolResultToFit(text, cap, shown, bound);
496
+ export function storedImageSlots(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined, notice?: number): ParsedImagePlaceholder[] {
497
+ const cut = cutStoredToolResultToFit(text, cap, shown, bound, notice);
471
498
  return parseImagePlaceholders(cut.head + formatKeptImageSlots(cut));
472
499
  }
473
500
 
@@ -85,7 +85,8 @@ function quotedList(names: string[], conjunction: 'and' | 'or' = 'and', quote: (
85
85
 
86
86
  /**
87
87
  * Every problem that keeps a document from being used, each by where it is;
88
- * none for a usable one. A defaults file (`descriptionsOnly`) may set
88
+ * none for a usable one. An object with no keys never reaches this:
89
+ * parseDocument() reads it as the empty document first. A defaults file (`descriptionsOnly`) may set
89
90
  * descriptions only, since visibility is the resident's to choose.
90
91
  */
91
92
  function documentProblems(doc: unknown, descriptionsOnly: boolean): string[] {
@@ -125,6 +126,12 @@ function parseDocument(raw: string, descriptionsOnly: boolean): PresentationDocu
125
126
  let doc: unknown;
126
127
  try { doc = JSON.parse(raw); }
127
128
  catch (error) { throw new PresentationFileError([`it isn't valid JSON: ${errorMessage(error)}`]); }
129
+ // An object with no keys carries no settings, so it's the empty document,
130
+ // as a missing resident file is: it's what an operator writes by hand to
131
+ // start one. A missing defaults file is still reported, by read().
132
+ // A 0-byte file isn't JSON and still refuses, since the store's
133
+ // temp-and-rename never writes one: that's what a lost file looks like.
134
+ if (doc && typeof doc === 'object' && !Array.isArray(doc) && Object.keys(doc).length === 0) return { version: 1, tools: {} };
128
135
  const problems = documentProblems(doc, descriptionsOnly);
129
136
  if (problems.length) throw new PresentationFileError(problems);
130
137
  return doc as PresentationDocument;
@@ -9,12 +9,14 @@
9
9
  * `maxChars` slice and re-introduces, one turn deferred, exactly the silent
10
10
  * hallucination this feature exists to kill.
11
11
  *
12
- * For MCP-shaped content arrays, this helper keeps text blocks verbatim and
12
+ * For MCP-shaped content arrays, this helper keeps text blocks verbatim,
13
+ * apart from a lone UTF-16 surrogate, which becomes U+FFFD (#695), and
13
14
  * replaces image blocks with a short `[image: mimeType, ~NKB]` placeholder.
14
- * For anything else it falls back to JSON, written by RFC-005's rules at
15
- * every depth (historyJson): a reference is shown as its stub wherever it
16
- * sits in the data, so its uri never reaches history, and inline data that
17
- * claims a uri or a disposition is withheld.
15
+ * A string is already text and is kept as it is. For anything else it falls
16
+ * back to JSON, written by RFC-005's rules at every depth (historyJson): a
17
+ * reference is shown as its stub wherever it sits in the data, so its uri
18
+ * never reaches history, and inline data that claims a uri or a disposition
19
+ * is withheld.
18
20
  */
19
21
 
20
22
  import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './mcpl/references.js';
@@ -72,9 +74,19 @@ export function toolResultDataToHistoryString(
72
74
  options?: HistorySerializeOptions,
73
75
  ): string {
74
76
  const fromArray = tryHistoryStringFromContentArray(data, options);
77
+ // A string is the text the tool means to say, kept as it is: JSON would
78
+ // quote it and escape every newline, so a multi-line result would reach
79
+ // the model, a script and its spill file as one escaped line. Kept well
80
+ // formed: a lone UTF-16 surrogate, which JSON escaped, isn't text, a
81
+ // provider can refuse a request that carries one, and UTF-8 can't store
82
+ // it in a spill file, so it reads as U+FFFD.
75
83
  // JSON leaves out undefined, a function and a symbol: a result with no data
76
84
  // is stored as no text, as an empty content array is.
77
- const str = fromArray ?? historyJson(data) ?? '';
85
+ // RFC-005 shows only objects otherwise (shownInHistory), so a string has
86
+ // nothing for historyJson to stub or withhold.
87
+ const str = fromArray
88
+ ?? (typeof data === 'string' ? data.toWellFormed() : historyJson(data))
89
+ ?? '';
78
90
  return maxChars ? truncateForHistory(str, maxChars) : str;
79
91
  }
80
92
 
@@ -86,6 +98,14 @@ export function toolResultDataToHistoryString(
86
98
  *
87
99
  * Each field is read once: the direct Agent API passes its caller's own
88
100
  * result here, unchecked, and a getter needn't answer the same way twice.
101
+ *
102
+ * Each text a tool result's copies carry is made well formed where it's
103
+ * taken from the result: a lone UTF-16 surrogate becomes U+FFFD, as the
104
+ * context store makes it when it writes, so the copy sent to membrane and the
105
+ * copy stored carry the same text (#695). Other data goes through JSON,
106
+ * which escapes a lone half. toWellFormed() keeps the length,
107
+ * so the cut, the spans and the slots are computed from the well-formed text
108
+ * as before; a cut at a lone high half no longer steps back over it.
89
109
  */
90
110
  export function nonErrorResultText(
91
111
  result: { data?: unknown; error?: string },
@@ -95,7 +115,10 @@ export function nonErrorResultText(
95
115
  const data = result.data;
96
116
  if (data === undefined || data === null) {
97
117
  const error = result.error;
98
- if (typeof error === 'string' && error) return maxChars ? truncateForHistory(error, maxChars) : error;
118
+ if (typeof error === 'string' && error) {
119
+ const text = error.toWellFormed();
120
+ return maxChars ? truncateForHistory(text, maxChars) : text;
121
+ }
99
122
  }
100
123
  return toolResultDataToHistoryString(data, maxChars, options);
101
124
  }
@@ -128,7 +151,9 @@ function tryHistoryStringFromContentArray(data: unknown, options?: HistorySerial
128
151
  if (b.type === 'text') {
129
152
  // A text that isn't a string shows as the converters' placeholder,
130
153
  // rather than sending the whole array to the JSON fallback.
131
- parts.push({ kind: 'text', text: typeof b.text === 'string' ? b.text : NON_STRING_TEXT_PLACEHOLDER });
154
+ // Read once: a direct Agent API result is unchecked.
155
+ const text = b.text;
156
+ parts.push({ kind: 'text', text: typeof text === 'string' ? text.toWellFormed() : NON_STRING_TEXT_PLACEHOLDER });
132
157
  } else if (isInlineContradiction(b)) {
133
158
  // RFC-005 vector 2: checked before the data branch so inline data
134
159
  // claiming bulk disposition is withheld, not rendered.
@@ -186,7 +186,13 @@ export interface ModuleEvent {
186
186
  export interface ToolResult {
187
187
  /** Whether the tool succeeded */
188
188
  success: boolean;
189
- /** Result data (if success) */
189
+ /**
190
+ * Result data (if success). The model reads a string as that text, an MCP
191
+ * content array whose every block the framework knows as those blocks, and
192
+ * anything else as JSON, where RFC-005 shows a reference as its stub and
193
+ * withholds inline data that claims a uri or a disposition. With no data,
194
+ * it reads the result's error text, if any.
195
+ */
190
196
  data?: unknown;
191
197
  /** Error message (if failure) */
192
198
  error?: string;
@@ -270,7 +276,7 @@ export interface ToolCall {
270
276
  /**
271
277
  * How long this call's result may be and still reach its caller whole,
272
278
  * measured as the framework stores it: an error's text, or a success's data
273
- * as JSON (a content array as its text). For a model's call it is the
279
+ * as JSON (a string or a content array as its text). For a model's call it is the
274
280
  * agent's inline cap, past which the framework spills the result to a
275
281
  * workspace file and sends a preview. A result that long is stored whole
276
282
  * unless its round's results together pass the readers' per-message bound,