@animalabs/agent-framework 0.24.1 → 0.25.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 +3 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +29 -6
- 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-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 +30 -6
- 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-presentation.ts +8 -1
- package/src/tool-result-history.ts +33 -8
- package/src/types/events.ts +8 -2
package/src/framework.ts
CHANGED
|
@@ -157,6 +157,7 @@ import { bounded, renderList, renderReturn, VALUE_CHARS } from './code-execution
|
|
|
157
157
|
import { splitProseRuns, withoutRoundsProse } from './prose-segments.js';
|
|
158
158
|
import { cumulativeDelta } from './usage-accounting.js';
|
|
159
159
|
import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';
|
|
160
|
+
import { contentAsStored, nameAsStored } from './text-as-stored.js';
|
|
160
161
|
|
|
161
162
|
/** An MCPL tool call refused because its server isn't in the registry: one
|
|
162
163
|
* closed for good, one removed, or one whose connect failed without
|
|
@@ -929,7 +930,8 @@ function describeDeferredWrite(msg: { id: string; participant: string; content:
|
|
|
929
930
|
function isInjectableMidTurn(msg: { participant: string; content: ContentBlock[] }, agentName: string): boolean {
|
|
930
931
|
// Exact, as membrane's role test is: only a participant equal to the
|
|
931
932
|
// request's assistantParticipant (Agent's name) renders as the assistant.
|
|
932
|
-
|
|
933
|
+
// It's the name as sent, which the boundary takes as the store keeps it.
|
|
934
|
+
return nameAsStored(msg.participant) !== agentName
|
|
933
935
|
&& !msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
|
|
934
936
|
}
|
|
935
937
|
|
|
@@ -1252,6 +1254,12 @@ function isTurnContinuation(reason: string): boolean {
|
|
|
1252
1254
|
return reason === 'context_budget_restart' || reason === 'tool_results_ready'
|
|
1253
1255
|
|| reason === 'tool_result_guard_retry';
|
|
1254
1256
|
}
|
|
1257
|
+
|
|
1258
|
+
/** Wake sources the framework generates for itself (bounce wakes,
|
|
1259
|
+
* continuations, retries, unsticks, admission requeues). A wake from any
|
|
1260
|
+
* other source — a channel message, a heartbeat or other server event, a
|
|
1261
|
+
* gate flush, a timer, an operator nudge — is a cause of its own. */
|
|
1262
|
+
const FRAMEWORK_WAKE_SOURCES = new Set(['framework', 'scheduler']);
|
|
1255
1263
|
const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
|
|
1256
1264
|
const INFERENCE_LOG_ID = 'framework/inference-log';
|
|
1257
1265
|
const PROCESS_LOG_ID = 'framework/process-log';
|
|
@@ -12145,11 +12153,14 @@ export class AgentFramework {
|
|
|
12145
12153
|
// 0.5.x, and from 0.6.0 declares it can't carry them, so none are
|
|
12146
12154
|
// collected for it. Each goes as its participant and content
|
|
12147
12155
|
// alone: a stored row's metadata is the host's record, not input
|
|
12148
|
-
// for membrane.
|
|
12156
|
+
// for membrane. Each goes with its name and text as the store
|
|
12157
|
+
// keeps them, whether it's a heard message, rendered or not, or a
|
|
12158
|
+
// notice the boundary made: this is where they leave for the
|
|
12159
|
+
// request.
|
|
12149
12160
|
currentState.stream.provideToolResults(
|
|
12150
12161
|
agent.toolResultGuard.submissionResults(membraneResults),
|
|
12151
12162
|
midTurnInjections.length > 0
|
|
12152
|
-
? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant, content })) }
|
|
12163
|
+
? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant: nameAsStored(participant), content: contentAsStored(content) })) }
|
|
12153
12164
|
: undefined,
|
|
12154
12165
|
);
|
|
12155
12166
|
// RFC-006 §3.3: consumption is determined at assembly, and this
|
|
@@ -14458,6 +14469,16 @@ export class AgentFramework {
|
|
|
14458
14469
|
// requests[0] in that mixed batch bypassed the turn lock because a
|
|
14459
14470
|
// restart existed, then treated the continuation as a fresh turn.
|
|
14460
14471
|
const trigger = budgetRestart ?? requests[0];
|
|
14472
|
+
// The bounce-wake cap (bounceProse) stops a bounce → wake → bounce loop
|
|
14473
|
+
// that nothing else feeds. A turn with a cause of its own is not that
|
|
14474
|
+
// loop, so the count starts again. Otherwise only a delivered prose
|
|
14475
|
+
// envelope reset it, and a resident that recovers from bounces with a
|
|
14476
|
+
// send tool kept the count for good: every later bounce, a reply to a
|
|
14477
|
+
// fresh message included, appended its notice without a wake.
|
|
14478
|
+
// (Optional chaining: prototype-built harnesses leave the map unset.)
|
|
14479
|
+
if (requests.some((r) => !FRAMEWORK_WAKE_SOURCES.has(r.source))) {
|
|
14480
|
+
this.proseBounceStreaks?.delete(agentName);
|
|
14481
|
+
}
|
|
14461
14482
|
// Route this turn's auto-published speech to the channel that triggered
|
|
14462
14483
|
// it (item-3 redux). A batched wake may carry several triggering channels
|
|
14463
14484
|
// (messages arrived in >1 channel while the agent was busy/idle):
|
|
@@ -16634,7 +16655,9 @@ export class AgentFramework {
|
|
|
16634
16655
|
}
|
|
16635
16656
|
}
|
|
16636
16657
|
|
|
16637
|
-
/** Cap on consecutive bounce-triggered wakes (notices still append after).
|
|
16658
|
+
/** Cap on consecutive bounce-triggered wakes (notices still append after).
|
|
16659
|
+
* The count resets on a delivered prose envelope, and when a turn starts
|
|
16660
|
+
* with a cause of its own (processInferenceRequests). */
|
|
16638
16661
|
private static readonly PROSE_BOUNCE_WAKE_CAP = 2;
|
|
16639
16662
|
|
|
16640
16663
|
/**
|
|
@@ -20631,7 +20654,7 @@ export class AgentFramework {
|
|
|
20631
20654
|
tc: CompletedToolCall,
|
|
20632
20655
|
maxChars: number | undefined,
|
|
20633
20656
|
): { text: string; shown: ShownRanges | null } {
|
|
20634
|
-
if (tc.result.isError) return { text: tc.result.error ?? 'Unknown error', shown: null };
|
|
20657
|
+
if (tc.result.isError) return { text: (tc.result.error ?? 'Unknown error').toWellFormed(), shown: null };
|
|
20635
20658
|
const ledger = this.toolImageLedgerFor(agentName);
|
|
20636
20659
|
let blocks = null as HistoryBlockSpan[] | null;
|
|
20637
20660
|
const text = nonErrorResultText(tc.result, undefined, {
|
|
@@ -21185,7 +21208,8 @@ export class AgentFramework {
|
|
|
21185
21208
|
if (b.type === 'text') {
|
|
21186
21209
|
// A text that isn't a string shows as the converters' placeholder,
|
|
21187
21210
|
// rather than sending the whole array to the JSON fallback.
|
|
21188
|
-
|
|
21211
|
+
// Well formed, as the stored copy's text is (nonErrorResultText).
|
|
21212
|
+
let text = typeof b.text === 'string' ? b.text.toWellFormed() : NON_STRING_TEXT_PLACEHOLDER;
|
|
21189
21213
|
if (maxChars && text.length > maxChars) {
|
|
21190
21214
|
text = text.slice(0, toolResultCutAt(text, maxChars))
|
|
21191
21215
|
+ '\n\n[truncated — original was ' + text.length + ' chars'
|
package/src/mcpl/references.ts
CHANGED
|
@@ -434,7 +434,10 @@ export function unfetchableReason(record: ReferenceRecord, nowMs = Date.now()):
|
|
|
434
434
|
/**
|
|
435
435
|
* RFC-005 treatment for one MCPL wire block, for the model-visible text
|
|
436
436
|
* lanes. Returns a replacement string, or null when the block should keep
|
|
437
|
-
* its existing (inline/text) handling.
|
|
437
|
+
* its existing (inline/text) handling. A reference's stub carries text taken
|
|
438
|
+
* from the block's own fields, so it's made well formed (a lone UTF-16
|
|
439
|
+
* surrogate becomes U+FFFD), as the context store would make it: a tool
|
|
440
|
+
* result's copies then carry the same text (#695).
|
|
438
441
|
*/
|
|
439
442
|
export function referenceStubOrNull(block: unknown, provenance?: string, serverId?: string): string | null {
|
|
440
443
|
const c = classifyBlock(block);
|
|
@@ -442,7 +445,7 @@ export function referenceStubOrNull(block: unknown, provenance?: string, serverI
|
|
|
442
445
|
case 'text': return null;
|
|
443
446
|
case 'inline':
|
|
444
447
|
return c.contradiction ? INLINE_WITHHELD_TEXT : null;
|
|
445
|
-
case 'reference': return buildReferenceStub(c.testimony, provenance, serverId);
|
|
448
|
+
case 'reference': return buildReferenceStub(c.testimony, provenance, serverId).toWellFormed();
|
|
446
449
|
case 'invalid': return c.shown;
|
|
447
450
|
}
|
|
448
451
|
}
|
|
@@ -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-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,
|