@animalabs/agent-framework 0.9.0 → 0.10.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 +1 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +14 -4
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +265 -23
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +62 -7
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +333 -22
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +56 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +11 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +21 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +2 -2
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +57 -13
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +23 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +48 -0
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +11 -2
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +18 -8
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +12 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +12 -2
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +4 -1
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +1 -1
- package/dist/test/event-gate-passthrough.test.d.ts +15 -0
- package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
- package/dist/test/event-gate-passthrough.test.js +289 -0
- package/dist/test/event-gate-passthrough.test.js.map +1 -0
- package/dist/test/explicit-prose-routing.test.js +137 -7
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/fixtures/awareness-barrier-mcpl-server.mjs +276 -0
- package/dist/test/fixtures/flaky-mcpl-server.mjs +41 -0
- package/dist/test/prose-stream-router.test.js +43 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.d.ts +1 -1
- package/dist/test/tool-result-spill.test.js +18 -9
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/xml-round-persist.test.d.ts +19 -0
- package/dist/test/xml-round-persist.test.d.ts.map +1 -0
- package/dist/test/xml-round-persist.test.js +235 -0
- package/dist/test/xml-round-persist.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/src/agent.ts +1 -1
- package/src/framework.ts +270 -25
- package/src/gate/event-gate.ts +355 -24
- package/src/gate/types.ts +58 -0
- package/src/mcpl/prose-grammar.ts +21 -0
- package/src/mcpl/prose-stream-router.ts +48 -14
- package/src/modules/workspace/index.ts +48 -0
- package/src/modules/workspace/sync.ts +30 -7
- package/src/tool-result-history.ts +12 -2
- package/src/types/agent.ts +4 -1
- package/src/types/framework.ts +1 -1
|
@@ -30,7 +30,7 @@ export interface RoutedDelta {
|
|
|
30
30
|
delta: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
type State = 'line-start' | 'maybe-prefix' | 'prefix-token' | 'after-token' | 'body';
|
|
33
|
+
type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body';
|
|
34
34
|
|
|
35
35
|
export class ProseStreamRouter {
|
|
36
36
|
private target: string | null;
|
|
@@ -42,8 +42,8 @@ export class ProseStreamRouter {
|
|
|
42
42
|
|
|
43
43
|
constructor(
|
|
44
44
|
private opts: {
|
|
45
|
-
/**
|
|
46
|
-
mode: 'explicit' | 'locus';
|
|
45
|
+
/** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */
|
|
46
|
+
mode: 'explicit' | 'hybrid' | 'locus';
|
|
47
47
|
/** Initial destination: the frozen turn locus (locus mode) or null. */
|
|
48
48
|
initialTarget: string | null;
|
|
49
49
|
/** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */
|
|
@@ -87,7 +87,12 @@ export class ProseStreamRouter {
|
|
|
87
87
|
* no body — dropped, matching delivery which would deliver empty body). */
|
|
88
88
|
finish(): RoutedDelta[] {
|
|
89
89
|
const out: RoutedDelta[] = [];
|
|
90
|
-
if (this.state === '
|
|
90
|
+
if (this.state === 'hybrid-indent') this.emit(this.held, out);
|
|
91
|
+
if (this.state === 'maybe-prefix') {
|
|
92
|
+
const full = this.opts.mode === 'hybrid' ? '>>>' : '>>';
|
|
93
|
+
const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
|
|
94
|
+
if (arrows !== full) this.emit(this.held, out);
|
|
95
|
+
}
|
|
91
96
|
// prefix-token / after-token at EOF: prefix without body — nothing to emit.
|
|
92
97
|
this.held = '';
|
|
93
98
|
this.state = 'line-start';
|
|
@@ -97,7 +102,7 @@ export class ProseStreamRouter {
|
|
|
97
102
|
private step(ch: string, out: RoutedDelta[]): void {
|
|
98
103
|
switch (this.state) {
|
|
99
104
|
case 'body':
|
|
100
|
-
if (ch === '\n' && this.opts.mode
|
|
105
|
+
if (ch === '\n' && this.opts.mode !== 'locus') {
|
|
101
106
|
this.emit('\n', out);
|
|
102
107
|
this.state = 'line-start';
|
|
103
108
|
} else {
|
|
@@ -106,34 +111,63 @@ export class ProseStreamRouter {
|
|
|
106
111
|
return;
|
|
107
112
|
|
|
108
113
|
case 'line-start':
|
|
114
|
+
if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
|
|
115
|
+
this.state = 'hybrid-indent';
|
|
116
|
+
this.held = ch;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
109
119
|
if (ch === '>') { this.state = 'maybe-prefix'; this.held = '>'; return; }
|
|
110
120
|
this.state = 'body';
|
|
111
121
|
this.step(ch, out);
|
|
112
122
|
return;
|
|
113
123
|
|
|
114
|
-
case '
|
|
115
|
-
if (
|
|
116
|
-
if (this.held
|
|
124
|
+
case 'hybrid-indent':
|
|
125
|
+
if (ch === ' ' || ch === '\t') { this.held += ch; return; }
|
|
126
|
+
if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; }
|
|
127
|
+
this.emit(this.held, out);
|
|
128
|
+
this.held = '';
|
|
129
|
+
this.state = 'body';
|
|
130
|
+
this.step(ch, out);
|
|
131
|
+
return;
|
|
132
|
+
|
|
133
|
+
case 'maybe-prefix': {
|
|
134
|
+
const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>';
|
|
135
|
+
const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
|
|
136
|
+
if (ch === '>' && arrows.length < prefix.length) {
|
|
137
|
+
this.held += '>';
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (arrows === prefix) {
|
|
141
|
+
if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
|
|
142
|
+
this.held = '';
|
|
143
|
+
this.state = 'prefix-gap';
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
117
146
|
if (ch === '\n' || ch === ' ' || ch === '\t') {
|
|
118
|
-
|
|
119
|
-
this.emit(this.held, out);
|
|
147
|
+
if (this.opts.mode === 'explicit') this.emit(this.held, out);
|
|
120
148
|
this.held = '';
|
|
121
|
-
this.state = 'body';
|
|
122
|
-
this.step(ch, out);
|
|
149
|
+
this.state = ch === '\n' ? 'line-start' : 'body';
|
|
150
|
+
if (ch !== '\n') this.step(ch, out);
|
|
123
151
|
return;
|
|
124
152
|
}
|
|
125
|
-
// real prefix begins
|
|
126
153
|
this.state = 'prefix-token';
|
|
127
154
|
this.token = ch;
|
|
128
155
|
this.held = '';
|
|
129
156
|
return;
|
|
130
157
|
}
|
|
131
|
-
// held === '>' and ch !== '>': plain body starting with '>'
|
|
132
158
|
this.emit(this.held, out);
|
|
133
159
|
this.held = '';
|
|
134
160
|
this.state = 'body';
|
|
135
161
|
this.step(ch, out);
|
|
136
162
|
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case 'prefix-gap':
|
|
166
|
+
if (ch === ' ' || ch === '\t') return;
|
|
167
|
+
if (ch === '\n') { this.target = null; this.state = 'line-start'; return; }
|
|
168
|
+
this.state = 'prefix-token';
|
|
169
|
+
this.token = ch;
|
|
170
|
+
return;
|
|
137
171
|
|
|
138
172
|
case 'prefix-token':
|
|
139
173
|
if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; }
|
|
@@ -1080,6 +1080,47 @@ export class WorkspaceModule implements Module {
|
|
|
1080
1080
|
return { data: blob };
|
|
1081
1081
|
}
|
|
1082
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* Read a file's raw bytes straight from the mount's filesystem, bypassing
|
|
1085
|
+
* the Chronicle tree.
|
|
1086
|
+
*
|
|
1087
|
+
* For material that is *passing through* rather than being kept: an upload,
|
|
1088
|
+
* a render being shipped somewhere, a file handed to a service. The store
|
|
1089
|
+
* path (`readBinary`) is append-only, so reading through it means every
|
|
1090
|
+
* byte is retained forever and the mount's `maxFileSize` guard applies —
|
|
1091
|
+
* correct for the resident's memory, wrong for egress, where paying a
|
|
1092
|
+
* permanent cost to send something once is the wrong trade.
|
|
1093
|
+
*
|
|
1094
|
+
* Same mount boundary and traversal guard as every other path here: only
|
|
1095
|
+
* declared mounts, nothing above their root. Deliberately does NOT sync,
|
|
1096
|
+
* register, or hash anything — it reads and returns.
|
|
1097
|
+
*/
|
|
1098
|
+
async readBinaryFromDisk(
|
|
1099
|
+
mountPrefixedPath: string,
|
|
1100
|
+
opts: { maxBytes?: number } = {},
|
|
1101
|
+
): Promise<{ data: Buffer; absolutePath: string } | { error: string }> {
|
|
1102
|
+
let mount: MountState;
|
|
1103
|
+
let relativePath: string;
|
|
1104
|
+
try {
|
|
1105
|
+
({ mount, relativePath } = this.parsePath(mountPrefixedPath));
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1108
|
+
}
|
|
1109
|
+
const absolutePath = resolve(mount.config.path, relativePath);
|
|
1110
|
+
try {
|
|
1111
|
+
const info = await stat(absolutePath);
|
|
1112
|
+
if (!info.isFile()) return { error: `Not a file: ${mountPrefixedPath}` };
|
|
1113
|
+
if (opts.maxBytes !== undefined && info.size > opts.maxBytes) {
|
|
1114
|
+
return { error: `File too large: ${info.size} > ${opts.maxBytes} bytes` };
|
|
1115
|
+
}
|
|
1116
|
+
return { data: await readFile(absolutePath), absolutePath };
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
const code = (error as { code?: string }).code;
|
|
1119
|
+
if (code === 'ENOENT') return { error: `File not found on disk: ${mountPrefixedPath}` };
|
|
1120
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1083
1124
|
/**
|
|
1084
1125
|
* Parse a mount-prefixed path into (mountName, relativePath).
|
|
1085
1126
|
*/
|
|
@@ -1919,6 +1960,7 @@ export class WorkspaceModule implements Module {
|
|
|
1919
1960
|
private async handleSync(input: SyncInput): Promise<ToolResult> {
|
|
1920
1961
|
const store = this.getStore();
|
|
1921
1962
|
const allResults: Array<{ mount: string; synced: string[]; conflicts: ConflictInfo[] }> = [];
|
|
1963
|
+
const allSkipped: Array<{ mount: string; path: string; reason: string }> = [];
|
|
1922
1964
|
|
|
1923
1965
|
let mountsToSync: Array<{ name: string; mount: MountState }>;
|
|
1924
1966
|
if (input.mount) {
|
|
@@ -1951,6 +1993,11 @@ export class WorkspaceModule implements Module {
|
|
|
1951
1993
|
});
|
|
1952
1994
|
this.emitFsEvents(name, result.synced, result.conflicts);
|
|
1953
1995
|
}
|
|
1996
|
+
// Skips were previously computed and dropped, so "nothing synced" and
|
|
1997
|
+
// "your file was refused" looked identical from the outside. Say which.
|
|
1998
|
+
for (const s of result.skipped) {
|
|
1999
|
+
allSkipped.push({ mount: name, path: s.path, reason: s.reason });
|
|
2000
|
+
}
|
|
1954
2001
|
}
|
|
1955
2002
|
|
|
1956
2003
|
return {
|
|
@@ -1959,6 +2006,7 @@ export class WorkspaceModule implements Module {
|
|
|
1959
2006
|
results: allResults,
|
|
1960
2007
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
1961
2008
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
2009
|
+
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
1962
2010
|
},
|
|
1963
2011
|
};
|
|
1964
2012
|
}
|
|
@@ -40,6 +40,13 @@ export function hashContent(content: string | Buffer): string {
|
|
|
40
40
|
/**
|
|
41
41
|
* Check if content appears to be binary.
|
|
42
42
|
*/
|
|
43
|
+
export interface SkippedFile {
|
|
44
|
+
/** Relative path of the file that was not synced */
|
|
45
|
+
path: string;
|
|
46
|
+
/** Why, in words the resident can act on */
|
|
47
|
+
reason: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
export function isBinary(buffer: Buffer): boolean {
|
|
44
51
|
// Check for null bytes in first 8KB
|
|
45
52
|
const check = buffer.subarray(0, 8192);
|
|
@@ -68,8 +75,11 @@ export interface SyncResult {
|
|
|
68
75
|
synced: SyncedPath[];
|
|
69
76
|
/** Paths that conflicted (both agent and user modified) — filesystem wins */
|
|
70
77
|
conflicts: ConflictInfo[];
|
|
71
|
-
/** Paths that were skipped
|
|
72
|
-
|
|
78
|
+
/** Paths that were skipped, each with the reason — a silently skipped file
|
|
79
|
+
* is indistinguishable from one that synced cleanly, which is how a
|
|
80
|
+
* resident ends up believing a file is in their workspace when it never
|
|
81
|
+
* arrived. */
|
|
82
|
+
skipped: SkippedFile[];
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
/**
|
|
@@ -93,7 +103,7 @@ export async function syncFromFs(
|
|
|
93
103
|
for (const relativePath of filesToSync) {
|
|
94
104
|
const absolutePath = safePath(mount.config.path, relativePath);
|
|
95
105
|
if (!absolutePath) {
|
|
96
|
-
result.skipped.push(relativePath);
|
|
106
|
+
result.skipped.push({ path: relativePath, reason: 'path resolves outside the mount' });
|
|
97
107
|
continue;
|
|
98
108
|
}
|
|
99
109
|
|
|
@@ -113,13 +123,23 @@ export async function syncFromFs(
|
|
|
113
123
|
const fileStat = await stat(absolutePath);
|
|
114
124
|
if (!fileStat.isFile()) continue;
|
|
115
125
|
if (fileStat.size > maxSize) {
|
|
116
|
-
result.skipped.push(
|
|
126
|
+
result.skipped.push({
|
|
127
|
+
path: relativePath,
|
|
128
|
+
reason: `too large: ${fileStat.size} bytes > maxFileSize ${maxSize}`,
|
|
129
|
+
});
|
|
117
130
|
continue;
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
const buffer = await readFile(absolutePath);
|
|
121
134
|
if (isBinary(buffer)) {
|
|
122
|
-
|
|
135
|
+
// Disk -> store sync is text-only, at ANY size: the tree stores text
|
|
136
|
+
// blobs. Binary that must live in the store has to be written through
|
|
137
|
+
// writeBinary; binary that is only passing through should be read with
|
|
138
|
+
// readBinaryFromDisk instead of synced at all.
|
|
139
|
+
result.skipped.push({
|
|
140
|
+
path: relativePath,
|
|
141
|
+
reason: 'binary file — disk sync stores text only (read it from disk, or write it via a binary-aware tool)',
|
|
142
|
+
});
|
|
123
143
|
continue;
|
|
124
144
|
}
|
|
125
145
|
|
|
@@ -159,8 +179,11 @@ export async function syncFromFs(
|
|
|
159
179
|
|
|
160
180
|
store.treeSet(mount.treeStateId, relativePath, entry);
|
|
161
181
|
result.synced.push({ path: relativePath, op: existing ? 'modified' : 'created' });
|
|
162
|
-
} catch {
|
|
163
|
-
result.skipped.push(
|
|
182
|
+
} catch (error) {
|
|
183
|
+
result.skipped.push({
|
|
184
|
+
path: relativePath,
|
|
185
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
186
|
+
});
|
|
164
187
|
}
|
|
165
188
|
}
|
|
166
189
|
|
|
@@ -20,10 +20,20 @@ import { safeSlice } from './safe-slice.js';
|
|
|
20
20
|
* House-safe default inline cap (chars) for tool results, error results, and
|
|
21
21
|
* background-script wake payloads (issue #89: a resident should never eat a
|
|
22
22
|
* 42k blob they didn't ask for). Durable per-residence value comes from
|
|
23
|
-
* `FrameworkConfig.toolResultInlineMaxChars`; a
|
|
23
|
+
* `FrameworkConfig.toolResultInlineMaxChars`; a per-agent value from
|
|
24
24
|
* agent_settings `tool_result_inline_max_chars`.
|
|
25
|
+
*
|
|
26
|
+
* Raised 5000 → 24000 (2026-08-07) on production evidence rather than taste.
|
|
27
|
+
* The first residence to actually run the 5000 default spilled 48 results in
|
|
28
|
+
* 23h, 71% of them ordinary shell output from source review — `git diff`, test
|
|
29
|
+
* runs, `sed -n '450,590p'`-style excerpts, which routinely land at 5–15k. A
|
|
30
|
+
* 5000-char cap is tuned for conversation and turns every file read into a
|
|
31
|
+
* spill round-trip for a resident doing code work. Measured on that corpus:
|
|
32
|
+
* 20000 keeps 76% inline, 24000 keeps 88%, 32000 only reaches 90% — 24000 is
|
|
33
|
+
* the elbow. Genuinely large payloads (full histories, channel registries,
|
|
34
|
+
* broad greps) still spill, which is the point.
|
|
25
35
|
*/
|
|
26
|
-
export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS =
|
|
36
|
+
export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS = 24000;
|
|
27
37
|
|
|
28
38
|
export function toolResultDataToHistoryString(data: unknown, maxChars?: number): string {
|
|
29
39
|
const fromArray = tryHistoryStringFromContentArray(data);
|
package/src/types/agent.ts
CHANGED
|
@@ -177,8 +177,11 @@ export interface AgentConfig {
|
|
|
177
177
|
* (`>>#channel` / `>>@person` / `>>skip_reply`); unprefixed prose is never
|
|
178
178
|
* delivered — it bounces to a clipboard for a cheap prefixed resend.
|
|
179
179
|
* See docs/explicit-prose-routing.md.
|
|
180
|
+
* - 'hybrid': unprefixed prose keeps locus routing; `>>>destination` routes
|
|
181
|
+
* that publication envelope through the same authorized resolver.
|
|
182
|
+
* See docs/hybrid-prose-routing.md.
|
|
180
183
|
*/
|
|
181
|
-
proseRouting?: 'locus' | 'explicit';
|
|
184
|
+
proseRouting?: 'locus' | 'explicit' | 'hybrid';
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
/** The intentionally small set of agent settings that may change in-process. */
|
package/src/types/framework.ts
CHANGED
|
@@ -145,7 +145,7 @@ export interface FrameworkConfig {
|
|
|
145
145
|
* name, overwritten on collision, retained until the workspace owner
|
|
146
146
|
* deletes it — never auto-GC'd) and replaced inline by a bounded preview
|
|
147
147
|
* plus the file reference; with no writable workspace the fallback is
|
|
148
|
-
* explicit plain truncation. Default
|
|
148
|
+
* explicit plain truncation. Default 24000 (house-safe; issue #89). Must be
|
|
149
149
|
* >= 1000. A resident's own agent_settings value
|
|
150
150
|
* `tool_result_inline_max_chars` (durable, persisted in framework state)
|
|
151
151
|
* takes precedence over this for that agent; the EFFECTIVE cap for every
|