@animalabs/agent-framework 0.6.10 → 0.7.2

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 (83) hide show
  1. package/dist/src/agent.d.ts +2 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +3 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/code-execution/index.d.ts +14 -0
  6. package/dist/src/code-execution/index.d.ts.map +1 -0
  7. package/dist/src/code-execution/index.js +13 -0
  8. package/dist/src/code-execution/index.js.map +1 -0
  9. package/dist/src/code-execution/py-runner.d.ts +91 -0
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -0
  11. package/dist/src/code-execution/py-runner.js +364 -0
  12. package/dist/src/code-execution/py-runner.js.map +1 -0
  13. package/dist/src/code-execution/runtime-py.d.ts +32 -0
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -0
  15. package/dist/src/code-execution/runtime-py.js +265 -0
  16. package/dist/src/code-execution/runtime-py.js.map +1 -0
  17. package/dist/src/code-execution/tool-definition.d.ts +16 -0
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -0
  19. package/dist/src/code-execution/tool-definition.js +45 -0
  20. package/dist/src/code-execution/tool-definition.js.map +1 -0
  21. package/dist/src/framework.d.ts +157 -15
  22. package/dist/src/framework.d.ts.map +1 -1
  23. package/dist/src/framework.js +1068 -104
  24. package/dist/src/framework.js.map +1 -1
  25. package/dist/src/index.d.ts +2 -0
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/mcpl/channel-registry.d.ts +99 -7
  30. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  31. package/dist/src/mcpl/channel-registry.js +374 -49
  32. package/dist/src/mcpl/channel-registry.js.map +1 -1
  33. package/dist/src/mcpl/prose-grammar.d.ts +24 -0
  34. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -0
  35. package/dist/src/mcpl/prose-grammar.js +33 -0
  36. package/dist/src/mcpl/prose-grammar.js.map +1 -0
  37. package/dist/src/mcpl/prose-stream-router.d.ts +58 -0
  38. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -0
  39. package/dist/src/mcpl/prose-stream-router.js +168 -0
  40. package/dist/src/mcpl/prose-stream-router.js.map +1 -0
  41. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  42. package/dist/src/modules/workspace/index.js +13 -4
  43. package/dist/src/modules/workspace/index.js.map +1 -1
  44. package/dist/src/types/agent.d.ts +17 -0
  45. package/dist/src/types/agent.d.ts.map +1 -1
  46. package/dist/src/types/framework.d.ts +33 -0
  47. package/dist/src/types/framework.d.ts.map +1 -1
  48. package/dist/src/types/trace.d.ts +32 -0
  49. package/dist/src/types/trace.d.ts.map +1 -1
  50. package/dist/test/channel-registry-routing.test.js +186 -10
  51. package/dist/test/channel-registry-routing.test.js.map +1 -1
  52. package/dist/test/code-execution.test.d.ts +11 -0
  53. package/dist/test/code-execution.test.d.ts.map +1 -0
  54. package/dist/test/code-execution.test.js +412 -0
  55. package/dist/test/code-execution.test.js.map +1 -0
  56. package/dist/test/explicit-prose-routing.test.d.ts +11 -0
  57. package/dist/test/explicit-prose-routing.test.d.ts.map +1 -0
  58. package/dist/test/explicit-prose-routing.test.js +247 -0
  59. package/dist/test/explicit-prose-routing.test.js.map +1 -0
  60. package/dist/test/present-while-acting.test.js +85 -8
  61. package/dist/test/present-while-acting.test.js.map +1 -1
  62. package/dist/test/prose-stream-router.test.d.ts +2 -0
  63. package/dist/test/prose-stream-router.test.d.ts.map +1 -0
  64. package/dist/test/prose-stream-router.test.js +87 -0
  65. package/dist/test/prose-stream-router.test.js.map +1 -0
  66. package/dist/test/trunk-channel-routing.test.js +2 -2
  67. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +4 -4
  70. package/src/agent.ts +3 -0
  71. package/src/code-execution/index.ts +19 -0
  72. package/src/code-execution/py-runner.ts +433 -0
  73. package/src/code-execution/runtime-py.ts +265 -0
  74. package/src/code-execution/tool-definition.ts +52 -0
  75. package/src/framework.ts +1160 -107
  76. package/src/index.ts +17 -0
  77. package/src/mcpl/channel-registry.ts +454 -54
  78. package/src/mcpl/prose-grammar.ts +40 -0
  79. package/src/mcpl/prose-stream-router.ts +161 -0
  80. package/src/modules/workspace/index.ts +13 -4
  81. package/src/types/agent.ts +18 -0
  82. package/src/types/framework.ts +35 -0
  83. package/src/types/trace.ts +47 -1
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Explicit-prose routing grammar — the SINGLE definition shared by the
3
+ * delivery path (framework.deliverProse*) and the outgoing-stream router
4
+ * (prose-stream-router.ts). Extracted from framework.ts verbatim so the two
5
+ * parsers cannot drift: a streamed chunk and its eventual delivery must agree
6
+ * on what is a prefix, what is a modifier, and what is body.
7
+ *
8
+ * Grammar (docs/explicit-prose-routing.md):
9
+ * `>>target [!] [body…]` — target = channel spec or `skip_reply`
10
+ * The ` !` continuation modifier must immediately follow the target,
11
+ * whitespace-separated. Body may start on the same line or the next.
12
+ * A line's `>>` counts only when immediately followed by a target token
13
+ * (`>> quoted arrow` stays body text).
14
+ */
15
+
16
+ export interface ProsePrefix {
17
+ kind: 'target' | 'private' | 'none';
18
+ target?: string;
19
+ continueTurn: boolean;
20
+ body: string;
21
+ }
22
+
23
+ /** A REAL prefix line: `>>` immediately followed by a target token. */
24
+ export const PROSE_PREFIX_LINE = /^>>\S/;
25
+
26
+ export function parseProsePrefix(text: string): ProsePrefix {
27
+ const m = /^>>(\S+)([ \t]+!)?[ \t]*\n?/.exec(text);
28
+ if (!m) return { kind: 'none', continueTurn: false, body: text };
29
+ const target = m[1]!;
30
+ const continueTurn = m[2] !== undefined;
31
+ const body = text.slice(m[0].length);
32
+ // `skip_reply` mirrors the tool the model already knows: text stays in
33
+ // context, nothing is delivered. (Deliberately NOT called "private"/"note" —
34
+ // that vocabulary sat adjacent to signed thinking in a live window and drew
35
+ // a reasoning_extraction classifier hit, 2026-07-24 Fable.)
36
+ if (target === 'skip_reply') {
37
+ return { kind: 'private', continueTurn, body };
38
+ }
39
+ return { kind: 'target', target, continueTurn, body };
40
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Incremental prose router: turns a stream of text deltas into a stream of
3
+ * (channelId, delta) pairs AS THE MODEL GENERATES, so downstream surfaces
4
+ * (voice synthesis, streamed message rendering, future platforms) can act on
5
+ * speech before the turn completes. This is the emission half of MCPL Spec
6
+ * 14.3 (`channels/outgoing/chunk`) — the notification has existed in the
7
+ * wire types since the spec landed; this class is what finally produces it.
8
+ *
9
+ * Correctness contract: routing decisions here MUST agree with the delivery
10
+ * path (deliverProse/parseProsePrefix). Both use prose-grammar.ts; this
11
+ * router additionally holds back exactly as much text as needed to make each
12
+ * line's decision — the start of a line is buffered only until it either
13
+ * cannot be a `>>` prefix (flush as body) or its prefix token (+ optional
14
+ * ` !` modifier) completes (switch target, swallow the prefix). Body text
15
+ * after the decision point streams through with zero added latency.
16
+ *
17
+ * Suppression is fail-closed: text whose destination is unknown (explicit
18
+ * mode before the first `>>`, unresolvable/ambiguous targets, `>>skip_reply`)
19
+ * emits NOTHING — the delivery path will bounce/retain it; a stream consumer
20
+ * must never see text that delivery would refuse to send.
21
+ *
22
+ * Two modes, mirroring proseRouting:
23
+ * - explicit: parse `>>` prefixes; initial target null (suppressed).
24
+ * - locus: NO prefix parsing (locus mode's `>>` lines are plain text);
25
+ * everything streams to the frozen turn locus.
26
+ */
27
+
28
+ export interface RoutedDelta {
29
+ channelId: string;
30
+ delta: string;
31
+ }
32
+
33
+ type State = 'line-start' | 'maybe-prefix' | 'prefix-token' | 'after-token' | 'body';
34
+
35
+ export class ProseStreamRouter {
36
+ private target: string | null;
37
+ private state: State = 'line-start';
38
+ private held = ''; // text held while a line's decision is pending
39
+ private token = ''; // the >>spec token being accumulated
40
+ private sawBang = false;
41
+ private accumulated = new Map<string, string>();
42
+
43
+ constructor(
44
+ private opts: {
45
+ /** 'explicit' parses `>>` prefixes; 'locus' is pure passthrough. */
46
+ mode: 'explicit' | 'locus';
47
+ /** Initial destination: the frozen turn locus (locus mode) or null. */
48
+ initialTarget: string | null;
49
+ /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */
50
+ resolve: (spec: string) => string | null;
51
+ },
52
+ ) {
53
+ this.target = opts.initialTarget;
54
+ if (opts.mode === 'locus') this.state = 'body';
55
+ }
56
+
57
+ /** Per-channel full text emitted so far (for outgoing/complete). */
58
+ byChannel(): ReadonlyMap<string, string> {
59
+ return this.accumulated;
60
+ }
61
+
62
+ feed(delta: string): RoutedDelta[] {
63
+ const out: RoutedDelta[] = [];
64
+ for (const ch of delta) this.step(ch, out);
65
+ return coalesce(out);
66
+ }
67
+
68
+ /** End of stream: flush any held line-start text (it can no longer become a
69
+ * complete prefix; an unterminated `>>tok` at EOF is routing metadata with
70
+ * no body — dropped, matching delivery which would deliver empty body). */
71
+ finish(): RoutedDelta[] {
72
+ const out: RoutedDelta[] = [];
73
+ if (this.state === 'maybe-prefix') this.emit(this.held, out); // "" | ">" | ">>"
74
+ // prefix-token / after-token at EOF: prefix without body — nothing to emit.
75
+ this.held = '';
76
+ this.state = 'line-start';
77
+ return coalesce(out);
78
+ }
79
+
80
+ private step(ch: string, out: RoutedDelta[]): void {
81
+ switch (this.state) {
82
+ case 'body':
83
+ if (ch === '\n' && this.opts.mode === 'explicit') {
84
+ this.emit('\n', out);
85
+ this.state = 'line-start';
86
+ } else {
87
+ this.emit(ch, out);
88
+ }
89
+ return;
90
+
91
+ case 'line-start':
92
+ if (ch === '>') { this.state = 'maybe-prefix'; this.held = '>'; return; }
93
+ this.state = 'body';
94
+ this.step(ch, out);
95
+ return;
96
+
97
+ case 'maybe-prefix':
98
+ if (this.held === '>' && ch === '>') { this.held = '>>'; return; }
99
+ if (this.held === '>>') {
100
+ if (ch === '\n' || ch === ' ' || ch === '\t') {
101
+ // `>> quoted arrow` / bare `>>`: body text, not a prefix.
102
+ this.emit(this.held, out);
103
+ this.held = '';
104
+ this.state = 'body';
105
+ this.step(ch, out);
106
+ return;
107
+ }
108
+ // real prefix begins
109
+ this.state = 'prefix-token';
110
+ this.token = ch;
111
+ this.held = '';
112
+ return;
113
+ }
114
+ // held === '>' and ch !== '>': plain body starting with '>'
115
+ this.emit(this.held, out);
116
+ this.held = '';
117
+ this.state = 'body';
118
+ this.step(ch, out);
119
+ return;
120
+
121
+ case 'prefix-token':
122
+ if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; }
123
+ if (ch === '\n') { this.applyTarget(); this.state = 'line-start'; return; }
124
+ this.token += ch;
125
+ return;
126
+
127
+ case 'after-token':
128
+ // Grammar: `([ \t]+!)?[ \t]*` — one optional `!` immediately after the
129
+ // first whitespace run is the continue-turn modifier, not body.
130
+ if (ch === ' ' || ch === '\t') return;
131
+ if (ch === '\n') { this.state = 'line-start'; return; }
132
+ if (ch === '!' && !this.sawBang) { this.sawBang = true; return; }
133
+ this.state = 'body';
134
+ this.step(ch, out);
135
+ return;
136
+ }
137
+ }
138
+
139
+ private applyTarget(): void {
140
+ const spec = this.token;
141
+ this.token = '';
142
+ if (spec === 'skip_reply') { this.target = null; return; }
143
+ this.target = this.opts.resolve(spec);
144
+ }
145
+
146
+ private emit(text: string, out: RoutedDelta[]): void {
147
+ if (this.target === null || !text) return;
148
+ out.push({ channelId: this.target, delta: text });
149
+ this.accumulated.set(this.target, (this.accumulated.get(this.target) ?? '') + text);
150
+ }
151
+ }
152
+
153
+ function coalesce(deltas: RoutedDelta[]): RoutedDelta[] {
154
+ const out: RoutedDelta[] = [];
155
+ for (const d of deltas) {
156
+ const last = out[out.length - 1];
157
+ if (last && last.channelId === d.channelId) last.delta += d.delta;
158
+ else out.push({ ...d });
159
+ }
160
+ return out;
161
+ }
@@ -733,13 +733,13 @@ export class WorkspaceModule implements Module {
733
733
  return [
734
734
  {
735
735
  name: 'read',
736
- 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.',
737
737
  inputSchema: {
738
738
  type: 'object' as const,
739
739
  properties: {
740
740
  path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
741
741
  offset: { type: 'number', description: 'Starting line number (1-indexed)' },
742
- limit: { type: 'number', description: 'Maximum number of lines to return' },
742
+ limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
743
743
  },
744
744
  required: ['path'],
745
745
  },
@@ -1344,10 +1344,16 @@ export class WorkspaceModule implements Module {
1344
1344
  const content = blob.toString('utf-8');
1345
1345
  const lines = content.split('\n');
1346
1346
 
1347
- // 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;
1348
1352
  const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
1349
- const endLine = input.limit ? startLine + input.limit : lines.length;
1353
+ const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
1354
+ const endLine = startLine + effectiveLimit;
1350
1355
  const slice = lines.slice(startLine, endLine);
1356
+ const truncated = endLine < lines.length;
1351
1357
 
1352
1358
  // Format with line numbers (cat -n style)
1353
1359
  const formatted = slice
@@ -1361,6 +1367,9 @@ export class WorkspaceModule implements Module {
1361
1367
  totalLines: lines.length,
1362
1368
  fromLine: startLine + 1,
1363
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
+ : {}),
1364
1373
  content: formatted,
1365
1374
  },
1366
1375
  };
@@ -133,6 +133,16 @@ export interface AgentConfig {
133
133
  */
134
134
  announceHumanTurns?: boolean;
135
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';
136
146
  }
137
147
 
138
148
  /** The intentionally small set of agent settings that may change in-process. */
@@ -239,4 +249,12 @@ export interface InferenceRequest {
239
249
  * which correctly fall back to the global default channel.
240
250
  */
241
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;
242
260
  }
@@ -31,6 +31,35 @@ export interface ProcessLoggingConfig {
31
31
  /**
32
32
  * Configuration for the agent framework.
33
33
  */
34
+ /**
35
+ * Client-side programmatic tool calling: a synthesized `code_execution` tool
36
+ * that runs model-authored Python on the host, with every framework tool
37
+ * injected as an async function. Mirrors the trained surface of Anthropic's
38
+ * server-side PTC; inner tool results never enter the model context.
39
+ *
40
+ * Robustness boundary, not a security sandbox (same doctrine as gate.js —
41
+ * the agent already has broader host access through its tools).
42
+ */
43
+ export interface CodeExecutionConfig {
44
+ /** Master switch — the tool is only synthesized when true. */
45
+ enabled: boolean;
46
+ /** Python interpreter to spawn (default: 'python3' from PATH). */
47
+ pythonPath?: string;
48
+ /**
49
+ * Per-inner-tool-call timeout; exceeding it raises TimeoutError inside the
50
+ * script (default 270_000 ms, mirroring the managed runtime's message).
51
+ */
52
+ toolCallTimeoutMs?: number;
53
+ /** Whole-script deadline: cancel → grace → SIGKILL (default 600_000 ms). */
54
+ scriptTimeoutMs?: number;
55
+ /**
56
+ * Idle interpreter reclaim — script globals are lost after this much
57
+ * inactivity (default 300_000 ms, mirroring ~5-minute container reclaim).
58
+ * 0 disables reclaim.
59
+ */
60
+ idleReclaimMs?: number;
61
+ }
62
+
34
63
  export interface FrameworkConfig {
35
64
  /**
36
65
  * IANA zone used only when rendering wall-clock times for the agent.
@@ -93,6 +122,12 @@ export interface FrameworkConfig {
93
122
  /** MCPL server configurations. If omitted or empty, no MCPL subsystems are created. */
94
123
  mcplServers?: McplServerConfig[];
95
124
 
125
+ /**
126
+ * Client-side programmatic tool calling (`code_execution` tool). Omitted or
127
+ * `enabled: false` → tool absent, no python is ever spawned.
128
+ */
129
+ codeExecution?: CodeExecutionConfig;
130
+
96
131
  /** Inference routing policy for server-initiated inference (optional). */
97
132
  inferenceRouting?: InferenceRoutingPolicy;
98
133
 
@@ -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.