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.
|
|
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
|
|
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
|
|
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
|
|
175
|
-
|
|
|
176
|
-
| `num`
|
|
177
|
-
| `timeout`
|
|
178
|
-
| `exclude`
|
|
179
|
-
| `synthesizer`
|
|
180
|
-
| `[
|
|
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
|
-
[
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
164
|
-
|
|
|
165
|
-
| `num`
|
|
166
|
-
| `timeout`
|
|
167
|
-
| `exclude`
|
|
168
|
-
| `synthesizer`
|
|
169
|
-
| `[
|
|
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
|
-
[
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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])
|
|
690
|
-
# `[
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
775
|
-
if not isinstance(
|
|
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(
|
|
778
|
-
|
|
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
|
|
803
|
-
|
|
804
|
-
Hand-rolled on purpose (no writer dependency): we
|
|
805
|
-
|
|
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
|
-
|
|
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("[
|
|
825
|
-
|
|
826
|
-
lines.append(f"
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1425
|
-
|
|
1426
|
-
if provider in
|
|
1427
|
-
del
|
|
1428
|
-
if not
|
|
1429
|
-
config.pop(
|
|
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
|
|
1587
|
+
typer.echo(f"Unset {key} {provider} in {config_path()}")
|
|
1432
1588
|
else:
|
|
1433
|
-
typer.echo(f"
|
|
1589
|
+
typer.echo(f"{key} {provider} was not set.")
|
|
1434
1590
|
return
|
|
1435
1591
|
|
|
1436
1592
|
if key not in _CONFIG_KEYS:
|