Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .orca/settings.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion adr/0014-opencode-server-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions adr/0020-configurable-role-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,19 @@ 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
`withModel` — orca does not normalise or validate model ids, consistent with
`Model`'s existing contract: for claude the CLI itself accepts aliases
(`opus`, `sonnet`), for opencode it is the provider-qualified `provider/model`
form its existing validation checks. An empty value is equivalent to the key
being absent (the existing parser rule for stack keys too).
being absent (the existing parser rule for stack keys too). The optional
`@<level>` 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

Expand Down Expand Up @@ -235,12 +239,14 @@ naming each resolved role, its harness/model, and its source
run here?":

```text
agents: planning=claude (default), coding=codex:gpt-5-mini (project), review=claude (global)
agents: planning=claude (default), coding=codex:gpt-5-mini@low (project), review=claude (global)
```

The harness+model shown is the winning `AgentSpec`; an override shows its
resolved backend's harness with no model (the override's pin isn't a spec);
the built-in default shows `claude` with no model.
the built-in default shows `claude` with no model. A role whose resolved agent
has an effort set, from settings or an override's `withEffort`, also shows
`@<level>`.

### 11. Non-goals

Expand Down
6 changes: 3 additions & 3 deletions adr/0021-orca-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,9 @@ pin. Preselection: planning defaults to `fable`/`gpt-5.6-sol`, coding/review
to `opus`/`gpt-5.6-sol`; re-configure preselects an existing pin's curated
row (or "enter manually" prefilled with it), and picking "harness default"
clears the pin. The wizard's closing note tells users the file is
hand-editable (`harness[:model]`); the harness-change/mint-fresh-sessions
detail (ADR 0020 §8) was dropped from that note per user feedback — unnecessary
detail at write time.
hand-editable (`harness[:model][@effort]`); the
harness-change/mint-fresh-sessions detail (ADR 0020 §8) was dropped from that
note per user feedback — unnecessary detail at write time.

Write path: file absent → fresh write with a wizard-appropriate header (not
`SettingsFile.Header`, whose stack-discovery wording doesn't apply) and
Expand Down
8 changes: 1 addition & 7 deletions claude/src/main/scala/orca/backend/claude/ClaudeAgents.scala
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 <level>`:
* `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
Expand Down
3 changes: 2 additions & 1 deletion claude/src/main/scala/orca/backend/claude/ClaudeArgs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import orca.agents.{
AutoApprove,
BackendTag,
AgentConfig,
EffortSupport,
Enforcement,
EnforcementCell,
WireSessionId,
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 2 additions & 10 deletions claude/src/test/scala/orca/backend/claude/ClaudeAgentsTest.scala
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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"):
Expand Down
8 changes: 8 additions & 0 deletions codex/src/main/scala/orca/backend/codex/CodexArgs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import orca.agents.{
AutoApprove,
BackendTag,
AgentConfig,
EffortSupport,
Enforcement,
EnforcementCell,
WireSessionId,
Expand Down Expand Up @@ -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 ++
Expand Down Expand Up @@ -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 ++
Expand Down Expand Up @@ -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)

Expand Down
22 changes: 22 additions & 0 deletions codex/src/test/scala/orca/backend/codex/CodexArgsTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import orca.agents.{
AutoApprove,
BackendTag,
AgentConfig,
Effort,
Model,
WireSessionId,
ToolSet
Expand All @@ -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 <workDir>"):
val workDir = TempDirs.dir()
val args = CodexArgs.exec(
Expand Down Expand Up @@ -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\"")))
14 changes: 8 additions & 6 deletions docs/api/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,8 +30,7 @@ with no model chosen, for example plain `claude`; `cheap` is the model that

Claude Code. Bare `claude` is Opus with the 1M-token context window. The model
accessors are `haiku`, `sonnet`, `opus` and `fable`; `cheap` picks haiku, and
`withModel(Model)` sets any model id. `withEffort(Effort)` sets the reasoning
effort.
`withModel(Model)` sets any model id.

Use `claude.sonnet` or `claude.haiku` for cheap one-shot calls, and
`claude.fable` for the hardest ones.
Expand Down Expand Up @@ -67,6 +67,9 @@ selection follow Pi's own configuration; to pick one, use
`withModel(Model("provider/model"))`. Interactive calls can ask clarifying
questions through Orca's `ask_user` bridge.

`withEffort` replaces a thinking level given in the model (Pi's `sonnet:high`
form). Pi lowers a level the model does not support to the nearest one it does.

## `gemini`

Google Gemini CLI, driven via `gemini --output-format stream-json`. Bare
Expand All @@ -82,6 +85,5 @@ Structured output is prompt-enforced, since Gemini has no schema flag.
How strongly each backend enforces a `ToolSet` is tabulated in
[Choosing agents](../authoring/choosing-agents.md#how-strongly-each-harness-enforces-a-limit).

In `settings.properties` a backend is named by the same word, with an optional
model after a colon, for example `codingAgent = codex:gpt-5-mini`. See
[Settings](../using/settings.md).
In `settings.properties` a backend is named by the same word; see
[Settings](../using/settings.md#agent-keys) for the value form.
19 changes: 15 additions & 4 deletions docs/authoring/choosing-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,21 @@ own `agent` in the event log), `withReadOnly`, `withNetworkOnly` and
the same harness can continue each other's conversations; see `chat.withAgent`
in [Talking to agents](talking-to-agents.md).

`claude` also has `withEffort(Effort.Medium)`, which sets the reasoning effort
(`Low`, `Medium`, `High`, `XHigh`, `Max`); `cheap` drops it. Orca wires it only
for claude so far, so it does not compile on the other harnesses. It cannot be
set in `settings.properties`.
`withEffort(Effort.High)` sets the reasoning effort; `cheap` drops it when it
switches to a cheaper model. The levels are `Off`, `Minimal`, `Low`, `Medium`,
`High`, `XHigh` and `Max`, and each harness accepts some of them:

| Harness | Levels | Sent as |
|----------|------------------------------------|---------------------------------|
| claude | `Low` to `Max` | `--effort` |
| codex | all | `-c model_reasoning_effort=...` |
| pi | all but `Max` | `--thinking` |
| opencode | all; the model decides which apply | the message's `variant` |
| gemini | none; does not compile | |

`withEffort` throws on a level the harness does not accept. In settings and
`orca config`, write the level after `@`, as in `claude:sonnet@high`; see
[Settings](../using/settings.md#agent-keys).

By default, the runtime owns git: every write-capable turn is told not to
commit, push or switch branches. `withSelfManagedGit` opts one agent out of
Expand Down
21 changes: 15 additions & 6 deletions docs/using/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -57,13 +57,14 @@ see [Gates and checks](../authoring/gates-and-checks.md).

The keys `planningAgent`, `codingAgent` and `reviewAgent` choose which agent
plays each role. They are valid in both files and are single-valued, so a
repeated agent key is an error. The value has the form `harness[:model]` and is
split at the first `:`, which means that a model id containing `:` survives
intact. The `harness` part is one of `claude`, `codex`, `opencode`, `pi` or
`gemini`; any other name is an error that lists the valid ones.
repeated agent key is an error. The value has the form
`harness[:model][@effort]`. The model is split off at the first `:`, so a model
id containing `:` survives intact. The `harness` part is one of `claude`,
`codex`, `opencode`, `pi` or `gemini`; any other name is an error that lists
the valid ones.

```properties
planningAgent = claude:opus
planningAgent = claude:opus@high
codingAgent = codex:gpt-5-mini
reviewAgent = opencode:anthropic/claude-haiku-5-5
```
Expand All @@ -73,6 +74,14 @@ model ids. There is one exception: claude's bare `haiku` alias is sent as
`claude-haiku-5-5`, because the CLI may resolve the bare alias to a pricier
model.

The optional `@effort` part sets the reasoning effort, as `withEffort` does in
code: `claude:opus@high`, or `codex@low` with no model. The level is written in
lowercase, and
[Choosing agents](../authoring/choosing-agents.md#tuning-an-agent) lists the
levels each harness accepts; any other is an error. Only letters after the
last `@` are read as a level, so a model id such as
`claude-sonnet-4@20250514` is passed on as is.

Note that agent keys are read even when `flow(stackSettings = Some(...))` pins
the stack commands. At setup, Orca announces where each role came from:

Expand Down
5 changes: 3 additions & 2 deletions docs/using/shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
```

Expand Down
15 changes: 6 additions & 9 deletions flow/src/main/scala/orca/AgentSet.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -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)
8 changes: 5 additions & 3 deletions flow/src/main/scala/orca/accessors.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading