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.
|
|
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
|
|
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
|
|
175
|
-
|
|
|
176
|
-
| `num`
|
|
177
|
-
| `timeout`
|
|
178
|
-
| `exclude`
|
|
179
|
-
| `synthesizer`
|
|
180
|
-
| `[
|
|
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
|
-
[
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
164
|
-
|
|
|
165
|
-
| `num`
|
|
166
|
-
| `timeout`
|
|
167
|
-
| `exclude`
|
|
168
|
-
| `synthesizer`
|
|
169
|
-
| `[
|
|
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
|
-
[
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
33
|
-
# output_file is a path the CLI may be told to write its final answer to; it
|
|
34
|
-
# None for providers that answer cleanly on stdout. Only codex uses it. `perm`
|
|
35
|
-
# is the permission argv (read-only or yolo flags)
|
|
36
|
-
|
|
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(
|
|
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(
|
|
85
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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])
|
|
690
|
-
# `[
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
775
|
-
if not isinstance(
|
|
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(
|
|
778
|
-
|
|
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
|
|
803
|
-
|
|
804
|
-
Hand-rolled on purpose (no writer dependency): we
|
|
805
|
-
|
|
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
|
-
|
|
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("[
|
|
825
|
-
|
|
826
|
-
lines.append(f"
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1425
|
-
|
|
1426
|
-
if provider in
|
|
1427
|
-
del
|
|
1428
|
-
if not
|
|
1429
|
-
config.pop(
|
|
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
|
|
1580
|
+
typer.echo(f"Unset {key} {provider} in {config_path()}")
|
|
1432
1581
|
else:
|
|
1433
|
-
typer.echo(f"
|
|
1582
|
+
typer.echo(f"{key} {provider} was not set.")
|
|
1434
1583
|
return
|
|
1435
1584
|
|
|
1436
1585
|
if key not in _CONFIG_KEYS:
|