@gotgenes/pi-subagents 15.0.2 → 16.1.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 +23 -0
- package/README.md +24 -24
- package/docs/architecture/architecture.md +111 -18
- package/docs/plans/0381-replace-concurrency-queue-with-limiter.md +267 -0
- package/docs/plans/0400-include-parent-prompt-in-replace-mode.md +199 -0
- package/docs/retro/0381-replace-concurrency-queue-with-limiter.md +49 -0
- package/docs/retro/0400-include-parent-prompt-in-replace-mode.md +84 -0
- package/package.json +1 -1
- package/src/index.ts +8 -15
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/subagent-manager.ts +38 -35
- package/src/lifecycle/subagent.ts +2 -1
- package/src/session/prompts.ts +25 -20
- package/src/lifecycle/concurrency-queue.ts +0 -63
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 381
|
|
3
|
+
issue_title: "Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
The `ConcurrencyQueue` stores background-agent IDs and decides *when* to start them, but it cannot start an agent itself.
|
|
11
|
+
It compensates with a `startAgent(id)` callback that reaches back into the manager (`getRecord(id)`, status check, `run()`) — a dependency back-edge that forces forward-referenced bindings in both `index.ts` and the manager test helper.
|
|
12
|
+
The queue also keeps its own `running` counter, fed by `markStarted`/`markFinished` relays in the manager's observer, duplicating state the agents already carry.
|
|
13
|
+
A queued agent has `promise === undefined` until the queue starts it, which is the direct cause of `waitForAll`'s `while (true)` drain loop and its `eslint-disable`.
|
|
14
|
+
|
|
15
|
+
These are three symptoms of one root cause: the queue schedules *identifiers it cannot act on* instead of *work it can run*.
|
|
16
|
+
Scheduling thunks (`() => Promise<void>`) instead of IDs dissolves all three at the source.
|
|
17
|
+
|
|
18
|
+
This is Phase 17 Step 1 (core consolidation), recorded in `docs/architecture/architecture.md` under "Improvement roadmap (Phase 17 — core consolidation)".
|
|
19
|
+
It unblocks Phase 17 Step 3 ([#374], run-start encapsulation).
|
|
20
|
+
|
|
21
|
+
## Goals
|
|
22
|
+
|
|
23
|
+
- Replace `ConcurrencyQueue` (ID registry + back-edge callback) with a `ConcurrencyLimiter` that schedules run closures FIFO against a dynamic limit and knows nothing about agents, IDs, or the manager.
|
|
24
|
+
- Make the dependency direction strictly `SubagentManager → ConcurrencyLimiter`: no callback back-edge, no forward-referenced bindings.
|
|
25
|
+
- Derive the active count from the limiter's own task lifecycle (increment on task start, decrement on settle); delete the observer's `markStarted`/`markFinished` relays.
|
|
26
|
+
- Give every spawned agent a real `promise` at spawn time, collapsing `waitForAll`'s `while (true)` drain loop and its `eslint-disable`.
|
|
27
|
+
- This is a non-breaking internal refactor: the FIFO admission behavior against `maxConcurrent` is preserved, and no public API, config key, or observable behavior changes.
|
|
28
|
+
|
|
29
|
+
## Non-Goals
|
|
30
|
+
|
|
31
|
+
- Renaming the `bypassQueue` spawn option.
|
|
32
|
+
It is part of the published `SubagentsService` type surface (`src/service/service.ts`), so renaming it would churn the type bundle and break consumers — out of scope; track in Open Questions.
|
|
33
|
+
- Folding the queued-status guard into `Subagent.start()` — that is Phase 17 Step 3 ([#374]).
|
|
34
|
+
This plan keeps the guard inside the scheduled thunk.
|
|
35
|
+
- Extracting `SubagentState` or making execution deps mandatory ([#373], Step 2).
|
|
36
|
+
- Any change to foreground execution (`spawnAndWait`) or to `bypassQueue` runs — both continue to invoke `record.run()` directly, never touching the limiter.
|
|
37
|
+
- Touching `src/service/service.ts` or `src/service/service-adapter.ts` — `bypassQueue` flows through unchanged.
|
|
38
|
+
|
|
39
|
+
## Background
|
|
40
|
+
|
|
41
|
+
Relevant modules:
|
|
42
|
+
|
|
43
|
+
- `src/lifecycle/concurrency-queue.ts` — the current `ConcurrencyQueue`: `isFull`, `enqueue`, `dequeue`, `markStarted`, `markFinished`, `drain`, `clear`, `queuedIds`.
|
|
44
|
+
Stores IDs; `drain()` calls the injected `startAgent(id)` back-edge.
|
|
45
|
+
- `src/lifecycle/subagent-manager.ts` — injects the queue via `SubagentManagerOptions.queue`.
|
|
46
|
+
`buildObserver` relays `markStarted`/`markFinished`; `spawn` enqueues when `isFull()`; `abort` calls `dequeue`; `abortAll` iterates `queuedIds` + `clear()`; `waitForAll` loops `drain()` + `Promise.allSettled`; `dispose` calls `clear()`.
|
|
47
|
+
- `src/index.ts` — constructs the queue with a `startAgent` callback that forward-references the manager (`manager.getRecord(id)` then `agent.run()`); wires `settings.onMaxConcurrentChanged` to `queue.drain()`.
|
|
48
|
+
- `src/lifecycle/subagent.ts` — `run()` sets status to `running` synchronously (`markRunning`) before its first `await`; `run()` always resolves (errors captured internally).
|
|
49
|
+
`abort()` acts only on `running` agents; its docstring references `ConcurrencyQueue.dequeue()`.
|
|
50
|
+
- `test/lifecycle/subagent-manager.test.ts` — `createManager` helper replicates the `index.ts` start callback with a `prefer-const` `eslint-disable` for the forward reference.
|
|
51
|
+
- `test/lifecycle/concurrency-queue.test.ts` — unit tests for the queue (drain ordering, `markStarted`/`markFinished` counting, `enqueue`/`dequeue`).
|
|
52
|
+
|
|
53
|
+
Constraints from AGENTS.md and skills:
|
|
54
|
+
|
|
55
|
+
- ES2024 `Promise.withResolvers` is available and preferred (`code-design` skill).
|
|
56
|
+
- The `bypassQueue` field lives in the public type bundle (`exports`, `verify:public-types`); renaming public surface is breaking (`package-pi-subagents` skill).
|
|
57
|
+
- `@typescript-eslint/require-await` is enabled for `src/`; a thunk with no `await` must return a `Promise` without `async`.
|
|
58
|
+
- Where the old `drain()` used `while (… && !isFull())` with `this.queue.shift()!`, prefer a bounded loop without a non-null assertion (`code-design` Biome/ESLint notes).
|
|
59
|
+
|
|
60
|
+
The current observer-relay path (`buildObserver` → `queue.markStarted`/`markFinished`) confirmed: the queue's `running` counter mirrors the per-agent status the manager already tracks (the manager filters on `status === "running" || "queued"` in `cleanup`, `clearCompleted`, `hasRunning`, `waitForAll`).
|
|
61
|
+
No production caller awaits a *queued* agent's promise (`get-result-tool.ts` guards on `status === "running"`; `spawnAndWait` is foreground; `waitForAll` filters by status), so giving queued agents a settled-on-completion promise is safe.
|
|
62
|
+
|
|
63
|
+
## Design Overview
|
|
64
|
+
|
|
65
|
+
### `ConcurrencyLimiter`
|
|
66
|
+
|
|
67
|
+
A pure FIFO scheduler over thunks.
|
|
68
|
+
It owns the active count and the pending queue; it has no knowledge of agents, IDs, or the manager.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
export class ConcurrencyLimiter {
|
|
72
|
+
private active = 0;
|
|
73
|
+
private readonly pending: Array<{ start: () => void; settle: () => void }> = [];
|
|
74
|
+
|
|
75
|
+
constructor(private readonly getLimit: () => number) {}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Schedule a task to run FIFO once a slot is free.
|
|
79
|
+
* The returned promise always settles: it follows the task's settlement when
|
|
80
|
+
* the task runs, or resolves early if clear() drops it before it starts.
|
|
81
|
+
*/
|
|
82
|
+
schedule(task: () => Promise<void>): Promise<void> {
|
|
83
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
84
|
+
this.pending.push({
|
|
85
|
+
start: () => {
|
|
86
|
+
this.active++;
|
|
87
|
+
task().then(resolve, reject).finally(() => {
|
|
88
|
+
this.active--;
|
|
89
|
+
this.recheck();
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
settle: resolve,
|
|
93
|
+
});
|
|
94
|
+
this.recheck();
|
|
95
|
+
return promise;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Start pending tasks until the limit is reached. Call when the limit may have grown. */
|
|
99
|
+
recheck(): void {
|
|
100
|
+
while (this.active < this.getLimit()) {
|
|
101
|
+
const next = this.pending.shift();
|
|
102
|
+
if (!next) break;
|
|
103
|
+
next.start();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Drop all pending tasks, resolving their promises without running them. */
|
|
108
|
+
clear(): void {
|
|
109
|
+
const dropped = this.pending.splice(0);
|
|
110
|
+
for (const task of dropped) task.settle();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Design decisions:
|
|
116
|
+
|
|
117
|
+
- **Active count derived from task lifecycle.**
|
|
118
|
+
`active++` happens synchronously inside `start()` before the task's first `await`; `active--` runs in `finally`.
|
|
119
|
+
This replaces the queue's `running` counter and the two observer relays.
|
|
120
|
+
- **`recheck()` is bounded.**
|
|
121
|
+
The loop terminates when the limit is reached or the pending queue empties — no `while (true)`, no `this.pending.shift()!` non-null assertion.
|
|
122
|
+
- **`clear()` settles dropped promises.**
|
|
123
|
+
Every `schedule()` promise becomes `record.promise`; the contract is that it always settles.
|
|
124
|
+
Dropping a thunk without resolving would leave a forever-pending `record.promise`.
|
|
125
|
+
`clear()` resolves dropped tasks so `dispose()`/`abortAll()` cannot strand a promise. (This is a few lines beyond the issue's "~40 lines" sketch; the extra `settle` handle is the deliberate cost of that invariant.)
|
|
126
|
+
- **Synchronous start.**
|
|
127
|
+
When a slot is free, `schedule()` runs the thunk synchronously inside `recheck()`, so `record.run()` executes its synchronous prefix (`markRunning`) immediately — preserving today's behavior where `record.promise = record.run()` flips status to `running` at once.
|
|
128
|
+
|
|
129
|
+
### Manager spawn call site
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
// spawn(), background and not bypassQueue:
|
|
133
|
+
record.promise = this.limiter.schedule(() => {
|
|
134
|
+
// Guard: an abort-while-queued task is a no-op (Step 3 folds this into Subagent.start()).
|
|
135
|
+
if (record.status !== "queued") return Promise.resolve();
|
|
136
|
+
return record.run();
|
|
137
|
+
});
|
|
138
|
+
// foreground or bypassQueue:
|
|
139
|
+
record.promise = record.run();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
This is Tell-Don't-Ask toward the limiter: the manager hands it work, the limiter decides timing.
|
|
143
|
+
The status guard replaces `dequeue` — an aborted queued agent (status `stopped`) becomes a no-op when its slot finally opens.
|
|
144
|
+
|
|
145
|
+
### Manager lifecycle methods
|
|
146
|
+
|
|
147
|
+
- `buildObserver` — drop the `markStarted` (in `onStarted`) and `markFinished` (in `onRunFinished`) relays; `onRunFinished` keeps the background `onSubagentCompleted` dispatch.
|
|
148
|
+
- `abort(id)` — for a `queued` agent, just `record.markStopped()` (no `dequeue`); otherwise `record.abort()`.
|
|
149
|
+
- `abortAll()` — iterate agents: `markStopped()` each `queued` agent (count it), else `record.abort()`; then `this.limiter.clear()` to drop pending thunks (their promises resolve).
|
|
150
|
+
- `waitForAll()` — every spawned agent has a `promise`, so the manual `drain()` loop collapses:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
async waitForAll(): Promise<void> {
|
|
154
|
+
let pending = this.pendingPromises();
|
|
155
|
+
while (pending.length > 0) {
|
|
156
|
+
await Promise.allSettled(pending);
|
|
157
|
+
pending = this.pendingPromises();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private pendingPromises(): Promise<void>[] {
|
|
162
|
+
return [...this.agents.values()]
|
|
163
|
+
.filter(r => r.status === "running" || r.status === "queued")
|
|
164
|
+
.map(r => r.promise)
|
|
165
|
+
.filter((p): p is Promise<void> => p != null);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The re-check loop is no longer `while (true)` and no longer drives scheduling — the limiter auto-starts queued agents as slots free, so a single `allSettled` covers the queued case.
|
|
170
|
+
The loop survives only to catch agents spawned *during* the wait.
|
|
171
|
+
The `eslint-disable @typescript-eslint/no-unnecessary-condition` is deleted.
|
|
172
|
+
- `dispose()` — `this.limiter.clear()` (unchanged in intent).
|
|
173
|
+
|
|
174
|
+
### `index.ts` wiring
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
const settings = new SettingsManager({
|
|
178
|
+
// …
|
|
179
|
+
onMaxConcurrentChanged: () => limiter.recheck(), // forward-ref closure (settings → limiter); benign
|
|
180
|
+
});
|
|
181
|
+
settings.load();
|
|
182
|
+
// …
|
|
183
|
+
const limiter = new ConcurrencyLimiter(() => settings.maxConcurrent);
|
|
184
|
+
const manager = new SubagentManager({ /* … */ limiter, /* … */ });
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The only surviving forward reference is `settings → limiter` (a runtime-only closure, the same shape as today's `settings → queue.drain`).
|
|
188
|
+
The `limiter → manager` back-edge (the `startAgent` callback and its explanatory comment) is **deleted entirely** — that is the structural win.
|
|
189
|
+
|
|
190
|
+
### Edge cases
|
|
191
|
+
|
|
192
|
+
- **Abort while queued** — `markStopped()` flips status; the scheduled thunk, when run, returns `Promise.resolve()` (no-op), settling `record.promise`.
|
|
193
|
+
- **Limit decreased below active count** — `recheck()` simply starts nothing (`active < getLimit()` is false); in-flight tasks finish normally.
|
|
194
|
+
- **Limit increased** — `onMaxConcurrentChanged → limiter.recheck()` starts newly-admissible pending tasks.
|
|
195
|
+
- **`clear()` with in-flight tasks** — only *pending* tasks are dropped; running tasks complete and `active--` on settle.
|
|
196
|
+
|
|
197
|
+
## Module-Level Changes
|
|
198
|
+
|
|
199
|
+
| File | Change |
|
|
200
|
+
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
201
|
+
| `src/lifecycle/concurrency-limiter.ts` | Add — new `ConcurrencyLimiter` (`schedule`, `recheck`, `clear`). |
|
|
202
|
+
| `src/lifecycle/concurrency-queue.ts` | Remove — replaced by the limiter. |
|
|
203
|
+
| `src/lifecycle/subagent-manager.ts` | Change — import limiter; `SubagentManagerOptions.queue` → `limiter: ConcurrencyLimiter` and the private field; drop `markStarted`/`markFinished` from `buildObserver`; `spawn` schedules a status-guarded thunk; `abort` drops `dequeue`; `abortAll` iterates agents + `limiter.clear()`; `waitForAll` simplified (add `pendingPromises` helper, delete the `while (true)` loop and its `eslint-disable`); `dispose` calls `limiter.clear()`; update the file-header comment. |
|
|
204
|
+
| `src/lifecycle/subagent.ts` | Change — `abort()` docstring: remove the `ConcurrencyQueue.dequeue()` reference (queue removal is now a status-guard no-op). |
|
|
205
|
+
| `src/index.ts` | Change — import `ConcurrencyLimiter`; construct it as `new ConcurrencyLimiter(() => settings.maxConcurrent)`; `onMaxConcurrentChanged: () => limiter.recheck()`; delete the `startAgent` callback and its forward-ref comment; inject `limiter` into the manager. |
|
|
206
|
+
| `test/lifecycle/concurrency-limiter.test.ts` | Add — limiter unit tests (no `startAgent` mock). |
|
|
207
|
+
| `test/lifecycle/concurrency-queue.test.ts` | Remove — the queue is gone. |
|
|
208
|
+
| `test/lifecycle/subagent-manager.test.ts` | Change — `createManager` constructs a `ConcurrencyLimiter`; delete the forward-ref `let mgr` + `prefer-const` `eslint-disable`; drop the unused `queue` field from the returned object. |
|
|
209
|
+
| `docs/architecture/architecture.md` | Change — Mermaid lifecycle node (`ConcurrencyQueue<br/>(scheduling, drain)` → `ConcurrencyLimiter<br/>(thunk admission gate)`); layout listing (`concurrency-queue.ts` → `concurrency-limiter.ts`); "What the core owns" bullet; mark roadmap Step 1 done; fix the Step 7 ([#378]) target filename reference. |
|
|
210
|
+
| `.pi/skills/package-pi-subagents/SKILL.md` | Change — lifecycle-domain table: `concurrency-queue.ts` → `concurrency-limiter.ts` and adjust the "scheduling" wording to "concurrency admission". |
|
|
211
|
+
|
|
212
|
+
Verified by grep that no other `src/`, `test/`, `docs/` (excluding `docs/architecture/history/` and prior plans/retros, which are historical), or `.pi/skills/` file references `ConcurrencyQueue`, `concurrency-queue`, `enqueue`, `dequeue`, `markStarted`/`markFinished` (queue), `drain`, `isFull`, or `queuedIds` for this queue.
|
|
213
|
+
`SKILL.md` line 80 (Phase 15 history) keeps `ConcurrencyQueue` — it is a historical record, not current state.
|
|
214
|
+
|
|
215
|
+
## Test Impact Analysis
|
|
216
|
+
|
|
217
|
+
1. **New tests the change enables.**
|
|
218
|
+
`ConcurrencyLimiter` is a pure thunk scheduler with no agent/manager knowledge, so it is unit-testable with plain `() => Promise<void>` tasks and `Promise.withResolvers` gates — no `startAgent` mock, no re-entrant `markStarted` simulation.
|
|
219
|
+
New coverage: FIFO start order; slot gating (only `limit` tasks run concurrently); `active` decrement frees a slot for the next pending task on settle; `recheck()` starts newly-admissible tasks when the limit grows; dynamic limit re-evaluation; `clear()` resolves pending promises without running their tasks; a task that rejects still frees its slot.
|
|
220
|
+
2. **Tests that become redundant.**
|
|
221
|
+
The entire `test/lifecycle/concurrency-queue.test.ts` (`isFull`, `enqueue`/`dequeue`, `markStarted`/`markFinished`, `drain`, auto-drain, `clear`, `queuedIds`) — those methods no longer exist; the limiter tests replace them at a cleaner seam.
|
|
222
|
+
3. **Tests that stay as-is (genuinely exercise the layer).**
|
|
223
|
+
The `SubagentManager — queueing and concurrency with injected stubs` describe block asserts manager-level behavior (queued → running transition order, abort-while-queued never runs the factory, `onSubagentStarted` fires on the queued → running transition).
|
|
224
|
+
These remain valid against the manager + limiter integration and need only the `createManager` helper change (construct a `ConcurrencyLimiter`), not a behavioral rewrite.
|
|
225
|
+
The `clearCompleted does not remove running or queued agents` test (maxConcurrent=1, blocking factory) also stays.
|
|
226
|
+
|
|
227
|
+
## TDD Order
|
|
228
|
+
|
|
229
|
+
Priority = preparatory addition first, then the atomic interface swap, then docs.
|
|
230
|
+
|
|
231
|
+
1. **Add `ConcurrencyLimiter` (red → green).**
|
|
232
|
+
Surface: new `test/lifecycle/concurrency-limiter.test.ts` against new `src/lifecycle/concurrency-limiter.ts`.
|
|
233
|
+
Covers FIFO start order, slot gating, `active`-frees-slot-on-settle, `recheck()` on limit growth, dynamic limit, `clear()` resolves pending without running, reject-frees-slot.
|
|
234
|
+
Pure addition — `ConcurrencyQueue` still exists and its tests still pass; the suite stays green.
|
|
235
|
+
Commit: `feat(pi-subagents): add ConcurrencyLimiter (#381)`.
|
|
236
|
+
|
|
237
|
+
2. **Migrate `SubagentManager`, `index.ts`, and the manager test helper to the limiter; delete the queue (red → green).**
|
|
238
|
+
Surface: `src/lifecycle/subagent-manager.ts`, `src/index.ts`, `src/lifecycle/subagent.ts` (docstring), `test/lifecycle/subagent-manager.test.ts`, and deletion of `src/lifecycle/concurrency-queue.ts` + `test/lifecycle/concurrency-queue.test.ts`.
|
|
239
|
+
This is one atomic commit: changing `SubagentManagerOptions.queue` → `limiter` breaks both call sites (`index.ts` and the test helper) at the type level simultaneously, and the old test file imports the deleted source — all must land together.
|
|
240
|
+
Drop the observer relays, the `dequeue`/`drain`/`isFull`/`queuedIds` usage, the `while (true)` loop + its `eslint-disable`, and the test helper's forward-ref `eslint-disable`.
|
|
241
|
+
Run `pnpm run check` immediately after (shared-interface change with multiple call sites), then the full `pnpm --filter @gotgenes/pi-subagents exec vitest run` (the queueing/concurrency integration tests must still pass).
|
|
242
|
+
Commit: `refactor(pi-subagents): replace ConcurrencyQueue with thunk-based ConcurrencyLimiter (#381)`.
|
|
243
|
+
|
|
244
|
+
3. **Update architecture doc and package skill (docs).**
|
|
245
|
+
Surface: `docs/architecture/architecture.md` (Mermaid node, layout listing, "What the core owns" bullet, roadmap Step 1 marked done, Step 7 filename reference) and `.pi/skills/package-pi-subagents/SKILL.md` (lifecycle-domain table entry + wording).
|
|
246
|
+
Commit: `docs(pi-subagents): update architecture and skill for ConcurrencyLimiter (#381)`.
|
|
247
|
+
|
|
248
|
+
## Risks and Mitigations
|
|
249
|
+
|
|
250
|
+
| Risk | Mitigation |
|
|
251
|
+
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
252
|
+
| A dropped pending thunk leaves `record.promise` forever pending. | `clear()` resolves dropped tasks' promises; the limiter's contract is that every `schedule()` promise settles. |
|
|
253
|
+
| `waitForAll` could spin or miss queued agents. | Queued agents now carry real promises, so a single `Promise.allSettled` covers them; the bounded re-check loop only catches agents spawned during the wait, and terminates when `pendingPromises()` is empty. |
|
|
254
|
+
| An abort-while-queued no-op thunk briefly occupies a slot. | The thunk returns a synchronously-resolved promise; `active++`/`active--` round-trip in one microtask and `recheck()` immediately pulls the next task — negligible. |
|
|
255
|
+
| Renaming the file/class leaves stale references. | Grep-verified inventory in Module-Level Changes; the migration deletes the source and its test in the same commit; docs updated in step 3. |
|
|
256
|
+
| `bypassQueue` public-surface name now slightly misnames the mechanism. | Out of scope (breaking); recorded in Open Questions. |
|
|
257
|
+
|
|
258
|
+
## Open Questions
|
|
259
|
+
|
|
260
|
+
- Should `bypassQueue` be renamed (e.g. `bypassLimiter`) for accuracy?
|
|
261
|
+
It is public type surface, so a rename is breaking and belongs in its own change — defer.
|
|
262
|
+
- Should the `code-design` "narrow interface, not concrete class" guidance be applied to the manager's `limiter` field (typed as `{ schedule; clear }` rather than the concrete `ConcurrencyLimiter`)?
|
|
263
|
+
Tests construct a real limiter (it is pure and trivially constructible), so no mock-cast pressure exists today; keep the concrete type to match the issue and existing pattern, and revisit only if a test needs to substitute it.
|
|
264
|
+
|
|
265
|
+
[#373]: https://github.com/gotgenes/pi-packages/issues/373
|
|
266
|
+
[#374]: https://github.com/gotgenes/pi-packages/issues/374
|
|
267
|
+
[#378]: https://github.com/gotgenes/pi-packages/issues/378
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 400
|
|
3
|
+
issue_title: "perf(pi-subagents): include parent system prompt in replace mode for KV cache reuse"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Include parent system prompt in replace mode for KV cache reuse
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
In replace mode, `buildAgentPrompt()` discards the parent system prompt entirely and substitutes a thin two-line header (`"You are a pi coding agent sub-agent. / You have been invoked to handle a specific task autonomously."`).
|
|
11
|
+
Replace-mode agents therefore lose the core identity, tool-usage guidelines, and AGENTS.md context the parent carries, and they share no prompt prefix with the parent or with each other — defeating LLM KV cache reuse.
|
|
12
|
+
The `parentSystemPrompt` parameter is already passed into `buildAgentPrompt()` but the replace branch ignores it.
|
|
13
|
+
|
|
14
|
+
## Goals
|
|
15
|
+
|
|
16
|
+
- Place the parent system prompt (or `genericBase` when no parent is available) at the front of the replace-mode prompt as a shared, cacheable prefix.
|
|
17
|
+
- Order the replace-mode prompt as: parent/`genericBase` → `<active_agent>` tag → env block → `config.systemPrompt`.
|
|
18
|
+
- Preserve the distinguishing feature of replace mode: it injects neither the `<sub_agent_context>` bridge nor the `<agent_instructions>` wrapper — the custom prompt keeps full control of the agent's instructions, placed last so it has the final say.
|
|
19
|
+
- Apply the change uniformly to every replace-mode agent, including the built-in `Explore` and `Plan` agents.
|
|
20
|
+
- This is a **breaking change**: replace-mode agents (including `Explore`/`Plan` and any custom `prompt_mode: replace` agent) now inherit the parent system prompt on upgrade with no user edit, and the thin two-line header is removed.
|
|
21
|
+
Ship it as `perf!:` with a `BREAKING CHANGE:` footer.
|
|
22
|
+
|
|
23
|
+
## Non-Goals
|
|
24
|
+
|
|
25
|
+
- No change to append-mode assembly (already reordered for KV cache in [#180]).
|
|
26
|
+
- No change to how `parentSystemPrompt` is sourced — `create-subagent-session.ts` already passes `snapshot.systemPrompt` through `session-config.ts`.
|
|
27
|
+
- No new mode or flag to distinguish "replace with parent" from "replace without parent" — the operator confirmed the change applies uniformly, so `Explore`/`Plan` are not special-cased.
|
|
28
|
+
- No change to `pi-permission-system` — its `<active_agent>` tag parsing is a full-string regex search, position-independent.
|
|
29
|
+
- No change to `pi-anthropic-auth` — its OAuth shaping is unaffected (see Background).
|
|
30
|
+
|
|
31
|
+
## Background
|
|
32
|
+
|
|
33
|
+
`buildAgentPrompt()` in `packages/pi-subagents/src/session/prompts.ts` assembles the child system prompt.
|
|
34
|
+
The append branch was reordered in [#180] (shipped in `pi-subagents-v6.18.3`) to place shared/stable content first; the parent prompt is placed verbatim (no wrapper tag) so it forms an identical byte prefix with the parent session, maximising KV cache hits.
|
|
35
|
+
The replace branch was left untouched and still emits the thin header.
|
|
36
|
+
|
|
37
|
+
Current replace branch:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// "replace" mode — env header + the config's full system prompt
|
|
41
|
+
const replaceHeader = `You are a pi coding agent sub-agent.
|
|
42
|
+
You have been invoked to handle a specific task autonomously.
|
|
43
|
+
|
|
44
|
+
${envBlock}`;
|
|
45
|
+
|
|
46
|
+
return activeAgentTag + replaceHeader + "\n\n" + config.systemPrompt;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`const identity = parentSystemPrompt ?? genericBase;` currently lives inside the append branch.
|
|
50
|
+
`genericBase` (a `# Role` / general-purpose coding agent blurb) is the shared fallback.
|
|
51
|
+
|
|
52
|
+
### Cross-extension interaction — `pi-anthropic-auth` OAuth
|
|
53
|
+
|
|
54
|
+
The operator asked how the `genericBase` fallback interacts with `@gotgenes/pi-anthropic-auth`.
|
|
55
|
+
Findings from reading that package's `src/system-prompt-shaping.ts` and `src/request-shaping.ts`:
|
|
56
|
+
|
|
57
|
+
- The OAuth de-fingerprinting (`shapeAnthropicOAuthSystemPrompt`) only activates when the system prompt contains `PI_DEFAULT_PROMPT_PREFIX` (Pi's default expert-coding-assistant preamble); otherwise it returns the prompt untouched.
|
|
58
|
+
- The `x-anthropic-billing-header` system block is prepended **unconditionally** for every OAuth request (`prependBillingHeader`), independent of the base prompt content — this is the primary Claude Code billing signal.
|
|
59
|
+
|
|
60
|
+
Implications for this change:
|
|
61
|
+
|
|
62
|
+
- Normal case (parent present): replace mode places the parent prompt verbatim at the front, structurally identical to append mode, which already works under the OAuth transport wrapper.
|
|
63
|
+
The inherited Pi preamble is de-fingerprinted exactly as it is for append-mode subagents and the main session today.
|
|
64
|
+
- `genericBase` fallback (only when the parent snapshot has no system prompt — effectively never in real sessions, since `parentSystemPrompt` is a required `string` at the `session-config` layer): `genericBase` carries no Pi fingerprint, so the OAuth shaping no-ops and the billing header is still prepended.
|
|
65
|
+
`genericBase` is already neutral, so nothing leaks.
|
|
66
|
+
|
|
67
|
+
Conclusion: #400 introduces no new OAuth interaction. `genericBase` remains the correct fallback and stays consistent with append mode.
|
|
68
|
+
|
|
69
|
+
### Constraints from AGENTS.md
|
|
70
|
+
|
|
71
|
+
- This package carries a type-declaration bundle for its public API, but `buildAgentPrompt` is internal — no `dist/public.d.ts` or `exports` impact, so `verify:public-types` is not required for this change.
|
|
72
|
+
- Conventional Commits; do not edit `CHANGELOG.md` (release-please owns it).
|
|
73
|
+
- The `BREAKING CHANGE:` footer text is reused verbatim in the release-please CHANGELOG and the issue close comment — name only real surface (`prompt_mode: replace`).
|
|
74
|
+
|
|
75
|
+
## Design Overview
|
|
76
|
+
|
|
77
|
+
Hoist the `identity` resolution above the branch so both modes share it, then rewrite the replace branch.
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
|
|
81
|
+
const envBlock = `# Environment\n...`;
|
|
82
|
+
const identity = parentSystemPrompt ?? genericBase;
|
|
83
|
+
|
|
84
|
+
if (config.promptMode === "append") {
|
|
85
|
+
// ...unchanged...
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// "replace" mode — shared parent prompt (or generic base) first for KV cache
|
|
89
|
+
// reuse, then the active_agent tag, env block, and the config's full system
|
|
90
|
+
// prompt. Unlike append mode, replace mode injects neither the
|
|
91
|
+
// <sub_agent_context> bridge nor the <agent_instructions> wrapper — the custom
|
|
92
|
+
// prompt keeps full control of the agent's instructions.
|
|
93
|
+
return identity + "\n\n" + activeAgentTag + envBlock + "\n\n" + config.systemPrompt;
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Resulting replace-mode order (`activeAgentTag` already ends with `\n\n`):
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
1. parentSystemPrompt (or genericBase) ← SHARED, cacheable prefix
|
|
100
|
+
2. <active_agent name="${name}"/> ← varies per agent
|
|
101
|
+
3. # Environment ... ← varies per runtime
|
|
102
|
+
4. config.systemPrompt ← custom instructions (full control)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This mirrors append mode's prefix-first ordering, minus the bridge and the `<agent_instructions>` wrapper.
|
|
106
|
+
The change is a pure single-function edit — no new collaborator, no new module, no interface change — so the design-review structural checklist (dependency width, Law of Demeter, extraction seams) does not apply.
|
|
107
|
+
|
|
108
|
+
### Edge cases
|
|
109
|
+
|
|
110
|
+
- Empty `config.systemPrompt` (e.g. a replace agent with no body): the prompt ends with a trailing `\n\n` after the env block.
|
|
111
|
+
Acceptable and consistent with current behavior; no special-casing.
|
|
112
|
+
`genericBase` only substitutes on a nullish parent (the `??` operator), so an empty-string parent prompt is preserved as-is, matching append mode.
|
|
113
|
+
|
|
114
|
+
## Module-Level Changes
|
|
115
|
+
|
|
116
|
+
### `packages/pi-subagents/src/session/prompts.ts`
|
|
117
|
+
|
|
118
|
+
1. Hoist `const identity = parentSystemPrompt ?? genericBase;` from the append branch to before the `if (config.promptMode === "append")` check so both branches use it.
|
|
119
|
+
2. Replace the replace-branch `replaceHeader` template and return statement with the new ordering (`identity` → `activeAgentTag` → `envBlock` → `config.systemPrompt`); remove the thin two-line header.
|
|
120
|
+
3. Update the JSDoc summary: replace-mode bullet becomes "parent system prompt (or generic base) + active_agent tag + env header + config.systemPrompt; no bridge, no agent_instructions wrapper," and update the trailing note about tag position (it is included, not prepended, in either mode).
|
|
121
|
+
|
|
122
|
+
### `packages/pi-subagents/test/session/prompts.test.ts`
|
|
123
|
+
|
|
124
|
+
See Test Impact Analysis and TDD Order for the specific test changes.
|
|
125
|
+
|
|
126
|
+
### `packages/pi-subagents/README.md`
|
|
127
|
+
|
|
128
|
+
1. Lines 119–120 — the `Explore` and `Plan` rows: revise the `replace` (standalone) framing, since replace mode now inherits the parent prompt as its base.
|
|
129
|
+
2. Line 187 — the `prompt_mode` frontmatter table: `replace` no longer means "no AGENTS.md / CLAUDE.md inheritance."
|
|
130
|
+
Reword to describe the new semantics: replace inherits the parent prompt as the base, then the body takes full control (no `<sub_agent_context>` bridge, no `<agent_instructions>` wrapper), whereas append wraps the body and adds the bridge.
|
|
131
|
+
3. Line 494 (Patch 3, `<active_agent>` tag): change "prepends ... to every assembled child system prompt (both `replace` and `append` modes)" to "includes ... in every assembled child system prompt (both modes)" — the tag follows the cacheable parent prefix in both modes now, so "prepends" is inaccurate.
|
|
132
|
+
|
|
133
|
+
No `docs/architecture/` updates: the architecture doc references `prompts.ts` only as a one-line file listing (no prompt-assembly description, no complexity/health table entry tied to this change).
|
|
134
|
+
|
|
135
|
+
## Test Impact Analysis
|
|
136
|
+
|
|
137
|
+
This is a behavior change, not an extraction, so the extraction-specific questions are limited.
|
|
138
|
+
|
|
139
|
+
- New behavior to cover: replace mode now includes the parent prompt as a cacheable prefix; falls back to `genericBase` with no parent; still excludes the bridge and the `<agent_instructions>` wrapper.
|
|
140
|
+
- Existing replace-mode tests that assert the old behavior must change (they pin the removed thin header and the "ignores parent prompt" premise).
|
|
141
|
+
- `toContain`-based tests for cwd/git/env and the `genericBase` fallback remain valid where position-independent.
|
|
142
|
+
- No existing test becomes redundant beyond the ones being rewritten; no test must stay frozen for a layer being extracted (nothing is extracted).
|
|
143
|
+
|
|
144
|
+
Tests that change in `test/session/prompts.test.ts`:
|
|
145
|
+
|
|
146
|
+
1. `"replace mode uses config systemPrompt directly"` — asserts `toContain("You are a pi coding agent sub-agent")`; that header is removed.
|
|
147
|
+
Rewrite to assert the config prompt is present and the thin header is gone.
|
|
148
|
+
2. `"replace mode ignores parent prompt"` — asserts the parent content is absent.
|
|
149
|
+
The premise inverts: rename to `"replace mode includes parent prompt as base (no bridge/wrapper)"` and assert the parent content is present while `<sub_agent_context>` and `<agent_instructions>` are absent.
|
|
150
|
+
3. `"prepends <active_agent name=...> tag in replace mode"` — asserts `prompt.startsWith('<active_agent name="Explore"/>\n\n')`.
|
|
151
|
+
The tag no longer leads (parent/`genericBase` does); rewrite to assert the tag appears after the identity prefix and before the env block.
|
|
152
|
+
4. `"active_agent tag appears before envBlock in both modes"` — the replace assertions pin `tagIdx === 0`.
|
|
153
|
+
Update the replace assertions: the tag is no longer at index 0 but still precedes `# Environment`.
|
|
154
|
+
The append assertions stay as-is.
|
|
155
|
+
|
|
156
|
+
## TDD Order
|
|
157
|
+
|
|
158
|
+
All test and source changes live in two files that the type checker links (the replace branch and its tests).
|
|
159
|
+
Each cycle is a single commit that leaves the suite green.
|
|
160
|
+
|
|
161
|
+
1. **Red: rewrite replace-mode behavioral tests.**
|
|
162
|
+
Update tests 1–2 above to the new behavior (parent prompt included as base; thin header removed; no bridge/wrapper), and add a test for the `genericBase` fallback when no parent is supplied in replace mode, plus a test pinning the full order (`identity` → `<active_agent>` → `# Environment` → `config.systemPrompt`).
|
|
163
|
+
These fail against the current implementation.
|
|
164
|
+
Commit: `test: assert replace mode inherits parent prompt as cacheable prefix (#400)`
|
|
165
|
+
|
|
166
|
+
2. **Green: rewrite the replace branch.**
|
|
167
|
+
Hoist `identity`, replace the `replaceHeader` block with the new ordering, remove the thin header, and update the JSDoc.
|
|
168
|
+
Update the positional `<active_agent>` tests (3–4 above) in the same commit — they break at runtime the moment the branch changes.
|
|
169
|
+
Commit body carries the `BREAKING CHANGE:` footer.
|
|
170
|
+
Commit: `perf!: include parent system prompt in replace mode (#400)`
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
BREAKING CHANGE: replace-mode subagents (built-in Explore/Plan and any
|
|
174
|
+
custom prompt_mode: replace agent) now inherit the parent system prompt as
|
|
175
|
+
their base instead of a thin standalone header. The custom prompt is
|
|
176
|
+
appended last and retains full control; the <sub_agent_context> bridge and
|
|
177
|
+
<agent_instructions> wrapper are still omitted in replace mode.
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
3. **Docs: update README replace-mode semantics.**
|
|
181
|
+
Apply the three README edits (Explore/Plan rows, `prompt_mode` table, Patch 3 `<active_agent>` wording).
|
|
182
|
+
Commit: `docs: describe replace-mode parent inheritance (#400)`
|
|
183
|
+
|
|
184
|
+
## Risks and Mitigations
|
|
185
|
+
|
|
186
|
+
| Risk | Mitigation |
|
|
187
|
+
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
188
|
+
| `Explore`/`Plan` behavior shifts — they now carry the full parent prompt plus their read-only specialist instructions | Operator confirmed uniform application; specialist instructions are placed last so they have the final say; existing read-only assertions (`READ-ONLY`, `file search specialist`) still hold via `toContain`. |
|
|
189
|
+
| `pi-permission-system` depends on `<active_agent>` tag position | Tag parsing is a full-string regex search; position-independent (same basis as [#180]). |
|
|
190
|
+
| `pi-anthropic-auth` OAuth shaping breaks with the new base | No new interaction — billing header is prepended unconditionally; de-fingerprinting keys off `PI_DEFAULT_PROMPT_PREFIX` and `genericBase` is already neutral (see Background). |
|
|
191
|
+
| A custom replace agent relied on the clean-slate (no parent) behavior | Documented as breaking in the `BREAKING CHANGE:` footer and README; this aligns with the expectation reported in the issue ([@jeffutter] expected the parent identity to be present). |
|
|
192
|
+
| Stale README claims that replace = no inheritance | README edits in cycle 3 correct lines 119–120, 187, and 494. |
|
|
193
|
+
|
|
194
|
+
## Open Questions
|
|
195
|
+
|
|
196
|
+
None — the three design decisions (breaking classification, `genericBase` fallback, uniform application to built-ins) were resolved with the operator before planning.
|
|
197
|
+
|
|
198
|
+
[#180]: https://github.com/gotgenes/pi-packages/issues/180
|
|
199
|
+
[@jeffutter]: https://github.com/jeffutter
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 381
|
|
3
|
+
issue_title: "Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #381 — Replace ConcurrencyQueue with a thunk-based ConcurrencyLimiter
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-06-13T00:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced a 3-step TDD plan to replace the ID-registry `ConcurrencyQueue` (with its `startAgent` back-edge and `markStarted`/`markFinished` relays) with a pure `ConcurrencyLimiter` that schedules thunks FIFO against a dynamic limit.
|
|
13
|
+
The design follows the architecture doc's Phase 17 Step 1 entry and the issue's revised framing closely; the plan adds concrete code sketches for `schedule`/`recheck`/`clear`, the manager call site, the simplified `waitForAll`, and `index.ts` wiring.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- Author is `gotgenes` (matches the gh CLI user), so the well-specified proposal was treated as the working hypothesis; the design is unambiguous (down to the architecture-doc Step 1), so the `ask_user` gate was skipped.
|
|
18
|
+
- Classified non-breaking: `ConcurrencyQueue`/`ConcurrencyLimiter` are internal — no public API, config, or observable behavior change.
|
|
19
|
+
The FIFO admission gate against `maxConcurrent` is preserved.
|
|
20
|
+
- Key design decision beyond the issue sketch: `clear()` must *settle* dropped pending promises (resolve them), not just drop the thunks.
|
|
21
|
+
Every `schedule()` promise becomes `record.promise`, and the post-spawn contract is that it always settles — dropping without resolving would strand a promise.
|
|
22
|
+
This costs a small `settle` handle per pending entry (a few lines beyond the issue's "~40 lines").
|
|
23
|
+
- Verified no production caller awaits a *queued* agent's promise in a blocking way (`get-result-tool.ts` guards on `status === "running"`; `spawnAndWait` is foreground/direct; `waitForAll` filters by status), confirming it is safe to give queued agents a real promise.
|
|
24
|
+
- Sequencing decision: the `SubagentManagerOptions.queue` → `limiter` swap breaks both call sites (`index.ts` + the manager test helper) and the old test file imports the deleted source, so step 2 is one atomic commit (migrate consumers + delete queue + delete old test).
|
|
25
|
+
- `bypassQueue` is kept as-is — it is in the published `SubagentsService` type bundle, so renaming would be breaking; deferred to Open Questions.
|
|
26
|
+
- Doc inventory: grep confirmed current-state references to update are the Mermaid lifecycle node, the layout listing, the "What the core owns" bullet, the Step 7 ([#378]) target filename, and the `package-pi-subagents` SKILL lifecycle-domain table.
|
|
27
|
+
`SKILL.md` line 80 (Phase 15 history) keeps `ConcurrencyQueue` as a historical record.
|
|
28
|
+
|
|
29
|
+
## Stage: Implementation — TDD (2026-06-13T22:15:00Z)
|
|
30
|
+
|
|
31
|
+
### Session summary
|
|
32
|
+
|
|
33
|
+
Executed all 3 planned TDD cycles: (1) added `ConcurrencyLimiter` + 13 unit tests, (2) migrated `SubagentManager`, `index.ts`, `subagent.ts` docstring, and the manager test helper to the limiter while deleting `concurrency-queue.ts` + its test in the same atomic commit, (3) updated `architecture.md` and the package SKILL.
|
|
34
|
+
Test count went 975 → 966 (−22 deleted queue tests, +13 new limiter tests); the full suite, `check`, `lint`, and `pnpm fallow dead-code` are all green.
|
|
35
|
+
|
|
36
|
+
### Observations
|
|
37
|
+
|
|
38
|
+
- The plan held up cleanly — no surprises in the manager integration tests.
|
|
39
|
+
The `queueing and concurrency` describe block passed unchanged after only the `createManager` helper swap (real `ConcurrencyLimiter` instead of `ConcurrencyQueue` + forward-ref start callback), confirming those tests exercise behavior, not queue internals.
|
|
40
|
+
- One deviation: a 4th commit (`90135005`, `refactor:`) fixes a stale `// before startAgent / queue drain` comment at `src/index.ts:125` that the plan's grep inventory missed (it named no removed symbol, just deleted concepts).
|
|
41
|
+
The pre-completion reviewer caught it.
|
|
42
|
+
Committed separately rather than amending the non-HEAD refactor commit, since AGENTS.md discourages interactive rebase in this environment.
|
|
43
|
+
- ESLint `@typescript-eslint/no-floating-promises` fired on every bare `limiter.schedule(...)` in the limiter test (the queue's `enqueue` returned `void`; `schedule` returns a promise).
|
|
44
|
+
Resolved by prefixing unawaited calls with `void` — all such tasks either stay pending or resolve, so no unhandled rejection.
|
|
45
|
+
- The `clear()`-settles-pending-promises decision (made at planning) proved correct and is covered by a dedicated test ("resolves the promises of dropped pending tasks").
|
|
46
|
+
- Pre-completion reviewer: WARN (no FAILs).
|
|
47
|
+
Reviewer warnings: the single stale-comment finding at `index.ts:125` — now fixed in commit `90135005`.
|
|
48
|
+
|
|
49
|
+
[#378]: https://github.com/gotgenes/pi-packages/issues/378
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 400
|
|
3
|
+
issue_title: "perf(pi-subagents): include parent system prompt in replace mode for KV cache reuse"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #400 — Include parent system prompt in replace mode for KV cache reuse
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-06-14T00:42:49Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced a numbered plan for including the parent system prompt as a cacheable prefix in `buildAgentPrompt()`'s replace branch, mirroring the [#180] append-mode reorder.
|
|
13
|
+
The change is a single-function edit plus test and README updates, planned across three TDD/docs commits.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- Three design decisions were confirmed with the operator (issue author = gh user) before planning:
|
|
18
|
+
1. Ship as breaking `perf!:` with a `BREAKING CHANGE:` footer — replace-mode agents inherit the parent prompt on upgrade with no user edit, and the thin two-line header is removed.
|
|
19
|
+
2. Use `genericBase` as the no-parent fallback, consistent with append mode.
|
|
20
|
+
3. Apply uniformly to all replace agents, including built-in `Explore` and `Plan` (one code path, no special-casing).
|
|
21
|
+
- The operator raised a cross-extension concern about the `genericBase` fallback interacting with `@gotgenes/pi-anthropic-auth`.
|
|
22
|
+
Investigation of that package's `system-prompt-shaping.ts` / `request-shaping.ts` showed no new interaction: the `x-anthropic-billing-header` block is prepended unconditionally for OAuth, and de-fingerprinting keys off `PI_DEFAULT_PROMPT_PREFIX` (absent from `genericBase`, which is already neutral).
|
|
23
|
+
Captured this in the plan's Background and Risks.
|
|
24
|
+
- `parentSystemPrompt` is a required `string` at the `session-config` layer (sourced from `snapshot.systemPrompt`), so the `genericBase` fallback is effectively a defensive/test-only path in real sessions.
|
|
25
|
+
- The thin replace header string (`You are a pi coding agent sub-agent`) appears only in `prompts.ts` and its test — no skill or live doc pins it; README needs three edits (Explore/Plan rows, `prompt_mode` table, Patch 3 `<active_agent>` wording, the last already slightly stale post-#180).
|
|
26
|
+
- Notable emergent scope point: `Explore`/`Plan` are built-in replace-mode agents, so this change affects them visibly — surfaced and confirmed rather than assumed.
|
|
27
|
+
|
|
28
|
+
## Stage: Implementation — TDD (2026-06-14T00:54:46Z)
|
|
29
|
+
|
|
30
|
+
### Session summary
|
|
31
|
+
|
|
32
|
+
Completed all 3 TDD cycles in `packages/pi-subagents`.
|
|
33
|
+
The change is a single-function edit to `src/session/prompts.ts` (hoist `identity`, rewrite replace branch) plus test updates and README/skill-doc corrections.
|
|
34
|
+
Test count went from 973 to 975 (+2 net new tests) across 59 test files.
|
|
35
|
+
|
|
36
|
+
### Observations
|
|
37
|
+
|
|
38
|
+
- Step 1 (Red): rewrote 2 existing replace-mode tests and added 2 new ones (4 failures confirmed against old code); the old "ignores parent prompt" test premise inverted cleanly into "includes parent prompt as base."
|
|
39
|
+
- Step 2 (Green): hoisting `const identity = parentSystemPrompt ?? genericBase;` above the `if` block and replacing the `replaceHeader` template were the only `src/` changes; also updated two positional `<active_agent>` tests in the same commit since they broke the moment the branch changed (`tagIdx === 0` → `toBeGreaterThan(0)`).
|
|
40
|
+
- The `BREAKING CHANGE:` footer wording was taken verbatim from the plan and landed in the `perf!:` commit.
|
|
41
|
+
- Pre-completion reviewer: WARN — one finding: `.pi/skills/package-pi-subagents/SKILL.md` still said "prepends" for the `<active_agent>` tag; fixed in a follow-up `docs:` commit before shipping.
|
|
42
|
+
- No deviations from the plan's Module-Level Changes list; no lockfile changes; fallow dead-code exited zero.
|
|
43
|
+
|
|
44
|
+
## Stage: Final Retrospective (2026-06-14T01:11:10Z)
|
|
45
|
+
|
|
46
|
+
### Session summary
|
|
47
|
+
|
|
48
|
+
Shipped #400 across three stages (Planning on `claude-opus-4-8`, TDD + Ship on `claude-sonnet-4-6`) as a single-function edit to `buildAgentPrompt()`'s replace branch plus tests and doc updates, released as `pi-subagents` v16.0.0 (major, breaking `perf!:`).
|
|
49
|
+
The run was clean end-to-end: two `ask_user` gates during planning, a 3-cycle TDD pass, one pre-completion WARN resolved before push, and a no-friction release-please merge.
|
|
50
|
+
|
|
51
|
+
### Observations
|
|
52
|
+
|
|
53
|
+
#### What went well
|
|
54
|
+
|
|
55
|
+
- Cross-extension investigation on demand — when the operator asked mid-`ask_user` how the `genericBase` fallback interacts with `@gotgenes/pi-anthropic-auth`, the agent read that sibling repo's `system-prompt-shaping.ts` and `request-shaping.ts` and proved no new interaction (billing header prepended unconditionally; de-fingerprinting keys off `PI_DEFAULT_PROMPT_PREFIX`, absent from the neutral `genericBase`) before answering.
|
|
56
|
+
This converted an open worry into a documented Risk row rather than a deferred unknown.
|
|
57
|
+
- Emergent-scope surfacing — planning noticed that built-in `Explore`/`Plan` are replace-mode agents and so are visibly affected, then confirmed uniform application via a second `ask_user` instead of assuming.
|
|
58
|
+
- Autoformat discipline — after `pi-autoformat` touched `README.md` mid-edit, the agent re-read the region before the next edit (turns 49–50) rather than matching against stale layout, avoiding a failed `oldText`.
|
|
59
|
+
|
|
60
|
+
#### What caused friction (agent side)
|
|
61
|
+
|
|
62
|
+
- `missing-context` (planning) — the plan listed the README's Patch 3 `<active_agent>` "prepends" wording as a doc update but missed the identical Patch 3 description in `.pi/skills/package-pi-subagents/SKILL.md`.
|
|
63
|
+
Exact-grep during planning keyed on removed strings (`You are a pi coding agent sub-agent`, `prompt_mode`); the stale prose carried none of them, so the skill file's "prepends `<active_agent>`" line was not found.
|
|
64
|
+
Impact: the pre-completion reviewer caught it as a WARN, requiring one follow-up `docs:` commit (8e93d2a4) during TDD before push — no rework beyond that, and the safety net worked as designed.
|
|
65
|
+
|
|
66
|
+
#### What caused friction (user side)
|
|
67
|
+
|
|
68
|
+
- None — the operator's mid-planning OAuth question was a high-value redirect that strengthened the plan, not friction.
|
|
69
|
+
|
|
70
|
+
### Diagnostic details
|
|
71
|
+
|
|
72
|
+
- **Model-performance correlation** — judgment-heavy planning ran on `claude-opus-4-8`; mechanical TDD execution and the deterministic ship steps ran on `claude-sonnet-4-6`.
|
|
73
|
+
Appropriate assignment in both directions; no mismatch.
|
|
74
|
+
- **Unused-tool detection** — the `colgrep` skill was loaded in planning but never used; exploration was all exact-symbol grep, which was correct for known symbols.
|
|
75
|
+
The one place it would have helped is the `missing-context` friction: a semantic search like "docs describing how the active_agent tag is added to the system prompt" would likely have surfaced both the README and the SKILL.md descriptions that symbol-grep missed.
|
|
76
|
+
- **Feedback-loop gap analysis** — verification ran incrementally throughout (green baseline before cycle 1, per-file `vitest` each cycle, full suite + `check` + `lint` + `fallow` after the last step).
|
|
77
|
+
No end-loaded verification.
|
|
78
|
+
- **Escalation-delay tracking** — no rabbit-holes; no error sequence exceeded one tool call.
|
|
79
|
+
|
|
80
|
+
### Changes made
|
|
81
|
+
|
|
82
|
+
1. `.pi/prompts/plan-issue.md` — extended the Module-Level Changes grep bullet: when a step reworks a documented mechanism's behavior (rather than removing a symbol), grep `.pi/skills/package-*/SKILL.md` for the mechanism name, since reworded prose carries no removed symbol to match.
|
|
83
|
+
|
|
84
|
+
[#180]: https://github.com/gotgenes/pi-packages/issues/180
|