From f72c942afd29b0469f6c3d349fa36c08d55f780f Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Tue, 20 Oct 2026 00:54:01 +0000 Subject: [PATCH 1/7] Support withEffort on codex, pi and opencode --- .../orca/backend/claude/ClaudeAgents.scala | 8 +-- .../orca/backend/claude/ClaudeArgs.scala | 3 +- .../backend/claude/ClaudeAgentsTest.scala | 12 +--- .../scala/orca/backend/codex/CodexArgs.scala | 8 +++ .../orca/backend/codex/CodexArgsTest.scala | 22 ++++++ docs/api/backends.md | 6 +- docs/authoring/choosing-agents.md | 18 +++-- .../orca/backend/opencode/OpencodeApi.scala | 3 +- .../orca/backend/opencode/OpencodeArgs.scala | 4 +- .../backend/opencode/OpencodeApiTest.scala | 12 +++- .../backend/opencode/OpencodeArgsTest.scala | 9 ++- .../main/scala/orca/backend/pi/PiArgs.scala | 2 + .../scala/orca/backend/pi/PiArgsTest.scala | 11 ++- runner/src/main/scala/orca/exports.scala | 1 - tools/src/main/scala/orca/agents/Agent.scala | 9 +-- tools/src/main/scala/orca/agents/Effort.scala | 69 +++++++++++++++++-- .../scala/orca/agents/AgentCheapTest.scala | 4 ++ .../test/scala/orca/agents/EffortTest.scala | 26 +++++++ 18 files changed, 184 insertions(+), 43 deletions(-) create mode 100644 tools/src/test/scala/orca/agents/EffortTest.scala diff --git a/claude/src/main/scala/orca/backend/claude/ClaudeAgents.scala b/claude/src/main/scala/orca/backend/claude/ClaudeAgents.scala index efeb462da..0bde9b203 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 0bd58a290..39f794b38 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 d0dc6c688..c0865045a 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 c34c2c120..49a32ed81 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 c2885102a..91ad7438d 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 976273423..0a5084ab1 100644 --- a/docs/api/backends.md +++ b/docs/api/backends.md @@ -29,8 +29,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 +66,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` overrides a level in the model pattern (`sonnet:high`). 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 diff --git a/docs/authoring/choosing-agents.md b/docs/authoring/choosing-agents.md index 6f2d82fca..d13116e0e 100644 --- a/docs/authoring/choosing-agents.md +++ b/docs/authoring/choosing-agents.md @@ -114,10 +114,20 @@ 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. 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 | depends on the model | the message's `variant` | +| gemini | none; does not compile | | + +A level the harness does not accept throws when the agent is built. It cannot +be set in `settings.properties`. 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/opencode/src/main/scala/orca/backend/opencode/OpencodeApi.scala b/opencode/src/main/scala/orca/backend/opencode/OpencodeApi.scala index b1109fc52..5ecf3ad70 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 7a6844c0d..8e0e6a719 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 28fcf79b1..21e585b00 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 9d12f3548..3cdf48f6a 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.XHigh)) + assertEquals( + OpencodeArgs.message(config, "hi", None, interactive).variant, + Some("xhigh") + ) + 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 b69c6d034..526b68f31 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 2cba9a3d6..92f67127a 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 af625e4a1..45cf0f8c3 100644 --- a/runner/src/main/scala/orca/exports.scala +++ b/runner/src/main/scala/orca/exports.scala @@ -131,7 +131,6 @@ export orca.backend.claude.ClaudeAgents.{ sonnet, opus, fable, - withEffort, withNetworkTools } export orca.backend.codex.CodexAgents diff --git a/tools/src/main/scala/orca/agents/Agent.scala b/tools/src/main/scala/orca/agents/Agent.scala index 7669ca8c1..973e5e56e 100644 --- a/tools/src/main/scala/orca/agents/Agent.scala +++ b/tools/src/main/scala/orca/agents/Agent.scala @@ -180,11 +180,12 @@ 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] = - copy(config = config.copy(effort = Some(effort))) + def withEffort(effort: Effort)(using support: EffortSupport[B]): Agent[B] = + copy(config = config.copy(effort = Some(support.validated(effort)))) /** Sibling whose config pins [[AgentConfig.networkTools]] to `tools`. * Backends that support network tools expose it as their own validated diff --git a/tools/src/main/scala/orca/agents/Effort.scala b/tools/src/main/scala/orca/agents/Effort.scala index c39424093..619e070ea 100644 --- a/tools/src/main/scala/orca/agents/Effort.scala +++ b/tools/src/main/scala/orca/agents/Effort.scala @@ -1,11 +1,66 @@ 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: + case Off, Minimal, Low, Medium, High, XHigh, Max + +/** 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. */ -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") +@implicitNotFound("This harness has no effort setting.") +final class EffortSupport[B <: BackendTag] private ( + harness: String, + levels: Map[Effort, String] +): + /** `effort`, or `IllegalArgumentException` if the harness does not accept it. + */ + def validated(effort: Effort): Effort = + if levels.contains(effort) then effort + else + throw IllegalArgumentException( + s"$harness has no $effort effort; it accepts " + + Effort.values.filter(levels.contains).mkString(", ") + ) + + /** The harness's value for `effort`; throws like [[validated]]. */ + def wire(effort: Effort): String = levels(validated(effort)) + +object EffortSupport: + private val upToMax: 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("claude", upToMax) + + given codex: EffortSupport[BackendTag.Codex.type] = + EffortSupport( + "codex", + upToMax + (Effort.Off -> "none") + (Effort.Minimal -> "minimal") + ) + + given pi: EffortSupport[BackendTag.Pi.type] = + EffortSupport( + "pi", + upToMax - 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( + "opencode", + upToMax + (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 42be8ae50..9e07e6b41 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 000000000..e77657bad --- /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) From 1a84ac04ddae56a066392ff5aafa81caa3cab901 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 00:21:42 +0000 Subject: [PATCH 2/7] Narrow EffortSupport methods; fix opencode docs, ADR and test --- adr/0014-opencode-server-driver.md | 2 +- docs/authoring/choosing-agents.md | 14 +++++++------- .../orca/backend/opencode/OpencodeArgsTest.scala | 4 ++-- tools/src/main/scala/orca/agents/Effort.scala | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/adr/0014-opencode-server-driver.md b/adr/0014-opencode-server-driver.md index 7ba8bdf45..75accb6af 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/docs/authoring/choosing-agents.md b/docs/authoring/choosing-agents.md index d13116e0e..ddb265511 100644 --- a/docs/authoring/choosing-agents.md +++ b/docs/authoring/choosing-agents.md @@ -118,13 +118,13 @@ in [Talking to agents](talking-to-agents.md). 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 | depends on the model | the message's `variant` | -| gemini | none; does not compile | | +| 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 | | A level the harness does not accept throws when the agent is built. It cannot be set in `settings.properties`. diff --git a/opencode/src/test/scala/orca/backend/opencode/OpencodeArgsTest.scala b/opencode/src/test/scala/orca/backend/opencode/OpencodeArgsTest.scala index 3cdf48f6a..0a3bb01bb 100644 --- a/opencode/src/test/scala/orca/backend/opencode/OpencodeArgsTest.scala +++ b/opencode/src/test/scala/orca/backend/opencode/OpencodeArgsTest.scala @@ -71,10 +71,10 @@ class OpencodeArgsTest extends munit.FunSuite: ) test("message sends the effort as the variant"): - val config = AgentConfig(effort = Some(Effort.XHigh)) + val config = AgentConfig(effort = Some(Effort.Off)) assertEquals( OpencodeArgs.message(config, "hi", None, interactive).variant, - Some("xhigh") + Some("none") ) test("message carries the composed system prompt, config included"): diff --git a/tools/src/main/scala/orca/agents/Effort.scala b/tools/src/main/scala/orca/agents/Effort.scala index 619e070ea..b01a7b0c4 100644 --- a/tools/src/main/scala/orca/agents/Effort.scala +++ b/tools/src/main/scala/orca/agents/Effort.scala @@ -20,7 +20,7 @@ final class EffortSupport[B <: BackendTag] private ( ): /** `effort`, or `IllegalArgumentException` if the harness does not accept it. */ - def validated(effort: Effort): Effort = + private[agents] def validated(effort: Effort): Effort = if levels.contains(effort) then effort else throw IllegalArgumentException( @@ -28,8 +28,8 @@ final class EffortSupport[B <: BackendTag] private ( Effort.values.filter(levels.contains).mkString(", ") ) - /** The harness's value for `effort`; throws like [[validated]]. */ - def wire(effort: Effort): String = levels(validated(effort)) + /** The harness's value for a level [[validated]] accepted. */ + private[orca] def wire(effort: Effort): String = levels(effort) object EffortSupport: private val upToMax: Map[Effort, String] = Map( From 47db7fea720b3c8beb9a840022114504d49a47af Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 00:33:53 +0000 Subject: [PATCH 3/7] Set effort in settings and orca config as harness[:model][@effort] --- adr/0020-configurable-role-agents.md | 5 +- adr/0021-orca-shell.md | 2 +- docs/authoring/choosing-agents.md | 5 +- docs/using/settings.md | 17 ++++-- docs/using/shell.md | 5 +- flow/src/main/scala/orca/AgentSet.scala | 15 ++--- flow/src/main/scala/orca/accessors.scala | 8 ++- .../main/scala/orca/settings/AgentSpec.scala | 55 ++++++++++++++----- .../scala/orca/settings/SettingsFile.scala | 7 +-- .../scala/orca/settings/AgentSpecTest.scala | 39 ++++++++++++- .../orca/settings/SettingsFileTest.scala | 6 +- runner/src/main/scala/orca/flowEntry.scala | 8 +-- .../main/scala/orca/runner/RoleAgents.scala | 12 ++-- .../scala/orca/runner/FlowLifecycleTest.scala | 4 +- .../scala/orca/runner/RoleAgentsTest.scala | 24 ++++++++ .../orca/shell/actions/ConfigAction.scala | 2 +- .../orca/shell/actions/ConfigSummary.scala | 7 +-- .../shell/actions/SettingsEditAction.scala | 2 +- shell/src/main/scala/orca/shell/cli/Cli.scala | 6 +- .../main/scala/orca/shell/cli/CliHelp.scala | 1 + .../main/scala/orca/shell/cli/ConfigCli.scala | 3 +- .../main/scala/orca/shell/wizard/Wizard.scala | 11 +++- .../scala/orca/shell/wizard/WizardTest.scala | 23 +++++++- tools/src/main/scala/orca/agents/Agent.scala | 10 +++- tools/src/main/scala/orca/agents/Effort.scala | 35 +++++++++--- 25 files changed, 234 insertions(+), 78 deletions(-) diff --git a/adr/0020-configurable-role-agents.md b/adr/0020-configurable-role-agents.md index da66aedb1..e83a8b9bf 100644 --- a/adr/0020-configurable-role-agents.md +++ b/adr/0020-configurable-role-agents.md @@ -59,7 +59,10 @@ 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). An optional +trailing `@` sets the reasoning effort; only an effort level name +counts, so a model id containing `@` survives, and a level the harness does not +accept is a parse error. ### 2. Locations & precedence diff --git a/adr/0021-orca-shell.md b/adr/0021-orca-shell.md index afa1e0bf1..a7e0cbb3e 100644 --- a/adr/0021-orca-shell.md +++ b/adr/0021-orca-shell.md @@ -289,7 +289,7 @@ 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 +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. diff --git a/docs/authoring/choosing-agents.md b/docs/authoring/choosing-agents.md index ddb265511..da1ceec23 100644 --- a/docs/authoring/choosing-agents.md +++ b/docs/authoring/choosing-agents.md @@ -126,8 +126,9 @@ each harness accepts some of them: | opencode | all; the model decides which apply | the message's `variant` | | gemini | none; does not compile | | -A level the harness does not accept throws when the agent is built. It cannot -be set in `settings.properties`. +A level the harness does not accept throws when the agent is built. In settings +and `orca config`, append it to the agent value instead, 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 39fe6fc40..23c04ee42 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,13 @@ 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 +repeated agent key is an error. The value has the form +`harness[:model][@effort]` 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. ```properties -planningAgent = claude:opus +planningAgent = claude:opus@high codingAgent = codex:gpt-5-mini reviewAgent = opencode:anthropic/claude-haiku-5-5 ``` @@ -73,6 +73,15 @@ 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 one of +`off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`, and +[Choosing agents](../authoring/choosing-agents.md#tuning-an-agent) lists which +ones each harness accepts. A level the harness does not accept, or any level on +gemini, is an error. Only a trailing `@` followed by a level name counts, so a +model id containing `@`, 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 4c3cfad84..83679787c 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 f2e479ebb..79341218e 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 e6a6549f3..df1abe5d3 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 69927e6e0..d54ffefb9 100644 --- a/flow/src/main/scala/orca/settings/AgentSpec.scala +++ b/flow/src/main/scala/orca/settings/AgentSpec.scala @@ -1,13 +1,21 @@ 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 one the harness accepts. */ -private[orca] case class AgentSpec(backend: BackendTag, model: Option[String]) +private[orca] case class AgentSpec( + backend: BackendTag, + model: Option[String], + effort: Option[Effort] = None +): + /** The settings value [[AgentSpec.parse]] reads back into this spec. */ + def render: String = + AgentSpec.harnessNameFor(backend) + model.fold("")(":" + _) + + effort.fold("")("@" + _.label) private[orca] object AgentSpec: /** The settings-file harness name for a backend tag (`claude`, `codex`, …) — @@ -29,17 +37,38 @@ private[orca] object AgentSpec: * no pin. */ def parse(value: String): Either[String, AgentSpec] = - val (harness, model) = value.indexOf(':') match - case -1 => (value.trim, None) + val (pinned, effort) = splitEffort(value) + val (harness, model) = pinned.indexOf(':') match + case -1 => (pinned.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( + (pinned.take(i).trim, Some(pinned.drop(i + 1).trim).filter(_.nonEmpty)) + for + tag <- harnessNames + .get(harness) + .toRight( s"unknown harness `$harness` — valid: " + harnessNames.keys.toList.sorted.mkString(", ") ) + checked <- effort match + case None => Right(None) + case Some(level) => + EffortSupport + .forTag(tag) + .toRight(s"$harness has no effort setting") + .flatMap(_.check(level)) + .map(Some(_)) + yield AgentSpec(tag, model, checked) + + /** Splits off a trailing `@`. Only an effort level name counts, so a + * model id containing `@` (Vertex's `claude-sonnet-4@20250514`) survives. + */ + private def splitEffort(value: String): (String, Option[Effort]) = + value.lastIndexOf('@') match + case -1 => (value, None) + case i => + Effort.fromLabel(value.drop(i + 1).trim) match + case Some(effort) => (value.take(i), Some(effort)) + case None => (value, None) /** 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 8093ef493..8ac42190d 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 " + + "# planningAgent/codingAgent/reviewAgent (harness[:model][@effort]): 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]" + "Values: harness[:model][@effort]" /** 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 d03fe031a..b74b0b93a 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,40 @@ class AgentSpecTest extends FunSuite: AgentSpec.parse("claude:"), Right(AgentSpec(BackendTag.ClaudeCode, None)) ) + + test("parse of harness:model@effort sets the effort"): + assertEquals( + AgentSpec.parse("claude:sonnet@high"), + Right(AgentSpec(BackendTag.ClaudeCode, Some("sonnet"), Some(Effort.High))) + ) + + test("parse of harness@effort sets the effort with no model pin"): + assertEquals( + AgentSpec.parse("codex@low"), + Right(AgentSpec(BackendTag.Codex, None, Some(Effort.Low))) + ) + + test("parse keeps an @ that is not followed by an effort level in the model"): + assertEquals( + AgentSpec.parse("claude:claude-sonnet-4@20250514"), + Right(AgentSpec(BackendTag.ClaudeCode, Some("claude-sonnet-4@20250514"))) + ) + + 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 reads back through parse"): + val spec = + AgentSpec(BackendTag.Pi, Some("sonnet:high"), Some(Effort.Minimal)) + assertEquals(AgentSpec.parse(spec.render), Right(spec)) diff --git a/flow/src/test/scala/orca/settings/SettingsFileTest.scala b/flow/src/test/scala/orca/settings/SettingsFileTest.scala index a2a568985..80617f7bd 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/runner/src/main/scala/orca/flowEntry.scala b/runner/src/main/scala/orca/flowEntry.scala index 6fc51f476..14d45e471 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 1d5bbf3ec..2d58cc0b3 100644 --- a/runner/src/main/scala/orca/runner/RoleAgents.scala +++ b/runner/src/main/scala/orca/runner/RoleAgents.scala @@ -18,7 +18,7 @@ private[runner] case class ResolvedRoles( /** Where a resolved role's agent came from, in precedence order. Drives the * role-announcement `Step`'s `(source)` suffix; the winning [[AgentSpec]] (if - * any) drives the `harness[:model]` part. + * any) drives the `harness:model` part. */ private[runner] enum RoleSource: case Override, Project, Global, Default @@ -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,13 @@ 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)` — + * pure formatting of [[RoleChoice]]'s precomputed fields and the agent's + * effort. */ private def announce(c: RoleChoice): String = - s"${c.label}=${c.harness}:${c.model.getOrElse(UnpinnedModelLabel)} (${sourceLabel(c.source)})" + val effort = c.agent.config.effort.fold("")("@" + _.label) + 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 3e6bc42da..ccde20e1a 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 58ab2ed89..309c10bf6 100644 --- a/runner/src/test/scala/orca/runner/RoleAgentsTest.scala +++ b/runner/src/test/scala/orca/runner/RoleAgentsTest.scala @@ -6,6 +6,7 @@ import orca.agents.{ BackendTag, ClaudeAgent, CodexAgent, + Effort, GeminiAgent, Model, OpencodeAgent, @@ -139,6 +140,29 @@ class RoleAgentsTest extends munit.FunSuite: s"expected the wired codex's own model: ${resolution.announcement}" ) + test("a project effort is applied to the role's agent"): + val roles = resolvedRoles( + AgentSettings(coding = + Some(AgentSpec(BackendTag.Codex, None, Some(Effort.Low))) + ), + wiredAgents() + ) + assertEquals(roles.coding.config.effort, Some(Effort.Low)) + + test("resolveAll announces a project effort after the model"): + val resolution = resolveInScope( + project = AgentSettings(coding = + Some(AgentSpec(BackendTag.Codex, Some("gpt-5-mini"), Some(Effort.Low))) + ), + 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 e92e2f461..0fb988688 100644 --- a/shell/src/main/scala/orca/shell/actions/ConfigAction.scala +++ b/shell/src/main/scala/orca/shell/actions/ConfigAction.scala @@ -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`)." + "(`harness[:model][@effort]`, 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 fc6085909..a2ddec222 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/SettingsEditAction.scala b/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala index 333bb4b90..fd597a842 100644 --- a/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala +++ b/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala @@ -27,7 +27,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" + + "# Role agents — harness[:model][@effort]; harness: claude|codex|opencode|pi|gemini.\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 96bb9aca5..eb5a996d4 100644 --- a/shell/src/main/scala/orca/shell/cli/Cli.scala +++ b/shell/src/main/scala/orca/shell/cli/Cli.scala @@ -320,11 +320,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 harness[:model][@effort]") planningAgent: Option[String] = None, - @arg(doc = "coding role, as harness[:model]") + @arg(doc = "coding role, as harness[:model][@effort]") codingAgent: Option[String] = None, - @arg(doc = "review role, as harness[:model]") + @arg(doc = "review role, as harness[:model][@effort]") 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 99f4e15f4..422c27611 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 a192d196c..369aad5af 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/wizard/Wizard.scala b/shell/src/main/scala/orca/shell/wizard/Wizard.scala index b704e98ea..81870210a 100644 --- a/shell/src/main/scala/orca/shell/wizard/Wizard.scala +++ b/shell/src/main/scala/orca/shell/wizard/Wizard.scala @@ -120,10 +120,15 @@ private[shell] class Wizard( choices, default = Some(currentTag.getOrElse(fallback)) ) - currentModel = - if currentTag.contains(tag) then current.flatMap(_.model) else None + sameHarness = currentTag.contains(tag) + currentModel = if sameHarness then current.flatMap(_.model) else None model <- selectModel(role, tag, currentModel) - yield AgentSpec(tag, model) + // The wizard has no effort step, so it keeps a hand-written one. + yield AgentSpec( + tag, + model, + if sameHarness then current.flatMap(_.effort) else None + ) /** The per-harness model step ([[ModelCatalog.pick]]). `currentModel` is the * role's existing pin, already dropped by the caller if the harness changed. diff --git a/shell/src/test/scala/orca/shell/wizard/WizardTest.scala b/shell/src/test/scala/orca/shell/wizard/WizardTest.scala index a18ae238b..c2e116508 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} @@ -326,6 +326,27 @@ 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, + Some(AgentSpec(BackendTag.ClaudeCode, None, Some(Effort.High))) + ) + 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 973e5e56e..d24716c23 100644 --- a/tools/src/main/scala/orca/agents/Agent.scala +++ b/tools/src/main/scala/orca/agents/Agent.scala @@ -185,7 +185,15 @@ final class Agent[B <: BackendTag] private ( * for a level the harness does not accept. */ def withEffort(effort: Effort)(using support: EffortSupport[B]): Agent[B] = - copy(config = config.copy(effort = Some(support.validated(effort)))) + 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`. * Backends that support network tools expose it as their own validated diff --git a/tools/src/main/scala/orca/agents/Effort.scala b/tools/src/main/scala/orca/agents/Effort.scala index b01a7b0c4..e6754a04e 100644 --- a/tools/src/main/scala/orca/agents/Effort.scala +++ b/tools/src/main/scala/orca/agents/Effort.scala @@ -9,6 +9,14 @@ import scala.annotation.implicitNotFound 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]]), ignoring case. */ + private[orca] def fromLabel(label: String): Option[Effort] = + values.find(_.label == label.toLowerCase) + /** 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. @@ -18,20 +26,31 @@ final class EffortSupport[B <: BackendTag] private ( harness: String, levels: Map[Effort, String] ): - /** `effort`, or `IllegalArgumentException` if the harness does not accept it. - */ - private[agents] def validated(effort: Effort): Effort = - if levels.contains(effort) then effort + /** `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 - throw IllegalArgumentException( - s"$harness has no $effort effort; it accepts " + - Effort.values.filter(levels.contains).mkString(", ") + Left( + s"$harness has no `${effort.label}` effort; it accepts " + + Effort.values.filter(levels.contains).map(_.label).mkString(", ") ) - /** The harness's value for a level [[validated]] accepted. */ + /** 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 upToMax: Map[Effort, String] = Map( Effort.Low -> "low", Effort.Medium -> "medium", From 8fe149665f7d2885ae640a541224da6c034bc246 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 00:50:10 +0000 Subject: [PATCH 4/7] Reject unknown effort levels; only parse sets AgentSpec.effort --- .orca/settings.properties | 2 +- adr/0020-configurable-role-agents.md | 11 ++-- docs/api/backends.md | 5 +- docs/using/settings.md | 12 ++-- .../main/scala/orca/settings/AgentSpec.scala | 56 +++++++++++++------ .../scala/orca/settings/AgentSpecTest.scala | 45 +++++++++++---- .../main/scala/orca/runner/RoleAgents.scala | 2 +- .../scala/orca/runner/RoleAgentsTest.scala | 6 +- .../main/scala/orca/shell/wizard/Wizard.scala | 29 +++++++--- .../scala/orca/shell/wizard/WizardTest.scala | 21 +++++-- tools/src/main/scala/orca/agents/Effort.scala | 4 +- 11 files changed, 131 insertions(+), 62 deletions(-) diff --git a/.orca/settings.properties b/.orca/settings.properties index fa50a1372..817598695 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/0020-configurable-role-agents.md b/adr/0020-configurable-role-agents.md index e83a8b9bf..b26d3674b 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,10 +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). An optional -trailing `@` sets the reasoning effort; only an effort level name -counts, so a model id containing `@` survives, and a level the harness does not -accept is a parse error. +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 diff --git a/docs/api/backends.md b/docs/api/backends.md index 0a5084ab1..22b05fc0a 100644 --- a/docs/api/backends.md +++ b/docs/api/backends.md @@ -84,6 +84,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/using/settings.md b/docs/using/settings.md index 23c04ee42..e71bda234 100644 --- a/docs/using/settings.md +++ b/docs/using/settings.md @@ -74,13 +74,11 @@ model ids. There is one exception: claude's bare `haiku` alias is sent as 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 one of -`off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`, and -[Choosing agents](../authoring/choosing-agents.md#tuning-an-agent) lists which -ones each harness accepts. A level the harness does not accept, or any level on -gemini, is an error. Only a trailing `@` followed by a level name counts, so a -model id containing `@`, such as `claude-sonnet-4@20250514`, is passed on as -is. +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/flow/src/main/scala/orca/settings/AgentSpec.scala b/flow/src/main/scala/orca/settings/AgentSpec.scala index d54ffefb9..494103ef3 100644 --- a/flow/src/main/scala/orca/settings/AgentSpec.scala +++ b/flow/src/main/scala/orca/settings/AgentSpec.scala @@ -5,19 +5,30 @@ import orca.agents.{BackendTag, Effort, EffortSupport} /** 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 one the harness accepts. + * normalise or validate model ids. `effort` is always one the harness accepts: + * only [[AgentSpec.parse]] sets it. */ -private[orca] case class AgentSpec( +private[orca] case class AgentSpec private ( backend: BackendTag, model: Option[String], - effort: Option[Effort] = None + effort: Option[Effort] ): /** The settings value [[AgentSpec.parse]] reads back into this spec. */ def render: String = AgentSpec.harnessNameFor(backend) + model.fold("")(":" + _) + effort.fold("")("@" + _.label) + /** 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: + /** A spec with no effort. */ + def apply(backend: BackendTag, model: Option[String]): AgentSpec = + new AgentSpec(backend, model, None) + /** The settings-file harness name for a backend tag (`claude`, `codex`, …) — * lowercase, distinct from the tag's case name. */ @@ -37,12 +48,15 @@ private[orca] object AgentSpec: * no pin. */ def parse(value: String): Either[String, AgentSpec] = - val (pinned, effort) = splitEffort(value) - val (harness, model) = pinned.indexOf(':') match - case -1 => (pinned.trim, None) - case i => - (pinned.take(i).trim, Some(pinned.drop(i + 1).trim).filter(_.nonEmpty)) for + (pinned, effort) <- splitEffort(value) + (harness, model) = pinned.indexOf(':') match + case -1 => (pinned.trim, None) + case i => + ( + pinned.take(i).trim, + Some(pinned.drop(i + 1).trim).filter(_.nonEmpty) + ) tag <- harnessNames .get(harness) .toRight( @@ -57,18 +71,28 @@ private[orca] object AgentSpec: .toRight(s"$harness has no effort setting") .flatMap(_.check(level)) .map(Some(_)) - yield AgentSpec(tag, model, checked) + yield new AgentSpec(tag, model, checked) - /** Splits off a trailing `@`. Only an effort level name counts, so a - * model id containing `@` (Vertex's `claude-sonnet-4@20250514`) survives. + /** Splits off a trailing `@`. Only an all-letter suffix is read as a + * level, so a model id like Vertex's `claude-sonnet-4@20250514` survives. */ - private def splitEffort(value: String): (String, Option[Effort]) = + private def splitEffort( + value: String + ): Either[String, (String, Option[Effort])] = value.lastIndexOf('@') match - case -1 => (value, None) + case -1 => Right((value, None)) case i => - Effort.fromLabel(value.drop(i + 1).trim) match - case Some(effort) => (value.take(i), Some(effort)) - case None => (value, None) + val suffix = value.drop(i + 1).trim + if suffix.nonEmpty && !suffix.forall(_.isLetter) then + Right((value, None)) + else + Effort + .fromLabel(suffix) + .map(effort => (value.take(i), Some(effort))) + .toRight( + s"unknown effort `$suffix` — valid: " + + Effort.values.map(_.label).mkString(", ") + ) /** 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/test/scala/orca/settings/AgentSpecTest.scala b/flow/src/test/scala/orca/settings/AgentSpecTest.scala index b74b0b93a..f12868b64 100644 --- a/flow/src/test/scala/orca/settings/AgentSpecTest.scala +++ b/flow/src/test/scala/orca/settings/AgentSpecTest.scala @@ -42,24 +42,38 @@ class AgentSpecTest extends FunSuite: 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( - AgentSpec.parse("claude:sonnet@high"), - Right(AgentSpec(BackendTag.ClaudeCode, Some("sonnet"), Some(Effort.High))) + 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( - AgentSpec.parse("codex@low"), - Right(AgentSpec(BackendTag.Codex, None, Some(Effort.Low))) + fields("codex@low"), + Right((BackendTag.Codex, None, Some(Effort.Low))) ) - test("parse keeps an @ that is not followed by an effort level in the model"): + test("parse keeps an @ followed by more than letters in the model"): assertEquals( - AgentSpec.parse("claude:claude-sonnet-4@20250514"), - Right(AgentSpec(BackendTag.ClaudeCode, Some("claude-sonnet-4@20250514"))) + 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"): + assert(AgentSpec.parse("claude:sonnet@").isLeft) + test("parse of a level the harness does not accept is a Left"): assertEquals( AgentSpec.parse("claude:sonnet@off"), @@ -75,6 +89,17 @@ class AgentSpecTest extends FunSuite: ) test("render reads back through parse"): - val spec = - AgentSpec(BackendTag.Pi, Some("sonnet:high"), Some(Effort.Minimal)) - assertEquals(AgentSpec.parse(spec.render), Right(spec)) + val spec = AgentSpec.parse("pi:sonnet:high@minimal") + assertEquals(spec.flatMap(s => AgentSpec.parse(s.render)), spec) + + test("repinned keeps the effort on the same harness"): + val spec = AgentSpec + .parse("claude:sonnet@high") + .map(_.repinned(BackendTag.ClaudeCode, None)) + assertEquals(spec.map(_.effort), Right(Some(Effort.High))) + + 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/runner/src/main/scala/orca/runner/RoleAgents.scala b/runner/src/main/scala/orca/runner/RoleAgents.scala index 2d58cc0b3..819fc8475 100644 --- a/runner/src/main/scala/orca/runner/RoleAgents.scala +++ b/runner/src/main/scala/orca/runner/RoleAgents.scala @@ -18,7 +18,7 @@ private[runner] case class ResolvedRoles( /** Where a resolved role's agent came from, in precedence order. Drives the * role-announcement `Step`'s `(source)` suffix; the winning [[AgentSpec]] (if - * any) drives the `harness:model` part. + * any) drives the `harness[:model]` part. */ private[runner] enum RoleSource: case Override, Project, Global, Default diff --git a/runner/src/test/scala/orca/runner/RoleAgentsTest.scala b/runner/src/test/scala/orca/runner/RoleAgentsTest.scala index 309c10bf6..977612162 100644 --- a/runner/src/test/scala/orca/runner/RoleAgentsTest.scala +++ b/runner/src/test/scala/orca/runner/RoleAgentsTest.scala @@ -142,9 +142,7 @@ class RoleAgentsTest extends munit.FunSuite: test("a project effort is applied to the role's agent"): val roles = resolvedRoles( - AgentSettings(coding = - Some(AgentSpec(BackendTag.Codex, None, Some(Effort.Low))) - ), + AgentSettings(coding = AgentSpec.parse("codex@low").toOption), wiredAgents() ) assertEquals(roles.coding.config.effort, Some(Effort.Low)) @@ -152,7 +150,7 @@ class RoleAgentsTest extends munit.FunSuite: test("resolveAll announces a project effort after the model"): val resolution = resolveInScope( project = AgentSettings(coding = - Some(AgentSpec(BackendTag.Codex, Some("gpt-5-mini"), Some(Effort.Low))) + AgentSpec.parse("codex:gpt-5-mini@low").toOption ), global = AgentSettings.empty, overrides = RoleOverrides(None, None, None), diff --git a/shell/src/main/scala/orca/shell/wizard/Wizard.scala b/shell/src/main/scala/orca/shell/wizard/Wizard.scala index 81870210a..127751367 100644 --- a/shell/src/main/scala/orca/shell/wizard/Wizard.scala +++ b/shell/src/main/scala/orca/shell/wizard/Wizard.scala @@ -120,15 +120,26 @@ private[shell] class Wizard( choices, default = Some(currentTag.getOrElse(fallback)) ) - sameHarness = currentTag.contains(tag) - currentModel = if sameHarness then current.flatMap(_.model) else None - model <- selectModel(role, tag, currentModel) - // The wizard has no effort step, so it keeps a hand-written one. - yield AgentSpec( - tag, - model, - if sameHarness then current.flatMap(_.effort) else None - ) + spec <- selectSpec(role, tag, current) + yield spec + + /** The model step for `tag`, asked again until the pick, plus any effort kept + * from `current`, reads back as a valid settings value: a free-text model + * ending in `@` is read as an effort. + */ + 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 => + val picked = current.fold(AgentSpec(tag, model))(_.repinned(tag, model)) + AgentSpec.parse(picked.render) match + case Right(spec) => UiOutcome.Selected(spec) + 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. diff --git a/shell/src/test/scala/orca/shell/wizard/WizardTest.scala b/shell/src/test/scala/orca/shell/wizard/WizardTest.scala index c2e116508..5d63f8e19 100644 --- a/shell/src/test/scala/orca/shell/wizard/WizardTest.scala +++ b/shell/src/test/scala/orca/shell/wizard/WizardTest.scala @@ -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" ): @@ -342,10 +358,7 @@ class WizardTest extends munit.FunSuite: assert(Wizard(ui, probe, path).run(reconfigure = true)) val agents = parse(os.read(path)) - assertEquals( - agents.coding, - Some(AgentSpec(BackendTag.ClaudeCode, None, Some(Effort.High))) - ) + assertEquals(agents.coding.flatMap(_.effort), Some(Effort.High)) test("re-configure onto harness default clears an existing pin"): withTempPath: path => diff --git a/tools/src/main/scala/orca/agents/Effort.scala b/tools/src/main/scala/orca/agents/Effort.scala index e6754a04e..7ff5bc724 100644 --- a/tools/src/main/scala/orca/agents/Effort.scala +++ b/tools/src/main/scala/orca/agents/Effort.scala @@ -13,9 +13,9 @@ enum Effort: private[orca] def label: String = toString.toLowerCase object Effort: - /** The level named `label` (see [[Effort.label]]), ignoring case. */ + /** The level named `label` (see [[Effort.label]]). */ private[orca] def fromLabel(label: String): Option[Effort] = - values.find(_.label == label.toLowerCase) + 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 From 6b993b7346c0c1b9729300cb58554be20e38d96b Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 17:43:52 +0000 Subject: [PATCH 5/7] Fix wizard effort override; one home for harness names and value form A typed model@level in the wizard now replaces an existing effort. BackendTag.harnessName replaces AgentSpec.harnessNameFor, AgentSpec.ValueForm holds the settings value form, parse is split into named steps, and docs are corrected (cheap keeps effort when the model doesn't change). --- docs/api/backends.md | 7 +- docs/authoring/choosing-agents.md | 12 +-- docs/using/settings.md | 7 +- .../main/scala/orca/settings/AgentSpec.scala | 78 ++++++++++--------- .../scala/orca/settings/SettingsFile.scala | 4 +- .../scala/orca/settings/AgentSpecTest.scala | 13 ++-- runner/src/main/scala/orca/exports.scala | 5 +- .../main/scala/orca/runner/RoleAgents.scala | 6 +- .../scala/orca/runner/RoleAgentsTest.scala | 8 -- .../orca/shell/actions/ConfigAction.scala | 4 +- .../orca/shell/actions/SessionAction.scala | 3 +- .../shell/actions/SettingsEditAction.scala | 4 +- shell/src/main/scala/orca/shell/cli/Cli.scala | 7 +- .../main/scala/orca/shell/cli/Tables.scala | 3 +- .../orca/shell/create/FlowAuthoring.scala | 2 +- .../orca/shell/sessions/ResumeCommand.scala | 13 ++-- .../orca/shell/sessions/SessionPicker.scala | 3 +- .../main/scala/orca/shell/wizard/Wizard.scala | 19 +++-- .../scala/orca/shell/wizard/WizardTest.scala | 16 ++++ .../main/scala/orca/agents/BackendTag.scala | 10 +++ tools/src/main/scala/orca/agents/Effort.scala | 20 ++--- 21 files changed, 131 insertions(+), 113 deletions(-) diff --git a/docs/api/backends.md b/docs/api/backends.md index 22b05fc0a..07ccedf88 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 @@ -66,8 +67,8 @@ 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` overrides a level in the model pattern (`sonnet:high`). Pi lowers -a level the model does not support to the nearest one it does. +`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` diff --git a/docs/authoring/choosing-agents.md b/docs/authoring/choosing-agents.md index da1ceec23..9e2dfcc1b 100644 --- a/docs/authoring/choosing-agents.md +++ b/docs/authoring/choosing-agents.md @@ -114,9 +114,9 @@ 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). -`withEffort(Effort.High)` sets the reasoning effort; `cheap` drops it. The -levels are `Off`, `Minimal`, `Low`, `Medium`, `High`, `XHigh` and `Max`, and -each harness accepts some of them: +`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 | |----------|------------------------------------|---------------------------------| @@ -126,9 +126,9 @@ each harness accepts some of them: | opencode | all; the model decides which apply | the message's `variant` | | gemini | none; does not compile | | -A level the harness does not accept throws when the agent is built. In settings -and `orca config`, append it to the agent value instead, as in -`claude:sonnet@high`; see [Settings](../using/settings.md#agent-keys). +`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 e71bda234..0dbc7a2bd 100644 --- a/docs/using/settings.md +++ b/docs/using/settings.md @@ -58,9 +58,10 @@ 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][@effort]` 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. +`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@high diff --git a/flow/src/main/scala/orca/settings/AgentSpec.scala b/flow/src/main/scala/orca/settings/AgentSpec.scala index 494103ef3..019baeb42 100644 --- a/flow/src/main/scala/orca/settings/AgentSpec.scala +++ b/flow/src/main/scala/orca/settings/AgentSpec.scala @@ -5,8 +5,8 @@ import orca.agents.{BackendTag, Effort, EffortSupport} /** 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: - * only [[AgentSpec.parse]] sets it. + * normalise or validate model ids. `effort` is always one the harness accepts; + * the constructor is private. */ private[orca] case class AgentSpec private ( backend: BackendTag, @@ -15,7 +15,7 @@ private[orca] case class AgentSpec private ( ): /** The settings value [[AgentSpec.parse]] reads back into this spec. */ def render: String = - AgentSpec.harnessNameFor(backend) + model.fold("")(":" + _) + + backend.harnessName + model.fold("")(":" + _) + effort.fold("")("@" + _.label) /** This spec moved to `tag` with `model`, keeping the effort only when the @@ -29,52 +29,54 @@ private[orca] object AgentSpec: def apply(backend: BackendTag, model: Option[String]): AgentSpec = new AgentSpec(backend, model, None) - /** The settings-file harness name for a backend tag (`claude`, `codex`, …) — - * lowercase, distinct from the tag's case name. - */ - 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" + /** The settings value's form, for help texts and file headers. */ + final val ValueForm = "harness[:model][@effort]" - /** Settings-file harness names — the reverse of [[harnessNameFor]]. */ + /** Settings-file harness names — the reverse of [[BackendTag.harnessName]]. + */ 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] = for (pinned, effort) <- splitEffort(value) - (harness, model) = pinned.indexOf(':') match - case -1 => (pinned.trim, None) - case i => - ( - pinned.take(i).trim, - Some(pinned.drop(i + 1).trim).filter(_.nonEmpty) - ) + (harness, model) = splitModel(pinned) tag <- harnessNames .get(harness) .toRight( s"unknown harness `$harness` — valid: " + harnessNames.keys.toList.sorted.mkString(", ") ) - checked <- effort match - case None => Right(None) - case Some(level) => - EffortSupport - .forTag(tag) - .toRight(s"$harness has no effort setting") - .flatMap(_.check(level)) - .map(Some(_)) + checked <- checkEffort(tag, effort) yield new AgentSpec(tag, model, checked) - /** Splits off a trailing `@`. Only an all-letter suffix is read as a - * level, so a model id like Vertex's `claude-sonnet-4@20250514` survives. + /** `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 @@ -83,15 +85,15 @@ private[orca] object AgentSpec: case -1 => Right((value, None)) case i => val suffix = value.drop(i + 1).trim - if suffix.nonEmpty && !suffix.forall(_.isLetter) then - Right((value, None)) + 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( - s"unknown effort `$suffix` — valid: " + - Effort.values.map(_.label).mkString(", ") + 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 diff --git a/flow/src/main/scala/orca/settings/SettingsFile.scala b/flow/src/main/scala/orca/settings/SettingsFile.scala index 8ac42190d..f2a119b60 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][@effort]): 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][@effort]" + 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 diff --git a/flow/src/test/scala/orca/settings/AgentSpecTest.scala b/flow/src/test/scala/orca/settings/AgentSpecTest.scala index f12868b64..5405a177c 100644 --- a/flow/src/test/scala/orca/settings/AgentSpecTest.scala +++ b/flow/src/test/scala/orca/settings/AgentSpecTest.scala @@ -72,7 +72,12 @@ class AgentSpecTest extends FunSuite: ) test("parse of a trailing @ is a Left"): - assert(AgentSpec.parse("claude:sonnet@").isLeft) + 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( @@ -92,12 +97,6 @@ class AgentSpecTest extends FunSuite: val spec = AgentSpec.parse("pi:sonnet:high@minimal") assertEquals(spec.flatMap(s => AgentSpec.parse(s.render)), spec) - test("repinned keeps the effort on the same harness"): - val spec = AgentSpec - .parse("claude:sonnet@high") - .map(_.repinned(BackendTag.ClaudeCode, None)) - assertEquals(spec.map(_.effort), Right(Some(Effort.High))) - test("repinned drops the effort when the harness changes"): val spec = AgentSpec .parse("claude:sonnet@high") diff --git a/runner/src/main/scala/orca/exports.scala b/runner/src/main/scala/orca/exports.scala index 45cf0f8c3..6eb4e768c 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.{ @@ -161,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/runner/RoleAgents.scala b/runner/src/main/scala/orca/runner/RoleAgents.scala index 819fc8475..b5923ba30 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( @@ -215,9 +215,7 @@ private[orca] object RoleAgents: */ private val UnpinnedModelLabel = "" - /** One role's announcement segment, `label=harness:model[@effort] (source)` — - * pure formatting of [[RoleChoice]]'s precomputed fields and the agent's - * effort. + /** One role's announcement segment, `label=harness:model[@effort] (source)`. */ private def announce(c: RoleChoice): String = val effort = c.agent.config.effort.fold("")("@" + _.label) diff --git a/runner/src/test/scala/orca/runner/RoleAgentsTest.scala b/runner/src/test/scala/orca/runner/RoleAgentsTest.scala index 977612162..aab4d25d1 100644 --- a/runner/src/test/scala/orca/runner/RoleAgentsTest.scala +++ b/runner/src/test/scala/orca/runner/RoleAgentsTest.scala @@ -6,7 +6,6 @@ import orca.agents.{ BackendTag, ClaudeAgent, CodexAgent, - Effort, GeminiAgent, Model, OpencodeAgent, @@ -140,13 +139,6 @@ class RoleAgentsTest extends munit.FunSuite: s"expected the wired codex's own model: ${resolution.announcement}" ) - test("a project effort is applied to the role's agent"): - val roles = resolvedRoles( - AgentSettings(coding = AgentSpec.parse("codex@low").toOption), - wiredAgents() - ) - assertEquals(roles.coding.config.effort, Some(Effort.Low)) - test("resolveAll announces a project effort after the 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 0fb988688..adc40a181 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][@effort]`, e.g. `claude:sonnet@high`)." + s"(`${AgentSpec.ValueForm}`, e.g. `claude:sonnet@high`)." ) diff --git a/shell/src/main/scala/orca/shell/actions/SessionAction.scala b/shell/src/main/scala/orca/shell/actions/SessionAction.scala index 8547db74d..dc678144c 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 fd597a842..2243716bc 100644 --- a/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala +++ b/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala @@ -1,7 +1,7 @@ package orca.shell.actions import orca.OrcaDir -import orca.settings.{AgentSettings, SettingsFile} +import orca.settings.{AgentSettings, AgentSpec, SettingsFile} import orca.shell.{ShellEnv, Tier} import ox.discard @@ -27,7 +27,7 @@ private[shell] object SettingsEditAction: "# lint = cargo check --tests\n" + "# test = cargo test\n" + "\n" + - "# Role agents — harness[:model][@effort]; harness: claude|codex|opencode|pi|gemini.\n" + + s"# Role agents — ${AgentSpec.ValueForm}; harness: claude|codex|opencode|pi|gemini.\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 eb5a996d4..05f4a2422 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][@effort]") + @arg(doc = "planning role, as " + AgentSpec.ValueForm) planningAgent: Option[String] = None, - @arg(doc = "coding role, as harness[:model][@effort]") + @arg(doc = "coding role, as " + AgentSpec.ValueForm) codingAgent: Option[String] = None, - @arg(doc = "review role, as harness[:model][@effort]") + @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/Tables.scala b/shell/src/main/scala/orca/shell/cli/Tables.scala index bd072ecf6..59d591ec6 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 ef9e47680..ccc9b2f5c 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 b9cb375b4..4cadc15a4 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]] (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. */ 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 67de031d6..35a50d8e6 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 127751367..28f71f5a9 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) @@ -123,9 +123,9 @@ private[shell] class Wizard( spec <- selectSpec(role, tag, current) yield spec - /** The model step for `tag`, asked again until the pick, plus any effort kept - * from `current`, reads back as a valid settings value: a free-text model - * ending in `@` is read as an effort. + /** 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, @@ -134,9 +134,12 @@ private[shell] class Wizard( ): UiOutcome[AgentSpec] = val currentModel = current.filter(_.backend == tag).flatMap(_.model) selectModel(role, tag, currentModel).flatMap: model => - val picked = current.fold(AgentSpec(tag, model))(_.repinned(tag, model)) - AgentSpec.parse(picked.render) match - case Right(spec) => UiOutcome.Selected(spec) + 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) @@ -162,7 +165,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 5d63f8e19..0ccd68cfe 100644 --- a/shell/src/test/scala/orca/shell/wizard/WizardTest.scala +++ b/shell/src/test/scala/orca/shell/wizard/WizardTest.scala @@ -360,6 +360,22 @@ class WizardTest extends munit.FunSuite: 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/BackendTag.scala b/tools/src/main/scala/orca/agents/BackendTag.scala index fc20219f4..1a3e32d8a 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 7ff5bc724..484e38f5e 100644 --- a/tools/src/main/scala/orca/agents/Effort.scala +++ b/tools/src/main/scala/orca/agents/Effort.scala @@ -23,7 +23,7 @@ object Effort: */ @implicitNotFound("This harness has no effort setting.") final class EffortSupport[B <: BackendTag] private ( - harness: String, + tag: BackendTag, levels: Map[Effort, String] ): /** `effort`, or a message naming the levels the harness accepts. */ @@ -31,7 +31,7 @@ final class EffortSupport[B <: BackendTag] private ( if levels.contains(effort) then Right(effort) else Left( - s"$harness has no `${effort.label}` effort; it accepts " + + s"${tag.harnessName} has no `${effort.label}` effort; it accepts " + Effort.values.filter(levels.contains).map(_.label).mkString(", ") ) @@ -51,7 +51,7 @@ object EffortSupport: case BackendTag.Pi => Some(pi) case BackendTag.Gemini => None - private val upToMax: Map[Effort, String] = Map( + private val lowToMax: Map[Effort, String] = Map( Effort.Low -> "low", Effort.Medium -> "medium", Effort.High -> "high", @@ -60,18 +60,18 @@ object EffortSupport: ) given claude: EffortSupport[BackendTag.ClaudeCode.type] = - EffortSupport("claude", upToMax) + EffortSupport(BackendTag.ClaudeCode, lowToMax) given codex: EffortSupport[BackendTag.Codex.type] = EffortSupport( - "codex", - upToMax + (Effort.Off -> "none") + (Effort.Minimal -> "minimal") + BackendTag.Codex, + lowToMax + (Effort.Off -> "none") + (Effort.Minimal -> "minimal") ) given pi: EffortSupport[BackendTag.Pi.type] = EffortSupport( - "pi", - upToMax - Effort.Max + (Effort.Off -> "off") + + BackendTag.Pi, + lowToMax - Effort.Max + (Effort.Off -> "off") + (Effort.Minimal -> "minimal") ) @@ -80,6 +80,6 @@ object EffortSupport: */ given opencode: EffortSupport[BackendTag.Opencode.type] = EffortSupport( - "opencode", - upToMax + (Effort.Off -> "none") + (Effort.Minimal -> "minimal") + BackendTag.Opencode, + lowToMax + (Effort.Off -> "none") + (Effort.Minimal -> "minimal") ) From f95e7ce3707df956a3b4e2c856de6db29c02f978 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 17:46:49 +0000 Subject: [PATCH 6/7] Share the effort suffix and harness list; tidy docs --- adr/0021-orca-shell.md | 6 +++--- docs/using/settings.md | 7 ++++--- flow/src/main/scala/orca/settings/AgentSpec.scala | 6 +++++- runner/src/main/scala/orca/runner/RoleAgents.scala | 2 +- .../scala/orca/shell/actions/SettingsEditAction.scala | 3 ++- .../main/scala/orca/shell/sessions/ResumeCommand.scala | 8 ++++---- shell/src/main/scala/orca/shell/wizard/Wizard.scala | 1 + 7 files changed, 20 insertions(+), 13 deletions(-) diff --git a/adr/0021-orca-shell.md b/adr/0021-orca-shell.md index a7e0cbb3e..53e027af0 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][@effort]`); 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/docs/using/settings.md b/docs/using/settings.md index 0dbc7a2bd..5e5b7671e 100644 --- a/docs/using/settings.md +++ b/docs/using/settings.md @@ -76,9 +76,10 @@ 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 +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 diff --git a/flow/src/main/scala/orca/settings/AgentSpec.scala b/flow/src/main/scala/orca/settings/AgentSpec.scala index 019baeb42..7dab6a813 100644 --- a/flow/src/main/scala/orca/settings/AgentSpec.scala +++ b/flow/src/main/scala/orca/settings/AgentSpec.scala @@ -16,7 +16,7 @@ private[orca] case class AgentSpec private ( /** The settings value [[AgentSpec.parse]] reads back into this spec. */ def render: String = backend.harnessName + model.fold("")(":" + _) + - effort.fold("")("@" + _.label) + AgentSpec.effortSuffix(effort) /** This spec moved to `tag` with `model`, keeping the effort only when the * harness stays the same. @@ -29,6 +29,10 @@ private[orca] object AgentSpec: 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]" diff --git a/runner/src/main/scala/orca/runner/RoleAgents.scala b/runner/src/main/scala/orca/runner/RoleAgents.scala index b5923ba30..505a9108f 100644 --- a/runner/src/main/scala/orca/runner/RoleAgents.scala +++ b/runner/src/main/scala/orca/runner/RoleAgents.scala @@ -218,7 +218,7 @@ private[orca] object RoleAgents: /** One role's announcement segment, `label=harness:model[@effort] (source)`. */ private def announce(c: RoleChoice): String = - val effort = c.agent.config.effort.fold("")("@" + _.label) + 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 = diff --git a/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala b/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala index 2243716bc..200ac80fc 100644 --- a/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala +++ b/shell/src/main/scala/orca/shell/actions/SettingsEditAction.scala @@ -1,6 +1,7 @@ package orca.shell.actions import orca.OrcaDir +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" + - s"# Role agents — ${AgentSpec.ValueForm}; 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/sessions/ResumeCommand.scala b/shell/src/main/scala/orca/shell/sessions/ResumeCommand.scala index 4cadc15a4..2e4394806 100644 --- a/shell/src/main/scala/orca/shell/sessions/ResumeCommand.scala +++ b/shell/src/main/scala/orca/shell/sessions/ResumeCommand.scala @@ -31,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 [[orca.agents.BackendTag.harnessName]] (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, diff --git a/shell/src/main/scala/orca/shell/wizard/Wizard.scala b/shell/src/main/scala/orca/shell/wizard/Wizard.scala index 28f71f5a9..d5cd43dd6 100644 --- a/shell/src/main/scala/orca/shell/wizard/Wizard.scala +++ b/shell/src/main/scala/orca/shell/wizard/Wizard.scala @@ -134,6 +134,7 @@ private[shell] class Wizard( ): 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) => From 1233777af88db5537c954e86b03c823eddc293ba Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 18:43:56 +0000 Subject: [PATCH 7/7] Pin render output in test; note effort in ADR 0020 announcement --- adr/0020-configurable-role-agents.md | 6 ++++-- flow/src/main/scala/orca/settings/AgentSpec.scala | 3 +-- flow/src/test/scala/orca/settings/AgentSpecTest.scala | 8 +++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/adr/0020-configurable-role-agents.md b/adr/0020-configurable-role-agents.md index b26d3674b..9dd4679e9 100644 --- a/adr/0020-configurable-role-agents.md +++ b/adr/0020-configurable-role-agents.md @@ -239,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/flow/src/main/scala/orca/settings/AgentSpec.scala b/flow/src/main/scala/orca/settings/AgentSpec.scala index 7dab6a813..4b51ac6f2 100644 --- a/flow/src/main/scala/orca/settings/AgentSpec.scala +++ b/flow/src/main/scala/orca/settings/AgentSpec.scala @@ -5,8 +5,7 @@ import orca.agents.{BackendTag, Effort, EffortSupport} /** 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; - * the constructor is private. + * normalise or validate model ids. `effort` is always one the harness accepts. */ private[orca] case class AgentSpec private ( backend: BackendTag, diff --git a/flow/src/test/scala/orca/settings/AgentSpecTest.scala b/flow/src/test/scala/orca/settings/AgentSpecTest.scala index 5405a177c..d309a6444 100644 --- a/flow/src/test/scala/orca/settings/AgentSpecTest.scala +++ b/flow/src/test/scala/orca/settings/AgentSpecTest.scala @@ -93,9 +93,11 @@ class AgentSpecTest extends FunSuite: Left("gemini has no effort setting") ) - test("render reads back through parse"): - val spec = AgentSpec.parse("pi:sonnet:high@minimal") - assertEquals(spec.flatMap(s => AgentSpec.parse(s.render)), spec) + 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