pi-doc-injector 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/async-subagent-bug.md +199 -0
- package/index.ts +0 -10
- package/package.json +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Doc-Injector & Async Subagents Bug Analysis
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The doc-injector extension causes spinner hangs when used alongside async subagent workflows (e.g., `subagent_isolated`, `subagent_with_context` with `async: true`).
|
|
6
|
+
|
|
7
|
+
## Root Cause
|
|
8
|
+
|
|
9
|
+
### The Flow
|
|
10
|
+
|
|
11
|
+
1. Parent spawns async subagents with `async: true, notifyOnComplete: "inject"`
|
|
12
|
+
2. Parent streams output (e.g., "I've launched 5 agents...")
|
|
13
|
+
3. Doc-injector's `message_update` fires, detects keyword match
|
|
14
|
+
4. Doc-injector calls `ctx.abort()` to abort current turn
|
|
15
|
+
5. `agent_end` fires → extension injects `"continue"` via `setTimeout()`
|
|
16
|
+
6. Parent receives `"continue"` → immediately calls `get_subagent_result x5`
|
|
17
|
+
7. Subagents still running → results not available → **spinner stuck**
|
|
18
|
+
|
|
19
|
+
### Why This Happens
|
|
20
|
+
|
|
21
|
+
The current doc-injector design assumes it can abort the current turn and restart immediately with injected docs. This works for simple single-turn workflows but breaks async subagent workflows because:
|
|
22
|
+
|
|
23
|
+
1. **The injected `"continue"` disrupts the expected flow** - parent should wait for `notifyOnComplete: "inject"` messages, not immediately poll for results
|
|
24
|
+
2. **`get_subagent_result` blocks** - when called before subagents finish, it hangs the parent thread
|
|
25
|
+
3. **The abort signal is shared** - in some subagent implementations (e.g., the `subagent` tool in pi-subagentura), async subagents explicitly don't inherit the parent abort signal. But the injected `"continue"` still breaks the flow
|
|
26
|
+
|
|
27
|
+
### Key Code Path (doc-injector/index.ts)
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
// message_update handler - triggers abort on keyword match
|
|
31
|
+
pi.on("message_update", async (event, ctx) => {
|
|
32
|
+
if (hasNew && !ctx.isIdle() && !abortingForInjection) {
|
|
33
|
+
abortingForInjection = true;
|
|
34
|
+
ctx.abort(); // ← PROBLEM: aborts current turn
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// agent_end handler - injects "continue" after abort
|
|
39
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
40
|
+
if (abortingForInjection) {
|
|
41
|
+
abortingForInjection = false;
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
pi.sendUserMessage("continue"); // ← PROBLEM: disrupts async flow
|
|
44
|
+
}, 0);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Subagentura System Context
|
|
50
|
+
|
|
51
|
+
### How Async Subagents Work
|
|
52
|
+
|
|
53
|
+
The subagentura system (`/Users/applesucks/dev/pi-agents/subagent.ts`) spawns async subagents that:
|
|
54
|
+
|
|
55
|
+
1. Run in the same process (not subprocess)
|
|
56
|
+
2. Have independent session/context
|
|
57
|
+
3. Signal via `notifyOnComplete: "inject"` - results injected as user messages when complete
|
|
58
|
+
4. Use `get_subagent_result` to poll/block for results
|
|
59
|
+
|
|
60
|
+
### Key Line (subagent.ts line 541, 744)
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
signal: undefined, // async: don't inherit parent signal (would abort subagent when tool returns)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Option A: Remove ctx.abort() Entirely (Recommended)
|
|
67
|
+
|
|
68
|
+
**Change:** Never call `ctx.abort()` in `message_update`. Just record matches and inject on next turn.
|
|
69
|
+
|
|
70
|
+
**Pros:**
|
|
71
|
+
- ✅ Simplest fix
|
|
72
|
+
- ✅ Works with ANY async workflow automatically
|
|
73
|
+
- ✅ No special-casing for subagents
|
|
74
|
+
|
|
75
|
+
**Cons:**
|
|
76
|
+
- ❌ Loses "immediate injection during streaming" optimization
|
|
77
|
+
- ❌ Docs injected on next turn instead of immediately
|
|
78
|
+
|
|
79
|
+
**Implementation:**
|
|
80
|
+
```typescript
|
|
81
|
+
pi.on("message_update", async (event, ctx) => {
|
|
82
|
+
if (keywordGenInFlight) return;
|
|
83
|
+
if (!enabled || !registry) return;
|
|
84
|
+
if (msg.role !== "assistant") return;
|
|
85
|
+
|
|
86
|
+
const content = (msg as unknown as { content: unknown }).content;
|
|
87
|
+
textBuffer = extractText(content);
|
|
88
|
+
if (!textBuffer) return;
|
|
89
|
+
|
|
90
|
+
const matcher = buildMatcher();
|
|
91
|
+
if (!matcher) return;
|
|
92
|
+
|
|
93
|
+
const results = matcher.match(textBuffer);
|
|
94
|
+
for (const result of results) {
|
|
95
|
+
if (!pendingMatches.has(result.entry.filePath)) {
|
|
96
|
+
hasNew = true; // Just record - don't abort
|
|
97
|
+
}
|
|
98
|
+
pendingMatches.set(result.entry.filePath, result.matchedKeywords);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// REMOVE: ctx.abort() entirely
|
|
102
|
+
// Just let the turn complete naturally
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Option B: Detect Async Subagents Before Aborting
|
|
107
|
+
|
|
108
|
+
**Change:** Check if assistant just spawned async subagents, skip abort if so.
|
|
109
|
+
|
|
110
|
+
**Pros:**
|
|
111
|
+
- ✅ Preserves immediate injection behavior
|
|
112
|
+
- ✅ Subagents continue running
|
|
113
|
+
|
|
114
|
+
**Cons:**
|
|
115
|
+
- ❌ Fragile - depends on detecting specific tool call patterns
|
|
116
|
+
- ❌ Doesn't scale to other async workflows
|
|
117
|
+
|
|
118
|
+
### Option C: Only Inject on User Input
|
|
119
|
+
|
|
120
|
+
**Change:** Only trigger immediate injection on `input` event (user messages), not on assistant streaming.
|
|
121
|
+
|
|
122
|
+
**Pros:**
|
|
123
|
+
- ✅ Clean separation of concerns
|
|
124
|
+
- ✅ User intent gets immediate response
|
|
125
|
+
|
|
126
|
+
**Cons:**
|
|
127
|
+
- ❌ Loses streaming injection optimization
|
|
128
|
+
- ❌ Still may not handle all async edge cases
|
|
129
|
+
|
|
130
|
+
## Recommendation
|
|
131
|
+
|
|
132
|
+
**Use Option A:** Remove `ctx.abort()` entirely.
|
|
133
|
+
|
|
134
|
+
The streaming injection optimization is not worth the complexity and the risk of breaking async workflows. Most keyword matches from assistant streaming are not time-critical - they can wait for the next turn.
|
|
135
|
+
|
|
136
|
+
## Additional Notes
|
|
137
|
+
|
|
138
|
+
### The notifyOnComplete:"inject" Flow
|
|
139
|
+
|
|
140
|
+
1. Parent spawns `async: true, notifyOnComplete: "inject"`
|
|
141
|
+
2. Parent continues immediately (doesn't wait)
|
|
142
|
+
3. Each subagent runs independently
|
|
143
|
+
4. When subagent completes, result is injected as user message via `pi.sendUserMessage()`
|
|
144
|
+
5. Parent receives the injected result and continues naturally
|
|
145
|
+
|
|
146
|
+
This flow is designed to work without any intervention from the parent. Breaking it with `ctx.abort()` + `"continue"` defeats the purpose.
|
|
147
|
+
|
|
148
|
+
### Session Context
|
|
149
|
+
|
|
150
|
+
Each subagent runs in its **own session** with its own doc-injector instance. Doc injection in subagent sessions is fine - they inject their own docs into their own context. The problem is only in the **parent session**.
|
|
151
|
+
|
|
152
|
+
## References
|
|
153
|
+
|
|
154
|
+
- doc-injector: `/Users/applesucks/dev/pi-docs/index.ts`
|
|
155
|
+
- subagentura: `/Users/applesucks/dev/pi-agents/subagent.ts`
|
|
156
|
+
- subagentura helpers: `/Users/applesucks/dev/pi-agents/helpers.ts`
|
|
157
|
+
|
|
158
|
+
## Web Research Summary
|
|
159
|
+
|
|
160
|
+
### Official Documentation
|
|
161
|
+
|
|
162
|
+
From `pi.dev/docs/latest/extensions`, the key lifecycle events are:
|
|
163
|
+
|
|
164
|
+
- `before_agent_start` - Fires after user submits prompt, **before** agent loop. Can inject a message and/or modify system prompt.
|
|
165
|
+
- `agent_start` / `agent_end` - Fires once per user prompt
|
|
166
|
+
- `message_update` - Fires during streaming output
|
|
167
|
+
- `input` - Fires on user input
|
|
168
|
+
|
|
169
|
+
**Important note from docs:** "For critical events (before_agent_start, context, tool_call), if all handlers fail, the system continues with default behavior."
|
|
170
|
+
|
|
171
|
+
### Key Extension API Points
|
|
172
|
+
|
|
173
|
+
1. **`ctx.abort()`** - "Request a graceful shutdown of pi. Interactive mode: Deferred until the agent becomes idle (after processing all queued steering and follow-up messages)."
|
|
174
|
+
2. **`ctx.signal`** - Available for extensions to forward cancellation into nested model calls, fetch(), and other abort-aware work.
|
|
175
|
+
3. **`before_agent_start`** - The recommended injection point for context, as it runs before the agent loop starts.
|
|
176
|
+
|
|
177
|
+
### Related GitHub Issues
|
|
178
|
+
|
|
179
|
+
- [Issue #624](https://github.com/earendil-works/pi/issues/624): "before_agent_start event does not properly inject message" - indicates there have been historical issues with injection timing
|
|
180
|
+
- [Issue #2660](https://github.com/earendil-works/pi/issues/2660): "Expose abort signal on ExtensionContext" - led to `ctx.signal` being added
|
|
181
|
+
|
|
182
|
+
### Subagent Extensions Found
|
|
183
|
+
|
|
184
|
+
1. **tintinweb/pi-subagents** (`pi.dev/packages/pi-subagents`)
|
|
185
|
+
- Claude Code-style autonomous subagents
|
|
186
|
+
- Spawns agents in isolated sessions
|
|
187
|
+
- Run in foreground or background
|
|
188
|
+
- Similar to subagentura
|
|
189
|
+
|
|
190
|
+
2. **nicobailon/pi-subagents** (GitHub)
|
|
191
|
+
- Async subagent delegation with truncation, artifacts, session sharing
|
|
192
|
+
|
|
193
|
+
### Conclusion from Web Research
|
|
194
|
+
|
|
195
|
+
No documented solutions exist for the specific conflict between streaming injection (via `ctx.abort()`) and async subagent workflows. The recommended approach based on official docs is:
|
|
196
|
+
|
|
197
|
+
1. Use `before_agent_start` for context injection (runs before agent loop)
|
|
198
|
+
2. Avoid `ctx.abort()` during streaming - it disrupts any workflow that expects continuous streaming
|
|
199
|
+
3. The `input` event is the cleanest injection point for user-driven context since it represents new user intent
|
package/index.ts
CHANGED
|
@@ -120,11 +120,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
const count = registry.getEntries().length;
|
|
123
|
-
if (count > 0) {
|
|
124
|
-
console.log(`[doc-injector] Loaded ${count} documents from ${docsPath}`);
|
|
125
|
-
} else {
|
|
126
|
-
console.warn(`[doc-injector] No documents found at ${docsPath}`);
|
|
127
|
-
}
|
|
128
123
|
};
|
|
129
124
|
|
|
130
125
|
const buildMatcher = (): KeywordMatcher | null => {
|
|
@@ -170,7 +165,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
|
|
|
170
165
|
const absPath = resolve(ctx.cwd, config.docsPath, item.path);
|
|
171
166
|
const fileStat = await stat(absPath).catch(() => null);
|
|
172
167
|
if (!fileStat) {
|
|
173
|
-
console.warn(`[doc-injector] Skipping keyword save for ${item.path}: file not found`);
|
|
174
168
|
continue;
|
|
175
169
|
}
|
|
176
170
|
cache.files[item.path] = {
|
|
@@ -231,7 +225,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
|
|
|
231
225
|
}
|
|
232
226
|
|
|
233
227
|
const count = registry.getEntries().length;
|
|
234
|
-
console.log(`[doc-injector] Reloaded: ${count} documents`);
|
|
235
228
|
return count;
|
|
236
229
|
};
|
|
237
230
|
|
|
@@ -338,7 +331,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
|
|
|
338
331
|
// past the model's limit.
|
|
339
332
|
const usage = ctx.getContextUsage();
|
|
340
333
|
if (usage && usage.tokens && usage.tokens > 0 && usage.percent && usage.percent > config.contextThreshold) {
|
|
341
|
-
console.warn(`[doc-injector] Skipping injection: context usage > ${config.contextThreshold}%`);
|
|
342
334
|
pendingMatches.clear();
|
|
343
335
|
return;
|
|
344
336
|
}
|
|
@@ -379,8 +371,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
|
|
|
379
371
|
setTimeout(() => {
|
|
380
372
|
pi.sendUserMessage("continue");
|
|
381
373
|
}, 0);
|
|
382
|
-
} else {
|
|
383
|
-
console.log('[doc-injector] agent_end: abortingForInjection is false, skipping');
|
|
384
374
|
}
|
|
385
375
|
});
|
|
386
376
|
|