@bermudi/pi-delegate 0.1.0 → 0.1.2

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.
@@ -1,6 +1,10 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
- import { DEFAULT_TOOLS, VALID_THINKING } from "./constants.ts";
3
+ import {
4
+ DEFAULT_AGENT_NAME,
5
+ DEFAULT_TOOLS,
6
+ VALID_THINKING,
7
+ } from "./constants.ts";
4
8
  import { TOOL_FACTORIES, resolveToolGroups } from "./tools.ts";
5
9
  import { configFor } from "./pool.ts";
6
10
  import { isSessionBusy } from "./tickets.ts";
@@ -14,10 +18,54 @@ import type {
14
18
  AgentConfig,
15
19
  DelegateToolCtx,
16
20
  DelegateToolResult,
21
+ ParentAgentDefaults,
17
22
  ResolvedTask,
18
23
  TaskDef,
19
24
  } from "./types.ts";
20
25
 
26
+ const PROJECT_CONTEXT_START =
27
+ "\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n";
28
+ const PROJECT_CONTEXT_END = "\n</project_context>\n";
29
+
30
+ /**
31
+ * Parent `getSystemPrompt()` is the fully assembled prompt, including the
32
+ * parent's AGENTS.md files. A delegated session resolves resources for its own
33
+ * cwd, so carrying that section across would leak global instructions and
34
+ * duplicate project context. Preserve the parent's base prompt and everything
35
+ * outside Pi's structured context section; the child ResourceLoader appends its
36
+ * own (filtered) context afterward.
37
+ *
38
+ * The AGENTS.md/CLAUDE.md content is inserted verbatim inside
39
+ * `<project_instructions>...</project_instructions>` blocks. A file that
40
+ * itself contains `\n</project_context>\n` can therefore forge an early
41
+ * closing marker. It could also include a forged `</project_instructions>` to
42
+ * balance the open tag, making the fake `</project_context>` look like the
43
+ * section end. A content-controlled marker can only ever appear *inside* the
44
+ * generated `<project_context>` block, before the real `</project_context>`
45
+ * that Pi appends after the last file. Skills escape angle brackets, the
46
+ * `appendSystemPrompt` text is added before this section, and the trailing
47
+ * `Current working directory:` line contains no such marker, so the real
48
+ * closing marker is the final raw `\n</project_context>\n` in the prompt.
49
+ * We therefore match the Pi-generated opening marker to the final matching
50
+ * closing marker, deterministically stripping the whole generated section.
51
+ */
52
+ export function stripInheritedProjectContext(
53
+ prompt: string | undefined,
54
+ ): string | undefined {
55
+ if (!prompt) return prompt;
56
+
57
+ const start = prompt.indexOf(PROJECT_CONTEXT_START);
58
+ if (start < 0) return prompt;
59
+
60
+ // The real closing marker is the final occurrence: child content cannot
61
+ // place a `</project_context>` after the one Pi generates to close the
62
+ // section.
63
+ const end = prompt.lastIndexOf(PROJECT_CONTEXT_END);
64
+ if (end < start) return prompt;
65
+
66
+ return `${prompt.slice(0, start)}${prompt.slice(end + PROJECT_CONTEXT_END.length)}`;
67
+ }
68
+
21
69
  /** Build a tool result for an error/notice with no task progress. */
22
70
  function noticeResult(
23
71
  text: string,
@@ -30,6 +78,11 @@ function noticeResult(
30
78
  };
31
79
  }
32
80
 
81
+ /** Format a task reference for error messages: one-based index with an optional caller id. */
82
+ function formatTaskRef(index: number, id: string | undefined): string {
83
+ return `Task ${index + 1}${id ? `#${id}` : ""}`;
84
+ }
85
+
33
86
  /** Pre-dispatch validation: duplicate sessionIds, sessions busy with an async
34
87
  * ticket, and unknown agent names. Returns an error result to short-circuit
35
88
  * the call, or null when all checks pass. */
@@ -65,12 +118,32 @@ export function validateTasks(
65
118
  );
66
119
  }
67
120
 
121
+ // Disallow duplicate caller-provided task ids within one dispatch.
122
+ const seenIds = new Map<string, number>();
123
+ const duplicateIds: string[] = [];
124
+ for (const [index, task] of tasks.entries()) {
125
+ if (task.id) {
126
+ if (seenIds.has(task.id)) {
127
+ duplicateIds.push(
128
+ `task ${index + 1}: duplicate id '${task.id}' — ids must be unique within one dispatch.`,
129
+ );
130
+ } else {
131
+ seenIds.set(task.id, index);
132
+ }
133
+ }
134
+ }
135
+ if (duplicateIds.length) {
136
+ return noticeResult(duplicateIds.join(" "), tasks, parentModelId);
137
+ }
138
+
68
139
  const unknown: string[] = [];
69
140
  for (const t of tasks) {
70
- if (t.agent && !agents.has(t.agent)) unknown.push(t.agent);
141
+ if (t.agent && t.agent !== DEFAULT_AGENT_NAME && !agents.has(t.agent)) {
142
+ unknown.push(t.agent);
143
+ }
71
144
  }
72
145
  if (unknown.length) {
73
- const names = [...agents.keys()];
146
+ const names = [DEFAULT_AGENT_NAME, ...agents.keys()];
74
147
  return noticeResult(
75
148
  `Unknown agent(s): ${unknown.join(", ")}. Available: ${names.join(", ") || "(none)"}. Call delegate with an empty tasks array for help.`,
76
149
  tasks,
@@ -89,6 +162,7 @@ export function resolveTasks(
89
162
  tasks: TaskDef[],
90
163
  ctx: DelegateToolCtx,
91
164
  agents: Map<string, AgentConfig>,
165
+ parentDefaults: ParentAgentDefaults,
92
166
  ): ResolvedTask[] {
93
167
  // Build parent transcript lazily — only computed once if any task uses with-parent-transcript
94
168
  let parentTranscript: string | null = null;
@@ -107,54 +181,104 @@ export function resolveTasks(
107
181
  );
108
182
  }
109
183
 
110
- const parentSystemPrompt = ctx.getSystemPrompt?.();
184
+ const parentSystemPrompt = stripInheritedProjectContext(
185
+ ctx.getSystemPrompt?.(),
186
+ );
111
187
 
112
188
  return tasks.map((t, i) => {
113
- const agent = t.agent ? agents.get(t.agent) : undefined;
189
+ const isDefaultAgent = t.agent === DEFAULT_AGENT_NAME;
190
+ const agent = t.agent && !isDefaultAgent ? agents.get(t.agent) : undefined;
114
191
  const cwd = resolveCwd(t.cwd ?? ctx.cwd, ctx.cwd);
115
192
 
116
193
  // Load settings-based overrides for this agent
117
194
  const settings = loadDelegateSettings(cwd);
118
195
  const agentOverride =
119
- t.agent && settings?.agentOverrides?.[t.agent]
196
+ t.agent && !isDefaultAgent && settings?.agentOverrides?.[t.agent]
120
197
  ? settings.agentOverrides[t.agent]
121
198
  : undefined;
122
199
 
123
200
  // 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.
201
+ // win; ad-hoc subagents inherit the parent's base prompt when Pi exposes
202
+ // it. The assembled parent project-context section was stripped above;
203
+ // the child ResourceLoader supplies context for this task's cwd.
126
204
  const pooledConfig = t.sessionId ? configFor(t.sessionId) : undefined;
205
+ const isPoolHit = pooledConfig !== undefined;
206
+ const parentNativeTools = parentDefaults.tools.filter((name) =>
207
+ Object.hasOwn(TOOL_FACTORIES, name),
208
+ );
209
+ let tools: string[] = [];
210
+ const warnings: string[] = [];
127
211
 
128
212
  // Prompt is required for fresh tasks. ResumeFrom provides context already.
129
213
  if (
130
- t.action !== "close" &&
131
- t.action !== "list" &&
214
+ t.sessionAction !== "close" &&
215
+ t.sessionAction !== "list" &&
132
216
  !t.resumeFrom &&
133
217
  !t.prompt?.trim()
134
218
  ) {
135
219
  throw new Error(
136
- `Task ${i}: prompt is required unless action is 'close'/'list' or resumeFrom is set.`,
220
+ `${formatTaskRef(i, t.id)}: prompt is required unless sessionAction is 'close'/'list' or resumeFrom is set.`,
137
221
  );
138
222
  }
139
223
 
224
+ // Resolve tools — warn about unknown tool names.
225
+ // For active pooled sessions, fall back to the frozen pooled config so
226
+ // "continue with only sessionId" works without re-supplying tools.
227
+ // Explicit overrides that don't match get rejected by acquireAgentSession.
228
+ if (t.sessionAction !== "close" && t.sessionAction !== "list") {
229
+ tools = resolveToolGroups(
230
+ t.tools ??
231
+ agentOverride?.tools ??
232
+ agent?.tools ??
233
+ (isDefaultAgent ? parentNativeTools : undefined) ??
234
+ (isPoolHit ? pooledConfig?.tools : undefined) ??
235
+ DEFAULT_TOOLS,
236
+ );
237
+ const unknownTools = tools.filter(
238
+ (name) => !Object.hasOwn(TOOL_FACTORIES, name),
239
+ );
240
+ if (unknownTools.length) {
241
+ warnings.push(
242
+ `Unknown tool(s) ignored: ${unknownTools.join(", ")}. Available: ${Object.keys(TOOL_FACTORIES).join(", ")}`,
243
+ );
244
+ }
245
+ tools = tools.filter((name) => Object.hasOwn(TOOL_FACTORIES, name));
246
+ }
247
+
140
248
  // 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 bodyparent session promptdefault. 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.
249
+ // skills + project AGENTS.md discovery (it appends them via
250
+ // _rebuildSystemPrompt), so we resolve only the *base* prompt here:
251
+ // explicit task prompt named agent bodysanitized parent prompt
252
+ // default. The resolved base is passed as the loader's customPrompt (see
253
+ // buildDelegateSession).
254
+ //
255
+ // The *requested* system prompt is what the reuse check compares against the
256
+ // frozen pooled value. A bare `{ prompt, sessionId }` omits it so the pool
257
+ // can keep using the frozen prompt even if the parent prompt changed. An
258
+ // explicit task/profile prompt is used as-is. The built-in `default` profile
259
+ // intentionally mirrors the live parent, so its requested prompt is the
260
+ // *sanitized* parent prompt — the same form that would be stored as the
261
+ // frozen base, avoiding a false mismatch when the inherited default prompt
262
+ // gets its stale tool inventory stripped.
263
+ const resolvedBasePrompt = buildSubagentSystemPrompt({
264
+ taskSystemPrompt: t.systemPrompt,
265
+ agentSystemPrompt: agent?.systemPrompt,
266
+ parentSystemPrompt,
267
+ tools,
268
+ });
148
269
  const requestedSystemPrompt = t.systemPrompt?.trim()
149
270
  ? t.systemPrompt
150
271
  : agent?.systemPrompt?.trim()
151
272
  ? agent.systemPrompt
152
- : undefined;
273
+ : isDefaultAgent
274
+ ? resolvedBasePrompt
275
+ : undefined;
153
276
  const systemPrompt = buildSubagentSystemPrompt({
154
277
  taskSystemPrompt: t.systemPrompt,
155
278
  agentSystemPrompt: agent?.systemPrompt,
156
279
  parentSystemPrompt,
157
280
  pooledSystemPrompt: pooledConfig?.systemPrompt,
281
+ tools,
158
282
  });
159
283
 
160
284
  // Build prompt — wrap with parent context if using with-parent-transcript
@@ -186,46 +310,49 @@ export function resolveTasks(
186
310
  let model: Model<Api> | undefined;
187
311
  let requestedModel: Model<Api> | undefined;
188
312
  let modelSuffix: ThinkingLevel | undefined;
189
- let tools: string[] = [];
190
313
  let thinking: ThinkingLevel = "off";
191
- const warnings: string[] = [];
192
314
 
193
- if (t.action !== "close" && t.action !== "list") {
315
+ if (t.sessionAction !== "close" && t.sessionAction !== "list") {
194
316
  // A pool hit always runs its frozen model, but an explicitly requested
195
317
  // task/profile model still has to be resolved so checkout can reject a
196
- // contradictory request rather than silently discarding it.
318
+ // contradictory request rather than silently discarding it. Naming the
319
+ // built-in `default` profile is also explicit: it requests the live
320
+ // parent model, so reuse fails clearly if the pool was frozen differently.
197
321
  if (pooledConfig) {
198
322
  const requestedModelSpec =
199
323
  t.model ??
200
- (t.agent ? (agentOverride?.model ?? agent?.model) : undefined);
324
+ (t.agent && !isDefaultAgent
325
+ ? (agentOverride?.model ?? agent?.model)
326
+ : undefined);
201
327
  if (requestedModelSpec) {
202
- requestedModel = resolveModelRequest(
328
+ const requested = resolveModelRequest(
203
329
  requestedModelSpec,
204
330
  ctx.modelRegistry,
205
331
  ctx.model,
206
- ).model;
332
+ );
333
+ requestedModel = requested.model;
334
+ modelSuffix = requested.strippedSuffix;
207
335
  if (!requestedModel) {
208
336
  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.`,
337
+ `${formatTaskRef(i, t.id)}: requested model '${requestedModelSpec}' is not available. Check provider config or remove the model field to continue the pooled session.`,
210
338
  );
211
339
  }
340
+ } else if (isDefaultAgent) {
341
+ requestedModel = ctx.model;
212
342
  }
213
343
  model = pooledConfig.model;
214
344
  } 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.
345
+ // The built-in `default` profile bypasses delegate.json and settings:
346
+ // absent a task override, it means this exact live parent Model object.
347
+ // Other tasks retain the normal task > config > frontmatter chain.
223
348
  const agentType = t.agent ?? "inline";
224
- const modelSpec = resolveModelSpec({
225
- taskModel: t.model ?? agentOverride?.model,
226
- agentType,
227
- frontmatterModel: agent?.model,
228
- });
349
+ const modelSpec = isDefaultAgent
350
+ ? t.model
351
+ : resolveModelSpec({
352
+ taskModel: t.model ?? agentOverride?.model,
353
+ agentType,
354
+ frontmatterModel: agent?.model,
355
+ });
229
356
  const resolvedRequest = modelSpec
230
357
  ? resolveModelRequest(modelSpec, ctx.modelRegistry, ctx.model)
231
358
  : undefined;
@@ -234,56 +361,46 @@ export function resolveTasks(
234
361
  // it is honored only as a last-resort thinking default (see below).
235
362
  modelSuffix = resolvedRequest?.strippedSuffix;
236
363
 
237
- // If the task or settings explicitly set a model but it couldn't resolve, fail loudly
238
- const explicitRequest = t.model ?? agentOverride?.model;
364
+ // The selected model spec is explicit regardless of whether it came
365
+ // from the task, settings, or named-agent frontmatter. If it cannot
366
+ // resolve, fail loudly instead of silently falling back to the parent.
367
+ const explicitRequest = modelSpec;
239
368
  if (explicitRequest && !resolvedModel) {
240
369
  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.`,
370
+ `${formatTaskRef(i, t.id)}: requested model '${explicitRequest}' is not available. Check provider config or remove the model field to use the parent model.`,
242
371
  );
243
372
  }
244
373
 
245
- model =
246
- resolvedModel ??
247
- findAvailableAlternative(ctx.model, ctx.modelRegistry) ??
248
- ctx.model;
374
+ model = isDefaultAgent
375
+ ? (resolvedModel ?? ctx.model)
376
+ : (resolvedModel ??
377
+ findAvailableAlternative(ctx.model, ctx.modelRegistry) ??
378
+ ctx.model);
249
379
  }
250
380
 
251
381
  if (!model) {
252
382
  throw new Error(
253
- `Task ${i}: no model available — parent session has no model set.`,
383
+ `${formatTaskRef(i, t.id)}: no model available — parent session has no model set.`,
254
384
  );
255
385
  }
256
386
 
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`.
387
+ // Resolve thinking. Precedence for most agents: explicit `thinking` >
388
+ // agent override > frontmatter > frozen pooled config > model `:level`
389
+ // suffix (last resort). The built-in `default` agent intentionally
390
+ // inverts the last two steps: model suffix beats the parent's live
391
+ // thinking, which beats the frozen pooled value. This surfaces a clear
392
+ // `config mismatch` error on reuse when the parent thinking level has
393
+ // changed, rather than silently reusing a stale frozen value. The final
394
+ // pooled fallback is reachable only when parentDefaults.thinking is
395
+ // undefined (headless parent without a thinking level).
281
396
  const thinkingRaw =
282
397
  t.thinking ??
283
398
  agentOverride?.thinking ??
284
399
  agent?.thinking ??
285
- (isPoolHit ? pooledConfig?.thinking : undefined) ??
400
+ (isPoolHit && !isDefaultAgent ? pooledConfig?.thinking : undefined) ??
286
401
  modelSuffix ??
402
+ (isDefaultAgent ? parentDefaults.thinking : undefined) ??
403
+ (isPoolHit ? pooledConfig?.thinking : undefined) ??
287
404
  "off";
288
405
  thinking = VALID_THINKING.has(thinkingRaw)
289
406
  ? (thinkingRaw as ThinkingLevel)
@@ -299,6 +416,7 @@ export function resolveTasks(
299
416
  }
300
417
  return {
301
418
  ...t,
419
+ id: t.id,
302
420
  cwd,
303
421
  systemPrompt,
304
422
  model: model!,
@@ -307,10 +425,11 @@ export function resolveTasks(
307
425
  // Empty only for close/list actions (validated above) — downstream
308
426
  // display code treats "" and absent alike (`t.prompt || …`).
309
427
  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",
428
+ // Keep the built-in selector visible in progress/results. Omitted-agent
429
+ // inline tasks retain the established `ad-hoc` label and config namespace.
430
+ agentName: isDefaultAgent
431
+ ? DEFAULT_AGENT_NAME
432
+ : (agent?.name ?? "ad-hoc"),
314
433
  warnings,
315
434
  reuseIntent: {
316
435
  model: requestedModel,