@bermudi/pi-delegate 0.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.
@@ -0,0 +1,321 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import { DEFAULT_TOOLS, VALID_THINKING } from "./constants.ts";
4
+ import { TOOL_FACTORIES, resolveToolGroups } from "./tools.ts";
5
+ import { configFor } from "./pool.ts";
6
+ import { isSessionBusy } from "./tickets.ts";
7
+ import { buildSubagentSystemPrompt } from "./agents.ts";
8
+ import { buildParentTranscript } from "./parent-context.ts";
9
+ import { findAvailableAlternative, resolveModelRequest } from "./model.ts";
10
+ import { resolveModelSpec } from "./config.ts";
11
+ import { loadDelegateSettings } from "./settings.ts";
12
+ import { resolveCwd } from "./utils.ts";
13
+ import type {
14
+ AgentConfig,
15
+ DelegateToolCtx,
16
+ DelegateToolResult,
17
+ ResolvedTask,
18
+ TaskDef,
19
+ } from "./types.ts";
20
+
21
+ /** Build a tool result for an error/notice with no task progress. */
22
+ function noticeResult(
23
+ text: string,
24
+ tasks: TaskDef[],
25
+ parentModel: string | undefined,
26
+ ): DelegateToolResult {
27
+ return {
28
+ content: [{ type: "text", text }],
29
+ details: { tasks, results: [], progress: [], parentModel },
30
+ };
31
+ }
32
+
33
+ /** Pre-dispatch validation: duplicate sessionIds, sessions busy with an async
34
+ * ticket, and unknown agent names. Returns an error result to short-circuit
35
+ * the call, or null when all checks pass. */
36
+ export function validateTasks(
37
+ tasks: TaskDef[],
38
+ agents: Map<string, AgentConfig>,
39
+ parentModelId: string | undefined,
40
+ ): DelegateToolResult | null {
41
+ // Disallow same sessionId across multiple parallel tasks (one agent can't serve two prompts concurrently).
42
+ const sessionIds = tasks.map((t) => t.sessionId).filter(Boolean) as string[];
43
+ const duplicateSessions = sessionIds.filter(
44
+ (id, i) => sessionIds.indexOf(id) !== i,
45
+ );
46
+ if (duplicateSessions.length) {
47
+ return noticeResult(
48
+ `Duplicate sessionId(s) across tasks: ${[...new Set(duplicateSessions)].join(", ")}. Each session can only handle one task at a time.`,
49
+ tasks,
50
+ parentModelId,
51
+ );
52
+ }
53
+
54
+ // Disallow sessionIds already claimed by a running async ticket.
55
+ const busyConflicts: string[] = [];
56
+ for (const sid of sessionIds) {
57
+ const owner = isSessionBusy(sid);
58
+ if (owner) busyConflicts.push(`${sid} (ticket ${owner})`);
59
+ }
60
+ if (busyConflicts.length) {
61
+ return noticeResult(
62
+ `Session(s) already in use: ${busyConflicts.join(", ")}. Each session can only handle one task at a time.`,
63
+ tasks,
64
+ parentModelId,
65
+ );
66
+ }
67
+
68
+ const unknown: string[] = [];
69
+ for (const t of tasks) {
70
+ if (t.agent && !agents.has(t.agent)) unknown.push(t.agent);
71
+ }
72
+ if (unknown.length) {
73
+ const names = [...agents.keys()];
74
+ return noticeResult(
75
+ `Unknown agent(s): ${unknown.join(", ")}. Available: ${names.join(", ") || "(none)"}. Call delegate with an empty tasks array for help.`,
76
+ tasks,
77
+ parentModelId,
78
+ );
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ /** Resolve every task into a fully-specified `ResolvedTask`: cwd, system
85
+ * prompt, model, tools, thinking, and prompt (with optional parent-transcript
86
+ * injection). Throws on unrecoverable misconfiguration (missing prompt,
87
+ * unavailable explicit model, no model at all). */
88
+ export function resolveTasks(
89
+ tasks: TaskDef[],
90
+ ctx: DelegateToolCtx,
91
+ agents: Map<string, AgentConfig>,
92
+ ): ResolvedTask[] {
93
+ // Build parent transcript lazily — only computed once if any task uses with-parent-transcript
94
+ let parentTranscript: string | null = null;
95
+ const needsParentContext = tasks.some(
96
+ (t) => t.context === "with-parent-transcript",
97
+ );
98
+ if (needsParentContext) {
99
+ if (!ctx.sessionManager) {
100
+ throw new Error(
101
+ "context: 'with-parent-transcript' requires a persisted parent session.",
102
+ );
103
+ }
104
+ parentTranscript = buildParentTranscript(
105
+ ctx.sessionManager.getEntries(),
106
+ ctx.sessionManager.getLeafId(),
107
+ );
108
+ }
109
+
110
+ const parentSystemPrompt = ctx.getSystemPrompt?.();
111
+
112
+ return tasks.map((t, i) => {
113
+ const agent = t.agent ? agents.get(t.agent) : undefined;
114
+ const cwd = resolveCwd(t.cwd ?? ctx.cwd, ctx.cwd);
115
+
116
+ // Load settings-based overrides for this agent
117
+ const settings = loadDelegateSettings(cwd);
118
+ const agentOverride =
119
+ t.agent && settings?.agentOverrides?.[t.agent]
120
+ ? settings.agentOverrides[t.agent]
121
+ : undefined;
122
+
123
+ // Build system prompt. Explicit task prompts and named agent prompts
124
+ // win; ad-hoc subagents inherit the parent prompt when Pi exposes it,
125
+ // then get explicit skills/AGENTS.md injection below.
126
+ const pooledConfig = t.sessionId ? configFor(t.sessionId) : undefined;
127
+
128
+ // Prompt is required for fresh tasks. ResumeFrom provides context already.
129
+ if (
130
+ t.action !== "close" &&
131
+ t.action !== "list" &&
132
+ !t.resumeFrom &&
133
+ !t.prompt?.trim()
134
+ ) {
135
+ throw new Error(
136
+ `Task ${i}: prompt is required unless action is 'close'/'list' or resumeFrom is set.`,
137
+ );
138
+ }
139
+
140
+ // System prompt resolution. AgentSession's resource loader owns
141
+ // skills + AGENTS.md discovery (it appends them via _rebuildSystemPrompt),
142
+ // so we resolve only the *base* prompt here: explicit task prompt → named
143
+ // agent body → parent session prompt → default. The resolved base is
144
+ // passed as the loader's customPrompt (see buildDelegateSession).
145
+ // Keep explicit intent separate: a bare `{ prompt, sessionId }` continues
146
+ // the frozen prompt even if the parent prompt has since changed, while an
147
+ // explicit task/profile prompt must not be silently ignored on reuse.
148
+ const requestedSystemPrompt = t.systemPrompt?.trim()
149
+ ? t.systemPrompt
150
+ : agent?.systemPrompt?.trim()
151
+ ? agent.systemPrompt
152
+ : undefined;
153
+ const systemPrompt = buildSubagentSystemPrompt({
154
+ taskSystemPrompt: t.systemPrompt,
155
+ agentSystemPrompt: agent?.systemPrompt,
156
+ parentSystemPrompt,
157
+ pooledSystemPrompt: pooledConfig?.systemPrompt,
158
+ });
159
+
160
+ // Build prompt — wrap with parent context if using with-parent-transcript
161
+ let prompt =
162
+ t.prompt ||
163
+ (t.resumeFrom
164
+ ? "Continue from where you left off. Pick up the task and keep going."
165
+ : t.prompt);
166
+ const parentCtx =
167
+ t.context === "with-parent-transcript" && parentTranscript
168
+ ? parentTranscript
169
+ : null;
170
+ if (parentCtx) {
171
+ prompt = [
172
+ "<parent-session>",
173
+ "The following is the conversation from the parent session.",
174
+ "Read this for context, then execute the task below.",
175
+ "Do not continue the parent conversation or respond to prior messages.",
176
+ "",
177
+ parentCtx,
178
+ "</parent-session>",
179
+ "",
180
+ "## Task",
181
+ prompt,
182
+ ].join("\n");
183
+ }
184
+
185
+ // Resolve model — explicit specs must resolve or fail; omitted falls back to parent
186
+ let model: Model<Api> | undefined;
187
+ let requestedModel: Model<Api> | undefined;
188
+ let modelSuffix: ThinkingLevel | undefined;
189
+ let tools: string[] = [];
190
+ let thinking: ThinkingLevel = "off";
191
+ const warnings: string[] = [];
192
+
193
+ if (t.action !== "close" && t.action !== "list") {
194
+ // A pool hit always runs its frozen model, but an explicitly requested
195
+ // task/profile model still has to be resolved so checkout can reject a
196
+ // contradictory request rather than silently discarding it.
197
+ if (pooledConfig) {
198
+ const requestedModelSpec =
199
+ t.model ??
200
+ (t.agent ? (agentOverride?.model ?? agent?.model) : undefined);
201
+ if (requestedModelSpec) {
202
+ requestedModel = resolveModelRequest(
203
+ requestedModelSpec,
204
+ ctx.modelRegistry,
205
+ ctx.model,
206
+ ).model;
207
+ if (!requestedModel) {
208
+ throw new Error(
209
+ `Task ${i}: requested model '${requestedModelSpec}' is not available. Check provider config or remove the model field to continue the pooled session.`,
210
+ );
211
+ }
212
+ }
213
+ model = pooledConfig.model;
214
+ } else {
215
+ // Resolve an explicit model spec (precedence: task > session > config >
216
+ // frontmatter). resolveModelSpec returns undefined when none is set, so
217
+ // we skip model re-resolution entirely — passing the parent's composite id
218
+ // (e.g. OpenRouter's "deepseek/deepseek-v4-flash") would split on "/"
219
+ // and misroute to the upstream provider. Leaving resolvedModel
220
+ // undefined also lets findAvailableAlternative run below: it returns
221
+ // ctx.model as-is when it has auth, or swaps to an authenticated
222
+ // same-id alternative when the parent's provider lost auth.
223
+ const agentType = t.agent ?? "inline";
224
+ const modelSpec = resolveModelSpec({
225
+ taskModel: t.model ?? agentOverride?.model,
226
+ agentType,
227
+ frontmatterModel: agent?.model,
228
+ });
229
+ const resolvedRequest = modelSpec
230
+ ? resolveModelRequest(modelSpec, ctx.modelRegistry, ctx.model)
231
+ : undefined;
232
+ const resolvedModel = resolvedRequest?.model;
233
+ // A Pi-style `:level` suffix was tolerated so the reference resolves;
234
+ // it is honored only as a last-resort thinking default (see below).
235
+ modelSuffix = resolvedRequest?.strippedSuffix;
236
+
237
+ // If the task or settings explicitly set a model but it couldn't resolve, fail loudly
238
+ const explicitRequest = t.model ?? agentOverride?.model;
239
+ if (explicitRequest && !resolvedModel) {
240
+ throw new Error(
241
+ `Task ${i}: requested model '${explicitRequest}' is not available. Check provider config or remove the model field to use the parent model.`,
242
+ );
243
+ }
244
+
245
+ model =
246
+ resolvedModel ??
247
+ findAvailableAlternative(ctx.model, ctx.modelRegistry) ??
248
+ ctx.model;
249
+ }
250
+
251
+ if (!model) {
252
+ throw new Error(
253
+ `Task ${i}: no model available — parent session has no model set.`,
254
+ );
255
+ }
256
+
257
+ // Resolve tools — warn about unknown tool names.
258
+ // For active pooled sessions, fall back to the frozen pooled config so
259
+ // "continue with only sessionId" works without re-supplying tools.
260
+ // Explicit overrides that don't match get rejected by acquireAgentSession.
261
+ const isPoolHit = pooledConfig !== undefined;
262
+ tools = resolveToolGroups(
263
+ t.tools ??
264
+ agentOverride?.tools ??
265
+ agent?.tools ??
266
+ (isPoolHit ? pooledConfig?.tools : undefined) ??
267
+ DEFAULT_TOOLS,
268
+ );
269
+ const unknownTools = tools.filter((name) => !(name in TOOL_FACTORIES));
270
+ if (unknownTools.length) {
271
+ warnings.push(
272
+ `Unknown tool(s) ignored: ${unknownTools.join(", ")}. Available: ${Object.keys(TOOL_FACTORIES).join(", ")}`,
273
+ );
274
+ }
275
+
276
+ // Resolve thinking. Precedence: task field → agent override → agent
277
+ // frontmatter → frozen pooled config → a Pi-style `:level` model suffix
278
+ // (honored only as a last-resort default, so a model-emitted `claude:max`
279
+ // runs at max when nothing else sets thinking). The suffix is lowest on
280
+ // purpose: an agent author's `thinking: low` must beat `model: x:max`.
281
+ const thinkingRaw =
282
+ t.thinking ??
283
+ agentOverride?.thinking ??
284
+ agent?.thinking ??
285
+ (isPoolHit ? pooledConfig?.thinking : undefined) ??
286
+ modelSuffix ??
287
+ "off";
288
+ thinking = VALID_THINKING.has(thinkingRaw)
289
+ ? (thinkingRaw as ThinkingLevel)
290
+ : "off";
291
+ // The suffix was set but a higher-precedence source won — surface it so
292
+ // the caller knows the `:level` had no effect (rather than silently
293
+ // discarding the intent).
294
+ if (modelSuffix && thinkingRaw !== modelSuffix) {
295
+ warnings.push(
296
+ `Model ':${modelSuffix}' suffix ignored — thinking resolved to '${thinking}' from a higher-precedence source.`,
297
+ );
298
+ }
299
+ }
300
+ return {
301
+ ...t,
302
+ cwd,
303
+ systemPrompt,
304
+ model: model!,
305
+ tools,
306
+ thinking,
307
+ // Empty only for close/list actions (validated above) — downstream
308
+ // display code treats "" and absent alike (`t.prompt || …`).
309
+ prompt: prompt ?? "",
310
+ // Display label for ad-hoc subagents (no named profile). NOTE: the
311
+ // config-namespace key at resolveModelSpec stays "inline" — that's a
312
+ // delegate.json/settings contract, not a display string (friction #4).
313
+ agentName: agent?.name ?? "ad-hoc",
314
+ warnings,
315
+ reuseIntent: {
316
+ model: requestedModel,
317
+ systemPrompt: requestedSystemPrompt,
318
+ },
319
+ };
320
+ });
321
+ }