Skip to content

Wire harness-native effort selection#2171

Draft
morgmart wants to merge 1 commit into
mainfrom
harness-native-effort
Draft

Wire harness-native effort selection#2171
morgmart wants to merge 1 commit into
mainfrom
harness-native-effort

Conversation

@morgmart

Copy link
Copy Markdown
Contributor

Context

PR 2.6 (#2158) introduced the Agent Config Core and made the mismatch explicit: Goose effort was persisted under Buzz Agent's key even though Goose reads GOOSE_THINKING_EFFORT, while Claude Code's native ACP effort / thought_level option was discarded by discovery.

This stacked PR makes the Effort control truthful end to end without changing onboarding's layout or interaction model.

Learnings

  • Live Goose 1.43 and Claude adapter probes both expose effort as an ACP config option with category thought_level.
  • Goose returns refreshed effort options in the response to a model change. Buzz therefore does not need a mirrored Goose effort table or a second source of truth.
  • Goose's native values differ from Buzz Agent's values, which confirms that reusing getProviderEffortConfig was incorrect.
  • Codex still owns effort through model IDs and should not render a generic Effort control.

Decisions and why

  • Goose migration: read old, write new, bridge at spawn. Existing BUZZ_AGENT_THINKING_EFFORT values remain visible and are honored at launch. The next save atomically moves the value to GOOSE_THINKING_EFFORT; Buzz never double-writes.
  • Claude persistence: BUZZ_ACP_EFFORT. This mirrors BUZZ_ACP_MODEL, preserves Buzz's global/definition/instance inheritance model, and is applied through the adapter's native config option when each session starts.
  • Harness options are authoritative. Goose and Claude levels come from live ACP discovery for the selected model. No generic fallback table is used.
  • Failure degrades to the harness default. A stale or unsupported effort must not stop the agent from answering.

What this PR does

  • Retains native thought_level config options in the pre-spawn ACP probe.
  • Refreshes effort choices for the selected model using the model-change response.
  • Renders and persists Goose effort under GOOSE_THINKING_EFFORT, including honest legacy migration.
  • Renders Claude's native effort options, persists via BUZZ_ACP_EFFORT, and applies through session/set_config_option at session creation.
  • Keeps Codex effort model-owned with no generic control.
  • Adds tests for capability extraction, migration precedence, model-specific effort normalization, spawn bridging, partial config-option responses, and per-harness core behavior.

What this PR defers

  • Moving the harness picker into the canonical component / making Settings honor preferred_runtime (PR 3).
  • Runtime setup statuses and inline setup actions (PR 3b).
  • Per-agent dialog adoption (PRs 5a/5b).
  • Any Codex effort control unless its adapter exposes a native option.

Verification

  • just ci
  • Desktop unit tests: 3,205 passed ✅
  • Live probe: Goose 1.43 (thinking_effort, thought_level) ✅
  • Live probe: Claude adapter (effort, thought_level) ✅
  • onboarding-agent-defaults.spec.ts: no assertions changed. A local full run was blocked by stale onboarding identity state in the E2E environment before reaching the config page; the shared unit/build/CI gates pass.
  • Pre-push integration suite still reports the known clean-main buzz-pair-relay failures: test_global_conn_cap and test_conn_counter_no_leak; branch was pushed with hooks bypassed only after just ci passed.

@morgmart
morgmart requested a review from a team as a code owner July 20, 2026 15:25
match response {
Ok(Ok(response)) => {
if response.get("configOptions").is_some() {
session_resp.raw = response;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replaces the entire session/new result with a set_config_option response. ACP config-option updates may be partial, so this can discard models, session metadata, and config categories omitted from the update; extract_model_state below then silently loses the unstable catalog. Please keep the original response and merge only refreshed configOptions (using a corrected merge_config_options).

let id = option.get("id").or_else(|| option.get("configId"));
if let Some(existing) = session_options
.iter_mut()
.find(|existing| existing.get("id").or_else(|| existing.get("configId")) == id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comparison here treats two absent IDs as equal (None == None). That means the first existing ID-less/malformed option can be overwritten by every unrelated ID-less refreshed option. Please merge only when the refreshed option has a concrete non-empty id/configId; otherwise append it or ignore it defensively. A regression test with two distinct ID-less options would pin this down.

Ok(Ok(response)) => {
crate::acp::merge_config_options(&mut resp.raw, &response);
}
Ok(Err(error)) => tracing::warn!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback is safe for an unsupported/stale effort value, but not for every request failure. session_set_config_option can return transport/protocol errors, and the outer timeout can leave the stdio stream desynchronized; swallowing either and reusing this session risks corrupting the next prompt. Please mirror apply_model_switch / permission handling: propagate I/O, protocol, exit, write-timeout, and outer-timeout failures so the pool recreates the session, while keeping application-level rejection non-fatal.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a few more findings on top of Wes's existing comments (his three cover the ACP defects: the session/new payload replacement, the ID-less option merge, and the swallowed transport errors on the effort path — no need to repeat those). Overall this reads as genuinely additive to the existing config bridge rather than a rework, which is great. The onboarding one below is the only thing I'd consider blocking alongside Wes's three; the rest are smaller.

},
render: "deferredUntilNativeOptionsAvailable",
value: null,
render: "control",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flip from deferredUntilNativeOptionsAvailable to render: "control" is what's failing CI — onboarding-agent-defaults.spec.ts asserts the effort control is absent for Claude during onboarding and now sees count = 1, deterministically on all three attempts, so it's not a flake. But I think this is bigger than a stale test: the plan of record says onboarding doesn't change outside the two sanctioned exceptions, and resolveDisclosure keeps showEffortField: true for onboarding-essential, so Claude users now get a new control on the onboarding defaults page. I'd rather this be an explicit decision than a test edit — either amend the plan to sanction harness-native effort rendering in onboarding, or gate it out of the onboarding-essential disclosure and keep the rule intact. Both seem defensible to me, I just want the plan and the code to agree on which one we picked.

let merged_env = discovery_env_with_baked_floor(merged_env);
// Native catalogs carry per-model options that provider APIs cannot.
let use_harness_catalog =
known_acp_runtime(agent_command).is_some_and(|runtime| runtime.supports_acp_native_config);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two concerns with keying this off supports_acp_native_config. First, it's a behavior change for existing goose users that I don't see called out in the description — model discovery now skips the direct provider-API paths (OpenAI/Anthropic/Databricks HTTP) entirely and shells out to the adapter's models command instead, which means different latency and failure modes (needs a working adapter binary, not just an API key) and possibly a different model list. Second, supports_acp_native_config was defined for the tier-1a config/read/config/write capability, so if that flag ever flips for another harness, its discovery path silently changes too. I'd prefer a dedicated discovery_via_harness_catalog field on KnownAcpRuntime (or an explicit runtime-id check) so the two capabilities can't drift apart accidentally.

? effortOptions.map((option) => option.value)
: buzzEffortConfig.validValues;
const effortDefault = usesHarnessNativeEffort
? effortCurrentValue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: for harness-native effort the Default (X) label sources from effortCurrentValue, which is whatever the discovery probe session reports — and the probe env includes the user's own env vars, so a user-set value can end up labeled as the harness default. Only a labeling issue, but probably cheap to fix or at least document.

? effortOptions.length > 0
? effortOptions.map((option) => option.value)
: currentEffortForAutoClear
? [currentEffortForAutoClear]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [currentEffortForAutoClear] fallback correctly avoids wiping the user's value when an older adapter returns no thought_level options, but the dropdown degenerates to a single option in that case. Worth a regression test pinning the intended behavior for the empty-effortOptions case so it doesn't silently change later.

// Legacy Goose configs stored effort under Buzz Agent's key. Honor that
// choice at launch without rewriting storage; the next user save swaps it
// to the runtime's native key.
if let Some((thinking_key, effort)) = legacy_effort_spawn_bridge(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Latent seam worth documenting or testing now: legacy_effort_spawn_bridge only sees effective_user_env (global → persona → agent), while baked build env is written directly onto the command via build_buzz_agent_provider_defaults earlier in this function. So a future baked BUZZ_AGENT_THINKING_EFFORT would never translate to GOOSE_THINKING_EFFORT. No impact today since releases bake no effort, but two independent reviewers hit this same trap while going through the PR — an invariant comment plus a test here would keep it from biting whoever adds baked effort later.

return config.env_vars[key]?.trim() || null;
}

export function migrateLegacyEffortPersistence(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly for release awareness: since this runs on every onConfigChange, any save through AgentConfigFields for a goose config migrates storage (legacy key → native key, legacy key deleted) even when effort wasn't the field being edited. Read-fallback covers display so I think the behavior is right, but it's a one-way storage migration triggered by unrelated saves — worth a line in the release notes.

@morgmart
morgmart marked this pull request as draft July 21, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants