diff --git a/.orca/settings.properties b/.orca/settings.properties index fa50a137..81759869 100644 --- a/.orca/settings.properties +++ b/.orca/settings.properties @@ -1,6 +1,6 @@ # orca settings — edit freely, commit with the project. # format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery. -# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both. +# planningAgent/codingAgent/reviewAgent (harness[:model][@effort]): override the global settings file; a flow's own code overrides both. # project/plugins.sbt; sbt-scalafmt plugin v2.6.2; format configuration in .scalafmt.conf format = sbt scalafmt # build.sbt; compiles main and test sources with strict flags (-Wunused:all, -Wvalue-discard, -Wnonunit-statement) without executing tests; lines 8-17 diff --git a/adr/0014-opencode-server-driver.md b/adr/0014-opencode-server-driver.md index 7ba8bdf4..75accb6a 100644 --- a/adr/0014-opencode-server-driver.md +++ b/adr/0014-opencode-server-driver.md @@ -207,7 +207,7 @@ families. Plus a public `withModel(providerModel: String)` for any other `provid no allowlist, so arbitrary ids pass straight through to the message body. `AgentConfig.model = None` lets the server use its configured default. `variant` -(reasoning effort) maps from a future config field; out of scope for v1. +carries `AgentConfig.effort`; see `EffortSupport.opencode`. **Model-identifier utilities.** For self-hosted / custom providers, provide a tiny constructor so callers don't hand-concatenate strings and `OpencodeArgs` doesn't diff --git a/adr/0020-configurable-role-agents.md b/adr/0020-configurable-role-agents.md index da66aedb..9dd4679e 100644 --- a/adr/0020-configurable-role-agents.md +++ b/adr/0020-configurable-role-agents.md @@ -51,7 +51,7 @@ codingAgent = codex reviewAgent = opencode:anthropic/claude-sonnet-4-5 ``` -Value grammar: `harness:model`, split at the FIRST `:` (so a model id +Value grammar: `harness[:model][@effort]`, split at the FIRST `:` (so a model id containing `:` survives), model optional. `harness` ∈ `claude | codex | opencode | pi | gemini` — an unrecognised name is a parse error naming the valid set. The model part is passed **verbatim** to the harness's @@ -59,7 +59,11 @@ valid set. The model part is passed **verbatim** to the harness's `Model`'s existing contract: for claude the CLI itself accepts aliases (`opus`, `sonnet`), for opencode it is the provider-qualified `provider/model` form its existing validation checks. An empty value is equivalent to the key -being absent (the existing parser rule for stack keys too). +being absent (the existing parser rule for stack keys too). The optional +`@` sets the reasoning effort and is split off first. An all-letter +suffix after the last `@` is read as a level, so a model id like +`claude-sonnet-4@20250514` survives; an unknown level, or one the harness does +not accept, is a parse error. ### 2. Locations & precedence @@ -235,12 +239,14 @@ naming each resolved role, its harness/model, and its source run here?": ```text -agents: planning=claude (default), coding=codex:gpt-5-mini (project), review=claude (global) +agents: planning=claude (default), coding=codex:gpt-5-mini@low (project), review=claude (global) ``` The harness+model shown is the winning `AgentSpec`; an override shows its resolved backend's harness with no model (the override's pin isn't a spec); -the built-in default shows `claude` with no model. +the built-in default shows `claude` with no model. A role whose resolved agent +has an effort set, from settings or an override's `withEffort`, also shows +`@`. ### 11. Non-goals diff --git a/adr/0021-orca-shell.md b/adr/0021-orca-shell.md index afa1e0bf..53e027af 100644 --- a/adr/0021-orca-shell.md +++ b/adr/0021-orca-shell.md @@ -289,9 +289,9 @@ pin. Preselection: planning defaults to `fable`/`gpt-5.6-sol`, coding/review to `opus`/`gpt-5.6-sol`; re-configure preselects an existing pin's curated row (or "enter manually" prefilled with it), and picking "harness default" clears the pin. The wizard's closing note tells users the file is -hand-editable (`harness[:model]`); the harness-change/mint-fresh-sessions -detail (ADR 0020 §8) was dropped from that note per user feedback — unnecessary -detail at write time. +hand-editable (`harness[:model][@effort]`); the +harness-change/mint-fresh-sessions detail (ADR 0020 §8) was dropped from that +note per user feedback — unnecessary detail at write time. Write path: file absent → fresh write with a wizard-appropriate header (not `SettingsFile.Header`, whose stack-discovery wording doesn't apply) and diff --git a/claude/src/main/scala/orca/backend/claude/ClaudeAgents.scala b/claude/src/main/scala/orca/backend/claude/ClaudeAgents.scala index efeb462d..0bde9b20 100644 --- a/claude/src/main/scala/orca/backend/claude/ClaudeAgents.scala +++ b/claude/src/main/scala/orca/backend/claude/ClaudeAgents.scala @@ -1,6 +1,6 @@ package orca.backend.claude -import orca.agents.{Agent, AgentConfig, ClaudeAgent, Effort} +import orca.agents.{Agent, AgentConfig, ClaudeAgent} import orca.backend.AgentWiring import orca.subprocess.OsProcCliRunner @@ -32,12 +32,6 @@ object ClaudeAgents: def opus: ClaudeAgent = agent.withModel(ClaudeModels.Opus1M) def fable: ClaudeAgent = agent.withModel(ClaudeModels.Fable) - /** Set the reasoning effort, passed as `--effort `: - * `claude.sonnet.withEffort(Effort.Medium)`. - */ - def withEffort(effort: Effort): ClaudeAgent = - agent.withEffortLevel(effort) - /** Set the network tools added to the read-only `--tools` allowlist on * [[orca.agents.ToolSet.NetworkOnly]] turns, replacing the default * `WebFetch`/`WebSearch`. Bare claude tool names, e.g. `WebFetch`; refuses diff --git a/claude/src/main/scala/orca/backend/claude/ClaudeArgs.scala b/claude/src/main/scala/orca/backend/claude/ClaudeArgs.scala index 0bd58a29..39f794b3 100644 --- a/claude/src/main/scala/orca/backend/claude/ClaudeArgs.scala +++ b/claude/src/main/scala/orca/backend/claude/ClaudeArgs.scala @@ -5,6 +5,7 @@ import orca.agents.{ AutoApprove, BackendTag, AgentConfig, + EffortSupport, Enforcement, EnforcementCell, WireSessionId, @@ -46,7 +47,7 @@ private[claude] object ClaudeArgs: "--include-partial-messages" ) ++ modelArgs(config) ++ - CliArgs.flag("--effort", config.effort)(_.name) ++ + CliArgs.flag("--effort", config.effort)(EffortSupport.claude.wire) ++ systemPromptFileArgs(systemPromptFile) ++ sessionArgs(dispatch) ++ permissionWiring( diff --git a/claude/src/test/scala/orca/backend/claude/ClaudeAgentsTest.scala b/claude/src/test/scala/orca/backend/claude/ClaudeAgentsTest.scala index d0dc6c68..c0865045 100644 --- a/claude/src/test/scala/orca/backend/claude/ClaudeAgentsTest.scala +++ b/claude/src/test/scala/orca/backend/claude/ClaudeAgentsTest.scala @@ -1,10 +1,10 @@ package orca.backend.claude -import orca.agents.{DefaultPrompts, Effort, NetworkTools} +import orca.agents.{DefaultPrompts, NetworkTools} import orca.backend.AgentWiring import orca.events.OrcaListener import orca.testkit.TestAgent -import orca.backend.claude.ClaudeAgents.{withEffort, withNetworkTools} +import orca.backend.claude.ClaudeAgents.withNetworkTools class ClaudeAgentsTest extends munit.FunSuite: @@ -17,14 +17,6 @@ class ClaudeAgentsTest extends munit.FunSuite: ) ) - test("withEffort pins the level and stays on the agent's backend"): - val tuned = claude.withEffort(Effort.Medium) - assertEquals(tuned.config.effort, Some(Effort.Medium)) - assert(tuned.sharesBackendWith(claude)) - - test("cheap drops the effort along with the leading model"): - assertEquals(claude.withEffort(Effort.Max).cheap.config.effort, None) - // One backend holds the sessions, the close latch and the enforcement // notices, so a sibling on another backend would lose all three. test("withNetworkTools stays on the agent's backend"): diff --git a/codex/src/main/scala/orca/backend/codex/CodexArgs.scala b/codex/src/main/scala/orca/backend/codex/CodexArgs.scala index c34c2c12..49a32ed8 100644 --- a/codex/src/main/scala/orca/backend/codex/CodexArgs.scala +++ b/codex/src/main/scala/orca/backend/codex/CodexArgs.scala @@ -6,6 +6,7 @@ import orca.agents.{ AutoApprove, BackendTag, AgentConfig, + EffortSupport, Enforcement, EnforcementCell, WireSessionId, @@ -37,6 +38,7 @@ private[codex] object CodexArgs: sandboxWiring(config.tools, config.autoApprove, TurnDispatch.Fresh) Seq("codex") ++ mcpServerArgs(mcpServerUrl) ++ + effortArgs(config) ++ wiring.preSubcommand ++ Seq("exec", "--json") ++ wiring.postSubcommand ++ @@ -73,6 +75,7 @@ private[codex] object CodexArgs: sandboxWiring(config.tools, config.autoApprove, TurnDispatch.Resumed) Seq("codex") ++ mcpServerArgs(mcpServerUrl) ++ + effortArgs(config) ++ wiring.preSubcommand ++ Seq("exec", "resume", "--json", WireSessionId.value(sessionId)) ++ wiring.postSubcommand ++ @@ -101,6 +104,11 @@ private[codex] object CodexArgs: s"mcp_servers.${AskUserMcpServer.ServerName}.tool_timeout_sec=$timeoutSec" ) + /** `model_reasoning_effort` as a global `-c` override, TOML-quoted. */ + private def effortArgs(config: AgentConfig): Seq[String] = + CliArgs.flag("-c", config.effort): effort => + s"""model_reasoning_effort="${EffortSupport.codex.wire(effort)}"""" + private def cwdArgs(workDir: os.Path): Seq[String] = Seq("-C", workDir.toString) diff --git a/codex/src/test/scala/orca/backend/codex/CodexArgsTest.scala b/codex/src/test/scala/orca/backend/codex/CodexArgsTest.scala index c2885102..91ad7438 100644 --- a/codex/src/test/scala/orca/backend/codex/CodexArgsTest.scala +++ b/codex/src/test/scala/orca/backend/codex/CodexArgsTest.scala @@ -4,6 +4,7 @@ import orca.agents.{ AutoApprove, BackendTag, AgentConfig, + Effort, Model, WireSessionId, ToolSet @@ -30,6 +31,18 @@ class CodexArgsTest extends munit.FunSuite: ) assert(args.containsSlice(Seq("--model", "gpt-5.4-mini"))) + test("exec sets model_reasoning_effort before the subcommand"): + val args = CodexArgs.exec( + prompt = "x", + config = AgentConfig(effort = Some(Effort.Off)), + outputSchemaFile = None, + workDir = os.pwd + ) + assertEquals( + args.take(4), + Seq("codex", "-c", "model_reasoning_effort=\"none\"", "exec") + ) + test("exec passes -C "): val workDir = TempDirs.dir() val args = CodexArgs.exec( @@ -253,3 +266,12 @@ class CodexArgsTest extends munit.FunSuite: AgentConfig().copy(model = Some(Model("gpt-5.4-mini"))) ) assert(args.containsSlice(Seq("--model", "gpt-5.4-mini"))) + + test("execResume propagates model_reasoning_effort"): + val sid = WireSessionId[BackendTag.Codex.type]("sid") + val args = CodexArgs.execResume( + sid, + "x", + AgentConfig(effort = Some(Effort.High)) + ) + assert(args.containsSlice(Seq("-c", "model_reasoning_effort=\"high\""))) diff --git a/docs/api/backends.md b/docs/api/backends.md index 97627342..07ccedf8 100644 --- a/docs/api/backends.md +++ b/docs/api/backends.md @@ -17,7 +17,8 @@ Note that `interactive` exists only on `resultAs[O]`. [`FlowSession` and All five backends also share the builders `withModel`, `withCheapModel`, `withAutoApprove`, `withSystemPrompt`, `withName`, `withReadOnly`, `withNetworkOnly` and `withSelfManagedGit`; see -[Choosing agents](../authoring/choosing-agents.md) for what each one does. +[Choosing agents](../authoring/choosing-agents.md) for what each one does. All +but `gemini` also have `withEffort`. `Model` wraps a model id string. The sections below describe what differs between the backends: which model @@ -29,8 +30,7 @@ with no model chosen, for example plain `claude`; `cheap` is the model that Claude Code. Bare `claude` is Opus with the 1M-token context window. The model accessors are `haiku`, `sonnet`, `opus` and `fable`; `cheap` picks haiku, and -`withModel(Model)` sets any model id. `withEffort(Effort)` sets the reasoning -effort. +`withModel(Model)` sets any model id. Use `claude.sonnet` or `claude.haiku` for cheap one-shot calls, and `claude.fable` for the hardest ones. @@ -67,6 +67,9 @@ selection follow Pi's own configuration; to pick one, use `withModel(Model("provider/model"))`. Interactive calls can ask clarifying questions through Orca's `ask_user` bridge. +`withEffort` replaces a thinking level given in the model (Pi's `sonnet:high` +form). Pi lowers a level the model does not support to the nearest one it does. + ## `gemini` Google Gemini CLI, driven via `gemini --output-format stream-json`. Bare @@ -82,6 +85,5 @@ Structured output is prompt-enforced, since Gemini has no schema flag. How strongly each backend enforces a `ToolSet` is tabulated in [Choosing agents](../authoring/choosing-agents.md#how-strongly-each-harness-enforces-a-limit). -In `settings.properties` a backend is named by the same word, with an optional -model after a colon, for example `codingAgent = codex:gpt-5-mini`. See -[Settings](../using/settings.md). +In `settings.properties` a backend is named by the same word; see +[Settings](../using/settings.md#agent-keys) for the value form. diff --git a/docs/authoring/choosing-agents.md b/docs/authoring/choosing-agents.md index 6f2d82fc..9e2dfcc1 100644 --- a/docs/authoring/choosing-agents.md +++ b/docs/authoring/choosing-agents.md @@ -114,10 +114,21 @@ own `agent` in the event log), `withReadOnly`, `withNetworkOnly` and the same harness can continue each other's conversations; see `chat.withAgent` in [Talking to agents](talking-to-agents.md). -`claude` also has `withEffort(Effort.Medium)`, which sets the reasoning effort -(`Low`, `Medium`, `High`, `XHigh`, `Max`); `cheap` drops it. Orca wires it only -for claude so far, so it does not compile on the other harnesses. It cannot be -set in `settings.properties`. +`withEffort(Effort.High)` sets the reasoning effort; `cheap` drops it when it +switches to a cheaper model. The levels are `Off`, `Minimal`, `Low`, `Medium`, +`High`, `XHigh` and `Max`, and each harness accepts some of them: + +| Harness | Levels | Sent as | +|----------|------------------------------------|---------------------------------| +| claude | `Low` to `Max` | `--effort` | +| codex | all | `-c model_reasoning_effort=...` | +| pi | all but `Max` | `--thinking` | +| opencode | all; the model decides which apply | the message's `variant` | +| gemini | none; does not compile | | + +`withEffort` throws on a level the harness does not accept. In settings and +`orca config`, write the level after `@`, as in `claude:sonnet@high`; see +[Settings](../using/settings.md#agent-keys). By default, the runtime owns git: every write-capable turn is told not to commit, push or switch branches. `withSelfManagedGit` opts one agent out of diff --git a/docs/using/settings.md b/docs/using/settings.md index 39fe6fc4..5e5b7671 100644 --- a/docs/using/settings.md +++ b/docs/using/settings.md @@ -38,7 +38,7 @@ For example, this is what a typical file written by ```properties # orca settings — edit freely, commit with the project. # format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery. -# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both. +# planningAgent/codingAgent/reviewAgent (harness[:model][@effort]): override the global settings file; a flow's own code overrides both. # Cargo.toml; via rustfmt format = cargo fmt # Cargo.toml @@ -57,13 +57,14 @@ see [Gates and checks](../authoring/gates-and-checks.md). The keys `planningAgent`, `codingAgent` and `reviewAgent` choose which agent plays each role. They are valid in both files and are single-valued, so a -repeated agent key is an error. The value has the form `harness[:model]` and is -split at the first `:`, which means that a model id containing `:` survives -intact. The `harness` part is one of `claude`, `codex`, `opencode`, `pi` or -`gemini`; any other name is an error that lists the valid ones. +repeated agent key is an error. The value has the form +`harness[:model][@effort]`. The model is split off at the first `:`, so a model +id containing `:` survives intact. The `harness` part is one of `claude`, +`codex`, `opencode`, `pi` or `gemini`; any other name is an error that lists +the valid ones. ```properties -planningAgent = claude:opus +planningAgent = claude:opus@high codingAgent = codex:gpt-5-mini reviewAgent = opencode:anthropic/claude-haiku-5-5 ``` @@ -73,6 +74,14 @@ model ids. There is one exception: claude's bare `haiku` alias is sent as `claude-haiku-5-5`, because the CLI may resolve the bare alias to a pricier model. +The optional `@effort` part sets the reasoning effort, as `withEffort` does in +code: `claude:opus@high`, or `codex@low` with no model. The level is written in +lowercase, and +[Choosing agents](../authoring/choosing-agents.md#tuning-an-agent) lists the +levels each harness accepts; any other is an error. Only letters after the +last `@` are read as a level, so a model id such as +`claude-sonnet-4@20250514` is passed on as is. + Note that agent keys are read even when `flow(stackSettings = Some(...))` pins the stack commands. At setup, Orca announces where each role came from: diff --git a/docs/using/shell.md b/docs/using/shell.md index 4c3cfad8..83679787 100644 --- a/docs/using/shell.md +++ b/docs/using/shell.md @@ -67,7 +67,8 @@ job. The commands are: selector, the newest one. `--list` shows the sessions, as JSON with `--json`. - `orca config` shows the role agents. `--planning-agent`, `--coding-agent` - and `--review-agent`, each taking `harness[:model]`, set any subset of them. + and `--review-agent`, each taking `harness[:model][@effort]` (see + [Settings](settings.md#agent-keys)), set any subset of them. Alternatively, `--edit project|global` opens a settings file for hand-editing, creating it from a template if it does not exist. - `orca list` lists project, global and built-in flows, as JSON with `--json`. @@ -118,7 +119,7 @@ orca continue # resume the last session orca continue --list orca continue feat/rate-limiter orca config --coding-agent codex -orca config --review-agent claude:sonnet +orca config --review-agent claude:sonnet@high orca view implement.sc ``` diff --git a/flow/src/main/scala/orca/AgentSet.scala b/flow/src/main/scala/orca/AgentSet.scala index f2e479eb..79341218 100644 --- a/flow/src/main/scala/orca/AgentSet.scala +++ b/flow/src/main/scala/orca/AgentSet.scala @@ -10,6 +10,7 @@ import orca.agents.{ OpencodeAgent, PiAgent } +import orca.settings.AgentSpec /** The five per-backend agents a run wires. The runtime resolves the three role * agents (ADR 0020) from settings against this set before the [[FlowContext]] @@ -37,12 +38,8 @@ trait AgentSet: case BackendTag.Pi => pi case BackendTag.Gemini => gemini - /** [[agentFor]] with an optional model pin (a settings `harness:model` value) - * applied through `withModel`. - */ - private[orca] def agentFor( - tag: BackendTag, - modelPin: Option[String] - ): Agent[?] = - val agent = agentFor(tag) - modelPin.fold(agent)(m => agent.withModel(Model(m))) + /** [[agentFor]] with a settings value's model pin and effort applied. */ + private[orca] def agentFor(spec: AgentSpec): Agent[?] = + val agent = agentFor(spec.backend) + val pinned = spec.model.fold(agent)(m => agent.withModel(Model(m))) + spec.effort.fold(pinned)(pinned.withCheckedEffort) diff --git a/flow/src/main/scala/orca/accessors.scala b/flow/src/main/scala/orca/accessors.scala index e6a6549f..df1abe5d 100644 --- a/flow/src/main/scala/orca/accessors.scala +++ b/flow/src/main/scala/orca/accessors.scala @@ -28,15 +28,17 @@ def fs(using ctx: FlowContext): FsTool = ctx.fs def userPrompt(using ctx: FlowContext): String = ctx.userPrompt /** The planning-role agent (ADR 0020): resolved from settings (`planningAgent = - * harness[:model]`), default claude. Reference it in `Plan.*` calls instead of - * a concrete accessor so planning follows whichever backend the settings name. + * harness[:model][@effort]`), default claude. Reference it in `Plan.*` calls + * instead of a concrete accessor so planning follows whichever backend the + * settings name. */ def planningAgent(using ctx: FlowContext): Agent[?] = ctx.planningAgent /** The coding-role agent — the run's primary: implementer sessions, branch * naming, stack discovery, and default commit messages run here. Reference it * in a body instead of a concrete accessor (`claude`/`codex`) so the flow - * follows whichever backend `codingAgent = harness[:model]` in settings names. + * follows whichever backend `codingAgent = harness[:model][@effort]` in + * settings names. * * Two ways to drive a model: a role accessor * (`codingAgent`/`planningAgent`/`reviewAgent`) is backend-agnostic — settings diff --git a/flow/src/main/scala/orca/settings/AgentSpec.scala b/flow/src/main/scala/orca/settings/AgentSpec.scala index 69927e6e..4b51ac6f 100644 --- a/flow/src/main/scala/orca/settings/AgentSpec.scala +++ b/flow/src/main/scala/orca/settings/AgentSpec.scala @@ -1,45 +1,103 @@ package orca.settings -import orca.agents.BackendTag +import orca.agents.{BackendTag, Effort, EffortSupport} -/** One configured role agent: a harness plus an optional model pin, the parsed - * form of a `harness[:model]` settings value. The model string is passed - * verbatim to the harness's `withModel` — orca does not normalise or validate - * model ids. +/** One configured role agent: a harness plus an optional model pin and effort, + * the parsed form of a `harness[:model][@effort]` settings value. The model + * string is passed verbatim to the harness's `withModel` — orca does not + * normalise or validate model ids. `effort` is always one the harness accepts. */ -private[orca] case class AgentSpec(backend: BackendTag, model: Option[String]) +private[orca] case class AgentSpec private ( + backend: BackendTag, + model: Option[String], + effort: Option[Effort] +): + /** The settings value [[AgentSpec.parse]] reads back into this spec. */ + def render: String = + backend.harnessName + model.fold("")(":" + _) + + AgentSpec.effortSuffix(effort) + + /** This spec moved to `tag` with `model`, keeping the effort only when the + * harness stays the same. + */ + def repinned(tag: BackendTag, model: Option[String]): AgentSpec = + AgentSpec(tag, model, effort.filter(_ => tag == backend)) private[orca] object AgentSpec: - /** The settings-file harness name for a backend tag (`claude`, `codex`, …) — - * lowercase, distinct from the tag's case name. + /** A spec with no effort. */ + def apply(backend: BackendTag, model: Option[String]): AgentSpec = + new AgentSpec(backend, model, None) + + /** The `@` part of a settings value; empty without an effort. */ + def effortSuffix(effort: Option[Effort]): String = + effort.fold("")("@" + _.label) + + /** The settings value's form, for help texts and file headers. */ + final val ValueForm = "harness[:model][@effort]" + + /** Settings-file harness names — the reverse of [[BackendTag.harnessName]]. */ - def harnessNameFor(tag: BackendTag): String = tag match - case BackendTag.ClaudeCode => "claude" - case BackendTag.Codex => "codex" - case BackendTag.Opencode => "opencode" - case BackendTag.Pi => "pi" - case BackendTag.Gemini => "gemini" - - /** Settings-file harness names — the reverse of [[harnessNameFor]]. */ val harnessNames: Map[String, BackendTag] = - BackendTag.values.map(t => harnessNameFor(t) -> t).toMap + BackendTag.values.map(t => t.harnessName -> t).toMap - /** Left = human-readable problem for the settings-error message. Split at the - * FIRST `:` so a model id containing `:` survives; an empty model part means - * no pin. + /** Reads a `harness[:model][@effort]` value; Left = human-readable problem + * for the settings-error message. `@effort` is split off first, then the + * rest at the FIRST `:`, so a model id containing `:` survives; an empty + * model part means no pin. */ def parse(value: String): Either[String, AgentSpec] = - val (harness, model) = value.indexOf(':') match - case -1 => (value.trim, None) - case i => - (value.take(i).trim, Some(value.drop(i + 1).trim).filter(_.nonEmpty)) - harnessNames.get(harness) match - case Some(tag) => Right(AgentSpec(tag, model)) - case None => - Left( + for + (pinned, effort) <- splitEffort(value) + (harness, model) = splitModel(pinned) + tag <- harnessNames + .get(harness) + .toRight( s"unknown harness `$harness` — valid: " + harnessNames.keys.toList.sorted.mkString(", ") ) + checked <- checkEffort(tag, effort) + yield new AgentSpec(tag, model, checked) + + /** `harness[:model]` as the harness and the model, if any. */ + private def splitModel(pinned: String): (String, Option[String]) = + pinned.indexOf(':') match + case -1 => (pinned.trim, None) + case i => + (pinned.take(i).trim, Some(pinned.drop(i + 1).trim).filter(_.nonEmpty)) + + /** `effort`, if the harness accepts it. */ + private def checkEffort( + tag: BackendTag, + effort: Option[Effort] + ): Either[String, Option[Effort]] = + effort.fold(Right(None)): level => + EffortSupport + .forTag(tag) + .toRight(s"${tag.harnessName} has no effort setting") + .flatMap(_.check(level)) + .map(Some(_)) + + /** Splits off a trailing `@`. Only an all-letter (or empty) suffix is + * read as a level, so a model id like Vertex's `claude-sonnet-4@20250514` + * survives. + */ + private def splitEffort( + value: String + ): Either[String, (String, Option[Effort])] = + value.lastIndexOf('@') match + case -1 => Right((value, None)) + case i => + val suffix = value.drop(i + 1).trim + if !suffix.forall(_.isLetter) then Right((value, None)) + else + val valid = Effort.values.map(_.label).mkString(", ") + Effort + .fromLabel(suffix) + .map(effort => (value.take(i), Some(effort))) + .toRight( + if suffix.isEmpty then s"missing effort after `@` — valid: $valid" + else s"unknown effort `$suffix` — valid: $valid" + ) /** The agent keys of one settings file; [[orElse]] layers one set over a * fallback per role — `ConfigCli` uses it to merge flag overrides over the diff --git a/flow/src/main/scala/orca/settings/SettingsFile.scala b/flow/src/main/scala/orca/settings/SettingsFile.scala index 8093ef49..f2a119b6 100644 --- a/flow/src/main/scala/orca/settings/SettingsFile.scala +++ b/flow/src/main/scala/orca/settings/SettingsFile.scala @@ -150,7 +150,7 @@ private[orca] object SettingsFile: "# format/lint/test: one shell command per key; `off` disables the " + "gate. Delete the stack lines (or the whole file) to re-run " + "auto-discovery.\n" + - "# planningAgent/codingAgent/reviewAgent (harness[:model]): override " + + s"# planningAgent/codingAgent/reviewAgent (${AgentSpec.ValueForm}): override " + "the global settings file; a flow's own code overrides both." /** The full settings-file text for `entries` under [[Header]], @@ -176,7 +176,7 @@ private[orca] object SettingsFile: */ private[orca] val GlobalHeader: String = "# orca global settings — role agents; edit freely. " + - "Values: harness[:model]" + s"Values: ${AgentSpec.ValueForm}" /** A fresh global settings file: [[GlobalHeader]] plus one `key = value` line * per role `agents` sets, in planning/coding/review order. Round-trips @@ -212,8 +212,7 @@ private[orca] object SettingsFile: (revLines.reverse ::: appended).mkString("", "\n", "\n") private def renderAgentLine(key: AgentKey, spec: AgentSpec): String = - val model = spec.model.fold("")(":" + _) - s"${key.raw} = ${AgentSpec.harnessNameFor(spec.backend)}$model" + s"${key.raw} = ${spec.render}" private def renderEntry(entry: SettingsEntry): String = entry match diff --git a/flow/src/test/scala/orca/settings/AgentSpecTest.scala b/flow/src/test/scala/orca/settings/AgentSpecTest.scala index d03fe031..d309a644 100644 --- a/flow/src/test/scala/orca/settings/AgentSpecTest.scala +++ b/flow/src/test/scala/orca/settings/AgentSpecTest.scala @@ -1,7 +1,7 @@ package orca.settings import munit.FunSuite -import orca.agents.BackendTag +import orca.agents.{BackendTag, Effort} class AgentSpecTest extends FunSuite: @@ -41,3 +41,66 @@ class AgentSpecTest extends FunSuite: AgentSpec.parse("claude:"), Right(AgentSpec(BackendTag.ClaudeCode, None)) ) + + private def fields(value: String) = + AgentSpec.parse(value).map(s => (s.backend, s.model, s.effort)) + + test("parse of harness:model@effort sets the effort"): + assertEquals( + fields("claude:sonnet@high"), + Right((BackendTag.ClaudeCode, Some("sonnet"), Some(Effort.High))) + ) + + test("parse of harness@effort sets the effort with no model pin"): + assertEquals( + fields("codex@low"), + Right((BackendTag.Codex, None, Some(Effort.Low))) + ) + + test("parse keeps an @ followed by more than letters in the model"): + assertEquals( + fields("claude:claude-sonnet-4@20250514"), + Right((BackendTag.ClaudeCode, Some("claude-sonnet-4@20250514"), None)) + ) + + test("parse of an unknown effort is a Left listing the valid levels"): + assertEquals( + AgentSpec.parse("claude:opus@hgih"), + Left( + "unknown effort `hgih` — valid: off, minimal, low, medium, high, xhigh, max" + ) + ) + + test("parse of a trailing @ is a Left"): + assertEquals( + AgentSpec.parse("claude:sonnet@"), + Left( + "missing effort after `@` — valid: off, minimal, low, medium, high, xhigh, max" + ) + ) + + test("parse of a level the harness does not accept is a Left"): + assertEquals( + AgentSpec.parse("claude:sonnet@off"), + Left( + "claude has no `off` effort; it accepts low, medium, high, xhigh, max" + ) + ) + + test("parse of an effort on gemini is a Left"): + assertEquals( + AgentSpec.parse("gemini@high"), + Left("gemini has no effort setting") + ) + + test("render gives back the parsed value"): + assertEquals( + AgentSpec.parse("pi:sonnet:high@minimal").map(_.render), + Right("pi:sonnet:high@minimal") + ) + + test("repinned drops the effort when the harness changes"): + val spec = AgentSpec + .parse("claude:sonnet@high") + .map(_.repinned(BackendTag.Gemini, None)) + assertEquals(spec.map(_.effort), Right(None)) diff --git a/flow/src/test/scala/orca/settings/SettingsFileTest.scala b/flow/src/test/scala/orca/settings/SettingsFileTest.scala index a2a56898..80617f7b 100644 --- a/flow/src/test/scala/orca/settings/SettingsFileTest.scala +++ b/flow/src/test/scala/orca/settings/SettingsFileTest.scala @@ -321,7 +321,7 @@ class SettingsFileTest extends FunSuite: SettingsFile.render(entries), """# orca settings — edit freely, commit with the project. |# format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery. - |# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both. + |# planningAgent/codingAgent/reviewAgent (harness[:model][@effort]): override the global settings file; a flow's own code overrides both. |# Cargo.toml (rustfmt ships with the toolchain) |format = cargo fmt |# compiles main+test code, runs nothing @@ -358,7 +358,7 @@ class SettingsFileTest extends FunSuite: val content = """# orca settings — edit freely, commit with the project. |# format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery. - |# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both. + |# planningAgent/codingAgent/reviewAgent (harness[:model][@effort]): override the global settings file; a flow's own code overrides both. |# Cargo.toml (rustfmt ships with the toolchain) |format = cargo fmt |# compiles main+test code, runs nothing @@ -374,7 +374,7 @@ class SettingsFileTest extends FunSuite: stripped, """# orca settings — edit freely, commit with the project. |# format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery. - |# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both. + |# planningAgent/codingAgent/reviewAgent (harness[:model][@effort]): override the global settings file; a flow's own code overrides both. |# a user note about coding agent |codingAgent = codex |# just a regular comment diff --git a/opencode/src/main/scala/orca/backend/opencode/OpencodeApi.scala b/opencode/src/main/scala/orca/backend/opencode/OpencodeApi.scala index b1109fc5..5ecf3ad7 100644 --- a/opencode/src/main/scala/orca/backend/opencode/OpencodeApi.scala +++ b/opencode/src/main/scala/orca/backend/opencode/OpencodeApi.scala @@ -42,7 +42,8 @@ private[opencode] object OpencodeApi: system: Option[String], agent: Option[String], tools: Option[Map[String, Boolean]], - format: Option[OutputFormat] + format: Option[OutputFormat], + variant: Option[String] ) derives ConfiguredJsonValueCodec /** Body of `POST /session`. */ diff --git a/opencode/src/main/scala/orca/backend/opencode/OpencodeArgs.scala b/opencode/src/main/scala/orca/backend/opencode/OpencodeArgs.scala index 7a6844c0..8e0e6a71 100644 --- a/opencode/src/main/scala/orca/backend/opencode/OpencodeArgs.scala +++ b/opencode/src/main/scala/orca/backend/opencode/OpencodeArgs.scala @@ -4,6 +4,7 @@ import orca.backend.{TurnMode, SystemPromptComposer} import orca.agents.{ AgentConfig, AutoApprove, + EffortSupport, Enforcement, EnforcementCell, Model, @@ -67,7 +68,8 @@ private[opencode] object OpencodeArgs: // server's default applies. agent = None, tools = toolFlags(config, mode), - format = outputSchema.map(s => OutputFormat("json_schema", RawJson(s))) + format = outputSchema.map(s => OutputFormat("json_schema", RawJson(s))), + variant = config.effort.map(EffortSupport.opencode.wire) ) private def toModelRef(model: Model): ModelRef = diff --git a/opencode/src/test/scala/orca/backend/opencode/OpencodeApiTest.scala b/opencode/src/test/scala/orca/backend/opencode/OpencodeApiTest.scala index 28fcf79b..21e585b0 100644 --- a/opencode/src/test/scala/orca/backend/opencode/OpencodeApiTest.scala +++ b/opencode/src/test/scala/orca/backend/opencode/OpencodeApiTest.scala @@ -9,7 +9,9 @@ import orca.json.RawJson class OpencodeApiTest extends munit.FunSuite: - test("MessageBody serialises parts, model, system, tools, and format"): + test( + "MessageBody serialises parts, model, system, tools, format and variant" + ): val body = MessageBody( parts = List(MessagePart("text", "do it")), model = Some(ModelRef("openai", "gpt-4o-mini")), @@ -17,7 +19,8 @@ class OpencodeApiTest extends munit.FunSuite: agent = None, tools = Some(Map("question" -> false)), format = - Some(OutputFormat("json_schema", RawJson("""{"type":"object"}"""))) + Some(OutputFormat("json_schema", RawJson("""{"type":"object"}"""))), + variant = Some("high") ) val json = writeToString(body) assert(json.contains(""""text":"do it""""), json) @@ -27,6 +30,7 @@ class OpencodeApiTest extends munit.FunSuite: assert(json.contains(""""question":false"""), json) assert(json.contains(""""type":"json_schema""""), json) assert(json.contains(""""schema":{"type":"object"}"""), json) + assert(json.contains(""""variant":"high""""), json) test("MessageBody omits absent optional fields"): val body = MessageBody( @@ -35,12 +39,14 @@ class OpencodeApiTest extends munit.FunSuite: system = None, agent = None, tools = None, - format = None + format = None, + variant = None ) val json = writeToString(body) assert(!json.contains("system"), json) assert(!json.contains("format"), json) assert(!json.contains("tools"), json) + assert(!json.contains("variant"), json) test("reply bodies serialise to the server's expected shape"): assertEquals( diff --git a/opencode/src/test/scala/orca/backend/opencode/OpencodeArgsTest.scala b/opencode/src/test/scala/orca/backend/opencode/OpencodeArgsTest.scala index 9d12f354..0a3bb01b 100644 --- a/opencode/src/test/scala/orca/backend/opencode/OpencodeArgsTest.scala +++ b/opencode/src/test/scala/orca/backend/opencode/OpencodeArgsTest.scala @@ -1,7 +1,7 @@ package orca.backend.opencode import orca.backend.{TurnMode, SystemPromptComposer} -import orca.agents.{AutoApprove, AgentConfig, Model, ToolSet} +import orca.agents.{AutoApprove, AgentConfig, Effort, Model, ToolSet} class OpencodeArgsTest extends munit.FunSuite: @@ -70,6 +70,13 @@ class OpencodeArgsTest extends munit.FunSuite: None ) + test("message sends the effort as the variant"): + val config = AgentConfig(effort = Some(Effort.Off)) + assertEquals( + OpencodeArgs.message(config, "hi", None, interactive).variant, + Some("none") + ) + test("message carries the composed system prompt, config included"): // What opencode owns is that the composed prompt reaches `system`; which // rules compose is `SystemPromptComposerTest`'s. A non-default config, so diff --git a/pi/src/main/scala/orca/backend/pi/PiArgs.scala b/pi/src/main/scala/orca/backend/pi/PiArgs.scala index b69c6d03..526b68f3 100644 --- a/pi/src/main/scala/orca/backend/pi/PiArgs.scala +++ b/pi/src/main/scala/orca/backend/pi/PiArgs.scala @@ -4,6 +4,7 @@ import orca.backend.CliArgs import orca.agents.{ AgentConfig, AutoApprove, + EffortSupport, Enforcement, EnforcementCell, ToolSet, @@ -38,6 +39,7 @@ private[pi] object PiArgs: Seq("pi", "--mode", "rpc", "--session-dir", sessionDir.toString) ++ continueArgs(dispatch) ++ CliArgs.modelArgs(config) ++ + CliArgs.flag("--thinking", config.effort)(EffortSupport.pi.wire) ++ systemPromptArgs(systemPromptFile) ++ toolsWiring( config.tools, diff --git a/pi/src/test/scala/orca/backend/pi/PiArgsTest.scala b/pi/src/test/scala/orca/backend/pi/PiArgsTest.scala index 2cba9a3d..92f67127 100644 --- a/pi/src/test/scala/orca/backend/pi/PiArgsTest.scala +++ b/pi/src/test/scala/orca/backend/pi/PiArgsTest.scala @@ -1,6 +1,6 @@ package orca.backend.pi -import orca.agents.{AgentConfig, Model, ToolSet, TurnDispatch} +import orca.agents.{AgentConfig, Effort, Model, ToolSet, TurnDispatch} class PiArgsTest extends munit.FunSuite: @@ -32,6 +32,15 @@ class PiArgsTest extends munit.FunSuite: args ) + test("effort is rendered as --thinking"): + val args = PiArgs.rpc( + dir, + TurnDispatch.Fresh, + AgentConfig(effort = Some(Effort.Off)), + None + ) + assert(args.containsSlice(Seq("--thinking", "off")), args) + test("read-only tools exclude writes"): val args = PiArgs.rpc( dir, diff --git a/runner/src/main/scala/orca/exports.scala b/runner/src/main/scala/orca/exports.scala index af625e4a..6eb4e768 100644 --- a/runner/src/main/scala/orca/exports.scala +++ b/runner/src/main/scala/orca/exports.scala @@ -39,7 +39,8 @@ export orca.agents.{ PromptEvent, ToolSet, BackendTag, - Announce + Announce, + Effort } export orca.json.{JsonData, schemaFromJsonData, codecFromJsonData} export orca.plan.{ @@ -131,7 +132,6 @@ export orca.backend.claude.ClaudeAgents.{ sonnet, opus, fable, - withEffort, withNetworkTools } export orca.backend.codex.CodexAgents @@ -162,8 +162,6 @@ type ReviewerSlug = orca.review.ReviewerSlug val ReviewerSlug: orca.review.ReviewerSlug.type = orca.review.ReviewerSlug type FindingId = orca.review.FindingId val FindingId: orca.review.FindingId.type = orca.review.FindingId -type Effort = orca.agents.Effort -val Effort: orca.agents.Effort.type = orca.agents.Effort type Model = orca.agents.Model val Model: orca.agents.Model.type = orca.agents.Model type OpencodeLauncher = orca.backend.opencode.OpencodeLauncher diff --git a/runner/src/main/scala/orca/flowEntry.scala b/runner/src/main/scala/orca/flowEntry.scala index 6fc51f47..14d45e47 100644 --- a/runner/src/main/scala/orca/flowEntry.scala +++ b/runner/src/main/scala/orca/flowEntry.scala @@ -90,10 +90,10 @@ import scala.util.control.NonFatal * `{workDir}/.orca/settings.properties` > the user-global file * `$XDG_CONFIG_HOME/orca/settings.properties` > the built-in default (claude, * default model). Each file carries `planningAgent`/`codingAgent`/ - * `reviewAgent = harness[:model]` lines; a malformed or unreadable value in - * either file aborts the run before any tree mutation. Setup emits one `Step` - * naming each resolved role and its source, the handle for "why did codex run - * here?". + * `reviewAgent = harness[:model][@effort]` lines; a malformed or unreadable + * value in either file aborts the run before any tree mutation. Setup emits + * one `Step` naming each resolved role and its source, the handle for "why did + * codex run here?". * * The three overrides are the programmatic top of that precedence — selector- * shaped (`Some(_.claude.opus)`) so a builder-derived sibling stays diff --git a/runner/src/main/scala/orca/runner/RoleAgents.scala b/runner/src/main/scala/orca/runner/RoleAgents.scala index 1d5bbf3e..505a9108 100644 --- a/runner/src/main/scala/orca/runner/RoleAgents.scala +++ b/runner/src/main/scala/orca/runner/RoleAgents.scala @@ -148,7 +148,7 @@ private[orca] object RoleAgents: spec: Option[AgentSpec] ): (String, Option[String]) = val harness = - AgentSpec.harnessNameFor(spec.fold(agent.backendTag)(_.backend)) + spec.fold(agent.backendTag)(_.backend).harnessName (harness, spec.flatMap(_.model).orElse(agent.config.model.map(_.name))) private def resolveOne( @@ -177,7 +177,7 @@ private[orca] object RoleAgents: val source = if projectSpec.isDefined then RoleSource.Project else RoleSource.Global - val agent = agents.agentFor(spec.backend, spec.model) + val agent = agents.agentFor(spec) val (harness, model) = harnessAndModel(agent, Some(spec)) RoleChoice(label, agent, source, harness, model, foreign = false) case None => @@ -215,11 +215,11 @@ private[orca] object RoleAgents: */ private val UnpinnedModelLabel = "" - /** One role's announcement segment, `label=harness:model (source)` — pure - * formatting of [[RoleChoice]]'s precomputed fields. + /** One role's announcement segment, `label=harness:model[@effort] (source)`. */ private def announce(c: RoleChoice): String = - s"${c.label}=${c.harness}:${c.model.getOrElse(UnpinnedModelLabel)} (${sourceLabel(c.source)})" + val effort = AgentSpec.effortSuffix(c.agent.config.effort) + s"${c.label}=${c.harness}:${c.model.getOrElse(UnpinnedModelLabel)}$effort (${sourceLabel(c.source)})" private def sourceLabel(source: RoleSource): String = source match diff --git a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala index 3e6bc42d..ccde20e1 100644 --- a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala +++ b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala @@ -1496,7 +1496,7 @@ class FlowLifecycleTest extends munit.FunSuite: os.read(OrcaDir.settingsPath(workDir)), """# orca settings — edit freely, commit with the project. |# format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery. - |# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both. + |# planningAgent/codingAgent/reviewAgent (harness[:model][@effort]): override the global settings file; a flow's own code overrides both. |# seed.txt; seeded fixture |format = echo fmt |# no lint config found @@ -1738,7 +1738,7 @@ class FlowLifecycleTest extends munit.FunSuite: content, """# orca settings — edit freely, commit with the project. |# format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery. - |# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both. + |# planningAgent/codingAgent/reviewAgent (harness[:model][@effort]): override the global settings file; a flow's own code overrides both. |# no formatter config found |format = off |# no evidence found diff --git a/runner/src/test/scala/orca/runner/RoleAgentsTest.scala b/runner/src/test/scala/orca/runner/RoleAgentsTest.scala index 58ab2ed8..aab4d25d 100644 --- a/runner/src/test/scala/orca/runner/RoleAgentsTest.scala +++ b/runner/src/test/scala/orca/runner/RoleAgentsTest.scala @@ -139,6 +139,20 @@ class RoleAgentsTest extends munit.FunSuite: s"expected the wired codex's own model: ${resolution.announcement}" ) + test("resolveAll announces a project effort after the model"): + val resolution = resolveInScope( + project = AgentSettings(coding = + AgentSpec.parse("codex:gpt-5-mini@low").toOption + ), + global = AgentSettings.empty, + overrides = RoleOverrides(None, None, None), + agents = wiredAgents() + ) + assert( + resolution.announcement.contains("coding=codex:gpt-5-mini@low (project)"), + resolution.announcement + ) + test("resolveAll renders a project model pin as harness:model"): val resolution = resolveInScope( project = AgentSettings(coding = diff --git a/shell/src/main/scala/orca/shell/actions/ConfigAction.scala b/shell/src/main/scala/orca/shell/actions/ConfigAction.scala index e92e2f46..adc40a18 100644 --- a/shell/src/main/scala/orca/shell/actions/ConfigAction.scala +++ b/shell/src/main/scala/orca/shell/actions/ConfigAction.scala @@ -1,7 +1,7 @@ package orca.shell.actions import orca.OrcaDir -import orca.settings.{AgentSettings, SettingsFile, SettingsScope} +import orca.settings.{AgentSettings, AgentSpec, SettingsFile, SettingsScope} import orca.shell.ui.ShellOutput /** Shows or writes the user-global settings file's role agents (ADR 0021 §4). @@ -73,5 +73,5 @@ private[shell] object ConfigAction: os.write.over(globalSettingsPath, content, createFolders = true) ShellOutput.info( s"Settings written to $globalSettingsPath — hand-editable any time " + - "(`harness[:model]`, e.g. `claude:sonnet`)." + s"(`${AgentSpec.ValueForm}`, e.g. `claude:sonnet@high`)." ) diff --git a/shell/src/main/scala/orca/shell/actions/ConfigSummary.scala b/shell/src/main/scala/orca/shell/actions/ConfigSummary.scala index fc608590..a2ddec22 100644 --- a/shell/src/main/scala/orca/shell/actions/ConfigSummary.scala +++ b/shell/src/main/scala/orca/shell/actions/ConfigSummary.scala @@ -21,8 +21,8 @@ import orca.tools.OsGitTool private[shell] object ConfigSummary: /** `agents: planning=X, coding=Y, review=Z`, `X`/`Y`/`Z` rendered - * `harness[:model]` (`claude` when a role is unset anywhere), with a - * project-file override winning over the global one per role. Either file + * `harness[:model][@effort]` (`claude` when a role is unset anywhere), with + * a project-file override winning over the global one per role. Either file * being malformed renders a one-line warning instead of crashing. */ def agentsLine(globalSettingsPath: os.Path, workDir: os.Path): String = @@ -63,8 +63,7 @@ private[shell] object ConfigSummary: case Head.Detached(_) => "branch: (detached HEAD)" private def renderSpec(spec: Option[AgentSpec]): String = - spec.fold("claude"): s => - AgentSpec.harnessNameFor(s.backend) + s.model.fold("")(":" + _) + spec.fold("claude")(_.render) /** `stack: format=X, lint=Y, test=Z` — one entry per [[StackAction.status]] * key, `off` for an empty/disabled one; `stack: not discovered yet — diff --git a/shell/src/main/scala/orca/shell/actions/SessionAction.scala b/shell/src/main/scala/orca/shell/actions/SessionAction.scala index 8547db74..dc678144 100644 --- a/shell/src/main/scala/orca/shell/actions/SessionAction.scala +++ b/shell/src/main/scala/orca/shell/actions/SessionAction.scala @@ -1,7 +1,6 @@ package orca.shell.actions import org.jline.terminal.Terminal -import orca.settings.AgentSpec import orca.shell.run.ChildTerminal import orca.shell.sessions.{ResumeCommand, SessionNaming, SessionSelection} import orca.shell.ui.ShellOutput @@ -31,7 +30,7 @@ private[shell] object SessionAction: def resumeNotice(selection: SessionSelection): String = identityNotice( selection, - AgentSpec.harnessNameFor(selection.session.backend) + selection.session.backend.harnessName ) /** The notice [[resumeNotice]] prints — name, harness, stage, recorded diff --git a/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala b/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala index 333bb4b9..200ac80f 100644 --- a/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala +++ b/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala @@ -1,7 +1,8 @@ package orca.shell.actions import orca.OrcaDir -import orca.settings.{AgentSettings, SettingsFile} +import orca.agents.BackendTag +import orca.settings.{AgentSettings, AgentSpec, SettingsFile} import orca.shell.{ShellEnv, Tier} import ox.discard @@ -27,7 +28,7 @@ private[shell] object SettingsEditAction: "# lint = cargo check --tests\n" + "# test = cargo test\n" + "\n" + - "# Role agents — harness[:model]; harness: claude|codex|opencode|pi|gemini.\n" + + s"# Role agents — ${AgentSpec.ValueForm}; harness: ${BackendTag.values.map(_.harnessName).mkString("|")}.\n" + "# planningAgent = claude:fable\n" + "# codingAgent = claude:opus\n" + "# reviewAgent = claude:opus\n" diff --git a/shell/src/main/scala/orca/shell/cli/Cli.scala b/shell/src/main/scala/orca/shell/cli/Cli.scala index 96bb9aca..05f4a242 100644 --- a/shell/src/main/scala/orca/shell/cli/Cli.scala +++ b/shell/src/main/scala/orca/shell/cli/Cli.scala @@ -11,6 +11,7 @@ import mainargs.{ } import org.jline.terminal.Terminal import orca.RawArgs +import orca.settings.AgentSpec import orca.shell.{ShellEnv, Tier, WorktreeScan} import orca.shell.run.LaunchResult import orca.shell.sessions.ObservedStatus @@ -320,11 +321,11 @@ private[cli] class CliCommands(using env: ShellEnv): "Example: orca config --coding-agent codex" ) def config( - @arg(doc = "planning role, as harness[:model]") + @arg(doc = "planning role, as " + AgentSpec.ValueForm) planningAgent: Option[String] = None, - @arg(doc = "coding role, as harness[:model]") + @arg(doc = "coding role, as " + AgentSpec.ValueForm) codingAgent: Option[String] = None, - @arg(doc = "review role, as harness[:model]") + @arg(doc = "review role, as " + AgentSpec.ValueForm) reviewAgent: Option[String] = None, @arg(doc = "rewrite a malformed settings file from scratch instead of refusing" diff --git a/shell/src/main/scala/orca/shell/cli/CliHelp.scala b/shell/src/main/scala/orca/shell/cli/CliHelp.scala index 99f4e15f..422c2761 100644 --- a/shell/src/main/scala/orca/shell/cli/CliHelp.scala +++ b/shell/src/main/scala/orca/shell/cli/CliHelp.scala @@ -33,5 +33,6 @@ private[shell] object CliHelp: | orca create "summarize a PR's review threads" | orca continue --list | orca config --coding-agent codex + | orca config --review-agent claude:sonnet@high | |Run 'orca --help' for details. 'orca --version' prints the version.""".stripMargin diff --git a/shell/src/main/scala/orca/shell/cli/ConfigCli.scala b/shell/src/main/scala/orca/shell/cli/ConfigCli.scala index a192d196..369aad5a 100644 --- a/shell/src/main/scala/orca/shell/cli/ConfigCli.scala +++ b/shell/src/main/scala/orca/shell/cli/ConfigCli.scala @@ -130,8 +130,7 @@ private[cli] object ConfigCli: private[cli] def renderAgents(agents: AgentSettings): String = def line(role: String, spec: Option[AgentSpec]): String = - val value = spec.fold("(not set)"): s => - AgentSpec.harnessNameFor(s.backend) + s.model.fold("")(":" + _) + val value = spec.fold("(not set)")(_.render) s"$role: $value" List( line("planning", agents.planning), diff --git a/shell/src/main/scala/orca/shell/cli/Tables.scala b/shell/src/main/scala/orca/shell/cli/Tables.scala index bd072ecf..59d591ec 100644 --- a/shell/src/main/scala/orca/shell/cli/Tables.scala +++ b/shell/src/main/scala/orca/shell/cli/Tables.scala @@ -10,7 +10,6 @@ import com.github.plokhotnyuk.jsoniter_scala.macros.{ } import orca.StagePath import orca.shell.flows.DiscoveredFlow -import orca.settings.AgentSpec import orca.shell.sessions.{ ObservedStatus, ResumeCommand, @@ -82,7 +81,7 @@ private[cli] object Tables: kind = session.kind, stage = session.stage, sessionStage = session.minted.map(_.stage), - harness = AgentSpec.harnessNameFor(session.backend), + harness = session.backend.harnessName, lastActiveAt = session.lastActiveAt.toString, resumable = gate.isRight, reason = gate.left.toOption, diff --git a/shell/src/main/scala/orca/shell/create/FlowAuthoring.scala b/shell/src/main/scala/orca/shell/create/FlowAuthoring.scala index ef9e4768..ccc9b2f5 100644 --- a/shell/src/main/scala/orca/shell/create/FlowAuthoring.scala +++ b/shell/src/main/scala/orca/shell/create/FlowAuthoring.scala @@ -78,7 +78,7 @@ private[shell] object FlowAuthoring: * pinned model only costs the local fallback. */ def slugArgv(agent: AgentSpec, prompt: String): Seq[String] = - val binary = AgentSpec.harnessNameFor(agent.backend) + val binary = agent.backend.harnessName val pin = agent.model.toSeq.flatMap(m => Seq("--model", m)) agent.backend match case BackendTag.ClaudeCode => diff --git a/shell/src/main/scala/orca/shell/sessions/ResumeCommand.scala b/shell/src/main/scala/orca/shell/sessions/ResumeCommand.scala index b9cb375b..2e439480 100644 --- a/shell/src/main/scala/orca/shell/sessions/ResumeCommand.scala +++ b/shell/src/main/scala/orca/shell/sessions/ResumeCommand.scala @@ -1,7 +1,6 @@ package orca.shell.sessions import orca.agents.BackendTag -import orca.settings.AgentSpec /** Per-harness interactive resume argv (ADR 0021 §8's resume table). This is * harness-session REATTACH — exec'ing the harness CLI's own resume flag @@ -19,7 +18,7 @@ private[shell] object ResumeCommand: */ def staticGate(s: RecordedSession): Either[String, String] = s.wireId.toRight( - s"${AgentSpec.harnessNameFor(s.backend)} session has no resumable id" + s"${s.backend.harnessName} session has no resumable id" ) /** Left = not resumable: [[staticGate]]'s checks, plus whatever the caller's @@ -32,10 +31,10 @@ private[shell] object ResumeCommand: * harness's branch, with the already-validated wire id — a non-applicable * lookup is never called, so it can't be mistaken for a failed one. * - * Binary names come from [[AgentSpec.harnessNameFor]] (the settings-file - * spelling — `claude`, `codex`, …). Also rejects a blank wireId or one - * starting with `-` — passed straight into an argv slot, such a value could - * otherwise be parsed as a flag by the harness CLI. + * Binary names come from [[orca.agents.BackendTag.harnessName]]. Also + * rejects a blank wireId or one starting with `-` — passed straight into an + * argv slot, such a value could otherwise be parsed as a flag by the harness + * CLI. */ def build( s: RecordedSession, @@ -46,7 +45,7 @@ private[shell] object ResumeCommand: if wireId.isBlank || wireId.startsWith("-") then Left(s"recorded wireId `$wireId` is not a valid session id") else - val binary = AgentSpec.harnessNameFor(s.backend) + val binary = s.backend.harnessName s.backend match case BackendTag.ClaudeCode => Right(Seq(binary, "--resume", wireId)) diff --git a/shell/src/main/scala/orca/shell/sessions/SessionPicker.scala b/shell/src/main/scala/orca/shell/sessions/SessionPicker.scala index 67de031d..35a50d8e 100644 --- a/shell/src/main/scala/orca/shell/sessions/SessionPicker.scala +++ b/shell/src/main/scala/orca/shell/sessions/SessionPicker.scala @@ -1,7 +1,6 @@ package orca.shell.sessions import orca.StagePath -import orca.settings.AgentSpec import orca.shell.ui.Choice /** The continue-a-session picker (ADR 0021 §8): labels a [[SessionIndex]]'s @@ -149,7 +148,7 @@ private[shell] object SessionPicker: * marker shows only for a crashed attempt. */ private def rowTail(selection: SessionSelection, marker: String): String = - val harness = AgentSpec.harnessNameFor(selection.session.backend) + val harness = selection.session.backend.harnessName val branch = selection.attempt.branch.fold("")(b => s" on ${b.value}") val crashed = SessionNaming.crashedSuffix(selection.observedStatus) s"[$harness]$marker$branch$crashed" diff --git a/shell/src/main/scala/orca/shell/wizard/Wizard.scala b/shell/src/main/scala/orca/shell/wizard/Wizard.scala index b704e98e..d5cd43dd 100644 --- a/shell/src/main/scala/orca/shell/wizard/Wizard.scala +++ b/shell/src/main/scala/orca/shell/wizard/Wizard.scala @@ -41,7 +41,7 @@ private[shell] class Wizard( else AgentSettings.empty val detected = BackendTag.values - .filter(tag => probe(AgentSpec.harnessNameFor(tag))) + .filter(tag => probe(tag.harnessName)) .toSet val fallback = BackendTag.values.find(detected.contains).getOrElse(BackendTag.ClaudeCode) @@ -120,10 +120,30 @@ private[shell] class Wizard( choices, default = Some(currentTag.getOrElse(fallback)) ) - currentModel = - if currentTag.contains(tag) then current.flatMap(_.model) else None - model <- selectModel(role, tag, currentModel) - yield AgentSpec(tag, model) + spec <- selectSpec(role, tag, current) + yield spec + + /** Asks for `tag`'s model. A typed model ending in `@` sets the + * effort; otherwise `current`'s effort is kept if the harness is unchanged. + * Asks again when the result is not a valid settings value. + */ + private def selectSpec( + role: Wizard.Role, + tag: BackendTag, + current: Option[AgentSpec] + ): UiOutcome[AgentSpec] = + val currentModel = current.filter(_.backend == tag).flatMap(_.model) + selectModel(role, tag, currentModel).flatMap: model => + // parse splits a typed `@` off the model + AgentSpec.parse(AgentSpec(tag, model).render) match + case Right(typed) if typed.effort.isDefined => UiOutcome.Selected(typed) + case Right(typed) => + UiOutcome.Selected( + current.fold(typed)(_.repinned(tag, typed.model)) + ) + case Left(problem) => + ShellOutput.error(problem) + selectSpec(role, tag, current) /** The per-harness model step ([[ModelCatalog.pick]]). `currentModel` is the * role's existing pin, already dropped by the caller if the harness changed. @@ -146,7 +166,7 @@ private[shell] class Wizard( current: Option[BackendTag], detected: Set[BackendTag] ): Choice[BackendTag] = - val name = AgentSpec.harnessNameFor(tag) + val name = tag.harnessName val marked = if current.contains(tag) then s"$name (current)" else name Choice(tag, s"$marked — ${Wizard.pathStatus(detected(tag))}") diff --git a/shell/src/test/scala/orca/shell/wizard/WizardTest.scala b/shell/src/test/scala/orca/shell/wizard/WizardTest.scala index a18ae238..0ccd68cf 100644 --- a/shell/src/test/scala/orca/shell/wizard/WizardTest.scala +++ b/shell/src/test/scala/orca/shell/wizard/WizardTest.scala @@ -1,6 +1,6 @@ package orca.shell.wizard -import orca.agents.BackendTag +import orca.agents.{BackendTag, Effort} import orca.settings.{AgentSettings, AgentSpec, SettingsFile, SettingsScope} import orca.shell.ui.{Choice, ShellUi, UiOutcome} @@ -186,6 +186,22 @@ class WizardTest extends munit.FunSuite: Some(AgentSpec(BackendTag.Opencode, Some("anthropic/claude-sonnet-5"))) ) + test("a free-text model with a level the harness rejects is asked again"): + withTempPath: path => + val ui = ScriptedUi( + selectScript = List.fill(3)(UiOutcome.Selected(BackendTag.Pi)), + inputScript = List( + UiOutcome.Selected("sonnet@max"), // pi has no max + UiOutcome.Selected("sonnet@high"), + UiOutcome.Selected(""), + UiOutcome.Selected("") + ) + ) + assert(Wizard(ui, probe, path).run(reconfigure = false)) + + val agents = parse(os.read(path)) + assertEquals(agents.planning, AgentSpec.parse("pi:sonnet@high").toOption) + test( "first run starts planning on fable, the role's own default model" ): @@ -326,6 +342,40 @@ class WizardTest extends munit.FunSuite: val agents = parse(os.read(path)) assertEquals(agents.coding, Some(AgentSpec(BackendTag.Gemini, None))) + test("re-configure keeps a hand-written effort on the same harness"): + withTempPath: path => + os.write(path, "codingAgent = claude:sonnet@high\n") + val ui = ScriptedUi( + selectScript = List( + UiOutcome.Selected(BackendTag.ClaudeCode), + UiOutcome.Selected(ModelCatalog.ModelPick.Default), + UiOutcome.Selected(BackendTag.ClaudeCode), + UiOutcome.Selected(ModelCatalog.ModelPick.Default), + UiOutcome.Selected(BackendTag.ClaudeCode), + UiOutcome.Selected(ModelCatalog.ModelPick.Default) + ) + ) + assert(Wizard(ui, probe, path).run(reconfigure = true)) + + val agents = parse(os.read(path)) + assertEquals(agents.coding.flatMap(_.effort), Some(Effort.High)) + + test("re-configure with a typed effort replaces the existing one"): + withTempPath: path => + os.write(path, "planningAgent = pi:sonnet@high\n") + val ui = ScriptedUi( + selectScript = List.fill(3)(UiOutcome.Selected(BackendTag.Pi)), + inputScript = List( + UiOutcome.Selected("sonnet@low"), + UiOutcome.Selected(""), + UiOutcome.Selected("") + ) + ) + assert(Wizard(ui, probe, path).run(reconfigure = true)) + + val agents = parse(os.read(path)) + assertEquals(agents.planning, AgentSpec.parse("pi:sonnet@low").toOption) + test("re-configure onto harness default clears an existing pin"): withTempPath: path => os.write( diff --git a/tools/src/main/scala/orca/agents/Agent.scala b/tools/src/main/scala/orca/agents/Agent.scala index 7669ca8c..d24716c2 100644 --- a/tools/src/main/scala/orca/agents/Agent.scala +++ b/tools/src/main/scala/orca/agents/Agent.scala @@ -180,10 +180,19 @@ final class Agent[B <: BackendTag] private ( def withSelfManagedGit: Agent[B] = copy(config = config.copy(selfManagedGit = true)) - /** Sibling whose config pins [[AgentConfig.effort]] to `effort`. Backends - * that support it expose this as `withEffort`. + /** Set the reasoning effort: `codex.withEffort(Effort.High)`. Compiles only + * on harnesses with an [[EffortSupport]]; throws `IllegalArgumentException` + * for a level the harness does not accept. */ - private[orca] def withEffortLevel(effort: Effort): Agent[B] = + def withEffort(effort: Effort)(using support: EffortSupport[B]): Agent[B] = + support.check(effort) match + case Right(accepted) => withCheckedEffort(accepted) + case Left(problem) => throw IllegalArgumentException(problem) + + /** [[withEffort]] for a level already passed through this harness's + * [[EffortSupport.check]], when only the harness's tag is known. + */ + private[orca] def withCheckedEffort(effort: Effort): Agent[B] = copy(config = config.copy(effort = Some(effort))) /** Sibling whose config pins [[AgentConfig.networkTools]] to `tools`. diff --git a/tools/src/main/scala/orca/agents/BackendTag.scala b/tools/src/main/scala/orca/agents/BackendTag.scala index fc20219f..1a3e32d8 100644 --- a/tools/src/main/scala/orca/agents/BackendTag.scala +++ b/tools/src/main/scala/orca/agents/BackendTag.scala @@ -16,6 +16,16 @@ import com.github.plokhotnyuk.jsoniter_scala.macros.{ enum BackendTag: case ClaudeCode, Codex, Opencode, Pi, Gemini + /** The harness's name in settings and messages, which is also its CLI binary: + * `claude`, `codex`, …. + */ + private[orca] def harnessName: String = this match + case ClaudeCode => "claude" + case Codex => "codex" + case Opencode => "opencode" + case Pi => "pi" + case Gemini => "gemini" + object BackendTag: given codec: ConfiguredJsonValueCodec[BackendTag] = ConfiguredJsonValueCodec.derived[BackendTag](using diff --git a/tools/src/main/scala/orca/agents/Effort.scala b/tools/src/main/scala/orca/agents/Effort.scala index c3942409..484e38f5 100644 --- a/tools/src/main/scala/orca/agents/Effort.scala +++ b/tools/src/main/scala/orca/agents/Effort.scala @@ -1,11 +1,85 @@ package orca.agents +import scala.annotation.implicitNotFound + /** How much reasoning an agent spends per turn. Set with - * `claude.sonnet.withEffort(Effort.Medium)`. + * `codex.withEffort(Effort.High)`; each harness accepts the levels its + * [[EffortSupport]] lists. */ -enum Effort(val name: String): - case Low extends Effort("low") - case Medium extends Effort("medium") - case High extends Effort("high") - case XHigh extends Effort("xhigh") - case Max extends Effort("max") +enum Effort: + case Off, Minimal, Low, Medium, High, XHigh, Max + + /** The name settings use for this level: `off`, `xhigh`, …. */ + private[orca] def label: String = toString.toLowerCase + +object Effort: + /** The level named `label` (see [[Effort.label]]). */ + private[orca] def fromLabel(label: String): Option[Effort] = + values.find(_.label == label) + +/** The [[Effort]] levels harness `B` accepts, and the value it is sent as. A + * harness without an instance has no effort setting, so `withEffort` does not + * compile on it. + */ +@implicitNotFound("This harness has no effort setting.") +final class EffortSupport[B <: BackendTag] private ( + tag: BackendTag, + levels: Map[Effort, String] +): + /** `effort`, or a message naming the levels the harness accepts. */ + private[orca] def check(effort: Effort): Either[String, Effort] = + if levels.contains(effort) then Right(effort) + else + Left( + s"${tag.harnessName} has no `${effort.label}` effort; it accepts " + + Effort.values.filter(levels.contains).map(_.label).mkString(", ") + ) + + /** The harness's value for a level [[check]] accepted. */ + private[orca] def wire(effort: Effort): String = levels(effort) + +object EffortSupport: + + /** The instance for `tag`, for code that only knows the harness at run time; + * `None` for a harness without an effort setting. + */ + private[orca] def forTag(tag: BackendTag): Option[EffortSupport[?]] = + tag match + case BackendTag.ClaudeCode => Some(claude) + case BackendTag.Codex => Some(codex) + case BackendTag.Opencode => Some(opencode) + case BackendTag.Pi => Some(pi) + case BackendTag.Gemini => None + + private val lowToMax: Map[Effort, String] = Map( + Effort.Low -> "low", + Effort.Medium -> "medium", + Effort.High -> "high", + Effort.XHigh -> "xhigh", + Effort.Max -> "max" + ) + + given claude: EffortSupport[BackendTag.ClaudeCode.type] = + EffortSupport(BackendTag.ClaudeCode, lowToMax) + + given codex: EffortSupport[BackendTag.Codex.type] = + EffortSupport( + BackendTag.Codex, + lowToMax + (Effort.Off -> "none") + (Effort.Minimal -> "minimal") + ) + + given pi: EffortSupport[BackendTag.Pi.type] = + EffortSupport( + BackendTag.Pi, + lowToMax - Effort.Max + (Effort.Off -> "off") + + (Effort.Minimal -> "minimal") + ) + + /** Sent as the message's `variant`. Which variants exist depends on the + * model, and orca does not look them up, so every level is accepted. + */ + given opencode: EffortSupport[BackendTag.Opencode.type] = + EffortSupport( + BackendTag.Opencode, + lowToMax + (Effort.Off -> "none") + (Effort.Minimal -> "minimal") + ) diff --git a/tools/src/test/scala/orca/agents/AgentCheapTest.scala b/tools/src/test/scala/orca/agents/AgentCheapTest.scala index 42be8ae5..9e07e6b4 100644 --- a/tools/src/test/scala/orca/agents/AgentCheapTest.scala +++ b/tools/src/test/scala/orca/agents/AgentCheapTest.scala @@ -25,6 +25,10 @@ class AgentCheapTest extends munit.FunSuite: .withReadOnly assertEquals(agent.cheap.config.model, Some(Model("cheap-x"))) + test("cheap drops the effort along with the leading model"): + val agent = TestAgent(TieredBackend).withEffort(Effort.High) + assertEquals(agent.cheap.config.effort, None) + test("cheap keeps the agent's name and role"): val agent = TestAgent(TieredBackend).withName("coder").withRole("coding") assertEquals( diff --git a/tools/src/test/scala/orca/agents/EffortTest.scala b/tools/src/test/scala/orca/agents/EffortTest.scala new file mode 100644 index 00000000..e77657ba --- /dev/null +++ b/tools/src/test/scala/orca/agents/EffortTest.scala @@ -0,0 +1,26 @@ +package orca.agents + +import orca.testkit.{ScriptedBackend, TestAgent} + +class EffortTest extends munit.FunSuite: + + test("withEffort pins the level"): + val agent = TestAgent(ScriptedBackend.unused(BackendTag.Pi)) + assertEquals( + agent.withEffort(Effort.Low).config.effort, + Some(Effort.Low) + ) + + test("claude refuses Off"): + val agent = TestAgent(ScriptedBackend.unused(BackendTag.ClaudeCode)) + intercept[IllegalArgumentException](agent.withEffort(Effort.Off)) + + test("pi refuses Max"): + val agent = TestAgent(ScriptedBackend.unused(BackendTag.Pi)) + intercept[IllegalArgumentException](agent.withEffort(Effort.Max)) + + test("withEffort does not compile on gemini"): + val errors = compileErrors( + "TestAgent(ScriptedBackend.unused(BackendTag.Gemini)).withEffort(Effort.Low)" + ) + assert(errors.contains("This harness has no effort setting."), errors)