Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`**

Profiles with `orchestrator: true` may themselves call `task` (one hop only): nested dispatch installs `task` + `search_agents` with `allowOrchestrator: false` so the tree bottoms out. Unknown `agent` ids fail closed.

**Reasoning effort by role** (`src/provider/reasoning-effort.ts` → `resolveEffortForRole`): spawn-time defaults are orchestrator → `high`, leaf → `medium`, clamped to the model. Explicit profile inference pins win; parent session effort is only a fallback when the role default is unsupported. This keeps multi-agent fleets off the sol+high latency cliff — see `docs/plans/reasoning-effort-by-role.md`.

**Session records** (`src/subagent/session-store.ts`): each spawn is retained as an inspectable child session (id, profile, description, brief, status, tool activity, transcript entries). Child events land only in this store — not in the parent chat transcript. Live progress still uses the light `onProgress` channel for the status bar / Agents strip. Completed sessions are capped (`maxCompleted`) so a long chat does not grow without bound.

**Enter-session TUI** (`src/tui/components/agents-strip.tsx`, `subagent-session-view.tsx`): `Ctrl+E` opens Agents-strip navigation (↑/↓ select, Enter observe, `x`/Backspace cancel selected running worker, Esc leave nav). Entering a session swaps the main log for that child's transcript (live while running, historical when done/failed/cancelled) without stealing the parent reactor. Header chrome shows which agent is focused; Esc returns to the parent; `x` cancels the focused running worker. Parent Esc/stop and `/clear` call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops.
Expand Down
32 changes: 32 additions & 0 deletions docs/plans/reasoning-effort-by-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Reasoning effort defaults by agent role (CL-5162)

## Why

`gpt-5.6-sol` (and similar) at **high** reasoning effort is a **latency cliff**: thinking tokens expand wall time before useful tool calls. Multi-agent fanout multiplies that cost when every leaf inherits the primary session's high setting.

## Product defaults

| Role | Default effort | Notes |
|---|---|---|
| Orchestrator (`profile.orchestrator: true`) | `high` | Planning / fan-out warrants deeper reasoning |
| Task leaf (generic or non-orchestrator profile) | `medium` | Keeps fleet latency off the sol+high cliff |

Clamped via `supportedEfforts(model)` when the model does not accept the preferred rung.

## Precedence

1. **Explicit pin** — profile `inference` leg `reasoningEffort`, or any future task-level pin
2. **Role default** — table above, when the model supports it
3. **Parent inheritance** — parent session effort, only when the role default is not in the model's supported set
4. **Clamp** — nearest supported rung to the role default
5. **Omit** — non-reasoning models get no effort on the wire

Implementation: `resolveEffortForRole` / `pickEffortFromCascade` in `src/provider/reasoning-effort.ts`, applied in `src/subagent/task-tool.ts` after profile/tier provider resolution.

## Operator UI

**Deferred.** No settings or TUI control in this change. Operators who need a different leaf effort pin it on the agent profile's inference leg.

## Latency eval

PerfTrace-backed medium vs high quality/latency comparison is deferred to the CL-5174 wave (`docs/plans/core-performance-tracing.md` / package C eval). Do not block this default on that instrumentation.
171 changes: 171 additions & 0 deletions src/provider/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { afterEach, describe, test, expect } from "bun:test";
import {
REASONING_EFFORTS,
ROLE_DEFAULT_EFFORT,
isReasoningEffort,
supportedEfforts,
validateEffort,
setModelReasoningCapabilities,
modelReasoningCapability,
clampEffort,
pickEffortFromCascade,
resolveEffortForRole,
} from "./reasoning-effort.js";

describe("REASONING_EFFORTS", () => {
Expand Down Expand Up @@ -119,3 +123,170 @@ describe("reasoning capability gate", () => {
if (!result.ok) expect(result.error).toContain("does not support reasoning");
});
});

describe("ROLE_DEFAULT_EFFORT", () => {
test("orchestrator is higher than leaf", () => {
expect(ROLE_DEFAULT_EFFORT.orchestrator).toBe("high");
expect(ROLE_DEFAULT_EFFORT.leaf).toBe("medium");
expect(REASONING_EFFORTS.indexOf(ROLE_DEFAULT_EFFORT.orchestrator)).toBeGreaterThan(
REASONING_EFFORTS.indexOf(ROLE_DEFAULT_EFFORT.leaf),
);
});
});

describe("clampEffort", () => {
test("returns desired when supported", () => {
expect(clampEffort("medium", ["low", "medium", "high"])).toBe("medium");
});

test("picks the nearest supported rung", () => {
// medium is between low and high; equidistant → first minimum wins (low).
expect(clampEffort("medium", ["low", "high"])).toBe("low");
expect(clampEffort("xhigh", ["low", "medium", "high"])).toBe("high");
expect(clampEffort("none", ["low", "medium", "high"])).toBe("low");
});

test("empty supported yields undefined", () => {
expect(clampEffort("medium", [])).toBeUndefined();
});
});

describe("pickEffortFromCascade (precedence table)", () => {
test("1. pin wins over role default and parent", () => {
expect(
pickEffortFromCascade({
pin: "low",
roleDefault: "medium",
parentEffort: "high",
supported: ["low", "medium", "high"],
}),
).toBe("low");
});

test("1b. unsupported pin is clamped onto supported", () => {
expect(
pickEffortFromCascade({
pin: "xhigh",
roleDefault: "medium",
parentEffort: "high",
supported: ["low", "medium", "high"],
}),
).toBe("high");
});

test("2. role default when supported (ignores parent)", () => {
expect(
pickEffortFromCascade({
roleDefault: "medium",
parentEffort: "high",
supported: ["low", "medium", "high"],
}),
).toBe("medium");
});

test("3. parent inheritance when role default is unsupported but parent is", () => {
expect(
pickEffortFromCascade({
roleDefault: "medium",
parentEffort: "high",
supported: ["low", "high", "xhigh"],
}),
).toBe("high");
});

test("4. clamp role default when neither role default nor parent is supported", () => {
expect(
pickEffortFromCascade({
roleDefault: "medium",
parentEffort: "xhigh",
supported: ["none", "minimal", "low"],
}),
).toBe("low");
});

test("5. empty supported yields undefined", () => {
expect(
pickEffortFromCascade({
roleDefault: "medium",
parentEffort: "high",
supported: [],
}),
).toBeUndefined();
});
});

describe("resolveEffortForRole", () => {
afterEach(() => setModelReasoningCapabilities({}));

test("explicit pin wins over role default and parent", () => {
expect(
resolveEffortForRole({
orchestrator: false,
pin: "low",
parentEffort: "high",
model: "gpt-5",
}),
).toBe("low");
expect(
resolveEffortForRole({
orchestrator: true,
pin: "minimal",
parentEffort: "high",
model: "gpt-5",
}),
).toBe("minimal");
});

test("leaf role default is medium even when parent is high", () => {
expect(
resolveEffortForRole({
orchestrator: false,
parentEffort: "high",
model: "gpt-5",
}),
).toBe("medium");
});

test("orchestrator role default is high even when parent is low", () => {
expect(
resolveEffortForRole({
orchestrator: true,
parentEffort: "low",
model: "gpt-5",
}),
).toBe("high");
});

test("non-reasoning model yields undefined even with parent effort", () => {
setModelReasoningCapabilities({ "chat-only-model": false });
expect(
resolveEffortForRole({
orchestrator: false,
parentEffort: "high",
model: "chat-only-model",
}),
).toBeUndefined();
});

test("codex leaf still gets medium (not parent xhigh)", () => {
expect(
resolveEffortForRole({
orchestrator: false,
parentEffort: "xhigh",
model: "gpt-5.6-sol",
isCodex: true,
}),
).toBe("medium");
});

test("codex orchestrator still gets high", () => {
expect(
resolveEffortForRole({
orchestrator: true,
parentEffort: "low",
model: "gpt-5.6-sol",
isCodex: true,
}),
).toBe("high");
});
});
101 changes: 101 additions & 0 deletions src/provider/reasoning-effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,104 @@ export function validateEffort(
error: `Model "${model}" does not support reasoning effort "${effort}" (supported: ${supported.join(", ")}).`,
};
}

// ---------------------------------------------------------------------------
// Role-based product defaults (CL-5162)
//
// Orchestrators plan and fan out work — higher effort is worth the latency.
// Task leaves should stay cheaper/faster so multi-agent fleets do not multiply
// a sol+high cliff across every child. No operator UI: this is the silent
// product default until a profile/task pin says otherwise.
// ---------------------------------------------------------------------------

/** Product default effort by agent role (before model clamping). */
export const ROLE_DEFAULT_EFFORT = {
orchestrator: "high",
leaf: "medium",
} as const satisfies Record<"orchestrator" | "leaf", ReasoningEffort>;

/**
* Nearest supported effort to `desired` by position on the canonical ladder.
* Returns undefined only when `supported` is empty.
*/
export function clampEffort(
desired: ReasoningEffort,
supported: readonly ReasoningEffort[],
): ReasoningEffort | undefined {
if (supported.length === 0) return undefined;
if (supported.includes(desired)) return desired;
const desiredIdx = REASONING_EFFORTS.indexOf(desired);
let best: ReasoningEffort = supported[0]!;
let bestDist = Number.POSITIVE_INFINITY;
for (const level of supported) {
const dist = Math.abs(REASONING_EFFORTS.indexOf(level) - desiredIdx);
if (dist < bestDist) {
bestDist = dist;
best = level;
}
}
return best;
}

export type ResolveEffortForRoleOpts = {
/** True when the spawn is an orchestrator profile (may call task). */
orchestrator: boolean;
/** Explicit profile inference leg or task-tier pin — highest precedence. */
pin?: ReasoningEffort;
/** Parent session effort — used only when the role default is not supported. */
parentEffort?: ReasoningEffort;
model: string;
isCodex?: boolean;
};

/**
* Pure cascade used by `resolveEffortForRole`. Exported for unit tests of the
* precedence table without depending on per-model supported sets.
*
* Precedence (first match wins):
* 1. Explicit pin (clamped onto supported when the pin is not in the set)
* 2. Role default when present in `supported`
* 3. Parent effort when present in `supported`
* 4. Clamp of role default onto `supported`
* 5. undefined when `supported` is empty
*
* Pins are still highest precedence, but an unsupported pin is clamped so the
* pure API owns the "never emit an unsupported effort" invariant (callers that
* want hard-fail on bad pins should validateEffort first, as task-tool does).
*/
export function pickEffortFromCascade(opts: {
pin?: ReasoningEffort;
roleDefault: ReasoningEffort;
parentEffort?: ReasoningEffort;
supported: readonly ReasoningEffort[];
}): ReasoningEffort | undefined {
if (opts.supported.length === 0) return undefined;
if (opts.pin !== undefined) {
return opts.supported.includes(opts.pin) ? opts.pin : clampEffort(opts.pin, opts.supported);
}
if (opts.supported.includes(opts.roleDefault)) return opts.roleDefault;
if (opts.parentEffort !== undefined && opts.supported.includes(opts.parentEffort)) {
return opts.parentEffort;
}
return clampEffort(opts.roleDefault, opts.supported);
}

/**
* Resolve reasoning effort for a sub-agent spawn.
*
* Why parent is below role default: a /agent high selection on the primary must
* not force every leaf onto high — that multiplies the sol+high latency cliff
* across the fleet. Parent still fills gaps when the role default is not in the
* model's supported set but the parent effort is.
*/
export function resolveEffortForRole(opts: ResolveEffortForRoleOpts): ReasoningEffort | undefined {
const supported = supportedEfforts(opts.model, undefined, opts.isCodex === true);
return pickEffortFromCascade({
...(opts.pin !== undefined ? { pin: opts.pin } : {}),
roleDefault: opts.orchestrator
? ROLE_DEFAULT_EFFORT.orchestrator
: ROLE_DEFAULT_EFFORT.leaf,
...(opts.parentEffort !== undefined ? { parentEffort: opts.parentEffort } : {}),
supported,
});
}
Loading
Loading