@gotgenes/pi-subagents 4.0.0 → 4.1.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 CHANGED
@@ -5,6 +5,24 @@ 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
+ ## [4.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v4.0.0...pi-subagents-v4.1.0) (2026-05-18)
9
+
10
+
11
+ ### Features
12
+
13
+ * add resolveInvocationModel to model-resolver ([462b519](https://github.com/gotgenes/pi-packages/commit/462b5194fdfdf86d8d2d166a99472c651e00b76b))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * remove quotes from rumdl glob patterns in lint:md script ([a8a0c62](https://github.com/gotgenes/pi-packages/commit/a8a0c62feb2fc45cf68cd7d777259dc159de671b))
19
+
20
+
21
+ ### Documentation
22
+
23
+ * plan extract model resolution from Agent.execute ([#53](https://github.com/gotgenes/pi-packages/issues/53)) ([4c07a47](https://github.com/gotgenes/pi-packages/commit/4c07a474f9f25043a2fa3a4f2829e97eb9bb7666))
24
+ * **retro:** add retro notes for issue [#48](https://github.com/gotgenes/pi-packages/issues/48) ([f244c04](https://github.com/gotgenes/pi-packages/commit/f244c04c64f768e724e89d77962f2fb63715b998))
25
+
8
26
  ## [4.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v3.0.0...pi-subagents-v4.0.0) (2026-05-17)
9
27
 
10
28
 
@@ -0,0 +1,181 @@
1
+ ---
2
+ issue: 53
3
+ issue_title: "refactor: extract model resolution from Agent.execute"
4
+ ---
5
+
6
+ # Extract model resolution from Agent.execute
7
+
8
+ ## Problem Statement
9
+
10
+ The `Agent` tool's `execute` callback in `index.ts` contains inline model-resolution logic (~lines 660–670) that determines which model an agent runs with.
11
+ This block checks `resolvedConfig.modelInput`, calls `resolveModel()`, distinguishes error strings from resolved model instances, and silently falls back to the parent model for config-specified models that fail resolution.
12
+ The logic is not independently testable — it is only exercised through integration-level agent spawning.
13
+
14
+ A second, simpler call site in `getModelLabel()` (~line 1043) also calls `resolveModel()` inline but only checks whether the model resolves; it does not need the same fallback semantics.
15
+
16
+ ## Goals
17
+
18
+ - Extract the inline model-resolution block from `Agent.execute` into a named, unit-testable function in `model-resolver.ts`.
19
+ - Keep the existing `resolveModel()` function unchanged — the new function composes it.
20
+ - No behavior change: model-resolution priority and fallback semantics remain identical.
21
+
22
+ ## Non-Goals
23
+
24
+ - Changing the `resolveModel()` fuzzy-matching algorithm.
25
+ - Refactoring the `getModelLabel()` call site (~line 1043) — it has different semantics (display-only, no fallback) and does not benefit from the same extraction.
26
+ - Refactoring `service-adapter.ts` model resolution — it already uses a clean injected-dependency pattern.
27
+ - Changing any public API surface.
28
+
29
+ ## Background
30
+
31
+ ### Existing modules
32
+
33
+ | Module | Role |
34
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `model-resolver.ts` | Exports `resolveModel(input, registry)` — returns a `Model` on success or an error string on failure. |
36
+ | `invocation-config.ts` | Exports `resolveAgentInvocationConfig()` — merges tool params with agent config. Returns `modelInput` (the raw string) and `modelFromParams` (whether the string came from tool params vs. agent config). |
37
+ | `service-adapter.ts` | Already receives `resolveModel` as a dependency via `AdapterDeps`. Its model resolution is simpler (always throw on failure). |
38
+ | `index.ts` | `Agent.execute` contains the inline block. Uses both `modelInput` and `modelFromParams` to decide: (a) return error to user if params-specified model fails, or (b) silently fall back to parent model if config-specified model fails. |
39
+
40
+ ### Relevant constraint from AGENTS.md
41
+
42
+ > Keep modules focused and composable (one concern per file).
43
+
44
+ The new function belongs in `model-resolver.ts` alongside `resolveModel()` since it composes the latter with invocation-level fallback policy.
45
+
46
+ ## Design Overview
47
+
48
+ ### New function signature
49
+
50
+ ```typescript
51
+ interface ModelResolutionResult {
52
+ model: unknown;
53
+ error?: undefined;
54
+ }
55
+
56
+ interface ModelResolutionError {
57
+ model?: undefined;
58
+ error: string;
59
+ }
60
+
61
+ type ModelResolution = ModelResolutionResult | ModelResolutionError;
62
+
63
+ function resolveInvocationModel(
64
+ parentModel: unknown,
65
+ modelInput: string | undefined,
66
+ modelFromParams: boolean,
67
+ registry: ModelRegistry,
68
+ ): ModelResolution;
69
+ ```
70
+
71
+ ### Decision model
72
+
73
+ The function encapsulates the existing three-branch logic:
74
+
75
+ 1. **No `modelInput`** → return `{ model: parentModel }` (inherit parent).
76
+ 2. **`modelInput` resolves** → return `{ model: resolved }`.
77
+ 3. **`modelInput` fails to resolve**:
78
+ - If `modelFromParams` (user typed it) → return `{ error: errorMessage }` so the caller can surface it.
79
+ - If `!modelFromParams` (agent config specified it) → return `{ model: parentModel }` (silent fallback).
80
+
81
+ ### Result shape rationale
82
+
83
+ A discriminated union (`ModelResolution`) with `model` and `error` fields avoids the existing `typeof resolved === "string"` type-narrowing smell.
84
+ The caller in `index.ts` becomes:
85
+
86
+ ```typescript
87
+ const resolution = resolveInvocationModel(
88
+ ctx.model,
89
+ resolvedConfig.modelInput,
90
+ resolvedConfig.modelFromParams,
91
+ ctx.modelRegistry,
92
+ );
93
+ if (resolution.error) return textResult(resolution.error);
94
+ const model = resolution.model;
95
+ ```
96
+
97
+ ### Edge cases
98
+
99
+ - `modelInput` is `undefined` → short-circuit, return parent model.
100
+ - `modelInput` is an empty string → delegates to `resolveModel()`, which currently matches vacuously (documented in existing tests); no change in behavior.
101
+
102
+ ## Module-Level Changes
103
+
104
+ ### `src/model-resolver.ts`
105
+
106
+ - Add `ModelResolutionResult`, `ModelResolutionError`, and `ModelResolution` type exports.
107
+ - Add `resolveInvocationModel()` export.
108
+ - No changes to existing `resolveModel()`, `ModelEntry`, or `ModelRegistry`.
109
+
110
+ ### `src/index.ts`
111
+
112
+ - Update import to include `resolveInvocationModel`.
113
+ - Replace the inline model-resolution block in `Agent.execute` (~lines 660–670) with a call to `resolveInvocationModel()` and a check on the result.
114
+ - Remove the now-unused destructuring of `modelFromParams` from `resolvedConfig` at the call site (it is consumed internally by `resolveInvocationModel` via the parameter).
115
+
116
+ ### `test/model-resolver.test.ts`
117
+
118
+ - Add a new `describe("resolveInvocationModel")` block with tests covering all three branches plus edge cases.
119
+
120
+ ## Test Impact Analysis
121
+
122
+ ### New unit tests enabled
123
+
124
+ The extraction enables direct testing of the three-branch fallback logic (inherit, resolve, fallback-on-config-failure) that was previously only exercisable through full agent spawning.
125
+ Specifically:
126
+
127
+ - Parent model inheritance when no `modelInput` is provided.
128
+ - Successful resolution returns the resolved model.
129
+ - User-specified model failure returns an error.
130
+ - Config-specified model failure silently falls back to parent.
131
+
132
+ ### Existing tests that stay as-is
133
+
134
+ - All existing `resolveModel` tests in `test/model-resolver.test.ts` — they test the lower-level function which is unchanged.
135
+ - Integration-level tests in `test/agent-runner.test.ts` and `test/agent-manager.test.ts` — they exercise model usage through the full agent lifecycle.
136
+ - `test/invocation-config.test.ts` — unchanged module.
137
+ - `test/service-adapter.test.ts` — uses its own injected `resolveModel` dependency, unaffected.
138
+
139
+ ### Tests that become redundant
140
+
141
+ None.
142
+ The inline block was not directly tested anywhere — it was only reached through integration paths that test much more than model resolution.
143
+
144
+ ## TDD Order
145
+
146
+ 1. **Red → Green: parent model inheritance.**
147
+ Test: `resolveInvocationModel` returns `{ model: parentModel }` when `modelInput` is `undefined`.
148
+ Commit: `test: add resolveInvocationModel tests for parent model inheritance`
149
+
150
+ 2. **Red → Green: successful model resolution.**
151
+ Test: returns `{ model: resolvedModel }` when `resolveModel` succeeds (both params-specified and config-specified).
152
+ Commit: `test: add resolveInvocationModel tests for successful resolution`
153
+
154
+ 3. **Red → Green: user-specified model failure.**
155
+ Test: returns `{ error: message }` when `modelFromParams` is `true` and `resolveModel` returns an error string.
156
+ Commit: `test: add resolveInvocationModel tests for param model failure`
157
+
158
+ 4. **Red → Green: config-specified model silent fallback.**
159
+ Test: returns `{ model: parentModel }` when `modelFromParams` is `false` and `resolveModel` returns an error string.
160
+ Commit: `test: add resolveInvocationModel tests for config model fallback`
161
+
162
+ 5. **Green: implement `resolveInvocationModel` in `model-resolver.ts`.**
163
+ All four test cases go green.
164
+ Commit: `feat: add resolveInvocationModel to model-resolver`
165
+
166
+ 6. **Refactor: replace inline block in `index.ts`.**
167
+ Replace the inline model-resolution block in `Agent.execute` with a call to `resolveInvocationModel`.
168
+ Run full test suite to confirm no regressions.
169
+ Commit: `refactor: use resolveInvocationModel in Agent.execute (#53)`
170
+
171
+ ## Risks and Mitigations
172
+
173
+ | Risk | Mitigation |
174
+ | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
175
+ | Subtle behavior difference in the extracted function vs. the inline block | TDD steps 1–4 encode the exact current semantics; step 6 is a pure mechanical substitution. |
176
+ | `resolveModel` return type is `any \| string` — fragile narrowing | The new function encapsulates the `typeof` check behind a discriminated union, reducing but not eliminating the `any`. Fixing the `any` is out of scope (would require Pi SDK model type changes). |
177
+ | Second call site (`getModelLabel`) might seem like it should also use the new function | Explicitly listed as a non-goal — it has display-only semantics with no fallback behavior. |
178
+
179
+ ## Open Questions
180
+
181
+ None — the extraction is mechanical and the issue's acceptance criteria are unambiguous.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -59,7 +59,7 @@
59
59
  "check": "tsc --noEmit",
60
60
  "test": "vitest run",
61
61
  "test:watch": "vitest",
62
- "lint:md": "rumdl check '*.md' 'docs/**/*.md'",
62
+ "lint:md": "rumdl check *.md docs/**/*.md",
63
63
  "lint": "biome check . && pnpm run lint:md"
64
64
  }
65
65
  }
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTu
20
20
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
21
21
  import { loadCustomAgents } from "./custom-agents.js";
22
22
  import { resolveAgentInvocationConfig } from "./invocation-config.js";
23
- import { type ModelRegistry, resolveModel } from "./model-resolver.js";
23
+ import { type ModelRegistry, resolveInvocationModel, resolveModel } from "./model-resolver.js";
24
24
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
25
25
  import { publishSubagentsService, unpublishSubagentsService } from "./service.js";
26
26
  import { createSubagentsService } from "./service-adapter.js";
@@ -657,16 +657,14 @@ Guidelines:
657
657
  const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
658
658
 
659
659
  // Resolve model from agent config first; tool-call params only fill gaps.
660
- let model = ctx.model;
661
- if (resolvedConfig.modelInput) {
662
- const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
663
- if (typeof resolved === "string") {
664
- if (resolvedConfig.modelFromParams) return textResult(resolved);
665
- // config-specified: silent fallback to parent
666
- } else {
667
- model = resolved;
668
- }
669
- }
660
+ const resolution = resolveInvocationModel(
661
+ ctx.model,
662
+ resolvedConfig.modelInput,
663
+ resolvedConfig.modelFromParams,
664
+ ctx.modelRegistry,
665
+ );
666
+ if (resolution.error) return textResult(resolution.error);
667
+ const model = resolution.model;
670
668
 
671
669
  const thinking = resolvedConfig.thinking;
672
670
  const inheritContext = resolvedConfig.inheritContext;
@@ -14,6 +14,45 @@ export interface ModelRegistry {
14
14
  getAvailable?(): any[];
15
15
  }
16
16
 
17
+ /** Successful model resolution — `model` is the resolved or inherited model instance. */
18
+ export interface ModelResolutionResult {
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ model: any;
21
+ error?: undefined;
22
+ }
23
+
24
+ /** Failed model resolution when the model was user-specified (params) — surface the error. */
25
+ export interface ModelResolutionError {
26
+ model?: undefined;
27
+ error: string;
28
+ }
29
+
30
+ /** Discriminated union returned by `resolveInvocationModel`. */
31
+ export type ModelResolution = ModelResolutionResult | ModelResolutionError;
32
+
33
+ /**
34
+ * Resolve the effective model for an agent invocation.
35
+ *
36
+ * Encapsulates the three-branch fallback policy used in `Agent.execute`:
37
+ * 1. No `modelInput` → inherit `parentModel`.
38
+ * 2. `modelInput` resolves → return the resolved model.
39
+ * 3. `modelInput` fails:
40
+ * - `modelFromParams` true → return `{ error }` so the caller can surface it.
41
+ * - `modelFromParams` false → silent fallback to `parentModel`.
42
+ */
43
+ export function resolveInvocationModel(
44
+ parentModel: unknown,
45
+ modelInput: string | undefined,
46
+ modelFromParams: boolean,
47
+ registry: ModelRegistry,
48
+ ): ModelResolution {
49
+ if (!modelInput) return { model: parentModel };
50
+ const resolved = resolveModel(modelInput, registry);
51
+ if (typeof resolved !== "string") return { model: resolved };
52
+ if (modelFromParams) return { error: resolved };
53
+ return { model: parentModel };
54
+ }
55
+
17
56
  /**
18
57
  * Resolve a model string to a Model instance.
19
58
  * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.