@gotgenes/pi-subagents 5.2.0 → 5.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/architecture/architecture.md +20 -13
- package/docs/plans/0071-extract-session-config-assembler.md +362 -0
- package/docs/retro/0069-create-subagent-runtime.md +43 -0
- package/package.json +1 -1
- package/src/agent-runner.ts +39 -164
- package/src/session-config.ts +263 -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
|
+
## [5.3.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v5.2.0...pi-subagents-v5.3.0) (2026-05-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add assembleSessionConfig in session-config.ts ([ee8076d](https://github.com/gotgenes/pi-packages/commit/ee8076dc2292ec957b64894af3fcd22567f23be5))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* add [#80](https://github.com/gotgenes/pi-packages/issues/80) to architecture roadmap, mark [#69](https://github.com/gotgenes/pi-packages/issues/69) and [#71](https://github.com/gotgenes/pi-packages/issues/71) done ([5744e28](https://github.com/gotgenes/pi-packages/commit/5744e28ac993454f8cb33afb18e5247569f9f971))
|
|
19
|
+
* plan session-config assembler extraction ([#71](https://github.com/gotgenes/pi-packages/issues/71)) ([5d2cd4f](https://github.com/gotgenes/pi-packages/commit/5d2cd4f8de214a03a11688b56221679591aedafd))
|
|
20
|
+
* **retro:** add retro notes for issue [#69](https://github.com/gotgenes/pi-packages/issues/69) ([18cbbdb](https://github.com/gotgenes/pi-packages/commit/18cbbdb627f2ae63f8109c1f5597c31265738415))
|
|
21
|
+
|
|
8
22
|
## [5.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v5.1.0...pi-subagents-v5.2.0) (2026-05-19)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -342,14 +342,14 @@ The following issues track the work needed to bring `pi-subagents` to the same l
|
|
|
342
342
|
|
|
343
343
|
### Phase 1: Foundation
|
|
344
344
|
|
|
345
|
-
These
|
|
346
|
-
Together they eliminate module-scope mutable state
|
|
345
|
+
These issues are independent of each other and can land in any order.
|
|
346
|
+
Together they eliminate module-scope mutable state, create a testable functional core, and simplify the agent-types API.
|
|
347
347
|
|
|
348
|
-
1. **gotgenes/pi-packages#69** — Create `SubagentRuntime`
|
|
348
|
+
1. **gotgenes/pi-packages#69** ✓ — Create `SubagentRuntime`
|
|
349
349
|
- Move `defaultMaxTurns`, `graceTurns`, `agentActivity`, `currentCtx`, and widget references out of closure/module scope into a single factory-constructed object.
|
|
350
350
|
- This unblocks handler extraction (Issue #70) by giving handlers a concrete deps bag instead of closure variables.
|
|
351
351
|
|
|
352
|
-
2. **gotgenes/pi-packages#71** — Extract pure agent-session assembler from `agent-runner.ts`
|
|
352
|
+
2. **gotgenes/pi-packages#71** ✓ — Extract pure agent-session assembler from `agent-runner.ts`
|
|
353
353
|
- Split `runAgent()` into a pure configuration assembler (~200 lines) and an IO shell (~200 lines).
|
|
354
354
|
- The assembler becomes independently testable without mocking the Pi SDK.
|
|
355
355
|
|
|
@@ -357,6 +357,10 @@ Together they eliminate module-scope mutable state and create a testable functio
|
|
|
357
357
|
- Replace the `process.cwd()` call in `dispose()` with a constructor parameter.
|
|
358
358
|
- A small, mechanical prerequisite for Issue #72.
|
|
359
359
|
|
|
360
|
+
4. **gotgenes/pi-packages#80** — Consolidate `getConfig` / `getAgentConfig` into a single resolution path
|
|
361
|
+
- Replace the two overlapping lookup functions with a single `resolveAgentConfig(type): AgentConfig` that handles the unknown-type fallback internally.
|
|
362
|
+
- Eliminates the duplicated fallback chain exposed by #71 and simplifies test mock setup.
|
|
363
|
+
|
|
360
364
|
### Phase 2: Core decomposition
|
|
361
365
|
|
|
362
366
|
These build on Phase 1 and should land after it.
|
|
@@ -394,19 +398,21 @@ Small cleanups that are safest after the structural changes settle.
|
|
|
394
398
|
### Dependency graph
|
|
395
399
|
|
|
396
400
|
```text
|
|
397
|
-
#69 (SubagentRuntime)
|
|
401
|
+
#69 (SubagentRuntime) ✓ ─┬─► #70 (handler extraction)
|
|
398
402
|
│
|
|
399
|
-
|
|
403
|
+
└─► #72 (AgentManager DI) ──(optional)──► #70
|
|
404
|
+
|
|
405
|
+
#71 (pure assembler) ✓ ──► #80 (consolidate getConfig/getAgentConfig)
|
|
400
406
|
|
|
401
|
-
#
|
|
407
|
+
#76 (cwd injection) ────► #72
|
|
402
408
|
|
|
403
|
-
#
|
|
409
|
+
#80 (config lookup) ────(independent, simplifies #72 and test mocks)
|
|
404
410
|
|
|
405
|
-
#66 (type casts)
|
|
406
|
-
#77 (projectAgentsDir)
|
|
411
|
+
#66 (type casts) ◄─────(after structural changes settle)
|
|
412
|
+
#77 (projectAgentsDir) ◄─(after #66 or parallel)
|
|
407
413
|
|
|
408
|
-
#61 (transcript format)
|
|
409
|
-
#22 (parent session)
|
|
414
|
+
#61 (transcript format) ◄(after structural refactor)
|
|
415
|
+
#22 (parent session) ◄──(cross-extension, independent)
|
|
410
416
|
```
|
|
411
417
|
|
|
412
418
|
### Recommended order
|
|
@@ -414,9 +420,10 @@ Small cleanups that are safest after the structural changes settle.
|
|
|
414
420
|
The recommended sequence is:
|
|
415
421
|
|
|
416
422
|
```text
|
|
417
|
-
#69 → #71 → #76 → #72 → #70 → #66 → #77 → #61
|
|
423
|
+
#69 ✓ → #71 ✓ → #80 → #76 → #72 → #70 → #66 → #77 → #61
|
|
418
424
|
```
|
|
419
425
|
|
|
426
|
+
Issue #80 slots after #71 because it cleans up the redundant lookup that #71 exposed, and simplifies mock setup for subsequent issues.
|
|
420
427
|
Issue #22 is a parallel cross-extension track and does not gate the structural work.
|
|
421
428
|
|
|
422
429
|
## Relationship with upstream
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 71
|
|
3
|
+
issue_title: "refactor: extract pure agent-session assembler from agent-runner.ts"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract session-config assembler from agent-runner
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`agent-runner.ts` `runAgent()` is ~390 lines (post-#69 cleanup) and mixes three concerns:
|
|
11
|
+
|
|
12
|
+
1. Configuration assembly — resolve model, detect env, build prompt extras, preload skills, build memory blocks, assemble system prompt, compute tool names (~200 lines).
|
|
13
|
+
2. Session construction — create `DefaultResourceLoader`, call `createAgentSession`, filter tools, bind extensions (~100 lines).
|
|
14
|
+
3. Runtime orchestration — subscribe to events, enforce turn limits, collect results (~90 lines).
|
|
15
|
+
|
|
16
|
+
The configuration assembly is deterministic given resolved inputs and does not need an `AgentSession`.
|
|
17
|
+
Because it is inlined in `runAgent()`, it cannot be unit-tested without mocking the entire Pi SDK (`createAgentSession`, `DefaultResourceLoader`, `SessionManager`, `SettingsManager`).
|
|
18
|
+
|
|
19
|
+
## Goals
|
|
20
|
+
|
|
21
|
+
- Extract a pure `assembleSessionConfig()` function into a new `src/session-config.ts` module.
|
|
22
|
+
- The assembler takes resolved inputs (agent config, environment info, narrow context) and returns a data object with everything `runAgent()` needs to create the session.
|
|
23
|
+
- Reduce `runAgent()` to an IO shell: call the assembler, create SDK objects, wire subscriptions, and run the event loop.
|
|
24
|
+
- Add focused unit tests for the assembler covering model resolution fallback chain, skill preloading, memory block selection (read-write vs read-only), prompt mode, tool name assembly, and disallowed-tool computation.
|
|
25
|
+
- No behavior change.
|
|
26
|
+
|
|
27
|
+
## Non-Goals
|
|
28
|
+
|
|
29
|
+
- Changing the `RunResult` shape or `RunOptions` interface.
|
|
30
|
+
- Refactoring the event subscription / turn-limit logic (stays in `runAgent()`).
|
|
31
|
+
- Extracting `resumeAgent` or `steerAgent`.
|
|
32
|
+
- Modifying the public API surface (`service.ts`).
|
|
33
|
+
|
|
34
|
+
## Background
|
|
35
|
+
|
|
36
|
+
### Prior art
|
|
37
|
+
|
|
38
|
+
`pi-permission-system` extracted `evaluate()` — a pure function of `(surface, pattern, ruleset)` — from `PermissionManager.checkPermission()`.
|
|
39
|
+
That made permission decisions independently testable without filesystem access or a manager instance.
|
|
40
|
+
This plan follows the same pattern: extract a pure core from an IO-heavy function.
|
|
41
|
+
|
|
42
|
+
### Current `runAgent()` structure
|
|
43
|
+
|
|
44
|
+
Lines 220–460 of `agent-runner.ts` break into these logical phases:
|
|
45
|
+
|
|
46
|
+
| Phase | Lines (approx) | SDK dependency |
|
|
47
|
+
| ------------------------------- | -------------- | -------------------------------------------------------- |
|
|
48
|
+
| Config + agentConfig lookup | 224–225 | None (agent-types registry) |
|
|
49
|
+
| effectiveCwd | 228 | None |
|
|
50
|
+
| detectEnv | 230 | `pi.exec` (async IO) |
|
|
51
|
+
| parentSystemPrompt | 233 | `ctx.getSystemPrompt()` |
|
|
52
|
+
| extensions / skills resolution | 237–245 | None |
|
|
53
|
+
| Skill preloading | 247–252 | `preloadSkills` (filesystem) |
|
|
54
|
+
| Tool names + memory | 254–274 | None (agent-types registry) |
|
|
55
|
+
| System prompt assembly | 277–303 | `buildAgentPrompt` (pure) |
|
|
56
|
+
| noSkills flag | 306 | None |
|
|
57
|
+
| DefaultResourceLoader | 308–320 | `DefaultResourceLoader` (SDK) |
|
|
58
|
+
| Model resolution | 323–324 | `ctx.modelRegistry` (narrow) |
|
|
59
|
+
| Thinking level | 327 | None |
|
|
60
|
+
| sessionOpts construction | 329–345 | `SessionManager`, `SettingsManager`, `getAgentDir` (SDK) |
|
|
61
|
+
| createAgentSession | 347 | SDK |
|
|
62
|
+
| Tool filtering + bindExtensions | 350–400 | `session.*` methods (SDK) |
|
|
63
|
+
| Event subscriptions + prompt | 402–460 | `session.*` methods (SDK) |
|
|
64
|
+
|
|
65
|
+
Everything above the `DefaultResourceLoader` line is configuration assembly — deterministic given resolved inputs.
|
|
66
|
+
Everything from `DefaultResourceLoader` onward is SDK orchestration.
|
|
67
|
+
|
|
68
|
+
### Modules the assembler will call
|
|
69
|
+
|
|
70
|
+
All are internal to this package — not Pi SDK:
|
|
71
|
+
|
|
72
|
+
- `agent-types.ts` — `getConfig()`, `getAgentConfig()`, `getToolNamesForType()`, `getMemoryToolNames()`, `getReadOnlyMemoryToolNames()`
|
|
73
|
+
- `prompts.ts` — `buildAgentPrompt()`
|
|
74
|
+
- `memory.ts` — `buildMemoryBlock()`, `buildReadOnlyMemoryBlock()`
|
|
75
|
+
- `skill-loader.ts` — `preloadSkills()`
|
|
76
|
+
- `default-agents.ts` — `DEFAULT_AGENTS` (fallback config)
|
|
77
|
+
|
|
78
|
+
### Relevant constraints from AGENTS.md
|
|
79
|
+
|
|
80
|
+
- Keep modules focused and composable (one concern per file).
|
|
81
|
+
- Keep Pi SDK imports out of business-logic modules.
|
|
82
|
+
- Prefer explicit configuration over hidden behavior.
|
|
83
|
+
- Business logic should be pure functions wherever possible — keep IO at the edges.
|
|
84
|
+
|
|
85
|
+
### Issue #69 status
|
|
86
|
+
|
|
87
|
+
Issue #69 (`SubagentRuntime`) is implemented.
|
|
88
|
+
Module-scope mutable state has been removed from `agent-runner.ts`.
|
|
89
|
+
`defaultMaxTurns` and `graceTurns` flow through `RunOptions`.
|
|
90
|
+
This plan builds on the post-#69 codebase.
|
|
91
|
+
|
|
92
|
+
## Design Overview
|
|
93
|
+
|
|
94
|
+
### Separation of concerns
|
|
95
|
+
|
|
96
|
+
`detectEnv()` is the only async IO call in the assembly phase — it calls `pi.exec()` to check git state.
|
|
97
|
+
The assembler is synchronous and takes `EnvInfo` as a pre-resolved parameter.
|
|
98
|
+
`runAgent()` calls `detectEnv()` first, then calls the assembler, then does SDK work.
|
|
99
|
+
|
|
100
|
+
### Narrow context interface
|
|
101
|
+
|
|
102
|
+
The assembler does not accept `ExtensionContext` — it accepts a narrow interface with only the fields it reads:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
interface AssemblerContext {
|
|
106
|
+
/** Parent working directory (overridable via options.cwd). */
|
|
107
|
+
cwd: string;
|
|
108
|
+
/** Parent's effective system prompt (for append-mode agents). */
|
|
109
|
+
parentSystemPrompt: string;
|
|
110
|
+
/** Parent's current model instance (fallback when agent config has no model). */
|
|
111
|
+
parentModel?: Model<any>;
|
|
112
|
+
/** Model registry for resolving config.model strings. */
|
|
113
|
+
modelRegistry: ModelRegistry;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`ModelRegistry` is a narrow interface (already exists in `model-resolver.ts`):
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
interface ModelRegistry {
|
|
121
|
+
find(provider: string, modelId: string): Model<any> | undefined;
|
|
122
|
+
getAvailable?(): Model<any>[];
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Tests construct plain objects satisfying these interfaces — no SDK mocking needed.
|
|
127
|
+
|
|
128
|
+
### Assembler signature
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
function assembleSessionConfig(
|
|
132
|
+
type: SubagentType,
|
|
133
|
+
ctx: AssemblerContext,
|
|
134
|
+
options: AssemblerOptions,
|
|
135
|
+
env: EnvInfo,
|
|
136
|
+
): SessionConfig;
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`AssemblerOptions` is a narrow pick of `RunOptions`:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
interface AssemblerOptions {
|
|
143
|
+
cwd?: string;
|
|
144
|
+
isolated?: boolean;
|
|
145
|
+
model?: Model<any>;
|
|
146
|
+
thinkingLevel?: ThinkingLevel;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Return type
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
interface SessionConfig {
|
|
154
|
+
/** Resolved working directory (options.cwd ?? ctx.cwd). */
|
|
155
|
+
effectiveCwd: string;
|
|
156
|
+
/** Fully-assembled system prompt string. */
|
|
157
|
+
systemPrompt: string;
|
|
158
|
+
/** Tool names for session creation and filtering. */
|
|
159
|
+
toolNames: string[];
|
|
160
|
+
/** Disallowed tool set from agent config (for filterActiveTools). */
|
|
161
|
+
disallowedSet: Set<string> | undefined;
|
|
162
|
+
/** Resolved extensions setting (for resource loader and tool filtering). */
|
|
163
|
+
extensions: boolean | string[];
|
|
164
|
+
/** Resolved model instance (or undefined → parent fallback). */
|
|
165
|
+
model: Model<any> | undefined;
|
|
166
|
+
/** Resolved thinking level (or undefined → inherit). */
|
|
167
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
168
|
+
/** Whether to skip skill loading in the resource loader. */
|
|
169
|
+
noSkills: boolean;
|
|
170
|
+
/** Prompt extras for transparency / debugging. */
|
|
171
|
+
extras: PromptExtras;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### `resolveDefaultModel` moves to session-config.ts
|
|
176
|
+
|
|
177
|
+
`resolveDefaultModel()` is a pure function that resolves model strings against a registry.
|
|
178
|
+
It belongs in the assembler module alongside the other resolution logic.
|
|
179
|
+
It becomes an internal function (not exported) — its behavior is tested through `assembleSessionConfig()`.
|
|
180
|
+
|
|
181
|
+
### `filterActiveTools` stays in agent-runner.ts
|
|
182
|
+
|
|
183
|
+
`filterActiveTools()` operates on a live session's active tool list.
|
|
184
|
+
It runs twice (pre- and post-`bindExtensions`) and is an IO-layer concern.
|
|
185
|
+
It stays in `agent-runner.ts` and consumes `toolNames`, `extensions`, and `disallowedSet` from the `SessionConfig` return.
|
|
186
|
+
|
|
187
|
+
### `normalizeMaxTurns` stays in agent-runner.ts
|
|
188
|
+
|
|
189
|
+
`normalizeMaxTurns()` is used in the turn-limit subscription callback — runtime orchestration, not config assembly.
|
|
190
|
+
It stays in `agent-runner.ts`.
|
|
191
|
+
|
|
192
|
+
### What runAgent() looks like after
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
export async function runAgent(
|
|
196
|
+
ctx: ExtensionContext,
|
|
197
|
+
type: SubagentType,
|
|
198
|
+
prompt: string,
|
|
199
|
+
options: RunOptions,
|
|
200
|
+
): Promise<RunResult> {
|
|
201
|
+
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
202
|
+
const env = await detectEnv(options.pi, effectiveCwd);
|
|
203
|
+
|
|
204
|
+
const config = assembleSessionConfig(type, {
|
|
205
|
+
cwd: ctx.cwd,
|
|
206
|
+
parentSystemPrompt: ctx.getSystemPrompt(),
|
|
207
|
+
parentModel: ctx.model,
|
|
208
|
+
modelRegistry: ctx.modelRegistry,
|
|
209
|
+
}, {
|
|
210
|
+
cwd: options.cwd,
|
|
211
|
+
isolated: options.isolated,
|
|
212
|
+
model: options.model,
|
|
213
|
+
thinkingLevel: options.thinkingLevel,
|
|
214
|
+
}, env);
|
|
215
|
+
|
|
216
|
+
// SDK orchestration: create loader, session, filter tools, bind, run
|
|
217
|
+
const agentDir = getAgentDir();
|
|
218
|
+
const loader = new DefaultResourceLoader({ ... });
|
|
219
|
+
await loader.reload();
|
|
220
|
+
const { session } = await createAgentSession({ ... });
|
|
221
|
+
|
|
222
|
+
// Tool filtering (two passes), bindExtensions, subscriptions, prompt
|
|
223
|
+
// ...same as today, using config.toolNames, config.disallowedSet, etc.
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Target: `runAgent()` drops to ~200 lines (down from ~390).
|
|
228
|
+
|
|
229
|
+
### Edge cases
|
|
230
|
+
|
|
231
|
+
- Unknown agent type: `getAgentConfig()` returns `undefined`.
|
|
232
|
+
The assembler falls back to `DEFAULT_AGENTS.get("general-purpose")` with `name: type`, matching the current `runAgent()` fallback.
|
|
233
|
+
- Empty `builtinToolNames`: `getToolNamesForType()` already falls back to `BUILTIN_TOOL_NAMES`.
|
|
234
|
+
- `isolated: true` overrides `extensions` and `skills` to `false` — same as today, now inside the assembler.
|
|
235
|
+
- Memory block selection: write-capable agents (have `write` or `edit` in effective tool set, not denied) get read-write memory; others get read-only.
|
|
236
|
+
The denylist check uses `disallowedSet` from the agent config.
|
|
237
|
+
|
|
238
|
+
## Module-Level Changes
|
|
239
|
+
|
|
240
|
+
### `src/session-config.ts` (new)
|
|
241
|
+
|
|
242
|
+
- `AssemblerContext` interface — narrow context (cwd, parentSystemPrompt, parentModel, modelRegistry).
|
|
243
|
+
- `AssemblerOptions` interface — narrow options subset (cwd, isolated, model, thinkingLevel).
|
|
244
|
+
- `SessionConfig` interface — return type with all assembled configuration.
|
|
245
|
+
- `assembleSessionConfig()` function — pure configuration assembly.
|
|
246
|
+
- `resolveDefaultModel()` — moved from `agent-runner.ts` (internal, not exported).
|
|
247
|
+
|
|
248
|
+
### `src/agent-runner.ts` (modified)
|
|
249
|
+
|
|
250
|
+
- Import `assembleSessionConfig` and `SessionConfig` from `./session-config.js`.
|
|
251
|
+
- Remove ~200 lines of configuration assembly from `runAgent()`.
|
|
252
|
+
- Replace with a call to `assembleSessionConfig()` followed by SDK orchestration using the returned `SessionConfig`.
|
|
253
|
+
- Remove `resolveDefaultModel()` (moved to session-config.ts).
|
|
254
|
+
- `filterActiveTools()`, `normalizeMaxTurns()`, `collectResponseText()`, `getLastAssistantText()`, `forwardAbortSignal()` — all stay.
|
|
255
|
+
- `RunOptions`, `RunResult`, `ToolActivity` — all stay (unchanged).
|
|
256
|
+
|
|
257
|
+
### `test/session-config.test.ts` (new)
|
|
258
|
+
|
|
259
|
+
- Unit tests for `assembleSessionConfig()` covering all assembly logic.
|
|
260
|
+
- Tests use plain objects for `AssemblerContext` — no SDK mocks.
|
|
261
|
+
- Mocks for `agent-types`, `prompts`, `memory`, `skill-loader` — simple function mocks.
|
|
262
|
+
|
|
263
|
+
### `test/agent-runner.test.ts` (modified)
|
|
264
|
+
|
|
265
|
+
- Existing tests stay as-is — they already mock the SDK and test the full `runAgent()` flow.
|
|
266
|
+
- Tests that verified assembly details (e.g., `suppresses AGENTS.md/CLAUDE.md` or `passes effective cwd to the loader`) remain valid because `runAgent()` still does the SDK orchestration.
|
|
267
|
+
- No tests are removed or rewritten.
|
|
268
|
+
|
|
269
|
+
### `test/agent-runner-extension-tools.test.ts` (unchanged)
|
|
270
|
+
|
|
271
|
+
- Tests extension-tool filtering via `filterActiveTools` — stays in `agent-runner.ts`.
|
|
272
|
+
- No impact.
|
|
273
|
+
|
|
274
|
+
## Test Impact Analysis
|
|
275
|
+
|
|
276
|
+
### New unit tests enabled by the extraction
|
|
277
|
+
|
|
278
|
+
1. Model resolution fallback chain — test that `assembleSessionConfig` returns the correct model for: explicit option model, config model string (valid/invalid), parent model fallback, and no model.
|
|
279
|
+
2. Skill preloading — test that `skills: string[]` triggers `preloadSkills` and populates `extras.skillBlocks`; `skills: false` and `skills: true` skip preloading.
|
|
280
|
+
3. Memory block selection — test read-write vs read-only memory based on tool availability and denylist interaction.
|
|
281
|
+
4. Tool name assembly — test that `getToolNamesForType` result is augmented with memory tool names when memory is configured.
|
|
282
|
+
5. Extensions / isolated interaction — test that `isolated: true` forces `extensions: false` and `skills: false`.
|
|
283
|
+
6. System prompt assembly — test that `buildAgentPrompt` is called with the correct config, extras, and env.
|
|
284
|
+
7. Disallowed tool set — test construction from `agentConfig.disallowedTools`.
|
|
285
|
+
8. Unknown type fallback — test that missing `agentConfig` triggers the general-purpose fallback.
|
|
286
|
+
9. Thinking level resolution — test explicit option vs config vs undefined.
|
|
287
|
+
|
|
288
|
+
### Existing tests that stay as-is
|
|
289
|
+
|
|
290
|
+
All tests in `test/agent-runner.test.ts`, `test/agent-runner-extension-tools.test.ts`, and `test/agent-runner-settings.test.ts` continue to pass unchanged.
|
|
291
|
+
They test the SDK orchestration layer which is not modified (only reduced in scope).
|
|
292
|
+
The assembly logic they implicitly tested is now covered more thoroughly by `test/session-config.test.ts`.
|
|
293
|
+
|
|
294
|
+
### Existing tests that could be simplified (future follow-up)
|
|
295
|
+
|
|
296
|
+
Some `agent-runner.test.ts` tests verify assembly-layer behavior through the full `runAgent()` call (e.g., checking `defaultResourceLoaderCtor` args).
|
|
297
|
+
These become redundant with the new assembler tests.
|
|
298
|
+
Simplifying them is a separate follow-up — not part of this issue's scope.
|
|
299
|
+
|
|
300
|
+
## TDD Order
|
|
301
|
+
|
|
302
|
+
1. **Red: assembler returns correct defaults for a standard agent type.**
|
|
303
|
+
Create `test/session-config.test.ts` with a test that calls `assembleSessionConfig()` for the `"Explore"` type and asserts the returned `SessionConfig` shape: `effectiveCwd`, `systemPrompt`, `toolNames`, `extensions: false`, `noSkills: true`, `disallowedSet: undefined`.
|
|
304
|
+
Mock `agent-types`, `prompts`, `memory`, `skill-loader` at the module level.
|
|
305
|
+
This fails because `session-config.ts` does not exist yet.
|
|
306
|
+
Commit: `test: add session-config assembler test for default agent type`
|
|
307
|
+
|
|
308
|
+
2. **Green: implement `assembleSessionConfig()` core path.**
|
|
309
|
+
Create `src/session-config.ts` with `AssemblerContext`, `AssemblerOptions`, `SessionConfig` interfaces and the `assembleSessionConfig()` function.
|
|
310
|
+
Implement the happy path: resolve config, compute effectiveCwd, resolve extensions/skills, build extras, build system prompt, compute toolNames, compute disallowedSet, resolve noSkills.
|
|
311
|
+
Tests go green.
|
|
312
|
+
Commit: `feat: add assembleSessionConfig in session-config.ts`
|
|
313
|
+
|
|
314
|
+
3. **Red→Green: model resolution fallback chain.**
|
|
315
|
+
Add tests for: explicit option model wins, config model string resolves via registry, invalid config model falls back to parent, no model returns undefined.
|
|
316
|
+
Move `resolveDefaultModel()` from `agent-runner.ts` to `session-config.ts` (internal).
|
|
317
|
+
Commit: `test: model resolution fallback chain in session-config`
|
|
318
|
+
|
|
319
|
+
4. **Red→Green: skill preloading paths.**
|
|
320
|
+
Add tests for: `skills: string[]` populates `extras.skillBlocks`, `skills: false` skips, `skills: true` skips preloading (loaded by resource loader instead), `isolated: true` forces skip.
|
|
321
|
+
Commit: `test: skill preloading paths in session-config`
|
|
322
|
+
|
|
323
|
+
5. **Red→Green: memory block selection.**
|
|
324
|
+
Add tests for: agent with memory + write tools → read-write block, agent with memory + read-only tools → read-only block, agent with memory + denied write tools → read-only block, agent without memory → no block.
|
|
325
|
+
Commit: `test: memory block selection in session-config`
|
|
326
|
+
|
|
327
|
+
6. **Red→Green: isolated mode, unknown type fallback, thinking level.**
|
|
328
|
+
Add tests for: `isolated: true` forces `extensions: false` and `noSkills: true`, unknown type falls back to general-purpose config, thinking level resolves from option > config > undefined.
|
|
329
|
+
Commit: `test: isolated mode, unknown type fallback, thinking level`
|
|
330
|
+
|
|
331
|
+
7. **Refactor: wire `assembleSessionConfig` into `runAgent()`.**
|
|
332
|
+
Replace the configuration assembly block in `runAgent()` with a call to `assembleSessionConfig()`.
|
|
333
|
+
Use the returned `SessionConfig` fields to construct `DefaultResourceLoader`, `createAgentSession` opts, and `filterActiveTools` args.
|
|
334
|
+
Remove `resolveDefaultModel()` from `agent-runner.ts` (already moved in step 3).
|
|
335
|
+
Run full test suite — all existing `agent-runner.test.ts` tests pass unchanged.
|
|
336
|
+
Commit: `refactor: wire assembleSessionConfig into runAgent (#71)`
|
|
337
|
+
|
|
338
|
+
8. **Verify acceptance criteria and clean up.**
|
|
339
|
+
Confirm `runAgent()` is ≤200 lines.
|
|
340
|
+
Confirm assembler tests run without mocking `AgentSession`, `ExtensionContext`, or Pi SDK types.
|
|
341
|
+
Confirm full test suite passes with no regressions.
|
|
342
|
+
Remove any dead imports.
|
|
343
|
+
Run `pnpm run check` for type safety.
|
|
344
|
+
Commit: `refactor: finalize session-config extraction (#71)`
|
|
345
|
+
|
|
346
|
+
## Risks and Mitigations
|
|
347
|
+
|
|
348
|
+
| Risk | Mitigation |
|
|
349
|
+
| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
350
|
+
| Assembly logic has subtle ordering dependencies (e.g., tool names must be computed before memory block selection) | The assembler mirrors the exact order from `runAgent()` today; tests verify each dependency chain explicitly. |
|
|
351
|
+
| Moving `resolveDefaultModel` changes import paths for any external consumer | `resolveDefaultModel` is not exported from the package — it is internal to `agent-runner.ts` today and internal to `session-config.ts` after the move. No external impact. |
|
|
352
|
+
| Existing `agent-runner.test.ts` tests break when assembly is delegated | The tests mock `agent-types`, `prompts`, `memory`, `skill-loader` — the assembler calls the same functions through the same module paths, so existing mocks continue to intercept. |
|
|
353
|
+
| `Model<any>` import from `@earendil-works/pi-ai` in the new module violates "keep Pi SDK imports out of business-logic modules" | `pi-ai` provides type-only interfaces (`Model`, `ThinkingLevel`) already used in `types.ts`. The constraint targets `pi-coding-agent` SDK types (`AgentSession`, `ExtensionContext`, `DefaultResourceLoader`). The assembler imports zero types from `pi-coding-agent`. |
|
|
354
|
+
| The assembler's return type becomes a wide interface (9 fields) | All fields are consumed by `runAgent()` — none are unused. The interface represents a single cohesive concept (session configuration). No consumer uses a subset; there is no narrowing opportunity. |
|
|
355
|
+
|
|
356
|
+
## Open Questions
|
|
357
|
+
|
|
358
|
+
- Should `assembleSessionConfig` also resolve `effectiveCwd` internally (trivial: `options.cwd ?? ctx.cwd`) or should the caller pre-compute it?
|
|
359
|
+
The plan assumes the assembler computes it (self-contained), but `runAgent()` also needs `effectiveCwd` for `detectEnv()` before calling the assembler.
|
|
360
|
+
Resolution: `runAgent()` computes `effectiveCwd` once, passes it as `options.cwd` (already resolved) or as a separate parameter.
|
|
361
|
+
The assembler still computes `effectiveCwd` from its inputs, which produces the same value.
|
|
362
|
+
This duplication is benign — both paths yield `options.cwd ?? ctx.cwd`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 69
|
|
3
|
+
issue_title: "refactor: eliminate module-scope mutable state in pi-subagents — create SubagentRuntime"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #69 — create SubagentRuntime
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-19T16:47:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned, implemented, and shipped `SubagentRuntime` — a composition-root object that replaces module-scope mutable state in `agent-runner.ts` and closure-scoped state in `index.ts`.
|
|
13
|
+
Six TDD steps completed with one deviation: `agent-tool.ts` and `agent-menu.ts` also imported the removed getter/setter exports, requiring unplanned fixes.
|
|
14
|
+
Released as `pi-subagents-v5.2.0`.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- The lift-and-shift strategy (introduce `RunOptions` fields alongside module-scope fallback, wire consumers, then remove old path) kept the 460-test suite green through every intermediate commit.
|
|
21
|
+
No step broke existing tests.
|
|
22
|
+
- `pnpm run check` caught the two missing downstream files (`agent-tool.ts`, `agent-menu.ts`) immediately after the removal step.
|
|
23
|
+
The typecheck-after-removal safety net worked exactly as intended.
|
|
24
|
+
- The `pi-permission-system` prior art (`ExtensionRuntime` in #43) provided a clear structural template, reducing design decisions to near zero.
|
|
25
|
+
|
|
26
|
+
#### What caused friction (agent side)
|
|
27
|
+
|
|
28
|
+
- `missing-context` — The plan's Module-Level Changes listed `agent-runner.ts`, `agent-manager.ts`, and `index.ts` but missed `src/tools/agent-tool.ts` and `src/ui/agent-menu.ts`, both of which imported `getDefaultMaxTurns`/`setDefaultMaxTurns`/`getGraceTurns`/`setGraceTurns` from `agent-runner.ts`.
|
|
29
|
+
A grep for all importers of the removed symbols during planning would have caught this.
|
|
30
|
+
Impact: 4 extra files touched in step 5 (the two source files + their test helpers); no rework of earlier steps, but the commit scope was wider than planned. (self-identified at `pnpm run check` time)
|
|
31
|
+
|
|
32
|
+
- `missing-context` — In step 3 (`agent-manager.test.ts`), checked `vi.mocked(runAgent).mock.calls[0]` without clearing the mock first.
|
|
33
|
+
The module-level `vi.mock("../src/agent-runner.js")` is shared across all describe blocks, so `calls[0]` picked up a stale invocation from an earlier test.
|
|
34
|
+
Impact: one debug cycle adding `vi.mocked(runAgent).mockClear()` after `resolvedRun()`. (self-identified)
|
|
35
|
+
|
|
36
|
+
#### What caused friction (user side)
|
|
37
|
+
|
|
38
|
+
- Nothing notable.
|
|
39
|
+
The plan was unambiguous, and the session ran without user intervention beyond the initial prompts.
|
|
40
|
+
|
|
41
|
+
### Changes made
|
|
42
|
+
|
|
43
|
+
1. `.pi/prompts/plan-issue.md` — added grep-importers rule to the Module-Level Changes bullet: when a step removes or renames an export, grep all `src/` and `test/` files for every removed symbol before finalizing the file list.
|
package/package.json
CHANGED
package/src/agent-runner.ts
CHANGED
|
@@ -14,19 +14,9 @@ import {
|
|
|
14
14
|
SessionManager,
|
|
15
15
|
SettingsManager,
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import {
|
|
18
|
-
getAgentConfig,
|
|
19
|
-
getConfig,
|
|
20
|
-
getMemoryToolNames,
|
|
21
|
-
getReadOnlyMemoryToolNames,
|
|
22
|
-
getToolNamesForType,
|
|
23
|
-
} from "./agent-types.js";
|
|
24
17
|
import { buildParentContext, extractText } from "./context.js";
|
|
25
|
-
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
26
18
|
import { detectEnv } from "./env.js";
|
|
27
|
-
import {
|
|
28
|
-
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
29
|
-
import { preloadSkills } from "./skill-loader.js";
|
|
19
|
+
import { assembleSessionConfig } from "./session-config.js";
|
|
30
20
|
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
31
21
|
|
|
32
22
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
@@ -74,39 +64,6 @@ export function normalizeMaxTurns(n: number | undefined): number | undefined {
|
|
|
74
64
|
return Math.max(1, n);
|
|
75
65
|
}
|
|
76
66
|
|
|
77
|
-
/**
|
|
78
|
-
* Try to find the right model for an agent type.
|
|
79
|
-
* Priority: explicit option > config.model > parent model.
|
|
80
|
-
*/
|
|
81
|
-
function resolveDefaultModel(
|
|
82
|
-
parentModel: Model<any> | undefined,
|
|
83
|
-
registry: {
|
|
84
|
-
find(provider: string, modelId: string): Model<any> | undefined;
|
|
85
|
-
getAvailable?(): Model<any>[];
|
|
86
|
-
},
|
|
87
|
-
configModel?: string,
|
|
88
|
-
): Model<any> | undefined {
|
|
89
|
-
if (configModel) {
|
|
90
|
-
const slashIdx = configModel.indexOf("/");
|
|
91
|
-
if (slashIdx !== -1) {
|
|
92
|
-
const provider = configModel.slice(0, slashIdx);
|
|
93
|
-
const modelId = configModel.slice(slashIdx + 1);
|
|
94
|
-
|
|
95
|
-
// Build a set of available model keys for fast lookup
|
|
96
|
-
const available = registry.getAvailable?.();
|
|
97
|
-
const availableKeys = available
|
|
98
|
-
? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
|
|
99
|
-
: undefined;
|
|
100
|
-
const isAvailable = (p: string, id: string) =>
|
|
101
|
-
!availableKeys || availableKeys.has(`${p}/${id}`);
|
|
102
|
-
|
|
103
|
-
const found = registry.find(provider, modelId);
|
|
104
|
-
if (found && isAvailable(provider, modelId)) return found;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return parentModel;
|
|
109
|
-
}
|
|
110
67
|
|
|
111
68
|
/** Info about a tool event in the subagent. */
|
|
112
69
|
export interface ToolActivity {
|
|
@@ -223,96 +180,27 @@ export async function runAgent(
|
|
|
223
180
|
prompt: string,
|
|
224
181
|
options: RunOptions,
|
|
225
182
|
): Promise<RunResult> {
|
|
226
|
-
|
|
227
|
-
const agentConfig = getAgentConfig(type);
|
|
228
|
-
|
|
229
|
-
// Resolve working directory: worktree override > parent cwd
|
|
183
|
+
// Resolve working directory upfront — needed for detectEnv before assembly.
|
|
230
184
|
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
231
|
-
|
|
232
185
|
const env = await detectEnv(options.pi, effectiveCwd);
|
|
233
186
|
|
|
234
|
-
//
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
let toolNames = getToolNamesForType(type);
|
|
253
|
-
|
|
254
|
-
// Persistent memory: detect write capability and branch accordingly.
|
|
255
|
-
// Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
|
|
256
|
-
if (agentConfig?.memory) {
|
|
257
|
-
const existingNames = new Set(toolNames);
|
|
258
|
-
const denied = agentConfig.disallowedTools
|
|
259
|
-
? new Set(agentConfig.disallowedTools)
|
|
260
|
-
: undefined;
|
|
261
|
-
const effectivelyHas = (name: string) =>
|
|
262
|
-
existingNames.has(name) && !denied?.has(name);
|
|
263
|
-
const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
|
|
264
|
-
|
|
265
|
-
if (hasWriteTools) {
|
|
266
|
-
// Read-write memory: add any missing memory tool names (read/write/edit)
|
|
267
|
-
const extraNames = getMemoryToolNames(existingNames);
|
|
268
|
-
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
269
|
-
extras.memoryBlock = buildMemoryBlock(
|
|
270
|
-
agentConfig.name,
|
|
271
|
-
agentConfig.memory,
|
|
272
|
-
effectiveCwd,
|
|
273
|
-
);
|
|
274
|
-
} else {
|
|
275
|
-
// Read-only memory: only add read tool name, use read-only prompt
|
|
276
|
-
const extraNames = getReadOnlyMemoryToolNames(existingNames);
|
|
277
|
-
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
278
|
-
extras.memoryBlock = buildReadOnlyMemoryBlock(
|
|
279
|
-
agentConfig.name,
|
|
280
|
-
agentConfig.memory,
|
|
281
|
-
effectiveCwd,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Build system prompt from agent config
|
|
287
|
-
let systemPrompt: string;
|
|
288
|
-
if (agentConfig) {
|
|
289
|
-
systemPrompt = buildAgentPrompt(
|
|
290
|
-
agentConfig,
|
|
291
|
-
effectiveCwd,
|
|
292
|
-
env,
|
|
293
|
-
parentSystemPrompt,
|
|
294
|
-
extras,
|
|
295
|
-
);
|
|
296
|
-
} else {
|
|
297
|
-
// Unknown type fallback: spread the canonical general-purpose config (defensive —
|
|
298
|
-
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
|
|
299
|
-
const fallback = DEFAULT_AGENTS.get("general-purpose");
|
|
300
|
-
if (!fallback)
|
|
301
|
-
throw new Error(
|
|
302
|
-
`No fallback config available for unknown type "${type}"`,
|
|
303
|
-
);
|
|
304
|
-
systemPrompt = buildAgentPrompt(
|
|
305
|
-
{ ...fallback, name: type },
|
|
306
|
-
effectiveCwd,
|
|
307
|
-
env,
|
|
308
|
-
parentSystemPrompt,
|
|
309
|
-
extras,
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// When skills is string[], we've already preloaded them into the prompt.
|
|
314
|
-
// Still pass noSkills: true since we don't need the skill loader to load them again.
|
|
315
|
-
const noSkills = skills === false || Array.isArray(skills);
|
|
187
|
+
// Assemble session configuration (synchronous, no SDK objects).
|
|
188
|
+
const cfg = assembleSessionConfig(
|
|
189
|
+
type,
|
|
190
|
+
{
|
|
191
|
+
cwd: ctx.cwd,
|
|
192
|
+
parentSystemPrompt: ctx.getSystemPrompt(),
|
|
193
|
+
parentModel: ctx.model,
|
|
194
|
+
modelRegistry: ctx.modelRegistry,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
cwd: options.cwd,
|
|
198
|
+
isolated: options.isolated,
|
|
199
|
+
model: options.model,
|
|
200
|
+
thinkingLevel: options.thinkingLevel,
|
|
201
|
+
},
|
|
202
|
+
env,
|
|
203
|
+
);
|
|
316
204
|
|
|
317
205
|
const agentDir = getAgentDir();
|
|
318
206
|
|
|
@@ -323,56 +211,43 @@ export async function runAgent(
|
|
|
323
211
|
// wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
|
|
324
212
|
// is embedded in systemPromptOverride) or inherit_context (conversation).
|
|
325
213
|
const loader = new DefaultResourceLoader({
|
|
326
|
-
cwd: effectiveCwd,
|
|
214
|
+
cwd: cfg.effectiveCwd,
|
|
327
215
|
agentDir,
|
|
328
|
-
noExtensions: extensions === false,
|
|
329
|
-
noSkills,
|
|
216
|
+
noExtensions: cfg.extensions === false,
|
|
217
|
+
noSkills: cfg.noSkills,
|
|
330
218
|
noPromptTemplates: true,
|
|
331
219
|
noThemes: true,
|
|
332
220
|
noContextFiles: true,
|
|
333
|
-
systemPromptOverride: () => systemPrompt,
|
|
221
|
+
systemPromptOverride: () => cfg.systemPrompt,
|
|
334
222
|
appendSystemPromptOverride: () => [],
|
|
335
223
|
});
|
|
336
224
|
await loader.reload();
|
|
337
225
|
|
|
338
|
-
// Resolve model: explicit option > config.model > parent model
|
|
339
|
-
const model =
|
|
340
|
-
options.model ??
|
|
341
|
-
resolveDefaultModel(ctx.model, ctx.modelRegistry, agentConfig?.model);
|
|
342
|
-
|
|
343
|
-
// Resolve thinking level: explicit option > agent config > undefined (inherit)
|
|
344
|
-
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
|
|
345
|
-
|
|
346
226
|
const sessionOpts: Parameters<typeof createAgentSession>[0] = {
|
|
347
|
-
cwd: effectiveCwd,
|
|
227
|
+
cwd: cfg.effectiveCwd,
|
|
348
228
|
agentDir,
|
|
349
|
-
sessionManager: SessionManager.inMemory(effectiveCwd),
|
|
350
|
-
settingsManager: SettingsManager.create(effectiveCwd, agentDir),
|
|
229
|
+
sessionManager: SessionManager.inMemory(cfg.effectiveCwd),
|
|
230
|
+
settingsManager: SettingsManager.create(cfg.effectiveCwd, agentDir),
|
|
351
231
|
modelRegistry: ctx.modelRegistry,
|
|
352
|
-
model,
|
|
353
|
-
tools: toolNames,
|
|
232
|
+
model: cfg.model as Model<any> | undefined,
|
|
233
|
+
tools: cfg.toolNames,
|
|
354
234
|
resourceLoader: loader,
|
|
355
235
|
};
|
|
356
|
-
if (thinkingLevel) {
|
|
357
|
-
sessionOpts.thinkingLevel = thinkingLevel;
|
|
236
|
+
if (cfg.thinkingLevel) {
|
|
237
|
+
sessionOpts.thinkingLevel = cfg.thinkingLevel;
|
|
358
238
|
}
|
|
359
239
|
|
|
360
240
|
const { session } = await createAgentSession(sessionOpts);
|
|
361
241
|
|
|
362
|
-
// Build disallowed tools set from agent config
|
|
363
|
-
const disallowedSet = agentConfig?.disallowedTools
|
|
364
|
-
? new Set(agentConfig.disallowedTools)
|
|
365
|
-
: undefined;
|
|
366
|
-
|
|
367
242
|
// Filter active tools: remove our own tools to prevent nesting,
|
|
368
243
|
// apply extension allowlist if specified, and apply disallowedTools denylist.
|
|
369
244
|
// First pass — over built-in tools, before bindExtensions registers extension tools.
|
|
370
|
-
if (extensions !== false || disallowedSet) {
|
|
245
|
+
if (cfg.extensions !== false || cfg.disallowedSet) {
|
|
371
246
|
const filtered = filterActiveTools(
|
|
372
247
|
session.getActiveToolNames(),
|
|
373
|
-
toolNames,
|
|
374
|
-
extensions,
|
|
375
|
-
disallowedSet,
|
|
248
|
+
cfg.toolNames,
|
|
249
|
+
cfg.extensions,
|
|
250
|
+
cfg.disallowedSet,
|
|
376
251
|
);
|
|
377
252
|
session.setActiveToolsByName(filtered);
|
|
378
253
|
}
|
|
@@ -396,12 +271,12 @@ export async function runAgent(
|
|
|
396
271
|
// re-filter, the `extensions: string[]` allowlist branch never matches any
|
|
397
272
|
// extension tools and `extensions: true` lets non-allowlisted denylist
|
|
398
273
|
// entries slip in. Run the same filter against the post-bind active set.
|
|
399
|
-
if (extensions !== false || disallowedSet) {
|
|
274
|
+
if (cfg.extensions !== false || cfg.disallowedSet) {
|
|
400
275
|
const refiltered = filterActiveTools(
|
|
401
276
|
session.getActiveToolNames(),
|
|
402
|
-
toolNames,
|
|
403
|
-
extensions,
|
|
404
|
-
disallowedSet,
|
|
277
|
+
cfg.toolNames,
|
|
278
|
+
cfg.extensions,
|
|
279
|
+
cfg.disallowedSet,
|
|
405
280
|
);
|
|
406
281
|
session.setActiveToolsByName(refiltered);
|
|
407
282
|
}
|
|
@@ -411,7 +286,7 @@ export async function runAgent(
|
|
|
411
286
|
// Track turns for graceful max_turns enforcement
|
|
412
287
|
let turnCount = 0;
|
|
413
288
|
const maxTurns = normalizeMaxTurns(
|
|
414
|
-
options.maxTurns ??
|
|
289
|
+
options.maxTurns ?? cfg.agentMaxTurns ?? options.defaultMaxTurns,
|
|
415
290
|
);
|
|
416
291
|
let softLimitReached = false;
|
|
417
292
|
let aborted = false;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-config.ts — Pure configuration assembler for agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* `assembleSessionConfig()` is the pure core extracted from `runAgent()`.
|
|
5
|
+
* It accepts resolved inputs (agent type, narrow context, run options, env info)
|
|
6
|
+
* and returns everything `runAgent()` needs to create the SDK session — without
|
|
7
|
+
* importing or constructing any Pi SDK types.
|
|
8
|
+
*
|
|
9
|
+
* The only async IO in the assembly phase (`detectEnv`) is handled by the caller
|
|
10
|
+
* before invoking this function, keeping the assembler synchronous.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
getAgentConfig,
|
|
15
|
+
getConfig,
|
|
16
|
+
getMemoryToolNames,
|
|
17
|
+
getReadOnlyMemoryToolNames,
|
|
18
|
+
getToolNamesForType,
|
|
19
|
+
} from "./agent-types.js";
|
|
20
|
+
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
21
|
+
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
22
|
+
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
23
|
+
import { preloadSkills } from "./skill-loader.js";
|
|
24
|
+
import type { EnvInfo, SubagentType, ThinkingLevel } from "./types.js";
|
|
25
|
+
|
|
26
|
+
// ── Public interfaces ────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Narrow context the assembler reads from the parent session.
|
|
30
|
+
* Tests construct plain objects satisfying this interface — no SDK mocking needed.
|
|
31
|
+
*
|
|
32
|
+
* Models are treated as opaque handles: the assembler never inspects their
|
|
33
|
+
* internals, only passes them through. `getAvailable` returns just enough
|
|
34
|
+
* structural information ({ provider, id }) for the availability check in
|
|
35
|
+
* `resolveDefaultModel`.
|
|
36
|
+
*/
|
|
37
|
+
export interface AssemblerContext {
|
|
38
|
+
/** Parent working directory (overridable via options.cwd). */
|
|
39
|
+
cwd: string;
|
|
40
|
+
/** Parent's effective system prompt (for append-mode agents). */
|
|
41
|
+
parentSystemPrompt: string;
|
|
42
|
+
/** Parent's current model instance (fallback when agent config has no model). */
|
|
43
|
+
parentModel?: unknown;
|
|
44
|
+
/** Model registry for resolving config.model strings. */
|
|
45
|
+
modelRegistry: {
|
|
46
|
+
find(provider: string, modelId: string): unknown;
|
|
47
|
+
getAvailable?(): Array<{ provider: string; id: string }>;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Narrow slice of RunOptions consumed by the assembler.
|
|
53
|
+
* All fields are optional — callers pass only what they have.
|
|
54
|
+
*/
|
|
55
|
+
export interface AssemblerOptions {
|
|
56
|
+
/** Override working directory (e.g. for worktree isolation). */
|
|
57
|
+
cwd?: string;
|
|
58
|
+
/** When true, forces extensions and skills to false. */
|
|
59
|
+
isolated?: boolean;
|
|
60
|
+
/** Explicit model override — wins over agentConfig.model and parent model. */
|
|
61
|
+
model?: unknown;
|
|
62
|
+
/** Explicit thinking level — wins over agentConfig.thinking. */
|
|
63
|
+
thinkingLevel?: ThinkingLevel;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Assembled configuration returned to `runAgent()`.
|
|
68
|
+
* Contains everything needed to create the SDK session and filter tools —
|
|
69
|
+
* with no SDK object references.
|
|
70
|
+
*/
|
|
71
|
+
export interface SessionConfig {
|
|
72
|
+
/** Resolved working directory (`options.cwd ?? ctx.cwd`). */
|
|
73
|
+
effectiveCwd: string;
|
|
74
|
+
/** Fully-assembled system prompt string (ready for `systemPromptOverride`). */
|
|
75
|
+
systemPrompt: string;
|
|
76
|
+
/** Built-in tool names for session creation, filtering, and memory augmentation. */
|
|
77
|
+
toolNames: string[];
|
|
78
|
+
/** Disallowed tool set from agentConfig (for `filterActiveTools`). undefined when empty. */
|
|
79
|
+
disallowedSet: Set<string> | undefined;
|
|
80
|
+
/** Resolved extensions setting for resource loader and tool filtering. */
|
|
81
|
+
extensions: boolean | string[];
|
|
82
|
+
/**
|
|
83
|
+
* Resolved model instance (undefined → use parent model as passed to SDK).
|
|
84
|
+
* Opaque handle — the assembler passes it through without inspection.
|
|
85
|
+
* Caller casts to the SDK’s Model<any> at the session-creation boundary.
|
|
86
|
+
*/
|
|
87
|
+
model: unknown;
|
|
88
|
+
/** Resolved thinking level (undefined → inherit from session). */
|
|
89
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
90
|
+
/** Whether to skip skill loading in the resource loader (`noSkills` flag). */
|
|
91
|
+
noSkills: boolean;
|
|
92
|
+
/** Prompt extras (memory block, preloaded skill blocks) — for transparency. */
|
|
93
|
+
extras: PromptExtras;
|
|
94
|
+
/** Per-agent configured max turns (from agentConfig.maxTurns). */
|
|
95
|
+
agentMaxTurns: number | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the default model from the agent config's model string.
|
|
102
|
+
*
|
|
103
|
+
* Priority: parentModel is the fallback; if `configModel` is a "provider/modelId"
|
|
104
|
+
* string that resolves against the registry AND is in the available set, return
|
|
105
|
+
* that model instead.
|
|
106
|
+
*/
|
|
107
|
+
function resolveDefaultModel(
|
|
108
|
+
parentModel: unknown,
|
|
109
|
+
registry: AssemblerContext["modelRegistry"],
|
|
110
|
+
configModel?: string,
|
|
111
|
+
): unknown {
|
|
112
|
+
if (configModel) {
|
|
113
|
+
const slashIdx = configModel.indexOf("/");
|
|
114
|
+
if (slashIdx !== -1) {
|
|
115
|
+
const provider = configModel.slice(0, slashIdx);
|
|
116
|
+
const modelId = configModel.slice(slashIdx + 1);
|
|
117
|
+
|
|
118
|
+
const available = registry.getAvailable?.();
|
|
119
|
+
const availableKeys = available
|
|
120
|
+
? new Set(available.map((m) => `${m.provider}/${m.id}`))
|
|
121
|
+
: undefined;
|
|
122
|
+
const isAvailable = (p: string, id: string) =>
|
|
123
|
+
!availableKeys || availableKeys.has(`${p}/${id}`);
|
|
124
|
+
|
|
125
|
+
const found = registry.find(provider, modelId);
|
|
126
|
+
if (found && isAvailable(provider, modelId)) return found;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return parentModel;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Public function ──────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Assemble all configuration needed to create an agent session.
|
|
136
|
+
*
|
|
137
|
+
* Synchronous and side-effect-free (beyond calling `preloadSkills` which reads
|
|
138
|
+
* the filesystem). The caller is responsible for resolving `EnvInfo` beforehand
|
|
139
|
+
* via `detectEnv()`.
|
|
140
|
+
*
|
|
141
|
+
* @param type The subagent type name (case-insensitive registry lookup).
|
|
142
|
+
* @param ctx Narrow context from the parent session.
|
|
143
|
+
* @param options Per-call overrides (cwd, isolated, model, thinkingLevel).
|
|
144
|
+
* @param env Pre-resolved environment info from `detectEnv()`.
|
|
145
|
+
*/
|
|
146
|
+
export function assembleSessionConfig(
|
|
147
|
+
type: SubagentType,
|
|
148
|
+
ctx: AssemblerContext,
|
|
149
|
+
options: AssemblerOptions,
|
|
150
|
+
env: EnvInfo,
|
|
151
|
+
): SessionConfig {
|
|
152
|
+
const config = getConfig(type);
|
|
153
|
+
const agentConfig = getAgentConfig(type);
|
|
154
|
+
|
|
155
|
+
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
156
|
+
|
|
157
|
+
// Resolve extensions/skills: isolated overrides to false
|
|
158
|
+
const extensions = options.isolated ? false : config.extensions;
|
|
159
|
+
const skills = options.isolated ? false : config.skills;
|
|
160
|
+
|
|
161
|
+
// Build prompt extras (memory, preloaded skills)
|
|
162
|
+
const extras: PromptExtras = {};
|
|
163
|
+
|
|
164
|
+
// Skill preloading: when skills is string[], preload their content into the prompt
|
|
165
|
+
if (Array.isArray(skills)) {
|
|
166
|
+
const loaded = preloadSkills(skills, effectiveCwd);
|
|
167
|
+
if (loaded.length > 0) {
|
|
168
|
+
extras.skillBlocks = loaded;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let toolNames = getToolNamesForType(type);
|
|
173
|
+
|
|
174
|
+
// Persistent memory: detect write capability and branch accordingly.
|
|
175
|
+
// Account for disallowedTools — a tool in the base set but on the denylist
|
|
176
|
+
// is not truly available.
|
|
177
|
+
if (agentConfig?.memory) {
|
|
178
|
+
const existingNames = new Set(toolNames);
|
|
179
|
+
const denied = agentConfig.disallowedTools
|
|
180
|
+
? new Set(agentConfig.disallowedTools)
|
|
181
|
+
: undefined;
|
|
182
|
+
const effectivelyHas = (name: string) =>
|
|
183
|
+
existingNames.has(name) && !denied?.has(name);
|
|
184
|
+
const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
|
|
185
|
+
|
|
186
|
+
if (hasWriteTools) {
|
|
187
|
+
const extraNames = getMemoryToolNames(existingNames);
|
|
188
|
+
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
189
|
+
extras.memoryBlock = buildMemoryBlock(
|
|
190
|
+
agentConfig.name,
|
|
191
|
+
agentConfig.memory,
|
|
192
|
+
effectiveCwd,
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
const extraNames = getReadOnlyMemoryToolNames(existingNames);
|
|
196
|
+
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
197
|
+
extras.memoryBlock = buildReadOnlyMemoryBlock(
|
|
198
|
+
agentConfig.name,
|
|
199
|
+
agentConfig.memory,
|
|
200
|
+
effectiveCwd,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Build system prompt from agent config (or general-purpose fallback for unknown types)
|
|
206
|
+
let systemPrompt: string;
|
|
207
|
+
if (agentConfig) {
|
|
208
|
+
systemPrompt = buildAgentPrompt(
|
|
209
|
+
agentConfig,
|
|
210
|
+
effectiveCwd,
|
|
211
|
+
env,
|
|
212
|
+
ctx.parentSystemPrompt,
|
|
213
|
+
extras,
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
// Unknown type fallback: spread the canonical general-purpose config (defensive —
|
|
217
|
+
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
|
|
218
|
+
const fallback = DEFAULT_AGENTS.get("general-purpose");
|
|
219
|
+
if (!fallback) {
|
|
220
|
+
throw new Error(`No fallback config available for unknown type "${type}"`);
|
|
221
|
+
}
|
|
222
|
+
systemPrompt = buildAgentPrompt(
|
|
223
|
+
{ ...fallback, name: type },
|
|
224
|
+
effectiveCwd,
|
|
225
|
+
env,
|
|
226
|
+
ctx.parentSystemPrompt,
|
|
227
|
+
extras,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// noSkills: when we've already preloaded skills into the prompt, or skills = false,
|
|
232
|
+
// tell the resource loader not to load them again.
|
|
233
|
+
const noSkills = skills === false || Array.isArray(skills);
|
|
234
|
+
|
|
235
|
+
// Disallowed tools set (for filterActiveTools in runAgent)
|
|
236
|
+
const disallowedSet = agentConfig?.disallowedTools
|
|
237
|
+
? new Set(agentConfig.disallowedTools)
|
|
238
|
+
: undefined;
|
|
239
|
+
|
|
240
|
+
// Model resolution: explicit option > config model string > parent model
|
|
241
|
+
const model =
|
|
242
|
+
options.model ??
|
|
243
|
+
resolveDefaultModel(ctx.parentModel, ctx.modelRegistry, agentConfig?.model);
|
|
244
|
+
|
|
245
|
+
// Thinking level: explicit option > agent config > undefined (inherit)
|
|
246
|
+
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
|
|
247
|
+
|
|
248
|
+
// Per-agent max turns (combined with options.maxTurns and defaultMaxTurns by runAgent)
|
|
249
|
+
const agentMaxTurns = agentConfig?.maxTurns;
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
effectiveCwd,
|
|
253
|
+
systemPrompt,
|
|
254
|
+
toolNames,
|
|
255
|
+
disallowedSet,
|
|
256
|
+
extensions,
|
|
257
|
+
model,
|
|
258
|
+
thinkingLevel,
|
|
259
|
+
noSkills,
|
|
260
|
+
extras,
|
|
261
|
+
agentMaxTurns,
|
|
262
|
+
};
|
|
263
|
+
}
|