@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.
- package/README.md +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
|
@@ -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
|
+
}
|