@arhen/pi-core-subagent 1.3.42 → 1.3.43

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.42",
3
+ "version": "1.3.43",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/format.ts CHANGED
@@ -242,8 +242,9 @@ export function makeSummary(run: RunSnapshot): string {
242
242
  // Edges are named so the leader can compare what it delegated against what came back.
243
243
  const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
244
244
  const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
245
+ const swap = task.modelNote ? `\nModel: ${task.modelNote}` : "";
245
246
  lines.push(
246
- `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
247
+ `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${swap}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
247
248
  );
248
249
  }
249
250
  // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
@@ -265,9 +266,10 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
265
266
  // 403, wrong persona) that override is the likeliest cause, so it has to be
266
267
  // in the notice, not only in the run summary the leader may never read.
267
268
  const src = task.agentFile ? `\nAgent file: ${task.agentFile}${task.model ? ` (model ${task.model})` : ""}` : "";
269
+ const swap = task.modelNote ? `\nModel: ${task.modelNote}` : "";
268
270
  return [
269
271
  `Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}${wt}`,
270
- `Goal: ${goal}${src}`,
272
+ `Goal: ${goal}${src}${swap}`,
271
273
  isStartupFailure(task, kind)
272
274
  ? "Never started — stop and diagnose before spawning anything else: a config-level error (model, plan, auth, agent file) fails identically on every respawn."
273
275
  : `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
package/src/manager.ts CHANGED
@@ -22,10 +22,10 @@ import {
22
22
  activitySnippet,
23
23
  describeCall,
24
24
  getFirstText,
25
+ isStartupFailure,
25
26
  isTalking,
26
27
  makeNotice,
27
28
  makeTaskNotice,
28
- isStartupFailure,
29
29
  SubagentsWidget,
30
30
  truncateText,
31
31
  } from "./format.ts";
@@ -160,14 +160,26 @@ export function cloneRun(run: RunSnapshot): RunSnapshot {
160
160
  return JSON.parse(JSON.stringify(run)) as RunSnapshot;
161
161
  }
162
162
  /** Resolve a child model from the pi model registry.
163
- * Order: explicit "provider/model-id" or bare id (searched across available
164
- * models) → agent file model → parent's current model (ctx.model) → undefined
165
- * (createAgentSession falls back to settings). */
163
+ * Order: explicit "provider/model-id" or bare id → agent file model → parent's
164
+ * current model (ctx.model) → undefined (createAgentSession falls back to settings).
165
+ *
166
+ * A BARE id is ambiguous: `claude-sonnet-5` exists on anthropic, commandcode,
167
+ * github-copilot and openrouter at once, and an agent file's `model:` is written
168
+ * without a provider. Taking the registry's first match routed children to a
169
+ * provider the session never chose (403 MODEL_NOT_IN_PLAN on every spawn), so the
170
+ * ACTIVE SESSION's provider is searched first — exact id, then its own prefixed
171
+ * form (9router carries `cc/claude-opus-5`, not `claude-opus-5`). */
166
172
  export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
167
173
  if (!explicit?.trim()) return ctx.model; // inherit the parent's active model
168
174
  const ref = explicit.trim();
169
175
  if (!ctx.modelRegistry) return ctx.model; // no registry to check against (tests, headless)
170
176
  const available = ctx.modelRegistry.getAvailable();
177
+ const sessionProvider = ctx.model?.provider;
178
+ if (sessionProvider && !ref.includes("/")) {
179
+ const own = available.filter((m) => m.provider === sessionProvider);
180
+ const hit = own.find((m) => m.id === ref) ?? own.find((m) => m.id.endsWith(`/${ref}`));
181
+ if (hit) return hit;
182
+ }
171
183
  // Model ids can contain slashes (e.g. 9router/cc/claude-opus-5), so a bare id
172
184
  // match and every provider/id split point must be tried, not just the first.
173
185
  const byId = available.find((m) => m.id === ref);
@@ -179,6 +191,48 @@ export function resolveChildModel(ctx: ExtensionContext, explicit: string | unde
179
191
  throw new Error(`Model not found: ${ref}`);
180
192
  }
181
193
 
194
+ /** One throwaway request against the resolved model. A child that cannot reach its
195
+ * model dies on its FIRST turn with no output, after a worktree and a session have
196
+ * already been built — and an agent file's `model:` is chosen by a file the leader
197
+ * never wrote, so "it resolved" is not evidence it is usable (plan gates, ZDR,
198
+ * dead keys all pass resolution). Returns the error text, or undefined when OK. */
199
+ async function probeModel(
200
+ ctx: ExtensionContext,
201
+ model: Model<Api>,
202
+ signal: AbortSignal | undefined,
203
+ ): Promise<string | undefined> {
204
+ try {
205
+ const reply = await ctx.modelRegistry.complete(
206
+ model,
207
+ { messages: [{ role: "user", content: "ping", timestamp: Date.now() }] },
208
+ { maxTokens: 16, signal },
209
+ );
210
+ return reply.stopReason === "error" ? (reply.errorMessage ?? "provider returned an error") : undefined;
211
+ } catch (err) {
212
+ return err instanceof Error ? err.message : String(err);
213
+ }
214
+ }
215
+
216
+ /** Preflight for the model a child is about to run on. Probes only what is not
217
+ * already proven: the session's own model answered this very turn. On failure the
218
+ * session model is the fallback — the one model known to work right now. */
219
+ export async function ensureUsableModel(
220
+ ctx: ExtensionContext,
221
+ model: Model<Api> | undefined,
222
+ signal: AbortSignal | undefined,
223
+ ): Promise<{ model: Model<Api> | undefined; note?: string }> {
224
+ const session = ctx.model;
225
+ if (!model || !ctx.modelRegistry) return { model };
226
+ if (session && model.provider === session.provider && model.id === session.id) return { model };
227
+ const error = await probeModel(ctx, model, signal);
228
+ if (!error) return { model };
229
+ if (!session) throw new Error(`Model ${model.provider}/${model.id} is unusable: ${error}`);
230
+ return {
231
+ model: session,
232
+ note: `${model.provider}/${model.id} failed preflight (${error}); using session model ${session.provider}/${session.id}`,
233
+ };
234
+ }
235
+
182
236
  /** Extension-registered providers (e.g. 9router) live only in the parent's
183
237
  * in-memory runtime. A child builds its runtime from disk and would lose them,
184
238
  * so replay the parent's registrations before the child resolves auth. */
@@ -810,10 +864,18 @@ export class SubagentManager {
810
864
  try {
811
865
  model = resolveChildModel(ctx, file?.model ?? input.model);
812
866
  validateThinking(model, thinking);
867
+ // Resolution only proves the id exists. Probe it before anything is built,
868
+ // and fall back to the session's own model when the probe fails.
869
+ const checked = await ensureUsableModel(ctx, model, signal);
870
+ model = checked.model;
871
+ if (checked.note) {
872
+ task.modelNote = checked.note;
873
+ validateThinking(model, thinking);
874
+ }
813
875
  // Record the model ACTUALLY used — an agent file's `model:` overrides the
814
876
  // requested one, and showing the request in the widget hides that entirely
815
877
  // (a 403 then names a model the leader never asked for).
816
- if (model) this.updateTask(run, task, { model: model.id }, ctx, onUpdate);
878
+ if (model) this.updateTask(run, task, { model: model.id, modelNote: checked.note }, ctx, onUpdate);
817
879
  } catch (err) {
818
880
  this.updateTask(
819
881
  run,
package/src/types.ts CHANGED
@@ -36,6 +36,9 @@ export interface TaskSnapshot {
36
36
  finalText?: string;
37
37
  error?: string;
38
38
  model?: string;
39
+ /** Why `model` is not what was requested: preflight failed and the session's
40
+ * model took over. Silent substitution is worse than a slow spawn. */
41
+ modelNote?: string;
39
42
  thinking?: string;
40
43
  tools?: string[];
41
44
  usage: UsageStats;