pi-observational-memory 2.4.1 → 2.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.
package/src/compaction.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@mariozechner/pi-agent-core";
2
- import { Type, type Message, type Model } from "@mariozechner/pi-ai";
3
- import type { Static } from "@sinclair/typebox";
1
+ import { agentLoop, type AgentContext, type AgentEvent, type AgentLoopConfig, type AgentTool } from "@mariozechner/pi-agent-core";
2
+ import { Type, type Message, type Model, type ModelThinkingLevel } from "@mariozechner/pi-ai";
3
+ import type { Static } from "typebox";
4
+ import { debugLog, isDebugLogEnabled } from "./debug-log.js";
4
5
  import { hashId } from "./ids.js";
6
+ import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "./model-budget.js";
5
7
  import { observationsToPromptLines } from "./observer.js";
6
8
  import { buildPrunerPassGuidance, buildReflectorPassGuidance, CONTEXT_USAGE_INSTRUCTIONS, PRUNER_SYSTEM, REFLECTOR_SYSTEM } from "./prompts.js";
7
9
  import { truncateRecordContent } from "./serialize.js";
@@ -9,12 +11,12 @@ import { estimateStringTokens } from "./tokens.js";
9
11
  import { reflectionContent, reflectionToPromptLine } from "./types.js";
10
12
  import type { MemoryReflection, ObservationRecord, ReflectionRecord } from "./types.js";
11
13
 
12
- const REFLECTOR_MAX_PASSES = 3;
13
- const PRUNER_MAX_PASSES = 5;
14
+ export const REFLECTOR_MAX_PASSES = 2;
15
+ export const PRUNER_MAX_PASSES = 2;
14
16
  const PRUNER_TARGET_RATIO = 0.8;
15
17
 
16
- function observationPoolTokens(observations: ObservationRecord[]): number {
17
- return observations.reduce((sum, o) => sum + estimateStringTokens(o.content), 0);
18
+ export function observationPoolTokens(observations: ObservationRecord[]): number {
19
+ return estimateStringTokens(observationsToPromptLines(observations).join("\n"));
18
20
  }
19
21
 
20
22
  interface LlmArgs {
@@ -23,6 +25,9 @@ interface LlmArgs {
23
25
  headers?: Record<string, string>;
24
26
  signal?: AbortSignal;
25
27
  agentLoop?: typeof agentLoop;
28
+ onEvent?: (event: import("@mariozechner/pi-agent-core").AgentEvent) => void;
29
+ maxTurns?: number;
30
+ thinkingLevel?: ModelThinkingLevel;
26
31
  }
27
32
 
28
33
  function joinReflectionsOrEmpty(items: MemoryReflection[]): string {
@@ -33,8 +38,123 @@ function joinObservationsOrEmpty(items: ObservationRecord[]): string {
33
38
  return items.length ? observationsToPromptLines(items).join("\n") : "(none yet)";
34
39
  }
35
40
 
41
+ function summarizeContentTypes(content: unknown): string | string[] {
42
+ if (!Array.isArray(content)) return typeof content;
43
+ return content.map((block) => {
44
+ if (block && typeof block === "object" && "type" in block) {
45
+ const type = (block as { type?: unknown }).type;
46
+ return typeof type === "string" ? type : typeof type;
47
+ }
48
+ return typeof block;
49
+ });
50
+ }
51
+
52
+ function summarizeAgentMessage(message: unknown): Record<string, unknown> {
53
+ if (!message || typeof message !== "object") return { type: typeof message };
54
+ const record = message as Record<string, unknown>;
55
+ const summary: Record<string, unknown> = {
56
+ role: typeof record.role === "string" ? record.role : "unknown",
57
+ };
58
+ if ("api" in record && typeof record.api === "string") summary.api = record.api;
59
+ if ("provider" in record && typeof record.provider === "string") summary.provider = record.provider;
60
+ if ("model" in record && typeof record.model === "string") summary.model = record.model;
61
+ if ("stopReason" in record && typeof record.stopReason === "string") summary.stopReason = record.stopReason;
62
+ if ("errorMessage" in record && typeof record.errorMessage === "string") summary.errorMessage = record.errorMessage;
63
+ if ("toolName" in record && typeof record.toolName === "string") summary.toolName = record.toolName;
64
+ if ("isError" in record && typeof record.isError === "boolean") summary.isError = record.isError;
65
+ if ("content" in record) summary.contentTypes = summarizeContentTypes(record.content);
66
+ return summary;
67
+ }
68
+
69
+ function finalAssistantSummary(messages: unknown): Record<string, unknown> | undefined {
70
+ if (!Array.isArray(messages)) return undefined;
71
+ for (let i = messages.length - 1; i >= 0; i--) {
72
+ const message = messages[i];
73
+ if (message && typeof message === "object" && (message as { role?: unknown }).role === "assistant") {
74
+ return summarizeAgentMessage(message);
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ function summarizeToolResults(toolResults: unknown): Record<string, unknown>[] {
81
+ if (!Array.isArray(toolResults)) return [];
82
+ return toolResults.map(summarizeAgentMessage);
83
+ }
84
+
85
+ function summarizeObjectKeys(value: unknown): string[] | undefined {
86
+ return value && typeof value === "object" ? Object.keys(value as Record<string, unknown>).sort() : undefined;
87
+ }
88
+
89
+ function logAgentLoopEvent(scope: "reflector" | "pruner", pass: number, event: AgentEvent): void {
90
+ switch (event.type) {
91
+ case "agent_start":
92
+ case "turn_start":
93
+ debugLog(`${scope}.agent_loop.${event.type}`, { pass });
94
+ return;
95
+ case "message_start":
96
+ case "message_end":
97
+ debugLog(`${scope}.agent_loop.${event.type}`, { pass, message: summarizeAgentMessage(event.message) });
98
+ return;
99
+ case "message_update":
100
+ debugLog(`${scope}.agent_loop.message_update`, {
101
+ pass,
102
+ message: summarizeAgentMessage(event.message),
103
+ assistantEventType: event.assistantMessageEvent.type,
104
+ });
105
+ return;
106
+ case "turn_end":
107
+ debugLog(`${scope}.agent_loop.turn_end`, {
108
+ pass,
109
+ message: summarizeAgentMessage(event.message),
110
+ toolResultCount: event.toolResults.length,
111
+ toolResults: summarizeToolResults(event.toolResults),
112
+ });
113
+ return;
114
+ case "agent_end":
115
+ debugLog(`${scope}.agent_loop.agent_end`, {
116
+ pass,
117
+ messageCount: event.messages.length,
118
+ finalAssistant: finalAssistantSummary(event.messages),
119
+ });
120
+ return;
121
+ case "tool_execution_start":
122
+ debugLog(`${scope}.agent_loop.tool_execution_start`, {
123
+ pass,
124
+ toolCallId: event.toolCallId,
125
+ toolName: event.toolName,
126
+ argsKeys: summarizeObjectKeys(event.args),
127
+ });
128
+ return;
129
+ case "tool_execution_update":
130
+ debugLog(`${scope}.agent_loop.tool_execution_update`, {
131
+ pass,
132
+ toolCallId: event.toolCallId,
133
+ toolName: event.toolName,
134
+ argsKeys: summarizeObjectKeys(event.args),
135
+ partialResultKeys: summarizeObjectKeys(event.partialResult),
136
+ });
137
+ return;
138
+ case "tool_execution_end":
139
+ debugLog(`${scope}.agent_loop.tool_execution_end`, {
140
+ pass,
141
+ toolCallId: event.toolCallId,
142
+ toolName: event.toolName,
143
+ isError: event.isError,
144
+ resultKeys: summarizeObjectKeys(event.result),
145
+ });
146
+ return;
147
+ }
148
+ }
149
+
36
150
  export type ObservationCoverageTag = "uncited" | "cited" | "reinforced";
37
151
 
152
+ export interface CoverageTagCounts {
153
+ uncited: number;
154
+ cited: number;
155
+ reinforced: number;
156
+ }
157
+
38
158
  export function deriveObservationCoverageTags(
39
159
  reflections: MemoryReflection[],
40
160
  observations: ObservationRecord[],
@@ -57,6 +177,18 @@ export function deriveObservationCoverageTags(
57
177
  return tags;
58
178
  }
59
179
 
180
+ export function coverageTagCounts(
181
+ reflections: MemoryReflection[],
182
+ observations: ObservationRecord[],
183
+ ): CoverageTagCounts {
184
+ const tags = deriveObservationCoverageTags(reflections, observations);
185
+ const counts: CoverageTagCounts = { uncited: 0, cited: 0, reinforced: 0 };
186
+ for (const observation of observations) {
187
+ counts[tags.get(observation.id) ?? "uncited"]++;
188
+ }
189
+ return counts;
190
+ }
191
+
60
192
  export function renderObservationsForPrunerPrompt(
61
193
  observations: ObservationRecord[],
62
194
  coverageTags: ReadonlyMap<string, ObservationCoverageTag>,
@@ -120,13 +252,13 @@ const RecordReflectionsSchema = Type.Object({
120
252
  supportingObservationIds: Type.Array(
121
253
  Type.String({
122
254
  pattern: "^[a-f0-9]{12}$",
123
- description: "Exact observation id from the current-observations list that supports this reflection.",
255
+ description: "Exact observation id from the current-observations list whose durable meaning is captured by this reflection.",
124
256
  }),
125
257
  {
126
258
  minItems: 1,
127
259
  description:
128
- "Smallest exact set of current observation ids that directly support this reflection. " +
129
- "Use only ids shown in the current observations list; never invent ids.",
260
+ "Current observation ids whose durable meaning is captured by this reflection and can be treated as covered active-memory detail. " +
261
+ "Do not include observations whose unique exact detail or current task state is not captured. Use only ids shown in the current observations list; never invent ids.",
130
262
  },
131
263
  ),
132
264
  }),
@@ -179,6 +311,83 @@ export interface ApplyReflectionProposalsResult {
179
311
  unsupported: number;
180
312
  }
181
313
 
314
+ export interface ReflectorPassStats {
315
+ pass: number;
316
+ toolCalls: number;
317
+ accepted: number;
318
+ added: number;
319
+ merged: number;
320
+ promoted: number;
321
+ duplicates: number;
322
+ unsupported: number;
323
+ failed: boolean;
324
+ }
325
+
326
+ export interface ReflectorStats {
327
+ passes: ReflectorPassStats[];
328
+ toolCalls: number;
329
+ accepted: number;
330
+ added: number;
331
+ merged: number;
332
+ promoted: number;
333
+ duplicates: number;
334
+ unsupported: number;
335
+ failedPass?: number;
336
+ }
337
+
338
+ export interface ReflectorResult {
339
+ reflections: MemoryReflection[];
340
+ stats: ReflectorStats;
341
+ }
342
+
343
+ function emptyReflectorPassStats(pass: number): ReflectorPassStats {
344
+ return {
345
+ pass,
346
+ toolCalls: 0,
347
+ accepted: 0,
348
+ added: 0,
349
+ merged: 0,
350
+ promoted: 0,
351
+ duplicates: 0,
352
+ unsupported: 0,
353
+ failed: false,
354
+ };
355
+ }
356
+
357
+ function addReflectionProposalStats(target: ReflectorPassStats, result: ApplyReflectionProposalsResult): void {
358
+ target.toolCalls++;
359
+ target.accepted += result.accepted;
360
+ target.added += result.added;
361
+ target.merged += result.merged;
362
+ target.promoted += result.promoted;
363
+ target.duplicates += result.duplicates;
364
+ target.unsupported += result.unsupported;
365
+ }
366
+
367
+ function aggregateReflectorStats(passes: ReflectorPassStats[]): ReflectorStats {
368
+ const stats: ReflectorStats = {
369
+ passes,
370
+ toolCalls: 0,
371
+ accepted: 0,
372
+ added: 0,
373
+ merged: 0,
374
+ promoted: 0,
375
+ duplicates: 0,
376
+ unsupported: 0,
377
+ };
378
+ for (const pass of passes) {
379
+ stats.toolCalls += pass.toolCalls;
380
+ stats.accepted += pass.accepted;
381
+ stats.added += pass.added;
382
+ stats.merged += pass.merged;
383
+ stats.promoted += pass.promoted;
384
+ stats.duplicates += pass.duplicates;
385
+ stats.unsupported += pass.unsupported;
386
+ if (pass.failed && stats.failedPass === undefined) stats.failedPass = pass.pass;
387
+ }
388
+ return stats;
389
+ }
390
+
182
391
  function reflectorPassContext(pass: number): ReflectorPassContext {
183
392
  return {
184
393
  pass,
@@ -307,16 +516,26 @@ async function runReflectorPass(
307
516
  reflections: MemoryReflection[],
308
517
  observations: ObservationRecord[],
309
518
  passContext: ReflectorPassContext,
310
- ): Promise<{ reflections: MemoryReflection[]; failed: boolean }> {
519
+ ): Promise<{ reflections: MemoryReflection[]; stats: ReflectorPassStats }> {
311
520
  const allowedObservationIds = observations.map((o) => o.id);
312
521
  let currentReflections = reflections;
522
+ const stats = emptyReflectorPassStats(passContext.pass);
523
+ let consecutiveEmptyCalls = 0;
524
+ debugLog("reflector.pass.start", {
525
+ pass: passContext.pass,
526
+ maxPasses: passContext.maxPasses,
527
+ minSupportingObservationIds: passContext.minSupportingObservationIds,
528
+ reflectionCount: reflections.length,
529
+ observationCount: observations.length,
530
+ observationIds: isDebugLogEnabled() ? allowedObservationIds : undefined,
531
+ });
313
532
 
314
533
  const recordTool: AgentTool<typeof RecordReflectionsSchema> = {
315
534
  name: "record_reflections",
316
535
  label: "Record reflections",
317
536
  description:
318
- "Record a batch of reflections crystallized from the observation pool. " +
319
- "May be called multiple times. Stop calling when nothing more is stable enough to crystallize for this pass, " +
537
+ "Record a batch of reflections crystallized from the observation pool, with supporting ids for observations whose durable meaning is captured. " +
538
+ "May be called multiple times. Stop calling when nothing more is stable enough to crystallize or strengthen for this pass, " +
320
539
  "then emit a short plain-text confirmation.",
321
540
  parameters: RecordReflectionsSchema,
322
541
  execute: async (_id, params: RecordReflectionsArgs) => {
@@ -327,6 +546,12 @@ async function runReflectorPass(
327
546
  passContext,
328
547
  );
329
548
  currentReflections = result.reflections;
549
+ addReflectionProposalStats(stats, result);
550
+ if (result.accepted === 0) {
551
+ consecutiveEmptyCalls++;
552
+ } else {
553
+ consecutiveEmptyCalls = 0;
554
+ }
330
555
  const parts: string[] = [];
331
556
  parts.push(`Accepted ${result.accepted} reflection proposal${result.accepted === 1 ? "" : "s"}.`);
332
557
  if (result.added) parts.push(`${result.added} new.`);
@@ -339,6 +564,20 @@ async function runReflectorPass(
339
564
  );
340
565
  }
341
566
  parts.push("Call record_reflections again if more should be crystallized for this pass; otherwise stop and emit a short plain-text confirmation.");
567
+ debugLog("reflector.tool_call", {
568
+ pass: passContext.pass,
569
+ accepted: result.accepted,
570
+ added: result.added,
571
+ merged: result.merged,
572
+ promoted: result.promoted,
573
+ duplicates: result.duplicates,
574
+ unsupported: result.unsupported,
575
+ currentReflectionCount: currentReflections.length,
576
+ proposals: params.reflections.map((reflection: ReflectionProposal) => ({
577
+ content: reflection.content,
578
+ supportingObservationIds: reflection.supportingObservationIds,
579
+ })),
580
+ });
342
581
  return {
343
582
  content: [{ type: "text", text: parts.join(" ") }],
344
583
  details: result,
@@ -356,7 +595,7 @@ ${joinObservationsOrEmpty(observations)}
356
595
  REFLECTOR PASS GUIDANCE:
357
596
  ${passGuidance}
358
597
 
359
- Crystallize long-lived reflections from the full observation pool for this pass. Call record_reflections with batches of reflection proposals, each with the exact supporting observation ids. You may call the tool multiple times as you reason through the pool. To strengthen or promote an existing reflection, repeat the exact existing reflection content with valid supporting observation ids. Do not lightly reword existing reflections. When done, stop calling the tool and emit a short plain-text confirmation.`;
598
+ Crystallize long-lived reflections from the full observation pool for this pass. Call record_reflections with batches of reflection proposals, each with supporting observation ids whose durable meaning is captured by that reflection. You may call the tool multiple times as you reason through the pool. To strengthen or promote an existing reflection, repeat the exact existing reflection content with additional valid supporting observation ids. Do not lightly reword existing reflections. Do not attach observations whose unique exact detail or current task state is not captured with equivalent fidelity. When done, stop calling the tool and emit a short plain-text confirmation.`;
360
599
 
361
600
  const prompts: Message[] = [
362
601
  {
@@ -373,50 +612,114 @@ Crystallize long-lived reflections from the full observation pool for this pass.
373
612
  };
374
613
 
375
614
  const reasoning = (args.model as { reasoning?: unknown }).reasoning;
615
+ const thinkingLevel = args.thinkingLevel ?? "low";
616
+ const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
617
+ let turnCount = 0;
618
+
376
619
  const config: AgentLoopConfig = {
377
620
  model: args.model as any,
378
621
  apiKey: args.apiKey,
379
622
  headers: args.headers,
380
- maxTokens: 4096,
623
+ maxTokens: boundedMaxTokens(args.model, AGENT_LOOP_MAX_TOKENS),
381
624
  convertToLlm: (msgs) => msgs as Message[],
382
625
  toolExecution: "sequential",
383
- ...(reasoning ? { reasoning: "high" as const } : {}),
626
+ ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
627
+ shouldStopAfterTurn: () => {
628
+ turnCount++;
629
+ if (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns) return true;
630
+ if (consecutiveEmptyCalls >= 2) return true;
631
+ return false;
632
+ },
384
633
  };
385
634
 
635
+ let firstEventSeen = false;
386
636
  try {
637
+ debugLog("reflector.agent_loop.before_call", { pass: passContext.pass });
387
638
  const loop = args.agentLoop ?? agentLoop;
388
639
  const stream = loop(prompts, context, config, args.signal);
389
- for await (const _event of stream) {
390
- // Drain events; the tool's execute already updates reflections.
640
+ debugLog("reflector.agent_loop.stream_created", { pass: passContext.pass });
641
+ for await (const event of stream) {
642
+ if (!firstEventSeen) {
643
+ firstEventSeen = true;
644
+ debugLog("reflector.agent_loop.first_event", { pass: passContext.pass, type: event.type });
645
+ }
646
+ logAgentLoopEvent("reflector", passContext.pass, event);
647
+ args.onEvent?.(event);
391
648
  }
392
649
  await stream.result();
393
- } catch {
394
- return { reflections: currentReflections, failed: true };
650
+ debugLog("reflector.pass.result", { pass: passContext.pass, stats, reflectionCount: currentReflections.length });
651
+ } catch (error) {
652
+ stats.failed = true;
653
+ debugLog("reflector.agent_loop.error", {
654
+ pass: passContext.pass,
655
+ firstEventSeen,
656
+ errorMessage: error instanceof Error ? error.message : String(error),
657
+ });
658
+ return { reflections: currentReflections, stats };
395
659
  }
396
660
 
397
- return { reflections: currentReflections, failed: false };
661
+ return { reflections: currentReflections, stats };
398
662
  }
399
663
 
400
664
  export async function runReflector(
401
665
  args: LlmArgs,
402
666
  reflections: MemoryReflection[],
403
667
  observations: ObservationRecord[],
404
- ): Promise<MemoryReflection[]> {
668
+ onPassStart?: (pass: number, maxPasses: number) => void,
669
+ ): Promise<ReflectorResult> {
670
+ debugLog("reflector.start", {
671
+ reflectionCount: reflections.length,
672
+ observationCount: observations.length,
673
+ observations: isDebugLogEnabled()
674
+ ? observations.map((observation) => ({
675
+ id: observation.id,
676
+ timestamp: observation.timestamp,
677
+ relevance: observation.relevance,
678
+ content: observation.content,
679
+ sourceEntryIds: observation.sourceEntryIds,
680
+ }))
681
+ : undefined,
682
+ });
405
683
  let currentReflections = reflections;
684
+ const passes: ReflectorPassStats[] = [];
406
685
 
407
686
  for (let pass = 1; pass <= REFLECTOR_MAX_PASSES; pass++) {
687
+ onPassStart?.(pass, REFLECTOR_MAX_PASSES);
408
688
  const result = await runReflectorPass(args, currentReflections, observations, reflectorPassContext(pass));
409
689
  currentReflections = result.reflections;
410
- if (result.failed) break;
690
+ passes.push(result.stats);
691
+ if (result.stats.failed) break;
411
692
  }
412
693
 
413
- return currentReflections;
694
+ const result = { reflections: currentReflections, stats: aggregateReflectorStats(passes) };
695
+ debugLog("reflector.result", {
696
+ stats: result.stats,
697
+ reflectionCount: result.reflections.length,
698
+ reflections: isDebugLogEnabled()
699
+ ? result.reflections.map((reflection) => typeof reflection === "string" ? { legacyString: true, content: reflection } : reflection)
700
+ : undefined,
701
+ });
702
+ return result;
703
+ }
704
+
705
+ export type PrunerStopReason = "empty" | "under_target" | "fell_back" | "zero_drops" | "max_passes";
706
+
707
+ export interface PrunerPassStats {
708
+ pass: number;
709
+ poolTokens: number;
710
+ targetTokens: number;
711
+ deltaTokens: number;
712
+ dropped: number;
713
+ remaining: number;
714
+ fellBack: boolean;
414
715
  }
415
716
 
416
717
  export interface PrunerResult {
417
718
  observations: ObservationRecord[];
418
719
  droppedIds: string[];
419
720
  fellBack: boolean;
721
+ passes: PrunerPassStats[];
722
+ stopReason: PrunerStopReason;
420
723
  }
421
724
 
422
725
  const DropObservationsSchema = Type.Object({
@@ -460,6 +763,17 @@ async function runPrunerPass(
460
763
  ): Promise<PrunerPassResult> {
461
764
  const idSet = new Set(observations.map((o) => o.id));
462
765
  const dropped = new Set<string>();
766
+ let consecutiveEmptyCalls = 0;
767
+ debugLog("pruner.pass.start", {
768
+ pass: passContext.pass,
769
+ maxPasses: passContext.maxPasses,
770
+ poolTokens: passContext.poolTokens,
771
+ targetTokens: passContext.targetTokens,
772
+ deltaTokens: passContext.deltaTokens,
773
+ observationCount: observations.length,
774
+ reflectionCount: reflections.length,
775
+ observationIds: isDebugLogEnabled() ? observations.map((observation) => observation.id) : undefined,
776
+ });
463
777
 
464
778
  const dropTool: AgentTool<typeof DropObservationsSchema> = {
465
779
  name: "drop_observations",
@@ -484,6 +798,11 @@ async function runPrunerPass(
484
798
  dropped.add(id);
485
799
  valid.push(id);
486
800
  }
801
+ if (valid.length === 0) {
802
+ consecutiveEmptyCalls++;
803
+ } else {
804
+ consecutiveEmptyCalls = 0;
805
+ }
487
806
  const remaining = idSet.size - dropped.size;
488
807
  const parts: string[] = [];
489
808
  parts.push(`Dropped ${valid.length} observation${valid.length === 1 ? "" : "s"}.`);
@@ -491,6 +810,15 @@ async function runPrunerPass(
491
810
  if (already.length) parts.push(`Already dropped: ${already.join(", ")}.`);
492
811
  parts.push(`Remaining kept: ${remaining} of ${idSet.size}.`);
493
812
  parts.push("Call drop_observations again if more should be removed; otherwise stop and emit a short plain-text confirmation.");
813
+ debugLog("pruner.tool_call", {
814
+ pass: passContext.pass,
815
+ requestedIds: params.ids,
816
+ dropped: valid,
817
+ unknown,
818
+ already,
819
+ remaining,
820
+ reason: params.reason,
821
+ });
494
822
  return {
495
823
  content: [{ type: "text", text: parts.join(" ") }],
496
824
  details: { dropped: valid, unknown, already, remaining },
@@ -532,29 +860,68 @@ Decide which observations to remove from the kept set. Call drop_observations wi
532
860
  };
533
861
 
534
862
  const reasoning = (args.model as { reasoning?: unknown }).reasoning;
863
+ const thinkingLevel = args.thinkingLevel ?? "low";
864
+ const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
865
+ let turnCount = 0;
866
+
535
867
  const config: AgentLoopConfig = {
536
868
  model: args.model as any,
537
869
  apiKey: args.apiKey,
538
870
  headers: args.headers,
539
- maxTokens: 2048,
871
+ maxTokens: boundedMaxTokens(args.model, AGENT_LOOP_MAX_TOKENS),
540
872
  convertToLlm: (msgs) => msgs as Message[],
541
873
  toolExecution: "sequential",
542
- ...(reasoning ? { reasoning: "high" as const } : {}),
874
+ ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
875
+ shouldStopAfterTurn: () => {
876
+ turnCount++;
877
+ if (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns) return true;
878
+ if (consecutiveEmptyCalls >= 2) return true;
879
+ return false;
880
+ },
543
881
  };
544
882
 
883
+ let agentLoopCalled = false;
884
+ let streamCreated = false;
885
+ let firstEventSeen = false;
545
886
  try {
887
+ debugLog("pruner.agent_loop.before_call", { pass: passContext.pass });
546
888
  const loop = args.agentLoop ?? agentLoop;
889
+ agentLoopCalled = true;
547
890
  const stream = loop(prompts, context, config, args.signal);
548
- for await (const _event of stream) {
549
- // Drain events; the tool's execute already records drops.
891
+ streamCreated = true;
892
+ debugLog("pruner.agent_loop.stream_created", { pass: passContext.pass });
893
+ for await (const event of stream) {
894
+ if (!firstEventSeen) {
895
+ firstEventSeen = true;
896
+ debugLog("pruner.agent_loop.first_event", { pass: passContext.pass, type: event.type });
897
+ }
898
+ logAgentLoopEvent("pruner", passContext.pass, event);
899
+ args.onEvent?.(event);
550
900
  }
551
901
  await stream.result();
552
- } catch {
902
+ } catch (error) {
903
+ debugLog("pruner.agent_loop.error", {
904
+ pass: passContext.pass,
905
+ agentLoopCalled,
906
+ streamCreated,
907
+ firstEventSeen,
908
+ errorMessage: error instanceof Error ? error.message : String(error),
909
+ });
553
910
  return { kept: observations, droppedIds: [], fellBack: true };
554
911
  }
555
912
 
556
913
  const kept = observations.filter((o) => !dropped.has(o.id));
557
- return { kept, droppedIds: Array.from(dropped), fellBack: false };
914
+ const droppedIds = Array.from(dropped);
915
+ debugLog("pruner.pass.result", {
916
+ pass: passContext.pass,
917
+ droppedIds,
918
+ dropped: droppedIds.length,
919
+ remaining: kept.length,
920
+ agentLoopCalled,
921
+ streamCreated,
922
+ firstEventSeen,
923
+ });
924
+ return { kept, droppedIds, fellBack: false };
558
925
  }
559
926
 
560
927
  export async function runPruner(
@@ -562,21 +929,44 @@ export async function runPruner(
562
929
  reflections: MemoryReflection[],
563
930
  observations: ObservationRecord[],
564
931
  budgetTokens: number,
932
+ onPassStart?: (pass: number, maxPasses: number) => void,
565
933
  ): Promise<PrunerResult> {
934
+ debugLog("pruner.start", {
935
+ reflectionCount: reflections.length,
936
+ observationCount: observations.length,
937
+ budgetTokens,
938
+ });
566
939
  if (observations.length === 0) {
567
- return { observations: [], droppedIds: [], fellBack: false };
940
+ const result: PrunerResult = { observations: [], droppedIds: [], fellBack: false, passes: [], stopReason: "empty" };
941
+ debugLog("pruner.result", {
942
+ stopReason: result.stopReason,
943
+ fellBack: result.fellBack,
944
+ droppedIds: result.droppedIds,
945
+ dropped: result.droppedIds.length,
946
+ passes: result.passes,
947
+ finalObservationCount: result.observations.length,
948
+ });
949
+ return result;
568
950
  }
569
951
 
570
952
  const target = Math.max(1, Math.floor(budgetTokens * PRUNER_TARGET_RATIO));
571
- let pool = observations;
572
953
  const coverageTags = deriveObservationCoverageTags(reflections, observations);
954
+ let pool = observations;
955
+
573
956
  const allDropped: string[] = [];
957
+ const passes: PrunerPassStats[] = [];
574
958
  let fellBack = false;
959
+ let stopReason: PrunerStopReason | undefined;
575
960
 
576
961
  for (let pass = 1; pass <= PRUNER_MAX_PASSES; pass++) {
577
962
  const poolTokens = observationPoolTokens(pool);
578
- if (poolTokens <= target) break;
963
+ if (poolTokens <= target) {
964
+ stopReason = "under_target";
965
+ debugLog("pruner.under_target", { pass, poolTokens, targetTokens: target, observationCount: pool.length });
966
+ break;
967
+ }
579
968
 
969
+ onPassStart?.(pass, PRUNER_MAX_PASSES);
580
970
  const deltaTokens = poolTokens - target;
581
971
  const result = await runPrunerPass(args, reflections, pool, {
582
972
  poolTokens,
@@ -586,18 +976,41 @@ export async function runPruner(
586
976
  maxPasses: PRUNER_MAX_PASSES,
587
977
  coverageTags,
588
978
  });
979
+ passes.push({
980
+ pass,
981
+ poolTokens,
982
+ targetTokens: target,
983
+ deltaTokens,
984
+ dropped: result.droppedIds.length,
985
+ remaining: result.kept.length,
986
+ fellBack: result.fellBack,
987
+ });
589
988
 
590
989
  if (result.fellBack) {
591
990
  fellBack = true;
991
+ stopReason = "fell_back";
992
+ break;
993
+ }
994
+ if (result.droppedIds.length === 0) {
995
+ stopReason = "zero_drops";
592
996
  break;
593
997
  }
594
- if (result.droppedIds.length === 0) break;
595
998
 
596
999
  pool = result.kept;
597
1000
  allDropped.push(...result.droppedIds);
598
1001
  }
599
1002
 
600
- return { observations: pool, droppedIds: allDropped, fellBack };
1003
+ stopReason ??= observationPoolTokens(pool) <= target ? "under_target" : "max_passes";
1004
+ const result = { observations: pool, droppedIds: allDropped, fellBack, passes, stopReason };
1005
+ debugLog("pruner.result", {
1006
+ stopReason: result.stopReason,
1007
+ fellBack: result.fellBack,
1008
+ droppedIds: result.droppedIds,
1009
+ dropped: result.droppedIds.length,
1010
+ passes: result.passes,
1011
+ finalObservationCount: result.observations.length,
1012
+ });
1013
+ return result;
601
1014
  }
602
1015
 
603
1016
  export function renderSummary(reflections: MemoryReflection[], observations: ObservationRecord[]): string {