@animalabs/agent-framework 0.5.3 → 0.5.6

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/src/framework.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { JsStore } from '@animalabs/chronicle';
3
3
  import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult } from '@animalabs/membrane';
4
+ import { MembraneError } from '@animalabs/membrane';
4
5
  import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
5
6
  import type {
6
7
  MessageId,
@@ -108,13 +109,28 @@ class DefaultInferencePolicy implements InferencePolicy {
108
109
 
109
110
  /**
110
111
  * Default error policy - retry with exponential backoff.
112
+ *
113
+ * Respects MembraneError.retryable: non-retryable errors (400 invalid_request,
114
+ * 401 auth, context_length, safety) are terminal on attempt 0. Retrying them
115
+ * burns API quota without any chance of success — the payload doesn't change
116
+ * between attempts. Production traces showed clerk + reviewer wasting 4
117
+ * inferences on each 400 due to blind retries.
118
+ *
119
+ * For retryable errors, honors retryAfterMs when present (rate limits),
120
+ * otherwise falls back to exponential backoff capped at maxRetries.
111
121
  */
112
122
  class DefaultErrorPolicy implements ErrorPolicy {
113
123
  maxRetries = 3;
114
124
 
115
125
  onInferenceError(error: Error, _agentName: string, attempt: number): ErrorAction {
126
+ if (error instanceof MembraneError && !error.retryable) {
127
+ return { retry: false };
128
+ }
116
129
  if (attempt < this.maxRetries) {
117
- return { retry: true, delayMs: Math.pow(2, attempt) * 1000 };
130
+ const delayMs = error instanceof MembraneError && error.retryAfterMs !== undefined
131
+ ? error.retryAfterMs
132
+ : Math.pow(2, attempt) * 1000;
133
+ return { retry: true, delayMs };
118
134
  }
119
135
  return { retry: false };
120
136
  }
@@ -680,13 +696,22 @@ export class AgentFramework {
680
696
  let toolCallsCount = 0;
681
697
  let settled = false;
682
698
  let inferenceStarted = false;
699
+ let lastActivity = Date.now();
683
700
 
684
701
  const STARTUP_TIMEOUT_MS = 30_000;
702
+ // After inference has started, give it 15 minutes of activity-bounded
703
+ // life. Each addressed trace event refreshes the deadline; only
704
+ // sustained silence trips it. Subagents that legitimately stream for
705
+ // 10+ minutes (e.g. long fork investigations) get the extra slack but
706
+ // stalled streams that emit `inference:completed` and then go quiet —
707
+ // the exact zombie shape we saw in production — are caught here.
708
+ const COMPLETION_IDLE_TIMEOUT_MS = 15 * 60_000;
685
709
 
686
710
  const cleanup = () => {
687
711
  if (settled) return;
688
712
  settled = true;
689
713
  clearTimeout(startupWatchdog);
714
+ clearInterval(completionWatchdog);
690
715
  this.offTrace(traceListener);
691
716
  this.agents.delete(agent.name);
692
717
  };
@@ -705,12 +730,34 @@ export class AgentFramework {
705
730
  }
706
731
  }, STARTUP_TIMEOUT_MS);
707
732
 
733
+ // Completion watchdog: once inference has started, reject if no trace
734
+ // activity for COMPLETION_IDLE_TIMEOUT_MS. Polls every 30s. Without
735
+ // this, an ephemeral that loses its terminal event (e.g. due to a
736
+ // future ordering regression or an upstream stream drop) hangs forever
737
+ // and keeps its concurrency slot pinned.
738
+ const completionWatchdog = setInterval(() => {
739
+ if (settled || !inferenceStarted) return;
740
+ const idle = Date.now() - lastActivity;
741
+ if (idle > COMPLETION_IDLE_TIMEOUT_MS) {
742
+ cleanup();
743
+ reject(new Error(
744
+ `Ephemeral agent "${agent.name}" stalled: no trace activity for ` +
745
+ `${Math.round(idle / 1000)}s after inference started (threshold ` +
746
+ `${Math.round(COMPLETION_IDLE_TIMEOUT_MS / 1000)}s). Stream likely ` +
747
+ `dropped or terminal event was lost.`
748
+ ));
749
+ }
750
+ }, 30_000);
751
+
708
752
  const traceListener = (event: TraceEvent) => {
709
753
  if (settled) return;
710
754
  // Only track events for our ephemeral agent
711
755
  const agentName = 'agentName' in event ? (event as { agentName: string }).agentName : null;
712
756
  if (agentName !== agent.name) return;
713
757
 
758
+ // Any addressed event counts as liveness for the completion watchdog.
759
+ lastActivity = Date.now();
760
+
714
761
  switch (event.type) {
715
762
  case 'inference:started':
716
763
  inferenceStarted = true;
@@ -732,13 +779,23 @@ export class AgentFramework {
732
779
  speech = '';
733
780
  break;
734
781
  case 'inference:completed': {
735
- // Check if agent is idle (no pending tool calls = truly done)
736
- // If tools were called, the agent will cycle through waiting_for_tools → ready → streaming
737
- // and eventually complete again. We only resolve when there are no more tool calls.
738
- if (agent.state.status === 'idle') {
739
- cleanup();
740
- resolve({ speech, toolCallsCount });
741
- }
782
+ // `inference:completed` is terminal — driveStream only emits it
783
+ // from `case 'complete'`, which fires after the full tool
784
+ // round-trip has finished and the model has stopped. Intermediate
785
+ // tool-cycle transitions use `inference:stream_resumed`, not
786
+ // `inference:completed`. So we resolve unconditionally here.
787
+ //
788
+ // Previously this branch gated on `agent.state.status === 'idle'`,
789
+ // which was correct in intent but order-fragile: the framework
790
+ // emitted `inference:completed` BEFORE calling `agent.reset()`,
791
+ // so synchronous listeners observed status === 'streaming' at
792
+ // the trace boundary. The gate failed silently and the promise
793
+ // hung forever — every production zombie subagent originated
794
+ // here. The reset+emit order has been fixed in driveStream's
795
+ // `case 'complete'` handler; the gate is dropped here as
796
+ // belt-and-suspenders.
797
+ cleanup();
798
+ resolve({ speech, toolCallsCount });
742
799
  break;
743
800
  }
744
801
  case 'inference:turn_ended': {
@@ -1770,8 +1827,22 @@ export class AgentFramework {
1770
1827
  type: 'inference:tokens',
1771
1828
  agentName: agent.name,
1772
1829
  content: event.content,
1830
+ blockType: event.meta.type,
1831
+ blockIndex: event.meta.blockIndex,
1832
+ });
1833
+ break;
1834
+
1835
+ case 'block': {
1836
+ const { event: phase, index, block } = event.event;
1837
+ this.emitTrace({
1838
+ type: 'inference:content_block',
1839
+ agentName: agent.name,
1840
+ phase,
1841
+ blockType: block.type,
1842
+ blockIndex: index,
1773
1843
  });
1774
1844
  break;
1845
+ }
1775
1846
 
1776
1847
  case 'tool-calls': {
1777
1848
  hadToolCalls = true;
@@ -1920,6 +1991,19 @@ export class AgentFramework {
1920
1991
  cacheRead: du.cacheReadTokens,
1921
1992
  }
1922
1993
  : undefined;
1994
+
1995
+ // Reset agent state BEFORE emitting inference:completed so that
1996
+ // synchronous listeners (e.g. runEphemeralToCompletion) observe
1997
+ // status === 'idle' the moment the event fires. Previously
1998
+ // agent.reset() ran after `await dispatchSpeech`, which meant the
1999
+ // ephemeral-completion listener saw status === 'streaming' at the
2000
+ // trace boundary, failed its idle gate, and the promise hung
2001
+ // forever — every "zombie subagent" in production tracked back
2002
+ // here. Speech dispatch happens after but doesn't depend on the
2003
+ // status field.
2004
+ agent.reset();
2005
+ this.eventGate?.onInferenceEnded(agent.name);
2006
+
1923
2007
  this.emitTrace({
1924
2008
  type: 'inference:completed',
1925
2009
  agentName: agent.name,
@@ -1968,9 +2052,6 @@ export class AgentFramework {
1968
2052
  );
1969
2053
  }
1970
2054
 
1971
- // Done — reset to idle
1972
- agent.reset();
1973
- this.eventGate?.onInferenceEnded(agent.name);
1974
2055
  break;
1975
2056
  }
1976
2057
 
@@ -0,0 +1,275 @@
1
+ /**
2
+ * HealthModule — minimal framework self-introspection.
3
+ *
4
+ * Exposes a single tool, `health--snapshot`, that returns a structured
5
+ * snapshot of recent framework activity. Useful when an agent gets a
6
+ * concurrency timeout, a 400 from the API, or wants to verify it's not
7
+ * silently failing.
8
+ *
9
+ * Sources:
10
+ * - `framework/inference-log` (queryInferenceLogs) — recent inference
11
+ * success/error counts, last N errors, per-agent token totals.
12
+ * - `modules/subagent/state` — best-effort. The slot is owned by
13
+ * `connectome-host`'s SubagentModule, not by this module. We read it
14
+ * directly from the chronicle store so the tool works even when the
15
+ * HealthModule has no awareness of SubagentModule's internals.
16
+ * - Registered module names.
17
+ *
18
+ * Kept deliberately read-only: no side effects, no kill switches.
19
+ * Reaping/cancellation lives in the SubagentModule.
20
+ */
21
+
22
+ import type { JsStore } from '@animalabs/chronicle';
23
+ import type { Module, ModuleContext } from '../../types/module.js';
24
+ import type { ToolDefinition, ToolCall, ToolResult, ProcessEvent } from '../../types/events.js';
25
+ import type { EventResponse, ProcessState } from '../../types/module.js';
26
+ import type { AgentFramework } from '../../framework.js';
27
+
28
+ export interface HealthModuleConfig {
29
+ /**
30
+ * Default number of recent inferences to summarize when the tool is
31
+ * called without an explicit limit. Default: 20.
32
+ */
33
+ defaultLookback?: number;
34
+
35
+ /**
36
+ * State slot to read for the subagent registry. Allows hosts that name
37
+ * their subagent module differently to point this at the right slot.
38
+ * Default: `modules/subagent/state`.
39
+ */
40
+ subagentStateId?: string;
41
+ }
42
+
43
+ interface SnapshotInput {
44
+ /** How many recent inferences to summarize. Default config.defaultLookback. */
45
+ lookback?: number;
46
+ /** Include per-agent token totals across all-time. Default true. */
47
+ includeTokens?: boolean;
48
+ /** Include subagent registry summary. Default true. */
49
+ includeSubagents?: boolean;
50
+ }
51
+
52
+ interface PersistedSubagentRecord {
53
+ name: string;
54
+ type?: string;
55
+ status?: string;
56
+ startedAt?: number;
57
+ lastActivityAt?: number;
58
+ completedAt?: number;
59
+ toolCallsCount?: number;
60
+ statusMessage?: string;
61
+ }
62
+
63
+ export class HealthModule implements Module {
64
+ readonly name = 'health';
65
+
66
+ private ctx: ModuleContext | null = null;
67
+ private framework: AgentFramework | null = null;
68
+ private store: JsStore | null = null;
69
+ private config: Required<HealthModuleConfig>;
70
+
71
+ constructor(config: HealthModuleConfig = {}) {
72
+ this.config = {
73
+ defaultLookback: config.defaultLookback ?? 20,
74
+ subagentStateId: config.subagentStateId ?? 'modules/subagent/state',
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Wire framework + store. Host calls this after AgentFramework.create()
80
+ * so the module can query inference logs and chronicle state.
81
+ */
82
+ bind(framework: AgentFramework, store: JsStore): void {
83
+ this.framework = framework;
84
+ this.store = store;
85
+ }
86
+
87
+ async start(ctx: ModuleContext): Promise<void> {
88
+ this.ctx = ctx;
89
+ }
90
+
91
+ async stop(): Promise<void> {
92
+ this.ctx = null;
93
+ }
94
+
95
+ getTools(): ToolDefinition[] {
96
+ return [
97
+ {
98
+ name: 'snapshot',
99
+ description:
100
+ 'Return a structured snapshot of framework health: recent inference success/error counts, last errors, per-agent token totals, active subagent registry, and registered modules. Read-only. Useful when diagnosing why a spawn timed out, why an inference failed, or whether the framework is silently degraded.',
101
+ inputSchema: {
102
+ type: 'object' as const,
103
+ properties: {
104
+ lookback: {
105
+ type: 'number',
106
+ description: `How many recent inferences to summarize (default ${this.config.defaultLookback}).`,
107
+ },
108
+ includeTokens: {
109
+ type: 'boolean',
110
+ description: 'Include per-agent token totals (default true).',
111
+ },
112
+ includeSubagents: {
113
+ type: 'boolean',
114
+ description: 'Include subagent registry summary (default true).',
115
+ },
116
+ },
117
+ },
118
+ },
119
+ ];
120
+ }
121
+
122
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
123
+ if (call.name !== 'snapshot') {
124
+ return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
125
+ }
126
+ const input = (call.input ?? {}) as SnapshotInput;
127
+ const lookback = input.lookback ?? this.config.defaultLookback;
128
+ const includeTokens = input.includeTokens ?? true;
129
+ const includeSubagents = input.includeSubagents ?? true;
130
+
131
+ try {
132
+ const snapshot = this.buildSnapshot(lookback, includeTokens, includeSubagents);
133
+ return { success: true, data: snapshot };
134
+ } catch (error) {
135
+ return {
136
+ success: false,
137
+ isError: true,
138
+ error: error instanceof Error ? error.message : String(error),
139
+ };
140
+ }
141
+ }
142
+
143
+ async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
144
+ return {};
145
+ }
146
+
147
+ private buildSnapshot(
148
+ lookback: number,
149
+ includeTokens: boolean,
150
+ includeSubagents: boolean,
151
+ ): Record<string, unknown> {
152
+ if (!this.framework || !this.store) {
153
+ throw new Error(
154
+ 'HealthModule not bound — host must call bind(framework, store) before tool dispatch.',
155
+ );
156
+ }
157
+
158
+ // ── Inference log summary ───────────────────────────────────────────
159
+ // queryInferenceLogs returns InferenceLogEntryWithId. Prefer the
160
+ // `summary` field (no blob deref) but fall back to `entry` for older
161
+ // records or if the summarizer wasn't populated.
162
+ const recent = this.framework.queryInferenceLogs({ limit: lookback });
163
+ const recentEntries = recent.entries ?? [];
164
+
165
+ const projectSummary = (e: typeof recentEntries[number]) => {
166
+ const s = e.summary ?? e.entry;
167
+ return {
168
+ timestamp: s.timestamp,
169
+ agentName: s.agentName,
170
+ success: s.success,
171
+ error: s.error,
172
+ requestId: s.requestId,
173
+ tokenUsage: s.tokenUsage,
174
+ };
175
+ };
176
+
177
+ const recentErrors = recentEntries
178
+ .map(projectSummary)
179
+ .filter((s) => s.success === false)
180
+ .slice(0, 10)
181
+ .map((s) => ({
182
+ timestamp: s.timestamp,
183
+ agentName: s.agentName,
184
+ error: typeof s.error === 'string' ? s.error.slice(0, 400) : s.error,
185
+ requestId: s.requestId,
186
+ }));
187
+
188
+ let tokenTotals: Record<string, { input: number; output: number; cacheRead: number; inferences: number }> | undefined;
189
+ if (includeTokens) {
190
+ // Pull a larger window for totals — defaults to 1000 entries which is
191
+ // enough to characterize ongoing usage without dragging the entire log.
192
+ const wide = this.framework.queryInferenceLogs({ limit: 1000 });
193
+ tokenTotals = {};
194
+ for (const raw of wide.entries ?? []) {
195
+ const s = projectSummary(raw);
196
+ const agent = s.agentName ?? 'unknown';
197
+ const t = tokenTotals[agent] ?? { input: 0, output: 0, cacheRead: 0, inferences: 0 };
198
+ t.inferences++;
199
+ const u = s.tokenUsage;
200
+ if (u) {
201
+ t.input += u.input ?? 0;
202
+ t.output += u.output ?? 0;
203
+ t.cacheRead += u.cacheRead ?? 0;
204
+ }
205
+ tokenTotals[agent] = t;
206
+ }
207
+ }
208
+
209
+ // ── Subagent registry summary (best-effort) ────────────────────────
210
+ let subagents: Array<Record<string, unknown>> | string | undefined;
211
+ if (includeSubagents) {
212
+ try {
213
+ const raw = this.store.getStateJson(this.config.subagentStateId);
214
+ if (raw) {
215
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
216
+ const agents = (parsed as { agents?: Record<string, PersistedSubagentRecord> })?.agents
217
+ ?? {};
218
+ const now = Date.now();
219
+ subagents = Object.entries(agents).map(([key, sa]) => {
220
+ const startedAt = sa.startedAt ?? 0;
221
+ const lastActivityAt = sa.lastActivityAt ?? startedAt;
222
+ return {
223
+ key,
224
+ name: sa.name,
225
+ type: sa.type,
226
+ status: sa.status,
227
+ startedAt: startedAt > 0 ? new Date(startedAt).toISOString() : null,
228
+ lastActivityAt: lastActivityAt > 0 ? new Date(lastActivityAt).toISOString() : null,
229
+ runtimeSeconds: startedAt > 0 ? Math.floor((now - startedAt) / 1000) : null,
230
+ silentSeconds: lastActivityAt > 0 ? Math.floor((now - lastActivityAt) / 1000) : null,
231
+ toolCallsCount: sa.toolCallsCount,
232
+ statusMessage: sa.statusMessage,
233
+ };
234
+ });
235
+ } else {
236
+ subagents = 'no subagent state registered';
237
+ }
238
+ } catch (error) {
239
+ subagents = `error reading subagent state: ${error instanceof Error ? error.message : String(error)}`;
240
+ }
241
+ }
242
+
243
+ // ── Module list ────────────────────────────────────────────────────
244
+ const modules = this.ctx ? this.listModulesViaContext() : [];
245
+
246
+ return {
247
+ timestamp: new Date().toISOString(),
248
+ window: {
249
+ lookback,
250
+ inferencesInWindow: recentEntries.length,
251
+ },
252
+ inferences: {
253
+ successCount: recentEntries.filter((e) => projectSummary(e).success).length,
254
+ errorCount: recentEntries.filter((e) => projectSummary(e).success === false).length,
255
+ recentErrors,
256
+ },
257
+ tokenTotalsByAgent: tokenTotals,
258
+ subagents,
259
+ modules,
260
+ };
261
+ }
262
+
263
+ private listModulesViaContext(): string[] {
264
+ // ModuleContext.getActiveTools returns flat tool names; derive module set
265
+ // from the prefix before '--'. This avoids needing a dedicated module-list
266
+ // accessor on ModuleContext (kept narrow on purpose).
267
+ const tools = this.ctx?.getActiveTools() ?? [];
268
+ const set = new Set<string>();
269
+ for (const t of tools) {
270
+ const i = t.name.indexOf('--');
271
+ if (i > 0) set.add(t.name.slice(0, i));
272
+ }
273
+ return [...set].sort();
274
+ }
275
+ }
@@ -5,6 +5,9 @@
5
5
  export { ApiModule } from './api/index.js';
6
6
  export type { ApiEvent } from './api/index.js';
7
7
 
8
+ export { HealthModule } from './health/index.js';
9
+ export type { HealthModuleConfig } from './health/index.js';
10
+
8
11
  export { WorkspaceModule } from './workspace/index.js';
9
12
  export type {
10
13
  WorkspaceConfig,
@@ -67,6 +67,27 @@ export type TraceEvent =
67
67
  type: 'inference:tokens';
68
68
  agentName: string;
69
69
  content: string;
70
+ /**
71
+ * Which membrane block produced this chunk. Carried verbatim from the
72
+ * membrane's ChunkMeta. Consumers that want to render thinking content
73
+ * distinctly from regular text should switch on this.
74
+ */
75
+ blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result';
76
+ /** 0-indexed block position in the current assistant turn. */
77
+ blockIndex: number;
78
+ })
79
+ | (TraceEventBase & {
80
+ /**
81
+ * Structural boundary inside the assistant turn — emitted on every
82
+ * block_start / block_complete from the membrane. Use this to switch
83
+ * render lanes (e.g. open a dim "thinking" element on block_start
84
+ * with blockType='thinking', close it on block_complete).
85
+ */
86
+ type: 'inference:content_block';
87
+ agentName: string;
88
+ phase: 'block_start' | 'block_complete';
89
+ blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result';
90
+ blockIndex: number;
70
91
  })
71
92
  | (TraceEventBase & {
72
93
  type: 'inference:tool_calls_yielded';