pi-blackhole 0.4.2 → 0.4.3

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 (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -411
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. package/vitest.config.ts +15 -15
@@ -6,7 +6,12 @@
6
6
  * and throws if the API errored without collecting any tool results.
7
7
  * This allows the consolidation pipeline to fall back to alternative models.
8
8
  */
9
- import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
9
+ import {
10
+ agentLoop,
11
+ type AgentContext,
12
+ type AgentLoopConfig,
13
+ type AgentTool,
14
+ } from "@earendil-works/pi-agent-core";
10
15
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
11
16
  import { createBridgeStreamFn } from "../../provider-stream.js";
12
17
  import { streamSimple } from "@earendil-works/pi-ai/compat";
@@ -20,166 +25,191 @@ import type { Observation, Relevance } from "../../ledger/index.js";
20
25
  import { estimateStringTokens } from "../../tokens.js";
21
26
 
22
27
  interface RunObserverArgs {
23
- model: Model<any>;
24
- apiKey: string;
25
- headers?: Record<string, string>;
26
- priorReflections: string[];
27
- priorObservations: string[];
28
- chunk: string;
29
- allowedSourceEntryIds: string[];
30
- signal?: AbortSignal;
31
- agentLoop?: typeof agentLoop;
32
- /** Optional custom stream function bypassing agentLoop's default streamSimple.
33
- * Used by the Symbol.for bridge to access native pi-ai provider registrations
34
- * from jiti-loaded consolidation agents. */
35
- streamFn?: (model: any, context: any, options: any) => any;
36
- maxTurns?: number;
37
- thinkingLevel?: ModelThinkingLevel;
28
+ model: Model<any>;
29
+ apiKey: string;
30
+ headers?: Record<string, string>;
31
+ priorReflections: string[];
32
+ priorObservations: string[];
33
+ chunk: string;
34
+ allowedSourceEntryIds: string[];
35
+ signal?: AbortSignal;
36
+ agentLoop?: typeof agentLoop;
37
+ /** Optional custom stream function bypassing agentLoop's default streamSimple.
38
+ * Used by the Symbol.for bridge to access native pi-ai provider registrations
39
+ * from jiti-loaded consolidation agents. */
40
+ streamFn?: (model: any, context: any, options: any) => any;
41
+ maxTurns?: number;
42
+ thinkingLevel?: ModelThinkingLevel;
38
43
  }
39
44
 
40
45
  const RelevanceSchema = Type.Union([
41
- Type.Literal("low"),
42
- Type.Literal("medium"),
43
- Type.Literal("high"),
44
- Type.Literal("critical"),
46
+ Type.Literal("low"),
47
+ Type.Literal("medium"),
48
+ Type.Literal("high"),
49
+ Type.Literal("critical"),
45
50
  ]);
46
51
 
47
- export const OBSERVATION_TIMESTAMP_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$";
52
+ export const OBSERVATION_TIMESTAMP_PATTERN =
53
+ "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$";
48
54
 
49
55
  const RecordObservationsSchema = Type.Object({
50
- observations: Type.Array(
51
- Type.Object({
52
- timestamp: Type.String({
53
- pattern: OBSERVATION_TIMESTAMP_PATTERN,
54
- description: "Observation time in local 'YYYY-MM-DD HH:MM' format.",
55
- }),
56
- content: Type.String({
57
- minLength: 1,
58
- description: "Single-line plain prose. No markdown, no tags, no embedded timestamp.",
59
- }),
60
- relevance: RelevanceSchema,
61
- sourceEntryIds: Type.Array(
62
- Type.String({ minLength: 1 }),
63
- {
64
- minItems: 1,
65
- description:
66
- "Exact source entry ids from the chunk that directly support this observation. " +
67
- "Use only ids shown in '[Source entry id: ...]' labels; never invent ids.",
68
- },
69
- ),
70
- }),
71
- { description: "Batch of new observations. May be empty only if the tool is not called at all." },
72
- ),
56
+ observations: Type.Array(
57
+ Type.Object({
58
+ timestamp: Type.String({
59
+ pattern: OBSERVATION_TIMESTAMP_PATTERN,
60
+ description: "Observation time in local 'YYYY-MM-DD HH:MM' format.",
61
+ }),
62
+ content: Type.String({
63
+ minLength: 1,
64
+ description:
65
+ "Single-line plain prose. No markdown, no tags, no embedded timestamp.",
66
+ }),
67
+ relevance: RelevanceSchema,
68
+ sourceEntryIds: Type.Array(Type.String({ minLength: 1 }), {
69
+ minItems: 1,
70
+ description:
71
+ "Exact source entry ids from the chunk that directly support this observation. " +
72
+ "Use only ids shown in '[Source entry id: ...]' labels; never invent ids.",
73
+ }),
74
+ }),
75
+ {
76
+ description:
77
+ "Batch of new observations. May be empty only if the tool is not called at all.",
78
+ },
79
+ ),
73
80
  });
74
81
 
75
82
  type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
76
83
 
77
84
  function joinOrEmpty(items: string[]): string {
78
- return items.length ? items.join("\n") : "(none yet)";
85
+ return items.length ? items.join("\n") : "(none yet)";
79
86
  }
80
87
 
81
88
  export function normalizeSourceEntryIds(
82
- sourceEntryIds: readonly string[] | undefined,
83
- allowedSourceEntryIds: readonly string[],
89
+ sourceEntryIds: readonly string[] | undefined,
90
+ allowedSourceEntryIds: readonly string[],
84
91
  ): string[] | undefined {
85
- if (!sourceEntryIds || sourceEntryIds.length === 0) return undefined;
86
- const allowedOrder = new Map<string, number>();
87
- for (let i = 0; i < allowedSourceEntryIds.length; i++) allowedOrder.set(allowedSourceEntryIds[i], i);
92
+ if (!sourceEntryIds || sourceEntryIds.length === 0) return undefined;
93
+ const allowedOrder = new Map<string, number>();
94
+ for (let i = 0; i < allowedSourceEntryIds.length; i++)
95
+ allowedOrder.set(allowedSourceEntryIds[i], i);
88
96
 
89
- // Filter out invalid/unknown IDs instead of rejecting the entire batch.
90
- // Matches the dropper's normalizeDropObservationIds pattern: one hallucinated
91
- // ID from the LLM should not discard valid observations.
92
- const seen = new Set<string>();
93
- const valid: string[] = [];
94
- for (const id of sourceEntryIds) {
95
- if (!allowedOrder.has(id)) continue;
96
- if (seen.has(id)) continue;
97
- seen.add(id);
98
- valid.push(id);
99
- }
100
- if (valid.length === 0) return undefined;
101
- return valid.sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
97
+ // Filter out invalid/unknown IDs instead of rejecting the entire batch.
98
+ // Matches the dropper's normalizeDropObservationIds pattern: one hallucinated
99
+ // ID from the LLM should not discard valid observations.
100
+ const seen = new Set<string>();
101
+ const valid: string[] = [];
102
+ for (const id of sourceEntryIds) {
103
+ if (!allowedOrder.has(id)) continue;
104
+ if (seen.has(id)) continue;
105
+ seen.add(id);
106
+ valid.push(id);
107
+ }
108
+ if (valid.length === 0) return undefined;
109
+ return valid.sort(
110
+ (a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0),
111
+ );
102
112
  }
103
113
 
104
114
  /** Result returned by runObserver when no observations are recorded. */
105
115
  export type ObserverEmptyReason =
106
- | { kind: "no_new_content" } // model ran but nothing worth recording
107
- | { kind: "tool_not_called" } // model didn't call record_observations at all
108
- | { kind: "all_rejected"; count: number } // tool called but all sourceEntryIds invalid
109
- | { kind: "all_duplicates"; count: number } // tool called but all already seen
110
- | { kind: "empty_array"; count: number }; // tool called but returned empty observations array
116
+ | { kind: "no_new_content" } // model ran but nothing worth recording
117
+ | { kind: "tool_not_called" } // model didn't call record_observations at all
118
+ | { kind: "all_rejected"; count: number } // tool called but all sourceEntryIds invalid
119
+ | { kind: "all_duplicates"; count: number } // tool called but all already seen
120
+ | { kind: "empty_array"; count: number }; // tool called but returned empty observations array
111
121
 
112
122
  export interface ObserverResult {
113
- observations: Observation[] | undefined;
114
- emptyReason?: ObserverEmptyReason;
123
+ observations: Observation[] | undefined;
124
+ emptyReason?: ObserverEmptyReason;
115
125
  }
116
126
 
117
- export async function runObserver(args: RunObserverArgs): Promise<ObserverResult> {
118
- const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
119
- const conversation = chunk.trim();
120
- if (!conversation) return { observations: undefined };
127
+ export async function runObserver(
128
+ args: RunObserverArgs,
129
+ ): Promise<ObserverResult> {
130
+ const {
131
+ model,
132
+ apiKey,
133
+ headers,
134
+ priorReflections,
135
+ priorObservations,
136
+ chunk,
137
+ allowedSourceEntryIds,
138
+ signal,
139
+ } = args;
140
+ const conversation = chunk.trim();
141
+ if (!conversation) return { observations: undefined };
121
142
 
122
- const accumulated = new Map<string, Observation>();
123
- let toolCalled = false;
124
- let totalAdded = 0;
125
- let totalDuplicates = 0;
126
- let totalRejected = 0;
127
- let totalProposed = 0;
143
+ const accumulated = new Map<string, Observation>();
144
+ let toolCalled = false;
145
+ let totalAdded = 0;
146
+ let totalDuplicates = 0;
147
+ let totalRejected = 0;
148
+ let totalProposed = 0;
128
149
 
129
- const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
130
- name: "record_observations",
131
- label: "Record observations",
132
- description:
133
- "Record a batch of new observations distilled from the conversation chunk. " +
134
- "Call this multiple times as you work through the chunk. Stop calling when coverage is complete, " +
135
- "then emit a short plain-text confirmation to end the run.",
136
- parameters: RecordObservationsSchema,
137
- execute: async (_id, params: RecordObservationsArgs) => {
138
- toolCalled = true;
139
- let added = 0;
140
- let duplicates = 0;
141
- let rejected = 0;
142
- for (const obs of params.observations) {
143
- totalProposed++;
144
- const sourceEntryIds = normalizeSourceEntryIds(obs.sourceEntryIds, allowedSourceEntryIds);
145
- if (!sourceEntryIds) {
146
- rejected++;
147
- continue;
148
- }
149
- const content = truncateRecordContent(obs.content);
150
- const id = hashId(content);
151
- if (accumulated.has(id)) {
152
- duplicates++;
153
- continue;
154
- }
155
- accumulated.set(id, {
156
- id,
157
- content,
158
- timestamp: obs.timestamp,
159
- relevance: obs.relevance as Relevance,
160
- sourceEntryIds,
161
- tokenCount: estimateStringTokens(content),
162
- });
163
- added++;
164
- }
165
- totalAdded += added;
166
- totalDuplicates += duplicates;
167
- totalRejected += rejected;
168
- const rejectedPart = rejected > 0
169
- ? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
170
- : "";
171
- const ack =
172
- `Recorded ${added} new observation${added === 1 ? "" : "s"} ` +
173
- (duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).` : ".") +
174
- rejectedPart +
175
- ` Total so far this run: ${accumulated.size}. ` +
176
- `Continue if the chunk still has uncovered content; otherwise stop calling the tool and emit a short plain-text confirmation.`;
177
- return { content: [{ type: "text", text: ack }], details: { added, duplicates, rejected, total: accumulated.size } };
178
- },
179
- };
150
+ const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
151
+ name: "record_observations",
152
+ label: "Record observations",
153
+ description:
154
+ "Record a batch of new observations distilled from the conversation chunk. " +
155
+ "Call this multiple times as you work through the chunk. Stop calling when coverage is complete, " +
156
+ "then emit a short plain-text confirmation to end the run.",
157
+ parameters: RecordObservationsSchema,
158
+ execute: async (_id, params: RecordObservationsArgs) => {
159
+ toolCalled = true;
160
+ let added = 0;
161
+ let duplicates = 0;
162
+ let rejected = 0;
163
+ for (const obs of params.observations) {
164
+ totalProposed++;
165
+ const sourceEntryIds = normalizeSourceEntryIds(
166
+ obs.sourceEntryIds,
167
+ allowedSourceEntryIds,
168
+ );
169
+ if (!sourceEntryIds) {
170
+ rejected++;
171
+ continue;
172
+ }
173
+ const content = truncateRecordContent(obs.content);
174
+ const id = hashId(content);
175
+ if (accumulated.has(id)) {
176
+ duplicates++;
177
+ continue;
178
+ }
179
+ accumulated.set(id, {
180
+ id,
181
+ content,
182
+ timestamp: obs.timestamp,
183
+ relevance: obs.relevance as Relevance,
184
+ sourceEntryIds,
185
+ tokenCount: estimateStringTokens(content),
186
+ });
187
+ added++;
188
+ }
189
+ totalAdded += added;
190
+ totalDuplicates += duplicates;
191
+ totalRejected += rejected;
192
+ const rejectedPart =
193
+ rejected > 0
194
+ ? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
195
+ : "";
196
+ const ack =
197
+ `Recorded ${added} new observation${added === 1 ? "" : "s"} ` +
198
+ (duplicates > 0
199
+ ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).`
200
+ : ".") +
201
+ rejectedPart +
202
+ ` Total so far this run: ${accumulated.size}. ` +
203
+ `Continue if the chunk still has uncovered content; otherwise stop calling the tool and emit a short plain-text confirmation.`;
204
+ return {
205
+ content: [{ type: "text", text: ack }],
206
+ details: { added, duplicates, rejected, total: accumulated.size },
207
+ };
208
+ },
209
+ };
180
210
 
181
- const now = nowTimestamp();
182
- const userText = `Current local time: ${now}
211
+ const now = nowTimestamp();
212
+ const userText = `Current local time: ${now}
183
213
 
184
214
  CURRENT REFLECTIONS:
185
215
  ${joinOrEmpty(priorReflections)}
@@ -192,83 +222,89 @@ Compress the following new conversation chunk into observations by calling recor
192
222
  NEW CONVERSATION CHUNK:
193
223
  ${conversation}`;
194
224
 
195
- const prompts: Message[] = [
196
- {
197
- role: "user",
198
- content: [{ type: "text", text: userText }],
199
- timestamp: Date.now(),
200
- },
201
- ];
225
+ const prompts: Message[] = [
226
+ {
227
+ role: "user",
228
+ content: [{ type: "text", text: userText }],
229
+ timestamp: Date.now(),
230
+ },
231
+ ];
202
232
 
203
- const context: AgentContext = {
204
- systemPrompt: OBSERVER_SYSTEM,
205
- messages: [],
206
- tools: [recordObservations as AgentTool<any>],
207
- };
233
+ const context: AgentContext = {
234
+ systemPrompt: OBSERVER_SYSTEM,
235
+ messages: [],
236
+ tools: [recordObservations as AgentTool<any>],
237
+ };
208
238
 
209
- const reasoning = (model as { reasoning?: unknown }).reasoning;
210
- const thinkingLevel = args.thinkingLevel ?? "low";
211
- const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
212
- let turnCount = 0;
213
- const config: AgentLoopConfig = {
214
- model,
215
- apiKey,
216
- headers,
217
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
218
- convertToLlm: (msgs) => msgs as Message[],
219
- toolExecution: "sequential",
220
- ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
221
- ...(effectiveMaxTurns !== undefined
222
- ? {
223
- shouldStopAfterTurn: () => {
224
- turnCount++;
225
- return turnCount >= effectiveMaxTurns;
226
- },
227
- }
228
- : {}),
229
- };
239
+ const reasoning = (model as { reasoning?: unknown }).reasoning;
240
+ const thinkingLevel = args.thinkingLevel ?? "low";
241
+ const effectiveMaxTurns =
242
+ args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
243
+ let turnCount = 0;
244
+ const config: AgentLoopConfig = {
245
+ model,
246
+ apiKey,
247
+ headers,
248
+ maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
249
+ convertToLlm: (msgs) => msgs as Message[],
250
+ toolExecution: "sequential",
251
+ ...(reasoning && thinkingLevel !== "off"
252
+ ? { reasoning: thinkingLevel }
253
+ : {}),
254
+ ...(effectiveMaxTurns !== undefined
255
+ ? {
256
+ shouldStopAfterTurn: () => {
257
+ turnCount++;
258
+ return turnCount >= effectiveMaxTurns;
259
+ },
260
+ }
261
+ : {}),
262
+ };
230
263
 
231
- const loop = args.agentLoop ?? agentLoop;
232
- // ── Bridge stream function ──
233
- // Consolidation agents run via jiti (moduleCache: false) which creates a separate
234
- // pi-ai instance whose apiProviderRegistry lacks custom providers registered by
235
- // other extensions (e.g., claude-bridge). The bridge looks up streamSimple functions
236
- const bridgeStreamFn = createBridgeStreamFn(streamSimple);
237
- const streamFn = args.streamFn ?? bridgeStreamFn;
238
- const stream = loop(prompts, context, config, signal, streamFn);
239
- let agentError: string | undefined;
240
- for await (const event of stream) {
241
- // Drain events; the tool's execute already collects records.
242
- if (event.type === "agent_end") {
243
- const msgs = ((event as any).messages || []) as Array<{ stopReason?: string; errorMessage?: string }>;
244
- const lastMsg = msgs[msgs.length - 1];
245
- if (lastMsg?.stopReason === "error") {
246
- agentError = lastMsg.errorMessage ?? "Unknown API error";
247
- }
248
- }
249
- }
250
- await stream.result();
264
+ const loop = args.agentLoop ?? agentLoop;
265
+ // ── Bridge stream function ──
266
+ // Consolidation agents run via jiti (moduleCache: false) which creates a separate
267
+ // pi-ai instance whose apiProviderRegistry lacks custom providers registered by
268
+ // other extensions (e.g., claude-bridge). The bridge looks up streamSimple functions
269
+ const bridgeStreamFn = createBridgeStreamFn(streamSimple);
270
+ const streamFn = args.streamFn ?? bridgeStreamFn;
271
+ const stream = loop(prompts, context, config, signal, streamFn);
272
+ let agentError: string | undefined;
273
+ for await (const event of stream) {
274
+ // Drain events; the tool's execute already collects records.
275
+ if (event.type === "agent_end") {
276
+ const msgs = ((event as any).messages || []) as Array<{
277
+ stopReason?: string;
278
+ errorMessage?: string;
279
+ }>;
280
+ const lastMsg = msgs[msgs.length - 1];
281
+ if (lastMsg?.stopReason === "error") {
282
+ agentError = lastMsg.errorMessage ?? "Unknown API error";
283
+ }
284
+ }
285
+ }
286
+ await stream.result();
251
287
 
252
- if (agentError && accumulated.size === 0) {
253
- throw new Error(`Observer API error: ${agentError}`);
254
- }
288
+ if (agentError && accumulated.size === 0) {
289
+ throw new Error(`Observer API error: ${agentError}`);
290
+ }
255
291
 
256
- if (accumulated.size === 0) {
257
- // Determine why no observations were recorded
258
- let emptyReason: ObserverEmptyReason;
259
- if (!toolCalled) {
260
- emptyReason = { kind: "tool_not_called" };
261
- } else if (totalRejected > 0 && totalAdded === 0) {
262
- emptyReason = { kind: "all_rejected", count: totalRejected };
263
- } else if (totalDuplicates > 0 && totalAdded === 0) {
264
- emptyReason = { kind: "all_duplicates", count: totalDuplicates };
265
- } else if (totalProposed === 0) {
266
- emptyReason = { kind: "empty_array", count: 0 };
267
- } else {
268
- emptyReason = { kind: "no_new_content" };
269
- }
270
- return { observations: undefined, emptyReason };
271
- }
292
+ if (accumulated.size === 0) {
293
+ // Determine why no observations were recorded
294
+ let emptyReason: ObserverEmptyReason;
295
+ if (!toolCalled) {
296
+ emptyReason = { kind: "tool_not_called" };
297
+ } else if (totalRejected > 0 && totalAdded === 0) {
298
+ emptyReason = { kind: "all_rejected", count: totalRejected };
299
+ } else if (totalDuplicates > 0 && totalAdded === 0) {
300
+ emptyReason = { kind: "all_duplicates", count: totalDuplicates };
301
+ } else if (totalProposed === 0) {
302
+ emptyReason = { kind: "empty_array", count: 0 };
303
+ } else {
304
+ emptyReason = { kind: "no_new_content" };
305
+ }
306
+ return { observations: undefined, emptyReason };
307
+ }
272
308
 
273
- return { observations: Array.from(accumulated.values()) };
309
+ return { observations: Array.from(accumulated.values()) };
274
310
  }