pi-pier 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -2
- package/package.json +1 -1
- package/schemas/efficiency-config.schema.json +25 -19
- package/schemas/role-manifest.schema.json +9 -9
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/compact-coordinator.ts +204 -33
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/delegation-core.ts +1 -1
- package/src/efficiency-config-core.ts +34 -20
- package/src/efficiency-store.ts +42 -5
- package/src/herdr-client.ts +15 -5
- package/src/index-notices.ts +1 -1
- package/src/index-pipe.ts +0 -8
- package/src/index.ts +71 -87
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +70 -33
- package/src/manifest-compose.ts +4 -4
- package/src/observation-core.ts +13 -1
- package/src/pipe-channel.ts +0 -8
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +53 -40
- package/src/plugins/terminal.ts +192 -47
- package/src/plugins/todo.ts +44 -22
- package/src/reducer-invoker.ts +28 -14
- package/src/renderers.ts +1 -13
- package/src/role-loader.ts +8 -8
- package/src/role-manifest.ts +22 -22
- package/src/roles/master.json +1 -1
- package/src/roles/worker-default.json +3 -3
- package/src/settle-wake-core.ts +8 -2
- package/src/subagent-core.ts +41 -3
- package/src/subagent-poller.ts +52 -18
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +6 -5
- package/src/terminal-core.ts +111 -17
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +31 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/tree-view.ts +10 -10
- package/src/vocab.ts +7 -1
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
} from '@earendil-works/pi-coding-agent';
|
|
12
12
|
import { Type } from 'typebox';
|
|
13
13
|
import { containsReducerReceipt, countLines, deriveObservationId, estimateTokens, formatObservationPlaceholder, isObservationId, sha256Hex, shouldPackForCache } from '../observation-core.ts';
|
|
14
|
-
import { appendEfficiencyLog, efficiencyLogPath, observationObjectPath, readStoredObjectChunk, resolveSessionRoot, storeContentAddressedObject } from '../efficiency-store.ts';
|
|
14
|
+
import { appendEfficiencyLog, efficiencyLogPath, observationObjectExists, observationObjectPath, readStoredObjectChunk, resolveSessionRoot, storeContentAddressedObject } from '../efficiency-store.ts';
|
|
15
15
|
import { resolveEfficiencyConfig, type EfficiencyConfig, type ObservationPackConfig } from '../efficiency-config-core.ts';
|
|
16
16
|
import { resolveCacheRatioFromCost } from '../compact-economics-core.ts';
|
|
17
17
|
import { planToolGate, type RuntimeRoleManifest } from '../tool-gate.ts';
|
|
@@ -51,14 +51,13 @@ function buildPlaceholder(
|
|
|
51
51
|
toolCallId: string,
|
|
52
52
|
text: string,
|
|
53
53
|
textBytes: number,
|
|
54
|
+
contentHash: string,
|
|
54
55
|
obsConfig: ObservationPackConfig,
|
|
55
56
|
middle?: ExcerptMiddlePick,
|
|
56
|
-
): { id: string; placeholder: string
|
|
57
|
-
const contentHash = sha256Hex(text);
|
|
57
|
+
): { id: string; placeholder: string } {
|
|
58
58
|
const id = deriveObservationId(toolName, toolCallId, contentHash);
|
|
59
59
|
return {
|
|
60
60
|
id,
|
|
61
|
-
contentHash,
|
|
62
61
|
placeholder: formatObservationPlaceholder({
|
|
63
62
|
id,
|
|
64
63
|
toolName,
|
|
@@ -77,6 +76,20 @@ function memoKeyFor(sessionRoot: string, toolCallId: string, charLength: number)
|
|
|
77
76
|
return `${sessionRoot}:${toolCallId}:${charLength}`;
|
|
78
77
|
}
|
|
79
78
|
|
|
79
|
+
function rememberPlaceholder(
|
|
80
|
+
sessionRoot: string,
|
|
81
|
+
toolCallId: string,
|
|
82
|
+
charLength: number,
|
|
83
|
+
placeholder: string,
|
|
84
|
+
obsId: string,
|
|
85
|
+
): void {
|
|
86
|
+
if (placeholderMemo.size >= MAX_MEMO_ENTRIES) {
|
|
87
|
+
const oldest = placeholderMemo.keys().next().value;
|
|
88
|
+
if (oldest) placeholderMemo.delete(oldest);
|
|
89
|
+
}
|
|
90
|
+
placeholderMemo.set(memoKeyFor(sessionRoot, toolCallId, charLength), { placeholder, obsId });
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
/** Append one observation.jsonl record; telemetry is always best-effort. */
|
|
81
94
|
async function logObservation(sessionRoot: string, fields: Record<string, unknown>): Promise<void> {
|
|
82
95
|
try {
|
|
@@ -100,8 +113,12 @@ interface ExcerptMiddlePick {
|
|
|
100
113
|
label: string;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
/** P0-3 seam:
|
|
104
|
-
|
|
116
|
+
/** P0-3 seam: batched middle-window picker (jev-backed), one result per item in order; null keeps
|
|
117
|
+
* the legacy head+tail halves. A whole compaction batch is one call. */
|
|
118
|
+
export type PickMiddleExcerpts = (
|
|
119
|
+
items: ReadonlyArray<{ obsId: string; text: string }>,
|
|
120
|
+
excerptBudgetBytes: number,
|
|
121
|
+
) => Promise<ReadonlyArray<ExcerptMiddlePick | null>>;
|
|
105
122
|
|
|
106
123
|
/** Build the OCC `onBeforeCompact` hook: role-gated, config-gated batch packing of the branch's
|
|
107
124
|
* large tool results before `ctx.compact()` rewrites the prefix. Exported so the gate behaviour is
|
|
@@ -110,7 +127,7 @@ export function createCompactionBatchPackHook(deps: {
|
|
|
110
127
|
getObsConfig: () => ObservationPackConfig;
|
|
111
128
|
getManifest?: () => RuntimeRoleManifest | null;
|
|
112
129
|
getSessionId?: () => string | undefined;
|
|
113
|
-
|
|
130
|
+
pickMiddleExcerpts?: PickMiddleExcerpts;
|
|
114
131
|
}): (sessionRoot: string, ctx: unknown) => Promise<void> {
|
|
115
132
|
return async (sessionRoot: string, ctx: unknown): Promise<void> => {
|
|
116
133
|
const obsConfig = deps.getObsConfig();
|
|
@@ -133,7 +150,7 @@ export function createCompactionBatchPackHook(deps: {
|
|
|
133
150
|
sessionId: deps.getSessionId?.(),
|
|
134
151
|
messages,
|
|
135
152
|
obsConfig,
|
|
136
|
-
|
|
153
|
+
pickMiddleExcerpts: deps.pickMiddleExcerpts,
|
|
137
154
|
});
|
|
138
155
|
};
|
|
139
156
|
}
|
|
@@ -147,20 +164,22 @@ async function packOneMessage(opts: {
|
|
|
147
164
|
text: string;
|
|
148
165
|
textBytes: number;
|
|
149
166
|
charLength: number;
|
|
167
|
+
contentHash: string;
|
|
168
|
+
obsId: string;
|
|
150
169
|
obsConfig: ObservationPackConfig;
|
|
151
170
|
/** `extra` carries audit fields such as `source: 'compaction'`, `sendCount`, `tailTokensAfter`. */
|
|
152
171
|
log?: { logEnabled: boolean; event: 'packed' | 'packed-batch'; sessionId?: string; extra?: Record<string, unknown> };
|
|
153
|
-
|
|
154
|
-
/** Prefetched middle excerpt (the batch path
|
|
172
|
+
pickMiddleExcerpts?: PickMiddleExcerpts;
|
|
173
|
+
/** Prefetched middle excerpt (the batch path picks for all items in one call); null/undefined = none. */
|
|
155
174
|
middle?: ExcerptMiddlePick | null;
|
|
156
175
|
}): Promise<string | null> {
|
|
157
|
-
const { sessionRoot, toolName, toolCallId, text, textBytes, charLength, obsConfig, log,
|
|
176
|
+
const { sessionRoot, toolName, toolCallId, text, textBytes, charLength, contentHash, obsConfig, log, pickMiddleExcerpts } = opts;
|
|
158
177
|
const middle = opts.middle !== undefined
|
|
159
178
|
? (opts.middle ?? undefined)
|
|
160
|
-
:
|
|
161
|
-
? (await
|
|
179
|
+
: pickMiddleExcerpts
|
|
180
|
+
? (await pickMiddleExcerpts([{ obsId: opts.obsId, text }], obsConfig.excerptBytes))[0] ?? undefined
|
|
162
181
|
: undefined;
|
|
163
|
-
const { id: obsId, placeholder
|
|
182
|
+
const { id: obsId, placeholder } = buildPlaceholder(toolName, toolCallId, text, textBytes, contentHash, obsConfig, middle);
|
|
164
183
|
const originalTokens = estimateTokens(text);
|
|
165
184
|
const placeholderTokens = estimateTokens(placeholder);
|
|
166
185
|
|
|
@@ -174,11 +193,7 @@ async function packOneMessage(opts: {
|
|
|
174
193
|
return null;
|
|
175
194
|
}
|
|
176
195
|
|
|
177
|
-
|
|
178
|
-
const oldest = placeholderMemo.keys().next().value;
|
|
179
|
-
if (oldest) placeholderMemo.delete(oldest);
|
|
180
|
-
}
|
|
181
|
-
placeholderMemo.set(memoKeyFor(sessionRoot, toolCallId, charLength), { placeholder, obsId });
|
|
196
|
+
rememberPlaceholder(sessionRoot, toolCallId, charLength, placeholder, obsId);
|
|
182
197
|
|
|
183
198
|
if (log?.logEnabled && !loggedPackedObsIds.has(obsId)) {
|
|
184
199
|
loggedPackedObsIds.add(obsId);
|
|
@@ -198,20 +213,65 @@ async function packOneMessage(opts: {
|
|
|
198
213
|
return placeholder;
|
|
199
214
|
}
|
|
200
215
|
|
|
201
|
-
/** One packable message: a non-error
|
|
202
|
-
interface PackCandidate {
|
|
216
|
+
/** One packable message: a non-error, text-only tool result over the size threshold. */
|
|
217
|
+
interface PackCandidate {
|
|
218
|
+
toolName: string;
|
|
219
|
+
toolCallId: string;
|
|
220
|
+
text: string;
|
|
221
|
+
textBytes: number;
|
|
222
|
+
charLength: number;
|
|
223
|
+
contentHash: string;
|
|
224
|
+
obsId: string;
|
|
225
|
+
}
|
|
203
226
|
|
|
204
|
-
function packCandidateAt(
|
|
227
|
+
function packCandidateAt(
|
|
228
|
+
messages: readonly unknown[],
|
|
229
|
+
index: number,
|
|
230
|
+
sessionRoot: string,
|
|
231
|
+
minBytes: number,
|
|
232
|
+
): PackCandidate | null {
|
|
205
233
|
const msg = messages[index] as ContextMessage | undefined;
|
|
206
234
|
const content = toolResultContent(msg);
|
|
207
235
|
if (!content) return null;
|
|
208
236
|
const { texts, charLength } = content;
|
|
209
237
|
const text = texts.join('\n');
|
|
238
|
+
const textBytes = Buffer.byteLength(text, 'utf8');
|
|
239
|
+
if (textBytes < minBytes) return null;
|
|
210
240
|
const toolName = typeof msg?.toolName === 'string' ? msg.toolName : 'tool';
|
|
211
241
|
const toolCallId = typeof msg?.toolCallId === 'string' ? msg.toolCallId : `call_${index}`;
|
|
212
242
|
if (placeholderMemo.has(memoKeyFor(sessionRoot, toolCallId, charLength))) return null;
|
|
213
243
|
if (containsReducerReceipt(text)) return null;
|
|
214
|
-
|
|
244
|
+
const contentHash = sha256Hex(text);
|
|
245
|
+
return {
|
|
246
|
+
toolName,
|
|
247
|
+
toolCallId,
|
|
248
|
+
text,
|
|
249
|
+
textBytes,
|
|
250
|
+
charLength,
|
|
251
|
+
contentHash,
|
|
252
|
+
obsId: deriveObservationId(toolName, toolCallId, contentHash),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A resumed process starts with an empty memo, but the content-addressed object is still on disk.
|
|
257
|
+
* Rebuild the deterministic placeholder from it instead of asking jev again, rewriting the object,
|
|
258
|
+
* and logging the same saving twice. Returns null when the object is not (verifiably) there. */
|
|
259
|
+
async function reusePackedObservation(
|
|
260
|
+
candidate: PackCandidate,
|
|
261
|
+
sessionRoot: string,
|
|
262
|
+
obsConfig: ObservationPackConfig,
|
|
263
|
+
): Promise<string | null> {
|
|
264
|
+
if (!(await observationObjectExists(sessionRoot, candidate.obsId, candidate.textBytes))) return null;
|
|
265
|
+
const { placeholder } = buildPlaceholder(
|
|
266
|
+
candidate.toolName,
|
|
267
|
+
candidate.toolCallId,
|
|
268
|
+
candidate.text,
|
|
269
|
+
candidate.textBytes,
|
|
270
|
+
candidate.contentHash,
|
|
271
|
+
obsConfig,
|
|
272
|
+
);
|
|
273
|
+
rememberPlaceholder(sessionRoot, candidate.toolCallId, candidate.charLength, placeholder, candidate.obsId);
|
|
274
|
+
return placeholder;
|
|
215
275
|
}
|
|
216
276
|
|
|
217
277
|
export async function batchPackObservations(opts: {
|
|
@@ -220,7 +280,7 @@ export async function batchPackObservations(opts: {
|
|
|
220
280
|
messages: readonly unknown[];
|
|
221
281
|
obsConfig: ObservationPackConfig;
|
|
222
282
|
limits?: { maxItems?: number; maxBytes?: number };
|
|
223
|
-
|
|
283
|
+
pickMiddleExcerpts?: PickMiddleExcerpts;
|
|
224
284
|
}): Promise<number> {
|
|
225
285
|
const { sessionRoot, sessionId, messages, obsConfig } = opts;
|
|
226
286
|
if (!obsConfig.enabled || !sessionRoot || !Array.isArray(messages)) return 0;
|
|
@@ -228,24 +288,25 @@ export async function batchPackObservations(opts: {
|
|
|
228
288
|
const maxItems = opts.limits?.maxItems ?? MAX_BATCH_PACK_ITEMS;
|
|
229
289
|
const maxBytes = opts.limits?.maxBytes ?? MAX_BATCH_PACK_BYTES;
|
|
230
290
|
|
|
231
|
-
// Pass 1 — select with the deterministic checks (memo, receipt, size,
|
|
232
|
-
//
|
|
291
|
+
// Pass 1 — select with the deterministic checks (memo, receipt, size, existing store object, soft
|
|
292
|
+
// byte cap). A reused object is memoized here, so no jev ask, write, or telemetry follows for it.
|
|
233
293
|
const candidates: PackCandidate[] = [];
|
|
234
294
|
let selectedBytes = 0;
|
|
235
295
|
for (let i = 0; i < messages.length && candidates.length < maxItems; i++) {
|
|
236
|
-
const candidate = packCandidateAt(messages, i, sessionRoot);
|
|
237
|
-
if (!candidate
|
|
296
|
+
const candidate = packCandidateAt(messages, i, sessionRoot, obsConfig.thresholdBytes);
|
|
297
|
+
if (!candidate) continue;
|
|
298
|
+
if (await reusePackedObservation(candidate, sessionRoot, obsConfig)) continue;
|
|
238
299
|
if (selectedBytes + candidate.textBytes > maxBytes && candidates.length > 0) break;
|
|
239
300
|
selectedBytes += candidate.textBytes;
|
|
240
301
|
candidates.push(candidate);
|
|
241
302
|
}
|
|
242
303
|
if (candidates.length === 0) return 0;
|
|
243
304
|
|
|
244
|
-
// Pass 2 —
|
|
245
|
-
//
|
|
246
|
-
const picker = opts.
|
|
305
|
+
// Pass 2 — one batched pick for every selected output: per-item asks were a 6-20-way burst that
|
|
306
|
+
// all failed together when jev was down.
|
|
307
|
+
const picker = opts.pickMiddleExcerpts;
|
|
247
308
|
const middles = picker
|
|
248
|
-
? await
|
|
309
|
+
? await picker(candidates.map((c) => ({ obsId: c.obsId, text: c.text })), obsConfig.excerptBytes)
|
|
249
310
|
: [];
|
|
250
311
|
|
|
251
312
|
let packedCount = 0;
|
|
@@ -274,7 +335,11 @@ async function runRecall(
|
|
|
274
335
|
return toolError(`invalid observation id format: "${String(rawId)}"`);
|
|
275
336
|
}
|
|
276
337
|
const id = rawId;
|
|
277
|
-
const
|
|
338
|
+
const rawOffset = params?.offset;
|
|
339
|
+
if (rawOffset !== undefined && (typeof rawOffset !== 'number' || !Number.isInteger(rawOffset) || rawOffset < 0)) {
|
|
340
|
+
return toolError(`invalid observation offset: "${String(rawOffset)}"`);
|
|
341
|
+
}
|
|
342
|
+
const offset = rawOffset ?? 0;
|
|
278
343
|
|
|
279
344
|
const sessionDir = ctx?.sessionManager?.getSessionDir?.();
|
|
280
345
|
const sessionId = ctx?.sessionManager?.getSessionId?.();
|
|
@@ -288,9 +353,12 @@ async function runRecall(
|
|
|
288
353
|
maxBytes: effConfig.observationPack.recallChunkBytes ?? RECALL_MAX_BYTES,
|
|
289
354
|
maxLines: RECALL_MAX_LINES,
|
|
290
355
|
});
|
|
356
|
+
// Mid-line offsets are advanced to the next line start by the reader; the header and telemetry
|
|
357
|
+
// report the offset the returned chunk actually starts at.
|
|
358
|
+
const alignedOffset = chunk.offset;
|
|
291
359
|
|
|
292
360
|
const header = [
|
|
293
|
-
`[obs_recall id=${id} offset=${
|
|
361
|
+
`[obs_recall id=${id} offset=${alignedOffset} next_offset=${chunk.nextOffset} eof=${chunk.eof}]`,
|
|
294
362
|
`[chunk_bytes=${chunk.bytes} chunk_lines=${chunk.lines}; use next_offset to continue]`,
|
|
295
363
|
].join('\n');
|
|
296
364
|
|
|
@@ -299,7 +367,7 @@ async function runRecall(
|
|
|
299
367
|
event: 'recall',
|
|
300
368
|
sessionId: sessionId ?? 'unknown',
|
|
301
369
|
obsId: id,
|
|
302
|
-
offset,
|
|
370
|
+
offset: alignedOffset,
|
|
303
371
|
chunkBytes: chunk.bytes,
|
|
304
372
|
lines: chunk.lines,
|
|
305
373
|
nextOffset: chunk.nextOffset,
|
|
@@ -312,7 +380,7 @@ async function runRecall(
|
|
|
312
380
|
content: [{ type: 'text', text: `${header}\n${chunk.text}` }],
|
|
313
381
|
details: {
|
|
314
382
|
id,
|
|
315
|
-
offset,
|
|
383
|
+
offset: alignedOffset,
|
|
316
384
|
bytes: chunk.bytes,
|
|
317
385
|
lines: chunk.lines,
|
|
318
386
|
nextOffset: chunk.nextOffset,
|
|
@@ -341,7 +409,7 @@ interface ObservationPackDeps {
|
|
|
341
409
|
getConfig?: (ctx: ExtensionContext) => EfficiencyConfig;
|
|
342
410
|
getRuntimeManifest?: () => RuntimeRoleManifest | null;
|
|
343
411
|
getRemainingHorizon?: () => number;
|
|
344
|
-
|
|
412
|
+
pickMiddleExcerpts?: PickMiddleExcerpts;
|
|
345
413
|
}
|
|
346
414
|
|
|
347
415
|
async function projectPlaceholders(
|
|
@@ -395,8 +463,16 @@ async function projectPlaceholders(
|
|
|
395
463
|
const sendCount = priorAssistantCounts[i] ?? 0;
|
|
396
464
|
if (sendCount < obsConfig.fullSends) continue;
|
|
397
465
|
|
|
398
|
-
const candidate = packCandidateAt(projected, i, sessionRoot);
|
|
399
|
-
if (!candidate
|
|
466
|
+
const candidate = packCandidateAt(projected, i, sessionRoot, obsConfig.thresholdBytes);
|
|
467
|
+
if (!candidate) continue;
|
|
468
|
+
|
|
469
|
+
// Resume: the object survived the restart even though the memo did not. Project the same
|
|
470
|
+
// deterministic placeholder without re-asking jev or re-logging the saving.
|
|
471
|
+
const reused = await reusePackedObservation(candidate, sessionRoot, obsConfig);
|
|
472
|
+
if (reused) {
|
|
473
|
+
projected[i] = { ...msg, content: [{ type: 'text', text: reused }] };
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
400
476
|
|
|
401
477
|
let tailTokensAfter = 0;
|
|
402
478
|
for (let j = i + 1; j < len; j++) {
|
|
@@ -410,7 +486,7 @@ async function projectPlaceholders(
|
|
|
410
486
|
// Projected saving of the legacy placeholder drives the economic decision; packOneMessage
|
|
411
487
|
// recomputes the same placeholder when the answer is yes.
|
|
412
488
|
const originalTokens = estimateTokens(candidate.text);
|
|
413
|
-
const estimate = buildPlaceholder(candidate.toolName, candidate.toolCallId, candidate.text, candidate.textBytes, obsConfig);
|
|
489
|
+
const estimate = buildPlaceholder(candidate.toolName, candidate.toolCallId, candidate.text, candidate.textBytes, candidate.contentHash, obsConfig);
|
|
414
490
|
const removedTokens = Math.max(0, originalTokens - estimateTokens(estimate.placeholder));
|
|
415
491
|
// Same resolution as OCC (provider family / token-account fallback): passing a raw 'auto' would
|
|
416
492
|
// let shouldPackForCache fall back on its own hardcoded ratio and diverge from compaction.
|
|
@@ -437,7 +513,7 @@ async function projectPlaceholders(
|
|
|
437
513
|
sessionId,
|
|
438
514
|
extra: { sendCount, tailTokensAfter },
|
|
439
515
|
},
|
|
440
|
-
|
|
516
|
+
pickMiddleExcerpts: deps.pickMiddleExcerpts,
|
|
441
517
|
});
|
|
442
518
|
if (!packed) continue;
|
|
443
519
|
projected[i] = { ...msg, content: [{ type: 'text', text: packed }] };
|
|
@@ -454,11 +530,7 @@ export function registerObservationPack(deps: ObservationPackDeps & { pi: Extens
|
|
|
454
530
|
name: RECALL_TOOL_NAME,
|
|
455
531
|
label: 'Recall Observation',
|
|
456
532
|
description:
|
|
457
|
-
'Recall a paged slice of a previously replaced large tool result by observation id and byte offset.',
|
|
458
|
-
promptGuidelines: [
|
|
459
|
-
'Call obs_recall with id and offset to inspect specific parts of large tool outputs.',
|
|
460
|
-
'Check the returned next_offset and eof to page through long logs.',
|
|
461
|
-
],
|
|
533
|
+
'Recall a paged slice of a previously replaced large tool result by observation id and byte offset; page on with the returned next_offset until eof.',
|
|
462
534
|
parameters: Type.Object({
|
|
463
535
|
id: Type.String({ description: 'Observation ID from placeholder (e.g. obs_...)' }),
|
|
464
536
|
offset: Type.Optional(
|
package/src/plugins/subagent.ts
CHANGED
|
@@ -84,24 +84,35 @@ export interface SubagentDeps {
|
|
|
84
84
|
logRouting?: (row: RoutingTelemetryRecord) => void;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Sent with every request: what a call needs. Details live in SUBAGENT_MANUAL (`action: "help"`). */
|
|
87
88
|
const SUBAGENT_DESCRIPTION = [
|
|
88
|
-
'Delegate a self-contained
|
|
89
|
-
'
|
|
90
|
-
'
|
|
91
|
-
'
|
|
92
|
-
'
|
|
93
|
-
'
|
|
94
|
-
'[spawn] `tab` (optional): name of a task tab to place the subagent into (join if a tab with this name exists, otherwise create it). Overrides the default placement. Default placement groups by git worktree: a subagent working in your checkout shares your tab; one working in a separate worktree (pass its path via `cwd`; create worktrees with `git worktree add`) gets its own tab named after the worktree directory.',
|
|
95
|
-
'[spawn] `role` + `allowed_tools`: when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest — baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names remain display labels only.',
|
|
96
|
-
'[spawn] `isolate` (default false): creates a FRESH git worktree for this subagent and runs it there (branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/). Three-way choice: heavy independent writing in parallel with your own edits or other workers, or work needing its own clean reviewable diff → isolate; read-mostly or sequential helper work → omit (shared checkout, writes guarded by the write-lock); targeting an existing directory/worktree → cwd. In isolate mode the subagent\'s writes cannot conflict with your checkout; its panes group into a tab named after the worktree; its prompt is prefixed with commit discipline (commit to its own branch, NEVER push); when it settles you get a diff summary (commits since base, files changed, uncommitted count). Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd.',
|
|
97
|
-
'[resume] `taskId`: revive a finished (collected) subagent from the delegation ledger — opens its saved conversation in a new pane (pi --session), then use action send to give it new work. The ledger is an append-only JSONL file, one row per status change (same taskId rows = generations, latest row is current): fields taskId, description, status (running|settled|consumed|closed), outcome (closing text), paneId, sessionFile, launchCommand, createdAt. Ledgers are per-checkout at ~/.pi/agent/herdr-pi/history/<flattened-cwd>/history.jsonl, but resume searches every checkout this master has spawned into, not only the current cwd. A still-running task is not resumed — use action send or output. Use action list for live panes.',
|
|
98
|
-
'[list] no extra parameters: list background subagents with live state (running / idle), pane ids, last activity, role, and descriptions. Foreground one-shot panes are not listed.',
|
|
99
|
-
'[output] `agentId` (required, the subagent pane id): view incremental output of a running or background subagent since the last output call. Text returned to the model is bounded (default 6000 chars) with status metadata (running/idle/blocked/settled), revision, and a truncated flag. When the delta cannot be computed the full text is returned with a "buffer scrolled or reset" note — that is normal for fullscreen TUI panes, not a crash indicator. Use this to observe subagent progress before settlement.',
|
|
100
|
-
'[interrupt] `agentId`: abort the current turn (fire-and-return). The pane stays; you can send again.',
|
|
101
|
-
'[role] `agentId` + `role`: switch that worker\'s role profile mid-session (pi 0.86 transcript tool delta — the new toolset applies on its next request and survives resume). Same role resolution as spawn; the reply reports the tool diff. Use when a task outgrew its delegation (needs more tools) or should be narrowed.',
|
|
102
|
-
'[send] `agentId` + `message`: follow-up to a background subagent. If working, delivered at next tool-call gap (steer, seconds); if idle, wakes a new turn. After settle, send to wake it; do not spawn a duplicate.',
|
|
89
|
+
'Delegate a self-contained task to a subagent: an isolated pi session in its own herdr pane (separate context — it does NOT see this conversation; a human can talk to it in that pane).',
|
|
90
|
+
'Actions (default spawn): spawn | send | output | list | interrupt | resume | role | help.',
|
|
91
|
+
'spawn: `prompt` is the COMPLETE task, since only the prompt reaches it; `description` labels the pane and is the todo-reconcile key — when delegating a todo entry, pass its content without the ` <sub>` marker and the entry auto-completes on settle. Several spawns in one message run in parallel (max 4).',
|
|
92
|
+
'A foreground spawn BLOCKS this call for up to ~5 minutes; pass run_in_background: true for long tasks. A background (or timed-out foreground) worker\'s settlement notice arrives automatically at your next tool boundary — no need to sleep or poll.',
|
|
93
|
+
'send: follow-up to a running or settled worker (steered between its tool calls). output: what it printed since your last check, plus its final report once done. resume: revive a collected task by taskId. role: switch a worker\'s role profile mid-session.',
|
|
94
|
+
'help: the full manual (isolate/cwd/tab placement, role profiles, the delegation ledger).',
|
|
103
95
|
].join(' ');
|
|
104
96
|
|
|
97
|
+
/** Returned by `action: "help"`: the reference text that used to ride every request. */
|
|
98
|
+
const SUBAGENT_MANUAL = [
|
|
99
|
+
'subagent manual',
|
|
100
|
+
'',
|
|
101
|
+
'spawn — the subagent shares this workspace and works independently; its result is its final text answer. Do not delegate the current step itself. Placement: by default panes group by git worktree — a subagent in your checkout shares your tab, one in a separate worktree gets its own tab named after the worktree directory. `tab` joins or creates a named task tab instead. `cwd` targets an existing directory or worktree (create worktrees with git worktree add).',
|
|
102
|
+
'',
|
|
103
|
+
'isolate (default false) — creates a FRESH git worktree (branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/). Choose it for heavy independent writing in parallel with your own edits or other workers, or work needing its own clean reviewable diff; omit it for read-mostly or sequential helper work (shared checkout, writes guarded by the write-lock); use cwd for an existing directory/worktree. Its writes cannot conflict with your checkout, its prompt is prefixed with commit discipline (commit to its own branch, never push), and on settle you get a diff summary (commits since base, files changed, uncommitted count). Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd.',
|
|
104
|
+
'',
|
|
105
|
+
'role + allowed_tools — when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names stay display labels. action role switches a live worker (pi transcript tool delta: applies on its next request, survives resume); the reply reports the tool diff.',
|
|
106
|
+
'',
|
|
107
|
+
'list — background subagents with live state (running / idle), pane ids, last activity, role and description; foreground one-shot panes are not listed.',
|
|
108
|
+
'',
|
|
109
|
+
'output — `agentId` required; returns the delta since your last output call (default 6000 chars, max_chars 100-16000) with status (running/idle/blocked/settled), revision and a truncated flag. TUI chrome is stripped; once the worker has finished, the complete transcript report is attached. A "buffer scrolled or reset" note means the full text was returned — normal for fullscreen TUI panes.',
|
|
110
|
+
'',
|
|
111
|
+
'send — `agentId` + `message`: if working, delivered at its next tool-call gap (seconds); if idle or settled, wakes a new turn. Do not spawn a duplicate for the same task. interrupt — aborts its current turn; the pane stays and you can send again.',
|
|
112
|
+
'',
|
|
113
|
+
'resume — `taskId`: revives a collected subagent from the delegation ledger in a new pane (pi --session); then use send. The ledger is append-only JSONL, one row per status change (taskId, description, status running|settled|consumed|closed, outcome, paneId, sessionFile, launchCommand, createdAt), per checkout at ~/.pi/agent/herdr-pi/history/<flattened-cwd>/history.jsonl; resume searches every checkout this master spawned into. A still-running task is not resumed — use send or output.',
|
|
114
|
+
].join('\n');
|
|
115
|
+
|
|
105
116
|
const SUBAGENT_CONCURRENCY = 4;
|
|
106
117
|
|
|
107
118
|
function defaultAgentSessionsDir(): string {
|
|
@@ -224,8 +235,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
224
235
|
if (port.current === boundPort) port.current = null;
|
|
225
236
|
}, 'subagent-port');
|
|
226
237
|
|
|
227
|
-
const injectNotice = (content: string): Promise<void> => {
|
|
228
|
-
if (d.deliverNotice) return d.deliverNotice(content);
|
|
238
|
+
const injectNotice = (content: string, paneId?: string): Promise<void> => {
|
|
239
|
+
if (d.deliverNotice) return d.deliverNotice(content, paneId);
|
|
229
240
|
pi.sendUserMessage?.(content, { deliverAs: 'followUp' });
|
|
230
241
|
return Promise.resolve();
|
|
231
242
|
};
|
|
@@ -353,7 +364,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
353
364
|
[DELEGATION_ENV]: JSON.stringify({
|
|
354
365
|
parentPaneId: env?.paneId ?? '',
|
|
355
366
|
parentSessionId: bareSessionId(d.getSessionId()),
|
|
356
|
-
//
|
|
367
|
+
// todo_sync needs the master's pipe; kind 'task' maps to the composed default.
|
|
357
368
|
parentPipe: pipeNameFor(process.cwd(), env?.paneId ?? ''),
|
|
358
369
|
taskId: entry.taskId,
|
|
359
370
|
description: entry.description,
|
|
@@ -638,7 +649,9 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
638
649
|
const lines: string[] = [];
|
|
639
650
|
for (const sub of byTask.values()) {
|
|
640
651
|
const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
|
|
641
|
-
|
|
652
|
+
// The branch (`pier/<slug>`) is not a directory; printing it made masters cd into
|
|
653
|
+
// `<repo>/pier/<slug>`. Always print the absolute worktree path they can actually enter.
|
|
654
|
+
const wtTag = sub.isolate ? ` [wt: ${sub.isolate.worktreePath}]` : '';
|
|
642
655
|
if (sub.status === 'closed') {
|
|
643
656
|
lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; action send revives)${tabTag}${wtTag} ${sub.description}`);
|
|
644
657
|
continue;
|
|
@@ -692,6 +705,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
692
705
|
// carry the final report. No preferred file — start from herdr's per-pane report, not a stale registry value.
|
|
693
706
|
readFinalReport: async (paneId: string, entryCwd: string) =>
|
|
694
707
|
(await session.subSessionState(paneId, entryCwd, 0)).text,
|
|
708
|
+
persistSubs,
|
|
695
709
|
};
|
|
696
710
|
|
|
697
711
|
const ACTIONS: Record<string, (params: ToolParams, toolCtx: unknown, onUpdate?: (u: unknown) => void) => Promise<unknown>> = {
|
|
@@ -702,6 +716,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
702
716
|
interrupt: (params, toolCtx) => executeSubagentInterrupt(params, toolCtx),
|
|
703
717
|
output: (params, toolCtx) => executeSubagentOutput(params, toolCtx, outputActionDeps),
|
|
704
718
|
role: (params, toolCtx) => executeSubagentRole(params, toolCtx),
|
|
719
|
+
help: async () => ({ content: [{ type: 'text', text: SUBAGENT_MANUAL }], details: {} }),
|
|
705
720
|
};
|
|
706
721
|
|
|
707
722
|
scoped.registerTool({
|
|
@@ -709,15 +724,12 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
709
724
|
label: 'Subagent',
|
|
710
725
|
// B4: the model picks tools from the system prompt's snippet/guideline surface; delegation
|
|
711
726
|
// decisions are made here, so state them rather than hoping for recall.
|
|
712
|
-
promptSnippet: 'subagent: delegate a self-contained task to a pi worker pane (spawn/isolate/background/send/output/interrupt/resume/list/role).',
|
|
727
|
+
promptSnippet: 'subagent: delegate a self-contained task to a pi worker pane (spawn/isolate/background/send/output/interrupt/resume/list/role/help).',
|
|
713
728
|
promptGuidelines: [
|
|
714
|
-
'Use subagent
|
|
715
|
-
'
|
|
716
|
-
'Use isolate: true when the worker
|
|
717
|
-
'
|
|
718
|
-
'Do not poll with action: "output" in a tight loop: the pane pushes a settlement notice when it finishes. Check once, then continue other work.',
|
|
719
|
-
'Send follow-up instructions with action: "send" (delivered as a steer between tool calls) instead of spawning a second worker for the same task.',
|
|
720
|
-
'Pass agentId, taskId, message, and max_chars as top-level arguments. Do not wrap them in a parameters object.',
|
|
729
|
+
'Use subagent for self-contained work whose result is what matters: output that would flood this context, long builds/test runs, or independent parts of a bigger change that can run in parallel.',
|
|
730
|
+
'The worker does not see this conversation: put paths, acceptance criteria, the test command and what to report back into the prompt.',
|
|
731
|
+
'Use isolate: true when the worker writes files you or another worker edit at the same time, or when its work needs its own reviewable branch.',
|
|
732
|
+
'Pass agentId, taskId, message and max_chars as top-level arguments, not inside a parameters object.',
|
|
721
733
|
],
|
|
722
734
|
description: SUBAGENT_DESCRIPTION,
|
|
723
735
|
parameters: Type.Object({
|
|
@@ -729,19 +741,20 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
729
741
|
Type.Literal('interrupt'),
|
|
730
742
|
Type.Literal('output'),
|
|
731
743
|
Type.Literal('role'),
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
744
|
+
Type.Literal('help'),
|
|
745
|
+
], { description: 'Default spawn' })),
|
|
746
|
+
description: Type.Optional(Type.String({ description: '[spawn] Pane label; also the todo-reconcile key' })),
|
|
747
|
+
prompt: Type.Optional(Type.String({ description: '[spawn] The complete self-contained task' })),
|
|
748
|
+
run_in_background: Type.Optional(Type.Boolean({ description: '[spawn] Return at once; the settlement notice arrives later (default false)' })),
|
|
749
|
+
cwd: Type.Optional(Type.String({ description: '[spawn] Existing directory or worktree to run in (absolute or workspace-relative); exclusive with isolate' })),
|
|
750
|
+
isolate: Type.Optional(Type.Boolean({ description: '[spawn] Run in a fresh git worktree (branch pier/<slug>) for heavy parallel writes or a reviewable diff; settle reports a diff summary (see help)' })),
|
|
751
|
+
role: Type.Optional(Type.String({ description: '[spawn|role] Role profile name; unknown names are display labels only' })),
|
|
752
|
+
tab: Type.Optional(Type.String({ description: '[spawn] Task tab to join or create (default: grouped by worktree)' })),
|
|
753
|
+
allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Extra tools on top of the role baseline' })),
|
|
754
|
+
taskId: Type.Optional(Type.String({ description: '[resume] Task id from the delegation ledger' })),
|
|
755
|
+
agentId: Type.Optional(Type.String({ description: '[send|interrupt|output|role] Slug, pane id, or slug@paneId as printed by spawn/list' })),
|
|
756
|
+
message: Type.Optional(Type.String({ description: '[send] Follow-up message' })),
|
|
757
|
+
max_chars: Type.Optional(Type.Integer({ description: '[output] Output cap (default 6000, 100-16000)' })),
|
|
745
758
|
}),
|
|
746
759
|
outputSchema: SUBAGENT_OUTPUT_SCHEMA,
|
|
747
760
|
// B2: `action` is optional in the schema; normalize it so the validated arguments and the
|