Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(harness): clamp the thinking level to the model
Resolve the model once in State and clamp the requested level to what
it supports, so the request, the persisted message and the ACP option
all carry the level that actually runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
  • Loading branch information
sanchitrk and claude committed Oct 10, 2026
commit e6adb046d7ee61c29042054b4ccb5ca0ee47c566
4 changes: 2 additions & 2 deletions packages/codework/src/acp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ export const options = Effect.fn("ACP.config.options")(function* (
];

const info = catalog[selection.provider]?.[selection.model];
const levels = info === undefined || info.reasoning === false ? [] : Model.getSupportedThinkingLevels(info);
const levels = info === undefined ? [] : Model.getSupportedThinkingLevels(info);
if (levels.some((level) => level !== "off")) {
result.push({
id: THOUGHT_LEVEL,
name: "Thinking Effort",
category: "thought_level",
type: "select",
currentValue: levels.includes(selection.thinkingLevel) ? selection.thinkingLevel : (levels[0] ?? "off"),
currentValue: selection.thinkingLevel,
options: levels.map((level) => ({ value: level, name: thinkingLabels[level] })),
});
}
Expand Down
3 changes: 2 additions & 1 deletion packages/harness/src/settings/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ export function resolveRequest(block: Block, model: Model.Info): State.RequestOp
*
* Separate from {@link resolveRequest} because these steer the loop itself -- how hard to
* think, how to schedule tool calls -- rather than the request body. Runtime bindings
* outrank a matched block, which outranks the file's top level.
* outrank a matched block, which outranks the file's top level. The thinking level is the
* requested one; State clamps it to the model once the catalog lookup is in hand.
*/
export const resolveControls = (settings: Info, block: Block, runtime: State.Options) => ({
thinkingLevel: runtime.thinkingLevel ?? block.thinkingLevel ?? settings.model.thinkingLevel,
Expand Down
32 changes: 20 additions & 12 deletions packages/harness/src/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* drain.
*/

import type { Model, Protocol } from "@codeworksh/aikit";
import { Model, type Protocol } from "@codeworksh/aikit";
import { Context, Effect, Layer, Option, Ref, Schema, Semaphore } from "effect";
import { Event } from "../event/event.ts";
import { EventList } from "../event/list.ts";
Expand Down Expand Up @@ -161,9 +161,14 @@ export interface Interface {
/**
* The model and thinking level a session's next exchange runs with: its runtime bindings over
* its chosen `SessionSchema.Config` over the caller's options over settings, resolved the
* way {@link snapshot} resolves them. Nothing is looked up in the catalog.
* way {@link snapshot} resolves them. The level is the one the model will actually run at.
*/
readonly configuration: (sessionId: SessionId) => Effect.Effect<Configuration, Settings.SettingsError>;
readonly configuration: (
sessionId: SessionId,
) => Effect.Effect<
Configuration,
Settings.SettingsError | Runner.ModelCatalogError | Runner.ModelNotFoundError | Runner.ProviderError
>;
/**
* Capture runtime state for one exchange. Called inside a session drain,
* where the mount it reads is already open.
Expand Down Expand Up @@ -343,7 +348,17 @@ export const layer = (
// Not wrapped: a file the user can fix is more useful to a client as a settings
// failure carrying its path and key than as an anonymous snapshot failure.
const loadedSettings = yield* settings.load(hostDir);
return { hostDir, loadedSettings, configured: compose(loadedSettings, options, sessionOptions) };
const composed = compose(loadedSettings, options, sessionOptions);
const resolvedModel = yield* LLM.resolve({
provider: composed.provider,
model: composed.model,
settings: composed.block,
models: loadedSettings.models,
});
// The chosen level stays as written. What runs, is stamped on the message and is shown is
// the nearest level this model supports, re-fitted whenever the model changes.
const thinkingLevel = Model.clampThinkingLevel(resolvedModel, composed.thinkingLevel);
return { hostDir, loadedSettings, configured: { ...composed, resolvedModel, thinkingLevel } };
});
return Service.of({
reload: Effect.gen(function* () {
Expand Down Expand Up @@ -388,18 +403,11 @@ export const layer = (
toolExecution: _toolExecution,
...rest
} = configured.runtime;
const { provider, model, thinkingLevel, toolExecution } = configured;
const { provider, model, resolvedModel, thinkingLevel, toolExecution } = configured;
const request = merge(resolveOptions(configured.block), rest);
const sandbox = yield* SandboxIO.Current;
const location = yield* Location.Service;

const resolvedModel = yield* LLM.resolve({
provider,
model,
settings: configured.block,
models: loadedSettings.models,
});

const refs = references(loadedSettings);
const failed = (cause: { readonly message: string }) =>
new SnapshotError({ sessionId, reason: cause.message, cause });
Expand Down
49 changes: 49 additions & 0 deletions packages/harness/test/__artifacts__/settings.thinking.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[
{
"requested": "high",
"model": "openai/gpt-4o-mini",
"configuration": "off",
"request": {
"reasoning": null,
"thinkingLevel": "off"
},
"persisted": "off"
},
{
"requested": "minimal",
"model": "openai/gpt-5.4",
"configuration": "low",
"request": {
"reasoning": "low",
"thinkingLevel": "low"
},
"persisted": "low"
},
{
"requested": "max",
"model": "openai/gpt-5.4",
"configuration": "xhigh",
"request": {
"reasoning": "xhigh",
"thinkingLevel": "xhigh"
},
"persisted": "xhigh"
},
{
"requested": "max",
"model": "openai/gpt-5.6-luna",
"configuration": "max",
"request": {
"reasoning": "max",
"thinkingLevel": "max"
},
"persisted": "max"
},
{
"chosen": "max",
"models": {
"openai/gpt-4o-mini": "off",
"openai/gpt-5.6-luna": "max"
}
}
]
20 changes: 10 additions & 10 deletions packages/harness/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("Harness Effect SDK", () => {
Effect.gen(function* () {
const created = yield* Session.create({
directory: process.cwd(),
model: { provider: "openai", id: "gpt-4o-mini" },
model: { provider: "openai", id: "gpt-5.6-luna" },
thinkingLevel: "max",
});
yield* created.run("first");
Expand All @@ -51,8 +51,8 @@ describe("Harness Effect SDK", () => {
yield* attached.run("second");

expect(inputs.map(({ provider, model, thinkingLevel }) => ({ provider, model, thinkingLevel }))).toEqual([
{ provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" },
{ provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" },
{ provider: "openai", model: "gpt-5.6-luna", thinkingLevel: "max" },
{ provider: "openai", model: "gpt-5.6-luna", thinkingLevel: "max" },
]);
}),
llm,
Expand All @@ -76,7 +76,7 @@ describe("Harness Effect SDK", () => {
const sessionId = yield* Effect.gen(function* () {
const session = yield* Session.create({
directory: process.cwd(),
model: { provider: "openai", id: "gpt-4o-mini" },
model: { provider: "openai", id: "gpt-5.6-luna" },
thinkingLevel: "max",
});
yield* session.run("first");
Expand All @@ -97,7 +97,7 @@ describe("Harness Effect SDK", () => {
yield* Effect.gen(function* () {
const session = yield* Session.attach({
sessionId,
model: { provider: "openai", id: "gpt-4o" },
model: { provider: "openai", id: "gpt-5.4" },
thinkingLevel: "low",
});
yield* session.run("third");
Expand All @@ -107,7 +107,7 @@ describe("Harness Effect SDK", () => {
const session = yield* Session.attach({ sessionId });
expect(yield* Session.configuration(sessionId)).toEqual({
provider: "openai",
model: "gpt-4o",
model: "gpt-5.4",
thinkingLevel: "low",
});
yield* session.run("fourth");
Expand All @@ -121,10 +121,10 @@ describe("Harness Effect SDK", () => {
*/
expect(inputs.map(({ provider, model, thinkingLevel }) => ({ provider, model, thinkingLevel }))).toEqual(
[
{ provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" },
{ provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" },
{ provider: "openai", model: "gpt-4o", thinkingLevel: "low" },
{ provider: "openai", model: "gpt-4o", thinkingLevel: "low" },
{ provider: "openai", model: "gpt-5.6-luna", thinkingLevel: "max" },
{ provider: "openai", model: "gpt-5.6-luna", thinkingLevel: "max" },
{ provider: "openai", model: "gpt-5.4", thinkingLevel: "low" },
{ provider: "openai", model: "gpt-5.4", thinkingLevel: "low" },
],
);
});
Expand Down
70 changes: 70 additions & 0 deletions packages/harness/test/settings.loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,4 +514,74 @@ describe("settings at exchange boundaries", () => {
}).pipe(Effect.scoped, Effect.timeout("10 seconds")),
);
}));

it("clamps the thinking level to the selected model and persists the clamped one", () =>
withSettings(async ({ root, custom }) => {
const inputs: Array<LLM.Input & { readonly reasoning: string | undefined }> = [];
const open: LLM.Open = (input, signal) =>
Effect.sync(() => {
inputs.push({ ...input, reasoning: LLM.runtimeOptions(input, input.resolvedModel, signal).reasoning });
return terminal(input, inputs.length);
});
const select = (model: object) => writeFile(join(custom, "settings.jsonc"), JSON.stringify({ model }));
const cases = [
// A model without reasoning runs the default "high" at "off".
{ provider: "openai", id: "gpt-4o-mini" },
// Nearest supported level, upward first: "minimal" is null on gpt-5.4.
{ provider: "openai", id: "gpt-5.4", thinkingLevel: "minimal" },
// Then downward: gpt-5.4 maps no "max".
{ provider: "openai", id: "gpt-5.4", thinkingLevel: "max" },
// A supported level is left alone.
{ provider: "openai", id: "gpt-5.6-luna", thinkingLevel: "max" },
];
await Effect.runPromise(
Effect.gen(function* () {
const handle = yield* Session.create({ directory: root });
const rows: Array<Record<string, unknown>> = [];
for (const [index, item] of cases.entries()) {
yield* Effect.promise(() => select(item));
yield* handle.run(`turn ${index}`);
const persisted = (yield* handle.path()).at(-1)?.entry;
const envelope: { readonly thinkingLevel?: string } = JSON.parse(persisted?.data ?? "{}");
rows.push({
requested: item.thinkingLevel ?? defaults.model.thinkingLevel,
model: `${item.provider}/${item.id}`,
configuration: (yield* Session.configuration(handle.id)).thinkingLevel,
request: {
reasoning: inputs.at(-1)?.reasoning ?? null,
thinkingLevel: inputs.at(-1)?.thinkingLevel,
},
persisted: persisted?.type === "assistant" ? envelope.thinkingLevel : persisted?.type,
});
}
// A session's own choice is clamped the same way, and re-fitted on a model change.
yield* Session.attach({ sessionId: handle.id, thinkingLevel: "max" });
yield* Effect.promise(() => select({ provider: "openai", id: "gpt-4o-mini" }));
const chosenOff = (yield* Session.configuration(handle.id)).thinkingLevel;
yield* Effect.promise(() => select({ provider: "openai", id: "gpt-5.6-luna" }));
const chosenMax = (yield* Session.configuration(handle.id)).thinkingLevel;
rows.push({
chosen: "max",
models: { "openai/gpt-4o-mini": chosenOff, "openai/gpt-5.6-luna": chosenMax },
});
yield* Effect.promise(() =>
expect(JSON.stringify(rows, null, "\t")).toMatchFileSnapshot(
"./__artifacts__/settings.thinking.json",
),
);
}).pipe(
Effect.provide(
Harness.layer({
home: join(root, "home"),
hostCwd: root,
database: ":memory:",
userConfigDir: custom,
llm: open,
}),
),
Effect.scoped,
Effect.timeout("10 seconds"),
),
);
}));
});