@cad0p/pi-tree-navigator 0.1.0-20260731.0
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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/extensions/navigate-tree/helpers.ts +135 -0
- package/extensions/navigate-tree/index.ts +1026 -0
- package/package.json +55 -0
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* navigate-tree — agent-callable session tree navigation.
|
|
3
|
+
*
|
|
4
|
+
* See README “Implementation notes” for the user-facing narrative
|
|
5
|
+
* (Anthropic tool_use↔tool_result pairing, same-loop context refresh,
|
|
6
|
+
* reflection bootstrap). This file-level JSDoc carries only
|
|
7
|
+
* source-internal facts the README doesn't.
|
|
8
|
+
*
|
|
9
|
+
* `/tree`-visible artifacts (empirical):
|
|
10
|
+
* • Synthetic assistant message right after each `branch_summary`,
|
|
11
|
+
* with one tool_call sharing the in-flight `toolCallId`.
|
|
12
|
+
* • Dangling tool_use on the anchor entry whose original
|
|
13
|
+
* tool_results were cut off by the rewind. Anthropic accepts this
|
|
14
|
+
* — the dangling tool_use is buffered behind the branch_summary's
|
|
15
|
+
* user-text rendering and the API doesn't reject it. No walk-up
|
|
16
|
+
* logic at anchor time.
|
|
17
|
+
*
|
|
18
|
+
* Reflection bootstrap replicates pi's own slash-command line
|
|
19
|
+
* verbatim (kept symmetric so a pi rename here surfaces as the
|
|
20
|
+
* runtime warning rather than silently drifting):
|
|
21
|
+
*
|
|
22
|
+
* this.agent.state.messages = this.sessionManager.buildSessionContext().messages;
|
|
23
|
+
*
|
|
24
|
+
* Risks of the reflection approach:
|
|
25
|
+
* • If pi switches any of the six fields this extension reads —
|
|
26
|
+
* `AgentSession.prototype.prompt`, `agent.state.messages`,
|
|
27
|
+
* `agent.state.systemPrompt`, `agent.state.tools`,
|
|
28
|
+
* `agent.prepareNextTurn`, or `agent.prepareNextTurnWithContext` —
|
|
29
|
+
* to ES `#` private fields, this breaks fundamentally.
|
|
30
|
+
* • If pi renames or restructures any of these fields, this breaks.
|
|
31
|
+
* • Patches `AgentSession.prototype.prompt` globally on import; not
|
|
32
|
+
* reversible without a process restart; affects every session in
|
|
33
|
+
* the pi process, including sessions that never call
|
|
34
|
+
* `navigate_tree`.
|
|
35
|
+
*
|
|
36
|
+
* Verified against pi 0.75.5 (`prepareNextTurn` path) and pi 0.83.0
|
|
37
|
+
* (`prepareNextTurnWithContext` path — required since pi 0.80.3, see
|
|
38
|
+
* `installPrepareNextTurn`).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { estimateContextTokens } from "@earendil-works/pi-agent-core";
|
|
42
|
+
import {
|
|
43
|
+
AgentSession,
|
|
44
|
+
buildSessionContext,
|
|
45
|
+
collectEntriesForBranchSummary,
|
|
46
|
+
type ExtensionAPI,
|
|
47
|
+
generateBranchSummary,
|
|
48
|
+
type SessionEntry,
|
|
49
|
+
type SessionManager,
|
|
50
|
+
} from "@earendil-works/pi-coding-agent";
|
|
51
|
+
import { Type } from "typebox";
|
|
52
|
+
import {
|
|
53
|
+
extractTextContent,
|
|
54
|
+
formatContextDelta,
|
|
55
|
+
formatPct1,
|
|
56
|
+
formatWindow,
|
|
57
|
+
isValidName,
|
|
58
|
+
MAX_NAME_LENGTH,
|
|
59
|
+
stripBranchSummaryBoilerplate,
|
|
60
|
+
toOneLine,
|
|
61
|
+
} from "./helpers.ts";
|
|
62
|
+
|
|
63
|
+
const LABEL_PREFIX = "anchor:";
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Exported boundary constants below (MAX_SESSION_REFS, MAX_HINT_WALK_DEPTH,
|
|
67
|
+
// MIN_SUMMARY_FOCUS_LENGTH, MAX_SYNTHETIC_FOCUS_LENGTH).
|
|
68
|
+
//
|
|
69
|
+
// Stability: these are internal tunables. Exported only so the test suite
|
|
70
|
+
// can pin boundary cases by constant rather than literal. Re-tuning is
|
|
71
|
+
// NOT a semver-breaking change for this package — production callers
|
|
72
|
+
// should rely on the registered `navigate_tree` tool surface, not import
|
|
73
|
+
// these constants directly. The `__testHooks` JSDoc carries the same
|
|
74
|
+
// caveat for module-internal helpers.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
// Cap on captured AgentSession refs across /new + /resume + /reload cycles.
|
|
78
|
+
// Worst case is ~one ref per long-lived session before reaping dead WeakRefs;
|
|
79
|
+
// 16 leaves headroom for the deepest session-fanout pattern observed (a few
|
|
80
|
+
// /resume cycles on top of a couple of /new cycles) without prematurely
|
|
81
|
+
// reaping a still-live session. Bump if the reaper fires while a session
|
|
82
|
+
// is still live.
|
|
83
|
+
export const MAX_SESSION_REFS = 16;
|
|
84
|
+
// Cap on parentId chain walks in `findLabelHint` (UX preview only;
|
|
85
|
+
// no need to walk to the root for a 50-char snippet).
|
|
86
|
+
export const MAX_HINT_WALK_DEPTH = 50;
|
|
87
|
+
// Floor on `summaryFocus` length (after trim) for `rewind`. The user's most
|
|
88
|
+
// recent instruction lives on the chain about to be collapsed; if the focus
|
|
89
|
+
// is shorter than this, it almost always elides that instruction (a terse
|
|
90
|
+
// "finish parser fix" is 17 chars and conveys nothing the next turn can
|
|
91
|
+
// act on). 20 is the empirical threshold below which the post-rewind turn
|
|
92
|
+
// reliably loses continuity — raising it forces more useful focus text
|
|
93
|
+
// without inviting verbosity.
|
|
94
|
+
export const MIN_SUMMARY_FOCUS_LENGTH = 20;
|
|
95
|
+
// Cap on the `summaryFocus` length stored in the synthetic assistant's
|
|
96
|
+
// arguments. The full focus is passed live to `generateBranchSummary`, so
|
|
97
|
+
// the summarizer always sees the original; we only need a trimmed copy in
|
|
98
|
+
// the synthetic's args because pi's `convertToLlm` re-emits the synthetic's
|
|
99
|
+
// toolCall block (including its arguments) on every subsequent turn until
|
|
100
|
+
// another rewind. Without a cap, a 100K-char focus inflates every later
|
|
101
|
+
// turn's input by ~100K chars indefinitely. 1024 chars is generous — well
|
|
102
|
+
// above empirically useful focus length, and the agent already saw the
|
|
103
|
+
// full focus string when it issued the rewind.
|
|
104
|
+
export const MAX_SYNTHETIC_FOCUS_LENGTH = 1024;
|
|
105
|
+
// Hint length cap for the per-row hint shown in `list` output. 50 chars
|
|
106
|
+
// fits one terminal column without wrapping in typical 80-column TUIs.
|
|
107
|
+
const LIST_HINT_MAX_LENGTH = 50;
|
|
108
|
+
// Hint length cap for the hint shown in the `anchor` response. The anchor
|
|
109
|
+
// response is a single block of prose (not a column-aligned table) so it
|
|
110
|
+
// can afford a longer hint than `list`'s per-row preview.
|
|
111
|
+
const ANCHOR_HINT_MAX_LENGTH = 60;
|
|
112
|
+
// padStart width for the percentage column in `list` output. The longest
|
|
113
|
+
// percent label is "100.0%" = 6 chars; "99.9%" = 5 chars covers the
|
|
114
|
+
// realistic worst case and keeps the column tight.
|
|
115
|
+
const LIST_PCT_COL_WIDTH = 5;
|
|
116
|
+
// padEnd width for the anchor-name column in `list` output. MAX_NAME_LENGTH
|
|
117
|
+
// is 40, but the typical kebab-case name is 8–20 chars; 28 keeps the
|
|
118
|
+
// hint column visible without truncating common names.
|
|
119
|
+
const LIST_LABEL_COL_WIDTH = 28;
|
|
120
|
+
const PNT_MARKER = Symbol.for("navigate-tree.pnt-installed");
|
|
121
|
+
const PNTWC_MARKER = Symbol.for("navigate-tree.pntwc-installed");
|
|
122
|
+
const ORIG_PROMPT_KEY = Symbol.for("navigate-tree.orig-prompt");
|
|
123
|
+
|
|
124
|
+
// Two warnings: list-site (read-only path; warns about the next turn's
|
|
125
|
+
// context view) and rewind-site (wrote to disk; leads with that). Both
|
|
126
|
+
// suggest /reload first, then restart pi, in that order.
|
|
127
|
+
const REFLECTION_BOOTSTRAP_WARNING_LIST =
|
|
128
|
+
"⚠ reflection bootstrap missing — anchors and rewinds still work, but the next assistant turn may snapshot pre-bootstrap context. Run `/reload` (or restart pi) to recover.";
|
|
129
|
+
const REFLECTION_BOOTSTRAP_WARNING_REWIND =
|
|
130
|
+
"⚠ reflection bootstrap missing — the rewind landed on disk but the next assistant turn may still see the pre-rewind context. Run `/reload` (or restart pi) to recover.";
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Typed views over pi internals.
|
|
134
|
+
//
|
|
135
|
+
// pi-coding-agent doesn't expose `agent`, `state`, `prepareNextTurn`, or
|
|
136
|
+
// `sessionManager` on `AgentSession` in its public types, but they are plain
|
|
137
|
+
// (non-`#`-private) fields on the class. Each cast point is a fragility
|
|
138
|
+
// surface for pi version bumps; grouping them here makes the dependency
|
|
139
|
+
// surface explicit.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
interface PiInternals {
|
|
143
|
+
agent: {
|
|
144
|
+
state: {
|
|
145
|
+
systemPrompt: string;
|
|
146
|
+
messages: unknown[];
|
|
147
|
+
tools: unknown[];
|
|
148
|
+
};
|
|
149
|
+
prepareNextTurn?: unknown;
|
|
150
|
+
// Preferred over `prepareNextTurn` by `Agent.createLoopConfig`
|
|
151
|
+
// when set; pi's own AgentSession always sets it (via
|
|
152
|
+
// `_installAgentNextTurnRefresh`) since pi-coding-agent 0.80.3.
|
|
153
|
+
prepareNextTurnWithContext?: unknown;
|
|
154
|
+
};
|
|
155
|
+
sessionManager: SessionManager;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function asInternals(session: AgentSession): PiInternals {
|
|
159
|
+
return session as unknown as PiInternals;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type PntResult = {
|
|
163
|
+
context?: {
|
|
164
|
+
systemPrompt?: unknown;
|
|
165
|
+
messages?: unknown[];
|
|
166
|
+
tools?: unknown[];
|
|
167
|
+
[k: string]: unknown;
|
|
168
|
+
};
|
|
169
|
+
model?: unknown;
|
|
170
|
+
thinkingLevel?: unknown;
|
|
171
|
+
};
|
|
172
|
+
// pi 0.75.5 invokes `agent.prepareNextTurn(signal)` from
|
|
173
|
+
// `Agent.createLoopConfig` — a single AbortSignal argument. Since pi
|
|
174
|
+
// 0.80.3 the loop instead invokes
|
|
175
|
+
// `agent.prepareNextTurnWithContext(nextTurnContext, signal)` (and only
|
|
176
|
+
// falls back to `prepareNextTurn` when the WithContext field is unset).
|
|
177
|
+
// Both differ from the documented
|
|
178
|
+
// `AgentLoopConfig.prepareNextTurn(context: PrepareNextTurnContext)`
|
|
179
|
+
// shape, which `Agent` is bridging. We accept whatever pi passes and
|
|
180
|
+
// forward it verbatim to the prior wrapper so we don't fight a future
|
|
181
|
+
// signature alignment. Verified against pi-coding-agent 0.75.5 and
|
|
182
|
+
// 0.83.0; revisit if the call site changes.
|
|
183
|
+
type PntFn = (...args: unknown[]) => Promise<PntResult> | PntResult;
|
|
184
|
+
type MarkedPntFn = PntFn & {
|
|
185
|
+
[PNT_MARKER]?: boolean;
|
|
186
|
+
[PNTWC_MARKER]?: boolean;
|
|
187
|
+
__prior?: PntFn;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// Reflection bootstrap & in-loop refresh
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
const sessionInstances: WeakRef<AgentSession>[] = [];
|
|
195
|
+
let seenSessions = new WeakSet<AgentSession>();
|
|
196
|
+
|
|
197
|
+
function captureSession(session: AgentSession): void {
|
|
198
|
+
if (seenSessions.has(session)) return;
|
|
199
|
+
seenSessions.add(session);
|
|
200
|
+
sessionInstances.push(new WeakRef(session));
|
|
201
|
+
// Reap dead WeakRefs occasionally so the array doesn't grow unbounded
|
|
202
|
+
// across /new and /resume cycles.
|
|
203
|
+
if (sessionInstances.length > MAX_SESSION_REFS) {
|
|
204
|
+
for (let i = sessionInstances.length - 1; i >= 0; i--) {
|
|
205
|
+
if (!sessionInstances[i].deref()) sessionInstances.splice(i, 1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function patchAgentSessionPrototype(): void {
|
|
211
|
+
const proto = AgentSession.prototype as unknown as Record<
|
|
212
|
+
PropertyKey,
|
|
213
|
+
unknown
|
|
214
|
+
>;
|
|
215
|
+
// Stash the truly-original prompt the FIRST time we patch. On subsequent
|
|
216
|
+
// /reloads the value is already there — we don't overwrite, we just read it
|
|
217
|
+
// back so the new wrapper still calls the original (not a previous wrapper).
|
|
218
|
+
if (!proto[ORIG_PROMPT_KEY]) {
|
|
219
|
+
proto[ORIG_PROMPT_KEY] = proto.prompt;
|
|
220
|
+
}
|
|
221
|
+
const orig = proto[ORIG_PROMPT_KEY] as (...args: unknown[]) => unknown;
|
|
222
|
+
|
|
223
|
+
// Always replace the wrapper, even if a previous load already patched. On
|
|
224
|
+
// /reload the previous wrapper closes over the previous module's
|
|
225
|
+
// `sessionInstances` — if we don't replace, captures land in the dead
|
|
226
|
+
// module and reflection finds nothing.
|
|
227
|
+
const patched = function (this: AgentSession, ...args: unknown[]) {
|
|
228
|
+
captureSession(this);
|
|
229
|
+
installPrepareNextTurn(this);
|
|
230
|
+
return orig.apply(this, args);
|
|
231
|
+
};
|
|
232
|
+
proto.prompt = patched;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Wire the in-flight agent loop to refresh its context from sessionManager
|
|
237
|
+
* between turns within the same prompt() call. Without this, the loop
|
|
238
|
+
* snapshots agent.state.messages once at prompt start and pushes new
|
|
239
|
+
* messages onto its own array — a rewind issued mid-loop doesn't reduce
|
|
240
|
+
* the next API call's size until the user sends a new prompt.
|
|
241
|
+
*
|
|
242
|
+
* Two hook fields, by pi version:
|
|
243
|
+
* • pi ≤0.80.2: `Agent.createLoopConfig` reads `agent.prepareNextTurn`.
|
|
244
|
+
* • pi ≥0.80.3: AgentSession's constructor installs its own
|
|
245
|
+
* `agent.prepareNextTurnWithContext` (`_installAgentNextTurnRefresh`),
|
|
246
|
+
* and `createLoopConfig` prefers that field over `prepareNextTurn`.
|
|
247
|
+
* Pi's wrapper refreshes systemPrompt/tools/model/thinkingLevel per
|
|
248
|
+
* turn but leaves `messages` stale — so wrapping only
|
|
249
|
+
* `prepareNextTurn` silently dead-ends (footer % and the wire context
|
|
250
|
+
* stay pre-rewind for the rest of the loop).
|
|
251
|
+
*
|
|
252
|
+
* We install on BOTH fields, each with the same marker/__prior chaining
|
|
253
|
+
* discipline: on new pi the WithContext wrapper chains pi's own (keeping
|
|
254
|
+
* its per-turn refreshes) and overrides only `messages`; on old pi the
|
|
255
|
+
* WithContext field is never read and `prepareNextTurn` does the work.
|
|
256
|
+
*
|
|
257
|
+
* The Agent class's `createLoopConfig` dereferences both fields at the
|
|
258
|
+
* closure call site, so the values here are read at every turn boundary.
|
|
259
|
+
* But it gates the closure on either field being truthy at config
|
|
260
|
+
* creation — so we have to set this BEFORE prompt() runs, hence wiring
|
|
261
|
+
* it from inside the prompt patch.
|
|
262
|
+
*/
|
|
263
|
+
function installPrepareNextTurn(session: AgentSession): void {
|
|
264
|
+
const internals = asInternals(session);
|
|
265
|
+
const agent = internals.agent;
|
|
266
|
+
if (!agent) return;
|
|
267
|
+
|
|
268
|
+
const sm = internals.sessionManager;
|
|
269
|
+
|
|
270
|
+
// If the existing hooks were installed by a previous load of THIS
|
|
271
|
+
// extension, recover the chains they captured (their `__prior`) so we
|
|
272
|
+
// don't strand other extensions' closures across /reload. Preserve any
|
|
273
|
+
// other extension's (or pi's own) hooks so we compose with them.
|
|
274
|
+
const existing = agent.prepareNextTurn as MarkedPntFn | undefined;
|
|
275
|
+
const prior: PntFn | undefined =
|
|
276
|
+
typeof existing === "function" && existing[PNT_MARKER]
|
|
277
|
+
? existing.__prior
|
|
278
|
+
: (existing as PntFn | undefined);
|
|
279
|
+
const existingWc = agent.prepareNextTurnWithContext as
|
|
280
|
+
| MarkedPntFn
|
|
281
|
+
| undefined;
|
|
282
|
+
const priorWc: PntFn | undefined =
|
|
283
|
+
typeof existingWc === "function" && existingWc[PNTWC_MARKER]
|
|
284
|
+
? existingWc.__prior
|
|
285
|
+
: (existingWc as PntFn | undefined);
|
|
286
|
+
|
|
287
|
+
const next: MarkedPntFn = async (...args: unknown[]) => {
|
|
288
|
+
let priorResult: PntResult | undefined;
|
|
289
|
+
if (typeof prior === "function") {
|
|
290
|
+
priorResult = await prior(...args);
|
|
291
|
+
}
|
|
292
|
+
// Pi's loop replaces context wholesale (`currentContext = ctx ??
|
|
293
|
+
// currentContext`), not field-merges — a prior wrapper that returns
|
|
294
|
+
// a partial context (e.g. only systemPrompt) would silently drop
|
|
295
|
+
// tools. Spread the prior first so its fields survive, then fall
|
|
296
|
+
// back to `agent.state` for any field the prior left undefined.
|
|
297
|
+
// `messages` is owned by this wrapper.
|
|
298
|
+
const priorContext = priorResult?.context;
|
|
299
|
+
return {
|
|
300
|
+
context: {
|
|
301
|
+
...priorContext,
|
|
302
|
+
systemPrompt: priorContext?.systemPrompt ?? agent.state.systemPrompt,
|
|
303
|
+
tools: priorContext?.tools ?? agent.state.tools,
|
|
304
|
+
messages: sm.buildSessionContext().messages,
|
|
305
|
+
},
|
|
306
|
+
model: priorResult?.model,
|
|
307
|
+
thinkingLevel: priorResult?.thinkingLevel,
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
next[PNT_MARKER] = true;
|
|
311
|
+
next.__prior = prior;
|
|
312
|
+
agent.prepareNextTurn = next;
|
|
313
|
+
|
|
314
|
+
const nextWc: MarkedPntFn = async (...args: unknown[]) => {
|
|
315
|
+
// On pi ≥0.80.3 the loop calls this as (nextTurnContext, signal),
|
|
316
|
+
// where nextTurnContext = { message, toolResults, context,
|
|
317
|
+
// newMessages }. Forward args verbatim to the prior (pi's own
|
|
318
|
+
// wrapper expects exactly this shape).
|
|
319
|
+
const turn = args[0] as { context?: PntResult["context"] } | undefined;
|
|
320
|
+
let priorResult: PntResult | undefined;
|
|
321
|
+
if (typeof priorWc === "function") {
|
|
322
|
+
priorResult = await priorWc(...args);
|
|
323
|
+
}
|
|
324
|
+
// Same wholesale-replacement rationale as the prepareNextTurn
|
|
325
|
+
// wrapper above. When no prior result exists, fall back to the
|
|
326
|
+
// turn's live context (mirrors pi's own wrapper, which does
|
|
327
|
+
// `previousSnapshot?.context ?? turn.context`) so systemPrompt and
|
|
328
|
+
// tools are never dropped. `messages` is owned by this wrapper —
|
|
329
|
+
// this override is the entire point of the hook on pi ≥0.80.3, whose
|
|
330
|
+
// own wrapper refreshes every other field but leaves messages stale.
|
|
331
|
+
const priorContext = priorResult?.context ?? turn?.context;
|
|
332
|
+
return {
|
|
333
|
+
...priorResult,
|
|
334
|
+
context: {
|
|
335
|
+
...priorContext,
|
|
336
|
+
systemPrompt: priorContext?.systemPrompt ?? agent.state.systemPrompt,
|
|
337
|
+
tools: priorContext?.tools ?? agent.state.tools,
|
|
338
|
+
messages: sm.buildSessionContext().messages,
|
|
339
|
+
},
|
|
340
|
+
model: priorResult?.model,
|
|
341
|
+
thinkingLevel: priorResult?.thinkingLevel,
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
nextWc[PNTWC_MARKER] = true;
|
|
345
|
+
nextWc.__prior = priorWc;
|
|
346
|
+
agent.prepareNextTurnWithContext = nextWc;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function findOwningSession(sm: SessionManager): AgentSession | null {
|
|
350
|
+
for (const ref of sessionInstances) {
|
|
351
|
+
const s = ref.deref();
|
|
352
|
+
if (s && asInternals(s).sessionManager === sm) {
|
|
353
|
+
return s;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function refreshAgentMessages(sm: SessionManager): boolean {
|
|
360
|
+
// Manually replicate the agent-state refresh that pi's
|
|
361
|
+
// commandCtx.navigateTree does after branchWithSummary. Returns true on
|
|
362
|
+
// success, false if reflection couldn't find the owning AgentSession (in
|
|
363
|
+
// which case the rewind is structurally complete on disk, but the next LLM
|
|
364
|
+
// call will still see stale messages).
|
|
365
|
+
const session = findOwningSession(sm);
|
|
366
|
+
if (!session) return false;
|
|
367
|
+
try {
|
|
368
|
+
const sessionContext = sm.buildSessionContext();
|
|
369
|
+
const agent = asInternals(session).agent;
|
|
370
|
+
if (!agent?.state) return false;
|
|
371
|
+
agent.state.messages = sessionContext.messages;
|
|
372
|
+
return true;
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// =============================================================================
|
|
379
|
+
// Helpers (extension-internal; pure helpers in ./helpers.ts)
|
|
380
|
+
// =============================================================================
|
|
381
|
+
|
|
382
|
+
function findLabeledEntry(
|
|
383
|
+
sm: SessionManager,
|
|
384
|
+
fullLabel: string,
|
|
385
|
+
): string | null {
|
|
386
|
+
const path = sm.getBranch();
|
|
387
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
388
|
+
if (sm.getLabel(path[i].id) === fullLabel) return path[i].id;
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function estimateActiveBranchTokens(sm: SessionManager): number {
|
|
394
|
+
return estimateContextTokens(sm.buildSessionContext().messages).tokens;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function estimateAtEntry(
|
|
398
|
+
entries: SessionEntry[],
|
|
399
|
+
entryId: string,
|
|
400
|
+
byId: Map<string, SessionEntry>,
|
|
401
|
+
): number {
|
|
402
|
+
return estimateContextTokens(
|
|
403
|
+
buildSessionContext(entries, entryId, byId).messages,
|
|
404
|
+
).tokens;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Walk parentId chain back from `fromId` and return a one-line preview of
|
|
409
|
+
* the first entry that has meaningful text content. Branch summaries are
|
|
410
|
+
* prefixed with `summary:` so the source is clear; user/assistant text is
|
|
411
|
+
* shown as-is.
|
|
412
|
+
*/
|
|
413
|
+
function findLabelHint(
|
|
414
|
+
sm: SessionManager,
|
|
415
|
+
fromId: string,
|
|
416
|
+
maxLen: number,
|
|
417
|
+
): string | null {
|
|
418
|
+
let cur: string | null | undefined = fromId;
|
|
419
|
+
let depth = 0;
|
|
420
|
+
while (cur && depth < MAX_HINT_WALK_DEPTH) {
|
|
421
|
+
const e = sm.getEntry(cur);
|
|
422
|
+
if (!e) break;
|
|
423
|
+
let text = "";
|
|
424
|
+
let prefix = "";
|
|
425
|
+
if (e.type === "branch_summary" && e.summary) {
|
|
426
|
+
text = stripBranchSummaryBoilerplate(e.summary);
|
|
427
|
+
prefix = "summary: ";
|
|
428
|
+
} else if (e.type === "message") {
|
|
429
|
+
const role = e.message.role;
|
|
430
|
+
if (role === "user" || role === "assistant") {
|
|
431
|
+
text = extractTextContent(e.message.content);
|
|
432
|
+
}
|
|
433
|
+
} else if (e.type === "custom_message") {
|
|
434
|
+
text = extractTextContent(e.content);
|
|
435
|
+
}
|
|
436
|
+
const oneLine = toOneLine(text, maxLen - prefix.length);
|
|
437
|
+
if (oneLine) return prefix + oneLine;
|
|
438
|
+
cur = e.parentId;
|
|
439
|
+
depth++;
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Build a synthetic assistant message containing a single tool_call whose id
|
|
446
|
+
* matches the in-flight tool_call id. Appended after `branchWithSummary` so
|
|
447
|
+
* the real tool_result lands paired with a matching tool_use.
|
|
448
|
+
*
|
|
449
|
+
* `usage` fields are zeroed except `totalTokens`, which is set to the
|
|
450
|
+
* chain size measured BEFORE this synthetic is appended (the post-rewind
|
|
451
|
+
* baseline that pi-agent-core's `estimateContextTokens` reads off the last
|
|
452
|
+
* assistant). `stopReason: "toolUse"` survives Kiro's `normalizeMessages`
|
|
453
|
+
* filter (which strips `error` / `aborted`); without it the synthetic would
|
|
454
|
+
* be filtered out and the tool_result would re-orphan.
|
|
455
|
+
*/
|
|
456
|
+
function buildSyntheticAssistant(
|
|
457
|
+
toolCallId: string,
|
|
458
|
+
toolName: string,
|
|
459
|
+
args: Record<string, unknown>,
|
|
460
|
+
model: { api?: string; provider?: string; id?: string } | undefined,
|
|
461
|
+
totalTokens: number,
|
|
462
|
+
) {
|
|
463
|
+
return {
|
|
464
|
+
role: "assistant" as const,
|
|
465
|
+
content: [
|
|
466
|
+
{
|
|
467
|
+
type: "toolCall" as const,
|
|
468
|
+
id: toolCallId,
|
|
469
|
+
name: toolName,
|
|
470
|
+
arguments: args,
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
api: model?.api ?? "unknown",
|
|
474
|
+
provider: model?.provider ?? "unknown",
|
|
475
|
+
model: model?.id ?? "unknown",
|
|
476
|
+
stopReason: "toolUse" as const,
|
|
477
|
+
timestamp: Date.now(),
|
|
478
|
+
usage: {
|
|
479
|
+
input: 0,
|
|
480
|
+
output: 0,
|
|
481
|
+
cacheRead: 0,
|
|
482
|
+
cacheWrite: 0,
|
|
483
|
+
totalTokens,
|
|
484
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// =============================================================================
|
|
490
|
+
// Extension
|
|
491
|
+
// =============================================================================
|
|
492
|
+
|
|
493
|
+
interface ToolResult {
|
|
494
|
+
content: Array<{ type: "text"; text: string }>;
|
|
495
|
+
details: Record<string, unknown>;
|
|
496
|
+
isError?: boolean;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function toolError(
|
|
500
|
+
text: string,
|
|
501
|
+
details: Record<string, unknown> = {},
|
|
502
|
+
): ToolResult {
|
|
503
|
+
return {
|
|
504
|
+
content: [{ type: "text", text }],
|
|
505
|
+
details,
|
|
506
|
+
isError: true,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export default function (
|
|
511
|
+
pi: ExtensionAPI,
|
|
512
|
+
opts?: { summarize?: typeof generateBranchSummary },
|
|
513
|
+
) {
|
|
514
|
+
// DI seam: tests inject a stub `summarize` to avoid hitting the real
|
|
515
|
+
// model. Production callers (pi's extension loader) pass no second
|
|
516
|
+
// argument, so this falls back to the real `generateBranchSummary`
|
|
517
|
+
// import.
|
|
518
|
+
const summarize = opts?.summarize ?? generateBranchSummary;
|
|
519
|
+
patchAgentSessionPrototype();
|
|
520
|
+
|
|
521
|
+
pi.registerTool({
|
|
522
|
+
name: "navigate_tree",
|
|
523
|
+
label: "Navigate Tree",
|
|
524
|
+
// Stateful: every action mutates SessionManager; concurrent calls would
|
|
525
|
+
// race on `leafId` / `labelsById` and produce an undefined tree.
|
|
526
|
+
executionMode: "sequential",
|
|
527
|
+
description: `Long-session context management via the pi session tree. Anchor named milestones, then collapse work between them into a model-generated summary while preserving the full history on a sibling branch. Despite the verb, \`rewind\` does not restore prior state — it forks a sibling branch from the anchor and continues forward from a model-generated summary; the abandoned subtree is preserved on disk but no longer on the active path.
|
|
528
|
+
|
|
529
|
+
Operations (set the \`action\` parameter):
|
|
530
|
+
• action='anchor', name='<milestone-name>': label the current point so a later rewind can target it. Use at the start of a stage you'll summarize (e.g. 'design-start', 'impl-start'). If the same name already exists on the active branch, the prior label is moved to the new leaf (no duplicates).
|
|
531
|
+
• action='rewind', labelStart='<existing>', labelEnd='<new>': collapse work between labelStart and the current leaf into a branch_summary. The new summary entry is itself labeled with labelEnd, so you can chain rewinds.
|
|
532
|
+
• action='list': show all named anchors on the active branch in chronological order, with cumulative context % at each anchor.
|
|
533
|
+
|
|
534
|
+
Both \`name\` (anchor) and \`labelEnd\` (rewind) write into the same anchor namespace — either becomes addressable as a future \`labelStart\`. \`list\` shows every anchor under the \`anchor:\` prefix, regardless of which action wrote it. Pi labels written via \`/label anchor:foo\` (manually or by other extensions) are also addressable here. Avoid the \`anchor:\` prefix in manually-set labels.
|
|
535
|
+
|
|
536
|
+
\`summaryFocus\` is required when \`action='rewind'\` (≥${MIN_SUMMARY_FOCUS_LENGTH} chars after trim). Calls without it are rejected. It's passed to pi's \`generateBranchSummary\` as \`customInstructions\`, biasing the summarizer LLM toward the agent's specified focus while it rewrites the collapsed work into pi's structured summary format. To preserve continuity, instruct the summarizer to keep: (1) the user's most recent message verbatim, (2) what's done in the collapsed segment, (3) what's left to do as a next action.`,
|
|
537
|
+
promptSnippet:
|
|
538
|
+
"Use to anchor named milestones and rewind the conversation tree to a prior point with a model-generated summary, for token-efficient long autonomous sessions.",
|
|
539
|
+
// The schema is intentionally a flat `Type.Object` with everything-but-
|
|
540
|
+
// `action` optional, with action-conditional required-ness enforced at
|
|
541
|
+
// runtime in `execute`. Discriminated unions / property-level required-
|
|
542
|
+
// when-action shapes break the Kiro/CodeWhisperer adapter, which forwards
|
|
543
|
+
// `inputSchema.json` verbatim and 400s on non-`type: "object"` roots.
|
|
544
|
+
// The runtime guards in `execute` provide the conditional-required
|
|
545
|
+
// behavior the schema can't express.
|
|
546
|
+
parameters: Type.Object({
|
|
547
|
+
action: Type.Union(
|
|
548
|
+
[Type.Literal("anchor"), Type.Literal("rewind"), Type.Literal("list")],
|
|
549
|
+
{
|
|
550
|
+
description:
|
|
551
|
+
"Which operation to perform. 'anchor' labels the current point, 'rewind' collapses work between an anchor and the current leaf into a branch_summary, 'list' shows every anchor on the active branch.",
|
|
552
|
+
},
|
|
553
|
+
),
|
|
554
|
+
name: Type.Optional(
|
|
555
|
+
Type.String({
|
|
556
|
+
description: `Required when action='anchor'. Kebab-case label (max ${MAX_NAME_LENGTH} chars) for the milestone. If a label with this name already exists on the active branch, it is moved to the new leaf.`,
|
|
557
|
+
}),
|
|
558
|
+
),
|
|
559
|
+
labelStart: Type.Optional(
|
|
560
|
+
Type.String({
|
|
561
|
+
description: `Required when action='rewind'. Kebab-case name (max ${MAX_NAME_LENGTH} chars) of an existing anchor on the active branch — work between this anchor and the current leaf is summarized.`,
|
|
562
|
+
}),
|
|
563
|
+
),
|
|
564
|
+
labelEnd: Type.Optional(
|
|
565
|
+
Type.String({
|
|
566
|
+
description: `Required when action='rewind'. Kebab-case name (max ${MAX_NAME_LENGTH} chars) for the resulting branch_summary entry. If a label with this name already exists on the active branch, it is moved to the new entry (mirrors anchor's move-on-collision). Becomes addressable as a future labelStart.`,
|
|
567
|
+
}),
|
|
568
|
+
),
|
|
569
|
+
summaryFocus: Type.Optional(
|
|
570
|
+
Type.String({
|
|
571
|
+
description: `Required when action='rewind'. ≥${MIN_SUMMARY_FOCUS_LENGTH} chars after trim. Should encode (1) the user's most recent instruction verbatim, (2) what was done in the collapsed segment, (3) what's left to do as a next action.`,
|
|
572
|
+
}),
|
|
573
|
+
),
|
|
574
|
+
}),
|
|
575
|
+
execute: async (toolCallId, params, signal, _onUpdate, ctx) => {
|
|
576
|
+
const sm = ctx.sessionManager as SessionManager;
|
|
577
|
+
const p = params as {
|
|
578
|
+
action: "anchor" | "rewind" | "list";
|
|
579
|
+
name?: string;
|
|
580
|
+
labelStart?: string;
|
|
581
|
+
labelEnd?: string;
|
|
582
|
+
summaryFocus?: string;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// --- list ---
|
|
586
|
+
if (p.action === "list") {
|
|
587
|
+
const path = sm.getBranch();
|
|
588
|
+
const allEntries = sm.getEntries();
|
|
589
|
+
const byId = new Map<string, SessionEntry>();
|
|
590
|
+
for (const e of allEntries) byId.set(e.id, e);
|
|
591
|
+
const cw = ctx.model?.contextWindow ?? 0;
|
|
592
|
+
const totalTokens = estimateActiveBranchTokens(sm);
|
|
593
|
+
const reflectionOk = !!findOwningSession(sm);
|
|
594
|
+
|
|
595
|
+
const lines: string[] = [];
|
|
596
|
+
for (const e of path) {
|
|
597
|
+
const lbl = sm.getLabel(e.id);
|
|
598
|
+
if (lbl?.startsWith(LABEL_PREFIX)) {
|
|
599
|
+
const name = lbl.slice(LABEL_PREFIX.length);
|
|
600
|
+
const tokensAt = estimateAtEntry(allEntries, e.id, byId);
|
|
601
|
+
const pct = formatPct1(tokensAt, cw).padStart(LIST_PCT_COL_WIDTH);
|
|
602
|
+
const hint = findLabelHint(sm, e.id, LIST_HINT_MAX_LENGTH);
|
|
603
|
+
const hintPart = hint ? ` (after: “${hint}”)` : "";
|
|
604
|
+
lines.push(
|
|
605
|
+
` ${pct} ${name.padEnd(LIST_LABEL_COL_WIDTH)}${hintPart}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const reflectionWarning = reflectionOk
|
|
611
|
+
? ""
|
|
612
|
+
: ` · ${REFLECTION_BOOTSTRAP_WARNING_LIST}`;
|
|
613
|
+
const header = `[list] · ${lines.length} label${lines.length === 1 ? "" : "s"} · ctx ${formatPct1(totalTokens, cw)}${cw > 0 ? ` of ${formatWindow(cw)}` : ""}${reflectionWarning}`;
|
|
614
|
+
const body = lines.length
|
|
615
|
+
? `Active labels (root → leaf):\n${lines.join("\n")}`
|
|
616
|
+
: "No labels on the active branch.";
|
|
617
|
+
return {
|
|
618
|
+
content: [{ type: "text", text: `${header}\n\n${body}` }],
|
|
619
|
+
details: {
|
|
620
|
+
count: lines.length,
|
|
621
|
+
contextTokens: totalTokens,
|
|
622
|
+
contextWindow: cw,
|
|
623
|
+
reflectionOk,
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// --- anchor ---
|
|
629
|
+
if (p.action === "anchor") {
|
|
630
|
+
if (!isValidName(p.name)) {
|
|
631
|
+
return toolError(
|
|
632
|
+
`anchor requires \`name\` in kebab-case, max ${MAX_NAME_LENGTH} chars (e.g. 'impl-start').`,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
const leafId = sm.getLeafId();
|
|
636
|
+
if (!leafId) {
|
|
637
|
+
return toolError("No session entries yet — nothing to anchor.");
|
|
638
|
+
}
|
|
639
|
+
// Write the new label first, then clear the prior. If the second
|
|
640
|
+
// setLabel throws, two labels of the same name briefly coexist on
|
|
641
|
+
// the active branch — `findLabeledEntry` walks leaf→root and
|
|
642
|
+
// returns the leaf-side match, so navigation behavior is correct
|
|
643
|
+
// during the overlap. The pre-PR "no enforcement" semantics already
|
|
644
|
+
// tolerated this. The reverse order (clear-then-set) was move-then-
|
|
645
|
+
// lose under failure: a partial collapse left the active branch
|
|
646
|
+
// with no anchor of the requested name at all.
|
|
647
|
+
const fullLabel = LABEL_PREFIX + p.name;
|
|
648
|
+
const prior = findLabeledEntry(sm, fullLabel);
|
|
649
|
+
pi.setLabel(leafId, fullLabel);
|
|
650
|
+
if (prior && prior !== leafId) {
|
|
651
|
+
pi.setLabel(prior, undefined);
|
|
652
|
+
}
|
|
653
|
+
const cw = ctx.model?.contextWindow ?? 0;
|
|
654
|
+
const tokensHere = estimateActiveBranchTokens(sm);
|
|
655
|
+
const labelHint = findLabelHint(sm, leafId, ANCHOR_HINT_MAX_LENGTH);
|
|
656
|
+
const positionLine = `${formatPct1(tokensHere, cw)}${cw > 0 ? ` of ${formatWindow(cw)}` : ""}`;
|
|
657
|
+
const hintLine = labelHint ? ` (after: “${labelHint}”)` : "";
|
|
658
|
+
return {
|
|
659
|
+
content: [
|
|
660
|
+
{
|
|
661
|
+
type: "text",
|
|
662
|
+
text:
|
|
663
|
+
`[anchor '${p.name}'] set at ${positionLine}${hintLine}\n\n` +
|
|
664
|
+
`When you finish this stage, call: navigate_tree(action='rewind', labelStart='${p.name}', labelEnd='<milestone-name>', summaryFocus='<≥${MIN_SUMMARY_FOCUS_LENGTH}-char focus: latest user instruction + done + remaining>').`,
|
|
665
|
+
},
|
|
666
|
+
],
|
|
667
|
+
details: {
|
|
668
|
+
label: p.name,
|
|
669
|
+
entryId: leafId,
|
|
670
|
+
contextTokens: tokensHere,
|
|
671
|
+
labelHint,
|
|
672
|
+
movedFromPriorEntry: prior && prior !== leafId ? prior : null,
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// --- rewind ---
|
|
678
|
+
if (!isValidName(p.labelStart)) {
|
|
679
|
+
return toolError(
|
|
680
|
+
`rewind requires \`labelStart\` in kebab-case, max ${MAX_NAME_LENGTH} chars.`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (!isValidName(p.labelEnd)) {
|
|
684
|
+
return toolError(
|
|
685
|
+
`rewind requires \`labelEnd\` in kebab-case, max ${MAX_NAME_LENGTH} chars.`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
if (
|
|
689
|
+
!p.summaryFocus ||
|
|
690
|
+
p.summaryFocus.trim().length < MIN_SUMMARY_FOCUS_LENGTH
|
|
691
|
+
) {
|
|
692
|
+
const focusLen = p.summaryFocus?.trim().length ?? 0;
|
|
693
|
+
return toolError(
|
|
694
|
+
`\`summaryFocus\` must be ≥${MIN_SUMMARY_FOCUS_LENGTH} chars after trim (got ${focusLen}). The user's most recent instruction (which triggered this rewind) lives on the chain that's about to be collapsed — if summaryFocus doesn't preserve it, the post-rewind turn won't know what's left to do.\n\n` +
|
|
695
|
+
`Include in summaryFocus:\n` +
|
|
696
|
+
` 1. the user's most recent instruction verbatim,\n` +
|
|
697
|
+
` 2. which parts have already been done in the work being collapsed,\n` +
|
|
698
|
+
` 3. which parts remain unactioned.`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const target = findLabeledEntry(sm, LABEL_PREFIX + p.labelStart);
|
|
703
|
+
if (!target) {
|
|
704
|
+
return toolError(
|
|
705
|
+
`No label '${p.labelStart}' on the active branch. Use action='list' to see available labels.`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const oldLeaf = sm.getLeafId();
|
|
710
|
+
if (!oldLeaf || oldLeaf === target) {
|
|
711
|
+
return toolError(
|
|
712
|
+
`Already at '${p.labelStart}' — nothing to summarize.`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (!ctx.model) {
|
|
717
|
+
return toolError("No model configured for summarization.");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
721
|
+
if (!auth.ok) {
|
|
722
|
+
return toolError(`Auth resolution failed: ${auth.error}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// The leaf at execute time is the assistant that just streamed the
|
|
726
|
+
// rewind tool call. Its `usage.input` is the *minimum* of recent API
|
|
727
|
+
// calls in this turn, so estimating from it understates what the
|
|
728
|
+
// user just saw. Use the chain up to its parent (which has the prior
|
|
729
|
+
// assistant's usage as baseline) so beforeTokens matches the value
|
|
730
|
+
// `list` would have reported on the previous turn.
|
|
731
|
+
const oldLeafEntry = sm.getEntry(oldLeaf);
|
|
732
|
+
let beforeTokens: number;
|
|
733
|
+
if (
|
|
734
|
+
oldLeafEntry &&
|
|
735
|
+
oldLeafEntry.type === "message" &&
|
|
736
|
+
oldLeafEntry.message.role === "assistant" &&
|
|
737
|
+
oldLeafEntry.parentId
|
|
738
|
+
) {
|
|
739
|
+
const allEntries = sm.getEntries();
|
|
740
|
+
const byId = new Map<string, SessionEntry>();
|
|
741
|
+
for (const e of allEntries) byId.set(e.id, e);
|
|
742
|
+
beforeTokens = estimateAtEntry(allEntries, oldLeafEntry.parentId, byId);
|
|
743
|
+
} else {
|
|
744
|
+
beforeTokens = estimateActiveBranchTokens(sm);
|
|
745
|
+
}
|
|
746
|
+
const contextWindow = ctx.model.contextWindow ?? 0;
|
|
747
|
+
|
|
748
|
+
const { entries } = collectEntriesForBranchSummary(sm, oldLeaf, target);
|
|
749
|
+
if (entries.length === 0) {
|
|
750
|
+
return toolError(
|
|
751
|
+
`No entries between leaf and '${p.labelStart}' — nothing to summarize.`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
// Chained-rewind-no-turns guard: bail if the only message between
|
|
755
|
+
// leaf and target matches our synthetic shape (single navigate_tree
|
|
756
|
+
// toolCall block + stopReason "toolUse" + zero usage), meaning the
|
|
757
|
+
// agent didn't append a real turn between rewinds. Keep only message
|
|
758
|
+
// entries — label / compaction / branch_summary / etc. carry no
|
|
759
|
+
// rewindable semantic content for this guard. The synthetic-shape
|
|
760
|
+
// predicate avoids false-positives on real navigate_tree calls
|
|
761
|
+
// (which have nonzero usage from the model).
|
|
762
|
+
const messageEntries = entries.filter((e) => e.type === "message");
|
|
763
|
+
if (messageEntries.length === 1) {
|
|
764
|
+
const lone = messageEntries[0];
|
|
765
|
+
if (lone.type === "message" && lone.message.role === "assistant") {
|
|
766
|
+
const msg = lone.message as {
|
|
767
|
+
content: Array<{ type?: string; name?: string }>;
|
|
768
|
+
stopReason?: string;
|
|
769
|
+
usage?: { input?: number; output?: number };
|
|
770
|
+
};
|
|
771
|
+
const block = msg.content[0];
|
|
772
|
+
const isSyntheticShape =
|
|
773
|
+
msg.stopReason === "toolUse" &&
|
|
774
|
+
(msg.usage?.input ?? 0) === 0 &&
|
|
775
|
+
(msg.usage?.output ?? 0) === 0;
|
|
776
|
+
if (
|
|
777
|
+
block &&
|
|
778
|
+
block.type === "toolCall" &&
|
|
779
|
+
block.name === "navigate_tree" &&
|
|
780
|
+
isSyntheticShape
|
|
781
|
+
) {
|
|
782
|
+
return toolError(
|
|
783
|
+
`Already at synthetic boundary — no work to summarize. Append at least one turn between rewinds.`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const result = await summarize(entries, {
|
|
790
|
+
model: ctx.model,
|
|
791
|
+
apiKey: auth.apiKey ?? "",
|
|
792
|
+
headers: auth.headers,
|
|
793
|
+
signal: signal ?? new AbortController().signal,
|
|
794
|
+
customInstructions: p.summaryFocus,
|
|
795
|
+
});
|
|
796
|
+
if (result.aborted) {
|
|
797
|
+
return toolError("Summarization aborted.");
|
|
798
|
+
}
|
|
799
|
+
if (result.error || !result.summary) {
|
|
800
|
+
return toolError(
|
|
801
|
+
`Summarization failed: ${result.error ?? "no summary text"}`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Move the tree.
|
|
806
|
+
const summaryId = sm.branchWithSummary(target, result.summary, {
|
|
807
|
+
readFiles: result.readFiles ?? [],
|
|
808
|
+
modifiedFiles: result.modifiedFiles ?? [],
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// Chain-validity invariants once `branchWithSummary` succeeds: a
|
|
812
|
+
// synthetic must land on every path with toolCallId === this
|
|
813
|
+
// in-flight call (so pi's appended tool_result pairs), and
|
|
814
|
+
// stopReason: "toolUse" (survives Kiro's normalizeMessages filter
|
|
815
|
+
// — see `buildSyntheticAssistant` JSDoc). The synthetic append
|
|
816
|
+
// sits OUTSIDE the try so it runs exactly once regardless of
|
|
817
|
+
// which earlier step threw. labelEnd write moves before clear,
|
|
818
|
+
// mirroring `anchor`'s move-on-collision so duplicate anchors
|
|
819
|
+
// can't survive a chained rewind.
|
|
820
|
+
const fullLabelEnd = LABEL_PREFIX + p.labelEnd;
|
|
821
|
+
let priorLabelEnd: ReturnType<typeof findLabeledEntry> = null;
|
|
822
|
+
let tokensAtNewLeaf = 0;
|
|
823
|
+
let originalErr: unknown;
|
|
824
|
+
let salvageDetail = "";
|
|
825
|
+
let failedStep:
|
|
826
|
+
| "lookup"
|
|
827
|
+
| "setLabelEnd"
|
|
828
|
+
| "clearPrior"
|
|
829
|
+
| "estimate"
|
|
830
|
+
| null = null;
|
|
831
|
+
try {
|
|
832
|
+
failedStep = "lookup";
|
|
833
|
+
priorLabelEnd = findLabeledEntry(sm, fullLabelEnd);
|
|
834
|
+
failedStep = "setLabelEnd";
|
|
835
|
+
pi.setLabel(summaryId, fullLabelEnd);
|
|
836
|
+
// `summaryId` was freshly allocated by branchWithSummary above;
|
|
837
|
+
// no pre-existing label can already point at it.
|
|
838
|
+
if (priorLabelEnd) {
|
|
839
|
+
failedStep = "clearPrior";
|
|
840
|
+
pi.setLabel(priorLabelEnd, undefined);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Compute afterTokens NOW — before we append the synthetic. This
|
|
844
|
+
// captures the chain size at the new leaf (branch_summary) using
|
|
845
|
+
// the prior real assistant's usage as the baseline.
|
|
846
|
+
failedStep = "estimate";
|
|
847
|
+
tokensAtNewLeaf = estimateActiveBranchTokens(sm);
|
|
848
|
+
failedStep = null;
|
|
849
|
+
} catch (err) {
|
|
850
|
+
originalErr = err;
|
|
851
|
+
// Best-effort retry of the specific failed step (pi.setLabel is
|
|
852
|
+
// idempotent under re-application). Per-step recovery shape:
|
|
853
|
+
// - setLabelEnd: retry pi.setLabel(summaryId, fullLabelEnd).
|
|
854
|
+
// - clearPrior: retry pi.setLabel(priorLabelEnd, undefined).
|
|
855
|
+
// - lookup / estimate: no retry — either prior state unknown
|
|
856
|
+
// or both labels already wrote; redundant retry would mask
|
|
857
|
+
// the real cause.
|
|
858
|
+
if (failedStep === "setLabelEnd") {
|
|
859
|
+
try {
|
|
860
|
+
pi.setLabel(summaryId, fullLabelEnd);
|
|
861
|
+
} catch (retryErr) {
|
|
862
|
+
salvageDetail = `labelEnd retry failed: ${
|
|
863
|
+
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
|
864
|
+
}`;
|
|
865
|
+
}
|
|
866
|
+
} else if (failedStep === "clearPrior" && priorLabelEnd) {
|
|
867
|
+
try {
|
|
868
|
+
pi.setLabel(priorLabelEnd, undefined);
|
|
869
|
+
} catch (retryErr) {
|
|
870
|
+
salvageDetail = `prior-clear retry failed: ${
|
|
871
|
+
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
|
872
|
+
}`;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Synthetic append: runs in BOTH the happy path and the salvage path.
|
|
878
|
+
// If `originalErr` is set we use a degenerate synthetic
|
|
879
|
+
// (totalTokens=0, since `tokensAtNewLeaf` may not have been computed).
|
|
880
|
+
// The synthetic's matching toolCallId is the only structural
|
|
881
|
+
// requirement for chain validity — pi's appended tool_result pairs
|
|
882
|
+
// with this synthetic regardless of which earlier step threw.
|
|
883
|
+
//
|
|
884
|
+
// The full `summaryFocus` is already live in the LLM call to
|
|
885
|
+
// `generateBranchSummary`; we only need a trimmed copy in the
|
|
886
|
+
// synthetic's args (which pi will re-emit on every subsequent turn).
|
|
887
|
+
// Truncate to MAX_SYNTHETIC_FOCUS_LENGTH so a long focus string
|
|
888
|
+
// doesn't inflate every later turn indefinitely.
|
|
889
|
+
const syntheticArgs: Record<string, unknown> = {
|
|
890
|
+
...(p as unknown as Record<string, unknown>),
|
|
891
|
+
};
|
|
892
|
+
if (
|
|
893
|
+
typeof p.summaryFocus === "string" &&
|
|
894
|
+
p.summaryFocus.length > MAX_SYNTHETIC_FOCUS_LENGTH
|
|
895
|
+
) {
|
|
896
|
+
syntheticArgs.summaryFocus = `${p.summaryFocus.slice(0, MAX_SYNTHETIC_FOCUS_LENGTH)}… [truncated]`;
|
|
897
|
+
}
|
|
898
|
+
const syntheticMsg = buildSyntheticAssistant(
|
|
899
|
+
toolCallId,
|
|
900
|
+
"navigate_tree",
|
|
901
|
+
syntheticArgs,
|
|
902
|
+
ctx.model as
|
|
903
|
+
| { api?: string; provider?: string; id?: string }
|
|
904
|
+
| undefined,
|
|
905
|
+
originalErr ? 0 : tokensAtNewLeaf,
|
|
906
|
+
);
|
|
907
|
+
const syntheticId = sm.appendMessage(syntheticMsg);
|
|
908
|
+
|
|
909
|
+
// Refresh agent.state.messages so the next prompt() snapshot reflects
|
|
910
|
+
// the rewound chain. Runs in both paths; `refreshAgentMessages`
|
|
911
|
+
// already swallows internal throws, so it can't re-trigger salvage.
|
|
912
|
+
const refreshed = refreshAgentMessages(sm);
|
|
913
|
+
|
|
914
|
+
if (originalErr) {
|
|
915
|
+
// Salvage path: synthetic landed (chain is valid), labelEnd retry
|
|
916
|
+
// and refresh were best-effort. Re-throw the original error with
|
|
917
|
+
// any salvage detail attached so the failure surfaces to the
|
|
918
|
+
// agent and post-mortem reviewers can tell what was recovered.
|
|
919
|
+
// Preserve the original via `Error.cause` (ES2022) so callers
|
|
920
|
+
// doing `instanceof` checks against typed subclasses, or
|
|
921
|
+
// post-mortem readers walking the cause chain, can recover the
|
|
922
|
+
// original throw. Older runtimes silently ignore the options
|
|
923
|
+
// bag, so this is forward-compatible without a feature gate.
|
|
924
|
+
const baseMsg =
|
|
925
|
+
originalErr instanceof Error
|
|
926
|
+
? originalErr.message
|
|
927
|
+
: String(originalErr);
|
|
928
|
+
throw new Error(
|
|
929
|
+
salvageDetail ? `${baseMsg} (salvage: ${salvageDetail})` : baseMsg,
|
|
930
|
+
{ cause: originalErr },
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const afterTokens = tokensAtNewLeaf;
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
content: [
|
|
938
|
+
{
|
|
939
|
+
type: "text",
|
|
940
|
+
text:
|
|
941
|
+
`[rewind '${p.labelStart}' → '${p.labelEnd}'] · ${formatContextDelta(beforeTokens, afterTokens, contextWindow)}\n\n` +
|
|
942
|
+
`A branch_summary recording the work just collapsed has been appended to your context. Items under '### Done' are complete. Items under '### In Progress', '### Blocked', or '## Next Steps' are pending — execute them next without re-confirming with the user. Other branch_summary messages, if present, record earlier collapsed segments.` +
|
|
943
|
+
(refreshed ? "" : `\n\n${REFLECTION_BOOTSTRAP_WARNING_REWIND}`),
|
|
944
|
+
},
|
|
945
|
+
],
|
|
946
|
+
details: {
|
|
947
|
+
labelStart: p.labelStart,
|
|
948
|
+
labelEnd: p.labelEnd,
|
|
949
|
+
targetId: target,
|
|
950
|
+
summaryId,
|
|
951
|
+
syntheticAssistantId: syntheticId,
|
|
952
|
+
collapsedEntries: entries.length,
|
|
953
|
+
contextBefore: beforeTokens,
|
|
954
|
+
contextAfter: afterTokens,
|
|
955
|
+
contextWindow,
|
|
956
|
+
agentMessagesRefreshed: refreshed,
|
|
957
|
+
readFiles: result.readFiles ?? [],
|
|
958
|
+
modifiedFiles: result.modifiedFiles ?? [],
|
|
959
|
+
},
|
|
960
|
+
};
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Non-stable testing-only hooks. **Do NOT import in production code.**
|
|
967
|
+
*
|
|
968
|
+
* The `__` prefix and individual member names are subject to change in any
|
|
969
|
+
* release without a semver-major bump. Intended exclusively for hermetic
|
|
970
|
+
* tests within this package; the hooks reach into module-internal state
|
|
971
|
+
* (the `AgentSession.prototype` patch, the `seenSessions` WeakSet, the
|
|
972
|
+
* `sessionInstances` array, the `prepareNextTurn` marker symbol) and are
|
|
973
|
+
* not designed for external consumption.
|
|
974
|
+
*
|
|
975
|
+
* If you found this via `node_modules` archaeology, you're holding it
|
|
976
|
+
* wrong — use the registered tool surface (`navigate_tree`) instead.
|
|
977
|
+
*/
|
|
978
|
+
export const __testHooks = {
|
|
979
|
+
/**
|
|
980
|
+
* Restore the original `AgentSession.prototype.prompt` (stashed by
|
|
981
|
+
* `patchAgentSessionPrototype` under the `ORIG_PROMPT_KEY` symbol) and
|
|
982
|
+
* drain the captured-session refs. Idempotent: a no-op if the patch was
|
|
983
|
+
* never installed or has already been reset.
|
|
984
|
+
*/
|
|
985
|
+
resetPrototype(): void {
|
|
986
|
+
const proto = AgentSession.prototype as unknown as Record<
|
|
987
|
+
PropertyKey,
|
|
988
|
+
unknown
|
|
989
|
+
>;
|
|
990
|
+
const orig = proto[ORIG_PROMPT_KEY];
|
|
991
|
+
if (typeof orig === "function") {
|
|
992
|
+
proto.prompt = orig;
|
|
993
|
+
delete proto[ORIG_PROMPT_KEY];
|
|
994
|
+
}
|
|
995
|
+
sessionInstances.length = 0;
|
|
996
|
+
// WeakSet has no .clear(); rebind to a fresh instance so a test that
|
|
997
|
+
// re-captures the SAME session identity post-reset isn't deduped by
|
|
998
|
+
// stale state from the previous test's capture.
|
|
999
|
+
seenSessions = new WeakSet();
|
|
1000
|
+
},
|
|
1001
|
+
/** Module-internal helpers exposed for hermetic unit tests. */
|
|
1002
|
+
buildSyntheticAssistant,
|
|
1003
|
+
findLabelHint,
|
|
1004
|
+
findLabeledEntry,
|
|
1005
|
+
installPrepareNextTurn,
|
|
1006
|
+
refreshAgentMessages,
|
|
1007
|
+
captureSession,
|
|
1008
|
+
/** Symbols used to mark the wrappers installed by `installPrepareNextTurn`
|
|
1009
|
+
* (`PNT_MARKER` on `agent.prepareNextTurn`, `PNTWC_MARKER` on
|
|
1010
|
+
* `agent.prepareNextTurnWithContext` — the field pi ≥0.80.3 prefers). */
|
|
1011
|
+
PNT_MARKER,
|
|
1012
|
+
PNTWC_MARKER,
|
|
1013
|
+
/** Read-only view of captured-session ref count for reaping assertions. */
|
|
1014
|
+
sessionRefCount(): number {
|
|
1015
|
+
return sessionInstances.length;
|
|
1016
|
+
},
|
|
1017
|
+
/**
|
|
1018
|
+
* The reflection-bootstrap-missing warning strings, split per site.
|
|
1019
|
+
* Exported so tests can pin the per-site verbatim wording (the `list`
|
|
1020
|
+
* site is read-only and uses the read-only phrasing; the `rewind` site
|
|
1021
|
+
* writes to disk and uses the write phrasing). Tests assert literal
|
|
1022
|
+
* containment at each site to catch drift.
|
|
1023
|
+
*/
|
|
1024
|
+
REFLECTION_BOOTSTRAP_WARNING_LIST,
|
|
1025
|
+
REFLECTION_BOOTSTRAP_WARNING_REWIND,
|
|
1026
|
+
};
|