@ryan_nookpi/pi-extension-subagent 0.5.10 → 0.5.11
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/index.ts +19 -0
- package/package.json +2 -1
- package/running-reminder.ts +207 -0
package/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
31
31
|
import { SubagentAsyncTasks } from "./async-task-lifecycle.js";
|
|
32
32
|
import { HANG_CHECK_INTERVAL_MS } from "./constants.js";
|
|
33
33
|
import { SUBAGENT_COMMANDS, SUBAGENT_SHORTCUTS, type SubagentCommandName } from "./registration-manifest.js";
|
|
34
|
+
import { handleRunningReminderContext, ReminderAnchors } from "./running-reminder.js";
|
|
34
35
|
|
|
35
36
|
interface SubagentCore {
|
|
36
37
|
store: import("./store.js").SubagentStore;
|
|
@@ -120,9 +121,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
121
|
|
|
121
122
|
let hangCheckTimer: ReturnType<typeof setInterval> | undefined;
|
|
122
123
|
let sessionGeneration = 0;
|
|
124
|
+
const reminderAnchors = new ReminderAnchors();
|
|
123
125
|
|
|
124
126
|
pi.on("session_start", (_event, ctx) => {
|
|
125
127
|
const generation = ++sessionGeneration;
|
|
128
|
+
reminderAnchors.clear();
|
|
126
129
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
127
130
|
// Fence tool admission synchronously, before lazy lifecycle work can yield.
|
|
128
131
|
try {
|
|
@@ -148,6 +151,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
148
151
|
|
|
149
152
|
pi.on("session_shutdown", async (event) => {
|
|
150
153
|
++sessionGeneration;
|
|
154
|
+
reminderAnchors.clear();
|
|
151
155
|
asyncTasks.shutdown();
|
|
152
156
|
if (hangCheckTimer) {
|
|
153
157
|
clearInterval(hangCheckTimer);
|
|
@@ -168,6 +172,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
168
172
|
return c.commands.handleBeforeAgentStart(event, ctx, c.store);
|
|
169
173
|
});
|
|
170
174
|
|
|
175
|
+
// before_agent_start misses turns started by a delivered completion or a steer; context covers every
|
|
176
|
+
// LLM call, including the ones inside a tool loop. Reminders are pinned to the message they were first
|
|
177
|
+
// rendered after, so mid-turn requests keep them without moving the cached prefix.
|
|
178
|
+
// No core means no run was ever started, so there is nothing to wait for.
|
|
179
|
+
pi.on("context", (event, ctx) => {
|
|
180
|
+
if (!core || core.store.disposed) return;
|
|
181
|
+
let sessionFile: string | undefined;
|
|
182
|
+
try {
|
|
183
|
+
sessionFile = ctx.sessionManager.getSessionFile?.();
|
|
184
|
+
} catch {
|
|
185
|
+
sessionFile = undefined;
|
|
186
|
+
}
|
|
187
|
+
return handleRunningReminderContext(reminderAnchors, event.messages, core.store, sessionFile);
|
|
188
|
+
});
|
|
189
|
+
|
|
171
190
|
// If input arrives while the core is still loading, awaiting here lets the
|
|
172
191
|
// live handler-array dispatch reach the shortcut handlers registerAll adds.
|
|
173
192
|
pi.on("input", async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryan_nookpi/pi-extension-subagent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
4
4
|
"description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"retry.ts",
|
|
52
52
|
"run-utils.ts",
|
|
53
53
|
"runner.ts",
|
|
54
|
+
"running-reminder.ts",
|
|
54
55
|
"session.ts",
|
|
55
56
|
"starter-pack.ts",
|
|
56
57
|
"store.ts",
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request reminder of subagent runs whose results have not reached the model yet.
|
|
3
|
+
*
|
|
4
|
+
* Imports nothing beyond the leaf time-utils module so index.ts can register the context handler
|
|
5
|
+
* without loading the core.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SubagentStore } from "./store.js";
|
|
9
|
+
import type { CommandRunState } from "./types.js";
|
|
10
|
+
import { formatElapsedSince } from "./utils/time-utils.js";
|
|
11
|
+
|
|
12
|
+
export const RUNNING_REMINDER_CUSTOM_TYPE = "subagent-running-reminder";
|
|
13
|
+
const MAX_REMINDER_TASK_LENGTH = 60;
|
|
14
|
+
/** Enough to cover a long turn; the oldest anchors fall off a session that runs agents for hours. */
|
|
15
|
+
const MAX_ANCHORS = 50;
|
|
16
|
+
/** A hosted runtime can serve a few sessions from one extension instance. */
|
|
17
|
+
const MAX_ANCHOR_SCOPES = 4;
|
|
18
|
+
|
|
19
|
+
type ReminderStore = Pick<SubagentStore, "globalLiveRuns" | "batchGroups" | "pipelines">;
|
|
20
|
+
|
|
21
|
+
function normalizeSessionFile(value: string | undefined | null): string {
|
|
22
|
+
return typeof value === "string" ? value.replace(/[\r\n\t]+/g, "").trim() : "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shortTask(run: CommandRunState): string {
|
|
26
|
+
const raw = (run.displayTask || run.task || "").replace(/\s+/g, " ").trim().replaceAll('"', "'");
|
|
27
|
+
return raw.length > MAX_REMINDER_TASK_LENGTH ? `${raw.slice(0, MAX_REMINDER_TASK_LENGTH - 3)}...` : raw;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs started from this session whose completion still goes to the model. Ownership matches
|
|
32
|
+
* tool-execute's origin check: an unknown session file on either side counts as the same session.
|
|
33
|
+
*
|
|
34
|
+
* A finished run stays listed only while someone still owes its delivery: its own held completion, or
|
|
35
|
+
* the batch or pipeline summary it belongs to. Entries whose group was already evicted are orphans and
|
|
36
|
+
* must not keep the model quiet forever.
|
|
37
|
+
*/
|
|
38
|
+
export function collectPendingRuns(store: ReminderStore, currentSessionFile: string | undefined): CommandRunState[] {
|
|
39
|
+
const current = normalizeSessionFile(currentSessionFile);
|
|
40
|
+
const runs: CommandRunState[] = [];
|
|
41
|
+
for (const entry of store.globalLiveRuns.values()) {
|
|
42
|
+
const run = entry.runState;
|
|
43
|
+
if (run.removed || run.deliveryMode === "humanOnly") continue;
|
|
44
|
+
if (run.status !== "running" && !deliveryStillOwed(store, entry.pendingCompletion !== undefined, run)) continue;
|
|
45
|
+
const origin = normalizeSessionFile(entry.originSessionFile);
|
|
46
|
+
if (current && origin && current !== origin) continue;
|
|
47
|
+
runs.push(run);
|
|
48
|
+
}
|
|
49
|
+
return runs.sort((left, right) => left.startedAt - right.startedAt || left.id - right.id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function deliveryStillOwed(store: ReminderStore, hasPendingCompletion: boolean, run: CommandRunState): boolean {
|
|
53
|
+
if (hasPendingCompletion) return true;
|
|
54
|
+
if (run.batchId && store.batchGroups.has(run.batchId)) return true;
|
|
55
|
+
return Boolean(run.pipelineId && store.pipelines.has(run.pipelineId));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatRunningReminder(runs: readonly CommandRunState[], now: number): string | undefined {
|
|
59
|
+
if (runs.length === 0) return undefined;
|
|
60
|
+
const items = runs.map((run) => {
|
|
61
|
+
const task = shortTask(run);
|
|
62
|
+
const head = `subagent run #${run.id} ${run.agent}${task ? ` "${task}"` : ""}`;
|
|
63
|
+
if (run.status !== "running") return `${head} finished, result not delivered yet`;
|
|
64
|
+
return `${head} ${formatElapsedSince(run.startedAt, now)}`;
|
|
65
|
+
});
|
|
66
|
+
return `Still running or awaiting delivery (results NOT delivered yet): ${items.join("; ")}. Do not state their final outcome, counts, hashes, or artifacts until their completion notification arrives or you read their final status yourself; partial output you read via the tool may be described as in-progress.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Anchor {
|
|
70
|
+
/** Frozen when the anchor is created, so later requests repeat it byte for byte. */
|
|
71
|
+
text: string;
|
|
72
|
+
timestamp: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface AnchorScope {
|
|
76
|
+
anchors: Map<string, Anchor>;
|
|
77
|
+
/** Ids the most recently created anchor already names, so a shrinking set does not add anchors. */
|
|
78
|
+
covered: ReadonlySet<string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pi clones the messages for every request, so an anchor cannot hold a message reference. Role,
|
|
83
|
+
* timestamp, and the id of a tool result or custom message identify the same message across requests.
|
|
84
|
+
*/
|
|
85
|
+
function anchorKey(message: unknown): string | undefined {
|
|
86
|
+
const candidate = message as
|
|
87
|
+
| { role?: unknown; timestamp?: unknown; toolCallId?: unknown; customType?: unknown }
|
|
88
|
+
| undefined;
|
|
89
|
+
if (!candidate || typeof candidate.role !== "string" || typeof candidate.timestamp !== "number") return undefined;
|
|
90
|
+
const detail =
|
|
91
|
+
typeof candidate.toolCallId === "string"
|
|
92
|
+
? candidate.toolCallId
|
|
93
|
+
: typeof candidate.customType === "string"
|
|
94
|
+
? candidate.customType
|
|
95
|
+
: "";
|
|
96
|
+
return `${candidate.role}|${candidate.timestamp}|${detail}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Keeps the reminder visible inside a tool loop without breaking the prompt cache.
|
|
101
|
+
*
|
|
102
|
+
* Appending a freshly rendered reminder to every request would change the tail each time and invalidate
|
|
103
|
+
* the cached prefix. Instead each reminder is pinned to the message it was first rendered after and
|
|
104
|
+
* replayed there verbatim, so a request built later in the same turn only grows at the end.
|
|
105
|
+
*/
|
|
106
|
+
export class ReminderAnchors {
|
|
107
|
+
/** Keyed by session file: a hosted runtime can route another session's request through this instance. */
|
|
108
|
+
private readonly scopes = new Map<string, AnchorScope>();
|
|
109
|
+
|
|
110
|
+
clear(): void {
|
|
111
|
+
this.scopes.clear();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
isEmpty(): boolean {
|
|
115
|
+
for (const scope of this.scopes.values()) if (scope.anchors.size > 0) return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
apply<T>(
|
|
120
|
+
session: string,
|
|
121
|
+
messages: readonly T[],
|
|
122
|
+
pending: { ids: readonly string[]; render: () => string | undefined },
|
|
123
|
+
now: number,
|
|
124
|
+
): { messages: T[] } | undefined {
|
|
125
|
+
const known = this.scopes.get(session);
|
|
126
|
+
if (pending.ids.length === 0 && !known?.anchors.size) return undefined;
|
|
127
|
+
const scope = known ?? this.openScope(session);
|
|
128
|
+
recordAnchor(scope, messages, pending, now);
|
|
129
|
+
if (scope.anchors.size === 0) return undefined;
|
|
130
|
+
return insertAnchors(scope.anchors, messages);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private openScope(session: string): AnchorScope {
|
|
134
|
+
const scope: AnchorScope = { anchors: new Map(), covered: new Set() };
|
|
135
|
+
this.scopes.set(session, scope);
|
|
136
|
+
while (this.scopes.size > MAX_ANCHOR_SCOPES) {
|
|
137
|
+
const oldest = this.scopes.keys().next().value;
|
|
138
|
+
if (oldest === undefined) break;
|
|
139
|
+
this.scopes.delete(oldest);
|
|
140
|
+
}
|
|
141
|
+
return scope;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function recordAnchor<T>(
|
|
146
|
+
scope: AnchorScope,
|
|
147
|
+
messages: readonly T[],
|
|
148
|
+
pending: { ids: readonly string[]; render: () => string | undefined },
|
|
149
|
+
now: number,
|
|
150
|
+
): void {
|
|
151
|
+
if (pending.ids.length === 0) return;
|
|
152
|
+
const last = messages.at(-1);
|
|
153
|
+
const key = anchorKey(last);
|
|
154
|
+
if (!key) return;
|
|
155
|
+
const continuesTurn = (last as { role?: unknown } | undefined)?.role === "toolResult";
|
|
156
|
+
// Mid-turn requests only earn an anchor once work the model has not been warned about shows up.
|
|
157
|
+
if (continuesTurn && pending.ids.every((id) => scope.covered.has(id))) return;
|
|
158
|
+
const text = pending.render();
|
|
159
|
+
if (!text) return;
|
|
160
|
+
scope.anchors.set(key, { text, timestamp: now });
|
|
161
|
+
scope.covered = new Set(pending.ids);
|
|
162
|
+
while (scope.anchors.size > MAX_ANCHORS) {
|
|
163
|
+
const oldest = scope.anchors.keys().next().value;
|
|
164
|
+
if (oldest === undefined) break;
|
|
165
|
+
scope.anchors.delete(oldest);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function insertAnchors<T>(anchors: ReadonlyMap<string, Anchor>, messages: readonly T[]): { messages: T[] } | undefined {
|
|
170
|
+
const result: T[] = [];
|
|
171
|
+
let inserted = false;
|
|
172
|
+
for (const message of messages) {
|
|
173
|
+
result.push(message);
|
|
174
|
+
const key = anchorKey(message);
|
|
175
|
+
const anchor = key ? anchors.get(key) : undefined;
|
|
176
|
+
if (!anchor) continue;
|
|
177
|
+
result.push({
|
|
178
|
+
role: "custom",
|
|
179
|
+
customType: RUNNING_REMINDER_CUSTOM_TYPE,
|
|
180
|
+
content: anchor.text,
|
|
181
|
+
display: false,
|
|
182
|
+
timestamp: anchor.timestamp,
|
|
183
|
+
} as unknown as T);
|
|
184
|
+
inserted = true;
|
|
185
|
+
}
|
|
186
|
+
return inserted ? { messages: result } : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* context handler body. Pi fires context before every LLM call, and does not persist the returned
|
|
191
|
+
* messages, so the reminder never enters the transcript.
|
|
192
|
+
*/
|
|
193
|
+
export function handleRunningReminderContext<T>(
|
|
194
|
+
anchors: ReminderAnchors,
|
|
195
|
+
messages: readonly T[],
|
|
196
|
+
store: ReminderStore | null | undefined,
|
|
197
|
+
currentSessionFile: string | undefined,
|
|
198
|
+
now = Date.now(),
|
|
199
|
+
): { messages: T[] } | undefined {
|
|
200
|
+
const runs = store ? collectPendingRuns(store, currentSessionFile) : [];
|
|
201
|
+
return anchors.apply(
|
|
202
|
+
normalizeSessionFile(currentSessionFile),
|
|
203
|
+
messages,
|
|
204
|
+
{ ids: runs.map((run) => String(run.id)), render: () => formatRunningReminder(runs, now) },
|
|
205
|
+
now,
|
|
206
|
+
);
|
|
207
|
+
}
|