moa-cli 0.3.1__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: moa-cli
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Ask one question to multiple local AI coding CLIs in parallel and collect their answers.
5
5
  Keywords: llm,agents,cli,claude,codex,agy,opencode,peer-review
6
6
  Author: Paul-Louis Pröve
@@ -50,7 +50,7 @@ A single model gives you one perspective. Asking three frontier models the same
50
50
 
51
51
  ```text
52
52
  $ moa ask "Is Postgres or SQLite better for a desktop app?"
53
- Asking claude, codex, agy (timeout 600s, read-only)
53
+ Asking claude, codex, agy (timeout 900s, read-only)
54
54
 
55
55
  ──────────────── claude (opus) · OK · 3.2s ────────────────
56
56
 
@@ -103,11 +103,16 @@ is enforced by spawning each CLI with its own read-only flags:
103
103
 
104
104
  | Provider | Read-only (default) | Reads files | Web research |
105
105
  | ---------- | -------------------------- | ----------- | ------------------------- |
106
- | `claude` | `--permission-mode plan` | yes | yes |
106
+ | `claude` | `--permission-mode default` | yes | yes |
107
107
  | `codex` | `-s read-only` | yes | **no** (sandbox blocks network) |
108
108
  | `opencode` | `--agent plan` | yes | yes |
109
109
  | `agy` | `--sandbox` (partial: shell only - can still edit files) | yes | yes |
110
110
 
111
+ `claude`'s `--permission-mode default` is read-only in moa's non-interactive use: it reads
112
+ files and researches online with the full toolset, but any write or edit needs an interactive
113
+ approval that never comes under `-p`, so all mutations are denied. (`plan` mode is **not**
114
+ usable headless - it emits a plan and waits for approval instead of answering.)
115
+
111
116
  `codex`'s read-only mode is a kernel sandbox that also blocks network, so codex does no
112
117
  web research in the default mode (it still reads local files). `agy` has **no true
113
118
  read-only mode**: its `--sandbox` flag restricts agy's terminal/shell but does **not** stop
@@ -171,13 +176,14 @@ To avoid repeating the same flags on every call, persist your own defaults in a
171
176
 
172
177
  **Keys** (all shared across `ask`/`distill`/`debate`):
173
178
 
174
- | Key | Type | Example |
175
- | ------------- | ----------------------- | ----------------------------- |
176
- | `num` | int (>= 1) | `num = 2` |
177
- | `timeout` | seconds (> 0) | `timeout = 120` |
178
- | `exclude` | list of provider names | `exclude = ["claude"]` |
179
- | `synthesizer` | `auto`/`random`/provider | `synthesizer = "codex"` |
180
- | `[models]` | provider -> model table | `claude = "sonnet"` |
179
+ | Key | Type | Example |
180
+ | ------------------ | ------------------------ | ----------------------------- |
181
+ | `num` | int (>= 1) | `num = 2` |
182
+ | `timeout` | seconds (> 0) | `timeout = 120` |
183
+ | `exclude` | list of provider names | `exclude = ["claude"]` |
184
+ | `synthesizer` | `auto`/`random`/provider | `synthesizer = "codex"` |
185
+ | `[providers.<name>]` | per-provider `model` + `effort` | see below |
186
+ | `[models]` | DEPRECATED provider -> model table | `claude = "sonnet"` |
181
187
 
182
188
  ```toml
183
189
  # ~/.moa/config.toml
@@ -186,11 +192,17 @@ timeout = 120
186
192
  exclude = ["claude"]
187
193
  synthesizer = "auto"
188
194
 
189
- [models]
190
- claude = "sonnet"
191
- agy = "Gemini 3.1 Pro (Low)"
195
+ [providers.codex]
196
+ model = "gpt-5.5"
197
+ effort = "high"
198
+
199
+ [providers.opencode]
200
+ model = "zai-coding-plan/glm-5.2"
201
+ effort = "high"
192
202
  ```
193
203
 
204
+ Model and effort are grouped per provider under `[providers.<name>]`. The flat `[models]` table still works as a **deprecated alias** for `[providers.<name>].model`; when both set a model for the same provider, the `[providers.<name>]` block wins (MOA prints a one-line note, not an error).
205
+
194
206
  **`moa config`** inspects and edits the file (it creates the dir/file as needed and validates provider names):
195
207
 
196
208
  ```bash
@@ -198,12 +210,29 @@ moa config show # effective config (defaults + file) + pat
198
210
  moa config path # print the config file path
199
211
  moa config set num 2 # set a scalar
200
212
  moa config set exclude claude,codex # set the exclude list (comma-separated)
201
- moa config set model claude=sonnet # set one entry in [models]
213
+ moa config set model codex=gpt-5.5 # set a provider's model
214
+ moa config set effort codex=high # set a provider's reasoning effort
202
215
  moa config unset num # remove a key
203
- moa config unset model claude # remove one [models] entry
216
+ moa config unset model codex # remove one provider's model
217
+ moa config unset effort codex # remove one provider's effort
204
218
  ```
205
219
 
206
- The role defaults are persistable too: the distill `synthesizer` and the debate `moderator` (e.g. `moa config set synthesizer codex`, `moa config set moderator agy`). `debate`'s `-r/--rounds` is not persisted. CLI `-m` overrides win per-provider over the config `[models]` table.
220
+ The role defaults are persistable too: the distill `synthesizer` and the debate `moderator` (e.g. `moa config set synthesizer codex`, `moa config set moderator agy`). `debate`'s `-r/--rounds` is not persisted. CLI `-m` overrides win per-provider over the config model.
221
+
222
+ #### Reasoning / effort
223
+
224
+ Pin a per-provider **reasoning/effort** level in config so the council runs each tool at the depth you want without repeating flags. This is **config-only**: there is intentionally no `-e/--effort` CLI flag.
225
+
226
+ MOA uses **raw pass-through with zero value mapping.** It does not normalize effort across providers or invent a canonical low/med/high scale. You write the **exact value the target tool expects**, and MOA pastes it verbatim into that provider's native flag. The only thing MOA maps is *where* the value lands in each provider's argv, never the value itself:
227
+
228
+ | Provider | `effort` lands in | Notes |
229
+ | ---------- | ------------------------------------ | ----------------------------------------------------------- |
230
+ | `codex` | `-c model_reasoning_effort=<value>` | generic config override |
231
+ | `opencode` | `--variant <value>` | opencode's "model variant (provider-specific reasoning effort)" |
232
+ | `agy` | (none) | reasoning is part of the model name, e.g. `Gemini 3.1 Pro (High)` |
233
+ | `claude` | (none) | no per-call effort flag |
234
+
235
+ Values are **tool-specific and not validated** by MOA (only "non-empty if present"): a value the target tool rejects fails at that tool, not in MOA. When no effort is configured for a provider, MOA passes **no effort flag at all**, so the tool's own default stands. Setting `effort` for `agy`/`claude` is stored but inert (they have no effort flag); MOA notes this when you set it.
207
236
 
208
237
  ### Output
209
238
 
@@ -39,7 +39,7 @@ A single model gives you one perspective. Asking three frontier models the same
39
39
 
40
40
  ```text
41
41
  $ moa ask "Is Postgres or SQLite better for a desktop app?"
42
- Asking claude, codex, agy (timeout 600s, read-only)
42
+ Asking claude, codex, agy (timeout 900s, read-only)
43
43
 
44
44
  ──────────────── claude (opus) · OK · 3.2s ────────────────
45
45
 
@@ -92,11 +92,16 @@ is enforced by spawning each CLI with its own read-only flags:
92
92
 
93
93
  | Provider | Read-only (default) | Reads files | Web research |
94
94
  | ---------- | -------------------------- | ----------- | ------------------------- |
95
- | `claude` | `--permission-mode plan` | yes | yes |
95
+ | `claude` | `--permission-mode default` | yes | yes |
96
96
  | `codex` | `-s read-only` | yes | **no** (sandbox blocks network) |
97
97
  | `opencode` | `--agent plan` | yes | yes |
98
98
  | `agy` | `--sandbox` (partial: shell only - can still edit files) | yes | yes |
99
99
 
100
+ `claude`'s `--permission-mode default` is read-only in moa's non-interactive use: it reads
101
+ files and researches online with the full toolset, but any write or edit needs an interactive
102
+ approval that never comes under `-p`, so all mutations are denied. (`plan` mode is **not**
103
+ usable headless - it emits a plan and waits for approval instead of answering.)
104
+
100
105
  `codex`'s read-only mode is a kernel sandbox that also blocks network, so codex does no
101
106
  web research in the default mode (it still reads local files). `agy` has **no true
102
107
  read-only mode**: its `--sandbox` flag restricts agy's terminal/shell but does **not** stop
@@ -160,13 +165,14 @@ To avoid repeating the same flags on every call, persist your own defaults in a
160
165
 
161
166
  **Keys** (all shared across `ask`/`distill`/`debate`):
162
167
 
163
- | Key | Type | Example |
164
- | ------------- | ----------------------- | ----------------------------- |
165
- | `num` | int (>= 1) | `num = 2` |
166
- | `timeout` | seconds (> 0) | `timeout = 120` |
167
- | `exclude` | list of provider names | `exclude = ["claude"]` |
168
- | `synthesizer` | `auto`/`random`/provider | `synthesizer = "codex"` |
169
- | `[models]` | provider -> model table | `claude = "sonnet"` |
168
+ | Key | Type | Example |
169
+ | ------------------ | ------------------------ | ----------------------------- |
170
+ | `num` | int (>= 1) | `num = 2` |
171
+ | `timeout` | seconds (> 0) | `timeout = 120` |
172
+ | `exclude` | list of provider names | `exclude = ["claude"]` |
173
+ | `synthesizer` | `auto`/`random`/provider | `synthesizer = "codex"` |
174
+ | `[providers.<name>]` | per-provider `model` + `effort` | see below |
175
+ | `[models]` | DEPRECATED provider -> model table | `claude = "sonnet"` |
170
176
 
171
177
  ```toml
172
178
  # ~/.moa/config.toml
@@ -175,11 +181,17 @@ timeout = 120
175
181
  exclude = ["claude"]
176
182
  synthesizer = "auto"
177
183
 
178
- [models]
179
- claude = "sonnet"
180
- agy = "Gemini 3.1 Pro (Low)"
184
+ [providers.codex]
185
+ model = "gpt-5.5"
186
+ effort = "high"
187
+
188
+ [providers.opencode]
189
+ model = "zai-coding-plan/glm-5.2"
190
+ effort = "high"
181
191
  ```
182
192
 
193
+ Model and effort are grouped per provider under `[providers.<name>]`. The flat `[models]` table still works as a **deprecated alias** for `[providers.<name>].model`; when both set a model for the same provider, the `[providers.<name>]` block wins (MOA prints a one-line note, not an error).
194
+
183
195
  **`moa config`** inspects and edits the file (it creates the dir/file as needed and validates provider names):
184
196
 
185
197
  ```bash
@@ -187,12 +199,29 @@ moa config show # effective config (defaults + file) + pat
187
199
  moa config path # print the config file path
188
200
  moa config set num 2 # set a scalar
189
201
  moa config set exclude claude,codex # set the exclude list (comma-separated)
190
- moa config set model claude=sonnet # set one entry in [models]
202
+ moa config set model codex=gpt-5.5 # set a provider's model
203
+ moa config set effort codex=high # set a provider's reasoning effort
191
204
  moa config unset num # remove a key
192
- moa config unset model claude # remove one [models] entry
205
+ moa config unset model codex # remove one provider's model
206
+ moa config unset effort codex # remove one provider's effort
193
207
  ```
194
208
 
195
- The role defaults are persistable too: the distill `synthesizer` and the debate `moderator` (e.g. `moa config set synthesizer codex`, `moa config set moderator agy`). `debate`'s `-r/--rounds` is not persisted. CLI `-m` overrides win per-provider over the config `[models]` table.
209
+ The role defaults are persistable too: the distill `synthesizer` and the debate `moderator` (e.g. `moa config set synthesizer codex`, `moa config set moderator agy`). `debate`'s `-r/--rounds` is not persisted. CLI `-m` overrides win per-provider over the config model.
210
+
211
+ #### Reasoning / effort
212
+
213
+ Pin a per-provider **reasoning/effort** level in config so the council runs each tool at the depth you want without repeating flags. This is **config-only**: there is intentionally no `-e/--effort` CLI flag.
214
+
215
+ MOA uses **raw pass-through with zero value mapping.** It does not normalize effort across providers or invent a canonical low/med/high scale. You write the **exact value the target tool expects**, and MOA pastes it verbatim into that provider's native flag. The only thing MOA maps is *where* the value lands in each provider's argv, never the value itself:
216
+
217
+ | Provider | `effort` lands in | Notes |
218
+ | ---------- | ------------------------------------ | ----------------------------------------------------------- |
219
+ | `codex` | `-c model_reasoning_effort=<value>` | generic config override |
220
+ | `opencode` | `--variant <value>` | opencode's "model variant (provider-specific reasoning effort)" |
221
+ | `agy` | (none) | reasoning is part of the model name, e.g. `Gemini 3.1 Pro (High)` |
222
+ | `claude` | (none) | no per-call effort flag |
223
+
224
+ Values are **tool-specific and not validated** by MOA (only "non-empty if present"): a value the target tool rejects fails at that tool, not in MOA. When no effort is configured for a provider, MOA passes **no effort flag at all**, so the tool's own default stands. Setting `effort` for `agy`/`claude` is stored but inert (they have no effort flag); MOA notes this when you set it.
196
225
 
197
226
  ### Output
198
227
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "moa-cli"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  description = "Ask one question to multiple local AI coding CLIs in parallel and collect their answers."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,3 +1,3 @@
1
1
  """MOA CLI package."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.3"
@@ -29,11 +29,14 @@ import typer
29
29
  # Providers: each agent CLI we know how to drive.
30
30
  # --------------------------------------------------------------------------- #
31
31
 
32
- # A command builder turns (prompt, model, output_file, perm) into an argv list.
33
- # output_file is a path the CLI may be told to write its final answer to; it is
34
- # None for providers that answer cleanly on stdout. Only codex uses it. `perm`
35
- # is the permission argv (read-only or yolo flags) spliced in before the prompt.
36
- CommandBuilder = Callable[[str, str, str | None, tuple[str, ...]], list[str]]
32
+ # A command builder turns (prompt, model, output_file, perm, effort) into an argv
33
+ # list. output_file is a path the CLI may be told to write its final answer to; it
34
+ # is None for providers that answer cleanly on stdout. Only codex uses it. `perm`
35
+ # is the permission argv (read-only or yolo flags) and `effort` is the reasoning
36
+ # argv (the provider's native flag with the user's value pasted in verbatim), both
37
+ # spliced in before the prompt. `effort` is an empty tuple when no effort is
38
+ # configured or the provider has no effort flag.
39
+ CommandBuilder = Callable[[str, str, str | None, tuple[str, ...], tuple[str, ...]], list[str]]
37
40
 
38
41
 
39
42
  @dataclass(frozen=True)
@@ -59,6 +62,25 @@ class Provider:
59
62
  unset_env: tuple[str, ...] = ()
60
63
  # codex's stdout is session chrome; its real answer goes to an output file.
61
64
  uses_output_file: bool = False
65
+ # Reasoning/effort flag mapping, declared as data (like perm_args), not
66
+ # branched per tool. This is the ONLY thing moa knows about effort: WHERE the
67
+ # user's value lands in argv. It never normalizes or validates the value. The
68
+ # callable takes the raw effort value and returns the argv to splice in; it is
69
+ # called only for a non-empty value. `None` means the provider has no per-call
70
+ # effort flag (agy carries effort in the model name; claude has none), so
71
+ # effort_args always returns () for it.
72
+ effort_flag: Callable[[str], tuple[str, ...]] | None = None
73
+
74
+ def effort_args(self, value: str | None) -> tuple[str, ...]:
75
+ """The reasoning/effort argv for this run, value pasted in verbatim.
76
+
77
+ Empty tuple when the provider has no effort flag (agy/claude) or no
78
+ effort is configured (value is None/empty). moa never interprets the
79
+ value: it is the exact wording the target tool expects.
80
+ """
81
+ if not value or self.effort_flag is None:
82
+ return ()
83
+ return self.effort_flag(value)
62
84
 
63
85
  def env(self) -> dict[str, str]:
64
86
  env = dict(os.environ)
@@ -77,31 +99,41 @@ class Provider:
77
99
  return self.readonly or ()
78
100
 
79
101
 
80
- def _claude(prompt: str, model: str, _out: str | None, perm: tuple[str, ...]) -> list[str]:
102
+ def _claude(
103
+ prompt: str, model: str, _out: str | None, perm: tuple[str, ...], _effort: tuple[str, ...]
104
+ ) -> list[str]:
105
+ # claude has no clean per-call effort flag, so _effort is always () here.
81
106
  return ["claude", "--model", model, *perm, "-p", prompt]
82
107
 
83
108
 
84
- def _codex(prompt: str, model: str, out: str | None, perm: tuple[str, ...]) -> list[str]:
85
- cmd = ["codex", "exec", "-m", model, "--skip-git-repo-check", "--color", "never", *perm]
109
+ def _codex(
110
+ prompt: str, model: str, out: str | None, perm: tuple[str, ...], effort: tuple[str, ...]
111
+ ) -> list[str]:
112
+ cmd = ["codex", "exec", "-m", model, "--skip-git-repo-check", "--color", "never", *perm, *effort]
86
113
  if out:
87
114
  cmd += ["-o", out]
88
115
  cmd.append(prompt)
89
116
  return cmd
90
117
 
91
118
 
92
- def _agy(prompt: str, model: str, _out: str | None, perm: tuple[str, ...]) -> list[str]:
119
+ def _agy(
120
+ prompt: str, model: str, _out: str | None, perm: tuple[str, ...], _effort: tuple[str, ...]
121
+ ) -> list[str]:
93
122
  # agy also hosts Claude/GPT-OSS models, so we pin a Gemini model explicitly
94
123
  # to keep the panel diverse. Without --model it defaults to Gemini Flash.
95
124
  # perm (e.g. --sandbox) goes first so the default reads `agy --sandbox
96
- # --model ... -p ...`.
125
+ # --model ... -p ...`. agy's reasoning lives in the model name, so _effort is
126
+ # always () here.
97
127
  return ["agy", *perm, "--model", model, "-p", prompt]
98
128
 
99
129
 
100
- def _opencode(prompt: str, model: str, _out: str | None, perm: tuple[str, ...]) -> list[str]:
130
+ def _opencode(
131
+ prompt: str, model: str, _out: str | None, perm: tuple[str, ...], effort: tuple[str, ...]
132
+ ) -> list[str]:
101
133
  # opencode has no universal default model (it depends on which provider the
102
134
  # user has authed), so we omit -m when no model is given and let opencode
103
135
  # pick its own default. The prompt is a positional arg.
104
- cmd = ["opencode", "run", *perm]
136
+ cmd = ["opencode", "run", *perm, *effort]
105
137
  if model:
106
138
  cmd += ["-m", model]
107
139
  cmd.append(prompt)
@@ -111,7 +143,11 @@ def _opencode(prompt: str, model: str, _out: str | None, perm: tuple[str, ...])
111
143
  PROVIDERS: dict[str, Provider] = {
112
144
  "claude": Provider(
113
145
  "claude", "claude", "opus", _claude,
114
- readonly=("--permission-mode", "plan"),
146
+ # In headless `-p`, `default` keeps the full toolset (Read, read-only Bash
147
+ # like git/grep, WebFetch) but has no way to approve a write/edit, so
148
+ # mutations are denied: effectively read-only with all tools. (`plan` mode
149
+ # instead emits a plan-approval stub and never answers under `-p`.)
150
+ readonly=("--permission-mode", "default"),
115
151
  yolo=("--permission-mode", "bypassPermissions"),
116
152
  unset_env=("CLAUDECODE",),
117
153
  ),
@@ -120,6 +156,10 @@ PROVIDERS: dict[str, Provider] = {
120
156
  readonly=("-s", "read-only"),
121
157
  yolo=("-s", "danger-full-access"),
122
158
  uses_output_file=True,
159
+ # Verified against installed codex: `-c key=value` is the generic config
160
+ # override (`codex exec --help`); reasoning effort lives at
161
+ # model_reasoning_effort. The value is pasted verbatim.
162
+ effort_flag=lambda v: ("-c", f"model_reasoning_effort={v}"),
123
163
  ),
124
164
  "agy": Provider(
125
165
  "agy", "agy", "Gemini 3.1 Pro (High)", _agy,
@@ -134,7 +174,14 @@ PROVIDERS: dict[str, Provider] = {
134
174
  "opencode": Provider(
135
175
  "opencode", "opencode", "", _opencode,
136
176
  readonly=("--agent", "plan"),
137
- yolo=(), # default = build agent (full access)
177
+ # Bare `build` (the default agent) still gates doom_loop/external_directory
178
+ # as `ask`, which auto-rejects headless - so true full access needs the
179
+ # explicit skip-permissions flag. (Verified on opencode 1.17.8.)
180
+ yolo=("--dangerously-skip-permissions",),
181
+ # Verified against installed opencode: `--variant <value>` is the
182
+ # "model variant (provider-specific reasoning effort, e.g., high)" flag
183
+ # (`opencode run --help`). The value is pasted verbatim.
184
+ effort_flag=lambda v: ("--variant", v),
138
185
  ),
139
186
  }
140
187
 
@@ -232,7 +279,12 @@ async def _terminate(process: asyncio.subprocess.Process) -> None:
232
279
 
233
280
 
234
281
  async def run_provider(
235
- provider: Provider, prompt: str, timeout: float, model: str | None = None, yolo: bool = False
282
+ provider: Provider,
283
+ prompt: str,
284
+ timeout: float,
285
+ model: str | None = None,
286
+ yolo: bool = False,
287
+ effort: str | None = None,
236
288
  ) -> RunResult:
237
289
  model = model or provider.default_model
238
290
  out_file: str | None = None
@@ -244,7 +296,7 @@ async def run_provider(
244
296
  try:
245
297
  try:
246
298
  process = await asyncio.create_subprocess_exec(
247
- *provider.build(prompt, model, out_file, provider.perm_args(yolo)),
299
+ *provider.build(prompt, model, out_file, provider.perm_args(yolo), provider.effort_args(effort)),
248
300
  # DEVNULL is essential: codex and agy block forever on an
249
301
  # inherited TTY stdin, burning the entire timeout otherwise.
250
302
  stdin=asyncio.subprocess.DEVNULL,
@@ -286,11 +338,15 @@ async def stream(
286
338
  timeout: float,
287
339
  models: dict[str, str] | None = None,
288
340
  yolo: bool = False,
341
+ efforts: dict[str, str] | None = None,
289
342
  ) -> AsyncIterator[RunResult]:
290
343
  """Run every provider in parallel, yielding each result as it finishes."""
291
344
  models = models or {}
345
+ efforts = efforts or {}
292
346
  tasks = [
293
- asyncio.create_task(run_provider(p, prompt, timeout, models.get(p.name), yolo))
347
+ asyncio.create_task(
348
+ run_provider(p, prompt, timeout, models.get(p.name), yolo, efforts.get(p.name))
349
+ )
294
350
  for p in providers
295
351
  ]
296
352
  for completed in asyncio.as_completed(tasks):
@@ -686,22 +742,31 @@ def verdict_record(result: RunResult, moderator: str) -> dict:
686
742
  # merge happens once, in resolve_run, so all verbs pick up defaults identically.
687
743
  # --------------------------------------------------------------------------- #
688
744
 
689
- # Scalar config keys and the type each maps to. `exclude` (list[str]) and the
690
- # `[models]` table are handled separately because they aren't plain scalars.
745
+ # Scalar config keys and the type each maps to. `exclude` (list[str]), the
746
+ # `[providers.<name>]` blocks, and the deprecated `[models]` table are handled
747
+ # separately because they aren't plain scalars.
691
748
  _CONFIG_SCALARS: dict[str, type] = {"num": int, "timeout": float, "synthesizer": str, "moderator": str}
692
- _CONFIG_KEYS: tuple[str, ...] = (*_CONFIG_SCALARS, "exclude", "models")
749
+ # Top-level config keys a file may contain. `providers` is the canonical
750
+ # per-provider block (model + effort); `models` is the DEPRECATED flat alias for
751
+ # `[providers.<name>].model`, kept for back-compat with 0.2.x/0.3.x configs.
752
+ _CONFIG_KEYS: tuple[str, ...] = (*_CONFIG_SCALARS, "exclude", "providers", "models")
753
+ # Per-provider keys allowed inside a `[providers.<name>]` block.
754
+ _PROVIDER_KEYS: tuple[str, ...] = ("model", "effort")
693
755
  # Synthesizer accepts the special modes plus any known provider name.
694
756
  _SYNTHESIZER_MODES: tuple[str, ...] = ("auto", "first", "random")
695
757
  # Moderator accepts "auto" (the top-priority selected agent) or a provider name.
696
758
  _MODERATOR_MODES: tuple[str, ...] = ("auto",)
697
759
  # The built-in defaults, shown by `config show` when a key isn't in the file.
760
+ # `models`/`efforts` are the normalized per-provider maps load_config produces;
761
+ # serialize_config renders them back as `[providers.<name>]` blocks.
698
762
  _CONFIG_DEFAULTS: dict = {
699
763
  "num": 3,
700
- "timeout": 600.0,
764
+ "timeout": 900.0,
701
765
  "synthesizer": "auto",
702
766
  "moderator": "auto",
703
767
  "exclude": [],
704
768
  "models": {},
769
+ "efforts": {},
705
770
  }
706
771
 
707
772
 
@@ -770,12 +835,57 @@ def load_config() -> dict:
770
835
  raise ValueError("Config key 'exclude' must be a list of provider names.")
771
836
  _validate_providers(value, "exclude")
772
837
  config["exclude"] = value
838
+ # Models come from two places: the canonical `[providers.<name>].model` and
839
+ # the DEPRECATED flat `[models]` table. Start from the deprecated table, then
840
+ # let the provider blocks win on conflict (with a one-line note, not an
841
+ # error). Efforts come only from `[providers.<name>].effort`.
842
+ models: dict[str, str] = {}
843
+ efforts: dict[str, str] = {}
844
+
773
845
  if "models" in raw:
774
- models = raw["models"]
775
- if not isinstance(models, dict) or not all(isinstance(v, str) for v in models.values()):
846
+ legacy = raw["models"]
847
+ if not isinstance(legacy, dict) or not all(isinstance(v, str) for v in legacy.values()):
776
848
  raise ValueError("Config table '[models]' must map provider names to model strings.")
777
- _validate_providers(models, "[models]")
778
- config["models"] = dict(models)
849
+ _validate_providers(legacy, "[models]")
850
+ models.update(legacy)
851
+
852
+ if "providers" in raw:
853
+ providers = raw["providers"]
854
+ if not isinstance(providers, dict):
855
+ raise ValueError("Config table '[providers]' must map provider names to {model, effort} blocks.")
856
+ _validate_providers(providers, "[providers]")
857
+ shadowed: list[str] = []
858
+ for name, block in providers.items():
859
+ if not isinstance(block, dict):
860
+ raise ValueError(f"Config table '[providers.{name}]' must be a {{model, effort}} block.")
861
+ unknown_pk = [k for k in block if k not in _PROVIDER_KEYS]
862
+ if unknown_pk:
863
+ raise ValueError(
864
+ f"Unknown key(s) in [providers.{name}]: {', '.join(unknown_pk)}. "
865
+ f"Known: {', '.join(_PROVIDER_KEYS)}."
866
+ )
867
+ if "model" in block:
868
+ model = block["model"]
869
+ if not isinstance(model, str):
870
+ raise ValueError(f"[providers.{name}].model must be a string.")
871
+ if name in raw.get("models", {}) and raw["models"][name] != model:
872
+ shadowed.append(name)
873
+ models[name] = model
874
+ if "effort" in block:
875
+ effort = block["effort"]
876
+ if not isinstance(effort, str) or not effort:
877
+ raise ValueError(f"[providers.{name}].effort must be a non-empty string.")
878
+ efforts[name] = effort
879
+ if shadowed:
880
+ _note(
881
+ f"Note: [providers.{shadowed[0]}].model overrides the deprecated [models] entry "
882
+ f"for {', '.join(shadowed)}."
883
+ )
884
+
885
+ if models:
886
+ config["models"] = models
887
+ if efforts:
888
+ config["efforts"] = efforts
779
889
  return config
780
890
 
781
891
 
@@ -799,10 +909,14 @@ def _toml_str(value: str) -> str:
799
909
 
800
910
 
801
911
  def serialize_config(config: dict) -> str:
802
- """Render our flat config schema back to TOML text.
803
-
804
- Hand-rolled on purpose (no writer dependency): we only ever emit scalars,
805
- the `exclude` string list, and the `[models]` string table, in that order.
912
+ """Render our config schema back to TOML text.
913
+
914
+ Hand-rolled on purpose (no writer dependency): we emit scalars, the `exclude`
915
+ string list, then one `[providers.<name>]` block per provider that has a
916
+ model and/or effort. We always write the canonical `[providers.<name>]` form
917
+ (never the deprecated flat `[models]` table), so a round-trip upgrades an old
918
+ file's models in place. The normalized `models`/`efforts` maps are folded
919
+ back together per provider so model and effort stay grouped.
806
920
  """
807
921
  lines: list[str] = []
808
922
  if "num" in config:
@@ -819,11 +933,19 @@ def serialize_config(config: dict) -> str:
819
933
  if "exclude" in config:
820
934
  items = ", ".join(_toml_str(v) for v in config["exclude"])
821
935
  lines.append(f"exclude = [{items}]")
822
- if config.get("models"):
936
+ models = config.get("models") or {}
937
+ efforts = config.get("efforts") or {}
938
+ # Emit one block per provider, in PRIORITY order then any extras, grouping the
939
+ # provider's model and effort together.
940
+ names = [n for n in PRIORITY if n in models or n in efforts]
941
+ names += [n for n in (*models, *efforts) if n not in names]
942
+ for name in names:
823
943
  lines.append("")
824
- lines.append("[models]")
825
- for provider, model in config["models"].items():
826
- lines.append(f"{provider} = {_toml_str(model)}")
944
+ lines.append(f"[providers.{name}]")
945
+ if name in models:
946
+ lines.append(f"model = {_toml_str(models[name])}")
947
+ if name in efforts:
948
+ lines.append(f"effort = {_toml_str(efforts[name])}")
827
949
  return "\n".join(lines) + "\n" if lines else ""
828
950
 
829
951
 
@@ -927,6 +1049,7 @@ async def _collect(
927
1049
  models: dict[str, str] | None = None,
928
1050
  yolo: bool = False,
929
1051
  emit_blocks: bool = True,
1052
+ efforts: dict[str, str] | None = None,
930
1053
  ) -> list[RunResult]:
931
1054
  """Gather every agent's result. With emit_blocks (ask), each complete answer
932
1055
  is flushed to stdout the instant it arrives. Without it (distill), the
@@ -934,7 +1057,7 @@ async def _collect(
934
1057
  distilled block is content - so we keep stdout clean and just heartbeat each
935
1058
  arrival to stderr so a multi-agent run doesn't look frozen while it waits."""
936
1059
  results: list[RunResult] = []
937
- async for result in stream(providers, prompt, timeout, models, yolo):
1060
+ async for result in stream(providers, prompt, timeout, models, yolo, efforts):
938
1061
  results.append(result)
939
1062
  if emit_blocks:
940
1063
  _emit(json.dumps(result_record(result)) if json_output else render_block(result))
@@ -991,6 +1114,7 @@ class RunConfig:
991
1114
  timeout: float
992
1115
  json_output: bool
993
1116
  yolo: bool
1117
+ efforts: dict[str, str]
994
1118
 
995
1119
 
996
1120
  def resolve_run(
@@ -1027,13 +1151,17 @@ def resolve_run(
1027
1151
  raise typer.BadParameter(f"{config_path()}: {exc}") from exc
1028
1152
 
1029
1153
  num = resolve_option(num, "num", config, default_num)
1030
- timeout = resolve_option(timeout, "timeout", config, 600.0)
1154
+ timeout = resolve_option(timeout, "timeout", config, 900.0)
1031
1155
  # Repeatable flags are an empty list when omitted, not None, so treat empty
1032
1156
  # as "fall back to config" for exclude.
1033
1157
  exclude_names = tuple(exclude) if exclude else tuple(config.get("exclude", ()))
1034
1158
  # CLI -m overrides win per-provider over config [models]; unnamed providers
1035
1159
  # keep their config value, then their built-in default.
1036
1160
  models = {**config.get("models", {}), **parse_model_overrides(model)}
1161
+ # Effort is config-only (no CLI flag, by design); it comes straight from the
1162
+ # per-provider config blocks. agy/claude have no effort flag, so a value set
1163
+ # for them is simply ignored by their builder (effort_args returns ()).
1164
+ efforts = dict(config.get("efforts", {}))
1037
1165
 
1038
1166
  if num < 1:
1039
1167
  raise typer.BadParameter("--num must be at least 1.")
@@ -1064,7 +1192,7 @@ def resolve_run(
1064
1192
  note += f"; note: {p.readonly_note}"
1065
1193
  _note(note)
1066
1194
 
1067
- return RunConfig(prompt_text, selected, models, timeout, json_output, yolo)
1195
+ return RunConfig(prompt_text, selected, models, timeout, json_output, yolo, efforts)
1068
1196
 
1069
1197
 
1070
1198
  @app.command()
@@ -1083,7 +1211,10 @@ def ask(
1083
1211
  cfg = resolve_run(prompt, file, num, provider, exclude, model, timeout, json_output, yolo)
1084
1212
 
1085
1213
  results = asyncio.run(
1086
- _collect(cfg.selected, cfg.prompt, cfg.timeout, cfg.json_output, cfg.models, cfg.yolo)
1214
+ _collect(
1215
+ cfg.selected, cfg.prompt, cfg.timeout, cfg.json_output, cfg.models, cfg.yolo,
1216
+ efforts=cfg.efforts,
1217
+ )
1087
1218
  )
1088
1219
  if not any(r.status == "ok" for r in results):
1089
1220
  raise typer.Exit(code=1)
@@ -1117,7 +1248,7 @@ def distill(
1117
1248
  results = asyncio.run(
1118
1249
  _collect(
1119
1250
  cfg.selected, cfg.prompt, cfg.timeout, cfg.json_output, cfg.models, cfg.yolo,
1120
- emit_blocks=False,
1251
+ emit_blocks=False, efforts=cfg.efforts,
1121
1252
  )
1122
1253
  )
1123
1254
  successes = [r for r in results if r.status == "ok"]
@@ -1152,7 +1283,10 @@ def _run_synthesis(
1152
1283
  _note(f"Distilling with {synth_name}...")
1153
1284
  synth_model = cfg.models.get(synth_name)
1154
1285
  synth_result = asyncio.run(
1155
- run_provider(PROVIDERS[synth_name], synth_prompt, cfg.timeout, synth_model, cfg.yolo)
1286
+ run_provider(
1287
+ PROVIDERS[synth_name], synth_prompt, cfg.timeout, synth_model, cfg.yolo,
1288
+ cfg.efforts.get(synth_name),
1289
+ )
1156
1290
  )
1157
1291
 
1158
1292
  if cfg.json_output:
@@ -1227,7 +1361,8 @@ async def _moderator_signals_done(
1227
1361
  prompt = build_convergence_prompt(cfg.prompt, latest_ok)
1228
1362
  _note(f"Round {round_num}: moderator {moderator.name} checking for convergence...")
1229
1363
  result = await run_provider(
1230
- moderator, prompt, cfg.timeout, cfg.models.get(moderator.name), cfg.yolo
1364
+ moderator, prompt, cfg.timeout, cfg.models.get(moderator.name), cfg.yolo,
1365
+ cfg.efforts.get(moderator.name),
1231
1366
  )
1232
1367
  done = result.status == "ok" and result.stdout.strip().upper().startswith(CONVERGENCE_DONE)
1233
1368
  if done:
@@ -1266,7 +1401,8 @@ async def _run_debate(
1266
1401
  turn_prompt = build_debate_turn_prompt(cfg.prompt, prior)
1267
1402
  _note(f"Round {round_num}: {debater.name} responding...")
1268
1403
  result = await run_provider(
1269
- debater, turn_prompt, cfg.timeout, cfg.models.get(debater.name), cfg.yolo
1404
+ debater, turn_prompt, cfg.timeout, cfg.models.get(debater.name), cfg.yolo,
1405
+ cfg.efforts.get(debater.name),
1270
1406
  )
1271
1407
  transcript.append(result)
1272
1408
  latest[debater.name] = result
@@ -1298,7 +1434,8 @@ async def _run_debate(
1298
1434
  verdict_prompt, _label_map = build_verdict_prompt(cfg.prompt, transcript)
1299
1435
  _note(f"Moderator {moderator.name} writing the final answer...")
1300
1436
  verdict = await run_provider(
1301
- moderator, verdict_prompt, cfg.timeout, cfg.models.get(moderator.name), cfg.yolo
1437
+ moderator, verdict_prompt, cfg.timeout, cfg.models.get(moderator.name), cfg.yolo,
1438
+ cfg.efforts.get(moderator.name),
1302
1439
  )
1303
1440
  transcript.append(verdict)
1304
1441
  _emit(
@@ -1371,8 +1508,8 @@ def config_show() -> None:
1371
1508
 
1372
1509
  @config_app.command("set")
1373
1510
  def config_set(
1374
- key: Annotated[str, typer.Argument(help="Config key: num | timeout | synthesizer | moderator | exclude | model.")],
1375
- value: Annotated[str, typer.Argument(help="Value. For models: PROVIDER=MODEL. For exclude: comma-separated names.")],
1511
+ key: Annotated[str, typer.Argument(help="Config key: num | timeout | synthesizer | moderator | exclude | model | effort.")],
1512
+ value: Annotated[str, typer.Argument(help="Value. For model/effort: PROVIDER=VALUE. For exclude: comma-separated names.")],
1376
1513
  ) -> None:
1377
1514
  """Write a value to the config file, creating the dir/file if missing."""
1378
1515
  config = _load_config_or_exit()
@@ -1385,6 +1522,22 @@ def config_set(
1385
1522
  if provider not in PROVIDERS:
1386
1523
  raise typer.BadParameter(f"Unknown provider: {provider!r}. Known: {', '.join(PROVIDERS)}.")
1387
1524
  config.setdefault("models", {})[provider] = model
1525
+ elif key == "effort":
1526
+ if "=" not in value:
1527
+ raise typer.BadParameter("effort expects PROVIDER=VALUE, e.g. `moa config set effort codex=high`.")
1528
+ provider, effort = value.split("=", 1)
1529
+ provider = provider.strip()
1530
+ if provider not in PROVIDERS:
1531
+ raise typer.BadParameter(f"Unknown provider: {provider!r}. Known: {', '.join(PROVIDERS)}.")
1532
+ # Raw pass-through: the value is whatever the target tool expects; moa
1533
+ # only refuses an empty string (no enum/scale validation, by design).
1534
+ if not effort:
1535
+ raise typer.BadParameter("effort value cannot be empty.")
1536
+ config.setdefault("efforts", {})[provider] = effort
1537
+ if PROVIDERS[provider].effort_flag is None:
1538
+ # Stored but inert: agy carries reasoning in the model name, claude
1539
+ # has no per-call effort flag. A note, not an error.
1540
+ _note(f"Note: {provider} has no effort flag; this value will be ignored at runtime.")
1388
1541
  elif key == "exclude":
1389
1542
  names = [name.strip() for name in value.split(",") if name.strip()]
1390
1543
  try:
@@ -1404,7 +1557,7 @@ def config_set(
1404
1557
  raise typer.BadParameter(str(exc)) from exc
1405
1558
  config[key] = coerced
1406
1559
  else:
1407
- known = "num, timeout, synthesizer, moderator, exclude, model"
1560
+ known = "num, timeout, synthesizer, moderator, exclude, model, effort"
1408
1561
  raise typer.BadParameter(f"Unknown config key: {key!r}. Known: {known}.")
1409
1562
 
1410
1563
  write_config(config)
@@ -1413,24 +1566,27 @@ def config_set(
1413
1566
 
1414
1567
  @config_app.command("unset")
1415
1568
  def config_unset(
1416
- key: Annotated[str, typer.Argument(help="Config key to remove. Use `model PROVIDER` to drop one model.")],
1417
- provider: Annotated[str | None, typer.Argument(help="Provider name, only when key is 'model'.")] = None,
1569
+ key: Annotated[str, typer.Argument(help="Config key to remove. Use `model PROVIDER` / `effort PROVIDER` to drop one.")],
1570
+ provider: Annotated[str | None, typer.Argument(help="Provider name, only when key is 'model' or 'effort'.")] = None,
1418
1571
  ) -> None:
1419
- """Remove a key from the config file (or a single model with `unset model PROVIDER`)."""
1572
+ """Remove a key from the config file (or a single model/effort with `unset model|effort PROVIDER`)."""
1420
1573
  config = _load_config_or_exit()
1421
1574
 
1422
- if key == "model":
1575
+ if key in ("model", "effort"):
1576
+ # model -> the normalized [providers.<name>].model map (a.k.a. [models]);
1577
+ # effort -> the [providers.<name>].effort map. Same per-provider shape.
1578
+ table_key = "models" if key == "model" else "efforts"
1423
1579
  if not provider:
1424
- raise typer.BadParameter("unset model expects a provider, e.g. `moa config unset model claude`.")
1425
- models = config.get("models", {})
1426
- if provider in models:
1427
- del models[provider]
1428
- if not models:
1429
- config.pop("models", None)
1580
+ raise typer.BadParameter(f"unset {key} expects a provider, e.g. `moa config unset {key} codex`.")
1581
+ table = config.get(table_key, {})
1582
+ if provider in table:
1583
+ del table[provider]
1584
+ if not table:
1585
+ config.pop(table_key, None)
1430
1586
  write_config(config)
1431
- typer.echo(f"Unset model {provider} in {config_path()}")
1587
+ typer.echo(f"Unset {key} {provider} in {config_path()}")
1432
1588
  else:
1433
- typer.echo(f"model {provider} was not set.")
1589
+ typer.echo(f"{key} {provider} was not set.")
1434
1590
  return
1435
1591
 
1436
1592
  if key not in _CONFIG_KEYS: