omnirush 0.10.2 → 0.10.3

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.
@@ -35,6 +35,7 @@ import {
35
35
  ENV_FALLBACK_MODEL,
36
36
  FALLBACK_MARKER,
37
37
  fallbackNote,
38
+ sourceNote,
38
39
  piThinking,
39
40
  type SubagentFallback,
40
41
  } from "./subagents-lib";
@@ -305,8 +306,8 @@ export interface ChildTask {
305
306
  role: AgentRole;
306
307
  task: string;
307
308
  /** Cross-model children: gateway model id the child runs on
308
- * (e.g. "muse-spark-1.3" for cheap swarm workers under an astra
309
- * parent). Undefined = inherit the parent's model. */
309
+ * (e.g. "muse-spark-1.1" workers under an astra parent). Undefined =
310
+ * inherit the parent's model. */
310
311
  model?: string;
311
312
  /** The effort the child runs on (gateway spelling); undefined = pi's default. */
312
313
  effort?: string;
@@ -316,6 +317,21 @@ export interface ChildTask {
316
317
  gatewayFallback?: { model: string; effort: string | null };
317
318
  /** Extra environment for the child (the sub-agent setting and main model, for nested layers). */
318
319
  env?: Record<string, string>;
320
+ /** The model[:effort] the delegating agent asked for (the task's own `model`), when it named one. */
321
+ requestedModel?: string;
322
+ /** That model did not run: the user's /subagents choice won, or the gateway does not serve it. */
323
+ override?: ChildModelOverride;
324
+ /** Where `model` comes from ("subagents", "user_prompt", "task", "parent"). */
325
+ modelSource?: string;
326
+ }
327
+
328
+ /** A task model that did not run, and what ran instead (the result's model_override). */
329
+ export interface ChildModelOverride {
330
+ requested: string;
331
+ used: string;
332
+ effort: string | null;
333
+ reason: string;
334
+ note: string;
319
335
  }
320
336
 
321
337
  /** A sub-agent that ran on the main model instead of the picked one. */
@@ -342,6 +358,12 @@ export interface ChildResult {
342
358
  effort?: string;
343
359
  /** It ran on the main model instead of the picked one (why, and since when). */
344
360
  model_fallback?: ChildModelFallback;
361
+ /** The task's own model did not run (the user's /subagents choice, or not served). */
362
+ model_override?: ChildModelOverride;
363
+ /** Where the model comes from: "subagents", "user_prompt", "task" or "parent". */
364
+ model_source?: string;
365
+ /** The model[:effort] the task asked for, when it named one. */
366
+ requested_model?: string;
345
367
  status: ChildStatus;
346
368
  /** Process exit code (null when killed by a signal or still unknown). */
347
369
  exitCode: number | null;
@@ -412,6 +434,9 @@ export function buildChildResult(
412
434
  ...(task.model ? { model: task.model } : {}),
413
435
  ...(task.effort ? { effort: task.effort } : {}),
414
436
  ...(modelFallback ? { model_fallback: modelFallback } : {}),
437
+ ...(task.override ? { model_override: { ...task.override } } : {}),
438
+ ...(task.modelSource ? { model_source: task.modelSource } : {}),
439
+ ...(task.requestedModel ? { requested_model: task.requestedModel } : {}),
415
440
  status: input.status ?? (input.exitCode === 0 ? "completed" : "failed"),
416
441
  exitCode: input.exitCode,
417
442
  output: capped,
@@ -921,16 +946,34 @@ export async function mapWithConcurrency<TIn, TOut>(
921
946
  return results;
922
947
  }
923
948
 
949
+ /** The model and effort a child really ran on (after a gateway fallback), and why that one. */
950
+ export function ranOn(result: Pick<ChildResult, "model" | "effort" | "model_fallback">): { model: string | null; effort: string | null } {
951
+ if (result.model_fallback?.kind === "gateway") return { model: result.model_fallback.used, effort: result.model_fallback.effort ?? null };
952
+ return { model: result.model ?? null, effort: result.effort ?? null };
953
+ }
954
+
955
+ function ranOnText(result: ChildResult): string {
956
+ const { model, effort } = ranOn(result);
957
+ if (!model) return "the parent's model (not an omnirush.ai model)";
958
+ const why = result.model_fallback?.kind === "gateway"
959
+ ? "the gateway refused the chosen model"
960
+ : result.model_fallback
961
+ ? "the main model"
962
+ : sourceNote(result.model_source);
963
+ return `${model}${effort ? ` · ${effort}` : ""}${why ? ` (${why})` : ""}`;
964
+ }
965
+
924
966
  /** Structured result text for the parent model (one section per child). */
925
967
  export function renderChildResults(results: ChildResult[], ids?: readonly string[]): string {
926
968
  const succeeded = results.filter((result) => result.status === "completed").length;
927
969
  const sections = results.map((result, index) => {
928
970
  const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
929
- const ranOn = result.model_fallback?.kind === "gateway" ? result.model_fallback.used : result.model;
930
- const effort = result.model_fallback?.kind === "gateway" ? result.model_fallback.effort ?? undefined : result.effort;
931
- const label = ranOn ? ` [${ranOn}${effort ? ` · ${effort}` : ""}]` : "";
971
+ const ran = ranOn(result);
972
+ const label = ran.model ? ` [${ran.model}${ran.effort ? ` · ${ran.effort}` : ""}]` : "";
932
973
  const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${label} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
933
974
  const meta: string[] = [`task: ${result.task}`];
975
+ meta.push(`ran on: ${ranOnText(result)}`);
976
+ if (result.model_override) meta.push(`note: ${result.model_override.note}`);
934
977
  if (result.model_fallback) meta.push(`note: ${result.model_fallback.note}`);
935
978
  if (result.error) meta.push(`error: ${result.error}`);
936
979
  return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
@@ -73,6 +73,7 @@ import {
73
73
  } from "./agents-lib";
74
74
  import { SWARM_GUIDELINE } from "./swarm-lib";
75
75
  import { omniDir } from "./auth";
76
+ import { loadCatalog, type CatalogModel } from "./subagents-lib";
76
77
  import { MemoryAdmission, readMemorySettings } from "./memory-lib";
77
78
 
78
79
  /** Blocking spawn_agents: progress updates at most this often (ms). */
@@ -84,16 +85,32 @@ const MIN_TIMEOUT_MINUTES = 1;
84
85
  /** customType of the message that brings background results back. */
85
86
  export const DELIVERY_TYPE = "omnirush-agents-result";
86
87
 
87
- const SpawnAgentsParams = Type.Object({
88
+ /** Served ids used as examples when the account's catalog is not cached (the live list at 0.10.3). */
89
+ export const SERVED_MODEL_EXAMPLES = ["gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol", "meta-muse-spark", "muse-spark-1.1"];
90
+
91
+ /** Examples for the per-task model: the ids the account's live catalog lists (served models only). */
92
+ export function modelExamples(catalog: readonly CatalogModel[] | null): string[] {
93
+ if (!catalog || catalog.length === 0) return SERVED_MODEL_EXAMPLES;
94
+ return catalog.map((model) => model.id);
95
+ }
96
+
97
+ export function modelParamDescription(examples: readonly string[]): string {
98
+ return [
99
+ "Optional model[:effort] for THIS task. Usually omit it: set it only when the user asked for a model for the sub-agents.",
100
+ `Served models: ${examples.map((id) => `"${id}"`).join(", ")}, optionally with an effort (e.g. "${examples[0] ?? "gpt-6-astra"}:high"); a model the account is not served is never used (the child runs on your model and effort).`,
101
+ "The user's /subagents choice always wins: while it is set, a model named here runs only if the user's own message names it.",
102
+ "Omitted, the child runs on the /subagents choice, else this session's current model and effort.",
103
+ ].join(" ");
104
+ }
105
+
106
+ const spawnAgentsParams = (examples: readonly string[]) => Type.Object({
88
107
  tasks: Type.Array(
89
108
  Type.Object({
90
109
  role: StringEnum(AGENT_ROLES, {
91
110
  description: "code-searcher: read-only codebase exploration; researcher-web: web research via web_search/web_fetch; general-worker: full-tool implementation work",
92
111
  }),
93
112
  task: Type.String({ description: "Self-contained task description for the child agent (it sees ONLY this, not your conversation)" }),
94
- model: Type.Optional(Type.String({
95
- description: 'Optional model[:effort] for THIS task (e.g. "muse-spark-1.3", "muse-spark-1.2-contributor" for cheap swarm workers, "meta-muse-spark", "muse-spark-1.1:low", "gpt-6-astra", "gpt-6-sol:high", "gpt-5.6-sol"). Omit (or leave empty) for the sub-agent model the user picked with /subagents, else the parent session\'s current model and effort',
96
- })),
113
+ model: Type.Optional(Type.String({ description: modelParamDescription(examples) })),
97
114
  }),
98
115
  { description: "Tasks to delegate; they all run in parallel", minItems: 1 },
99
116
  ),
@@ -115,6 +132,26 @@ const IdsParam = Type.Optional(Type.Array(Type.String(), {
115
132
  description: 'Agent ids from spawn_agents (e.g. "a3"); omit (or "all") for every agent of this session',
116
133
  }));
117
134
 
135
+ /** The text of the latest user message on the session's branch (null when there is none). */
136
+ export function lastUserMessageText(ctx: any): string | null {
137
+ let entries: any[] = [];
138
+ try {
139
+ entries = ctx?.sessionManager?.getBranch?.() ?? ctx?.sessionManager?.getEntries?.() ?? [];
140
+ } catch {
141
+ return null;
142
+ }
143
+ for (let index = entries.length - 1; index >= 0; index--) {
144
+ const message = entries[index]?.type === "message" ? entries[index].message : null;
145
+ if (!message || message.role !== "user") continue;
146
+ if (typeof message.content === "string") return message.content;
147
+ if (Array.isArray(message.content)) {
148
+ return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
149
+ }
150
+ return null;
151
+ }
152
+ return null;
153
+ }
154
+
118
155
  function resultOf(record: AgentRecord): ChildResult & { agent_id: string } {
119
156
  return { ...(record.result as ChildResult), agent_id: record.id };
120
157
  }
@@ -227,6 +264,30 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
227
264
  };
228
265
  (globalThis as any)[AGENTS_HOOK] = hook;
229
266
 
267
+ // The user's latest message (the text they typed, not an extension's):
268
+ // a task's own model runs over the /subagents choice only when it names
269
+ // that model. Sub-agents have no user: their task text never counts.
270
+ let lastUserInput: { session: string; text: string } | null = null;
271
+ const sessionIdOf = (ctx: any): string => {
272
+ try {
273
+ return String(ctx?.sessionManager?.getSessionId?.() ?? "");
274
+ } catch {
275
+ return "";
276
+ }
277
+ };
278
+ if (depth === 0) {
279
+ pi.on("input", (event: any, ctx: any) => {
280
+ if (event?.source === "extension" || typeof event?.text !== "string") return;
281
+ lastUserInput = { session: sessionIdOf(ctx), text: event.text };
282
+ });
283
+ }
284
+ const latestUserPrompt = (ctx: any): string | null => {
285
+ if (depth > 0) return null;
286
+ const session = sessionIdOf(ctx);
287
+ if (lastUserInput && lastUserInput.session === session) return lastUserInput.text;
288
+ return lastUserMessageText(ctx);
289
+ };
290
+
230
291
  const sessionOf = (ctx: any): string => {
231
292
  lastContext = ctx ?? lastContext;
232
293
  return hub.sessionOf(ctx);
@@ -244,7 +305,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
244
305
  "Default (wait true) blocks until all children finish and returns their results.",
245
306
  "wait:false dispatches them in the BACKGROUND: the call returns at once with agent ids, you keep working, and their results arrive automatically as a message in this conversation when they finish.",
246
307
  "Manage background agents with agents_status, agents_wait, agents_result and agents_cancel.",
247
- 'Each task can run on a DIFFERENT model via its "model" field — e.g. cheap muse workers for wide swarm sweeps under an astra parent.',
308
+ 'Each task can run on a different model via its "model" field when the user asks for one; the user\'s /subagents choice wins over it, and a model the account is not served is not used. Every result says the model and effort the child ran on.',
248
309
  "Every task description must be SELF-CONTAINED: the child cannot see this conversation.",
249
310
  "Use for parallelizable work: broad code surveys, independent research questions, independent implementation chunks.",
250
311
  "Give each task only its own part of the work: never the user's whole message or instructions about sub-agents (the child would follow them and delegate again).",
@@ -257,7 +318,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
257
318
  "Do not use spawn_agents for a single quick action — doing it yourself is cheaper.",
258
319
  ...(depth > 0 ? [] : [SWARM_GUIDELINE]),
259
320
  ],
260
- parameters: SpawnAgentsParams,
321
+ parameters: spawnAgentsParams(modelExamples(loadCatalog(omniDir()))),
261
322
 
262
323
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
263
324
  const parentSessionId = sessionOf(ctx);
@@ -266,9 +327,9 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
266
327
  if (!raw || typeof raw.task !== "string" || !raw.task.trim()) {
267
328
  throw new Error("invalid tasks: every entry needs a non-empty task string");
268
329
  }
269
- // A task's own model[:effort] wins; an omitted (or empty) one takes
270
- // the /subagents pick, else the parent session's current model and
271
- // effort (resolved below by subagentTasks).
330
+ // A task's own model[:effort] (resolved below by subagentTasks):
331
+ // the user's /subagents choice wins over it unless the user's message
332
+ // names that model; unset, it runs only when the gateway serves it.
272
333
  const requestedModel = typeof raw.model === "string" ? raw.model.trim() : "";
273
334
  tasks.push({
274
335
  role: raw.role,
@@ -277,11 +338,11 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
277
338
  });
278
339
  }
279
340
  if (tasks.length === 0) throw new Error("no tasks given");
280
- // The model and effort each child runs on (subagents.ts): the task's
281
- // own model[:effort], else the /subagents pick, else the parent
341
+ // The model and effort each child runs on (subagents.ts): the
342
+ // /subagents choice, else a served task model, else the parent
282
343
  // session's current model and effort; the main model when a pick is
283
- // unavailable.
284
- const resolvedTasks = subagentTasks(pi, ctx, tasks);
344
+ // unavailable. Overrides are noted on the result and the trace.
345
+ const resolvedTasks = subagentTasks(pi, ctx, tasks, { userPrompt: latestUserPrompt(ctx) });
285
346
  if (!parentSessionId) throw new Error("no parent session id — subagents cannot be traced");
286
347
  const workdir = ctx?.cwd ? String(ctx.cwd) : process.cwd();
287
348
  // Only a deliberate limit counts: models fill optional numbers with
@@ -334,7 +395,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
334
395
 
335
396
  if (background) {
336
397
  const lines = dispatched.records.map((record, index) => {
337
- const note = childModelFallback(resolvedTasks[index])?.note;
398
+ const note = [resolvedTasks[index].override?.note, childModelFallback(resolvedTasks[index])?.note].filter(Boolean).join("; ");
338
399
  return `- ${record.id}: ${record.role}${record.model ? ` [${record.model}${record.effort ? ` · ${record.effort}` : ""}]` : ""} — ${record.task.length > 100 ? `${record.task.slice(0, 99)}…` : record.task}${note ? ` (${note})` : ""}`;
339
400
  });
340
401
  return {
@@ -363,6 +424,9 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
363
424
  ...(record.model ? { model: record.model } : {}),
364
425
  ...(record.effort ? { effort: record.effort } : {}),
365
426
  ...(modelFallback ? { model_fallback: modelFallback } : {}),
427
+ ...(resolvedTasks[index].override ? { model_override: { ...resolvedTasks[index].override } } : {}),
428
+ ...(resolvedTasks[index].modelSource ? { model_source: resolvedTasks[index].modelSource } : {}),
429
+ ...(resolvedTasks[index].requestedModel ? { requested_model: resolvedTasks[index].requestedModel } : {}),
366
430
  parallel_limit: record.parallelLimit,
367
431
  status: "running",
368
432
  };
@@ -159,13 +159,48 @@ function modelFallbackOf(result: any): ChildModelFallback | null {
159
159
  };
160
160
  }
161
161
 
162
+ /** A task's own model that did not run (spawn_agents' model_override). */
163
+ export type ChildModelOverride = { requested: string; used: string; effort: string | null; reason: string; note: string };
164
+
165
+ /** The model and effort a sub-agent really ran on, once its result is final. */
166
+ export type ChildRanOn = { model: string | null; effort: string | null; source: string | null; requested: string | null; status: string };
167
+
168
+ function modelOverrideOf(result: any): ChildModelOverride | null {
169
+ const override = result && typeof result.model_override === "object" ? result.model_override : null;
170
+ if (!override || typeof override.requested !== "string" || typeof override.used !== "string") return null;
171
+ return {
172
+ requested: override.requested,
173
+ used: override.used,
174
+ effort: typeof override.effort === "string" ? override.effort : null,
175
+ reason: typeof override.reason === "string" ? override.reason : "user_choice",
176
+ note: typeof override.note === "string" ? override.note : `ran on ${override.used}`,
177
+ };
178
+ }
179
+
180
+ /** A finished child's model and effort (the gateway fallback's when the gateway moved it). */
181
+ function ranOnOf(result: any, fallback: ChildModelFallback | null): ChildRanOn | null {
182
+ const status = result && typeof result.status === "string" ? result.status : "";
183
+ if (!status || status === "running" || status === "queued") return null;
184
+ const gateway = fallback?.kind === "gateway" ? fallback : null;
185
+ return {
186
+ model: gateway ? gateway.used : typeof result.model === "string" ? result.model : null,
187
+ effort: gateway ? gateway.effort : typeof result.effort === "string" ? result.effort : null,
188
+ source: typeof result.model_source === "string" ? result.model_source : null,
189
+ requested: typeof result.requested_model === "string" ? result.requested_model : null,
190
+ status,
191
+ };
192
+ }
193
+
194
+ type SpawnedChild = { id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback; modelOverride?: ChildModelOverride; ranOn?: ChildRanOn };
195
+
162
196
  /**
163
197
  * Sub-agents a session started: its spawn_agents results (session id, role,
164
198
  * task), with the model fallback a later result reported (a background
165
- * child's delivery, agents_wait / agents_result) when there is one.
199
+ * child's delivery, agents_wait / agents_result) when there is one, a task
200
+ * model that did not run, and the model and effort it ran on once finished.
166
201
  */
167
- export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> {
168
- const children: Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> = [];
202
+ export function spawnedChildren(entries: readonly PiEntry[]): SpawnedChild[] {
203
+ const children: SpawnedChild[] = [];
169
204
  for (const entry of entries) {
170
205
  const message = entry.type === "message" ? entry.message : null;
171
206
  const spawned = Boolean(message && message.role === "toolResult" && message.toolName === "spawn_agents");
@@ -182,16 +217,23 @@ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string
182
217
  if (!sanitizeSessionId(id)) continue;
183
218
  const known = children.find((child) => child.id === id);
184
219
  const modelFallback = modelFallbackOf(result);
220
+ const modelOverride = modelOverrideOf(result);
185
221
  if (known) {
186
222
  if (modelFallback) known.modelFallback = modelFallback;
223
+ if (modelOverride) known.modelOverride = modelOverride;
224
+ const ran = ranOnOf(result, known.modelFallback ?? null);
225
+ if (ran) known.ranOn = ran;
187
226
  continue;
188
227
  }
189
228
  if (!spawned) continue;
229
+ const ran = ranOnOf(result, modelFallback);
190
230
  children.push({
191
231
  id,
192
232
  role: typeof result.role === "string" ? result.role : null,
193
233
  task: typeof result.task === "string" ? result.task : null,
194
234
  ...(modelFallback ? { modelFallback } : {}),
235
+ ...(modelOverride ? { modelOverride } : {}),
236
+ ...(ran ? { ranOn: ran } : {}),
195
237
  });
196
238
  }
197
239
  }
@@ -200,6 +242,15 @@ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string
200
242
 
201
243
  /** Fallbacks already recorded as trace events (root session : child session). */
202
244
  const recordedFallbacks = new Set<string>();
245
+ /** Overrides and ran-on models already recorded (same keys). */
246
+ const recordedOverrides = new Set<string>();
247
+ const recordedRanOn = new Set<string>();
248
+
249
+ /** A child's trace title: its task, with the model note when its model was not the one asked for. */
250
+ function childTitle(child: SpawnedChild): string | null {
251
+ const notes = [child.modelOverride?.note, child.modelFallback?.note].filter(Boolean);
252
+ return child.task && notes.length ? `${child.task} (${notes.join("; ")})` : child.task;
253
+ }
203
254
 
204
255
  /** The session file of `sessionId`: `<ts>_<id>.jsonl` in one of the session dirs. */
205
256
  async function findSessionFile(sessionId: string, dirs: readonly string[]): Promise<string | null> {
@@ -369,13 +420,38 @@ export async function captureChildSessions(input: {
369
420
  kind: child.modelFallback.kind,
370
421
  });
371
422
  }
423
+ if (child.modelOverride && !recordedOverrides.has(fallbackKey)) {
424
+ // The task named a model that did not run: the user's /subagents
425
+ // choice won, or the gateway does not serve it to this account.
426
+ recordedOverrides.add(fallbackKey);
427
+ input.sync.recordTrace(input.rootSessionId, "subagent.model_override", {
428
+ child_session_id: child.id,
429
+ requested: child.modelOverride.requested,
430
+ used: child.modelOverride.used,
431
+ effort: child.modelOverride.effort,
432
+ reason: child.modelOverride.reason,
433
+ });
434
+ }
435
+ if (child.ranOn && !recordedRanOn.has(fallbackKey)) {
436
+ // What the sub-agent really ran on (after any gateway fallback).
437
+ recordedRanOn.add(fallbackKey);
438
+ input.sync.recordTrace(input.rootSessionId, "subagent.model", {
439
+ child_session_id: child.id,
440
+ role: child.role,
441
+ model: child.ranOn.model,
442
+ effort: child.ranOn.effort,
443
+ source: child.ranOn.source,
444
+ requested: child.ranOn.requested,
445
+ status: child.ranOn.status,
446
+ });
447
+ }
372
448
  if (history.delta.length > 0 || !input.known.has(child.id)) {
373
449
  await input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
374
450
  input.sync.recordChildSession(input.rootSessionId, {
375
451
  childSessionId: child.id,
376
452
  parentSessionId: input.parentSessionId,
377
453
  depth: input.depth,
378
- title: child.task && child.modelFallback ? `${child.task} (${child.modelFallback.note})` : child.task,
454
+ title: childTitle(child),
379
455
  agent: turnModelFromMessages(messages)?.agent ?? agent,
380
456
  messages: history.delta,
381
457
  lastMessageId: typeof messages.at(-1)?.info.id === "string" ? (messages.at(-1)!.info.id as string) : null,
@@ -12,7 +12,12 @@
12
12
  // agent's model and the main agent's effort.
13
13
  // - spawn_agents (agents.ts) resolves every task against it
14
14
  // (resolveSubagentModel) and starts the child with that --model and
15
- // --thinking; a task's own `model` wins over the picked one.
15
+ // --thinking. The user's choice wins over a task's own `model` (the
16
+ // agent's), which runs only when the user's message names it; with no
17
+ // choice set, a task model runs only when the account's catalog lists
18
+ // it (served), else the delegating agent's model and effort. Every
19
+ // override is on the result and in the trace (subagent.model_override),
20
+ // and every finished child's real model and effort (subagent.model).
16
21
  // - A picked model that is not in the account's catalog (the list the
17
22
  // launcher fetched from GET /v1/models), or that the gateway refused in
18
23
  // the last five minutes, resolves to the main model instead (a
@@ -134,7 +139,9 @@ export function catalogFromPayload(payload: unknown): CatalogModel[] | null {
134
139
  const ownership = [entry.provider, entry.owned_by, entry.owner, entry.source, entry.namespace, entry.managed_by]
135
140
  .filter((value): value is string => typeof value === "string")
136
141
  .map((value) => value.trim().toLowerCase());
137
- const shipped = ["gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol", "meta-muse-spark", "muse-spark-1.1", "muse-spark-1.3", "muse-spark-1.2-contributor"];
142
+ // Ids the gateway serves under their bare names; an id counts as served
143
+ // only when this payload lists it (never because the CLI ships it).
144
+ const shipped = ["gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol", "meta-muse-spark", "muse-spark-1.1"];
138
145
  if (!shipped.includes(id) && entry.omnirush !== true && !ownership.includes("omnirush")) continue;
139
146
  const levels = Array.isArray(entry.reasoning_levels)
140
147
  ? EFFORTS.filter((effort) => entry.reasoning_levels.some((level: unknown) => parseEffort(level) === effort))
@@ -323,6 +330,26 @@ export interface SubagentFallback {
323
330
  reason: FallbackReason | string;
324
331
  }
325
332
 
333
+ /** Why a task's own `model` did not run: the user's /subagents choice, or the gateway does not serve it. */
334
+ export type OverrideReason = "user_choice" | "not_served" | "catalog_unavailable" | "refused";
335
+
336
+ /** A task's own `model[:effort]` that was not used, and what ran instead. */
337
+ export interface SubagentOverride {
338
+ /** The task's model[:effort] as the agent wrote it. */
339
+ requested: string;
340
+ used: string;
341
+ effort: string | null;
342
+ reason: OverrideReason;
343
+ }
344
+
345
+ /**
346
+ * Where a sub-agent's model comes from: "subagents" (the user's /subagents
347
+ * choice), "user_prompt" (a task model the user's own message named),
348
+ * "task" (a task model the gateway serves, /subagents unset) or "parent"
349
+ * (the delegating agent's model and effort).
350
+ */
351
+ export type ModelSource = "subagents" | "user_prompt" | "task" | "parent";
352
+
326
353
  export interface SubagentResolution {
327
354
  /** The model the child runs on; null = leave the child to pi's own default (no omnirush model known). */
328
355
  model: string | null;
@@ -332,6 +359,10 @@ export interface SubagentResolution {
332
359
  fallback?: SubagentFallback;
333
360
  /** The main model (and effort) the child's gateway guard moves to when the gateway refuses `model`. */
334
361
  gatewayFallback?: { model: string; effort: string | null };
362
+ /** The task named a model that did not run (the user's choice won, or it is not served). */
363
+ override?: SubagentOverride;
364
+ /** Where the model comes from (absent when no omnirush model is known). */
365
+ source?: ModelSource;
335
366
  }
336
367
 
337
368
  export interface AgentModel {
@@ -342,14 +373,33 @@ export interface AgentModel {
342
373
  effort: string | null;
343
374
  }
344
375
 
376
+ /** Whether `text` names the model id `id` (case-insensitive, as a whole id: "gpt-6-sol" is not named by "gpt-6-sol-mini" or "gpt-5.6-sol"). */
377
+ export function textNamesModel(text: string | null | undefined, id: string): boolean {
378
+ if (typeof text !== "string" || !text || !id) return false;
379
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
380
+ return new RegExp(`(?<![A-Za-z0-9._-])${escaped}(?![A-Za-z0-9_-]|\\.[A-Za-z0-9])`, "i").test(text);
381
+ }
382
+
383
+ /** Whether the gateway serves `model` to this account: the live catalog lists it. */
384
+ export function isServed(catalog: readonly CatalogModel[] | null, model: string): boolean {
385
+ return Boolean(catalog?.some((entry) => entry.id === model));
386
+ }
387
+
345
388
  /**
346
389
  * What one sub-agent runs on.
347
390
  *
348
391
  * `delegating` is the agent calling spawn_agents (its model is what "same as
349
392
  * main" keeps); `main` is the main session's model and effort (handed down
350
393
  * to nested layers through the environment), the one a picked model falls
351
- * back to. `explicit` is the task's own `model`, which wins over the picked
352
- * one and is not checked against the catalog (the gateway decides).
394
+ * back to. `explicit` is the task's own `model[:effort]` (the agent's
395
+ * choice) and `userPrompt` the user's latest message:
396
+ * - the user set /subagents (model and/or effort): that choice wins; a
397
+ * task model runs only when the user's own message names it (and the
398
+ * gateway serves it), otherwise it is overridden ("user_choice");
399
+ * - /subagents unset: a task model runs only when the account's live
400
+ * catalog lists it (and the gateway did not refuse it a moment ago);
401
+ * otherwise the delegating agent's model and the main effort run
402
+ * ("not_served", "catalog_unavailable" or "refused").
353
403
  */
354
404
  export function resolveSubagentModel(input: {
355
405
  setting: SubagentSetting;
@@ -357,6 +407,7 @@ export function resolveSubagentModel(input: {
357
407
  delegating: AgentModel;
358
408
  main?: AgentModel | null;
359
409
  explicit?: string | null;
410
+ userPrompt?: string | null;
360
411
  refused?: (model: string) => boolean;
361
412
  }): SubagentResolution {
362
413
  const { setting, catalog, delegating } = input;
@@ -364,6 +415,7 @@ export function resolveSubagentModel(input: {
364
415
  const mainIsGateway = main.provider === PROVIDER_ID && Boolean(main.model);
365
416
  const wantedEffort = setting.effort ?? parseEffort(main.effort) ?? null;
366
417
  const effortOn = (model: string): string | null => nearestEffort(wantedEffort, levelsFor(catalog, model));
418
+ const userChose = Boolean(setting.model || setting.effort);
367
419
 
368
420
  const withGatewayFallback = (resolution: SubagentResolution): SubagentResolution => {
369
421
  if (
@@ -377,21 +429,39 @@ export function resolveSubagentModel(input: {
377
429
  return resolution;
378
430
  };
379
431
 
380
- // The task's own model[:effort]: an effort it names wins over the picked
381
- // and the parent's (mapped to the nearest level the model offers).
382
- const explicit = input.explicit && isModelId(input.explicit) ? splitModelEffort(input.explicit) : null;
432
+ // The task's own model[:effort].
433
+ const requested = typeof input.explicit === "string" ? input.explicit.trim() : "";
434
+ const explicit = requested && isModelId(requested) ? splitModelEffort(requested) : null;
435
+ let overrideReason: OverrideReason | null = null;
383
436
  if (explicit && isModelId(explicit.model)) {
384
- const effort = explicit.effort ? nearestEffort(explicit.effort, levelsFor(catalog, explicit.model)) ?? explicit.effort : effortOn(explicit.model);
385
- return withGatewayFallback({ model: explicit.model, effort });
437
+ const served = isServed(catalog, explicit.model);
438
+ const refusedNow = served && Boolean(input.refused?.(explicit.model));
439
+ const named = served && textNamesModel(input.userPrompt, explicit.model);
440
+ const allowed = served && !refusedNow && (userChose ? named : true);
441
+ if (allowed) {
442
+ // An effort the task names applies unless the user picked one with /subagents.
443
+ const taskEffort = explicit.effort && !setting.effort ? explicit.effort : null;
444
+ const effort = taskEffort ? nearestEffort(taskEffort, levelsFor(catalog, explicit.model)) ?? taskEffort : effortOn(explicit.model);
445
+ return withGatewayFallback({ model: explicit.model, effort, source: named ? "user_prompt" : "task" });
446
+ }
447
+ overrideReason = userChose ? "user_choice" : !catalog ? "catalog_unavailable" : !served ? "not_served" : "refused";
386
448
  }
449
+ const withOverride = (resolution: SubagentResolution): SubagentResolution => {
450
+ if (!overrideReason || !explicit || !resolution.model) return resolution;
451
+ // The same model at the same effort is no override.
452
+ if (explicit.model === resolution.model && (!explicit.effort || explicit.effort === resolution.effort)) return resolution;
453
+ resolution.override = { requested, used: resolution.model, effort: resolution.effort, reason: overrideReason };
454
+ return resolution;
455
+ };
387
456
 
388
457
  if (setting.model) {
389
458
  const inCatalog = !catalog || catalog.some((entry) => entry.id === setting.model);
390
459
  const reason: FallbackReason | null = !inCatalog ? "not_in_catalog" : input.refused?.(setting.model) ? "refused" : null;
391
460
  if (reason && mainIsGateway && main.model !== setting.model) {
392
- return {
461
+ return withOverride({
393
462
  model: main.model!,
394
463
  effort: effortOn(main.model!),
464
+ source: "subagents",
395
465
  fallback: {
396
466
  requested: setting.model,
397
467
  requestedName: displayName(catalog, setting.model),
@@ -399,16 +469,46 @@ export function resolveSubagentModel(input: {
399
469
  usedName: displayName(catalog, main.model!),
400
470
  reason,
401
471
  },
402
- };
472
+ });
403
473
  }
404
- return withGatewayFallback({ model: setting.model, effort: effortOn(setting.model) });
474
+ return withOverride(withGatewayFallback({ model: setting.model, effort: effortOn(setting.model), source: "subagents" }));
405
475
  }
406
476
 
407
477
  // "Same as main": the delegating agent's own model.
408
478
  if (delegating.provider !== PROVIDER_ID || !delegating.model) {
409
479
  return { model: null, effort: null };
410
480
  }
411
- return withGatewayFallback({ model: delegating.model, effort: effortOn(delegating.model) });
481
+ return withOverride(withGatewayFallback({
482
+ model: delegating.model,
483
+ effort: effortOn(delegating.model),
484
+ source: userChose ? "subagents" : "parent",
485
+ }));
486
+ }
487
+
488
+ /** A readable note for a task model that did not run (for the parent's result and the trace title). */
489
+ export function overrideNote(override: SubagentOverride, catalog: readonly CatalogModel[] | null = null): string {
490
+ const ran = `${displayName(catalog, override.used)}${override.effort ? ` · ${override.effort}` : ""}`;
491
+ switch (override.reason) {
492
+ case "user_choice":
493
+ return `ran on ${ran}, your /subagents choice (${override.requested} was asked for)`;
494
+ case "not_served":
495
+ return `ran on ${ran}, the parent's model: ${override.requested} is not served to this account`;
496
+ case "catalog_unavailable":
497
+ return `ran on ${ran}, the parent's model: the account's model list is not available to check ${override.requested}`;
498
+ default:
499
+ return `ran on ${ran}, the parent's model: ${override.requested} was refused by omnirush.ai recently`;
500
+ }
501
+ }
502
+
503
+ /** How a sub-agent's model was chosen, in words (the result the parent reads). */
504
+ export function sourceNote(source: ModelSource | string | undefined): string {
505
+ switch (source) {
506
+ case "subagents": return "your /subagents choice";
507
+ case "user_prompt": return "named in your message";
508
+ case "task": return "the task's model";
509
+ case "parent": return "same as the parent";
510
+ default: return "";
511
+ }
412
512
  }
413
513
 
414
514
  /** A readable note for a sub-agent that ran on the main model instead of the picked one. */
@@ -31,6 +31,7 @@ import {
31
31
  loadCatalog,
32
32
  nearestEffort,
33
33
  nextSetting,
34
+ overrideNote,
34
35
  parseEffort,
35
36
  PROVIDER_ID,
36
37
  readSetting,
@@ -115,7 +116,12 @@ export function mainModel(pi: any, ctx: any, env: NodeJS.ProcessEnv = process.en
115
116
  * model when the picked one is not available, and the environment that hands
116
117
  * the setting and the main model down to nested layers.
117
118
  */
118
- export function subagentTasks(pi: any, ctx: any, tasks: ChildTask[], options: { dir?: string; env?: NodeJS.ProcessEnv; now?: number } = {}): ChildTask[] {
119
+ export function subagentTasks(
120
+ pi: any,
121
+ ctx: any,
122
+ tasks: ChildTask[],
123
+ options: { dir?: string; env?: NodeJS.ProcessEnv; now?: number; userPrompt?: string | null } = {},
124
+ ): ChildTask[] {
119
125
  const dir = options.dir ?? omniDir();
120
126
  const env = options.env ?? process.env;
121
127
  const { setting } = currentSetting(pi, dir);
@@ -137,12 +143,17 @@ export function subagentTasks(pi: any, ctx: any, tasks: ChildTask[], options: {
137
143
  delegating,
138
144
  main,
139
145
  explicit: task.model ?? null,
146
+ userPrompt: options.userPrompt ?? null,
140
147
  refused: (model) => Boolean(isRefused(dir, model, options.now)),
141
148
  });
142
149
  const next: ChildTask = { ...task, env: { ...(task.env ?? {}), ...handDown } };
150
+ if (task.model) next.requestedModel = task.model;
143
151
  if (resolution.model) next.model = resolution.model;
144
152
  else delete next.model;
145
153
  if (resolution.model && resolution.effort) next.effort = resolution.effort;
154
+ else delete next.effort;
155
+ if (resolution.source) next.modelSource = resolution.source;
156
+ if (resolution.override) next.override = { ...resolution.override, note: overrideNote(resolution.override, catalog) };
146
157
  if (resolution.fallback) next.fallback = resolution.fallback;
147
158
  if (resolution.gatewayFallback) next.gatewayFallback = resolution.gatewayFallback;
148
159
  return next;
@@ -292,7 +303,7 @@ export default function (pi: any) {
292
303
  if (patch.model !== undefined) override.model = next.model;
293
304
  if (patch.effort !== undefined || next.effort !== current.effort) override.effort = next.effort;
294
305
  sessionOverrides.set(pi, override);
295
- if (patch.model !== undefined && current.effort && next.effort === null) {
306
+ if (command !== "reset" && patch.model !== undefined && current.effort && next.effort === null) {
296
307
  notify(`Sub-agent effort ${current.effort} is not offered by ${next.model ? displayName(catalog, next.model) : "the main model"}: back to same as main.`, "warning");
297
308
  }
298
309
  if (next.effort && next.model === null && main.model) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnirush",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Omnirush — free daily tokens for the most powerful coding model on earth.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",