@gotgenes/pi-subagents 6.2.0 → 6.3.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 +14 -0
- package/docs/plans/0100-replace-callback-threading-with-session-subscription.md +454 -0
- package/docs/retro/0099-replace-ctx-with-parent-snapshot.md +37 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +17 -38
- package/src/agent-runner.ts +2 -113
- package/src/record-observer.ts +60 -0
- package/src/tools/agent-tool.ts +27 -64
- package/src/ui/ui-observer.ts +83 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [6.3.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.2.0...pi-subagents-v6.3.0) (2026-05-21)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add record observer for direct session subscription ([#100](https://github.com/gotgenes/pi-packages/issues/100)) ([3776345](https://github.com/gotgenes/pi-packages/commit/37763454609d38d55c85f89bb051b8c592d425bf))
|
|
14
|
+
* add UI observer for direct session subscription ([#100](https://github.com/gotgenes/pi-packages/issues/100)) ([5b35e80](https://github.com/gotgenes/pi-packages/commit/5b35e80e784d96883ee44bf5e890284efa1047ef))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* plan callback-threading replacement with session subscription ([#100](https://github.com/gotgenes/pi-packages/issues/100)) ([7a8e262](https://github.com/gotgenes/pi-packages/commit/7a8e262ebaf99cba017aac00691cc3614ef8c80a))
|
|
20
|
+
* **retro:** add retro notes for issue [#99](https://github.com/gotgenes/pi-packages/issues/99) ([b596d0c](https://github.com/gotgenes/pi-packages/commit/b596d0c34862542d0ebb9fae67dd90e9fc660a8b))
|
|
21
|
+
|
|
8
22
|
## [6.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.1.0...pi-subagents-v6.2.0) (2026-05-21)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 100
|
|
3
|
+
issue_title: "Replace callback threading with direct session-event subscription"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Replace callback threading with direct session-event subscription
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`SpawnOptions` carries 6 `on*` callback fields that thread through three layers: `agent-tool.ts` creates activity-tracking callbacks → `AgentManager.startAgent()` wraps each to update the record, then forwards → `runner.run()` subscribes to session events and translates them into callback invocations.
|
|
11
|
+
The session already emits all of these events via `session.subscribe()`.
|
|
12
|
+
Three layers reimplement what two independent subscriptions could provide.
|
|
13
|
+
|
|
14
|
+
The same pattern repeats in `resume()`, which hand-rolls 3 of the 4 callback wrappers.
|
|
15
|
+
|
|
16
|
+
## Goals
|
|
17
|
+
|
|
18
|
+
- Replace the 3-layer callback chain with two direct session subscribers: a **record observer** (AgentManager, updates record stats) and a **UI observer** (agent-tool, streams widget state).
|
|
19
|
+
- Drop 5 `on*` callback fields from `SpawnOptions` (keep `onSessionCreated` as the session-delivery mechanism).
|
|
20
|
+
- Drop 5 `on*` callback fields from `RunOptions`.
|
|
21
|
+
- Drop 3 `on*` callback fields from `ResumeOptions`.
|
|
22
|
+
- Simplify `startAgent()` from ~80 lines of callback wiring to ~20 lines of observer setup.
|
|
23
|
+
- Eliminate duplicated callback wiring between `startAgent()` and `resume()`.
|
|
24
|
+
|
|
25
|
+
## Non-Goals
|
|
26
|
+
|
|
27
|
+
- Extracting turn-limit enforcement from the runner — it stays as the runner's own subscription, just simplified.
|
|
28
|
+
A follow-up could extract it into a `turn-limiter.ts` module.
|
|
29
|
+
- Changing the public `SubagentsService` API in `service.ts` — the public `SpawnOptions` type is unaffected.
|
|
30
|
+
- Removing `onSessionCreated` — it remains as the session-delivery bridge between the runner and external subscribers.
|
|
31
|
+
- Changing `collectResponseText` or `forwardAbortSignal` in the runner — those are the runner's own concerns.
|
|
32
|
+
|
|
33
|
+
## Background
|
|
34
|
+
|
|
35
|
+
### Prerequisite status
|
|
36
|
+
|
|
37
|
+
- Issue #98 (AgentRecord state machine) — **done**.
|
|
38
|
+
`AgentRecord` is a class with encapsulated transition methods (`markRunning`, `markCompleted`, `markError`, etc.).
|
|
39
|
+
The record observer uses public mutable fields (`toolUses`, `lifetimeUsage`, `compactionCount`) and the `addUsage` helper.
|
|
40
|
+
- Issue #99 (ParentSnapshot) — **done**.
|
|
41
|
+
`runner.run()` accepts `ParentSnapshot`, `SpawnArgs` stores a snapshot, `AgentManager` has `exec: ShellExec` injected.
|
|
42
|
+
|
|
43
|
+
### Current callback threading
|
|
44
|
+
|
|
45
|
+
In `startAgent()`, AgentManager wraps 4 of the 6 callbacks to interleave record mutations:
|
|
46
|
+
|
|
47
|
+
| Callback | Record mutation | Forwarding |
|
|
48
|
+
| ------------------ | ------------------------------------------------------------- | ------------------------------------- |
|
|
49
|
+
| `onToolActivity` | `record.toolUses++` on end | Forward to `options.onToolActivity` |
|
|
50
|
+
| `onAssistantUsage` | `addUsage(record.lifetimeUsage, …)` | Forward to `options.onAssistantUsage` |
|
|
51
|
+
| `onCompaction` | `record.compactionCount++` + `this.onCompact?.(record, info)` | Forward to `options.onCompaction` |
|
|
52
|
+
| `onSessionCreated` | Store session, capture file, flush steers | Forward to `options.onSessionCreated` |
|
|
53
|
+
| `onTextDelta` | None | Pass-through |
|
|
54
|
+
| `onTurnEnd` | None | Pass-through |
|
|
55
|
+
|
|
56
|
+
In `resume()`, 3 callbacks are wired manually (onToolActivity, onAssistantUsage, onCompaction) with no forwarding — only record mutations.
|
|
57
|
+
|
|
58
|
+
The runner's big `session.subscribe()` block (lines 323–370 of `agent-runner.ts`) mixes turn-limit enforcement with event→callback translation for all 6 callback types.
|
|
59
|
+
|
|
60
|
+
### Code-design constraints
|
|
61
|
+
|
|
62
|
+
- **Parameter relay** (code-design skill): the 5 UI callbacks thread through `SpawnOptions` → `startAgent()` wrapping → `RunOptions` → runner subscription.
|
|
63
|
+
The intermediary (`startAgent`) only uses them to interleave record mutations.
|
|
64
|
+
- **Scattered resets** (design-review): `resume()` hand-rolls 3 of the same 4 callback wrappers from `startAgent()`.
|
|
65
|
+
|
|
66
|
+
### Relevant modules
|
|
67
|
+
|
|
68
|
+
| Module | Role in this change |
|
|
69
|
+
| ------------------------- | -------------------------------------------------------------------- |
|
|
70
|
+
| `src/agent-manager.ts` | Owns `SpawnOptions`, `startAgent()`, `resume()` — primary target |
|
|
71
|
+
| `src/agent-runner.ts` | Owns `RunOptions`, `ResumeOptions`, runner subscription — simplifies |
|
|
72
|
+
| `src/tools/agent-tool.ts` | Owns `createActivityTracker()` — replaced by UI observer |
|
|
73
|
+
| `src/ui/agent-widget.ts` | Defines `AgentActivity` interface — unchanged |
|
|
74
|
+
| `src/agent-record.ts` | `AgentRecord` class — unchanged (observer writes to public fields) |
|
|
75
|
+
| `src/usage.ts` | `addUsage` helper — unchanged, used by record observer |
|
|
76
|
+
|
|
77
|
+
## Design Overview
|
|
78
|
+
|
|
79
|
+
### Two independent observers
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
session.subscribe()
|
|
83
|
+
│
|
|
84
|
+
┌─────────────┼─────────────┐
|
|
85
|
+
│ │
|
|
86
|
+
Record observer UI observer
|
|
87
|
+
(accumulates stats on record) (updates widget state)
|
|
88
|
+
managed by AgentManager managed by agent-tool
|
|
89
|
+
subscribes in onSessionCreated subscribes in onSessionCreated
|
|
90
|
+
unsubscribes in .then/.catch unsubscribes on completion
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Both subscribe to the same session but update independent state.
|
|
94
|
+
Neither wraps or forwards the other's events.
|
|
95
|
+
|
|
96
|
+
### Record observer
|
|
97
|
+
|
|
98
|
+
New module `src/record-observer.ts`:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
export interface RecordObserverOptions {
|
|
102
|
+
onCompact?: (record: AgentRecord, info: CompactionInfo) => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function subscribeRecordObserver(
|
|
106
|
+
session: AgentSession,
|
|
107
|
+
record: AgentRecord,
|
|
108
|
+
options?: RecordObserverOptions,
|
|
109
|
+
): () => void;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Handles three event types:
|
|
113
|
+
|
|
114
|
+
| Session event | Record mutation |
|
|
115
|
+
| ------------------------------------------- | ---------------------------------------------------- |
|
|
116
|
+
| `tool_execution_end` | `record.toolUses++` |
|
|
117
|
+
| `message_end` (assistant, with usage) | `addUsage(record.lifetimeUsage, usage)` |
|
|
118
|
+
| `compaction_end` (not aborted, with result) | `record.compactionCount++`, call `options.onCompact` |
|
|
119
|
+
|
|
120
|
+
These are the exact mutations currently scattered across `startAgent()` and `resume()` callback wrappers.
|
|
121
|
+
|
|
122
|
+
The returned function unsubscribes from the session.
|
|
123
|
+
|
|
124
|
+
### UI observer
|
|
125
|
+
|
|
126
|
+
New module `src/ui/ui-observer.ts`:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
export function subscribeUIObserver(
|
|
130
|
+
session: AgentSession,
|
|
131
|
+
state: AgentActivity,
|
|
132
|
+
onUpdate?: () => void,
|
|
133
|
+
): () => void;
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Handles six event types — the same events currently translated by `createActivityTracker`'s callbacks:
|
|
137
|
+
|
|
138
|
+
| Session event | State mutation |
|
|
139
|
+
| ------------------------------------- | --------------------------------------------------- |
|
|
140
|
+
| `tool_execution_start` | Add to `state.activeTools` |
|
|
141
|
+
| `tool_execution_end` | Remove from `state.activeTools`, `state.toolUses++` |
|
|
142
|
+
| `message_start` | Reset `state.responseText` |
|
|
143
|
+
| `message_update` (text_delta) | Append to `state.responseText` |
|
|
144
|
+
| `turn_end` | `state.turnCount++` |
|
|
145
|
+
| `message_end` (assistant, with usage) | `addUsage(state.lifetimeUsage, usage)` |
|
|
146
|
+
|
|
147
|
+
Calls `onUpdate?.()` after each mutation (matching current `onStreamUpdate` behavior for foreground rendering).
|
|
148
|
+
|
|
149
|
+
The returned function unsubscribes from the session.
|
|
150
|
+
|
|
151
|
+
### SpawnOptions after the change
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
export interface SpawnOptions {
|
|
155
|
+
description: string;
|
|
156
|
+
model?: Model<any>;
|
|
157
|
+
maxTurns?: number;
|
|
158
|
+
isolated?: boolean;
|
|
159
|
+
inheritContext?: boolean;
|
|
160
|
+
thinkingLevel?: ThinkingLevel;
|
|
161
|
+
isBackground?: boolean;
|
|
162
|
+
bypassQueue?: boolean;
|
|
163
|
+
isolation?: IsolationMode;
|
|
164
|
+
invocation?: AgentInvocation;
|
|
165
|
+
signal?: AbortSignal;
|
|
166
|
+
parentSessionFile?: string;
|
|
167
|
+
parentSessionId?: string;
|
|
168
|
+
/** Called when the session is created — the one remaining callback. */
|
|
169
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Drops: `onToolActivity`, `onTextDelta`, `onTurnEnd`, `onAssistantUsage`, `onCompaction`.
|
|
174
|
+
|
|
175
|
+
### RunOptions after the change
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
export interface RunOptions {
|
|
179
|
+
exec: ShellExec;
|
|
180
|
+
model?: Model<any>;
|
|
181
|
+
maxTurns?: number;
|
|
182
|
+
signal?: AbortSignal;
|
|
183
|
+
isolated?: boolean;
|
|
184
|
+
thinkingLevel?: ThinkingLevel;
|
|
185
|
+
cwd?: string;
|
|
186
|
+
parentSessionFile?: string;
|
|
187
|
+
parentSessionId?: string;
|
|
188
|
+
defaultMaxTurns?: number;
|
|
189
|
+
graceTurns?: number;
|
|
190
|
+
/** Called once after session creation — session delivery mechanism. */
|
|
191
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Drops: `onToolActivity`, `onTextDelta`, `onTurnEnd`, `onAssistantUsage`, `onCompaction`.
|
|
196
|
+
|
|
197
|
+
### ResumeOptions after the change
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
export interface ResumeOptions {
|
|
201
|
+
signal?: AbortSignal;
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Drops: `onToolActivity`, `onAssistantUsage`, `onCompaction`.
|
|
206
|
+
|
|
207
|
+
### Runner simplification
|
|
208
|
+
|
|
209
|
+
The runner's big `session.subscribe()` block in `runAgent()` (currently ~50 lines mixing turn-limit enforcement with callback forwarding) simplifies to turn-limit enforcement only (~15 lines):
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const unsubTurns = session.subscribe((event) => {
|
|
213
|
+
if (event.type === "turn_end") {
|
|
214
|
+
turnCount++;
|
|
215
|
+
if (maxTurns != null) {
|
|
216
|
+
if (!softLimitReached && turnCount >= maxTurns) {
|
|
217
|
+
softLimitReached = true;
|
|
218
|
+
session.steer("...");
|
|
219
|
+
} else if (softLimitReached && turnCount >= maxTurns + grace) {
|
|
220
|
+
aborted = true;
|
|
221
|
+
session.abort();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`collectResponseText(session)` and `forwardAbortSignal(session, signal)` remain unchanged — they are the runner's own concerns.
|
|
229
|
+
|
|
230
|
+
`resumeAgent()` drops its conditional event subscription entirely — only `collectResponseText` and `forwardAbortSignal` remain.
|
|
231
|
+
|
|
232
|
+
### Unsubscription strategy
|
|
233
|
+
|
|
234
|
+
| Observer | Subscribe point | Unsubscribe point |
|
|
235
|
+
| ------------------- | -------------------------- | ---------------------------- |
|
|
236
|
+
| Record (startAgent) | `onSessionCreated` handler | `.then()` and `.catch()` |
|
|
237
|
+
| Record (resume) | Before `runner.resume()` | `finally` block |
|
|
238
|
+
| UI (foreground) | `onSessionCreated` handler | After `spawnAndWait` returns |
|
|
239
|
+
| UI (background) | `onSessionCreated` handler | Session disposal on cleanup |
|
|
240
|
+
|
|
241
|
+
### createActivityTracker replacement
|
|
242
|
+
|
|
243
|
+
`createActivityTracker()` in `agent-tool.ts` is replaced by:
|
|
244
|
+
|
|
245
|
+
1. Inline `AgentActivity` state construction (the state object is simple enough).
|
|
246
|
+
2. In the `onSessionCreated` callback: `subscribeUIObserver(session, state, onStreamUpdate)`.
|
|
247
|
+
|
|
248
|
+
The `callbacks` spread pattern (`...bgCallbacks` / `...fgCallbacks`) disappears entirely.
|
|
249
|
+
|
|
250
|
+
## Module-Level Changes
|
|
251
|
+
|
|
252
|
+
### New files
|
|
253
|
+
|
|
254
|
+
1. `src/record-observer.ts` — `subscribeRecordObserver()` function.
|
|
255
|
+
2. `test/record-observer.test.ts` — unit tests with mock session.
|
|
256
|
+
3. `src/ui/ui-observer.ts` — `subscribeUIObserver()` function.
|
|
257
|
+
4. `test/ui/ui-observer.test.ts` — unit tests with mock session.
|
|
258
|
+
|
|
259
|
+
### Changed files (source)
|
|
260
|
+
|
|
261
|
+
1. `src/agent-manager.ts` — Wire record observer in `onSessionCreated`; simplify callback wrappers to pass-through (cycle 3); drop 5 `on*` fields from `SpawnOptions` (cycle 4).
|
|
262
|
+
2. `src/tools/agent-tool.ts` — Replace `createActivityTracker` with inline state + `subscribeUIObserver`; drop callback spread from spawn calls (cycle 4).
|
|
263
|
+
3. `src/agent-runner.ts` — Drop 5 `on*` fields from `RunOptions`; drop 3 `on*` fields from `ResumeOptions`; simplify `runAgent()` subscription to turn-limit only; remove conditional subscription from `resumeAgent()` (cycle 5).
|
|
264
|
+
|
|
265
|
+
### Changed files (tests)
|
|
266
|
+
|
|
267
|
+
1. `test/agent-manager.test.ts` — Upgrade `mockSession()` to support `subscribe()`; update 3 stat-verification tests to emit events through mock session instead of calling callbacks on `RunOptions` (cycle 3).
|
|
268
|
+
2. `test/agent-runner.test.ts` — Drop tests for callback forwarding (`onAssistantUsage` wiring, `onCompaction` forwarding); runner tests focus on turn-limit enforcement and session creation (cycle 5).
|
|
269
|
+
3. `test/agent-runner-extension-tools.test.ts` — Drop callback-related fields from `RunOptions` mock construction (cycle 5).
|
|
270
|
+
|
|
271
|
+
### Unchanged files
|
|
272
|
+
|
|
273
|
+
- `src/service.ts` — public API unchanged (its `SpawnOptions` is a separate type).
|
|
274
|
+
- `src/agent-record.ts` — public mutable fields used by observer, no changes needed.
|
|
275
|
+
- `src/usage.ts` — `addUsage` helper used by observer, unchanged.
|
|
276
|
+
- `src/ui/agent-widget.ts` — `AgentActivity` interface unchanged; widget reads state as before.
|
|
277
|
+
- `src/session-config.ts`, `src/context.ts`, `src/env.ts` — unrelated.
|
|
278
|
+
- `src/service-adapter.ts` — `AgentManagerLike.spawn` signature unchanged (onSessionCreated is already optional on SpawnOptions).
|
|
279
|
+
- `test/tools/agent-tool.test.ts` — tests use mocked `deps.manager` and don't exercise callback wiring; spawn mock shape doesn't change.
|
|
280
|
+
- `test/service-adapter.test.ts` — tests mock `AgentManagerLike`, unaffected by internal SpawnOptions changes.
|
|
281
|
+
|
|
282
|
+
## Test Impact Analysis
|
|
283
|
+
|
|
284
|
+
### New tests enabled by the extraction
|
|
285
|
+
|
|
286
|
+
1. `subscribeRecordObserver` tested in isolation with a mock session and real `AgentRecord`.
|
|
287
|
+
Previously impossible — record stat updates were interleaved with callback wrapping inside `startAgent()`.
|
|
288
|
+
2. `subscribeUIObserver` tested in isolation with a mock session and `AgentActivity` state object.
|
|
289
|
+
Previously impossible — UI state updates were buried in `createActivityTracker` closures that required a full spawn flow to exercise.
|
|
290
|
+
|
|
291
|
+
### Existing tests that simplify
|
|
292
|
+
|
|
293
|
+
1. `agent-manager.test.ts` stat tests — currently simulate callbacks by having the mock runner call `opts.onAssistantUsage?.(...)`.
|
|
294
|
+
After the change, mock runners call `opts.onSessionCreated?.(session)` and tests emit events through the mock session.
|
|
295
|
+
The pattern is more realistic (events drive state, not callbacks).
|
|
296
|
+
2. `agent-runner.test.ts` — callback forwarding tests (`onAssistantUsage wiring`, `onCompaction forwarding`) become unnecessary since the runner no longer translates events to callbacks.
|
|
297
|
+
Turn-limit tests remain and simplify (no callback interleaving).
|
|
298
|
+
|
|
299
|
+
### Existing tests that stay as-is
|
|
300
|
+
|
|
301
|
+
1. All `agent-manager.test.ts` lifecycle tests (spawn, abort, queue drain, worktree, resume flow) — they verify AgentManager behavior and don't depend on callback wiring.
|
|
302
|
+
Only the mock construction (`mockSession`) gains a `subscribe` method.
|
|
303
|
+
2. `agent-record.test.ts` — state-machine transitions are unrelated.
|
|
304
|
+
3. `agent-runner-extension-tools.test.ts` — tool filtering tests are unrelated to callbacks.
|
|
305
|
+
4. `test/tools/agent-tool.test.ts` — tests mock the manager; spawn/spawnAndWait call shapes don't change from the test's perspective.
|
|
306
|
+
|
|
307
|
+
## TDD Order
|
|
308
|
+
|
|
309
|
+
### Phase 1: Extract observers (additive, no breaking changes)
|
|
310
|
+
|
|
311
|
+
#### Cycle 1: Record observer module
|
|
312
|
+
|
|
313
|
+
Test surface: `test/record-observer.test.ts` (new).
|
|
314
|
+
|
|
315
|
+
Tests:
|
|
316
|
+
|
|
317
|
+
- `tool_execution_end` event increments `record.toolUses`.
|
|
318
|
+
- `message_end` (assistant, with usage) accumulates into `record.lifetimeUsage`.
|
|
319
|
+
- `compaction_end` (not aborted) increments `record.compactionCount` and calls `onCompact`.
|
|
320
|
+
- `compaction_end` with `aborted: true` is ignored.
|
|
321
|
+
- Returned function unsubscribes from session.
|
|
322
|
+
|
|
323
|
+
Source changes:
|
|
324
|
+
|
|
325
|
+
- `src/record-observer.ts`: `subscribeRecordObserver()` function.
|
|
326
|
+
|
|
327
|
+
Commit: `feat: add record observer for direct session subscription (#100)`
|
|
328
|
+
|
|
329
|
+
#### Cycle 2: UI observer module
|
|
330
|
+
|
|
331
|
+
Test surface: `test/ui/ui-observer.test.ts` (new).
|
|
332
|
+
|
|
333
|
+
Tests:
|
|
334
|
+
|
|
335
|
+
- `tool_execution_start` adds to `state.activeTools`, calls `onUpdate`.
|
|
336
|
+
- `tool_execution_end` removes from `state.activeTools`, increments `state.toolUses`, calls `onUpdate`.
|
|
337
|
+
- `message_start` resets `state.responseText`.
|
|
338
|
+
- `message_update` (text_delta) appends to `state.responseText`, calls `onUpdate`.
|
|
339
|
+
- `turn_end` increments `state.turnCount`, calls `onUpdate`.
|
|
340
|
+
- `message_end` (assistant, with usage) accumulates into `state.lifetimeUsage`, calls `onUpdate`.
|
|
341
|
+
- Returned function unsubscribes from session.
|
|
342
|
+
|
|
343
|
+
Source changes:
|
|
344
|
+
|
|
345
|
+
- `src/ui/ui-observer.ts`: `subscribeUIObserver()` function.
|
|
346
|
+
|
|
347
|
+
Commit: `feat: add UI observer for direct session subscription (#100)`
|
|
348
|
+
|
|
349
|
+
### Phase 2: AgentManager uses record observer
|
|
350
|
+
|
|
351
|
+
#### Cycle 3: Wire record observer into startAgent and resume
|
|
352
|
+
|
|
353
|
+
Test surface: `test/agent-manager.test.ts` (updated).
|
|
354
|
+
|
|
355
|
+
This cycle replaces the record-mutation logic in `startAgent()` and `resume()` callback wrappers with `subscribeRecordObserver`.
|
|
356
|
+
The 5 UI callbacks (`onToolActivity` through `onCompaction`) are still accepted on `SpawnOptions` and forwarded to `RunOptions` as pass-through (no wrapping).
|
|
357
|
+
They are removed in cycle 4.
|
|
358
|
+
|
|
359
|
+
Source changes:
|
|
360
|
+
|
|
361
|
+
- `src/agent-manager.ts`:
|
|
362
|
+
- Import `subscribeRecordObserver`.
|
|
363
|
+
- In `startAgent()`: subscribe record observer inside `onSessionCreated` handler; remove `record.toolUses++` from `onToolActivity` wrapper (pass through `options.onToolActivity`); remove `addUsage` from `onAssistantUsage` wrapper (pass through); remove `record.compactionCount++` from `onCompaction` wrapper (pass through, `this.onCompact` moves to observer's `onCompact` option); capture unsubscribe function, call in `.then()` and `.catch()`.
|
|
364
|
+
- In `resume()`: subscribe record observer to `record.session` before calling `runner.resume()`; drop `onToolActivity`, `onAssistantUsage`, `onCompaction` args from `runner.resume()` call; unsubscribe in `finally` block.
|
|
365
|
+
|
|
366
|
+
Test changes:
|
|
367
|
+
|
|
368
|
+
- `test/agent-manager.test.ts`:
|
|
369
|
+
- Upgrade `mockSession()` to support `subscribe()` and provide a test helper `emit()` method.
|
|
370
|
+
- Update the `onAssistantUsage` test: mock runner calls `opts.onSessionCreated?.(session)`, then emits `message_end` events through the mock session.
|
|
371
|
+
- Update the `onCompaction` test: same pattern — emit `compaction_end` events through mock session.
|
|
372
|
+
- Update the `resume()` accumulation test: mock session emits events during `runner.resume()`.
|
|
373
|
+
|
|
374
|
+
Run: full test suite + `pnpm run check`.
|
|
375
|
+
|
|
376
|
+
Commit: `refactor: AgentManager subscribes record observer directly (#100)`
|
|
377
|
+
|
|
378
|
+
### Phase 3: Agent-tool uses UI observer
|
|
379
|
+
|
|
380
|
+
#### Cycle 4: Replace createActivityTracker with UI observer, drop SpawnOptions callbacks
|
|
381
|
+
|
|
382
|
+
Test surface: `test/tools/agent-tool.test.ts` (verified unchanged), `test/agent-manager.test.ts` (verified compatible).
|
|
383
|
+
|
|
384
|
+
This cycle replaces `createActivityTracker` with inline `AgentActivity` state construction and `subscribeUIObserver` subscription via `onSessionCreated`.
|
|
385
|
+
`SpawnOptions` drops 5 `on*` fields.
|
|
386
|
+
|
|
387
|
+
Source changes:
|
|
388
|
+
|
|
389
|
+
- `src/tools/agent-tool.ts`:
|
|
390
|
+
- Import `subscribeUIObserver` from `../ui/ui-observer.js`.
|
|
391
|
+
- Remove `createActivityTracker` function.
|
|
392
|
+
- Background path: construct `AgentActivity` state inline; pass `onSessionCreated` callback that subscribes UI observer; remove `...bgCallbacks` spread.
|
|
393
|
+
- Foreground path: construct `AgentActivity` state inline; pass `onSessionCreated` callback that subscribes UI observer, registers activity in widget, and captures unsubscribe; remove `...fgCallbacks` spread; call UI unsubscribe after `spawnAndWait` returns.
|
|
394
|
+
- `src/agent-manager.ts`:
|
|
395
|
+
- `SpawnOptions`: remove `onToolActivity`, `onTextDelta`, `onTurnEnd`, `onAssistantUsage`, `onCompaction` fields.
|
|
396
|
+
- `startAgent()`: remove pass-through forwarding of the 5 dropped callbacks to `RunOptions`.
|
|
397
|
+
|
|
398
|
+
Run: full test suite + `pnpm run check`.
|
|
399
|
+
|
|
400
|
+
Commit: `refactor: agent-tool subscribes UI observer, drop SpawnOptions callbacks (#100)`
|
|
401
|
+
|
|
402
|
+
### Phase 4: Runner drops callback forwarding
|
|
403
|
+
|
|
404
|
+
#### Cycle 5: RunOptions and ResumeOptions drop callback fields, runner simplifies
|
|
405
|
+
|
|
406
|
+
Test surface: `test/agent-runner.test.ts` (updated), `test/agent-runner-extension-tools.test.ts` (updated).
|
|
407
|
+
|
|
408
|
+
Source changes:
|
|
409
|
+
|
|
410
|
+
- `src/agent-runner.ts`:
|
|
411
|
+
- `RunOptions`: remove `onToolActivity`, `onTextDelta`, `onTurnEnd`, `onAssistantUsage`, `onCompaction` fields.
|
|
412
|
+
- `ResumeOptions`: remove `onToolActivity`, `onAssistantUsage`, `onCompaction` fields (keep `signal`).
|
|
413
|
+
- `runAgent()`: simplify the big `session.subscribe()` block to turn-limit enforcement only (turn_end → count, steer, abort).
|
|
414
|
+
Remove message_start/message_update/tool_execution_start/tool_execution_end/message_end/compaction_end handling.
|
|
415
|
+
- `resumeAgent()`: remove the conditional `session.subscribe()` block entirely (no callbacks to forward).
|
|
416
|
+
Keep `collectResponseText` and `forwardAbortSignal`.
|
|
417
|
+
- Remove `ToolActivity` export if no longer needed externally (check consumers).
|
|
418
|
+
|
|
419
|
+
Test changes:
|
|
420
|
+
|
|
421
|
+
- `test/agent-runner.test.ts`:
|
|
422
|
+
- Remove or simplify callback forwarding tests (onAssistantUsage wiring, onCompaction forwarding, tool activity forwarding).
|
|
423
|
+
- Turn-limit tests remain, with simplified setup (no callback expectations).
|
|
424
|
+
- `runAgent()` call sites drop the 5 callback fields.
|
|
425
|
+
- `test/agent-runner-extension-tools.test.ts`:
|
|
426
|
+
- Drop callback-related fields from `runAgent()` call construction.
|
|
427
|
+
|
|
428
|
+
Run: full test suite + `pnpm run check`.
|
|
429
|
+
|
|
430
|
+
Commit: `refactor: RunOptions and ResumeOptions drop callback fields (#100)`
|
|
431
|
+
|
|
432
|
+
## Risks and Mitigations
|
|
433
|
+
|
|
434
|
+
| Risk | Mitigation |
|
|
435
|
+
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
436
|
+
| Double-counting during transition (old callbacks + new observer both update record) | Cycle 3 replaces record mutations atomically — old wrappers become pass-through in the same commit that adds the observer |
|
|
437
|
+
| Mock session complexity increases (need `subscribe` + `emit` helper) | One-time investment; upgrade `mockSession()` once, reused across all tests; more realistic than simulating callbacks |
|
|
438
|
+
| Event ordering between record observer and UI observer | Observers update independent state (record vs AgentActivity); no cross-dependency, ordering irrelevant |
|
|
439
|
+
| `ToolActivity` type used outside this package | Grep before removing; if used, keep the export and add a deprecation note |
|
|
440
|
+
| `createActivityTracker` exported but unused externally | Verified: only used within `agent-tool.ts`; safe to remove |
|
|
441
|
+
| Turn-limit enforcement mixed with callback subscription in runner | Cycle 5 extracts turn-limit into its own subscription cleanly; `collectResponseText` stays separate |
|
|
442
|
+
| Unsubscription missed on error paths | Record observer unsubscribe captured in closure, called in both `.then()` and `.catch()`; resume uses `finally` |
|
|
443
|
+
|
|
444
|
+
## Open Questions
|
|
445
|
+
|
|
446
|
+
- Whether to extract turn-limit enforcement from the runner into a separate `turn-limiter.ts` module.
|
|
447
|
+
This would improve testability but is orthogonal to callback elimination.
|
|
448
|
+
Deferred — the runner's subscription simplifies enough in cycle 5.
|
|
449
|
+
- Whether `ResumeOptions` should be renamed or inlined since it reduces to `{ signal?: AbortSignal }`.
|
|
450
|
+
Keeping it as a named type is fine for extensibility.
|
|
451
|
+
Deferred — cosmetic, can be done anytime.
|
|
452
|
+
- Whether to introduce a `SessionLike` interface for the mock session pattern used in `record-observer.test.ts` and `ui-observer.test.ts`.
|
|
453
|
+
Both tests need a minimal session with `subscribe()`.
|
|
454
|
+
If the pattern proves reusable, extract it; otherwise keep the inline mock.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 99
|
|
3
|
+
issue_title: "Replace live `ctx` capture with ParentSnapshot in AgentManager"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #99 — Replace live ctx capture with ParentSnapshot
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-20T20:30:00-04:00)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Implemented the `ParentSnapshot` extraction — Step 2 of the AgentManager internal decomposition.
|
|
13
|
+
The user prompted a mid-planning restructure using Kent Beck's "make the change that makes the change easy" principle, which split the work into two clean phases: pi-elimination prep (cycles 1–2), then snapshot introduction (cycles 3–5).
|
|
14
|
+
All 5 cycles landed first-try with no rework, releasing as `pi-subagents-v6.2.0`.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- The two-phase plan structure (prep then payload) kept each cycle focused on one concern.
|
|
21
|
+
Cycle 5 was particularly clean — only 2 files changed because `buildParentSnapshot` was mocked in agent-manager tests.
|
|
22
|
+
- `sed` for mechanical bulk replacements (27 `spawn(mockPi,` → `spawn(mockCtx,`) was the right tool — faster and less error-prone than 27 individual edits.
|
|
23
|
+
- The `pnpm run check` after cycle 4 caught exactly the expected type error in `agent-manager.ts`, confirming the incremental approach was working as designed.
|
|
24
|
+
|
|
25
|
+
#### What caused friction (agent side)
|
|
26
|
+
|
|
27
|
+
- `wrong-abstraction` — Initial plan (before user intervention) interleaved pi-elimination and snapshot concerns across all 5 cycles.
|
|
28
|
+
The user had to invoke Kent Beck's principle to trigger the restructure.
|
|
29
|
+
Impact: one plan rewrite (amend commit), but no implementation rework since it happened before coding started.
|
|
30
|
+
|
|
31
|
+
- `missing-context` — Used `Parameters<typeof createAgentSession>[0]["modelRegistry"]` as a type cast without checking that the SDK's `createAgentSession` parameter type is a union including `undefined`.
|
|
32
|
+
Impact: minimal — caught immediately by `pnpm run check`, fixed with a simple `as any` in the same cycle.
|
|
33
|
+
|
|
34
|
+
#### What caused friction (user side)
|
|
35
|
+
|
|
36
|
+
- The user's "make the change easy" prompt was well-timed — after the plan was written but before implementation.
|
|
37
|
+
No suggestions for improvement here; the intervention was strategic and well-placed.
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -10,12 +10,12 @@ import { randomUUID } from "node:crypto";
|
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
11
|
import type { AgentSession, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { AgentRecord } from "./agent-record.js";
|
|
13
|
-
import type { AgentRunner
|
|
13
|
+
import type { AgentRunner } from "./agent-runner.js";
|
|
14
14
|
import { debugLog } from "./debug.js";
|
|
15
15
|
import { buildParentSnapshot } from "./parent-snapshot.js";
|
|
16
|
+
import { subscribeRecordObserver } from "./record-observer.js";
|
|
16
17
|
import type { RunConfig } from "./runtime.js";
|
|
17
18
|
import type { AgentInvocation, IsolationMode, ParentSnapshot, ShellExec, SubagentType, ThinkingLevel } from "./types.js";
|
|
18
|
-
import { addUsage } from "./usage.js";
|
|
19
19
|
import type { WorktreeManager } from "./worktree.js";
|
|
20
20
|
|
|
21
21
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
@@ -64,18 +64,8 @@ export interface SpawnOptions {
|
|
|
64
64
|
invocation?: AgentInvocation;
|
|
65
65
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
66
66
|
signal?: AbortSignal;
|
|
67
|
-
/** Called
|
|
68
|
-
onToolActivity?: (activity: ToolActivity) => void;
|
|
69
|
-
/** Called on streaming text deltas from the assistant response. */
|
|
70
|
-
onTextDelta?: (delta: string, fullText: string) => void;
|
|
71
|
-
/** Called when the agent session is created (for accessing session stats). */
|
|
67
|
+
/** Called when the agent session is created — the one remaining callback. */
|
|
72
68
|
onSessionCreated?: (session: AgentSession) => void;
|
|
73
|
-
/** Called at the end of each agentic turn with the cumulative count. */
|
|
74
|
-
onTurnEnd?: (turnCount: number) => void;
|
|
75
|
-
/** Called once per assistant message_end with that message's usage delta. */
|
|
76
|
-
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
77
|
-
/** Called when the session successfully compacts. */
|
|
78
|
-
onCompaction?: (info: CompactionInfo) => void;
|
|
79
69
|
/** Path to the parent session's JSONL file (for deriving the subagent session directory). */
|
|
80
70
|
parentSessionFile?: string;
|
|
81
71
|
/** Session ID of the parent agent (stored in the child session's parentSession header). */
|
|
@@ -198,6 +188,8 @@ export class AgentManager {
|
|
|
198
188
|
}
|
|
199
189
|
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
200
190
|
|
|
191
|
+
let unsubRecordObserver: (() => void) | undefined;
|
|
192
|
+
|
|
201
193
|
const runConfig = this.getRunConfig?.();
|
|
202
194
|
const promise = this.runner.run(snapshot, type, prompt, {
|
|
203
195
|
exec: this.exec,
|
|
@@ -211,21 +203,6 @@ export class AgentManager {
|
|
|
211
203
|
parentSessionFile: options.parentSessionFile,
|
|
212
204
|
parentSessionId: options.parentSessionId,
|
|
213
205
|
signal: record.abortController!.signal,
|
|
214
|
-
onToolActivity: (activity) => {
|
|
215
|
-
if (activity.type === "end") record.toolUses++;
|
|
216
|
-
options.onToolActivity?.(activity);
|
|
217
|
-
},
|
|
218
|
-
onTurnEnd: options.onTurnEnd,
|
|
219
|
-
onTextDelta: options.onTextDelta,
|
|
220
|
-
onAssistantUsage: (usage) => {
|
|
221
|
-
addUsage(record.lifetimeUsage, usage);
|
|
222
|
-
options.onAssistantUsage?.(usage);
|
|
223
|
-
},
|
|
224
|
-
onCompaction: (info) => {
|
|
225
|
-
record.compactionCount++;
|
|
226
|
-
this.onCompact?.(record, info);
|
|
227
|
-
options.onCompaction?.(info);
|
|
228
|
-
},
|
|
229
206
|
onSessionCreated: (session) => {
|
|
230
207
|
record.session = session;
|
|
231
208
|
// Capture the session file path early so it's available for display
|
|
@@ -239,10 +216,15 @@ export class AgentManager {
|
|
|
239
216
|
}
|
|
240
217
|
record.pendingSteers = undefined;
|
|
241
218
|
}
|
|
219
|
+
// Subscribe record observer for stats accumulation
|
|
220
|
+
unsubRecordObserver = subscribeRecordObserver(session, record, {
|
|
221
|
+
onCompact: (r, info) => this.onCompact?.(r, info),
|
|
222
|
+
});
|
|
242
223
|
options.onSessionCreated?.(session);
|
|
243
224
|
},
|
|
244
225
|
})
|
|
245
226
|
.then(({ responseText, session, aborted, steered, sessionFile }) => {
|
|
227
|
+
unsubRecordObserver?.();
|
|
246
228
|
detach();
|
|
247
229
|
|
|
248
230
|
// Clean up worktree before transition so the final result includes branch text
|
|
@@ -273,6 +255,7 @@ export class AgentManager {
|
|
|
273
255
|
.catch((err) => {
|
|
274
256
|
record.markError(err);
|
|
275
257
|
|
|
258
|
+
unsubRecordObserver?.();
|
|
276
259
|
detach();
|
|
277
260
|
|
|
278
261
|
// Best-effort worktree cleanup on error
|
|
@@ -340,23 +323,19 @@ export class AgentManager {
|
|
|
340
323
|
|
|
341
324
|
record.resetForResume(Date.now());
|
|
342
325
|
|
|
326
|
+
const unsubResume = subscribeRecordObserver(record.session, record, {
|
|
327
|
+
onCompact: (r, info) => this.onCompact?.(r, info),
|
|
328
|
+
});
|
|
329
|
+
|
|
343
330
|
try {
|
|
344
331
|
const responseText = await this.runner.resume(record.session, prompt, {
|
|
345
|
-
onToolActivity: (activity) => {
|
|
346
|
-
if (activity.type === "end") record.toolUses++;
|
|
347
|
-
},
|
|
348
|
-
onAssistantUsage: (usage) => {
|
|
349
|
-
addUsage(record.lifetimeUsage, usage);
|
|
350
|
-
},
|
|
351
|
-
onCompaction: (info) => {
|
|
352
|
-
record.compactionCount++;
|
|
353
|
-
this.onCompact?.(record, info);
|
|
354
|
-
},
|
|
355
332
|
signal,
|
|
356
333
|
});
|
|
357
334
|
record.markCompleted(responseText);
|
|
358
335
|
} catch (err) {
|
|
359
336
|
record.markError(err);
|
|
337
|
+
} finally {
|
|
338
|
+
unsubResume();
|
|
360
339
|
}
|
|
361
340
|
|
|
362
341
|
return record;
|
package/src/agent-runner.ts
CHANGED
|
@@ -64,12 +64,6 @@ export function normalizeMaxTurns(n: number | undefined): number | undefined {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
/** Info about a tool event in the subagent. */
|
|
68
|
-
export interface ToolActivity {
|
|
69
|
-
type: "start" | "end";
|
|
70
|
-
toolName: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
67
|
export interface RunOptions {
|
|
74
68
|
/** Shell-exec callback for detectEnv — injected from pi.exec(). */
|
|
75
69
|
exec: ShellExec;
|
|
@@ -84,31 +78,8 @@ export interface RunOptions {
|
|
|
84
78
|
parentSessionFile?: string;
|
|
85
79
|
/** Session ID of the parent agent (stored in the child session's parentSession header). */
|
|
86
80
|
parentSessionId?: string;
|
|
87
|
-
/** Called
|
|
88
|
-
onToolActivity?: (activity: ToolActivity) => void;
|
|
89
|
-
/** Called on streaming text deltas from the assistant response. */
|
|
90
|
-
onTextDelta?: (delta: string, fullText: string) => void;
|
|
81
|
+
/** Called once after session creation — session delivery mechanism. */
|
|
91
82
|
onSessionCreated?: (session: AgentSession) => void;
|
|
92
|
-
/** Called at the end of each agentic turn with the cumulative count. */
|
|
93
|
-
onTurnEnd?: (turnCount: number) => void;
|
|
94
|
-
/**
|
|
95
|
-
* Called once per assistant message_end with that message's usage delta.
|
|
96
|
-
* Lets callers maintain a lifetime accumulator that survives compaction
|
|
97
|
-
* (which replaces session.state.messages and resets stats-derived sums).
|
|
98
|
-
*/
|
|
99
|
-
onAssistantUsage?: (usage: {
|
|
100
|
-
input: number;
|
|
101
|
-
output: number;
|
|
102
|
-
cacheWrite: number;
|
|
103
|
-
}) => void;
|
|
104
|
-
/**
|
|
105
|
-
* Called when the session successfully compacts. `tokensBefore` is upstream's
|
|
106
|
-
* pre-compaction context size estimate. Aborted compactions don't fire.
|
|
107
|
-
*/
|
|
108
|
-
onCompaction?: (info: {
|
|
109
|
-
reason: "manual" | "threshold" | "overflow";
|
|
110
|
-
tokensBefore: number;
|
|
111
|
-
}) => void;
|
|
112
83
|
/**
|
|
113
84
|
* Default max turns from runtime config. Falls back to the module-scope
|
|
114
85
|
* `defaultMaxTurns` during the lift-and-shift migration; superseded by
|
|
@@ -135,9 +106,6 @@ export interface RunResult {
|
|
|
135
106
|
|
|
136
107
|
/** Options for resuming an existing agent session. */
|
|
137
108
|
export interface ResumeOptions {
|
|
138
|
-
onToolActivity?: (activity: ToolActivity) => void;
|
|
139
|
-
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
140
|
-
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
|
|
141
109
|
signal?: AbortSignal;
|
|
142
110
|
}
|
|
143
111
|
|
|
@@ -284,14 +252,7 @@ export async function runAgent(
|
|
|
284
252
|
// (e.g. loading credentials, setting up state). Placed after tool filtering
|
|
285
253
|
// so extension-provided skills/prompts from extendResourcesFromExtensions()
|
|
286
254
|
// respect the active tool set. All ExtensionBindings fields are optional.
|
|
287
|
-
await session.bindExtensions({
|
|
288
|
-
onError: (err) => {
|
|
289
|
-
options.onToolActivity?.({
|
|
290
|
-
type: "end",
|
|
291
|
-
toolName: `extension-error:${err.extensionPath}`,
|
|
292
|
-
});
|
|
293
|
-
},
|
|
294
|
-
});
|
|
255
|
+
await session.bindExtensions({});
|
|
295
256
|
|
|
296
257
|
// Patch 2 (RepOne #443): re-filter active tools after bindExtensions.
|
|
297
258
|
// Extension-registered tools (added during bindExtensions) are not in the
|
|
@@ -319,11 +280,9 @@ export async function runAgent(
|
|
|
319
280
|
let softLimitReached = false;
|
|
320
281
|
let aborted = false;
|
|
321
282
|
|
|
322
|
-
let currentMessageText = "";
|
|
323
283
|
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
|
|
324
284
|
if (event.type === "turn_end") {
|
|
325
285
|
turnCount++;
|
|
326
|
-
options.onTurnEnd?.(turnCount);
|
|
327
286
|
if (maxTurns != null) {
|
|
328
287
|
if (!softLimitReached && turnCount >= maxTurns) {
|
|
329
288
|
softLimitReached = true;
|
|
@@ -336,40 +295,6 @@ export async function runAgent(
|
|
|
336
295
|
}
|
|
337
296
|
}
|
|
338
297
|
}
|
|
339
|
-
if (event.type === "message_start") {
|
|
340
|
-
currentMessageText = "";
|
|
341
|
-
}
|
|
342
|
-
if (
|
|
343
|
-
event.type === "message_update" &&
|
|
344
|
-
event.assistantMessageEvent.type === "text_delta"
|
|
345
|
-
) {
|
|
346
|
-
currentMessageText += event.assistantMessageEvent.delta;
|
|
347
|
-
options.onTextDelta?.(
|
|
348
|
-
event.assistantMessageEvent.delta,
|
|
349
|
-
currentMessageText,
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
if (event.type === "tool_execution_start") {
|
|
353
|
-
options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
354
|
-
}
|
|
355
|
-
if (event.type === "tool_execution_end") {
|
|
356
|
-
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
357
|
-
}
|
|
358
|
-
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
359
|
-
const u = (event.message as any).usage;
|
|
360
|
-
if (u)
|
|
361
|
-
options.onAssistantUsage?.({
|
|
362
|
-
input: u.input ?? 0,
|
|
363
|
-
output: u.output ?? 0,
|
|
364
|
-
cacheWrite: u.cacheWrite ?? 0,
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
368
|
-
options.onCompaction?.({
|
|
369
|
-
reason: event.reason,
|
|
370
|
-
tokensBefore: event.result.tokensBefore,
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
298
|
});
|
|
374
299
|
|
|
375
300
|
const collector = collectResponseText(session);
|
|
@@ -411,46 +336,10 @@ export async function resumeAgent(
|
|
|
411
336
|
const collector = collectResponseText(session);
|
|
412
337
|
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
413
338
|
|
|
414
|
-
const unsubEvents =
|
|
415
|
-
options.onToolActivity || options.onAssistantUsage || options.onCompaction
|
|
416
|
-
? session.subscribe((event: AgentSessionEvent) => {
|
|
417
|
-
if (event.type === "tool_execution_start")
|
|
418
|
-
options.onToolActivity?.({
|
|
419
|
-
type: "start",
|
|
420
|
-
toolName: event.toolName,
|
|
421
|
-
});
|
|
422
|
-
if (event.type === "tool_execution_end")
|
|
423
|
-
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
424
|
-
if (
|
|
425
|
-
event.type === "message_end" &&
|
|
426
|
-
event.message.role === "assistant"
|
|
427
|
-
) {
|
|
428
|
-
const u = (event.message as any).usage;
|
|
429
|
-
if (u)
|
|
430
|
-
options.onAssistantUsage?.({
|
|
431
|
-
input: u.input ?? 0,
|
|
432
|
-
output: u.output ?? 0,
|
|
433
|
-
cacheWrite: u.cacheWrite ?? 0,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
if (
|
|
437
|
-
event.type === "compaction_end" &&
|
|
438
|
-
!event.aborted &&
|
|
439
|
-
event.result
|
|
440
|
-
) {
|
|
441
|
-
options.onCompaction?.({
|
|
442
|
-
reason: event.reason,
|
|
443
|
-
tokensBefore: event.result.tokensBefore,
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
})
|
|
447
|
-
: () => {};
|
|
448
|
-
|
|
449
339
|
try {
|
|
450
340
|
await session.prompt(prompt);
|
|
451
341
|
} finally {
|
|
452
342
|
collector.unsubscribe();
|
|
453
|
-
unsubEvents();
|
|
454
343
|
cleanupAbort();
|
|
455
344
|
}
|
|
456
345
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* record-observer.ts — Subscribes to session events and updates AgentRecord stats.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the scattered callback-wrapping logic in AgentManager's startAgent()
|
|
5
|
+
* and resume() with a single direct subscription.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CompactionInfo } from "./agent-manager.js";
|
|
9
|
+
import type { AgentRecord } from "./agent-record.js";
|
|
10
|
+
import { addUsage } from "./usage.js";
|
|
11
|
+
|
|
12
|
+
/** Narrow session interface — only the subscribe method needed by the observer. */
|
|
13
|
+
interface SubscribableSession {
|
|
14
|
+
subscribe(fn: (event: any) => void): () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RecordObserverOptions {
|
|
18
|
+
onCompact?: (record: AgentRecord, info: CompactionInfo) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to session events and accumulate stats on the agent record.
|
|
23
|
+
*
|
|
24
|
+
* Handles:
|
|
25
|
+
* - `tool_execution_end` → `record.toolUses++`
|
|
26
|
+
* - `message_end` (assistant, with usage) → `addUsage(record.lifetimeUsage, …)`
|
|
27
|
+
* - `compaction_end` (not aborted) → `record.compactionCount++`, call `onCompact`
|
|
28
|
+
*
|
|
29
|
+
* @returns An unsubscribe function.
|
|
30
|
+
*/
|
|
31
|
+
export function subscribeRecordObserver(
|
|
32
|
+
session: SubscribableSession,
|
|
33
|
+
record: AgentRecord,
|
|
34
|
+
options?: RecordObserverOptions,
|
|
35
|
+
): () => void {
|
|
36
|
+
return session.subscribe((event: any) => {
|
|
37
|
+
if (event.type === "tool_execution_end") {
|
|
38
|
+
record.toolUses++;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
42
|
+
const u = event.message.usage;
|
|
43
|
+
if (u) {
|
|
44
|
+
addUsage(record.lifetimeUsage, {
|
|
45
|
+
input: u.input ?? 0,
|
|
46
|
+
output: u.output ?? 0,
|
|
47
|
+
cacheWrite: u.cacheWrite ?? 0,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
53
|
+
record.compactionCount++;
|
|
54
|
+
options?.onCompact?.(record, {
|
|
55
|
+
reason: event.reason,
|
|
56
|
+
tokensBefore: event.result.tokensBefore,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -20,17 +20,15 @@ import {
|
|
|
20
20
|
SPINNER,
|
|
21
21
|
type UICtx,
|
|
22
22
|
} from "../ui/agent-widget.js";
|
|
23
|
-
import {
|
|
23
|
+
import { subscribeUIObserver } from "../ui/ui-observer.js";
|
|
24
|
+
import type { LifetimeUsage } from "../usage.js";
|
|
24
25
|
import { formatLifetimeTokens, textResult } from "./helpers.js";
|
|
25
26
|
|
|
26
27
|
// ---- Agent-tool-specific helpers ----
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*/
|
|
32
|
-
export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
33
|
-
const state: AgentActivity = {
|
|
29
|
+
/** Create a fresh AgentActivity state for tracking UI progress. */
|
|
30
|
+
function createAgentActivity(maxTurns?: number): AgentActivity {
|
|
31
|
+
return {
|
|
34
32
|
activeTools: new Map(),
|
|
35
33
|
toolUses: 0,
|
|
36
34
|
turnCount: 1,
|
|
@@ -39,40 +37,6 @@ export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () =>
|
|
|
39
37
|
session: undefined,
|
|
40
38
|
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
41
39
|
};
|
|
42
|
-
|
|
43
|
-
const callbacks = {
|
|
44
|
-
onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
|
|
45
|
-
if (activity.type === "start") {
|
|
46
|
-
state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
|
|
47
|
-
} else {
|
|
48
|
-
for (const [key, name] of state.activeTools) {
|
|
49
|
-
if (name === activity.toolName) {
|
|
50
|
-
state.activeTools.delete(key);
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
state.toolUses++;
|
|
55
|
-
}
|
|
56
|
-
onStreamUpdate?.();
|
|
57
|
-
},
|
|
58
|
-
onTextDelta: (_delta: string, fullText: string) => {
|
|
59
|
-
state.responseText = fullText;
|
|
60
|
-
onStreamUpdate?.();
|
|
61
|
-
},
|
|
62
|
-
onTurnEnd: (turnCount: number) => {
|
|
63
|
-
state.turnCount = turnCount;
|
|
64
|
-
onStreamUpdate?.();
|
|
65
|
-
},
|
|
66
|
-
onSessionCreated: (session: any) => {
|
|
67
|
-
state.session = session;
|
|
68
|
-
},
|
|
69
|
-
onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
|
|
70
|
-
addUsage(state.lifetimeUsage, usage);
|
|
71
|
-
onStreamUpdate?.();
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
return { state, callbacks };
|
|
76
40
|
}
|
|
77
41
|
|
|
78
42
|
/** Parenthetical status note for completed agent result text. */
|
|
@@ -451,8 +415,7 @@ Guidelines:
|
|
|
451
415
|
|
|
452
416
|
// Background execution
|
|
453
417
|
if (runInBackground) {
|
|
454
|
-
const
|
|
455
|
-
createActivityTracker(effectiveMaxTurns);
|
|
418
|
+
const bgState = createAgentActivity(effectiveMaxTurns);
|
|
456
419
|
|
|
457
420
|
let id: string;
|
|
458
421
|
|
|
@@ -469,7 +432,10 @@ Guidelines:
|
|
|
469
432
|
isBackground: true,
|
|
470
433
|
isolation,
|
|
471
434
|
invocation: agentInvocation,
|
|
472
|
-
|
|
435
|
+
onSessionCreated: (session: any) => {
|
|
436
|
+
bgState.session = session;
|
|
437
|
+
subscribeUIObserver(session, bgState);
|
|
438
|
+
},
|
|
473
439
|
});
|
|
474
440
|
} catch (err) {
|
|
475
441
|
return textResult(err instanceof Error ? err.message : String(err));
|
|
@@ -521,6 +487,9 @@ Guidelines:
|
|
|
521
487
|
const startedAt = Date.now();
|
|
522
488
|
let fgId: string | undefined;
|
|
523
489
|
|
|
490
|
+
const fgState = createAgentActivity(effectiveMaxTurns);
|
|
491
|
+
let unsubUI: (() => void) | undefined;
|
|
492
|
+
|
|
524
493
|
const streamUpdate = () => {
|
|
525
494
|
const details: AgentDetails = {
|
|
526
495
|
...detailBase,
|
|
@@ -539,25 +508,6 @@ Guidelines:
|
|
|
539
508
|
});
|
|
540
509
|
};
|
|
541
510
|
|
|
542
|
-
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
|
|
543
|
-
effectiveMaxTurns,
|
|
544
|
-
streamUpdate,
|
|
545
|
-
);
|
|
546
|
-
|
|
547
|
-
// Wire session creation to register in widget
|
|
548
|
-
const origOnSession = fgCallbacks.onSessionCreated;
|
|
549
|
-
fgCallbacks.onSessionCreated = (session: any) => {
|
|
550
|
-
origOnSession(session);
|
|
551
|
-
for (const a of deps.manager.listAgents()) {
|
|
552
|
-
if (a.session === session) {
|
|
553
|
-
fgId = a.id;
|
|
554
|
-
deps.agentActivity.set(a.id, fgState);
|
|
555
|
-
deps.widget.ensureTimer();
|
|
556
|
-
break;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
};
|
|
560
|
-
|
|
561
511
|
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
562
512
|
const spinnerInterval = setInterval(() => {
|
|
563
513
|
spinnerFrame++;
|
|
@@ -584,15 +534,28 @@ Guidelines:
|
|
|
584
534
|
signal,
|
|
585
535
|
parentSessionFile: ctx.sessionManager.getSessionFile(),
|
|
586
536
|
parentSessionId: ctx.sessionManager.getSessionId(),
|
|
587
|
-
|
|
537
|
+
onSessionCreated: (session: any) => {
|
|
538
|
+
fgState.session = session;
|
|
539
|
+
unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
|
|
540
|
+
for (const a of deps.manager.listAgents()) {
|
|
541
|
+
if (a.session === session) {
|
|
542
|
+
fgId = a.id;
|
|
543
|
+
deps.agentActivity.set(a.id, fgState);
|
|
544
|
+
deps.widget.ensureTimer();
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
},
|
|
588
549
|
},
|
|
589
550
|
);
|
|
590
551
|
} catch (err) {
|
|
591
552
|
clearInterval(spinnerInterval);
|
|
553
|
+
unsubUI?.();
|
|
592
554
|
return textResult(err instanceof Error ? err.message : String(err));
|
|
593
555
|
}
|
|
594
556
|
|
|
595
557
|
clearInterval(spinnerInterval);
|
|
558
|
+
unsubUI?.();
|
|
596
559
|
|
|
597
560
|
// Clean up foreground agent from widget
|
|
598
561
|
if (fgId) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui-observer.ts — Subscribes to session events and updates AgentActivity state.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the callback-based createActivityTracker pattern with a direct
|
|
5
|
+
* session subscription for streaming UI state (active tools, response text,
|
|
6
|
+
* turn count, lifetime usage).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { addUsage } from "../usage.js";
|
|
10
|
+
import type { AgentActivity } from "./agent-widget.js";
|
|
11
|
+
|
|
12
|
+
/** Narrow session interface — only the subscribe method needed by the observer. */
|
|
13
|
+
interface SubscribableSession {
|
|
14
|
+
subscribe(fn: (event: any) => void): () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe to session events and stream UI state into an AgentActivity object.
|
|
19
|
+
*
|
|
20
|
+
* Handles:
|
|
21
|
+
* - `tool_execution_start` → add to `state.activeTools`
|
|
22
|
+
* - `tool_execution_end` → remove from `state.activeTools`, `state.toolUses++`
|
|
23
|
+
* - `message_start` → reset `state.responseText`
|
|
24
|
+
* - `message_update` (text_delta) → append to `state.responseText`
|
|
25
|
+
* - `turn_end` → `state.turnCount++`
|
|
26
|
+
* - `message_end` (assistant, with usage) → `addUsage(state.lifetimeUsage, …)`
|
|
27
|
+
*
|
|
28
|
+
* Calls `onUpdate?.()` after each state mutation to trigger re-renders.
|
|
29
|
+
*
|
|
30
|
+
* @returns An unsubscribe function.
|
|
31
|
+
*/
|
|
32
|
+
export function subscribeUIObserver(
|
|
33
|
+
session: SubscribableSession,
|
|
34
|
+
state: AgentActivity,
|
|
35
|
+
onUpdate?: () => void,
|
|
36
|
+
): () => void {
|
|
37
|
+
return session.subscribe((event: any) => {
|
|
38
|
+
if (event.type === "tool_execution_start") {
|
|
39
|
+
state.activeTools.set(event.toolName + "_" + Date.now(), event.toolName);
|
|
40
|
+
onUpdate?.();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (event.type === "tool_execution_end") {
|
|
44
|
+
for (const [key, name] of state.activeTools) {
|
|
45
|
+
if (name === event.toolName) {
|
|
46
|
+
state.activeTools.delete(key);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
state.toolUses++;
|
|
51
|
+
onUpdate?.();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (event.type === "message_start") {
|
|
55
|
+
state.responseText = "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
event.type === "message_update" &&
|
|
60
|
+
event.assistantMessageEvent?.type === "text_delta"
|
|
61
|
+
) {
|
|
62
|
+
state.responseText += event.assistantMessageEvent.delta;
|
|
63
|
+
onUpdate?.();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (event.type === "turn_end") {
|
|
67
|
+
state.turnCount++;
|
|
68
|
+
onUpdate?.();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
72
|
+
const u = event.message.usage;
|
|
73
|
+
if (u) {
|
|
74
|
+
addUsage(state.lifetimeUsage, {
|
|
75
|
+
input: u.input ?? 0,
|
|
76
|
+
output: u.output ?? 0,
|
|
77
|
+
cacheWrite: u.cacheWrite ?? 0,
|
|
78
|
+
});
|
|
79
|
+
onUpdate?.();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|