@gotgenes/pi-subagents 5.2.0 → 5.4.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 +27 -0
- package/docs/architecture/architecture.md +20 -13
- package/docs/plans/0071-extract-session-config-assembler.md +362 -0
- package/docs/plans/0080-consolidate-agent-config-lookup.md +247 -0
- package/docs/retro/0069-create-subagent-runtime.md +43 -0
- package/docs/retro/0071-extract-session-config-assembler.md +60 -0
- package/package.json +1 -1
- package/src/agent-runner.ts +39 -164
- package/src/agent-types.ts +10 -36
- package/src/index.ts +8 -8
- package/src/session-config.ts +243 -0
- package/src/tools/agent-tool.ts +3 -3
- package/src/ui/agent-menu.ts +11 -10
- package/src/ui/agent-widget.ts +4 -3
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 80
|
|
3
|
+
issue_title: "refactor: consolidate getConfig / getAgentConfig into a single resolution path"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Consolidate agent config lookup into resolveAgentConfig
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`agent-types.ts` exports two overlapping lookup functions:
|
|
11
|
+
|
|
12
|
+
- `getConfig(type)` — returns a narrow shape (`displayName`, `description`, `builtinToolNames`, `extensions`, `skills`, `promptMode`) with a guaranteed-non-null fallback chain (unknown → general-purpose → absolute fallback).
|
|
13
|
+
- `getAgentConfig(type)` — returns the full `AgentConfig | undefined`.
|
|
14
|
+
|
|
15
|
+
Every field `getConfig()` returns also exists on `AgentConfig`.
|
|
16
|
+
Callers that need both must call both and keep them in sync:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
const config = getConfig(type);
|
|
20
|
+
const agentConfig = getAgentConfig(type);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This showed up concretely in `session-config.ts` (extracted in #71), where the assembler calls both, then handles the `agentConfig === undefined` fallback separately — duplicating the same fallback chain that `getConfig()` already handles internally.
|
|
24
|
+
Test mocks must also set up both `mockGetConfig` and `mockGetAgentConfig` with compatible values, which is error-prone.
|
|
25
|
+
|
|
26
|
+
## Goals
|
|
27
|
+
|
|
28
|
+
- Add a single `resolveAgentConfig(type): AgentConfig` function that returns a guaranteed-non-null `AgentConfig`, handling the fallback chain internally (unknown → general-purpose → absolute fallback).
|
|
29
|
+
- Migrate all callers of `getConfig()` and `getAgentConfig()` to `resolveAgentConfig()`.
|
|
30
|
+
- Remove `getConfig()` and `getAgentConfig()`.
|
|
31
|
+
- Simplify test mocks from two compatible stubs to one.
|
|
32
|
+
|
|
33
|
+
This is a pure internal refactor — no behavior change, no public API impact (the package's `exports` only expose `service.ts`).
|
|
34
|
+
|
|
35
|
+
## Non-Goals
|
|
36
|
+
|
|
37
|
+
- Changing the `AgentConfig` type shape.
|
|
38
|
+
- Refactoring `getToolNamesForType()` (it has its own lookup logic and is a separate concern).
|
|
39
|
+
- Modifying the public API surface (`service.ts`).
|
|
40
|
+
- Changing the fallback semantics (the chain stays: type → general-purpose → absolute fallback).
|
|
41
|
+
|
|
42
|
+
## Background
|
|
43
|
+
|
|
44
|
+
### Relevant modules
|
|
45
|
+
|
|
46
|
+
| Module | Role |
|
|
47
|
+
| --------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
48
|
+
| `agent-types.ts` | Unified agent type registry — owns `getConfig`, `getAgentConfig`, `resolveType`, `getToolNamesForType`, `isValidType` |
|
|
49
|
+
| `session-config.ts` | Pure configuration assembler — calls both `getConfig` + `getAgentConfig` |
|
|
50
|
+
| `ui/agent-widget.ts` | Widget rendering — calls `getConfig` via `getDisplayName()` and `getPromptModeLabel()` helpers |
|
|
51
|
+
| `index.ts` | Extension entry point — calls `getAgentConfig` (3 sites) for type listing and model label |
|
|
52
|
+
| `tools/agent-tool.ts` | Agent tool handler — calls `getAgentConfig` (1 site) after `resolveType()` |
|
|
53
|
+
| `ui/agent-menu.ts` | Agent menu UI — calls `getAgentConfig` (5 sites) for listing, detail, and existence checks |
|
|
54
|
+
|
|
55
|
+
### Current caller inventory
|
|
56
|
+
|
|
57
|
+
Source files that import `getConfig` or `getAgentConfig`:
|
|
58
|
+
|
|
59
|
+
| File | `getConfig` calls | `getAgentConfig` calls | Notes |
|
|
60
|
+
| --------------------- | ----------------- | ---------------------- | --------------------------------------------- |
|
|
61
|
+
| `session-config.ts` | 1 | 1 | Primary motivation; dual-call pattern |
|
|
62
|
+
| `ui/agent-widget.ts` | 2 (via helpers) | 0 | `getDisplayName()`, `getPromptModeLabel()` |
|
|
63
|
+
| `index.ts` | 0 | 3 | Iterates known names; `?.` is defensive |
|
|
64
|
+
| `tools/agent-tool.ts` | 0 | 1 | After `resolveType()` — guaranteed to exist |
|
|
65
|
+
| `ui/agent-menu.ts` | 0 | 5 | 3 iterate known names; 2 are existence guards |
|
|
66
|
+
|
|
67
|
+
Test files that mock `agent-types.js` with both functions:
|
|
68
|
+
|
|
69
|
+
| Test file | Mocks `getConfig` | Mocks `getAgentConfig` |
|
|
70
|
+
| -------------------------------------- | ----------------- | ---------------------- |
|
|
71
|
+
| `session-config.test.ts` | ✓ | ✓ |
|
|
72
|
+
| `agent-runner.test.ts` | ✓ | ✓ |
|
|
73
|
+
| `agent-runner-extension-tools.test.ts` | ✓ | ✓ |
|
|
74
|
+
|
|
75
|
+
### Caller migration notes
|
|
76
|
+
|
|
77
|
+
- **`session-config.ts`**: Replace both calls with one `resolveAgentConfig(type)`.
|
|
78
|
+
Read `extensions` and `skills` directly from the returned `AgentConfig` instead of the narrow shape.
|
|
79
|
+
The prompt-building fallback (`agentConfig` null → use `DEFAULT_AGENTS.get("general-purpose")`) collapses into the single call since `resolveAgentConfig` already handles the fallback.
|
|
80
|
+
- **`ui/agent-widget.ts`**: `getDisplayName()` becomes `resolveAgentConfig(type).displayName ?? resolveAgentConfig(type).name` (or cache the result).
|
|
81
|
+
`getPromptModeLabel()` reads `.promptMode` from the resolved config.
|
|
82
|
+
- **`index.ts`**: All 3 call sites iterate names from `getDefaultAgentNames()` or `getUserAgentNames()` — configs are guaranteed to exist.
|
|
83
|
+
Direct replacement with `resolveAgentConfig()`.
|
|
84
|
+
- **`tools/agent-tool.ts`**: Called after `resolveType()` — config guaranteed.
|
|
85
|
+
Direct replacement.
|
|
86
|
+
- **`ui/agent-menu.ts`**: 3 of 5 calls iterate `getAllTypes()` — configs guaranteed.
|
|
87
|
+
The 2 defensive existence guards (lines 187, 248) switch to `resolveType(name) != null` checks, then use `resolveAgentConfig()` for the config.
|
|
88
|
+
|
|
89
|
+
### Constraints from AGENTS.md
|
|
90
|
+
|
|
91
|
+
- Keep modules focused and composable (one concern per file).
|
|
92
|
+
- Prefer explicit configuration over hidden behavior.
|
|
93
|
+
- Use `vi.hoisted()` for module-level mocks, `.mockClear()` when the factory provides a default.
|
|
94
|
+
- Run `pnpm run check` after interface changes.
|
|
95
|
+
|
|
96
|
+
## Design Overview
|
|
97
|
+
|
|
98
|
+
### `resolveAgentConfig` semantics
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
export function resolveAgentConfig(type: string): AgentConfig
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
1. Case-insensitive key lookup via `resolveKey(type)`.
|
|
105
|
+
2. If found and `enabled !== false`, return the `AgentConfig`.
|
|
106
|
+
3. Fallback to `general-purpose` (if present and enabled).
|
|
107
|
+
4. Absolute fallback: a synthetic `AgentConfig` with safe defaults (same values as today's `getConfig` absolute fallback, plus the missing `AgentConfig` fields like `name`, `systemPrompt`).
|
|
108
|
+
|
|
109
|
+
The function is pure (reads from the module-level `agents` map) and deterministic.
|
|
110
|
+
|
|
111
|
+
### Absolute fallback shape
|
|
112
|
+
|
|
113
|
+
The absolute fallback is a complete `AgentConfig` synthesized inline, matching the current `getConfig` absolute fallback values plus required `AgentConfig` fields:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
{
|
|
117
|
+
name: type,
|
|
118
|
+
displayName: "Agent",
|
|
119
|
+
description: "General-purpose agent for complex, multi-step tasks",
|
|
120
|
+
builtinToolNames: BUILTIN_TOOL_NAMES,
|
|
121
|
+
extensions: true,
|
|
122
|
+
skills: true,
|
|
123
|
+
systemPrompt: "",
|
|
124
|
+
promptMode: "append",
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### session-config.ts simplification
|
|
129
|
+
|
|
130
|
+
Before (two calls, two variables, null-check branching):
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const config = getConfig(type);
|
|
134
|
+
const agentConfig = getAgentConfig(type);
|
|
135
|
+
// ...
|
|
136
|
+
const extensions = options.isolated ? false : config.extensions;
|
|
137
|
+
const skills = options.isolated ? false : config.skills;
|
|
138
|
+
// ...
|
|
139
|
+
if (agentConfig) {
|
|
140
|
+
systemPrompt = buildAgentPrompt(agentConfig, ...);
|
|
141
|
+
} else {
|
|
142
|
+
const fallback = DEFAULT_AGENTS.get("general-purpose");
|
|
143
|
+
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, ...);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
After (one call, no null checks):
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
const agentConfig = resolveAgentConfig(type);
|
|
151
|
+
// ...
|
|
152
|
+
const extensions = options.isolated ? false : agentConfig.extensions;
|
|
153
|
+
const skills = options.isolated ? false : agentConfig.skills;
|
|
154
|
+
// ...
|
|
155
|
+
systemPrompt = buildAgentPrompt(agentConfig, ...);
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The `DEFAULT_AGENTS` import in `session-config.ts` becomes unnecessary.
|
|
159
|
+
|
|
160
|
+
## Module-Level Changes
|
|
161
|
+
|
|
162
|
+
### `src/agent-types.ts`
|
|
163
|
+
|
|
164
|
+
- **Add** `resolveAgentConfig(type: string): AgentConfig` — guaranteed-non-null lookup with fallback chain.
|
|
165
|
+
- **Remove** `getConfig(type)` — replaced by `resolveAgentConfig`.
|
|
166
|
+
- **Remove** `getAgentConfig(type)` — replaced by `resolveAgentConfig`.
|
|
167
|
+
|
|
168
|
+
### `src/session-config.ts`
|
|
169
|
+
|
|
170
|
+
- **Change** imports: replace `getConfig`, `getAgentConfig` with `resolveAgentConfig`.
|
|
171
|
+
- **Remove** `DEFAULT_AGENTS` import (no longer needed for the unknown-type fallback prompt path).
|
|
172
|
+
- **Simplify** `assembleSessionConfig()`: one `resolveAgentConfig(type)` call replaces two; remove the `if (agentConfig)` / `else` branch for prompt building.
|
|
173
|
+
|
|
174
|
+
### `src/ui/agent-widget.ts`
|
|
175
|
+
|
|
176
|
+
- **Change** import: replace `getConfig` with `resolveAgentConfig`.
|
|
177
|
+
- **Update** `getDisplayName()`: `const config = resolveAgentConfig(type); return config.displayName ?? config.name;`.
|
|
178
|
+
- **Update** `getPromptModeLabel()`: read `promptMode` from resolved config.
|
|
179
|
+
|
|
180
|
+
### `src/index.ts`
|
|
181
|
+
|
|
182
|
+
- **Change** import: replace `getAgentConfig` with `resolveAgentConfig`.
|
|
183
|
+
- **Update** 3 call sites: direct replacement (all iterate known names).
|
|
184
|
+
|
|
185
|
+
### `src/tools/agent-tool.ts`
|
|
186
|
+
|
|
187
|
+
- **Change** import: replace `getAgentConfig` with `resolveAgentConfig`.
|
|
188
|
+
- **Update** 1 call site: direct replacement (type already resolved via `resolveType()`).
|
|
189
|
+
|
|
190
|
+
### `src/ui/agent-menu.ts`
|
|
191
|
+
|
|
192
|
+
- **Change** import: replace `getAgentConfig` with `resolveAgentConfig`, add `resolveType` if not already imported.
|
|
193
|
+
- **Update** 5 call sites: 3 are direct replacements; 2 existence guards switch to `resolveType(name) != null` before calling `resolveAgentConfig()`.
|
|
194
|
+
|
|
195
|
+
### Test files
|
|
196
|
+
|
|
197
|
+
- **`test/agent-types.test.ts`**: Add tests for `resolveAgentConfig`; remove tests for `getConfig` and `getAgentConfig`.
|
|
198
|
+
- **`test/session-config.test.ts`**: Replace `mockGetConfig` + `mockGetAgentConfig` with a single `mockResolveAgentConfig`; remove all `mockGetConfig` usages.
|
|
199
|
+
- **`test/agent-runner.test.ts`**: Update `vi.mock("../src/agent-types.js")` factory: replace `getConfig` + `getAgentConfig` with `resolveAgentConfig`.
|
|
200
|
+
- **`test/agent-runner-extension-tools.test.ts`**: Same mock update as above.
|
|
201
|
+
|
|
202
|
+
## Test Impact Analysis
|
|
203
|
+
|
|
204
|
+
1. **New unit tests enabled**: `resolveAgentConfig` gets focused tests for the fallback chain (unknown → general-purpose → absolute fallback), case-insensitive lookup, and disabled-type fallback.
|
|
205
|
+
These replace the scattered `getConfig` fallback tests in `agent-types.test.ts`.
|
|
206
|
+
2. **Redundant tests**: `getConfig`-specific tests (return shape, fallback, extension allowlist) become redundant since `resolveAgentConfig` returns the full `AgentConfig` directly.
|
|
207
|
+
Remove them.
|
|
208
|
+
3. **Tests that must stay**: `agent-types.test.ts` tests for `registerAgents`, `resolveType`, `isValidType`, `getAvailableTypes`, `getAllTypes`, `getToolNamesForType`, `getMemoryToolNames`, `getReadOnlyMemoryToolNames` are unaffected.
|
|
209
|
+
`session-config.test.ts` tests all stay — they test assembler behavior, just with a simpler mock setup.
|
|
210
|
+
|
|
211
|
+
## TDD Order
|
|
212
|
+
|
|
213
|
+
1. **Add `resolveAgentConfig` with tests** — add the function to `agent-types.ts` alongside existing functions.
|
|
214
|
+
Tests: known type returns config; unknown type falls back to general-purpose; disabled type falls back; absolute fallback when general-purpose is missing; case-insensitive lookup.
|
|
215
|
+
Commit: `feat: add resolveAgentConfig with guaranteed-non-null fallback chain`.
|
|
216
|
+
|
|
217
|
+
2. **Migrate `session-config.ts` and its tests** — replace `getConfig` + `getAgentConfig` imports with `resolveAgentConfig`; remove `DEFAULT_AGENTS` import; simplify the assembler body (one call, no null-check branch).
|
|
218
|
+
Update `session-config.test.ts`: replace `mockGetConfig` + `mockGetAgentConfig` with single `mockResolveAgentConfig`; update all test setups.
|
|
219
|
+
Commit: `refactor: migrate session-config to resolveAgentConfig`.
|
|
220
|
+
|
|
221
|
+
3. **Migrate `agent-widget.ts`** — update `getDisplayName()` and `getPromptModeLabel()` to use `resolveAgentConfig`.
|
|
222
|
+
Commit: `refactor: migrate agent-widget to resolveAgentConfig`.
|
|
223
|
+
|
|
224
|
+
4. **Migrate remaining source callers** — update `index.ts` (3 sites), `tools/agent-tool.ts` (1 site), `ui/agent-menu.ts` (5 sites, including 2 existence-guard rewrites).
|
|
225
|
+
Commit: `refactor: migrate remaining callers to resolveAgentConfig`.
|
|
226
|
+
|
|
227
|
+
5. **Update transitive test mocks** — update `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts` mock factories to export `resolveAgentConfig` instead of `getConfig` + `getAgentConfig`.
|
|
228
|
+
Commit: `test: update agent-runner test mocks for resolveAgentConfig`.
|
|
229
|
+
|
|
230
|
+
6. **Remove `getConfig` and `getAgentConfig`** — delete both functions from `agent-types.ts`; remove their tests from `agent-types.test.ts`.
|
|
231
|
+
Run `pnpm run check` to verify no remaining references.
|
|
232
|
+
Commit: `refactor: remove getConfig and getAgentConfig`.
|
|
233
|
+
|
|
234
|
+
## Risks and Mitigations
|
|
235
|
+
|
|
236
|
+
| Risk | Mitigation |
|
|
237
|
+
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
238
|
+
| Overlooked caller of removed functions | Grep all `src/` and `test/` files for `getConfig` and `getAgentConfig` before the removal step; `pnpm run check` catches any remaining imports. |
|
|
239
|
+
| `agent-menu.ts` existence guards behave differently with `resolveType()` | `resolveType()` is already the canonical existence check used by `agent-tool.ts`; the guard semantics are identical. |
|
|
240
|
+
| Absolute fallback `AgentConfig` shape drift | The absolute fallback is a single inline literal — any future `AgentConfig` field additions will cause a type error at the definition site. |
|
|
241
|
+
| Test mock compatibility during migration | Lift-and-shift: `resolveAgentConfig` coexists with old functions until all callers are migrated; each step leaves tests green. |
|
|
242
|
+
|
|
243
|
+
## Open Questions
|
|
244
|
+
|
|
245
|
+
- `getToolNamesForType()` does its own lookup with similar fallback logic.
|
|
246
|
+
It could be simplified to delegate to `resolveAgentConfig()`, but that is out of scope for this issue.
|
|
247
|
+
Consider a follow-up if the duplication becomes a maintenance concern.
|
|
@@ -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.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 71
|
|
3
|
+
issue_title: "refactor: extract pure agent-session assembler from agent-runner.ts"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #71 - extract session-config assembler
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-19T22:15:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned, implemented, and shipped `assembleSessionConfig()` - a pure configuration assembler extracted from `runAgent()` in `agent-runner.ts`.
|
|
13
|
+
Eight TDD steps completed across three phases (plan, implement, ship), adding 33 new tests in `test/session-config.test.ts` and reducing `runAgent()` from ~390 lines to ~198.
|
|
14
|
+
Released as `pi-subagents-v5.3.0`.
|
|
15
|
+
Filed follow-up #80 (consolidate `getConfig`/`getAgentConfig`) and updated the architecture roadmap.
|
|
16
|
+
|
|
17
|
+
### Observations
|
|
18
|
+
|
|
19
|
+
#### What went well
|
|
20
|
+
|
|
21
|
+
- The user's redirecting question ("Do we need more decomposition?
|
|
22
|
+
Are there any seams we can exploit?") at the `as any` fix point was perfectly timed.
|
|
23
|
+
It shifted the approach from mechanical casting (8 `as any` casts) to a structural fix: `unknown` for opaque model handles, `Array<{ provider: string; id: string }>` for the availability check.
|
|
24
|
+
The final design removed the `@earendil-works/pi-ai` import from `session-config.ts` entirely - cleaner than the plan's original specification of `Model<any>`.
|
|
25
|
+
- The prior art from `pi-permission-system` (`evaluate()` extraction) provided a clear template for the pure-core-from-IO-shell pattern.
|
|
26
|
+
Design decisions were minimal.
|
|
27
|
+
- All 451 existing tests stayed green through every intermediate commit.
|
|
28
|
+
The mock-at-module-boundary strategy (`vi.mock("../src/agent-types.js")` etc.) meant existing `agent-runner.test.ts` mocks continued to intercept the same module paths even after the assembler delegated to them.
|
|
29
|
+
|
|
30
|
+
#### What caused friction (agent side)
|
|
31
|
+
|
|
32
|
+
- `premature-convergence` - When `pnpm run check` surfaced 10 type errors from `Model<any>` in `SessionConfig`, the first fix attempt was adding `as any` casts to the `vi.fn` factory return values and all test model objects (8 casts total).
|
|
33
|
+
This partially addressed the symptom but created new `never[]` inference errors and left a fundamentally wrong interface.
|
|
34
|
+
The user's first pushback ("Can we find a real type?") prompted a proper analysis of `Model<TApi>`'s ~10 required fields, leading to the `unknown` solution.
|
|
35
|
+
Impact: ~20 minutes of rework across 3 attempts (the `as any` factory cast, diagnosing the residual `never[]` errors, and the final `unknown` rewrite). (user-caught)
|
|
36
|
+
|
|
37
|
+
- `missing-context` - The plan omitted `agentMaxTurns` from the `SessionConfig` return type.
|
|
38
|
+
`runAgent()`'s turn-limit resolution reads `agentConfig?.maxTurns`, which is no longer available after the assembly delegation.
|
|
39
|
+
Caught during step 7 (wiring) when the code wouldn't compile without it.
|
|
40
|
+
Impact: added one field to `SessionConfig` and `assembleSessionConfig`; no rework of earlier steps, but the commit body noted the deviation. (self-identified at implementation time)
|
|
41
|
+
|
|
42
|
+
- `missing-context` - The `vi.fn(() => [])` → `never[]` TypeScript inference issue wasn't anticipated.
|
|
43
|
+
Five mock factories (`mockGetMemoryToolNames`, `mockGetReadOnlyMemoryToolNames`, `mockPreloadSkills`, `mockRegistry.find`, `mockRegistry.getAvailable`) needed explicit return-type annotations.
|
|
44
|
+
Impact: one debug cycle to diagnose, then a second to fix with `import type` annotations. (self-identified during `pnpm run check`)
|
|
45
|
+
|
|
46
|
+
#### What caused friction (user side)
|
|
47
|
+
|
|
48
|
+
- The user's second pushback ("Do we need more decomposition?") was the highest-leverage intervention in the session.
|
|
49
|
+
Without it, the `as any` approach would have landed and the `pi-ai` import would have stayed in `session-config.ts` - defeating the goal of a SDK-free business-logic module.
|
|
50
|
+
The pattern of catching design-level issues through "is there a better seam?"
|
|
51
|
+
questions is worth preserving.
|
|
52
|
+
|
|
53
|
+
#### Design observations (not actionable as rules)
|
|
54
|
+
|
|
55
|
+
- The `cfg.model as Model<any> | undefined` cast in `agent-runner.ts` is a legitimate interim cost — it's one line at the SDK boundary and will be resolved when #66 (replace `as any` casts with proper SDK types) and #72 (AgentManager DI) refine the interface contracts.
|
|
56
|
+
Codifying “use `unknown` + boundary cast” as a general pattern would normalize something that should feel uncomfortable and motivate further interface refinement.
|
|
57
|
+
|
|
58
|
+
### Changes made
|
|
59
|
+
|
|
60
|
+
1. `.pi/skills/testing/SKILL.md` — added `vi.fn()` return-type annotation rule under “Vitest mock patterns”: annotate factories that return empty arrays or narrow literals to prevent `never[]` inference.
|
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;
|