@animalabs/agent-framework 0.5.1 → 0.5.4

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': {
@@ -1920,6 +1977,19 @@ export class AgentFramework {
1920
1977
  cacheRead: du.cacheReadTokens,
1921
1978
  }
1922
1979
  : undefined;
1980
+
1981
+ // Reset agent state BEFORE emitting inference:completed so that
1982
+ // synchronous listeners (e.g. runEphemeralToCompletion) observe
1983
+ // status === 'idle' the moment the event fires. Previously
1984
+ // agent.reset() ran after `await dispatchSpeech`, which meant the
1985
+ // ephemeral-completion listener saw status === 'streaming' at the
1986
+ // trace boundary, failed its idle gate, and the promise hung
1987
+ // forever — every "zombie subagent" in production tracked back
1988
+ // here. Speech dispatch happens after but doesn't depend on the
1989
+ // status field.
1990
+ agent.reset();
1991
+ this.eventGate?.onInferenceEnded(agent.name);
1992
+
1923
1993
  this.emitTrace({
1924
1994
  type: 'inference:completed',
1925
1995
  agentName: agent.name,
@@ -1968,9 +2038,6 @@ export class AgentFramework {
1968
2038
  );
1969
2039
  }
1970
2040
 
1971
- // Done — reset to idle
1972
- agent.reset();
1973
- this.eventGate?.onInferenceEnded(agent.name);
1974
2041
  break;
1975
2042
  }
1976
2043
 
@@ -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,
@@ -41,6 +41,17 @@ export interface AgentConfig {
41
41
  /** Max input tokens before framework breaks a yielding stream and
42
42
  * restarts with recompiled (compressed) context. Default: 150000. */
43
43
  maxStreamTokens?: number;
44
+
45
+ /**
46
+ * Extended thinking config. When `enabled: true`, the agent runs with
47
+ * Anthropic's native extended thinking; responses include `thinking` blocks
48
+ * with cryptographic signatures, and the API enforces `temperature: 1`
49
+ * (Membrane handles this). Omit or set `enabled: false` to disable.
50
+ */
51
+ thinking?: {
52
+ enabled: boolean;
53
+ budgetTokens?: number;
54
+ };
44
55
  }
45
56
 
46
57
  /**