feat(task): add per-spawn effort ceiling
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### Changed
|
||||
|
||||
- Made the task tool's per-spawn `effort` parameter opt-in through `task.enableEffort`, which defaults to false and omits the field from flat and batch schemas and tool guidance until enabled.
|
||||
- Added `task.maxEffort` to cap the task tool's optional per-spawn effort hint after model-specific resolution, so operators can enable effort hints without allowing them to exceed a configured ceiling ([#6580](https://github.com/can1357/oh-my-pi/issues/6580)).
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -4600,6 +4600,20 @@ export const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
"task.maxEffort": {
|
||||
type: "enum",
|
||||
values: THINKING_EFFORTS,
|
||||
default: "max",
|
||||
ui: {
|
||||
tab: "tasks",
|
||||
group: "Subagents",
|
||||
label: "Maximum Per-Spawn Effort",
|
||||
description:
|
||||
"Maximum reasoning effort allowed for the task tool's per-spawn effort hint. Lower values prevent callers from escalating subagents above this ceiling; the default preserves the model's full range.",
|
||||
options: THINKING_EFFORTS.map(getThinkingLevelMetadata),
|
||||
},
|
||||
},
|
||||
|
||||
"task.disabledAgents": {
|
||||
type: "array",
|
||||
default: [] as string[],
|
||||
|
||||
@@ -2647,9 +2647,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
progress.contextWindow = model.contextWindow;
|
||||
}
|
||||
// Caller-requested coarse effort maps onto the resolved model's
|
||||
// supported range; undefined (no effort, or no controllable effort
|
||||
// surface) falls through to the normal selectors below.
|
||||
const effortLevel = options.effort !== undefined ? resolveTaskEffortLevel(model, options.effort) : undefined;
|
||||
// supported range, then respects the operator-configured ceiling.
|
||||
// Undefined (no effort, or no controllable effort surface) falls
|
||||
// through to the normal selectors below.
|
||||
const effortLevel =
|
||||
options.effort !== undefined
|
||||
? resolveTaskEffortLevel(model, options.effort, settings.get("task.maxEffort"))
|
||||
: undefined;
|
||||
if (model) {
|
||||
const displayLevel = effortLevel ?? (explicitThinkingLevel ? resolvedThinkingLevel : undefined);
|
||||
progress.resolvedModel =
|
||||
|
||||
@@ -273,17 +273,29 @@ export type TaskEffort = (typeof TASK_EFFORTS)[number];
|
||||
* Returns `undefined` when the model has no controllable effort surface, so
|
||||
* callers fall back to their default selector (e.g. `auto`).
|
||||
*/
|
||||
export function resolveTaskEffortLevel(model: Model | undefined, effort: TaskEffort): Effort | undefined {
|
||||
export function resolveTaskEffortLevel(
|
||||
model: Model | undefined,
|
||||
effort: TaskEffort,
|
||||
maxEffort?: Effort,
|
||||
): Effort | undefined {
|
||||
const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
|
||||
if (supported.length === 0) return undefined;
|
||||
let resolved: Effort;
|
||||
switch (effort) {
|
||||
case "lo":
|
||||
return supported[0];
|
||||
resolved = supported[0];
|
||||
break;
|
||||
case "med":
|
||||
return supported[(supported.length - 1) >> 1];
|
||||
resolved = supported[(supported.length - 1) >> 1];
|
||||
break;
|
||||
case "hi":
|
||||
return supported[supported.length - 1];
|
||||
resolved = supported[supported.length - 1];
|
||||
break;
|
||||
}
|
||||
if (maxEffort === undefined) return resolved;
|
||||
const ceiling = clampThinkingLevelForModel(model, maxEffort);
|
||||
if (ceiling === undefined) return resolved;
|
||||
return THINKING_EFFORTS.indexOf(resolved) > THINKING_EFFORTS.indexOf(ceiling) ? ceiling : resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -233,6 +233,48 @@ describe("runSubprocess parent-discovery pass-through (issue #2190)", () => {
|
||||
expect(Object.hasOwn(result, "structuredOutput")).toBe(false);
|
||||
});
|
||||
|
||||
it("caps caller-requested effort at task.maxEffort", async () => {
|
||||
const model = getBundledModel("openai-codex", "gpt-5.6-sol");
|
||||
if (!model) throw new Error("Expected gpt-5.6-sol model to exist");
|
||||
const settings = Settings.isolated({ "task.maxEffort": "low" });
|
||||
settings.setModelRole("task", `${model.provider}/${model.id}`);
|
||||
const session = yieldEmittingSession();
|
||||
const spy = vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session));
|
||||
|
||||
const result = await runSubprocess({
|
||||
...baseOptions,
|
||||
agent: { ...baseAgent, model: ["@task"] },
|
||||
id: "subagent-effort-ceiling",
|
||||
effort: "hi",
|
||||
settings,
|
||||
modelRegistry: createModelRegistry(model),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(spy.mock.calls[0]?.[0]?.thinkingLevel).toBe(ThinkingLevel.Low);
|
||||
});
|
||||
|
||||
it("preserves the model's full effort range by default", async () => {
|
||||
const model = getBundledModel("openai-codex", "gpt-5.6-sol");
|
||||
if (!model) throw new Error("Expected gpt-5.6-sol model to exist");
|
||||
const settings = Settings.isolated();
|
||||
settings.setModelRole("task", `${model.provider}/${model.id}`);
|
||||
const session = yieldEmittingSession();
|
||||
const spy = vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session));
|
||||
|
||||
const result = await runSubprocess({
|
||||
...baseOptions,
|
||||
agent: { ...baseAgent, model: ["@task"] },
|
||||
id: "subagent-default-effort-ceiling",
|
||||
effort: "hi",
|
||||
settings,
|
||||
modelRegistry: createModelRegistry(model),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(spy.mock.calls[0]?.[0]?.thinkingLevel).toBe(ThinkingLevel.Max);
|
||||
});
|
||||
|
||||
it("resolves an explicit task-role effort suffix over the agent-definition default", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
|
||||
|
||||
Reference in New Issue
Block a user