moa-cli 0.3.0__tar.gz → 0.3.2__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.0
3
+ Version: 0.3.2
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 180s, read-only)
53
+ Asking claude, codex, agy (timeout 600s, read-only)
54
54
 
55
55
  ──────────────── claude (opus) · OK · 3.2s ────────────────
56
56
 
@@ -171,13 +171,14 @@ To avoid repeating the same flags on every call, persist your own defaults in a
171
171
 
172
172
  **Keys** (all shared across `ask`/`distill`/`debate`):
173
173
 
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"` |
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
+ | `[providers.<name>]` | per-provider `model` + `effort` | see below |
181
+ | `[models]` | DEPRECATED provider -> model table | `claude = "sonnet"` |
181
182
 
182
183
  ```toml
183
184
  # ~/.moa/config.toml
@@ -186,11 +187,17 @@ timeout = 120
186
187
  exclude = ["claude"]
187
188
  synthesizer = "auto"
188
189
 
189
- [models]
190
- claude = "sonnet"
191
- agy = "Gemini 3.1 Pro (Low)"
190
+ [providers.codex]
191
+ model = "gpt-5.5"
192
+ effort = "high"
193
+
194
+ [providers.opencode]
195
+ model = "zai-coding-plan/glm-5.2"
196
+ effort = "high"
192
197
  ```
193
198
 
199
+ 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).
200
+
194
201
  **`moa config`** inspects and edits the file (it creates the dir/file as needed and validates provider names):
195
202
 
196
203
  ```bash
@@ -198,12 +205,29 @@ moa config show # effective config (defaults + file) + pat
198
205
  moa config path # print the config file path
199
206
  moa config set num 2 # set a scalar
200
207
  moa config set exclude claude,codex # set the exclude list (comma-separated)
201
- moa config set model claude=sonnet # set one entry in [models]
208
+ moa config set model codex=gpt-5.5 # set a provider's model
209
+ moa config set effort codex=high # set a provider's reasoning effort
202
210
  moa config unset num # remove a key
203
- moa config unset model claude # remove one [models] entry
211
+ moa config unset model codex # remove one provider's model
212
+ moa config unset effort codex # remove one provider's effort
204
213
  ```
205
214
 
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.
215
+ 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.
216
+
217
+ #### Reasoning / effort
218
+
219
+ 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.
220
+
221
+ 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:
222
+
223
+ | Provider | `effort` lands in | Notes |
224
+ | ---------- | ------------------------------------ | ----------------------------------------------------------- |
225
+ | `codex` | `-c model_reasoning_effort=<value>` | generic config override |
226
+ | `opencode` | `--variant <value>` | opencode's "model variant (provider-specific reasoning effort)" |
227
+ | `agy` | (none) | reasoning is part of the model name, e.g. `Gemini 3.1 Pro (High)` |
228
+ | `claude` | (none) | no per-call effort flag |
229
+
230
+ 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
231
 
208
232
  ### Output
209
233
 
@@ -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 180s, read-only)
42
+ Asking claude, codex, agy (timeout 600s, read-only)
43
43
 
44
44
  ──────────────── claude (opus) · OK · 3.2s ────────────────
45
45
 
@@ -160,13 +160,14 @@ To avoid repeating the same flags on every call, persist your own defaults in a
160
160
 
161
161
  **Keys** (all shared across `ask`/`distill`/`debate`):
162
162
 
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"` |
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
+ | `[providers.<name>]` | per-provider `model` + `effort` | see below |
170
+ | `[models]` | DEPRECATED provider -> model table | `claude = "sonnet"` |
170
171
 
171
172
  ```toml
172
173
  # ~/.moa/config.toml
@@ -175,11 +176,17 @@ timeout = 120
175
176
  exclude = ["claude"]
176
177
  synthesizer = "auto"
177
178
 
178
- [models]
179
- claude = "sonnet"
180
- agy = "Gemini 3.1 Pro (Low)"
179
+ [providers.codex]
180
+ model = "gpt-5.5"
181
+ effort = "high"
182
+
183
+ [providers.opencode]
184
+ model = "zai-coding-plan/glm-5.2"
185
+ effort = "high"
181
186
  ```
182
187
 
188
+ 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).
189
+
183
190
  **`moa config`** inspects and edits the file (it creates the dir/file as needed and validates provider names):
184
191
 
185
192
  ```bash
@@ -187,12 +194,29 @@ moa config show # effective config (defaults + file) + pat
187
194
  moa config path # print the config file path
188
195
  moa config set num 2 # set a scalar
189
196
  moa config set exclude claude,codex # set the exclude list (comma-separated)
190
- moa config set model claude=sonnet # set one entry in [models]
197
+ moa config set model codex=gpt-5.5 # set a provider's model
198
+ moa config set effort codex=high # set a provider's reasoning effort
191
199
  moa config unset num # remove a key
192
- moa config unset model claude # remove one [models] entry
200
+ moa config unset model codex # remove one provider's model
201
+ moa config unset effort codex # remove one provider's effort
193
202
  ```
194
203
 
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.
204
+ 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.
205
+
206
+ #### Reasoning / effort
207
+
208
+ 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.
209
+
210
+ 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:
211
+
212
+ | Provider | `effort` lands in | Notes |
213
+ | ---------- | ------------------------------------ | ----------------------------------------------------------- |
214
+ | `codex` | `-c model_reasoning_effort=<value>` | generic config override |
215
+ | `opencode` | `--variant <value>` | opencode's "model variant (provider-specific reasoning effort)" |
216
+ | `agy` | (none) | reasoning is part of the model name, e.g. `Gemini 3.1 Pro (High)` |
217
+ | `claude` | (none) | no per-call effort flag |
218
+
219
+ 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
220
 
197
221
  ### Output
198
222
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "moa-cli"
3
- version = "0.3.0"
3
+ version = "0.3.2"
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.0"
3
+ __version__ = "0.3.2"
@@ -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)
@@ -120,6 +152,10 @@ PROVIDERS: dict[str, Provider] = {
120
152
  readonly=("-s", "read-only"),
121
153
  yolo=("-s", "danger-full-access"),
122
154
  uses_output_file=True,
155
+ # Verified against installed codex: `-c key=value` is the generic config
156
+ # override (`codex exec --help`); reasoning effort lives at
157
+ # model_reasoning_effort. The value is pasted verbatim.
158
+ effort_flag=lambda v: ("-c", f"model_reasoning_effort={v}"),
123
159
  ),
124
160
  "agy": Provider(
125
161
  "agy", "agy", "Gemini 3.1 Pro (High)", _agy,
@@ -135,6 +171,10 @@ PROVIDERS: dict[str, Provider] = {
135
171
  "opencode", "opencode", "", _opencode,
136
172
  readonly=("--agent", "plan"),
137
173
  yolo=(), # default = build agent (full access)
174
+ # Verified against installed opencode: `--variant <value>` is the
175
+ # "model variant (provider-specific reasoning effort, e.g., high)" flag
176
+ # (`opencode run --help`). The value is pasted verbatim.
177
+ effort_flag=lambda v: ("--variant", v),
138
178
  ),
139
179
  }
140
180
 
@@ -232,7 +272,12 @@ async def _terminate(process: asyncio.subprocess.Process) -> None:
232
272
 
233
273
 
234
274
  async def run_provider(
235
- provider: Provider, prompt: str, timeout: float, model: str | None = None, yolo: bool = False
275
+ provider: Provider,
276
+ prompt: str,
277
+ timeout: float,
278
+ model: str | None = None,
279
+ yolo: bool = False,
280
+ effort: str | None = None,
236
281
  ) -> RunResult:
237
282
  model = model or provider.default_model
238
283
  out_file: str | None = None
@@ -244,7 +289,7 @@ async def run_provider(
244
289
  try:
245
290
  try:
246
291
  process = await asyncio.create_subprocess_exec(
247
- *provider.build(prompt, model, out_file, provider.perm_args(yolo)),
292
+ *provider.build(prompt, model, out_file, provider.perm_args(yolo), provider.effort_args(effort)),
248
293
  # DEVNULL is essential: codex and agy block forever on an
249
294
  # inherited TTY stdin, burning the entire timeout otherwise.
250
295
  stdin=asyncio.subprocess.DEVNULL,
@@ -286,11 +331,15 @@ async def stream(
286
331
  timeout: float,
287
332
  models: dict[str, str] | None = None,
288
333
  yolo: bool = False,
334
+ efforts: dict[str, str] | None = None,
289
335
  ) -> AsyncIterator[RunResult]:
290
336
  """Run every provider in parallel, yielding each result as it finishes."""
291
337
  models = models or {}
338
+ efforts = efforts or {}
292
339
  tasks = [
293
- asyncio.create_task(run_provider(p, prompt, timeout, models.get(p.name), yolo))
340
+ asyncio.create_task(
341
+ run_provider(p, prompt, timeout, models.get(p.name), yolo, efforts.get(p.name))
342
+ )
294
343
  for p in providers
295
344
  ]
296
345
  for completed in asyncio.as_completed(tasks):
@@ -686,22 +735,31 @@ def verdict_record(result: RunResult, moderator: str) -> dict:
686
735
  # merge happens once, in resolve_run, so all verbs pick up defaults identically.
687
736
  # --------------------------------------------------------------------------- #
688
737
 
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.
738
+ # Scalar config keys and the type each maps to. `exclude` (list[str]), the
739
+ # `[providers.<name>]` blocks, and the deprecated `[models]` table are handled
740
+ # separately because they aren't plain scalars.
691
741
  _CONFIG_SCALARS: dict[str, type] = {"num": int, "timeout": float, "synthesizer": str, "moderator": str}
692
- _CONFIG_KEYS: tuple[str, ...] = (*_CONFIG_SCALARS, "exclude", "models")
742
+ # Top-level config keys a file may contain. `providers` is the canonical
743
+ # per-provider block (model + effort); `models` is the DEPRECATED flat alias for
744
+ # `[providers.<name>].model`, kept for back-compat with 0.2.x/0.3.x configs.
745
+ _CONFIG_KEYS: tuple[str, ...] = (*_CONFIG_SCALARS, "exclude", "providers", "models")
746
+ # Per-provider keys allowed inside a `[providers.<name>]` block.
747
+ _PROVIDER_KEYS: tuple[str, ...] = ("model", "effort")
693
748
  # Synthesizer accepts the special modes plus any known provider name.
694
749
  _SYNTHESIZER_MODES: tuple[str, ...] = ("auto", "first", "random")
695
750
  # Moderator accepts "auto" (the top-priority selected agent) or a provider name.
696
751
  _MODERATOR_MODES: tuple[str, ...] = ("auto",)
697
752
  # The built-in defaults, shown by `config show` when a key isn't in the file.
753
+ # `models`/`efforts` are the normalized per-provider maps load_config produces;
754
+ # serialize_config renders them back as `[providers.<name>]` blocks.
698
755
  _CONFIG_DEFAULTS: dict = {
699
756
  "num": 3,
700
- "timeout": 180.0,
757
+ "timeout": 600.0,
701
758
  "synthesizer": "auto",
702
759
  "moderator": "auto",
703
760
  "exclude": [],
704
761
  "models": {},
762
+ "efforts": {},
705
763
  }
706
764
 
707
765
 
@@ -770,12 +828,57 @@ def load_config() -> dict:
770
828
  raise ValueError("Config key 'exclude' must be a list of provider names.")
771
829
  _validate_providers(value, "exclude")
772
830
  config["exclude"] = value
831
+ # Models come from two places: the canonical `[providers.<name>].model` and
832
+ # the DEPRECATED flat `[models]` table. Start from the deprecated table, then
833
+ # let the provider blocks win on conflict (with a one-line note, not an
834
+ # error). Efforts come only from `[providers.<name>].effort`.
835
+ models: dict[str, str] = {}
836
+ efforts: dict[str, str] = {}
837
+
773
838
  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()):
839
+ legacy = raw["models"]
840
+ if not isinstance(legacy, dict) or not all(isinstance(v, str) for v in legacy.values()):
776
841
  raise ValueError("Config table '[models]' must map provider names to model strings.")
777
- _validate_providers(models, "[models]")
778
- config["models"] = dict(models)
842
+ _validate_providers(legacy, "[models]")
843
+ models.update(legacy)
844
+
845
+ if "providers" in raw:
846
+ providers = raw["providers"]
847
+ if not isinstance(providers, dict):
848
+ raise ValueError("Config table '[providers]' must map provider names to {model, effort} blocks.")
849
+ _validate_providers(providers, "[providers]")
850
+ shadowed: list[str] = []
851
+ for name, block in providers.items():
852
+ if not isinstance(block, dict):
853
+ raise ValueError(f"Config table '[providers.{name}]' must be a {{model, effort}} block.")
854
+ unknown_pk = [k for k in block if k not in _PROVIDER_KEYS]
855
+ if unknown_pk:
856
+ raise ValueError(
857
+ f"Unknown key(s) in [providers.{name}]: {', '.join(unknown_pk)}. "
858
+ f"Known: {', '.join(_PROVIDER_KEYS)}."
859
+ )
860
+ if "model" in block:
861
+ model = block["model"]
862
+ if not isinstance(model, str):
863
+ raise ValueError(f"[providers.{name}].model must be a string.")
864
+ if name in raw.get("models", {}) and raw["models"][name] != model:
865
+ shadowed.append(name)
866
+ models[name] = model
867
+ if "effort" in block:
868
+ effort = block["effort"]
869
+ if not isinstance(effort, str) or not effort:
870
+ raise ValueError(f"[providers.{name}].effort must be a non-empty string.")
871
+ efforts[name] = effort
872
+ if shadowed:
873
+ _note(
874
+ f"Note: [providers.{shadowed[0]}].model overrides the deprecated [models] entry "
875
+ f"for {', '.join(shadowed)}."
876
+ )
877
+
878
+ if models:
879
+ config["models"] = models
880
+ if efforts:
881
+ config["efforts"] = efforts
779
882
  return config
780
883
 
781
884
 
@@ -799,10 +902,14 @@ def _toml_str(value: str) -> str:
799
902
 
800
903
 
801
904
  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.
905
+ """Render our config schema back to TOML text.
906
+
907
+ Hand-rolled on purpose (no writer dependency): we emit scalars, the `exclude`
908
+ string list, then one `[providers.<name>]` block per provider that has a
909
+ model and/or effort. We always write the canonical `[providers.<name>]` form
910
+ (never the deprecated flat `[models]` table), so a round-trip upgrades an old
911
+ file's models in place. The normalized `models`/`efforts` maps are folded
912
+ back together per provider so model and effort stay grouped.
806
913
  """
807
914
  lines: list[str] = []
808
915
  if "num" in config:
@@ -819,11 +926,19 @@ def serialize_config(config: dict) -> str:
819
926
  if "exclude" in config:
820
927
  items = ", ".join(_toml_str(v) for v in config["exclude"])
821
928
  lines.append(f"exclude = [{items}]")
822
- if config.get("models"):
929
+ models = config.get("models") or {}
930
+ efforts = config.get("efforts") or {}
931
+ # Emit one block per provider, in PRIORITY order then any extras, grouping the
932
+ # provider's model and effort together.
933
+ names = [n for n in PRIORITY if n in models or n in efforts]
934
+ names += [n for n in (*models, *efforts) if n not in names]
935
+ for name in names:
823
936
  lines.append("")
824
- lines.append("[models]")
825
- for provider, model in config["models"].items():
826
- lines.append(f"{provider} = {_toml_str(model)}")
937
+ lines.append(f"[providers.{name}]")
938
+ if name in models:
939
+ lines.append(f"model = {_toml_str(models[name])}")
940
+ if name in efforts:
941
+ lines.append(f"effort = {_toml_str(efforts[name])}")
827
942
  return "\n".join(lines) + "\n" if lines else ""
828
943
 
829
944
 
@@ -927,6 +1042,7 @@ async def _collect(
927
1042
  models: dict[str, str] | None = None,
928
1043
  yolo: bool = False,
929
1044
  emit_blocks: bool = True,
1045
+ efforts: dict[str, str] | None = None,
930
1046
  ) -> list[RunResult]:
931
1047
  """Gather every agent's result. With emit_blocks (ask), each complete answer
932
1048
  is flushed to stdout the instant it arrives. Without it (distill), the
@@ -934,7 +1050,7 @@ async def _collect(
934
1050
  distilled block is content - so we keep stdout clean and just heartbeat each
935
1051
  arrival to stderr so a multi-agent run doesn't look frozen while it waits."""
936
1052
  results: list[RunResult] = []
937
- async for result in stream(providers, prompt, timeout, models, yolo):
1053
+ async for result in stream(providers, prompt, timeout, models, yolo, efforts):
938
1054
  results.append(result)
939
1055
  if emit_blocks:
940
1056
  _emit(json.dumps(result_record(result)) if json_output else render_block(result))
@@ -991,6 +1107,7 @@ class RunConfig:
991
1107
  timeout: float
992
1108
  json_output: bool
993
1109
  yolo: bool
1110
+ efforts: dict[str, str]
994
1111
 
995
1112
 
996
1113
  def resolve_run(
@@ -1027,13 +1144,17 @@ def resolve_run(
1027
1144
  raise typer.BadParameter(f"{config_path()}: {exc}") from exc
1028
1145
 
1029
1146
  num = resolve_option(num, "num", config, default_num)
1030
- timeout = resolve_option(timeout, "timeout", config, 180.0)
1147
+ timeout = resolve_option(timeout, "timeout", config, 600.0)
1031
1148
  # Repeatable flags are an empty list when omitted, not None, so treat empty
1032
1149
  # as "fall back to config" for exclude.
1033
1150
  exclude_names = tuple(exclude) if exclude else tuple(config.get("exclude", ()))
1034
1151
  # CLI -m overrides win per-provider over config [models]; unnamed providers
1035
1152
  # keep their config value, then their built-in default.
1036
1153
  models = {**config.get("models", {}), **parse_model_overrides(model)}
1154
+ # Effort is config-only (no CLI flag, by design); it comes straight from the
1155
+ # per-provider config blocks. agy/claude have no effort flag, so a value set
1156
+ # for them is simply ignored by their builder (effort_args returns ()).
1157
+ efforts = dict(config.get("efforts", {}))
1037
1158
 
1038
1159
  if num < 1:
1039
1160
  raise typer.BadParameter("--num must be at least 1.")
@@ -1064,7 +1185,7 @@ def resolve_run(
1064
1185
  note += f"; note: {p.readonly_note}"
1065
1186
  _note(note)
1066
1187
 
1067
- return RunConfig(prompt_text, selected, models, timeout, json_output, yolo)
1188
+ return RunConfig(prompt_text, selected, models, timeout, json_output, yolo, efforts)
1068
1189
 
1069
1190
 
1070
1191
  @app.command()
@@ -1083,7 +1204,10 @@ def ask(
1083
1204
  cfg = resolve_run(prompt, file, num, provider, exclude, model, timeout, json_output, yolo)
1084
1205
 
1085
1206
  results = asyncio.run(
1086
- _collect(cfg.selected, cfg.prompt, cfg.timeout, cfg.json_output, cfg.models, cfg.yolo)
1207
+ _collect(
1208
+ cfg.selected, cfg.prompt, cfg.timeout, cfg.json_output, cfg.models, cfg.yolo,
1209
+ efforts=cfg.efforts,
1210
+ )
1087
1211
  )
1088
1212
  if not any(r.status == "ok" for r in results):
1089
1213
  raise typer.Exit(code=1)
@@ -1117,7 +1241,7 @@ def distill(
1117
1241
  results = asyncio.run(
1118
1242
  _collect(
1119
1243
  cfg.selected, cfg.prompt, cfg.timeout, cfg.json_output, cfg.models, cfg.yolo,
1120
- emit_blocks=False,
1244
+ emit_blocks=False, efforts=cfg.efforts,
1121
1245
  )
1122
1246
  )
1123
1247
  successes = [r for r in results if r.status == "ok"]
@@ -1152,7 +1276,10 @@ def _run_synthesis(
1152
1276
  _note(f"Distilling with {synth_name}...")
1153
1277
  synth_model = cfg.models.get(synth_name)
1154
1278
  synth_result = asyncio.run(
1155
- run_provider(PROVIDERS[synth_name], synth_prompt, cfg.timeout, synth_model, cfg.yolo)
1279
+ run_provider(
1280
+ PROVIDERS[synth_name], synth_prompt, cfg.timeout, synth_model, cfg.yolo,
1281
+ cfg.efforts.get(synth_name),
1282
+ )
1156
1283
  )
1157
1284
 
1158
1285
  if cfg.json_output:
@@ -1227,7 +1354,8 @@ async def _moderator_signals_done(
1227
1354
  prompt = build_convergence_prompt(cfg.prompt, latest_ok)
1228
1355
  _note(f"Round {round_num}: moderator {moderator.name} checking for convergence...")
1229
1356
  result = await run_provider(
1230
- moderator, prompt, cfg.timeout, cfg.models.get(moderator.name), cfg.yolo
1357
+ moderator, prompt, cfg.timeout, cfg.models.get(moderator.name), cfg.yolo,
1358
+ cfg.efforts.get(moderator.name),
1231
1359
  )
1232
1360
  done = result.status == "ok" and result.stdout.strip().upper().startswith(CONVERGENCE_DONE)
1233
1361
  if done:
@@ -1266,7 +1394,8 @@ async def _run_debate(
1266
1394
  turn_prompt = build_debate_turn_prompt(cfg.prompt, prior)
1267
1395
  _note(f"Round {round_num}: {debater.name} responding...")
1268
1396
  result = await run_provider(
1269
- debater, turn_prompt, cfg.timeout, cfg.models.get(debater.name), cfg.yolo
1397
+ debater, turn_prompt, cfg.timeout, cfg.models.get(debater.name), cfg.yolo,
1398
+ cfg.efforts.get(debater.name),
1270
1399
  )
1271
1400
  transcript.append(result)
1272
1401
  latest[debater.name] = result
@@ -1298,7 +1427,8 @@ async def _run_debate(
1298
1427
  verdict_prompt, _label_map = build_verdict_prompt(cfg.prompt, transcript)
1299
1428
  _note(f"Moderator {moderator.name} writing the final answer...")
1300
1429
  verdict = await run_provider(
1301
- moderator, verdict_prompt, cfg.timeout, cfg.models.get(moderator.name), cfg.yolo
1430
+ moderator, verdict_prompt, cfg.timeout, cfg.models.get(moderator.name), cfg.yolo,
1431
+ cfg.efforts.get(moderator.name),
1302
1432
  )
1303
1433
  transcript.append(verdict)
1304
1434
  _emit(
@@ -1371,8 +1501,8 @@ def config_show() -> None:
1371
1501
 
1372
1502
  @config_app.command("set")
1373
1503
  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.")],
1504
+ key: Annotated[str, typer.Argument(help="Config key: num | timeout | synthesizer | moderator | exclude | model | effort.")],
1505
+ value: Annotated[str, typer.Argument(help="Value. For model/effort: PROVIDER=VALUE. For exclude: comma-separated names.")],
1376
1506
  ) -> None:
1377
1507
  """Write a value to the config file, creating the dir/file if missing."""
1378
1508
  config = _load_config_or_exit()
@@ -1385,6 +1515,22 @@ def config_set(
1385
1515
  if provider not in PROVIDERS:
1386
1516
  raise typer.BadParameter(f"Unknown provider: {provider!r}. Known: {', '.join(PROVIDERS)}.")
1387
1517
  config.setdefault("models", {})[provider] = model
1518
+ elif key == "effort":
1519
+ if "=" not in value:
1520
+ raise typer.BadParameter("effort expects PROVIDER=VALUE, e.g. `moa config set effort codex=high`.")
1521
+ provider, effort = value.split("=", 1)
1522
+ provider = provider.strip()
1523
+ if provider not in PROVIDERS:
1524
+ raise typer.BadParameter(f"Unknown provider: {provider!r}. Known: {', '.join(PROVIDERS)}.")
1525
+ # Raw pass-through: the value is whatever the target tool expects; moa
1526
+ # only refuses an empty string (no enum/scale validation, by design).
1527
+ if not effort:
1528
+ raise typer.BadParameter("effort value cannot be empty.")
1529
+ config.setdefault("efforts", {})[provider] = effort
1530
+ if PROVIDERS[provider].effort_flag is None:
1531
+ # Stored but inert: agy carries reasoning in the model name, claude
1532
+ # has no per-call effort flag. A note, not an error.
1533
+ _note(f"Note: {provider} has no effort flag; this value will be ignored at runtime.")
1388
1534
  elif key == "exclude":
1389
1535
  names = [name.strip() for name in value.split(",") if name.strip()]
1390
1536
  try:
@@ -1404,7 +1550,7 @@ def config_set(
1404
1550
  raise typer.BadParameter(str(exc)) from exc
1405
1551
  config[key] = coerced
1406
1552
  else:
1407
- known = "num, timeout, synthesizer, moderator, exclude, model"
1553
+ known = "num, timeout, synthesizer, moderator, exclude, model, effort"
1408
1554
  raise typer.BadParameter(f"Unknown config key: {key!r}. Known: {known}.")
1409
1555
 
1410
1556
  write_config(config)
@@ -1413,24 +1559,27 @@ def config_set(
1413
1559
 
1414
1560
  @config_app.command("unset")
1415
1561
  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,
1562
+ key: Annotated[str, typer.Argument(help="Config key to remove. Use `model PROVIDER` / `effort PROVIDER` to drop one.")],
1563
+ provider: Annotated[str | None, typer.Argument(help="Provider name, only when key is 'model' or 'effort'.")] = None,
1418
1564
  ) -> None:
1419
- """Remove a key from the config file (or a single model with `unset model PROVIDER`)."""
1565
+ """Remove a key from the config file (or a single model/effort with `unset model|effort PROVIDER`)."""
1420
1566
  config = _load_config_or_exit()
1421
1567
 
1422
- if key == "model":
1568
+ if key in ("model", "effort"):
1569
+ # model -> the normalized [providers.<name>].model map (a.k.a. [models]);
1570
+ # effort -> the [providers.<name>].effort map. Same per-provider shape.
1571
+ table_key = "models" if key == "model" else "efforts"
1423
1572
  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)
1573
+ raise typer.BadParameter(f"unset {key} expects a provider, e.g. `moa config unset {key} codex`.")
1574
+ table = config.get(table_key, {})
1575
+ if provider in table:
1576
+ del table[provider]
1577
+ if not table:
1578
+ config.pop(table_key, None)
1430
1579
  write_config(config)
1431
- typer.echo(f"Unset model {provider} in {config_path()}")
1580
+ typer.echo(f"Unset {key} {provider} in {config_path()}")
1432
1581
  else:
1433
- typer.echo(f"model {provider} was not set.")
1582
+ typer.echo(f"{key} {provider} was not set.")
1434
1583
  return
1435
1584
 
1436
1585
  if key not in _CONFIG_KEYS: