@gotgenes/pi-subagents 5.1.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.
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { getDefaultMaxTurns, normalizeMaxTurns } from "../agent-runner.js";
3
+ import { normalizeMaxTurns } from "../agent-runner.js";
4
4
  import { getAgentConfig, resolveType } from "../agent-types.js";
5
5
  import { resolveAgentInvocationConfig } from "../invocation-config.js";
6
6
  import { resolveInvocationModel } from "../model-resolver.js";
@@ -146,6 +146,8 @@ export interface AgentToolDeps {
146
146
  typeListText: string;
147
147
  availableTypesText: string;
148
148
  agentDir: string;
149
+ /** Returns the runtime default max turns (undefined = unlimited). */
150
+ getDefaultMaxTurns: () => number | undefined;
149
151
  }
150
152
 
151
153
  // ---- Factory ----
@@ -396,7 +398,7 @@ Guidelines:
396
398
  ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
397
399
  : undefined;
398
400
  const effectiveMaxTurns = normalizeMaxTurns(
399
- resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
401
+ resolvedConfig.maxTurns ?? deps.getDefaultMaxTurns(),
400
402
  );
401
403
  const agentInvocation: AgentInvocation = {
402
404
  modelName,
@@ -1,11 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import {
4
- getDefaultMaxTurns,
5
- getGraceTurns,
6
- setDefaultMaxTurns,
7
- setGraceTurns,
8
- } from "../agent-runner.js";
3
+
9
4
  import {
10
5
  BUILTIN_TOOL_NAMES,
11
6
  getAgentConfig,
@@ -42,6 +37,14 @@ export interface AgentMenuDeps {
42
37
  ) => { message: string; level: string };
43
38
  emitEvent: (name: string, data: unknown) => void;
44
39
  personalAgentsDir: string;
40
+ /** Returns the runtime default max turns (undefined = unlimited). */
41
+ getDefaultMaxTurns: () => number | undefined;
42
+ /** Returns the runtime grace turns value. */
43
+ getGraceTurns: () => number;
44
+ /** Updates the runtime default max turns (undefined = unlimited). */
45
+ setDefaultMaxTurns: (n: number | undefined) => void;
46
+ /** Updates the runtime grace turns value (minimum 1). */
47
+ setGraceTurns: (n: number) => void;
45
48
  }
46
49
 
47
50
  // ---- Narrow UI context types ----
@@ -620,8 +623,8 @@ ${systemPrompt}
620
623
  async function showSettings(ctx: MenuContext) {
621
624
  const choice = await ctx.ui.select("Settings", [
622
625
  `Max concurrency (current: ${deps.manager.getMaxConcurrent()})`,
623
- `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
624
- `Grace turns (current: ${getGraceTurns()})`,
626
+ `Default max turns (current: ${deps.getDefaultMaxTurns() ?? "unlimited"})`,
627
+ `Grace turns (current: ${deps.getGraceTurns()})`,
625
628
  ]);
626
629
  if (!choice) return;
627
630
 
@@ -642,15 +645,15 @@ ${systemPrompt}
642
645
  } else if (choice.startsWith("Default max turns")) {
643
646
  const val = await ctx.ui.input(
644
647
  "Default max turns before wrap-up (0 = unlimited)",
645
- String(getDefaultMaxTurns() ?? 0),
648
+ String(deps.getDefaultMaxTurns() ?? 0),
646
649
  );
647
650
  if (val) {
648
651
  const n = parseInt(val, 10);
649
652
  if (n === 0) {
650
- setDefaultMaxTurns(undefined);
653
+ deps.setDefaultMaxTurns(undefined);
651
654
  notifyApplied(ctx, "Default max turns set to unlimited");
652
655
  } else if (n >= 1) {
653
- setDefaultMaxTurns(n);
656
+ deps.setDefaultMaxTurns(n);
654
657
  notifyApplied(ctx, `Default max turns set to ${n}`);
655
658
  } else {
656
659
  ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
@@ -659,12 +662,12 @@ ${systemPrompt}
659
662
  } else if (choice.startsWith("Grace turns")) {
660
663
  const val = await ctx.ui.input(
661
664
  "Grace turns after wrap-up steer",
662
- String(getGraceTurns()),
665
+ String(deps.getGraceTurns()),
663
666
  );
664
667
  if (val) {
665
668
  const n = parseInt(val, 10);
666
669
  if (n >= 1) {
667
- setGraceTurns(n);
670
+ deps.setGraceTurns(n);
668
671
  notifyApplied(ctx, `Grace turns set to ${n}`);
669
672
  } else {
670
673
  ctx.ui.notify("Must be a positive integer.", "warning");