@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.
Files changed (66) hide show
  1. package/dist/src/agent.d.ts +1 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/framework.d.ts +14 -4
  5. package/dist/src/framework.d.ts.map +1 -1
  6. package/dist/src/framework.js +265 -23
  7. package/dist/src/framework.js.map +1 -1
  8. package/dist/src/gate/event-gate.d.ts +62 -7
  9. package/dist/src/gate/event-gate.d.ts.map +1 -1
  10. package/dist/src/gate/event-gate.js +333 -22
  11. package/dist/src/gate/event-gate.js.map +1 -1
  12. package/dist/src/gate/types.d.ts +56 -0
  13. package/dist/src/gate/types.d.ts.map +1 -1
  14. package/dist/src/mcpl/prose-grammar.d.ts +11 -0
  15. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  16. package/dist/src/mcpl/prose-grammar.js +21 -0
  17. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  18. package/dist/src/mcpl/prose-stream-router.d.ts +2 -2
  19. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  20. package/dist/src/mcpl/prose-stream-router.js +57 -13
  21. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  22. package/dist/src/modules/workspace/index.d.ts +23 -0
  23. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  24. package/dist/src/modules/workspace/index.js +48 -0
  25. package/dist/src/modules/workspace/index.js.map +1 -1
  26. package/dist/src/modules/workspace/sync.d.ts +11 -2
  27. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  28. package/dist/src/modules/workspace/sync.js +18 -8
  29. package/dist/src/modules/workspace/sync.js.map +1 -1
  30. package/dist/src/tool-result-history.d.ts +12 -2
  31. package/dist/src/tool-result-history.d.ts.map +1 -1
  32. package/dist/src/tool-result-history.js +12 -2
  33. package/dist/src/tool-result-history.js.map +1 -1
  34. package/dist/src/types/agent.d.ts +4 -1
  35. package/dist/src/types/agent.d.ts.map +1 -1
  36. package/dist/src/types/framework.d.ts +1 -1
  37. package/dist/test/event-gate-passthrough.test.d.ts +15 -0
  38. package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
  39. package/dist/test/event-gate-passthrough.test.js +289 -0
  40. package/dist/test/event-gate-passthrough.test.js.map +1 -0
  41. package/dist/test/explicit-prose-routing.test.js +137 -7
  42. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  43. package/dist/test/fixtures/awareness-barrier-mcpl-server.mjs +276 -0
  44. package/dist/test/fixtures/flaky-mcpl-server.mjs +41 -0
  45. package/dist/test/prose-stream-router.test.js +43 -0
  46. package/dist/test/prose-stream-router.test.js.map +1 -1
  47. package/dist/test/tool-result-spill.test.d.ts +1 -1
  48. package/dist/test/tool-result-spill.test.js +18 -9
  49. package/dist/test/tool-result-spill.test.js.map +1 -1
  50. package/dist/test/xml-round-persist.test.d.ts +19 -0
  51. package/dist/test/xml-round-persist.test.d.ts.map +1 -0
  52. package/dist/test/xml-round-persist.test.js +235 -0
  53. package/dist/test/xml-round-persist.test.js.map +1 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/package.json +2 -1
  56. package/src/agent.ts +1 -1
  57. package/src/framework.ts +270 -25
  58. package/src/gate/event-gate.ts +355 -24
  59. package/src/gate/types.ts +58 -0
  60. package/src/mcpl/prose-grammar.ts +21 -0
  61. package/src/mcpl/prose-stream-router.ts +48 -14
  62. package/src/modules/workspace/index.ts +48 -0
  63. package/src/modules/workspace/sync.ts +30 -7
  64. package/src/tool-result-history.ts +12 -2
  65. package/src/types/agent.ts +4 -1
  66. 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
- /** 'explicit' parses `>>` prefixes; 'locus' is pure passthrough. */
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 === 'maybe-prefix') this.emit(this.held, out); // "" | ">" | ">>"
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 === 'explicit') {
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 'maybe-prefix':
115
- if (this.held === '>' && ch === '>') { this.held = '>>'; return; }
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
- // `>> quoted arrow` / bare `>>`: body text, not a prefix.
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 (binary, too large, etc.) */
72
- skipped: string[];
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(relativePath);
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
- result.skipped.push(relativePath);
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(relativePath);
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 temporary per-agent lift from
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 = 5000;
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);
@@ -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. */
@@ -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 5000 (house-safe; issue #89). Must be
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