@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.
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +21 -8
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +18 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +87 -37
- package/dist/src/framework.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +4 -1
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +5 -2
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +74 -20
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +6 -2
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/text-as-stored.d.ts +31 -0
- package/dist/src/text-as-stored.d.ts.map +1 -0
- package/dist/src/text-as-stored.js +70 -0
- package/dist/src/text-as-stored.js.map +1 -0
- package/dist/src/tool-image-ledger.d.ts +26 -3
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +31 -4
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +15 -5
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +33 -9
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/events.d.ts +8 -2
- package/dist/src/types/events.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agent.ts +21 -8
- package/src/framework.ts +92 -36
- package/src/mcpl/references.ts +5 -2
- package/src/modules/workspace/index.ts +74 -20
- package/src/modules/workspace/types.ts +6 -2
- package/src/text-as-stored.ts +73 -0
- package/src/tool-image-ledger.ts +31 -4
- package/src/tool-presentation.ts +8 -1
- package/src/tool-result-history.ts +33 -8
- 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.'
|
|
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
|
|
4623
|
-
|
|
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
|
-
//
|
|
4703
|
-
//
|
|
4704
|
-
|
|
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 =
|
|
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
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
*
|
|
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
|
+
}
|
package/src/tool-image-ledger.ts
CHANGED
|
@@ -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
|
|
package/src/tool-presentation.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* sits in the data, so its uri
|
|
17
|
-
* claims a uri or a disposition
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
package/src/types/events.ts
CHANGED
|
@@ -186,7 +186,13 @@ export interface ModuleEvent {
|
|
|
186
186
|
export interface ToolResult {
|
|
187
187
|
/** Whether the tool succeeded */
|
|
188
188
|
success: boolean;
|
|
189
|
-
/**
|
|
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,
|