Skip to content

Commit f644088

Browse files
Reasoning effort defaults by agent role (CL-5162) (#302)
* Default reasoning effort by agent role for task leaves Orchestrators stay on high; leaves default to medium so parent high selections do not multiply sol+high latency across the fleet. Explicit profile pins still win. Precedence is covered by unit tests. * Clamp unsupported effort pins in the role cascade The pure resolver now owns the never-emit-unsupported-effort invariant so callers that skip validateEffort cannot put a bad pin on the wire. * Clarify task agent schema role-effort cascade Profiles set role; orchestrator/leaf defaults drive reasoning effort unless the profile pins inference.reasoningEffort.
1 parent 86a525d commit f644088

7 files changed

Lines changed: 415 additions & 16 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`**
170170

171171
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.
172172

173+
**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`.
174+
173175
**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.
174176

175177
**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.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Reasoning effort defaults by agent role (CL-5162)
2+
3+
## Why
4+
5+
`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.
6+
7+
## Product defaults
8+
9+
| Role | Default effort | Notes |
10+
|---|---|---|
11+
| Orchestrator (`profile.orchestrator: true`) | `high` | Planning / fan-out warrants deeper reasoning |
12+
| Task leaf (generic or non-orchestrator profile) | `medium` | Keeps fleet latency off the sol+high cliff |
13+
14+
Clamped via `supportedEfforts(model)` when the model does not accept the preferred rung.
15+
16+
## Precedence
17+
18+
1. **Explicit pin** — profile `inference` leg `reasoningEffort`, or any future task-level pin
19+
2. **Role default** — table above, when the model supports it
20+
3. **Parent inheritance** — parent session effort, only when the role default is not in the model's supported set
21+
4. **Clamp** — nearest supported rung to the role default
22+
5. **Omit** — non-reasoning models get no effort on the wire
23+
24+
Implementation: `resolveEffortForRole` / `pickEffortFromCascade` in `src/provider/reasoning-effort.ts`, applied in `src/subagent/task-tool.ts` after profile/tier provider resolution.
25+
26+
## Operator UI
27+
28+
**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.
29+
30+
## Latency eval
31+
32+
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.

src/provider/reasoning-effort.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { afterEach, describe, test, expect } from "bun:test";
22
import {
33
REASONING_EFFORTS,
4+
ROLE_DEFAULT_EFFORT,
45
isReasoningEffort,
56
supportedEfforts,
67
validateEffort,
78
setModelReasoningCapabilities,
89
modelReasoningCapability,
10+
clampEffort,
11+
pickEffortFromCascade,
12+
resolveEffortForRole,
913
} from "./reasoning-effort.js";
1014

1115
describe("REASONING_EFFORTS", () => {
@@ -119,3 +123,170 @@ describe("reasoning capability gate", () => {
119123
if (!result.ok) expect(result.error).toContain("does not support reasoning");
120124
});
121125
});
126+
127+
describe("ROLE_DEFAULT_EFFORT", () => {
128+
test("orchestrator is higher than leaf", () => {
129+
expect(ROLE_DEFAULT_EFFORT.orchestrator).toBe("high");
130+
expect(ROLE_DEFAULT_EFFORT.leaf).toBe("medium");
131+
expect(REASONING_EFFORTS.indexOf(ROLE_DEFAULT_EFFORT.orchestrator)).toBeGreaterThan(
132+
REASONING_EFFORTS.indexOf(ROLE_DEFAULT_EFFORT.leaf),
133+
);
134+
});
135+
});
136+
137+
describe("clampEffort", () => {
138+
test("returns desired when supported", () => {
139+
expect(clampEffort("medium", ["low", "medium", "high"])).toBe("medium");
140+
});
141+
142+
test("picks the nearest supported rung", () => {
143+
// medium is between low and high; equidistant → first minimum wins (low).
144+
expect(clampEffort("medium", ["low", "high"])).toBe("low");
145+
expect(clampEffort("xhigh", ["low", "medium", "high"])).toBe("high");
146+
expect(clampEffort("none", ["low", "medium", "high"])).toBe("low");
147+
});
148+
149+
test("empty supported yields undefined", () => {
150+
expect(clampEffort("medium", [])).toBeUndefined();
151+
});
152+
});
153+
154+
describe("pickEffortFromCascade (precedence table)", () => {
155+
test("1. pin wins over role default and parent", () => {
156+
expect(
157+
pickEffortFromCascade({
158+
pin: "low",
159+
roleDefault: "medium",
160+
parentEffort: "high",
161+
supported: ["low", "medium", "high"],
162+
}),
163+
).toBe("low");
164+
});
165+
166+
test("1b. unsupported pin is clamped onto supported", () => {
167+
expect(
168+
pickEffortFromCascade({
169+
pin: "xhigh",
170+
roleDefault: "medium",
171+
parentEffort: "high",
172+
supported: ["low", "medium", "high"],
173+
}),
174+
).toBe("high");
175+
});
176+
177+
test("2. role default when supported (ignores parent)", () => {
178+
expect(
179+
pickEffortFromCascade({
180+
roleDefault: "medium",
181+
parentEffort: "high",
182+
supported: ["low", "medium", "high"],
183+
}),
184+
).toBe("medium");
185+
});
186+
187+
test("3. parent inheritance when role default is unsupported but parent is", () => {
188+
expect(
189+
pickEffortFromCascade({
190+
roleDefault: "medium",
191+
parentEffort: "high",
192+
supported: ["low", "high", "xhigh"],
193+
}),
194+
).toBe("high");
195+
});
196+
197+
test("4. clamp role default when neither role default nor parent is supported", () => {
198+
expect(
199+
pickEffortFromCascade({
200+
roleDefault: "medium",
201+
parentEffort: "xhigh",
202+
supported: ["none", "minimal", "low"],
203+
}),
204+
).toBe("low");
205+
});
206+
207+
test("5. empty supported yields undefined", () => {
208+
expect(
209+
pickEffortFromCascade({
210+
roleDefault: "medium",
211+
parentEffort: "high",
212+
supported: [],
213+
}),
214+
).toBeUndefined();
215+
});
216+
});
217+
218+
describe("resolveEffortForRole", () => {
219+
afterEach(() => setModelReasoningCapabilities({}));
220+
221+
test("explicit pin wins over role default and parent", () => {
222+
expect(
223+
resolveEffortForRole({
224+
orchestrator: false,
225+
pin: "low",
226+
parentEffort: "high",
227+
model: "gpt-5",
228+
}),
229+
).toBe("low");
230+
expect(
231+
resolveEffortForRole({
232+
orchestrator: true,
233+
pin: "minimal",
234+
parentEffort: "high",
235+
model: "gpt-5",
236+
}),
237+
).toBe("minimal");
238+
});
239+
240+
test("leaf role default is medium even when parent is high", () => {
241+
expect(
242+
resolveEffortForRole({
243+
orchestrator: false,
244+
parentEffort: "high",
245+
model: "gpt-5",
246+
}),
247+
).toBe("medium");
248+
});
249+
250+
test("orchestrator role default is high even when parent is low", () => {
251+
expect(
252+
resolveEffortForRole({
253+
orchestrator: true,
254+
parentEffort: "low",
255+
model: "gpt-5",
256+
}),
257+
).toBe("high");
258+
});
259+
260+
test("non-reasoning model yields undefined even with parent effort", () => {
261+
setModelReasoningCapabilities({ "chat-only-model": false });
262+
expect(
263+
resolveEffortForRole({
264+
orchestrator: false,
265+
parentEffort: "high",
266+
model: "chat-only-model",
267+
}),
268+
).toBeUndefined();
269+
});
270+
271+
test("codex leaf still gets medium (not parent xhigh)", () => {
272+
expect(
273+
resolveEffortForRole({
274+
orchestrator: false,
275+
parentEffort: "xhigh",
276+
model: "gpt-5.6-sol",
277+
isCodex: true,
278+
}),
279+
).toBe("medium");
280+
});
281+
282+
test("codex orchestrator still gets high", () => {
283+
expect(
284+
resolveEffortForRole({
285+
orchestrator: true,
286+
parentEffort: "low",
287+
model: "gpt-5.6-sol",
288+
isCodex: true,
289+
}),
290+
).toBe("high");
291+
});
292+
});

src/provider/reasoning-effort.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,104 @@ export function validateEffort(
9595
error: `Model "${model}" does not support reasoning effort "${effort}" (supported: ${supported.join(", ")}).`,
9696
};
9797
}
98+
99+
// ---------------------------------------------------------------------------
100+
// Role-based product defaults (CL-5162)
101+
//
102+
// Orchestrators plan and fan out work — higher effort is worth the latency.
103+
// Task leaves should stay cheaper/faster so multi-agent fleets do not multiply
104+
// a sol+high cliff across every child. No operator UI: this is the silent
105+
// product default until a profile/task pin says otherwise.
106+
// ---------------------------------------------------------------------------
107+
108+
/** Product default effort by agent role (before model clamping). */
109+
export const ROLE_DEFAULT_EFFORT = {
110+
orchestrator: "high",
111+
leaf: "medium",
112+
} as const satisfies Record<"orchestrator" | "leaf", ReasoningEffort>;
113+
114+
/**
115+
* Nearest supported effort to `desired` by position on the canonical ladder.
116+
* Returns undefined only when `supported` is empty.
117+
*/
118+
export function clampEffort(
119+
desired: ReasoningEffort,
120+
supported: readonly ReasoningEffort[],
121+
): ReasoningEffort | undefined {
122+
if (supported.length === 0) return undefined;
123+
if (supported.includes(desired)) return desired;
124+
const desiredIdx = REASONING_EFFORTS.indexOf(desired);
125+
let best: ReasoningEffort = supported[0]!;
126+
let bestDist = Number.POSITIVE_INFINITY;
127+
for (const level of supported) {
128+
const dist = Math.abs(REASONING_EFFORTS.indexOf(level) - desiredIdx);
129+
if (dist < bestDist) {
130+
bestDist = dist;
131+
best = level;
132+
}
133+
}
134+
return best;
135+
}
136+
137+
export type ResolveEffortForRoleOpts = {
138+
/** True when the spawn is an orchestrator profile (may call task). */
139+
orchestrator: boolean;
140+
/** Explicit profile inference leg or task-tier pin — highest precedence. */
141+
pin?: ReasoningEffort;
142+
/** Parent session effort — used only when the role default is not supported. */
143+
parentEffort?: ReasoningEffort;
144+
model: string;
145+
isCodex?: boolean;
146+
};
147+
148+
/**
149+
* Pure cascade used by `resolveEffortForRole`. Exported for unit tests of the
150+
* precedence table without depending on per-model supported sets.
151+
*
152+
* Precedence (first match wins):
153+
* 1. Explicit pin (clamped onto supported when the pin is not in the set)
154+
* 2. Role default when present in `supported`
155+
* 3. Parent effort when present in `supported`
156+
* 4. Clamp of role default onto `supported`
157+
* 5. undefined when `supported` is empty
158+
*
159+
* Pins are still highest precedence, but an unsupported pin is clamped so the
160+
* pure API owns the "never emit an unsupported effort" invariant (callers that
161+
* want hard-fail on bad pins should validateEffort first, as task-tool does).
162+
*/
163+
export function pickEffortFromCascade(opts: {
164+
pin?: ReasoningEffort;
165+
roleDefault: ReasoningEffort;
166+
parentEffort?: ReasoningEffort;
167+
supported: readonly ReasoningEffort[];
168+
}): ReasoningEffort | undefined {
169+
if (opts.supported.length === 0) return undefined;
170+
if (opts.pin !== undefined) {
171+
return opts.supported.includes(opts.pin) ? opts.pin : clampEffort(opts.pin, opts.supported);
172+
}
173+
if (opts.supported.includes(opts.roleDefault)) return opts.roleDefault;
174+
if (opts.parentEffort !== undefined && opts.supported.includes(opts.parentEffort)) {
175+
return opts.parentEffort;
176+
}
177+
return clampEffort(opts.roleDefault, opts.supported);
178+
}
179+
180+
/**
181+
* Resolve reasoning effort for a sub-agent spawn.
182+
*
183+
* Why parent is below role default: a /agent high selection on the primary must
184+
* not force every leaf onto high — that multiplies the sol+high latency cliff
185+
* across the fleet. Parent still fills gaps when the role default is not in the
186+
* model's supported set but the parent effort is.
187+
*/
188+
export function resolveEffortForRole(opts: ResolveEffortForRoleOpts): ReasoningEffort | undefined {
189+
const supported = supportedEfforts(opts.model, undefined, opts.isCodex === true);
190+
return pickEffortFromCascade({
191+
...(opts.pin !== undefined ? { pin: opts.pin } : {}),
192+
roleDefault: opts.orchestrator
193+
? ROLE_DEFAULT_EFFORT.orchestrator
194+
: ROLE_DEFAULT_EFFORT.leaf,
195+
...(opts.parentEffort !== undefined ? { parentEffort: opts.parentEffort } : {}),
196+
supported,
197+
});
198+
}

0 commit comments

Comments
 (0)