@animalabs/agent-framework 0.6.8 → 0.7.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 (49) hide show
  1. package/dist/src/agent.d.ts +6 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +11 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +129 -15
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +967 -113
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/mcpl/channel-registry.d.ts +87 -7
  10. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  11. package/dist/src/mcpl/channel-registry.js +329 -49
  12. package/dist/src/mcpl/channel-registry.js.map +1 -1
  13. package/dist/src/modules/workspace/index.d.ts +10 -0
  14. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  15. package/dist/src/modules/workspace/index.js +61 -6
  16. package/dist/src/modules/workspace/index.js.map +1 -1
  17. package/dist/src/modules/workspace/types.d.ts +2 -0
  18. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  19. package/dist/src/modules/workspace/types.js.map +1 -1
  20. package/dist/src/modules/workspace/watcher.d.ts +17 -0
  21. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  22. package/dist/src/modules/workspace/watcher.js +143 -7
  23. package/dist/src/modules/workspace/watcher.js.map +1 -1
  24. package/dist/src/types/agent.d.ts +32 -0
  25. package/dist/src/types/agent.d.ts.map +1 -1
  26. package/dist/src/types/trace.d.ts +32 -0
  27. package/dist/src/types/trace.d.ts.map +1 -1
  28. package/dist/test/channel-registry-routing.test.js +186 -10
  29. package/dist/test/channel-registry-routing.test.js.map +1 -1
  30. package/dist/test/explicit-prose-routing.test.d.ts +11 -0
  31. package/dist/test/explicit-prose-routing.test.d.ts.map +1 -0
  32. package/dist/test/explicit-prose-routing.test.js +247 -0
  33. package/dist/test/explicit-prose-routing.test.js.map +1 -0
  34. package/dist/test/present-while-acting.test.js +85 -8
  35. package/dist/test/present-while-acting.test.js.map +1 -1
  36. package/dist/test/trunk-channel-routing.test.js +7 -3
  37. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  38. package/dist/test/workspace-watcher.test.js +161 -9
  39. package/dist/test/workspace-watcher.test.js.map +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +3 -3
  42. package/src/agent.ts +11 -1
  43. package/src/framework.ts +1037 -116
  44. package/src/mcpl/channel-registry.ts +401 -54
  45. package/src/modules/workspace/index.ts +60 -6
  46. package/src/modules/workspace/types.ts +2 -0
  47. package/src/modules/workspace/watcher.ts +160 -7
  48. package/src/types/agent.ts +35 -0
  49. package/src/types/trace.ts +47 -1
@@ -651,6 +651,15 @@ export class WorkspaceModule implements Module {
651
651
  error: err.message,
652
652
  } as ProcessEvent);
653
653
  },
654
+ onReattach: () => {
655
+ mount.watcherError = null;
656
+ void this.initialScan(name);
657
+ this.ctx?.pushEvent({
658
+ type: 'workspace:watcher-reattached',
659
+ mount: name,
660
+ path: mount.config.path,
661
+ } as ProcessEvent);
662
+ },
654
663
  },
655
664
  );
656
665
  watcher.start();
@@ -724,13 +733,13 @@ export class WorkspaceModule implements Module {
724
733
  return [
725
734
  {
726
735
  name: 'read',
727
- description: 'Read a file from the workspace. Returns content with line numbers.',
736
+ description: 'Read a file from the workspace. Returns content with line numbers. Reads at most 2000 lines per call by default — use offset/limit to page through larger files.',
728
737
  inputSchema: {
729
738
  type: 'object' as const,
730
739
  properties: {
731
740
  path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
732
741
  offset: { type: 'number', description: 'Starting line number (1-indexed)' },
733
- limit: { type: 'number', description: 'Maximum number of lines to return' },
742
+ limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
734
743
  },
735
744
  required: ['path'],
736
745
  },
@@ -1018,6 +1027,30 @@ export class WorkspaceModule implements Module {
1018
1027
  };
1019
1028
  }
1020
1029
 
1030
+ /**
1031
+ * Read a file's raw bytes from a mount (through the Chronicle tree, synced
1032
+ * first like the `read` tool). Public API for the framework's synthesized
1033
+ * `read_image` tool and other peer callers that need binary content.
1034
+ */
1035
+ async readBinary(
1036
+ mountPrefixedPath: string,
1037
+ ): Promise<{ data: Buffer } | { error: string }> {
1038
+ let mount: MountState;
1039
+ let relativePath: string;
1040
+ try {
1041
+ ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1042
+ } catch (error) {
1043
+ return { error: error instanceof Error ? error.message : String(error) };
1044
+ }
1045
+ const store = this.getStore();
1046
+ await this.ensureSynced(mount, relativePath);
1047
+ const entry = store.treeGet(mount.treeStateId, relativePath);
1048
+ if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
1049
+ const blob = store.getBlob(entry.blobHash);
1050
+ if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
1051
+ return { data: blob };
1052
+ }
1053
+
1021
1054
  /**
1022
1055
  * Parse a mount-prefixed path into (mountName, relativePath).
1023
1056
  */
@@ -1311,10 +1344,16 @@ export class WorkspaceModule implements Module {
1311
1344
  const content = blob.toString('utf-8');
1312
1345
  const lines = content.split('\n');
1313
1346
 
1314
- // Apply offset/limit
1347
+ // Apply offset/limit. An unlimited read of a large file would inject the
1348
+ // whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
1349
+ // past the model context and 400s), so an omitted limit falls back to a
1350
+ // default cap; the result reports total/from/to so the agent can page.
1351
+ const DEFAULT_READ_LINE_LIMIT = 2000;
1315
1352
  const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
1316
- const endLine = input.limit ? startLine + input.limit : lines.length;
1353
+ const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
1354
+ const endLine = startLine + effectiveLimit;
1317
1355
  const slice = lines.slice(startLine, endLine);
1356
+ const truncated = endLine < lines.length;
1318
1357
 
1319
1358
  // Format with line numbers (cat -n style)
1320
1359
  const formatted = slice
@@ -1328,6 +1367,9 @@ export class WorkspaceModule implements Module {
1328
1367
  totalLines: lines.length,
1329
1368
  fromLine: startLine + 1,
1330
1369
  toLine: Math.min(endLine, lines.length),
1370
+ ...(truncated
1371
+ ? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
1372
+ : {}),
1331
1373
  content: formatted,
1332
1374
  },
1333
1375
  };
@@ -1662,8 +1704,20 @@ export class WorkspaceModule implements Module {
1662
1704
  mount.initialSyncDone = true;
1663
1705
  }
1664
1706
 
1665
- const prefix = relativePath ? relativePath + '/' : undefined;
1666
- const entries = store.treeList(mount.treeStateId, prefix);
1707
+ // If `path` points at a single FILE, grep just that file. Otherwise treat
1708
+ // `path` as a directory prefix (the original behaviour). Previously a file
1709
+ // path produced an empty prefix like "notes.md/" and matched nothing, so
1710
+ // grepping a specific file silently returned zero results.
1711
+ let entries: Array<{ path: string; blobHash: string }>;
1712
+ const fileNode = relativePath
1713
+ ? (store.treeGet(mount.treeStateId, relativePath) as { blobHash?: string } | null)
1714
+ : null;
1715
+ if (fileNode && fileNode.blobHash) {
1716
+ entries = [{ path: relativePath, blobHash: fileNode.blobHash }];
1717
+ } else {
1718
+ const prefix = relativePath ? relativePath + '/' : undefined;
1719
+ entries = store.treeList(mount.treeStateId, prefix);
1720
+ }
1667
1721
 
1668
1722
  for (const entry of entries) {
1669
1723
  if (fileGlob && !fileGlob.test(entry.path)) continue;
@@ -25,6 +25,8 @@ export interface MountConfig {
25
25
  watch?: 'always' | 'on-agent-action' | 'never';
26
26
  /** Debounce window in ms for watch: 'always' mode (default: 300) */
27
27
  watchDebounceMs?: number;
28
+ /** Poll interval in ms for checking watched root identity changes (default: 2000) */
29
+ watchRootPollMs?: number;
28
30
  /**
29
31
  * Simple ignore patterns (NOT full gitignore syntax). Supported forms:
30
32
  * - Exact name match: ".git", "node_modules"
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { watch, type FSWatcher } from 'chokidar';
9
- import { mkdirSync } from 'node:fs';
9
+ import { mkdirSync, statSync } from 'node:fs';
10
+ import { basename } from 'node:path';
10
11
  import { type MountConfig } from './types.js';
11
12
 
12
13
  export type FsOp = 'created' | 'modified' | 'deleted';
@@ -30,6 +31,14 @@ export interface WatcherLifecycle {
30
31
  onReady?: () => void;
31
32
  /** Fires on any chokidar-reported error (ENOENT, EACCES, platform faults). */
32
33
  onError?: (err: Error) => void;
34
+ /** Fires after the watcher re-attaches to a replaced root inode. */
35
+ onReattach?: () => void;
36
+ }
37
+
38
+ interface RootIdentity {
39
+ dev: number;
40
+ ino: number;
41
+ birthtimeMs: number;
33
42
  }
34
43
 
35
44
  /**
@@ -42,8 +51,13 @@ export class MountWatcher {
42
51
  // create -> modify collapses to 'created' (single batch); delete always wins.
43
52
  private pendingChanges = new Map<string, FsOp>();
44
53
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
54
+ private rootPollTimer: ReturnType<typeof setInterval> | null = null;
45
55
  private suppressedPaths = new Set<string>();
46
56
  private suppressionTimers = new Map<string, ReturnType<typeof setTimeout>>();
57
+ private rootIdentity: RootIdentity | null = null;
58
+ private detached = false;
59
+ private attaching = false;
60
+ private stopped = true;
47
61
  private readonly debounceMs: number;
48
62
  private readonly config: MountConfig;
49
63
  private readonly onChangeCallback: (changes: FsChange[]) => void;
@@ -64,8 +78,14 @@ export class MountWatcher {
64
78
  * Start watching the filesystem.
65
79
  */
66
80
  start(): void {
67
- if (this.watcher) return;
81
+ if (this.watcher || this.rootPollTimer) return;
82
+
83
+ this.stopped = false;
84
+ this.attach();
85
+ this.startRootPolling();
86
+ }
68
87
 
88
+ private attach(): void {
69
89
  // Read-write mounts: ensure the path exists before chokidar attaches.
70
90
  // chokidar 4's fs.watch-based backend silently fails to fire events when
71
91
  // the watched path OR any of its parent directories don't exist at
@@ -110,21 +130,39 @@ export class MountWatcher {
110
130
  this.watcher.on('add', handleEvent('created'));
111
131
  this.watcher.on('change', handleEvent('modified'));
112
132
  this.watcher.on('unlink', handleEvent('deleted'));
133
+ this.watcher.on('unlinkDir', (dirPath) => {
134
+ if (this.isRootPath(dirPath)) {
135
+ this.detached = true;
136
+ }
137
+ });
113
138
 
114
139
  if (this.lifecycle.onReady) {
115
140
  this.watcher.once('ready', this.lifecycle.onReady);
116
141
  }
117
- if (this.lifecycle.onError) {
118
- this.watcher.on('error', (err) => {
119
- this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
120
- });
121
- }
142
+ this.watcher.on('error', (err) => {
143
+ this.reportError(err);
144
+ });
145
+ this.watcher.on('raw', (event: string, rawPath: string, _details: unknown) => {
146
+ if (event !== 'rename') return;
147
+ // A rename on the watched root's path/basename is the one signal that
148
+ // survives inode reuse on Linux, where unlinkDir/error never fire.
149
+ if (this.isRootPath(rawPath) || basename(rawPath) === basename(this.config.path)) {
150
+ void this.checkRootLiveness({ force: true });
151
+ }
152
+ });
153
+
154
+ this.captureRootIdentity();
122
155
  }
123
156
 
124
157
  /**
125
158
  * Stop watching.
126
159
  */
127
160
  async stop(): Promise<void> {
161
+ this.stopped = true;
162
+ if (this.rootPollTimer) {
163
+ clearInterval(this.rootPollTimer);
164
+ this.rootPollTimer = null;
165
+ }
128
166
  if (this.debounceTimer) {
129
167
  clearTimeout(this.debounceTimer);
130
168
  this.debounceTimer = null;
@@ -140,6 +178,9 @@ export class MountWatcher {
140
178
  await this.watcher.close();
141
179
  this.watcher = null;
142
180
  }
181
+ this.rootIdentity = null;
182
+ this.detached = false;
183
+ this.attaching = false;
143
184
  }
144
185
 
145
186
  /**
@@ -219,6 +260,108 @@ export class MountWatcher {
219
260
  }, this.debounceMs);
220
261
  }
221
262
 
263
+ private startRootPolling(): void {
264
+ const pollMs = this.config.watchRootPollMs ?? 2000;
265
+ this.rootPollTimer = setInterval(() => {
266
+ void this.checkRootLiveness();
267
+ }, pollMs);
268
+ this.rootPollTimer.unref();
269
+ }
270
+
271
+ private async checkRootLiveness(opts: { force?: boolean } = {}): Promise<void> {
272
+ if (this.stopped || this.attaching) return;
273
+
274
+ let current: RootIdentity;
275
+ try {
276
+ current = this.readRootIdentity();
277
+ } catch (err) {
278
+ if (this.isErrno(err, 'ENOENT')) {
279
+ this.detached = true;
280
+ if (this.config.mode === 'read-write') {
281
+ await this.reattach();
282
+ }
283
+ return;
284
+ }
285
+ this.reportError(err);
286
+ if (opts.force) {
287
+ this.detached = true;
288
+ await this.reattach();
289
+ }
290
+ return;
291
+ }
292
+
293
+ if (!this.rootIdentity && !this.detached) {
294
+ await this.reattach();
295
+ return;
296
+ }
297
+
298
+ if (!opts.force && !this.detached && this.rootIdentity
299
+ && this.sameIdentity(current, this.rootIdentity)) {
300
+ return;
301
+ }
302
+
303
+ await this.reattach();
304
+ }
305
+
306
+ private async reattach(): Promise<void> {
307
+ if (this.attaching || this.stopped) return;
308
+
309
+ this.attaching = true;
310
+ try {
311
+ const oldWatcher = this.watcher;
312
+ this.watcher = null;
313
+ if (oldWatcher) {
314
+ await oldWatcher.close();
315
+ }
316
+ if (this.stopped) return;
317
+
318
+ this.attach();
319
+ if (!this.detached && this.rootIdentity) {
320
+ this.lifecycle.onReattach?.();
321
+ }
322
+ } catch (err) {
323
+ this.reportError(err);
324
+ } finally {
325
+ this.attaching = false;
326
+ }
327
+ }
328
+
329
+ private captureRootIdentity(): void {
330
+ try {
331
+ this.rootIdentity = this.readRootIdentity();
332
+ this.detached = false;
333
+ } catch (err) {
334
+ if (this.isErrno(err, 'ENOENT')) {
335
+ this.rootIdentity = null;
336
+ this.detached = true;
337
+ return;
338
+ }
339
+ this.rootIdentity = null;
340
+ this.detached = false;
341
+ this.reportError(err);
342
+ }
343
+ }
344
+
345
+ private readRootIdentity(): RootIdentity {
346
+ const stat = statSync(this.config.path);
347
+ return { dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs };
348
+ }
349
+
350
+ private sameIdentity(a: RootIdentity, b: RootIdentity): boolean {
351
+ // birthtimeMs is only a secondary tripwire: sub-millisecond replacements
352
+ // can reuse both the inode and timestamp, so raw rename remains essential.
353
+ return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
354
+ }
355
+
356
+ private isErrno(err: unknown, code: string): boolean {
357
+ return typeof err === 'object' && err !== null && 'code' in err
358
+ && (err as NodeJS.ErrnoException).code === code;
359
+ }
360
+
361
+ private reportError(err: unknown): void {
362
+ this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
363
+ }
364
+
222
365
  private toRelative(absolutePath: string): string | null {
223
366
  const base = this.config.path.endsWith('/')
224
367
  ? this.config.path
@@ -228,4 +371,14 @@ export class MountWatcher {
228
371
  }
229
372
  return null;
230
373
  }
374
+
375
+ private isRootPath(absolutePath: string): boolean {
376
+ const root = this.config.path.endsWith('/')
377
+ ? this.config.path.slice(0, -1)
378
+ : this.config.path;
379
+ const candidate = absolutePath.endsWith('/')
380
+ ? absolutePath.slice(0, -1)
381
+ : absolutePath;
382
+ return candidate === root;
383
+ }
231
384
  }
@@ -68,6 +68,23 @@ export interface AgentConfig {
68
68
  */
69
69
  cacheTtl?: '5m' | '1h';
70
70
 
71
+ /**
72
+ * Whether to emit prompt-cache markers (cache_control) on requests.
73
+ * Default true (Anthropic API). Set false for transports that reject
74
+ * cache_control — e.g. Bedrock legacy Claude models, which fail with
75
+ * "your request did not allow prompt caching".
76
+ */
77
+ promptCaching?: boolean;
78
+
79
+ /**
80
+ * Prefill-formatter support: a synthetic user message appended after the
81
+ * conversation (e.g. chapterx's `<cmd>cat untitled.txt</cmd>` CLI-sim
82
+ * scaffold). Forwarded verbatim to membrane's prefill formatters; ignored
83
+ * by native formatting. Part of reproducing a prefill-era bot's exact
84
+ * prompting structure when migrating it into a resident.
85
+ */
86
+ prefillUserMessage?: string;
87
+
71
88
  /** Provider-specific request parameters (for example Responses reasoning
72
89
  * and server-side compaction settings). */
73
90
  providerParams?: Record<string, unknown>;
@@ -116,6 +133,16 @@ export interface AgentConfig {
116
133
  */
117
134
  announceHumanTurns?: boolean;
118
135
  };
136
+
137
+ /**
138
+ * How the agent's PLAIN PROSE (non-tool output) reaches channels.
139
+ * - 'locus' (default): host-inferred — the turn-frozen locus machinery.
140
+ * - 'explicit': the model prefixes prose with a destination
141
+ * (`>>#channel` / `>>@person` / `>>skip_reply`); unprefixed prose is never
142
+ * delivered — it bounces to a clipboard for a cheap prefixed resend.
143
+ * See docs/explicit-prose-routing.md.
144
+ */
145
+ proseRouting?: 'locus' | 'explicit';
119
146
  }
120
147
 
121
148
  /** The intentionally small set of agent settings that may change in-process. */
@@ -222,4 +249,12 @@ export interface InferenceRequest {
222
249
  * which correctly fall back to the global default channel.
223
250
  */
224
251
  channelId?: string;
252
+ /**
253
+ * True when the triggering message explicitly addressed the agent (mention,
254
+ * reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake
255
+ * batch spans several channels, an addressed channel outranks ambient
256
+ * chatter for the turn's frozen speech locus: overheard conversation must
257
+ * not capture the agent's voice just by being newest.
258
+ */
259
+ addressed?: boolean;
225
260
  }
@@ -37,7 +37,18 @@ export type TraceEvent =
37
37
  })
38
38
 
39
39
  // Inference lifecycle
40
- | (TraceEventBase & { type: 'inference:started'; agentName: string })
40
+ | (TraceEventBase & {
41
+ type: 'inference:started';
42
+ agentName: string;
43
+ /**
44
+ * The turn-frozen outbound locus (see turnLocusPins) — the channel this
45
+ * turn's plain prose will be routed to. Omitted for turns with no locus
46
+ * (heartbeats with no default channel). Observability only, like every
47
+ * trace field; lets external taps (e.g. a TTS relay) tag the whole
48
+ * activation with its channel without re-deriving routing.
49
+ */
50
+ channelId?: string;
51
+ })
41
52
  | (TraceEventBase & {
42
53
  type: 'inference:completed';
43
54
  agentName: string;
@@ -82,6 +93,8 @@ export type TraceEvent =
82
93
  blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result';
83
94
  /** 0-indexed block position in the current assistant turn. */
84
95
  blockIndex: number;
96
+ /** Turn-frozen outbound locus for this turn (see inference:started). */
97
+ channelId?: string;
85
98
  })
86
99
  | (TraceEventBase & {
87
100
  /**
@@ -95,6 +108,8 @@ export type TraceEvent =
95
108
  phase: 'block_start' | 'block_complete';
96
109
  blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result';
97
110
  blockIndex: number;
111
+ /** Turn-frozen outbound locus for this turn (see inference:started). */
112
+ channelId?: string;
98
113
  })
99
114
  | (TraceEventBase & {
100
115
  type: 'inference:tool_calls_yielded';
@@ -281,6 +296,37 @@ export type TraceEvent =
281
296
  channelId?: string;
282
297
  })
283
298
 
299
+ // Host-owned speech routing (ChannelRegistry.routeSpeech). One trace per
300
+ // delivered prose segment. `text` is the segment verbatim — the same content
301
+ // already on the bus chunk-by-chunk via inference:tokens, and mcpl:* traces
302
+ // are 'ops'-scoped on the webui wire — and `messageId` is the surface's id
303
+ // for the posted message (when the MCPL server reports one). Together they
304
+ // give external taps the (channelId, messageId, text) tuple needed to edit
305
+ // a just-posted message, e.g. a TTS interruption truncating a reply to the
306
+ // words actually voiced.
307
+ | (TraceEventBase & {
308
+ type: 'mcpl:speech-routed';
309
+ /** Agent / conversation whose speech was routed. */
310
+ conversationId: string;
311
+ serverId: string;
312
+ channelId: string;
313
+ delivered: boolean;
314
+ textLen: number;
315
+ /** The routed prose segment, verbatim. */
316
+ text: string;
317
+ /** Surface message id (e.g. Discord message id), when reported. */
318
+ messageId?: string;
319
+ })
320
+ | (TraceEventBase & {
321
+ type: 'mcpl:speech-route-failed';
322
+ /** Agent / conversation whose speech failed to route. */
323
+ conversationId: string;
324
+ /** Empty string when the failure was "no locus at all". */
325
+ channelId: string;
326
+ reason: string;
327
+ textLen: number;
328
+ })
329
+
284
330
  // MCPL server connection lifecycle (spawn / handshake / reconnect health).
285
331
  // Previously these outcomes were visible only on the host process's own
286
332
  // stderr, so a server that lost the boot handshake race just went missing.