@bermudi/pi-delegate 0.1.0 → 0.1.1
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 +52 -8
- package/agents.ts +13 -2
- package/constants.ts +3 -0
- package/delegate.ts +13 -1
- package/dispatch.ts +33 -3
- package/extension.ts +66 -2
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/lifecycle.ts +220 -85
- package/manual.ts +10 -3
- package/model.ts +3 -4
- package/package.json +22 -20
- package/pool.ts +169 -51
- package/render-branches.ts +21 -3
- package/runner.ts +18 -9
- package/schema.ts +58 -26
- package/status.ts +203 -0
- package/task-resolution.ts +127 -44
- package/types.ts +8 -0
package/status.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-activity visibility: footer status, settle warning, and
|
|
3
|
+
* session-replacement guards for live async tickets.
|
|
4
|
+
*
|
|
5
|
+
* Async tickets keep subagents running after the parent turn settles, but pi
|
|
6
|
+
* renders an idle session — nothing tells the human work is still in flight,
|
|
7
|
+
* and quitting silently kills it. This module owns the three signals that
|
|
8
|
+
* close that gap:
|
|
9
|
+
*
|
|
10
|
+
* 1. A persistent footer status (`ctx.ui.setStatus`) while any ticket is
|
|
11
|
+
* active — the only always-on indicator that background subagents exist.
|
|
12
|
+
* 2. A one-shot warning notification at the first `agent_settled` with each
|
|
13
|
+
* active ticket — the moment a user is most likely to assume everything
|
|
14
|
+
* is done and close the session.
|
|
15
|
+
* 3. A confirm guard on the session-replacement paths pi lets extensions
|
|
16
|
+
* cancel (`session_before_switch`, `session_before_fork`).
|
|
17
|
+
*
|
|
18
|
+
* Quit (Ctrl+C×2 / Ctrl+D / /quit) and /reload CANNOT be intercepted from an
|
|
19
|
+
* extension — `session_shutdown` is advisory, not cancellable. The footer
|
|
20
|
+
* status plus the exit/reload trace in extension.ts are the mitigations
|
|
21
|
+
* there.
|
|
22
|
+
*/
|
|
23
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { ticketRegistry } from "./tickets.ts";
|
|
25
|
+
import type { AsyncTicket } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
const STATUS_KEY = "delegate";
|
|
28
|
+
|
|
29
|
+
export interface ActiveTicketSummary {
|
|
30
|
+
/** Tickets in a non-terminal state (running or cancelling). */
|
|
31
|
+
tickets: AsyncTicket[];
|
|
32
|
+
/** Progress rows still executing or queued across active tickets. */
|
|
33
|
+
activeSubagents: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Snapshot the live background work from the ticket registry. */
|
|
37
|
+
export function activeTicketSummary(): ActiveTicketSummary {
|
|
38
|
+
const tickets: AsyncTicket[] = [];
|
|
39
|
+
let activeSubagents = 0;
|
|
40
|
+
for (const ticket of ticketRegistry.values()) {
|
|
41
|
+
if (ticket.status !== "running" && ticket.status !== "cancelling") continue;
|
|
42
|
+
tickets.push(ticket);
|
|
43
|
+
activeSubagents += ticket.progress.filter(
|
|
44
|
+
(p) => p.status === "running" || p.status === "pending",
|
|
45
|
+
).length;
|
|
46
|
+
}
|
|
47
|
+
return { tickets, activeSubagents };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function plural(n: number, noun: string): string {
|
|
51
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Footer status text, or undefined when nothing is active. */
|
|
55
|
+
export function buildStatusText(
|
|
56
|
+
summary: ActiveTicketSummary,
|
|
57
|
+
): string | undefined {
|
|
58
|
+
const { tickets, activeSubagents } = summary;
|
|
59
|
+
if (tickets.length === 0) return undefined;
|
|
60
|
+
// Wind-down window: tasks have settled but the ticket has not flipped to a
|
|
61
|
+
// terminal status yet. "settling" is more honest than "0 subagents".
|
|
62
|
+
if (activeSubagents === 0) {
|
|
63
|
+
return tickets.length === 1
|
|
64
|
+
? `⏳ ${tickets[0]!.id} settling…`
|
|
65
|
+
: `⏳ ${plural(tickets.length, "ticket")} settling…`;
|
|
66
|
+
}
|
|
67
|
+
return tickets.length === 1
|
|
68
|
+
? `⏳ ${plural(activeSubagents, "subagent")} · ${tickets[0]!.id}`
|
|
69
|
+
: `⏳ ${plural(activeSubagents, "subagent")} · ${plural(tickets.length, "ticket")}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Most recent context with UI access. Refreshed by every sync call that
|
|
73
|
+
* receives a ctx, so event-driven updates can reach the footer without a
|
|
74
|
+
* direct ctx of their own. Single runtime per process — a replaced runtime
|
|
75
|
+
* refreshes this on its first delegate call or session event. */
|
|
76
|
+
let lastCtx: ExtensionContext | undefined;
|
|
77
|
+
/** Last text pushed to the footer — setStatus triggers a render, so dedupe. */
|
|
78
|
+
let lastStatusText: string | undefined;
|
|
79
|
+
/** Tickets already warned about at settle. Pruned to active tickets on sync. */
|
|
80
|
+
const settledWarnedTicketIds = new Set<string>();
|
|
81
|
+
|
|
82
|
+
/** Recompute the footer status from the registry and push it when changed.
|
|
83
|
+
* Called on every ticket lifecycle mutation (create, progress, complete,
|
|
84
|
+
* cancel); event-driven only — no timers, so the text never goes stale
|
|
85
|
+
* (counts are the only content). */
|
|
86
|
+
export function syncDelegateStatus(ctx?: ExtensionContext): void {
|
|
87
|
+
if (ctx) lastCtx = ctx;
|
|
88
|
+
|
|
89
|
+
const summary = activeTicketSummary();
|
|
90
|
+
const text = buildStatusText(summary);
|
|
91
|
+
|
|
92
|
+
if (settledWarnedTicketIds.size) {
|
|
93
|
+
const activeIds = new Set(summary.tickets.map((t) => t.id));
|
|
94
|
+
for (const id of settledWarnedTicketIds) {
|
|
95
|
+
if (!activeIds.has(id)) settledWarnedTicketIds.delete(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!lastCtx || text === lastStatusText) return;
|
|
100
|
+
try {
|
|
101
|
+
// The `ui` getter itself throws on a stale ctx — pi invalidates the old
|
|
102
|
+
// runtime on session replacement, and an unwinding ticket can race the
|
|
103
|
+
// teardown. A footer update must never crash the host.
|
|
104
|
+
lastCtx.ui.setStatus(STATUS_KEY, text);
|
|
105
|
+
lastStatusText = text;
|
|
106
|
+
} catch {
|
|
107
|
+
// Drop the stale ctx; the next live event re-caches a fresh one.
|
|
108
|
+
lastCtx = undefined;
|
|
109
|
+
lastStatusText = undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Drop the cached ctx and warn-set on session shutdown — the runtime is
|
|
114
|
+
* about to be invalidated, and any post-teardown sync (e.g. an aborted
|
|
115
|
+
* ticket unwinding) must become a no-op instead of touching a stale ctx. */
|
|
116
|
+
export function clearDelegateStatusContext(): void {
|
|
117
|
+
lastCtx = undefined;
|
|
118
|
+
lastStatusText = undefined;
|
|
119
|
+
settledWarnedTicketIds.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Warn once per ticket at the first agent_settled with that ticket active —
|
|
123
|
+
* the "looks idle but isn't" moment. The persistent footer status carries
|
|
124
|
+
* the information from then on, so later settles stay quiet. */
|
|
125
|
+
export function notifyActiveTicketsOnSettled(ctx: ExtensionContext): void {
|
|
126
|
+
lastCtx = ctx;
|
|
127
|
+
const summary = activeTicketSummary();
|
|
128
|
+
const fresh = summary.tickets.filter(
|
|
129
|
+
(t) => !settledWarnedTicketIds.has(t.id),
|
|
130
|
+
);
|
|
131
|
+
if (!fresh.length) return;
|
|
132
|
+
|
|
133
|
+
const subagents = fresh.reduce(
|
|
134
|
+
(n, t) =>
|
|
135
|
+
n +
|
|
136
|
+
t.progress.filter((p) => p.status === "running" || p.status === "pending")
|
|
137
|
+
.length,
|
|
138
|
+
0,
|
|
139
|
+
);
|
|
140
|
+
const detail =
|
|
141
|
+
fresh.length === 1
|
|
142
|
+
? `(ticket ${fresh[0]!.id})`
|
|
143
|
+
: `across ${plural(fresh.length, "ticket")} (${fresh.map((t) => t.id).join(", ")})`;
|
|
144
|
+
try {
|
|
145
|
+
ctx.ui.notify(
|
|
146
|
+
`⏳ ${plural(subagents, "background subagent")} still running ${detail} — quitting pi aborts them`,
|
|
147
|
+
"warning",
|
|
148
|
+
);
|
|
149
|
+
for (const t of fresh) settledWarnedTicketIds.add(t.id);
|
|
150
|
+
} catch {
|
|
151
|
+
// Stale or headless ctx: drop it so the next live event re-caches one.
|
|
152
|
+
lastCtx = undefined;
|
|
153
|
+
lastStatusText = undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Block a session replacement (switch/fork) while background subagents are
|
|
158
|
+
* live, unless the human confirms. Returns `{ cancel: true }` to abort the
|
|
159
|
+
* replacement, undefined to let it proceed. Headless contexts (no dialog
|
|
160
|
+
* capability) are never blocked — automation must not deadlock on a
|
|
161
|
+
* confirm it cannot answer. */
|
|
162
|
+
export async function guardSessionReplacement(
|
|
163
|
+
ctx: ExtensionContext,
|
|
164
|
+
action: "switch" | "fork",
|
|
165
|
+
): Promise<{ cancel: true } | undefined> {
|
|
166
|
+
lastCtx = ctx;
|
|
167
|
+
const summary = activeTicketSummary();
|
|
168
|
+
if (!summary.tickets.length || !ctx.hasUI) return undefined;
|
|
169
|
+
|
|
170
|
+
const ids = summary.tickets.map((t) => t.id).join(", ");
|
|
171
|
+
const verb =
|
|
172
|
+
action === "switch" ? "Switching sessions" : "Forking this session";
|
|
173
|
+
const proceed = await ctx.ui.confirm(
|
|
174
|
+
"Background subagents still running",
|
|
175
|
+
`${plural(summary.activeSubagents, "subagent")} (${ids}) still working. ` +
|
|
176
|
+
`${verb} aborts them — work already done is not rolled back. Continue anyway?`,
|
|
177
|
+
);
|
|
178
|
+
return proceed ? undefined : { cancel: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One-line description of live work for shutdown traces. */
|
|
182
|
+
export function describeActiveTickets(
|
|
183
|
+
summary: ActiveTicketSummary = activeTicketSummary(),
|
|
184
|
+
): string {
|
|
185
|
+
const ids = summary.tickets.map((t) => t.id).join(", ");
|
|
186
|
+
const agents = [
|
|
187
|
+
...new Set(
|
|
188
|
+
summary.tickets.flatMap((t) =>
|
|
189
|
+
t.progress
|
|
190
|
+
.filter((p) => p.status === "running" || p.status === "pending")
|
|
191
|
+
.map((p) => p.agent),
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
];
|
|
195
|
+
const agentList = agents.length ? ` [${agents.join(", ")}]` : "";
|
|
196
|
+
return `${plural(summary.activeSubagents, "background subagent")}${agentList} (ticket${summary.tickets.length === 1 ? "" : "s"}: ${ids})`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function _resetDelegateStatusForTesting(): void {
|
|
200
|
+
lastCtx = undefined;
|
|
201
|
+
lastStatusText = undefined;
|
|
202
|
+
settledWarnedTicketIds.clear();
|
|
203
|
+
}
|
package/task-resolution.ts
CHANGED
|
@@ -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 {
|
|
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,62 @@ 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` would otherwise terminate the
|
|
41
|
+
* scan early and leak the remainder of the parent context (P1). We therefore
|
|
42
|
+
* treat a candidate closing marker as valid only when it is *outside* any
|
|
43
|
+
* open `<project_instructions>` block — i.e. the first `PROJECT_CONTEXT_END`
|
|
44
|
+
* after `start` that is not nested. That matches the generated section's
|
|
45
|
+
* final closing marker while ignoring embedded fakes. A trailing
|
|
46
|
+
* `</project_context>` in post-context prompt text (skills/cwd) would also be
|
|
47
|
+
* outside, but such text is controlled by Pi and far less likely; picking the
|
|
48
|
+
* first outside marker preserves trailing prompt content instead of
|
|
49
|
+
* over-consuming it.
|
|
50
|
+
*/
|
|
51
|
+
export function stripInheritedProjectContext(
|
|
52
|
+
prompt: string | undefined,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
if (!prompt) return prompt;
|
|
55
|
+
const start = prompt.indexOf(PROJECT_CONTEXT_START);
|
|
56
|
+
if (start < 0) return prompt;
|
|
57
|
+
let searchFrom = start + PROJECT_CONTEXT_START.length;
|
|
58
|
+
let end = -1;
|
|
59
|
+
while (true) {
|
|
60
|
+
const candidate = prompt.indexOf(PROJECT_CONTEXT_END, searchFrom);
|
|
61
|
+
if (candidate < 0) break;
|
|
62
|
+
const before = prompt.slice(start, candidate);
|
|
63
|
+
const openCount = (before.match(/<project_instructions/g) || []).length;
|
|
64
|
+
const closeCount = (before.match(/<\/project_instructions>/g) || []).length;
|
|
65
|
+
if (openCount > closeCount) {
|
|
66
|
+
// Inside a file block — embedded fake, skip it.
|
|
67
|
+
searchFrom = candidate + PROJECT_CONTEXT_END.length;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
end = candidate;
|
|
71
|
+
break; // first valid outside block is the true closing
|
|
72
|
+
}
|
|
73
|
+
if (end < 0) return prompt;
|
|
74
|
+
return `${prompt.slice(0, start)}${prompt.slice(end + PROJECT_CONTEXT_END.length)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
21
77
|
/** Build a tool result for an error/notice with no task progress. */
|
|
22
78
|
function noticeResult(
|
|
23
79
|
text: string,
|
|
@@ -67,10 +123,12 @@ export function validateTasks(
|
|
|
67
123
|
|
|
68
124
|
const unknown: string[] = [];
|
|
69
125
|
for (const t of tasks) {
|
|
70
|
-
if (t.agent && !agents.has(t.agent))
|
|
126
|
+
if (t.agent && t.agent !== DEFAULT_AGENT_NAME && !agents.has(t.agent)) {
|
|
127
|
+
unknown.push(t.agent);
|
|
128
|
+
}
|
|
71
129
|
}
|
|
72
130
|
if (unknown.length) {
|
|
73
|
-
const names = [...agents.keys()];
|
|
131
|
+
const names = [DEFAULT_AGENT_NAME, ...agents.keys()];
|
|
74
132
|
return noticeResult(
|
|
75
133
|
`Unknown agent(s): ${unknown.join(", ")}. Available: ${names.join(", ") || "(none)"}. Call delegate with an empty tasks array for help.`,
|
|
76
134
|
tasks,
|
|
@@ -89,6 +147,7 @@ export function resolveTasks(
|
|
|
89
147
|
tasks: TaskDef[],
|
|
90
148
|
ctx: DelegateToolCtx,
|
|
91
149
|
agents: Map<string, AgentConfig>,
|
|
150
|
+
parentDefaults: ParentAgentDefaults,
|
|
92
151
|
): ResolvedTask[] {
|
|
93
152
|
// Build parent transcript lazily — only computed once if any task uses with-parent-transcript
|
|
94
153
|
let parentTranscript: string | null = null;
|
|
@@ -107,22 +166,26 @@ export function resolveTasks(
|
|
|
107
166
|
);
|
|
108
167
|
}
|
|
109
168
|
|
|
110
|
-
const parentSystemPrompt =
|
|
169
|
+
const parentSystemPrompt = stripInheritedProjectContext(
|
|
170
|
+
ctx.getSystemPrompt?.(),
|
|
171
|
+
);
|
|
111
172
|
|
|
112
173
|
return tasks.map((t, i) => {
|
|
113
|
-
const
|
|
174
|
+
const isDefaultAgent = t.agent === DEFAULT_AGENT_NAME;
|
|
175
|
+
const agent = t.agent && !isDefaultAgent ? agents.get(t.agent) : undefined;
|
|
114
176
|
const cwd = resolveCwd(t.cwd ?? ctx.cwd, ctx.cwd);
|
|
115
177
|
|
|
116
178
|
// Load settings-based overrides for this agent
|
|
117
179
|
const settings = loadDelegateSettings(cwd);
|
|
118
180
|
const agentOverride =
|
|
119
|
-
t.agent && settings?.agentOverrides?.[t.agent]
|
|
181
|
+
t.agent && !isDefaultAgent && settings?.agentOverrides?.[t.agent]
|
|
120
182
|
? settings.agentOverrides[t.agent]
|
|
121
183
|
: undefined;
|
|
122
184
|
|
|
123
185
|
// Build system prompt. Explicit task prompts and named agent prompts
|
|
124
|
-
// win; ad-hoc subagents inherit the parent prompt when Pi exposes
|
|
125
|
-
//
|
|
186
|
+
// win; ad-hoc subagents inherit the parent's base prompt when Pi exposes
|
|
187
|
+
// it. The assembled parent project-context section was stripped above;
|
|
188
|
+
// the child ResourceLoader supplies context for this task's cwd.
|
|
126
189
|
const pooledConfig = t.sessionId ? configFor(t.sessionId) : undefined;
|
|
127
190
|
|
|
128
191
|
// Prompt is required for fresh tasks. ResumeFrom provides context already.
|
|
@@ -138,10 +201,11 @@ export function resolveTasks(
|
|
|
138
201
|
}
|
|
139
202
|
|
|
140
203
|
// System prompt resolution. AgentSession's resource loader owns
|
|
141
|
-
// skills + AGENTS.md discovery (it appends them via
|
|
142
|
-
// so we resolve only the *base* prompt here:
|
|
143
|
-
//
|
|
144
|
-
// passed as the loader's customPrompt (see
|
|
204
|
+
// skills + project AGENTS.md discovery (it appends them via
|
|
205
|
+
// _rebuildSystemPrompt), so we resolve only the *base* prompt here:
|
|
206
|
+
// explicit task prompt → named agent body → sanitized parent prompt →
|
|
207
|
+
// default. The resolved base is passed as the loader's customPrompt (see
|
|
208
|
+
// buildDelegateSession).
|
|
145
209
|
// Keep explicit intent separate: a bare `{ prompt, sessionId }` continues
|
|
146
210
|
// the frozen prompt even if the parent prompt has since changed, while an
|
|
147
211
|
// explicit task/profile prompt must not be silently ignored on reuse.
|
|
@@ -149,7 +213,9 @@ export function resolveTasks(
|
|
|
149
213
|
? t.systemPrompt
|
|
150
214
|
: agent?.systemPrompt?.trim()
|
|
151
215
|
? agent.systemPrompt
|
|
152
|
-
:
|
|
216
|
+
: isDefaultAgent
|
|
217
|
+
? parentSystemPrompt
|
|
218
|
+
: undefined;
|
|
153
219
|
const systemPrompt = buildSubagentSystemPrompt({
|
|
154
220
|
taskSystemPrompt: t.systemPrompt,
|
|
155
221
|
agentSystemPrompt: agent?.systemPrompt,
|
|
@@ -193,39 +259,44 @@ export function resolveTasks(
|
|
|
193
259
|
if (t.action !== "close" && t.action !== "list") {
|
|
194
260
|
// A pool hit always runs its frozen model, but an explicitly requested
|
|
195
261
|
// task/profile model still has to be resolved so checkout can reject a
|
|
196
|
-
// contradictory request rather than silently discarding it.
|
|
262
|
+
// contradictory request rather than silently discarding it. Naming the
|
|
263
|
+
// built-in `default` profile is also explicit: it requests the live
|
|
264
|
+
// parent model, so reuse fails clearly if the pool was frozen differently.
|
|
197
265
|
if (pooledConfig) {
|
|
198
266
|
const requestedModelSpec =
|
|
199
267
|
t.model ??
|
|
200
|
-
(t.agent
|
|
268
|
+
(t.agent && !isDefaultAgent
|
|
269
|
+
? (agentOverride?.model ?? agent?.model)
|
|
270
|
+
: undefined);
|
|
201
271
|
if (requestedModelSpec) {
|
|
202
|
-
|
|
272
|
+
const requested = resolveModelRequest(
|
|
203
273
|
requestedModelSpec,
|
|
204
274
|
ctx.modelRegistry,
|
|
205
275
|
ctx.model,
|
|
206
|
-
)
|
|
276
|
+
);
|
|
277
|
+
requestedModel = requested.model;
|
|
278
|
+
modelSuffix = requested.strippedSuffix;
|
|
207
279
|
if (!requestedModel) {
|
|
208
280
|
throw new Error(
|
|
209
281
|
`Task ${i}: requested model '${requestedModelSpec}' is not available. Check provider config or remove the model field to continue the pooled session.`,
|
|
210
282
|
);
|
|
211
283
|
}
|
|
284
|
+
} else if (isDefaultAgent) {
|
|
285
|
+
requestedModel = ctx.model;
|
|
212
286
|
}
|
|
213
287
|
model = pooledConfig.model;
|
|
214
288
|
} else {
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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.
|
|
289
|
+
// The built-in `default` profile bypasses delegate.json and settings:
|
|
290
|
+
// absent a task override, it means this exact live parent Model object.
|
|
291
|
+
// Other tasks retain the normal task > config > frontmatter chain.
|
|
223
292
|
const agentType = t.agent ?? "inline";
|
|
224
|
-
const modelSpec =
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
293
|
+
const modelSpec = isDefaultAgent
|
|
294
|
+
? t.model
|
|
295
|
+
: resolveModelSpec({
|
|
296
|
+
taskModel: t.model ?? agentOverride?.model,
|
|
297
|
+
agentType,
|
|
298
|
+
frontmatterModel: agent?.model,
|
|
299
|
+
});
|
|
229
300
|
const resolvedRequest = modelSpec
|
|
230
301
|
? resolveModelRequest(modelSpec, ctx.modelRegistry, ctx.model)
|
|
231
302
|
: undefined;
|
|
@@ -242,10 +313,11 @@ export function resolveTasks(
|
|
|
242
313
|
);
|
|
243
314
|
}
|
|
244
315
|
|
|
245
|
-
model =
|
|
246
|
-
resolvedModel ??
|
|
247
|
-
|
|
248
|
-
|
|
316
|
+
model = isDefaultAgent
|
|
317
|
+
? (resolvedModel ?? ctx.model)
|
|
318
|
+
: (resolvedModel ??
|
|
319
|
+
findAvailableAlternative(ctx.model, ctx.modelRegistry) ??
|
|
320
|
+
ctx.model);
|
|
249
321
|
}
|
|
250
322
|
|
|
251
323
|
if (!model) {
|
|
@@ -259,10 +331,14 @@ export function resolveTasks(
|
|
|
259
331
|
// "continue with only sessionId" works without re-supplying tools.
|
|
260
332
|
// Explicit overrides that don't match get rejected by acquireAgentSession.
|
|
261
333
|
const isPoolHit = pooledConfig !== undefined;
|
|
334
|
+
const parentNativeTools = parentDefaults.tools.filter(
|
|
335
|
+
(name) => name in TOOL_FACTORIES,
|
|
336
|
+
);
|
|
262
337
|
tools = resolveToolGroups(
|
|
263
338
|
t.tools ??
|
|
264
339
|
agentOverride?.tools ??
|
|
265
340
|
agent?.tools ??
|
|
341
|
+
(isDefaultAgent ? parentNativeTools : undefined) ??
|
|
266
342
|
(isPoolHit ? pooledConfig?.tools : undefined) ??
|
|
267
343
|
DEFAULT_TOOLS,
|
|
268
344
|
);
|
|
@@ -273,17 +349,23 @@ export function resolveTasks(
|
|
|
273
349
|
);
|
|
274
350
|
}
|
|
275
351
|
|
|
276
|
-
// Resolve thinking. Precedence
|
|
277
|
-
// frontmatter
|
|
278
|
-
// (
|
|
279
|
-
//
|
|
280
|
-
//
|
|
352
|
+
// Resolve thinking. Precedence for most agents: explicit `thinking` >
|
|
353
|
+
// agent override > frontmatter > frozen pooled config > model `:level`
|
|
354
|
+
// suffix (last resort). The built-in `default` agent intentionally
|
|
355
|
+
// inverts the last two steps: model suffix beats the parent's live
|
|
356
|
+
// thinking, which beats the frozen pooled value. This surfaces a clear
|
|
357
|
+
// `config mismatch` error on reuse when the parent thinking level has
|
|
358
|
+
// changed, rather than silently reusing a stale frozen value. The final
|
|
359
|
+
// pooled fallback is reachable only when parentDefaults.thinking is
|
|
360
|
+
// undefined (headless parent without a thinking level).
|
|
281
361
|
const thinkingRaw =
|
|
282
362
|
t.thinking ??
|
|
283
363
|
agentOverride?.thinking ??
|
|
284
364
|
agent?.thinking ??
|
|
285
|
-
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
365
|
+
(isPoolHit && !isDefaultAgent ? pooledConfig?.thinking : undefined) ??
|
|
286
366
|
modelSuffix ??
|
|
367
|
+
(isDefaultAgent ? parentDefaults.thinking : undefined) ??
|
|
368
|
+
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
287
369
|
"off";
|
|
288
370
|
thinking = VALID_THINKING.has(thinkingRaw)
|
|
289
371
|
? (thinkingRaw as ThinkingLevel)
|
|
@@ -307,10 +389,11 @@ export function resolveTasks(
|
|
|
307
389
|
// Empty only for close/list actions (validated above) — downstream
|
|
308
390
|
// display code treats "" and absent alike (`t.prompt || …`).
|
|
309
391
|
prompt: prompt ?? "",
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
392
|
+
// Keep the built-in selector visible in progress/results. Omitted-agent
|
|
393
|
+
// inline tasks retain the established `ad-hoc` label and config namespace.
|
|
394
|
+
agentName: isDefaultAgent
|
|
395
|
+
? DEFAULT_AGENT_NAME
|
|
396
|
+
: (agent?.name ?? "ad-hoc"),
|
|
314
397
|
warnings,
|
|
315
398
|
reuseIntent: {
|
|
316
399
|
model: requestedModel,
|
package/types.ts
CHANGED
|
@@ -65,6 +65,14 @@ export interface AsyncTicket {
|
|
|
65
65
|
waiters?: TicketWaiter[];
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Live parent settings captured when a delegate call starts. The built-in
|
|
69
|
+
* `default` profile mirrors these settings, limited to tools delegate can
|
|
70
|
+
* safely recreate without loading the parent's extensions. */
|
|
71
|
+
export interface ParentAgentDefaults {
|
|
72
|
+
thinking: ThinkingLevel;
|
|
73
|
+
tools: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
export interface ReuseIntent {
|
|
69
77
|
/** Explicit model requested by this call/profile; omitted means use frozen. */
|
|
70
78
|
model?: Model<Api>;
|