diff --git a/.changeset/improvement-surface-and-reasoning-control.md b/.changeset/improvement-surface-and-reasoning-control.md new file mode 100644 index 0000000..82ef998 --- /dev/null +++ b/.changeset/improvement-surface-and-reasoning-control.md @@ -0,0 +1,9 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Give the improvement-surface list and the native reasoning control one owner each. + +`AgentImprovementSurface` is now derived from the exported `AGENT_IMPROVEMENT_SURFACES` list, which the promotion schema's enum also reads, and it gains `rollout-policy`. A rollout-policy improvement can now be named in a proposal; before, `improve()` could produce a surface no proposal could report. + +`nativeReasoningControl(harness, effort)` returns the exact control token a harness process receives — the value a materialization receipt carries as `reasoningEffort.applied`. Adapters that build harness argv and callers that verify the receipt now read one table instead of two hand-rolled copies that had already drifted apart. A harness with no native control answers `null`. diff --git a/packages/agent-interface/src/agent-candidate-promotion-schema.ts b/packages/agent-interface/src/agent-candidate-promotion-schema.ts index 3ce3032..b604b0e 100644 --- a/packages/agent-interface/src/agent-candidate-promotion-schema.ts +++ b/packages/agent-interface/src/agent-candidate-promotion-schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { AGENT_IMPROVEMENT_SURFACES } from "./agent-candidate.js"; import type { AgentCandidateExperiment, AgentCandidateJsonValue, @@ -35,18 +36,7 @@ import { changedProfileImprovementSurfaces, } from "./agent-profile-improvement-schema.js"; -const improvementSurfaceSchema = z.enum([ - "prompt", - "skills", - "tools", - "mcp", - "hooks", - "subagents", - "agent-profile", - "memory", - "code", - "knowledge", -]); +const improvementSurfaceSchema = z.enum(AGENT_IMPROVEMENT_SURFACES); export const agentCandidateExperimentSchema = z .object({ diff --git a/packages/agent-interface/src/agent-candidate.ts b/packages/agent-interface/src/agent-candidate.ts index 0ad722e..e0107d1 100644 --- a/packages/agent-interface/src/agent-candidate.ts +++ b/packages/agent-interface/src/agent-candidate.ts @@ -828,17 +828,30 @@ export interface AgentCandidateRunReceipt { digest: Sha256Digest; } +/** + * Every surface an improvement proposal can name. One owner: the validator + * ({@link agentCandidatePromotionSchema}'s surface enum) and every producer read this + * list, so a new surface cannot be proposable in one place and unnameable in another. + * + * `rollout-policy` is the inference-time structural-rollout dials + * (`profile.extensions['structural-rollout']`); `knowledge` is the corpus lane. + */ +export const AGENT_IMPROVEMENT_SURFACES = Object.freeze([ + "prompt", + "skills", + "tools", + "mcp", + "hooks", + "subagents", + "agent-profile", + "memory", + "code", + "knowledge", + "rollout-policy", +] as const); + export type AgentImprovementSurface = - | "prompt" - | "skills" - | "tools" - | "mcp" - | "hooks" - | "subagents" - | "agent-profile" - | "memory" - | "code" - | "knowledge"; + (typeof AGENT_IMPROVEMENT_SURFACES)[number]; /** One paired Runtime execution from the exact signed experiment. */ export interface AgentCandidateExperimentMeasurement { diff --git a/packages/agent-interface/src/harness-capabilities.test.ts b/packages/agent-interface/src/harness-capabilities.test.ts index 83783fb..11bb82e 100644 --- a/packages/agent-interface/src/harness-capabilities.test.ts +++ b/packages/agent-interface/src/harness-capabilities.test.ts @@ -8,12 +8,14 @@ import { harnessSupportsModel, harnessSystemPromptIntents, modelProvider, + nativeReasoningControl, preferredHarnessForModel, reasoningEffortsFor, snapHarnessToModel, snapModelToHarness, } from "./harness-capabilities.js"; import { harnessTypeSchema } from "./harness.js"; +import { REASONING_EFFORTS } from "./agent-profile.js"; const CATALOG = [ "anthropic/claude-opus-4-6", @@ -287,3 +289,54 @@ describe("system-prompt intents", () => { expect([...appenders].sort()).toEqual(["claude-code", "opencode", "pi", "prime"]); }); }); + +describe("nativeReasoningControl", () => { + it("maps the ladder onto each harness's own control token", () => { + // Pinned against the argv builders that spawn each CLI. A rung renamed upstream must be + // changed here, where both the adapter and the receipt check read it. + expect(REASONING_EFFORTS.map((e) => nativeReasoningControl("claude-code", e))).toEqual([ + "low", "low", "low", "medium", "high", "xhigh", "max", + ]); + expect(REASONING_EFFORTS.map((e) => nativeReasoningControl("codex", e))).toEqual([ + "none", "minimal", "low", "medium", "high", "xhigh", "ultra", + ]); + expect(REASONING_EFFORTS.map((e) => nativeReasoningControl("pi", e))).toEqual([ + "off", "minimal", "low", "medium", "high", "xhigh", "xhigh", + ]); + expect(REASONING_EFFORTS.map((e) => nativeReasoningControl("prime", e))).toEqual([ + "off", "minimal", "low", "medium", "high", "xhigh", "max", + ]); + expect(REASONING_EFFORTS.map((e) => nativeReasoningControl("kimi-code", e))).toEqual([ + "--no-thinking", "--no-thinking", "--no-thinking", null, "--thinking", "--thinking", "--thinking", + ]); + expect(REASONING_EFFORTS.map((e) => nativeReasoningControl("opencode", e))).toEqual([ + ...REASONING_EFFORTS, + ]); + }); + + it("answers null for a harness that applies no native control", () => { + // gemini derives thinking from the model, and the rest plumb no thinking flag. Asserting a + // token for them refuses a legitimate run, which is why the default is `null`, not the request. + for (const harness of harnessTypeSchema.options) { + if (["claude-code", "codex", "pi", "prime", "kimi-code", "opencode"].includes(harness)) continue; + for (const effort of REASONING_EFFORTS) { + expect(nativeReasoningControl(harness, effort)).toBeNull(); + } + } + }); + + it("applies no control when nothing was requested", () => { + for (const harness of harnessTypeSchema.options) { + expect(nativeReasoningControl(harness, null)).toBeNull(); + } + }); + + it("never claims a control for a harness whose runner drops the effort", () => { + for (const harness of harnessTypeSchema.options) { + if (harnessHonorsEffort(harness)) continue; + for (const effort of REASONING_EFFORTS) { + expect(nativeReasoningControl(harness, effort)).toBeNull(); + } + } + }); +}); diff --git a/packages/agent-interface/src/harness-capabilities.ts b/packages/agent-interface/src/harness-capabilities.ts index 6610cae..4ae3281 100644 --- a/packages/agent-interface/src/harness-capabilities.ts +++ b/packages/agent-interface/src/harness-capabilities.ts @@ -151,8 +151,8 @@ export function snapHarnessToModel( * - claude-code: `--effort` accepts `low|medium|high|xhigh|max`; canonical `ultracode` maps to * native `max`. It cannot express `none` or `minimal`, and an unsupported value is warned about * and silently replaced with the default rather than rejected — so the set must not overstate. - * - pi: `--thinking` accepts `off|minimal|low|medium|high|xhigh|max`; canonical `none` maps to - * `off` and `ultracode` to `max`. + * - pi: `--thinking` accepts `off|minimal|low|medium|high|xhigh`; canonical `none` maps to + * `off` and `ultracode` clamps to `xhigh`, its top rung. * - prime: the prime fork of the pi line accepts the same `--thinking` set * (`off|minimal|low|medium|high|xhigh|max`); canonical `none` maps to `off` and `ultracode` to * `max`. @@ -231,6 +231,73 @@ export function reasoningEffortsFor( return efforts; } +// ── Native reasoning control (the exact token the harness process receives) ─── + +/** + * Canonical effort → the harness's OWN control token, or `null` when the harness applies no + * native reasoning control for that request. This is the value a materialization receipt carries + * as `reasoningEffort.applied`, so a caller can check that the effort it asked for reached the + * process instead of trusting an echo of its own request. + * + * Read from the argv builders that actually spawn each CLI, not from help text: + * - claude-code — `--effort `; it cannot express `none` or `minimal`, so both clamp to + * `low`, and `ultracode` becomes its ceiling `max`. + * - codex — `-c model_reasoning_effort=""`; it takes the canonical rungs directly and + * names its ceiling `ultra`. + * - pi — `--thinking `; `none` becomes `off` and `ultracode` clamps to `xhigh`, the + * highest rung the pi line accepts. + * - prime — `--thinking `; the fork carries `max` above `xhigh`, so `ultracode` reaches + * `max`. This is the one rung where prime and pi differ. + * - kimi-code — the control is the FLAG itself, `--thinking` or `--no-thinking`, because kimi's + * thinking switch is binary. `medium` is its default and passes no flag at all. + * - opencode — the router-backed variant name is the canonical rung unchanged. + * + * A harness with no entry applies NO native control: either it derives thinking from the model + * (gemini's `--thinking-budget`) or it plumbs no thinking flag at all (see + * {@link harnessHonorsEffort}). Both answer `null`, which is what their receipts carry — so an + * unknown harness is never asserted to have applied a control it cannot apply. + */ +const harnessNativeReasoningControl: Partial< + Record string | null> +> = { + "claude-code": (effort) => { + if (effort === "none" || effort === "minimal") return "low"; + return effort === "ultracode" ? "max" : effort; + }, + codex: (effort) => (effort === "ultracode" ? "ultra" : effort), + pi: (effort) => { + if (effort === "none") return "off"; + return effort === "ultracode" ? "xhigh" : effort; + }, + prime: (effort) => { + if (effort === "none") return "off"; + return effort === "ultracode" ? "max" : effort; + }, + "kimi-code": (effort) => { + if (effort === "medium") return null; + return effort === "none" || effort === "minimal" || effort === "low" + ? "--no-thinking" + : "--thinking"; + }, + opencode: (effort) => effort, +}; + +/** + * The native control token a harness applies for one canonical effort, or `null` when it applies + * none. `effort: null` (nothing requested) is always `null`. + * + * One owner for both sides of the check: the adapter that builds the harness argv and the caller + * that verifies the receipt read this function, so a CLI that renames a rung moves both at once + * instead of turning into a refused run. + */ +export function nativeReasoningControl( + harness: HarnessType, + effort: ReasoningEffort | null, +): string | null { + if (effort === null) return null; + return harnessNativeReasoningControl[harness]?.(effort) ?? null; +} + // ── Per-turn selector support (does the harness honor the chat pickers?) ────── /**