Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/gentle-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,30 @@ The `subagent_*` tools and the agents card replace the third-party subagents pac

Agent paths follow `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent` for definitions, config, history, child sessions, and transcripts. These overrides select the agent profile; they do not sandbox project or shared global resources.

#### Role model fallbacks

A `model_profiles` entry in `subagents.json` (global or project) may list ordered `fallbacks` for the role. When the role's model reports explicit credit or quota exhaustion, the same task continues on the next fallback with the role's thinking level unchanged:

```json
{
"model_profiles": {
"gentle-ai-worker": {
"model": "provider-a/primary-model",
"effort": "high",
"fallbacks": ["provider-b/fallback-model", "provider-c/primary-model"]
}
}
}
```

- Only explicit exhaustion triggers a fallback: the usage limits Pi itself refuses to retry (`insufficient_quota`, `quota exceeded`, `billing`, `available balance`, `out of budget`, monthly, free and subscription usage limits), plus HTTP 402 and an exhausted quota or credit balance. Rate limits never do, even when worded as a quota (`Quota exceeded … per minute`) or reported only as `RESOURCE_EXHAUSTED`; neither do concurrency caps, overloads or ordinary errors. Pi's own retries handle the transient ones before the child settles, so a quota error Pi does retry falls back only once that retry budget is spent.
- A fallback may reuse the primary's model id on another provider (account rotation). An entry equal to the primary itself is ignored, as are non-string or empty entries.
- A project `fallbacks` list replaces the global one for that role; a project entry without `fallbacks` inherits it, and `"fallbacks": []` clears it. Fallbacks apply whichever source supplied the primary (profile, agent frontmatter or `default_model`).
- The task continues in the failed child's own session when its file exists, so finished work and context carry over; otherwise it restarts with the original prompt and context. Each attempt is one new child, bounded by the list.
- The agents card and `subagent_status` show the model currently running; `subagent_status` and the result name every model tried, in order, and the reason (`fallback: provider-a/primary-model -> provider-b/fallback-model (provider quota exhausted)`), and the task thread records a note. When every model is exhausted the task fails with the models tried and a hint to add credits or another fallback.
- Applying a profile from `/gentle:profiles` or `/gentle:models`, or a repository's pinned profile, keeps a role's `fallbacks` while its primary model is unchanged (including a role that names no primary on either side) and drops them when the primary changes.
- Fallbacks cover subagent roles. The primary orchestrator (#882) and in-process review lenses keep their own routing, and continuing a finished task later starts again from the role's primary.

```text
╭─ ❀ Agents · 1 active · 1 done ─────────────────────────────── 1m24s ╮
│ ✓ gentle-ai-explore map footer sources gpt-5.6-terra · 34k · $0.27 · 25s │
Expand Down
17 changes: 14 additions & 3 deletions extensions/gentle-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,22 @@ function taskDetails(task: TaskRecord): Record<string, unknown> {
export function describeTask(task: TaskRecord): string {
const head = `${task.id} · ${task.agent} · ${task.status} · ${task.mode}`;
const detail = task.error ? `\n${task.error}` : "";
return `${head} · cwd: ${task.cwd} · ${task.turns} turns · ${task.toolCalls} tool calls · last: ${task.lastStep}${detail}`;
return `${head} · cwd: ${task.cwd} · ${task.turns} turns · ${task.toolCalls} tool calls · last: ${task.lastStep}${fallbackSuffix(task)}${detail}`;
}

// Which model really did the work after a role fallback, and why it moved.
function fallbackNote(task: TaskRecord): string {
return task.fallback ? `fallback: ${task.fallback.models.join(" -> ")} (${task.fallback.reason})` : "";
}

function fallbackSuffix(task: TaskRecord): string {
return task.fallback ? ` · ${fallbackNote(task)}` : "";
}

function finishedText(task: TaskRecord): string {
if (task.status === "completed") return task.result ?? "(the subagent returned no text)";
return `Subagent ${task.agent} ${task.status}${task.error ? `: ${task.error}` : ""}${task.result ? `\n\nLast answer:\n${task.result}` : ""}`;
const note = task.fallback ? `\n\n[${fallbackNote(task)}]` : "";
if (task.status === "completed") return `${task.result ?? "(the subagent returned no text)"}${note}`;
return `Subagent ${task.agent} ${task.status}${task.error ? `: ${task.error}` : ""}${task.result ? `\n\nLast answer:\n${task.result}` : ""}${note}`;
}

// pi's keybinding hint needs a live theme; outside one (tests, headless) the
Expand Down Expand Up @@ -1373,6 +1383,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
...(target === undefined || foreign ? {} : { onLaunch: () => { registry.register(target, "subagent:spawn"); } }),
model: profile.model,
thinking: profile.thinking,
...(profile.fallbacks === undefined ? {} : { fallbacks: profile.fallbacks }),
sessionDir,
resumeSessionPath: resume,
...(deps.childExtensionPaths && deps.childExtensionPaths.length > 0 ? { extensionPaths: [...deps.childExtensionPaths] } : {}),
Expand Down
18 changes: 16 additions & 2 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2449,6 +2449,20 @@ function modelProfileForRoutingEntry(
return Object.keys(profile).length > 0 ? profile : undefined;
}

// The profile store knows nothing about role fallbacks, so rewriting a role
// from it must not erase the list a user keeps in subagents.json. The list
// belongs to the primary it was written for: it survives only while the
// materialized model is unchanged. That includes a cleared entry over a
// fallback-only profile, where neither side names a primary.
function withPreservedFallbacks(
profile: Record<string, string> | undefined,
existing: unknown,
): Record<string, unknown> | undefined {
if (!isRecord(existing) || !Array.isArray(existing.fallbacks)) return profile;
if (existing.model !== profile?.model) return profile;
return { ...profile, fallbacks: existing.fallbacks };
}

function updateSubagentModelProfileAtPath(
path: string,
name: string,
Expand All @@ -2467,7 +2481,7 @@ function updateSubagentModelProfileAtPath(
const modelProfiles = isRecord(config.model_profiles)
? { ...config.model_profiles }
: {};
const profile = modelProfileForRoutingEntry(entry);
const profile = withPreservedFallbacks(modelProfileForRoutingEntry(entry), modelProfiles[name]);
// A write that would leave the profile as it is (including removing a
// profile that was never there) is not an update and touches no file.
if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false;
Expand Down Expand Up @@ -2500,7 +2514,7 @@ async function updateSubagentModelProfileAtPathAsync(
const modelProfiles = isRecord(config.model_profiles)
? { ...config.model_profiles }
: {};
const profile = modelProfileForRoutingEntry(entry);
const profile = withPreservedFallbacks(modelProfileForRoutingEntry(entry), modelProfiles[name]);
// A write that would leave the profile as it is (including removing a
// profile that was never there) is not an update and touches no file.
if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false;
Expand Down
50 changes: 46 additions & 4 deletions lib/agents-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export interface AgentDefinitionError {
export interface ModelProfile {
model: ModelRef | undefined;
thinking: ThinkingLevel | undefined;
// Ordered models tried after the primary reports quota exhaustion. Absent
// means "not configured" (a lower scope may supply it); empty means "none".
fallbacks?: ModelRef[];
}

export interface AgentsConfig {
Expand All @@ -83,6 +86,8 @@ export interface ProfileSources {
export interface ResolvedProfile {
model: ModelRef | undefined;
thinking: ThinkingLevel | undefined;
// Present only when the role has fallbacks distinct from the resolved primary.
fallbacks?: ModelRef[];
source: ProfileSources;
}

Expand Down Expand Up @@ -239,22 +244,43 @@ function positiveInteger(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
}

// A non-array value is "not configured"; unusable entries are dropped and
// repeats keep their first position.
function parseFallbacks(value: unknown): ModelRef[] | undefined {
if (!Array.isArray(value)) return undefined;
const seen = new Set<string>();
const refs: ModelRef[] = [];
for (const item of value) {
const ref = parseModelRef(item);
if (!ref || seen.has(formatModelRef(ref))) continue;
seen.add(formatModelRef(ref));
refs.push(ref);
}
return refs;
}

function parseProfiles(value: unknown): Record<string, ModelProfile> {
const profiles: Record<string, ModelProfile> = {};
if (!value || typeof value !== "object") return profiles;
for (const [name, raw] of Object.entries(value as Record<string, unknown>)) {
if (!raw || typeof raw !== "object") continue;
const entry = raw as Record<string, unknown>;
const thinking = parseThinking(entry.effort ?? entry.thinking);
profiles[name] = { model: parseModelRef(entry.model), thinking: thinking !== undefined && THINKING_LEVELS.includes(thinking) ? (thinking as ThinkingLevel) : undefined };
const fallbacks = parseFallbacks(entry.fallbacks);
profiles[name] = {
model: parseModelRef(entry.model),
thinking: thinking !== undefined && THINKING_LEVELS.includes(thinking) ? (thinking as ThinkingLevel) : undefined,
...(fallbacks === undefined ? {} : { fallbacks }),
};
}
return profiles;
}

function mergeProfiles(base: Record<string, ModelProfile>, override: Record<string, ModelProfile>): Record<string, ModelProfile> {
const merged = { ...base };
for (const [name, profile] of Object.entries(override)) {
merged[name] = { model: profile.model ?? base[name]?.model, thinking: profile.thinking ?? base[name]?.thinking };
const fallbacks = profile.fallbacks ?? base[name]?.fallbacks;
merged[name] = { model: profile.model ?? base[name]?.model, thinking: profile.thinking ?? base[name]?.thinking, ...(fallbacks === undefined ? {} : { fallbacks }) };
}
return merged;
}
Expand Down Expand Up @@ -309,7 +335,20 @@ export function withPinnedModelProfiles(
// repository that pinned a different one, which is the exact conflict a pin
// exists to remove. Only `modelProfiles` moves: the orchestrator routing and
// every operational default stay global.
return { ...config, modelProfiles: parseProfiles(pinned) };
const modelProfiles = parseProfiles(pinned);
// The pin store carries no fallbacks. A role that keeps the very same primary
// keeps the fallbacks configured for it; a different primary starts clean.
for (const [name, profile] of Object.entries(modelProfiles)) {
const configured = config.modelProfiles[name];
// Both unset means the role still inherits the same primary (definition or default_model).
const samePrimary = profile.model === undefined
? configured?.model === undefined
: configured?.model !== undefined && formatModelRef(profile.model) === formatModelRef(configured.model);
if (profile.fallbacks === undefined && configured?.fallbacks !== undefined && samePrimary) {
profile.fallbacks = configured.fallbacks;
}
}
return { ...config, modelProfiles };
}

function pick<T>(candidates: Array<[T | undefined, ProfileSource]>): [T | undefined, ProfileSource] {
Expand All @@ -328,7 +367,10 @@ export function resolveAgentProfile(agent: AgentDefinition, config: AgentsConfig
[agent.thinking, PROFILE_SOURCE.DEFINITION],
[config.defaultThinking, PROFILE_SOURCE.DEFAULT],
]);
return { model, thinking, source: { model: modelSource, thinking: thinkingSource } };
// A fallback equal to the primary would only repeat the exhausted route. The
// same model id on another provider (account rotation) is a different ref.
const fallbacks = (profile?.fallbacks ?? []).filter((ref) => formatModelRef(ref) !== formatModelRef(model));
return { model, thinking, ...(fallbacks.length > 0 ? { fallbacks } : {}), source: { model: modelSource, thinking: thinkingSource } };
}

export function formatModelRef(model: ModelRef | undefined): string {
Expand Down
17 changes: 14 additions & 3 deletions lib/agents-protocol.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { isQuotaExhaustion } from "./agents-quota.ts";
import { sanitizeTerminalText } from "./terminal-theme.ts";

// Gentle Agents protocol. A child pi process streams RPC events; the host
Expand Down Expand Up @@ -64,6 +65,8 @@ export interface AgentEndEvent {
text: string;
outcome: "success" | "error" | "aborted" | "empty";
diagnostic?: string;
/** The provider explicitly reported credit/quota exhaustion (see agents-quota.ts). */
quotaExhausted?: true;
}
export interface AgentSettledEvent { type: typeof TASK_EVENT.AGENT_SETTLED }
export interface ErrorEvent { type: typeof TASK_EVENT.ERROR; message: string }
Expand Down Expand Up @@ -137,6 +140,8 @@ export interface TaskRecord {
toolCalls: number;
tokens: number;
cost: number;
/** Set once a role fallback took over: why, and every model tried in order (the last one is current). */
fallback?: { reason: string; models: string[] };
}

export interface TaskSummary {
Expand Down Expand Up @@ -175,12 +180,18 @@ function keepTail(text: string, max: number): string {
function terminalAssistant(messages: unknown): Omit<AgentEndEvent, "type"> {
if (!Array.isArray(messages)) return { text: "", outcome: "empty", diagnostic: "assistant returned no final report" };
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index] as { role?: string; content?: unknown; stopReason?: unknown };
const message = messages[index] as { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown };
if (message?.role !== "assistant") continue;
const stopReason = clean(message.stopReason).toLowerCase();
// Do not preserve unbounded provider error payloads. The terminal reason is
// enough for an operator to distinguish failure from an empty report.
if (stopReason === "error") return { text: "", outcome: "error", diagnostic: "assistant reported an error" };
// enough for an operator to distinguish failure from an empty report. The
// one thing the payload may say that routing needs is quota exhaustion, so
// it is classified here and only a fixed phrase and a flag leave this scope.
if (stopReason === "error") {
return isQuotaExhaustion(message.errorMessage)
? { text: "", outcome: "error", diagnostic: "assistant reported an error: provider quota exhausted", quotaExhausted: true }
: { text: "", outcome: "error", diagnostic: "assistant reported an error" };
}
if (stopReason === "aborted") return { text: "", outcome: "aborted", diagnostic: "assistant aborted" };
const text = contentText(message.content);
return text.length > 0
Expand Down
33 changes: 33 additions & 0 deletions lib/agents-quota.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Explicit provider credit/quota exhaustion, the only failure that moves a role
// to its next fallback model. A rate limit, a concurrency cap or an overload is
// transient and stays with Pi's own retry policy; routing must never change for
// those, so this classifier is deliberately conservative.

const MAX_CLASSIFIED_CHARS = 2_000;

// Pi's own non-retryable provider-limit vocabulary (pi-ai `isRetryableAssistantError`).
// Not retrying is not proof of exhaustion: a per-minute "quota exceeded" is a rate
// limit, so the transient guard below applies to these too.
const PI_PROVIDER_LIMIT = /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing|subscription_sharing_usage_limit_exceeded/i;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Further explicit exhaustion wording from providers outside Pi's list.
const QUOTA_EXHAUSTED = [
/^\s*402\b/,
/\bpayment required\b/i,
/\binsufficient[_\s-]+(quota|credits?|funds|balance)\b/i,
/\b(exceeded|exhausted|out of)\b.{0,40}\b(quota|credits?)\b/i,
/\bquota\b.{0,40}\b(exhausted|reached)\b/i,
/\bcredit balance\b/i,
/usage[_\s-]?limit/i,
];

// Transient limiters always win. RESOURCE_EXHAUSTED alone is not evidence either:
// Google also uses it for RPM/TPM throttling, so it needs exhaustion wording above.
const TRANSIENT_LIMIT = /\b(concurren\w*|simultaneous|per[\s-]+(minute|second)|rpm|tpm|rate[_\s-]?limit\w*|too many requests|overloaded)\b/i;

export function isQuotaExhaustion(message: unknown): boolean {
if (typeof message !== "string") return false;
const text = message.slice(0, MAX_CLASSIFIED_CHARS);
if (TRANSIENT_LIMIT.test(text)) return false;
return PI_PROVIDER_LIMIT.test(text) || QUOTA_EXHAUSTED.some((pattern) => pattern.test(text));
}
Loading