@matthewfl/pi-contemplator 0.0.10 → 0.1.1
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/README.md +12 -12
- package/package.json +6 -6
- package/src/agents/contemplator/agent.ts +325 -91
- package/src/agents/contemplator/prompts.ts +6 -6
- package/src/agents/observer/agent.ts +96 -39
- package/src/agents/observer/prompts.ts +19 -10
- package/src/agents/reviewer/agent.ts +24 -4
- package/src/agents/reviewer/prompts.ts +1 -1
- package/src/agents/reviewer/tools.ts +24 -9
- package/src/agents/stream-errors.ts +1 -1
- package/src/agents/summarizer/agent.ts +597 -0
- package/src/agents/summarizer/prompts.ts +46 -0
- package/src/agents/summarizer/sampling.ts +80 -0
- package/src/commands/contemplator-view.ts +22 -1
- package/src/commands/settings.ts +95 -70
- package/src/commands/status.ts +60 -36
- package/src/commands/summarizer-view.ts +58 -0
- package/src/commands/view.ts +22 -10
- package/src/config.ts +30 -37
- package/src/hooks/compaction-hook.ts +32 -17
- package/src/hooks/compaction-resume.ts +4 -4
- package/src/hooks/compaction-trigger.ts +33 -11
- package/src/hooks/consolidation-trigger.ts +245 -215
- package/src/memory-citations.ts +37 -0
- package/src/required-tool-choice.ts +28 -0
- package/src/runtime.ts +115 -32
- package/src/session-ledger/fold.ts +82 -53
- package/src/session-ledger/index.ts +1 -0
- package/src/session-ledger/pools.ts +77 -0
- package/src/session-ledger/progress.ts +8 -19
- package/src/session-ledger/projection.ts +45 -177
- package/src/session-ledger/recall.ts +129 -127
- package/src/session-ledger/render-summary.ts +20 -19
- package/src/session-ledger/search.ts +99 -115
- package/src/session-ledger/types.ts +103 -77
- package/src/tools/compact-context.ts +1 -1
- package/src/tools/recall-observation.ts +99 -459
- package/src/tools/search-memories.ts +31 -72
- package/src/agents/dropper/agent.ts +0 -291
- package/src/agents/dropper/coverage.ts +0 -128
- package/src/agents/dropper/pool.ts +0 -67
- package/src/agents/dropper/prompts.ts +0 -48
- package/src/agents/reflector/agent.ts +0 -213
- package/src/agents/reflector/prompts.ts +0 -81
|
@@ -6,7 +6,7 @@ export function buildContemplatorSystemPrompt(
|
|
|
6
6
|
|
|
7
7
|
Neither you nor the primary agent should be assumed to know the correct solution. You are jointly exploring a problem space from different perspectives. The primary agent interacts with the actual environment and carries out the work. You maintain a longer-term view of the reasoning, evidence, assumptions, alternatives, unresolved questions${reviewerEnabled ? ", and recurring structural patterns" : ""} that emerge over time.
|
|
8
8
|
|
|
9
|
-
You receive incremental observations and
|
|
9
|
+
You receive incremental observations and cited summaries produced by other agent loops. Some memories summarize user messages. Pay extra attention to memories about the user’s intent, priorities, constraints, corrections, and desired outcome.
|
|
10
10
|
|
|
11
11
|
You see only the memory ledger, not the primary agent’s live activity. Your understanding may be incomplete or slightly stale. Do not infer inactivity, failure, or lack of progress from missing recent results. A result may simply not have reached memory yet.
|
|
12
12
|
|
|
@@ -42,7 +42,7 @@ Do not treat the absence of a recent test result, tool result, or implementation
|
|
|
42
42
|
|
|
43
43
|
Do not encourage speculation merely because several possibilities can be imagined. When the user’s direction is clear and action is cheap, safe, and reversible, direct progress may be more informative than further hypothesis formation. Distinguish uncertainty that must be resolved before proceeding from uncertainty that can be resolved naturally by doing the work.
|
|
44
44
|
|
|
45
|
-
Your other high-value responsibility is reality-checking. Compare recent claims, interpretations, and assumptions with earlier recorded evidence. When a recent claim conflicts with an earlier observation,
|
|
45
|
+
Your other high-value responsibility is reality-checking. Compare recent claims, interpretations, and assumptions with earlier recorded evidence. When a recent claim conflicts with an earlier observation, summary, user-intent memory, or previously supported conclusion, make the contradiction visible and cite the memories on both sides.
|
|
46
46
|
|
|
47
47
|
Also look for opportunities to help the primary agent:
|
|
48
48
|
|
|
@@ -72,7 +72,7 @@ A useful probe may ask:
|
|
|
72
72
|
|
|
73
73
|
Questions should be grounded in the actual memories rather than generic problem-solving advice.
|
|
74
74
|
|
|
75
|
-
You have access to search_memories for finding older ${reviewerEnabled ? `observations,
|
|
75
|
+
You have access to search_memories for finding older ${reviewerEnabled ? `observations, summaries, and durable review results` : `observations and summaries`} on the current branch. Use it when the updates provided do not contain enough context, searching with distinctive terms rather than broad questions. Results include memory identifiers that you can cite in a probe${reviewerEnabled ? ` or review request` : ``}.
|
|
76
76
|
|
|
77
77
|
You also have recall for recovering exact source context behind a specific memory identifier. Use it when a result is important but compressed.
|
|
78
78
|
|
|
@@ -185,8 +185,8 @@ A good review request should:
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
${reviewerEnabled ?
|
|
188
|
-
`Your interventions are asynchronous.
|
|
189
|
-
`Your probes are delivered asynchronously.
|
|
188
|
+
`Your interventions are asynchronous. You must finish every update by calling one final-action tool: send_probe, request_review, or no_intervention. This requirement is bookkeeping, not pressure to intervene. no_intervention takes no arguments and is the preferred default whenever no specific, grounded, materially useful intervention is clearly warranted or usefulness is uncertain. Never send a probe merely to satisfy the tool requirement. A valid final-action call ends your turn immediately, so do not plan to add narration afterward. If an intervention tool reports an invalid memory citation, correct it and call the appropriate tool again; the later call replaces the earlier one.` :
|
|
189
|
+
`Your probes are delivered asynchronously. You must finish every update by calling one final-action tool: send_probe or no_intervention. This requirement is bookkeeping, not pressure to intervene. no_intervention takes no arguments and is the preferred default whenever no specific, grounded, materially useful probe is clearly warranted or usefulness is uncertain. Never send a probe merely to satisfy the tool requirement. A valid final-action call ends your turn immediately, so do not plan to add narration afterward. If send_probe reports an invalid memory citation, correct it and call send_probe again; the later call replaces the earlier one.`}
|
|
190
190
|
|
|
191
191
|
Prioritize:
|
|
192
192
|
|
|
@@ -203,7 +203,7 @@ ${
|
|
|
203
203
|
: ``
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
If no specific, grounded, materially useful intervention exists,
|
|
206
|
+
If no specific, grounded, materially useful intervention clearly exists, or if its usefulness is uncertain, call the argument-free no_intervention tool.`
|
|
207
207
|
.replace(/\n{3,}/g, "\n\n")
|
|
208
208
|
.trim();
|
|
209
209
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
4
|
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
@@ -8,7 +8,7 @@ import { logAgentStreamError } from "../stream-errors.js";
|
|
|
8
8
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
9
9
|
import { OBSERVER_SYSTEM } from "./prompts.js";
|
|
10
10
|
import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
|
|
11
|
-
import type { Observation, Relevance } from "../../session-ledger/index.js";
|
|
11
|
+
import type { Observation, Relevance, Retention } from "../../session-ledger/index.js";
|
|
12
12
|
import { estimateStringTokens } from "../../tokens.js";
|
|
13
13
|
import type { LlmUsageInput } from "../../runtime.js";
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ interface RunObserverArgs {
|
|
|
16
16
|
model: Model<any>;
|
|
17
17
|
apiKey: string;
|
|
18
18
|
headers?: Record<string, string>;
|
|
19
|
-
|
|
19
|
+
priorSummaries?: string[];
|
|
20
20
|
priorObservations: string[];
|
|
21
21
|
chunk: string;
|
|
22
22
|
allowedSourceEntryIds: string[];
|
|
@@ -34,6 +34,12 @@ const RelevanceSchema = Type.Union([
|
|
|
34
34
|
Type.Literal("critical"),
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
|
+
const RetentionSchema = Type.Union([
|
|
38
|
+
Type.Literal("ephemeral"),
|
|
39
|
+
Type.Literal("contextual"),
|
|
40
|
+
Type.Literal("durable"),
|
|
41
|
+
]);
|
|
42
|
+
|
|
37
43
|
export const OBSERVATION_TIMESTAMP_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$";
|
|
38
44
|
|
|
39
45
|
const RecordObservationsSchema = Type.Object({
|
|
@@ -48,6 +54,7 @@ const RecordObservationsSchema = Type.Object({
|
|
|
48
54
|
description: "Single-line plain prose. No markdown, no tags, no embedded timestamp.",
|
|
49
55
|
}),
|
|
50
56
|
relevance: RelevanceSchema,
|
|
57
|
+
retention: Type.Optional(RetentionSchema),
|
|
51
58
|
sourceEntryIds: Type.Array(
|
|
52
59
|
Type.String({ minLength: 1 }),
|
|
53
60
|
{
|
|
@@ -64,6 +71,16 @@ const RecordObservationsSchema = Type.Object({
|
|
|
64
71
|
|
|
65
72
|
type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
|
|
66
73
|
|
|
74
|
+
/** A terminal provider/agent-loop failure that must not advance observation coverage. */
|
|
75
|
+
export class ObserverStreamError extends Error {
|
|
76
|
+
readonly stopReason: string;
|
|
77
|
+
constructor(stopReason: string, errorMessage?: string) {
|
|
78
|
+
super(`observer stream ended with stopReason "${stopReason}"${errorMessage ? `: ${errorMessage}` : ""}`);
|
|
79
|
+
this.name = "ObserverStreamError";
|
|
80
|
+
this.stopReason = stopReason;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
function joinOrEmpty(items: string[]): string {
|
|
68
85
|
return items.length ? items.join("\n") : "(none yet)";
|
|
69
86
|
}
|
|
@@ -86,19 +103,20 @@ export function normalizeSourceEntryIds(
|
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
|
|
89
|
-
const { model, apiKey, headers,
|
|
106
|
+
const { model, apiKey, headers, priorSummaries = [], priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
90
107
|
const conversation = chunk.trim();
|
|
91
108
|
if (!conversation) return undefined;
|
|
92
109
|
|
|
93
110
|
const accumulated = new Map<string, Observation>();
|
|
111
|
+
let rejectedTotal = 0;
|
|
112
|
+
let doneCalled = false;
|
|
94
113
|
|
|
95
114
|
const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
|
|
96
115
|
name: "record_observations",
|
|
97
116
|
label: "Record observations",
|
|
98
117
|
description:
|
|
99
118
|
"Record a batch of new observations distilled from the conversation chunk. " +
|
|
100
|
-
"Call this multiple times as you work through the chunk
|
|
101
|
-
"then emit a short plain-text confirmation to end the run.",
|
|
119
|
+
"Call this multiple times as you work through the chunk, then call done alone when coverage is complete.",
|
|
102
120
|
parameters: RecordObservationsSchema,
|
|
103
121
|
execute: async (_id, params: RecordObservationsArgs) => {
|
|
104
122
|
let added = 0;
|
|
@@ -121,11 +139,13 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
|
|
|
121
139
|
content,
|
|
122
140
|
timestamp: obs.timestamp,
|
|
123
141
|
relevance: obs.relevance as Relevance,
|
|
142
|
+
retention: (obs.retention ?? "contextual") as Retention,
|
|
124
143
|
sourceEntryIds,
|
|
125
144
|
tokenCount: estimateStringTokens(content),
|
|
126
145
|
});
|
|
127
146
|
added++;
|
|
128
147
|
}
|
|
148
|
+
rejectedTotal += rejected;
|
|
129
149
|
const rejectedPart = rejected > 0
|
|
130
150
|
? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
|
|
131
151
|
: "";
|
|
@@ -134,74 +154,111 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
|
|
|
134
154
|
(duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).` : ".") +
|
|
135
155
|
rejectedPart +
|
|
136
156
|
` Total so far this run: ${accumulated.size}. ` +
|
|
137
|
-
`Continue if the chunk still has uncovered content; otherwise
|
|
157
|
+
`Continue if the chunk still has uncovered content; otherwise call done alone.`;
|
|
138
158
|
return { content: [{ type: "text", text: ack }], details: { added, duplicates, rejected, total: accumulated.size } };
|
|
139
159
|
},
|
|
140
160
|
};
|
|
141
161
|
|
|
162
|
+
const doneTool: AgentTool<any> = {
|
|
163
|
+
name: "done",
|
|
164
|
+
label: "Done",
|
|
165
|
+
description: "Confirm that the entire provided conversation chunk has been inspected and all useful new observations have been recorded. Call alone, including when there is nothing new to record.",
|
|
166
|
+
parameters: Type.Object({}),
|
|
167
|
+
execute: async () => {
|
|
168
|
+
doneCalled = true;
|
|
169
|
+
return { content: [{ type: "text", text: "Observer coverage confirmed." }], details: {}, terminate: true };
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
142
173
|
const now = nowTimestamp();
|
|
143
174
|
const userText = `Current local time: ${now}
|
|
144
175
|
|
|
145
|
-
CURRENT
|
|
146
|
-
${joinOrEmpty(
|
|
176
|
+
CURRENT SUMMARIES:
|
|
177
|
+
${joinOrEmpty(priorSummaries)}
|
|
147
178
|
|
|
148
179
|
CURRENT OBSERVATIONS:
|
|
149
180
|
${joinOrEmpty(priorObservations)}
|
|
150
181
|
|
|
151
|
-
Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current
|
|
182
|
+
Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. When the chunk is fully covered, call done alone. If the chunk contains no useful new information, call done without calling record_observations.
|
|
152
183
|
|
|
153
184
|
NEW CONVERSATION CHUNK:
|
|
154
185
|
${conversation}`;
|
|
155
186
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
timestamp: Date.now(),
|
|
161
|
-
},
|
|
162
|
-
];
|
|
163
|
-
|
|
164
|
-
const context: AgentContext = {
|
|
165
|
-
systemPrompt: OBSERVER_SYSTEM,
|
|
166
|
-
messages: [],
|
|
167
|
-
tools: [recordObservations as AgentTool<any>],
|
|
187
|
+
const initialPrompt: Message = {
|
|
188
|
+
role: "user",
|
|
189
|
+
content: [{ type: "text", text: userText }],
|
|
190
|
+
timestamp: Date.now(),
|
|
168
191
|
};
|
|
169
192
|
|
|
170
193
|
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
171
194
|
const thinkingLevel = args.thinkingLevel ?? "low";
|
|
172
195
|
const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
|
|
173
196
|
let turnCount = 0;
|
|
174
|
-
const
|
|
197
|
+
const baseConfig: AgentLoopConfig = {
|
|
175
198
|
model,
|
|
176
199
|
apiKey,
|
|
177
200
|
headers,
|
|
178
201
|
maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
|
|
179
202
|
convertToLlm: (msgs) => msgs as Message[],
|
|
180
203
|
toolExecution: "sequential",
|
|
204
|
+
shouldStopAfterTurn: () => {
|
|
205
|
+
turnCount++;
|
|
206
|
+
return doneCalled || (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns);
|
|
207
|
+
},
|
|
181
208
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
182
|
-
...(effectiveMaxTurns !== undefined
|
|
183
|
-
? {
|
|
184
|
-
shouldStopAfterTurn: () => {
|
|
185
|
-
turnCount++;
|
|
186
|
-
return turnCount >= effectiveMaxTurns;
|
|
187
|
-
},
|
|
188
|
-
}
|
|
189
|
-
: {}),
|
|
190
209
|
};
|
|
191
210
|
|
|
192
211
|
const loop = args.agentLoop ?? agentLoop;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
212
|
+
const history: AgentMessage[] = [];
|
|
213
|
+
let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
|
|
214
|
+
const runInvocation = async (prompt: Message): Promise<void> => {
|
|
215
|
+
const context: AgentContext = {
|
|
216
|
+
systemPrompt: OBSERVER_SYSTEM,
|
|
217
|
+
messages: history.slice(),
|
|
218
|
+
tools: [recordObservations as AgentTool<any>, doneTool],
|
|
219
|
+
};
|
|
220
|
+
const stream = loop([prompt], context, baseConfig, signal, streamSimple);
|
|
221
|
+
for await (const event of stream) {
|
|
222
|
+
logAgentStreamError("observer", event);
|
|
223
|
+
const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
|
|
224
|
+
if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
|
|
225
|
+
terminalFailure = { stopReason: message.stopReason!, errorMessage: message.errorMessage };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const result = await stream.result();
|
|
229
|
+
if (!Array.isArray(result)) return;
|
|
230
|
+
history.push(...result);
|
|
200
231
|
for (const message of result) {
|
|
201
|
-
if (message.role === "assistant" &&
|
|
232
|
+
if (message.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
|
|
233
|
+
terminalFailure = { stopReason: message.stopReason, errorMessage: message.errorMessage };
|
|
234
|
+
}
|
|
235
|
+
if (args.recordUsage && message.role === "assistant" && message.usage) args.recordUsage(message.usage);
|
|
202
236
|
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
await runInvocation(initialPrompt);
|
|
240
|
+
if (accumulated.size === 0 && !doneCalled && !terminalFailure && rejectedTotal === 0) {
|
|
241
|
+
const reminder: Message = {
|
|
242
|
+
role: "user",
|
|
243
|
+
content: [{ type: "text", text: `You stopped without confirming coverage. Observations recorded so far: ${accumulated.size}. If the chunk is fully covered, call done now. Otherwise call record_observations for anything still missing, then call done.` }],
|
|
244
|
+
timestamp: Date.now(),
|
|
245
|
+
};
|
|
246
|
+
await runInvocation(reminder);
|
|
203
247
|
}
|
|
204
248
|
|
|
249
|
+
// Accepted observations remain useful even if the model neglected the final
|
|
250
|
+
// confirmation. Zero-observation coverage is advanced only by an explicit
|
|
251
|
+
// done call; failures, truncation, malformed records, and repeated prose do
|
|
252
|
+
// not silently discard the source chunk.
|
|
253
|
+
if (accumulated.size === 0 && terminalFailure) {
|
|
254
|
+
throw new ObserverStreamError(terminalFailure.stopReason, terminalFailure.errorMessage);
|
|
255
|
+
}
|
|
256
|
+
if (accumulated.size === 0 && rejectedTotal > 0) {
|
|
257
|
+
throw new ObserverStreamError("invalid_observations", `${rejectedTotal} proposed observation${rejectedTotal === 1 ? " was" : "s were"} rejected`);
|
|
258
|
+
}
|
|
259
|
+
if (accumulated.size === 0 && !doneCalled) {
|
|
260
|
+
throw new ObserverStreamError("incomplete", "observer stopped twice without recording observations or calling done");
|
|
261
|
+
}
|
|
205
262
|
if (accumulated.size === 0) return undefined;
|
|
206
263
|
return Array.from(accumulated.values());
|
|
207
264
|
}
|
|
@@ -2,29 +2,30 @@ export const OBSERVER_SYSTEM = `You are the observation agent for a coding assis
|
|
|
2
2
|
|
|
3
3
|
These records are the ONLY information the assistant will have about past interactions once the raw conversation is compacted out of context. Anything you do not capture here will be forgotten. Anything you distort here will be remembered wrong. Take this seriously.
|
|
4
4
|
|
|
5
|
-
Your job is to compress a chunk of recent conversation into timestamped
|
|
5
|
+
Your job is to compress a chunk of recent conversation into timestamped observations with separate relevance and retention judgments by calling the record_observations tool. The observations you emit — together with cited summaries compressed from them — are the assistant's ONLY memory of this session after the raw conversation falls out of context.
|
|
6
6
|
|
|
7
7
|
You receive:
|
|
8
|
-
- Current
|
|
8
|
+
- Current summaries (compressed memories already recorded).
|
|
9
9
|
- Current observations (already-recorded observations, each shown as "[id] YYYY-MM-DD HH:MM [relevance] content").
|
|
10
10
|
- A new chunk of conversation with source entry labels and inline message timestamps. Each source block starts with "[Source entry id: <id>]" followed by content formatted as "[User @ YYYY-MM-DD HH:MM]:", "[Assistant @ ...]:", "[Tool result for <name> @ ...]:", custom messages, or branch summaries.
|
|
11
11
|
- A current local time fallback for observations that have no obvious message timestamp.
|
|
12
12
|
|
|
13
13
|
How you work:
|
|
14
|
-
1. Read
|
|
14
|
+
1. Read summaries and current observations so you know what is already captured.
|
|
15
15
|
2. Read the conversation chunk and identify what new information it contains.
|
|
16
16
|
3. Call record_observations with a batch covering part (or all) of the chunk.
|
|
17
17
|
4. Read the progress receipt. If content remains uncovered, call again. You may call the tool many times.
|
|
18
|
-
5. When the chunk is fully covered,
|
|
18
|
+
5. When the chunk is fully covered, call done alone. If there is no useful new information, call done without calling record_observations. Prose does not confirm coverage.
|
|
19
19
|
|
|
20
20
|
What to emit:
|
|
21
|
-
- Produce NEW observations for the new chunk only. Do not restate facts already present in
|
|
21
|
+
- Produce NEW observations for the new chunk only. Do not restate facts already present in summaries or current observations unless something has materially changed.
|
|
22
22
|
- Use the timestamp from the relevant conversation message. Fall back to current local time ONLY when no message timestamp applies.
|
|
23
23
|
- For every observation, include sourceEntryIds: the smallest exact set of "[Source entry id: ...]" ids that directly support the observation.
|
|
24
24
|
- Never invent source entry ids. Use only ids printed in the chunk. If an observation spans multiple turns or tool results, include every supporting source entry id.
|
|
25
|
+
- For every observation, choose retention independently from relevance. Recording the observation correctly comes first; never skip useful evidence because retention is uncertain.
|
|
25
26
|
- Observations with missing, empty, or invalid sourceEntryIds will be rejected and not recorded, so do not call record_observations until you can cite valid source ids.
|
|
26
27
|
- Group repeated similar tool calls into a single observation rather than one per call.
|
|
27
|
-
- Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case,
|
|
28
|
+
- Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, do not call record_observations and call done alone. Ignoring a chunk or replying in prose does not mark it covered.
|
|
28
29
|
|
|
29
30
|
Observation content rules:
|
|
30
31
|
|
|
@@ -57,7 +58,7 @@ Use precise action verbs. Replace vague verbs with ones that clarify the nature
|
|
|
57
58
|
Frame state changes as supersession so the old state is explicit.
|
|
58
59
|
BAD: User prefers React Query now.
|
|
59
60
|
GOOD: User will use React Query (switching from SWR).
|
|
60
|
-
Why this matters: without supersession framing,
|
|
61
|
+
Why this matters: without supersession framing, a later summary may preserve both the old and the new as equally valid preferences.
|
|
61
62
|
|
|
62
63
|
Mark concrete completions explicitly.
|
|
63
64
|
Use "completed:", "resolved:", "confirmed working", or similar phrasing so future runs know not to redo the work.
|
|
@@ -73,7 +74,7 @@ If a single message contains multiple independent facts, intents, or events, emi
|
|
|
73
74
|
GOOD: User started a new job. + User will move to a new apartment next week.
|
|
74
75
|
BAD: Assistant recommended Lucia, NextAuth, and Clerk for auth, and user chose Lucia.
|
|
75
76
|
GOOD: Assistant recommended auth libraries: Lucia (session-based, minimal), NextAuth (OAuth-heavy, Next-native), Clerk (hosted, paid). + User chose Lucia.
|
|
76
|
-
Why this matters: a future query like "which auth library did the user pick?" can match a single-fact observation cleanly; a compound observation hides the decision inside a recommendation list.
|
|
77
|
+
Why this matters: a future query like "which auth library did the user pick?" can match a single-fact observation cleanly; a compound observation hides the decision inside a recommendation list and makes safe summarization harder.
|
|
77
78
|
|
|
78
79
|
Group repeated similar tool calls into a single observation rather than one per call.
|
|
79
80
|
BAD: Agent viewed src/auth.ts. Agent viewed src/users.ts. Agent viewed src/routes.ts.
|
|
@@ -104,10 +105,18 @@ Relevance levels (pick one per observation; this field drives future dropping):
|
|
|
104
105
|
- critical: user assertions about identity, role, or persistent preferences; explicit corrections ("no, don't do X"); concrete completions that future runs MUST NOT redo. These are highest-resistance, load-bearing observations and require the strongest evidence before leaving active memory. Why this matters: if a "critical" item is lost, the assistant may redo finished work, contradict a correction, or misrepresent who the user is.
|
|
105
106
|
- high: non-trivial technical decisions, architectural direction, unresolved blockers, key constraints. Worth keeping across many compactions.
|
|
106
107
|
- medium: task-level context that helps within the current work but isn't durable. The default when you are unsure between medium and high.
|
|
107
|
-
- low: routine tool-call acks, repetitive status updates, content trivially re-derivable from recent messages. The
|
|
108
|
+
- low: routine tool-call acks, repetitive status updates, content trivially re-derivable from recent messages. The summarizer considers relevance together with retention, age, and later evidence.
|
|
108
109
|
|
|
109
110
|
Do NOT default to "critical" or "high". Most observations are medium or low. Reserve "critical" for things that would cause real damage if forgotten.
|
|
110
111
|
|
|
112
|
+
Retention horizons (pick one independently from relevance):
|
|
113
|
+
|
|
114
|
+
- ephemeral: likely useful for only the next few steps; intermediate attempts, routine outputs, temporary readings, and local status.
|
|
115
|
+
- contextual: useful while related work continues or may soon resume; exact errors, hypotheses, partial state, subsystem details, and unresolved local choices. Use this when uncertain.
|
|
116
|
+
- durable: likely useful across context changes; persistent preferences, constraints, corrections, decisions, rationale, reusable findings, and significant outcomes.
|
|
117
|
+
|
|
118
|
+
A critical exact blocker can be contextual; a medium stable preference can be durable. Retention is a hint for summarizer sampling and judgment, never an instruction for code to remove anything automatically. Notice factual transitions—replacement, abandonment, resolution, moving away from a subject, resuming it, or invalidating an old result—but record the transition as ordinary evidence rather than commands to the summarizer.
|
|
119
|
+
|
|
111
120
|
BAD: relevance=critical for "Agent ran tests and they passed."
|
|
112
121
|
GOOD: relevance=low for "Agent ran tests and they passed." (routine; captured by a completion observation if it matters)
|
|
113
122
|
|
|
@@ -116,4 +125,4 @@ Do NOT default to "critical" or "high". Most observations are medium or low. Res
|
|
|
116
125
|
|
|
117
126
|
Timestamp format: "YYYY-MM-DD HH:MM" (local time, 24-hour, to the minute). This goes in the timestamp field, not the content.
|
|
118
127
|
|
|
119
|
-
Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count.`;
|
|
128
|
+
Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count. Always finish by calling done alone.`;
|
|
@@ -4,6 +4,7 @@ import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
|
4
4
|
import { hashId } from "../../ids.js";
|
|
5
5
|
import { boundedMaxTokens, REVIEWER_TOTAL_TOKEN_LIMIT } from "../../model-budget.js";
|
|
6
6
|
import type { LlmUsageInput } from "../../runtime.js";
|
|
7
|
+
import { recallMemorySources } from "../../session-ledger/recall.js";
|
|
7
8
|
import type { Entry, ReviewResult, StructuralReviewRequest } from "../../session-ledger/types.js";
|
|
8
9
|
import { createRecallAgentTool } from "../../tools/recall-observation.js";
|
|
9
10
|
import { createSearchMemoriesAgentTool } from "../../tools/search-memories.js";
|
|
@@ -94,18 +95,30 @@ function budgetExhaustedResult(request: StructuralReviewRequest): ReviewResult {
|
|
|
94
95
|
|
|
95
96
|
export async function runStructuralReview(args: RunStructuralReviewArgs): Promise<ReviewResult | undefined> {
|
|
96
97
|
let terminal: ReviewTerminalResult | undefined;
|
|
97
|
-
|
|
98
|
+
let warnedTerminal: ReviewTerminalResult | undefined;
|
|
99
|
+
const acceptTerminal = (candidate: ReviewTerminalResult, missingReferenceIds: string[]) => {
|
|
98
100
|
if (terminal) throw new Error("A structural reviewer may make only one terminal tool call.");
|
|
101
|
+
const overwritten = warnedTerminal !== undefined;
|
|
102
|
+
if (missingReferenceIds.length > 0) {
|
|
103
|
+
warnedTerminal = candidate;
|
|
104
|
+
return { terminal: false, overwritten };
|
|
105
|
+
}
|
|
99
106
|
terminal = candidate;
|
|
107
|
+
warnedTerminal = undefined;
|
|
108
|
+
return { terminal: true, overwritten };
|
|
109
|
+
};
|
|
110
|
+
const referenceExists = (id: string): boolean => {
|
|
111
|
+
const branch = args.getBranch();
|
|
112
|
+
return branch.some((entry) => entry.id === id) || recallMemorySources(branch, id).status === "found";
|
|
100
113
|
};
|
|
101
114
|
const searchMemories = createSearchMemoriesAgentTool(args.getBranch);
|
|
102
115
|
const recall = createRecallAgentTool(args.getBranch);
|
|
103
116
|
const searchChatHistory = createSearchChatHistoryAgentTool(args.getBranch);
|
|
104
117
|
const readChatHistory = createReadChatHistoryAgentTool(args.getBranch);
|
|
105
118
|
const scopeTool = args.request.scope === "workflow"
|
|
106
|
-
? createWorkflowProposalTool(acceptTerminal)
|
|
107
|
-
: createSoftwareProposalTool(acceptTerminal);
|
|
108
|
-
const noProposal = createNoProposalTool(args.request.scope, acceptTerminal);
|
|
119
|
+
? createWorkflowProposalTool(acceptTerminal, referenceExists)
|
|
120
|
+
: createSoftwareProposalTool(acceptTerminal, referenceExists);
|
|
121
|
+
const noProposal = createNoProposalTool(args.request.scope, acceptTerminal, referenceExists);
|
|
109
122
|
const tools = [
|
|
110
123
|
searchMemories as AgentTool<any>,
|
|
111
124
|
recall as AgentTool<any>,
|
|
@@ -179,6 +192,13 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
|
|
|
179
192
|
// assistant usage that the real stream wrapper did not already account for.
|
|
180
193
|
const reportedOutputTokens = assistantOutputTokens(assistants);
|
|
181
194
|
totalOutputTokens += Math.max(0, reportedOutputTokens - streamedOutputTokens);
|
|
195
|
+
// A warned terminal call deliberately leaves shouldStopAfterTurn false so
|
|
196
|
+
// the reviewer can search/recall and replace it. If the reviewer instead
|
|
197
|
+
// ends its turn, honor the staged outcome exactly as the warning promised.
|
|
198
|
+
if (!terminal && warnedTerminal) {
|
|
199
|
+
terminal = warnedTerminal;
|
|
200
|
+
warnedTerminal = undefined;
|
|
201
|
+
}
|
|
182
202
|
return totalOutputTokens - iterationStartTokens;
|
|
183
203
|
};
|
|
184
204
|
|
|
@@ -28,7 +28,7 @@ First recall every memory cited in the request. Then search for:
|
|
|
28
28
|
- existing proposals, tools, abstractions, scripts, representations, or workflows that may already address it;
|
|
29
29
|
- evidence showing whether the pattern is isolated, temporary, or recurring.
|
|
30
30
|
|
|
31
|
-
Observations and
|
|
31
|
+
Observations and summaries are compressed. When they do not preserve enough detail to evaluate the suspected pattern, use search_chat_history to search the primary agent's recorded chat with a regular expression. The search is deterministic, scans newest entries first, and returns each match separately. It does not interpret concepts for you. Form concrete patterns from the review concern and use regular expression wild cards around a term when the returned match should include nearby text. Use read_chat_history with a returned entry id when you need the complete entry or preceding and following messages.
|
|
32
32
|
|
|
33
33
|
Do not search chat history indiscriminately or treat a missing regex match as proof that an event did not occur. Search for specific evidence that can support, weaken, or contextualize the concern.
|
|
34
34
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import { Type } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { Static, TSchema } from "typebox";
|
|
4
|
+
import { memoryReferenceIds } from "../../memory-citations.js";
|
|
4
5
|
import type { ReviewNoProposal, SoftwareReviewProposal, WorkflowReviewProposal } from "../../session-ledger/types.js";
|
|
5
6
|
|
|
6
7
|
const prose = (description: string) => Type.String({ minLength: 1, description });
|
|
@@ -39,19 +40,26 @@ export type WorkflowProposalArgs = Static<typeof WorkflowProposalSchema>;
|
|
|
39
40
|
export type SoftwareProposalArgs = Static<typeof SoftwareProposalSchema>;
|
|
40
41
|
export type NoProposalArgs = Static<typeof NoProposalSchema>;
|
|
41
42
|
export type ReviewTerminalResult = Omit<WorkflowReviewProposal, "id" | "version" | "reviewRequestId" | "createdAt" | "requestedBy"> | Omit<SoftwareReviewProposal, "id" | "version" | "reviewRequestId" | "createdAt" | "requestedBy"> | Omit<ReviewNoProposal, "id" | "version" | "reviewRequestId" | "createdAt" | "requestedBy">;
|
|
43
|
+
export type TerminalWriteResult = { terminal: boolean; overwritten: boolean };
|
|
44
|
+
export type ReviewTerminalWriter = (result: ReviewTerminalResult, missingReferenceIds: string[]) => TerminalWriteResult;
|
|
42
45
|
|
|
43
46
|
function trimOptional(value: string | undefined): string | undefined {
|
|
44
47
|
const normalized = value?.trim();
|
|
45
48
|
return normalized || undefined;
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
function citedIds(params: Record<string, unknown>): string[] {
|
|
52
|
+
return memoryReferenceIds(Object.values(params).filter((value): value is string => typeof value === "string").join("\n"));
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
function terminalTool<T extends TSchema>(
|
|
49
56
|
name: string,
|
|
50
57
|
label: string,
|
|
51
58
|
description: string,
|
|
52
59
|
parameters: T,
|
|
53
60
|
build: (params: Static<T>) => ReviewTerminalResult,
|
|
54
|
-
onTerminal:
|
|
61
|
+
onTerminal: ReviewTerminalWriter,
|
|
62
|
+
referenceExists: (id: string) => boolean,
|
|
55
63
|
): AgentTool<T> {
|
|
56
64
|
return {
|
|
57
65
|
name,
|
|
@@ -59,26 +67,33 @@ function terminalTool<T extends TSchema>(
|
|
|
59
67
|
description,
|
|
60
68
|
parameters,
|
|
61
69
|
execute: async (_id, params) => {
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
const missingReferenceIds = citedIds(params as Record<string, unknown>).filter((id) => !referenceExists(id));
|
|
71
|
+
const write = onTerminal(build(params), missingReferenceIds);
|
|
72
|
+
if (missingReferenceIds.length === 0) {
|
|
73
|
+
return { content: [{ type: "text", text: "Terminal review outcome recorded." }], details: { terminal: true, overwritten: write.overwritten, missingReferenceIds } };
|
|
74
|
+
}
|
|
75
|
+
const warnings = missingReferenceIds.map((id) => `WARNING: memory or primary-chat entry ${id} not found; use search_memories and recall, or search_chat_history and read_chat_history, to find the correct reference.`);
|
|
76
|
+
if (write.overwritten) warnings.push("WARNING: overwriting the prior review outcome; only the latest outcome will be delivered.");
|
|
77
|
+
warnings.push(`You can call ${name} again to replace this review, or end your turn and it will be delivered as-is.`);
|
|
78
|
+
return { content: [{ type: "text", text: warnings.join("\n") }], details: { terminal: false, replaceable: true, overwritten: write.overwritten, missingReferenceIds } };
|
|
64
79
|
},
|
|
65
80
|
};
|
|
66
81
|
}
|
|
67
82
|
|
|
68
|
-
export function createWorkflowProposalTool(onTerminal: (
|
|
83
|
+
export function createWorkflowProposalTool(onTerminal: ReviewTerminalWriter, referenceExists: (id: string) => boolean = () => true): AgentTool<typeof WorkflowProposalSchema> {
|
|
69
84
|
return terminalTool("submit_workflow_proposal", "Submit workflow proposal", "Record the one durable workflow proposal for this review.", WorkflowProposalSchema, (params: WorkflowProposalArgs) => ({
|
|
70
85
|
outcome: "proposal", proposalKind: "workflow", scope: "workflow", title: params.title.trim(), summary: params.summary.trim(), evidence: params.evidence.trim(), inefficiency: params.inefficiency.trim(), conceptualDesign: params.conceptual_design.trim(), inputs: trimOptional(params.inputs), outputs: trimOptional(params.outputs), integration: trimOptional(params.integration), expectedEffect: params.expected_effect.trim(), uncertainties: params.uncertainties.trim(),
|
|
71
|
-
}), onTerminal);
|
|
86
|
+
}), onTerminal, referenceExists);
|
|
72
87
|
}
|
|
73
88
|
|
|
74
|
-
export function createSoftwareProposalTool(onTerminal: (
|
|
89
|
+
export function createSoftwareProposalTool(onTerminal: ReviewTerminalWriter, referenceExists: (id: string) => boolean = () => true): AgentTool<typeof SoftwareProposalSchema> {
|
|
75
90
|
return terminalTool("submit_software_proposal", "Submit software proposal", "Record the one durable software design proposal for this review.", SoftwareProposalSchema, (params: SoftwareProposalArgs) => ({
|
|
76
91
|
outcome: "proposal", proposalKind: "software", scope: "software", title: params.title.trim(), summary: params.summary.trim(), evidence: params.evidence.trim(), structuralIssue: params.structural_issue.trim(), conceptualDesign: params.conceptual_design.trim(), preservedBehavior: params.preserved_behavior.trim(), expectedEffect: params.expected_effect.trim(), uncertainties: params.uncertainties.trim(),
|
|
77
|
-
}), onTerminal);
|
|
92
|
+
}), onTerminal, referenceExists);
|
|
78
93
|
}
|
|
79
94
|
|
|
80
|
-
export function createNoProposalTool(scope: "workflow" | "software", onTerminal: (
|
|
95
|
+
export function createNoProposalTool(scope: "workflow" | "software", onTerminal: ReviewTerminalWriter, referenceExists: (id: string) => boolean = () => true): AgentTool<typeof NoProposalSchema> {
|
|
81
96
|
return terminalTool("review_concluded_no_proposal", "Conclude no proposal", "Record that this review found no durable proposal justified.", NoProposalSchema, (params: NoProposalArgs) => ({
|
|
82
97
|
outcome: "no_proposal", scope, reason: params.reason.trim(), evidenceReviewed: params.evidence_reviewed.trim(), reconsiderIf: trimOptional(params.reconsider_if),
|
|
83
|
-
}), onTerminal);
|
|
98
|
+
}), onTerminal, referenceExists);
|
|
84
99
|
}
|
|
@@ -10,7 +10,7 @@ import { debugLog } from "../debug-log.js";
|
|
|
10
10
|
* "the model chose not to call the tool", which hides the real cause
|
|
11
11
|
* (rate limits, oversized prompts, auth failures, ...) from the debug log.
|
|
12
12
|
*/
|
|
13
|
-
export function logAgentStreamError(stage: "observer" | "
|
|
13
|
+
export function logAgentStreamError(stage: "observer" | "summarizer" | "contemplator" | "reviewer", event: AgentEvent): void {
|
|
14
14
|
if (event.type !== "message_end") return;
|
|
15
15
|
const message = event.message;
|
|
16
16
|
if (message.role !== "assistant") return;
|