@gaia-minds/assistant-cli 0.1.1 → 0.2.0
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.
- package/README.md +6 -1
- package/assistant/README.md +122 -10
- package/package.json +1 -1
- package/tools/agent-config.yml +20 -5
- package/tools/agent-loop.py +499 -61
- package/tools/gaia-assistant.py +2363 -34
package/tools/agent-loop.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
This is the reasoning core of an autonomous agent that:
|
|
5
5
|
1. Gathers state from the repository and GitHub
|
|
6
|
-
2. Asks
|
|
6
|
+
2. Asks the configured reasoning provider what to do
|
|
7
7
|
3. Checks alignment of proposed actions
|
|
8
8
|
4. Executes approved actions
|
|
9
9
|
5. Writes memory (decisions, lessons, state)
|
|
@@ -25,6 +25,8 @@ import logging
|
|
|
25
25
|
import os
|
|
26
26
|
import sys
|
|
27
27
|
import time
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
28
30
|
from datetime import datetime, timezone
|
|
29
31
|
from pathlib import Path
|
|
30
32
|
from typing import Any, Dict, List, Optional
|
|
@@ -41,7 +43,6 @@ sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
41
43
|
from agent_alignment import ( # noqa: E402
|
|
42
44
|
AlignmentResult,
|
|
43
45
|
check_alignment,
|
|
44
|
-
classify_risk,
|
|
45
46
|
load_constitution,
|
|
46
47
|
)
|
|
47
48
|
from agent_actions import ( # noqa: E402
|
|
@@ -118,6 +119,11 @@ DEFAULT_BUDGET_POLICY: Dict[str, Any] = {
|
|
|
118
119
|
"hard_cycle_token_cap": 12000,
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929"
|
|
123
|
+
DEFAULT_OPENAI_MODEL = "gpt-4.1-mini"
|
|
124
|
+
DEFAULT_OPENROUTER_MODEL = "openrouter/auto"
|
|
125
|
+
SUPPORTED_REASONING_PROVIDERS = {"anthropic", "openai", "openrouter"}
|
|
126
|
+
|
|
121
127
|
|
|
122
128
|
# ---------------------------------------------------------------------------
|
|
123
129
|
# Config
|
|
@@ -148,6 +154,181 @@ def load_config(path: Path = CONFIG_PATH) -> Dict[str, Any]:
|
|
|
148
154
|
return config
|
|
149
155
|
|
|
150
156
|
|
|
157
|
+
def _resolve_reasoning_provider(config: Dict[str, Any]) -> str:
|
|
158
|
+
"""Resolve reasoning provider from env override or config."""
|
|
159
|
+
override = os.environ.get("GAIA_REASONING_PROVIDER", "").strip().lower()
|
|
160
|
+
if override:
|
|
161
|
+
if override in SUPPORTED_REASONING_PROVIDERS:
|
|
162
|
+
return override
|
|
163
|
+
log.warning(
|
|
164
|
+
"Unsupported GAIA_REASONING_PROVIDER=%s; falling back to config/default",
|
|
165
|
+
override,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
reasoning_cfg = config.get("reasoning", {})
|
|
169
|
+
if not isinstance(reasoning_cfg, dict):
|
|
170
|
+
reasoning_cfg = {}
|
|
171
|
+
configured = str(reasoning_cfg.get("provider", "anthropic")).strip().lower()
|
|
172
|
+
if configured in SUPPORTED_REASONING_PROVIDERS:
|
|
173
|
+
return configured
|
|
174
|
+
return "anthropic"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_reasoning_model(config: Dict[str, Any], provider: str) -> str:
|
|
178
|
+
"""Resolve reasoning model from env overrides and config."""
|
|
179
|
+
direct_override = os.environ.get("GAIA_REASONING_MODEL", "").strip()
|
|
180
|
+
if direct_override:
|
|
181
|
+
return direct_override
|
|
182
|
+
|
|
183
|
+
reasoning = config.get("reasoning", {})
|
|
184
|
+
reasoning = reasoning if isinstance(reasoning, dict) else {}
|
|
185
|
+
models_cfg = reasoning.get("models", {})
|
|
186
|
+
models_cfg = models_cfg if isinstance(models_cfg, dict) else {}
|
|
187
|
+
|
|
188
|
+
if provider == "openai":
|
|
189
|
+
env_model = os.environ.get("OPENAI_MODEL", "").strip()
|
|
190
|
+
if env_model:
|
|
191
|
+
return env_model
|
|
192
|
+
|
|
193
|
+
configured_openai_model = str(models_cfg.get("openai", "")).strip()
|
|
194
|
+
if configured_openai_model:
|
|
195
|
+
return configured_openai_model
|
|
196
|
+
|
|
197
|
+
openai_cfg = reasoning.get("openai", {})
|
|
198
|
+
if isinstance(openai_cfg, dict):
|
|
199
|
+
openai_model = str(openai_cfg.get("model", "")).strip()
|
|
200
|
+
if openai_model:
|
|
201
|
+
return openai_model
|
|
202
|
+
|
|
203
|
+
legacy_model = str(reasoning.get("model", "")).strip()
|
|
204
|
+
if legacy_model and legacy_model != DEFAULT_ANTHROPIC_MODEL:
|
|
205
|
+
return legacy_model
|
|
206
|
+
|
|
207
|
+
return DEFAULT_OPENAI_MODEL
|
|
208
|
+
|
|
209
|
+
if provider == "openrouter":
|
|
210
|
+
env_model = os.environ.get("OPENROUTER_MODEL", "").strip()
|
|
211
|
+
if env_model:
|
|
212
|
+
return env_model
|
|
213
|
+
|
|
214
|
+
configured_openrouter_model = str(models_cfg.get("openrouter", "")).strip()
|
|
215
|
+
if configured_openrouter_model:
|
|
216
|
+
return configured_openrouter_model
|
|
217
|
+
|
|
218
|
+
openrouter_cfg = reasoning.get("openrouter", {})
|
|
219
|
+
if isinstance(openrouter_cfg, dict):
|
|
220
|
+
openrouter_model = str(openrouter_cfg.get("model", "")).strip()
|
|
221
|
+
if openrouter_model:
|
|
222
|
+
return openrouter_model
|
|
223
|
+
|
|
224
|
+
legacy_model = str(reasoning.get("model", "")).strip()
|
|
225
|
+
if legacy_model and legacy_model != DEFAULT_ANTHROPIC_MODEL:
|
|
226
|
+
return legacy_model
|
|
227
|
+
|
|
228
|
+
return DEFAULT_OPENROUTER_MODEL
|
|
229
|
+
|
|
230
|
+
env_model = os.environ.get("ANTHROPIC_MODEL", "").strip()
|
|
231
|
+
if env_model:
|
|
232
|
+
return env_model
|
|
233
|
+
|
|
234
|
+
configured_anthropic_model = str(models_cfg.get("anthropic", "")).strip()
|
|
235
|
+
if configured_anthropic_model:
|
|
236
|
+
return configured_anthropic_model
|
|
237
|
+
|
|
238
|
+
legacy_model = str(reasoning.get("model", "")).strip()
|
|
239
|
+
return legacy_model or DEFAULT_ANTHROPIC_MODEL
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _resolve_openai_runtime(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
243
|
+
"""Resolve OpenAI runtime settings from config and env."""
|
|
244
|
+
reasoning = config.get("reasoning", {})
|
|
245
|
+
reasoning = reasoning if isinstance(reasoning, dict) else {}
|
|
246
|
+
openai_cfg = reasoning.get("openai", {})
|
|
247
|
+
openai_cfg = openai_cfg if isinstance(openai_cfg, dict) else {}
|
|
248
|
+
|
|
249
|
+
base_url = str(
|
|
250
|
+
os.environ.get(
|
|
251
|
+
"OPENAI_BASE_URL",
|
|
252
|
+
openai_cfg.get("base_url", "https://api.openai.com/v1"),
|
|
253
|
+
)
|
|
254
|
+
).strip()
|
|
255
|
+
if not base_url:
|
|
256
|
+
base_url = "https://api.openai.com/v1"
|
|
257
|
+
base_url = base_url.rstrip("/")
|
|
258
|
+
|
|
259
|
+
timeout_raw = os.environ.get(
|
|
260
|
+
"OPENAI_TIMEOUT_SECONDS",
|
|
261
|
+
str(openai_cfg.get("timeout_seconds", 120)),
|
|
262
|
+
)
|
|
263
|
+
try:
|
|
264
|
+
timeout_seconds = int(str(timeout_raw).strip())
|
|
265
|
+
except ValueError:
|
|
266
|
+
timeout_seconds = 120
|
|
267
|
+
if timeout_seconds < 1:
|
|
268
|
+
timeout_seconds = 120
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
"api_key": os.environ.get("OPENAI_API_KEY", "").strip(),
|
|
272
|
+
"base_url": base_url,
|
|
273
|
+
"timeout_seconds": timeout_seconds,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _resolve_openrouter_runtime(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
278
|
+
"""Resolve OpenRouter runtime settings from config and env."""
|
|
279
|
+
reasoning = config.get("reasoning", {})
|
|
280
|
+
reasoning = reasoning if isinstance(reasoning, dict) else {}
|
|
281
|
+
openrouter_cfg = reasoning.get("openrouter", {})
|
|
282
|
+
openrouter_cfg = openrouter_cfg if isinstance(openrouter_cfg, dict) else {}
|
|
283
|
+
|
|
284
|
+
base_url = str(
|
|
285
|
+
os.environ.get(
|
|
286
|
+
"OPENROUTER_BASE_URL",
|
|
287
|
+
openrouter_cfg.get("base_url", "https://openrouter.ai/api/v1"),
|
|
288
|
+
)
|
|
289
|
+
).strip()
|
|
290
|
+
if not base_url:
|
|
291
|
+
base_url = "https://openrouter.ai/api/v1"
|
|
292
|
+
base_url = base_url.rstrip("/")
|
|
293
|
+
|
|
294
|
+
app_name = str(
|
|
295
|
+
os.environ.get(
|
|
296
|
+
"OPENROUTER_APP_NAME",
|
|
297
|
+
openrouter_cfg.get("app_name", "gaia-minds-agent"),
|
|
298
|
+
)
|
|
299
|
+
).strip()
|
|
300
|
+
if not app_name:
|
|
301
|
+
app_name = "gaia-minds-agent"
|
|
302
|
+
|
|
303
|
+
app_url = str(
|
|
304
|
+
os.environ.get(
|
|
305
|
+
"OPENROUTER_APP_URL",
|
|
306
|
+
openrouter_cfg.get("app_url", "https://github.com/Gaia-minds/gaia-minds"),
|
|
307
|
+
)
|
|
308
|
+
).strip()
|
|
309
|
+
if not app_url:
|
|
310
|
+
app_url = "https://github.com/Gaia-minds/gaia-minds"
|
|
311
|
+
|
|
312
|
+
timeout_raw = os.environ.get(
|
|
313
|
+
"OPENROUTER_TIMEOUT_SECONDS",
|
|
314
|
+
str(openrouter_cfg.get("timeout_seconds", 120)),
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
timeout_seconds = int(str(timeout_raw).strip())
|
|
318
|
+
except ValueError:
|
|
319
|
+
timeout_seconds = 120
|
|
320
|
+
if timeout_seconds < 1:
|
|
321
|
+
timeout_seconds = 120
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
"api_key": os.environ.get("OPENROUTER_API_KEY", "").strip(),
|
|
325
|
+
"base_url": base_url,
|
|
326
|
+
"app_name": app_name,
|
|
327
|
+
"app_url": app_url,
|
|
328
|
+
"timeout_seconds": timeout_seconds,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
151
332
|
# ---------------------------------------------------------------------------
|
|
152
333
|
# Memory
|
|
153
334
|
# ---------------------------------------------------------------------------
|
|
@@ -390,7 +571,7 @@ def action_allowed_in_track(action_type: str, active_track: str, config: Dict[st
|
|
|
390
571
|
|
|
391
572
|
|
|
392
573
|
# ---------------------------------------------------------------------------
|
|
393
|
-
#
|
|
574
|
+
# Reasoning
|
|
394
575
|
# ---------------------------------------------------------------------------
|
|
395
576
|
|
|
396
577
|
SYSTEM_PROMPT_TEMPLATE = """\
|
|
@@ -482,19 +663,168 @@ def state_to_summary(state: RepoState) -> Dict[str, Any]:
|
|
|
482
663
|
}
|
|
483
664
|
|
|
484
665
|
|
|
485
|
-
def
|
|
666
|
+
def _strip_markdown_fences(text: str) -> str:
|
|
667
|
+
"""Remove markdown code fences from LLM text responses."""
|
|
668
|
+
out = text.strip()
|
|
669
|
+
if out.startswith("```"):
|
|
670
|
+
lines = out.splitlines()
|
|
671
|
+
lines = [line for line in lines if not line.strip().startswith("```")]
|
|
672
|
+
out = "\n".join(lines)
|
|
673
|
+
return out.strip()
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _extract_anthropic_text(response: Any) -> str:
|
|
677
|
+
"""Extract plain text from an Anthropic response object."""
|
|
678
|
+
text = ""
|
|
679
|
+
for block in response.content:
|
|
680
|
+
if hasattr(block, "text"):
|
|
681
|
+
text += block.text
|
|
682
|
+
return text
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _extract_chat_completion_text(payload: Dict[str, Any], provider_name: str) -> str:
|
|
686
|
+
"""Extract assistant message text from chat completion payload."""
|
|
687
|
+
choices = payload.get("choices", [])
|
|
688
|
+
if not isinstance(choices, list) or not choices:
|
|
689
|
+
raise ValueError(f"{provider_name} response missing choices")
|
|
690
|
+
message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {}
|
|
691
|
+
if not isinstance(message, dict):
|
|
692
|
+
raise ValueError(f"{provider_name} response has invalid message payload")
|
|
693
|
+
content = message.get("content", "")
|
|
694
|
+
if isinstance(content, str):
|
|
695
|
+
return content
|
|
696
|
+
if isinstance(content, list):
|
|
697
|
+
parts: List[str] = []
|
|
698
|
+
for item in content:
|
|
699
|
+
if isinstance(item, dict):
|
|
700
|
+
text_value = item.get("text")
|
|
701
|
+
if isinstance(text_value, str):
|
|
702
|
+
parts.append(text_value)
|
|
703
|
+
return "".join(parts)
|
|
704
|
+
raise ValueError(f"{provider_name} response content is not text")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _openai_chat_completion(
|
|
708
|
+
client: Dict[str, Any],
|
|
709
|
+
model: str,
|
|
710
|
+
system_prompt: str,
|
|
711
|
+
user_prompt: str,
|
|
712
|
+
max_tokens: int,
|
|
713
|
+
temperature: float,
|
|
714
|
+
) -> str:
|
|
715
|
+
"""Call OpenAI chat completions API and return text response."""
|
|
716
|
+
endpoint = f"{client['base_url']}/chat/completions"
|
|
717
|
+
payload = {
|
|
718
|
+
"model": model,
|
|
719
|
+
"messages": [
|
|
720
|
+
{"role": "system", "content": system_prompt},
|
|
721
|
+
{"role": "user", "content": user_prompt},
|
|
722
|
+
],
|
|
723
|
+
"max_tokens": max_tokens,
|
|
724
|
+
"temperature": temperature,
|
|
725
|
+
}
|
|
726
|
+
headers = {
|
|
727
|
+
"Authorization": f"Bearer {client['api_key']}",
|
|
728
|
+
"Content-Type": "application/json",
|
|
729
|
+
}
|
|
730
|
+
request = urllib.request.Request(
|
|
731
|
+
endpoint,
|
|
732
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
733
|
+
headers=headers,
|
|
734
|
+
method="POST",
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
with urllib.request.urlopen(request, timeout=int(client["timeout_seconds"])) as response:
|
|
739
|
+
raw = response.read().decode("utf-8")
|
|
740
|
+
except urllib.error.HTTPError as exc:
|
|
741
|
+
details = exc.read().decode("utf-8", errors="replace")
|
|
742
|
+
raise RuntimeError(f"OpenAI API returned HTTP {exc.code}: {details[:400]}") from exc
|
|
743
|
+
except urllib.error.URLError as exc:
|
|
744
|
+
raise RuntimeError(f"OpenAI request failed: {exc}") from exc
|
|
745
|
+
|
|
746
|
+
try:
|
|
747
|
+
parsed = json.loads(raw)
|
|
748
|
+
except json.JSONDecodeError as exc:
|
|
749
|
+
raise RuntimeError("OpenAI API returned non-JSON response") from exc
|
|
750
|
+
|
|
751
|
+
return _extract_chat_completion_text(parsed, "OpenAI")
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _openrouter_chat_completion(
|
|
755
|
+
client: Dict[str, Any],
|
|
756
|
+
model: str,
|
|
757
|
+
system_prompt: str,
|
|
758
|
+
user_prompt: str,
|
|
759
|
+
max_tokens: int,
|
|
760
|
+
temperature: float,
|
|
761
|
+
) -> str:
|
|
762
|
+
"""Call OpenRouter chat completions API and return text response."""
|
|
763
|
+
endpoint = f"{client['base_url']}/chat/completions"
|
|
764
|
+
payload = {
|
|
765
|
+
"model": model,
|
|
766
|
+
"messages": [
|
|
767
|
+
{"role": "system", "content": system_prompt},
|
|
768
|
+
{"role": "user", "content": user_prompt},
|
|
769
|
+
],
|
|
770
|
+
"max_tokens": max_tokens,
|
|
771
|
+
"temperature": temperature,
|
|
772
|
+
}
|
|
773
|
+
headers = {
|
|
774
|
+
"Authorization": f"Bearer {client['api_key']}",
|
|
775
|
+
"Content-Type": "application/json",
|
|
776
|
+
"HTTP-Referer": client["app_url"],
|
|
777
|
+
"X-Title": client["app_name"],
|
|
778
|
+
}
|
|
779
|
+
request = urllib.request.Request(
|
|
780
|
+
endpoint,
|
|
781
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
782
|
+
headers=headers,
|
|
783
|
+
method="POST",
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
try:
|
|
787
|
+
with urllib.request.urlopen(request, timeout=int(client["timeout_seconds"])) as response:
|
|
788
|
+
raw = response.read().decode("utf-8")
|
|
789
|
+
except urllib.error.HTTPError as exc:
|
|
790
|
+
details = exc.read().decode("utf-8", errors="replace")
|
|
791
|
+
raise RuntimeError(f"OpenRouter API returned HTTP {exc.code}: {details[:400]}") from exc
|
|
792
|
+
except urllib.error.URLError as exc:
|
|
793
|
+
raise RuntimeError(f"OpenRouter request failed: {exc}") from exc
|
|
794
|
+
|
|
795
|
+
try:
|
|
796
|
+
parsed = json.loads(raw)
|
|
797
|
+
except json.JSONDecodeError as exc:
|
|
798
|
+
raise RuntimeError("OpenRouter API returned non-JSON response") from exc
|
|
799
|
+
|
|
800
|
+
return _extract_chat_completion_text(parsed, "OpenRouter")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def ask_model_for_plan(
|
|
486
804
|
client: Any,
|
|
487
805
|
config: Dict[str, Any],
|
|
488
806
|
state: RepoState,
|
|
489
807
|
memory: Dict[str, Any],
|
|
490
808
|
constitution: str,
|
|
491
809
|
active_track: str,
|
|
810
|
+
provider: str,
|
|
811
|
+
model: str,
|
|
492
812
|
) -> Dict[str, Any]:
|
|
493
|
-
"""Ask
|
|
813
|
+
"""Ask the configured reasoning provider to analyze state and propose actions."""
|
|
494
814
|
reasoning_config = config.get("reasoning", {})
|
|
495
|
-
|
|
815
|
+
reasoning_config = reasoning_config if isinstance(reasoning_config, dict) else {}
|
|
496
816
|
max_tokens = reasoning_config.get("max_tokens", 4096)
|
|
497
817
|
temperature = reasoning_config.get("temperature", 0.3)
|
|
818
|
+
try:
|
|
819
|
+
max_tokens_int = int(max_tokens)
|
|
820
|
+
except (TypeError, ValueError):
|
|
821
|
+
max_tokens_int = 4096
|
|
822
|
+
if max_tokens_int < 1:
|
|
823
|
+
max_tokens_int = 4096
|
|
824
|
+
try:
|
|
825
|
+
temperature_float = float(temperature)
|
|
826
|
+
except (TypeError, ValueError):
|
|
827
|
+
temperature_float = 0.3
|
|
498
828
|
|
|
499
829
|
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(constitution=constitution)
|
|
500
830
|
|
|
@@ -513,42 +843,62 @@ def ask_claude_for_plan(
|
|
|
513
843
|
budget_policy_json=json.dumps(budget_policy, indent=2),
|
|
514
844
|
)
|
|
515
845
|
|
|
516
|
-
log.info("Asking
|
|
846
|
+
log.info("Asking reasoning provider '%s' (%s) for a plan...", provider, model)
|
|
517
847
|
log.debug("System prompt: %d chars, User prompt: %d chars", len(system_prompt), len(user_prompt))
|
|
518
848
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
849
|
+
text: str
|
|
850
|
+
try:
|
|
851
|
+
if provider == "anthropic":
|
|
852
|
+
response = client.messages.create(
|
|
853
|
+
model=model,
|
|
854
|
+
max_tokens=max_tokens_int,
|
|
855
|
+
temperature=temperature_float,
|
|
856
|
+
system=system_prompt,
|
|
857
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
858
|
+
)
|
|
859
|
+
text = _extract_anthropic_text(response)
|
|
860
|
+
elif provider == "openai":
|
|
861
|
+
if not isinstance(client, dict):
|
|
862
|
+
raise RuntimeError("OpenAI client is misconfigured")
|
|
863
|
+
text = _openai_chat_completion(
|
|
864
|
+
client=client,
|
|
865
|
+
model=model,
|
|
866
|
+
system_prompt=system_prompt,
|
|
867
|
+
user_prompt=user_prompt,
|
|
868
|
+
max_tokens=max_tokens_int,
|
|
869
|
+
temperature=temperature_float,
|
|
870
|
+
)
|
|
871
|
+
elif provider == "openrouter":
|
|
872
|
+
if not isinstance(client, dict):
|
|
873
|
+
raise RuntimeError("OpenRouter client is misconfigured")
|
|
874
|
+
text = _openrouter_chat_completion(
|
|
875
|
+
client=client,
|
|
876
|
+
model=model,
|
|
877
|
+
system_prompt=system_prompt,
|
|
878
|
+
user_prompt=user_prompt,
|
|
879
|
+
max_tokens=max_tokens_int,
|
|
880
|
+
temperature=temperature_float,
|
|
881
|
+
)
|
|
882
|
+
else:
|
|
883
|
+
raise RuntimeError(f"Unsupported reasoning provider: {provider}")
|
|
884
|
+
except Exception as exc:
|
|
885
|
+
log.error("Reasoning request failed (%s/%s): %s", provider, model, exc)
|
|
886
|
+
return {"reasoning": f"Reasoning request failed: {exc}", "actions": []}
|
|
532
887
|
|
|
533
|
-
# Parse JSON from response
|
|
534
|
-
|
|
535
|
-
text = text.strip()
|
|
536
|
-
if text.startswith("```"):
|
|
537
|
-
# Remove code fences
|
|
538
|
-
lines = text.splitlines()
|
|
539
|
-
lines = [l for l in lines if not l.strip().startswith("```")]
|
|
540
|
-
text = "\n".join(lines)
|
|
888
|
+
# Parse JSON from response text.
|
|
889
|
+
text = _strip_markdown_fences(text)
|
|
541
890
|
|
|
542
891
|
try:
|
|
543
892
|
plan = json.loads(text)
|
|
544
893
|
except json.JSONDecodeError as exc:
|
|
545
|
-
log.error("Failed to parse
|
|
894
|
+
log.error("Failed to parse reasoning response as JSON: %s", exc)
|
|
546
895
|
log.error("Raw response: %s", text[:500])
|
|
547
896
|
plan = {"reasoning": f"Failed to parse response: {exc}", "actions": []}
|
|
548
897
|
|
|
549
898
|
actions = plan.get("actions", [])
|
|
550
899
|
log.info(
|
|
551
|
-
"
|
|
900
|
+
"Reasoning provider '%s' proposed %d action(s): %s",
|
|
901
|
+
provider,
|
|
552
902
|
len(actions),
|
|
553
903
|
", ".join(a.get("type", "?") for a in actions) or "(none)",
|
|
554
904
|
)
|
|
@@ -566,6 +916,8 @@ def run_cycle(
|
|
|
566
916
|
config: Dict[str, Any],
|
|
567
917
|
client: Any,
|
|
568
918
|
cycle_number: int,
|
|
919
|
+
reasoning_provider: str,
|
|
920
|
+
reasoning_model: str,
|
|
569
921
|
dry_run: bool = False,
|
|
570
922
|
) -> List[ActionResult]:
|
|
571
923
|
"""Run one complete agent cycle."""
|
|
@@ -590,12 +942,30 @@ def run_cycle(
|
|
|
590
942
|
# 3. Load constitution
|
|
591
943
|
constitution = load_constitution(repo_root)
|
|
592
944
|
|
|
593
|
-
# 4. Ask
|
|
945
|
+
# 4. Ask reasoning provider for a plan
|
|
594
946
|
if client is None:
|
|
595
|
-
log.info(
|
|
596
|
-
|
|
947
|
+
log.info(
|
|
948
|
+
"No API client available for provider '%s' -- skipping reasoning (dry-run)",
|
|
949
|
+
reasoning_provider,
|
|
950
|
+
)
|
|
951
|
+
plan = {
|
|
952
|
+
"reasoning": (
|
|
953
|
+
f"No API client (dry-run without provider credentials), "
|
|
954
|
+
f"provider={reasoning_provider}, active_track={active_track}"
|
|
955
|
+
),
|
|
956
|
+
"actions": [],
|
|
957
|
+
}
|
|
597
958
|
else:
|
|
598
|
-
plan =
|
|
959
|
+
plan = ask_model_for_plan(
|
|
960
|
+
client=client,
|
|
961
|
+
config=config,
|
|
962
|
+
state=state,
|
|
963
|
+
memory=memory,
|
|
964
|
+
constitution=constitution,
|
|
965
|
+
active_track=active_track,
|
|
966
|
+
provider=reasoning_provider,
|
|
967
|
+
model=reasoning_model,
|
|
968
|
+
)
|
|
599
969
|
|
|
600
970
|
actions = plan.get("actions", [])
|
|
601
971
|
if not actions:
|
|
@@ -632,8 +1002,8 @@ def run_cycle(
|
|
|
632
1002
|
action,
|
|
633
1003
|
constitution,
|
|
634
1004
|
json.dumps(memory.get("recent_decisions", [])[-5:]),
|
|
635
|
-
client=client if _HAS_ANTHROPIC else None,
|
|
636
|
-
model=
|
|
1005
|
+
client=client if reasoning_provider == "anthropic" and _HAS_ANTHROPIC else None,
|
|
1006
|
+
model=reasoning_model,
|
|
637
1007
|
)
|
|
638
1008
|
|
|
639
1009
|
log.info(
|
|
@@ -817,23 +1187,6 @@ def main() -> int:
|
|
|
817
1187
|
level = logging.DEBUG if args.verbose else logging.INFO
|
|
818
1188
|
logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, stream=sys.stderr)
|
|
819
1189
|
|
|
820
|
-
# Check for anthropic SDK (not needed in dry-run)
|
|
821
|
-
if not _HAS_ANTHROPIC and not args.dry_run:
|
|
822
|
-
log.error(
|
|
823
|
-
"The 'anthropic' package is required. Install it:\n"
|
|
824
|
-
" pip install anthropic\n"
|
|
825
|
-
"Or: pip install -r requirements.txt"
|
|
826
|
-
)
|
|
827
|
-
return 1
|
|
828
|
-
|
|
829
|
-
# Check for API key (not needed in dry-run)
|
|
830
|
-
if not os.environ.get("ANTHROPIC_API_KEY") and not args.dry_run:
|
|
831
|
-
log.error(
|
|
832
|
-
"ANTHROPIC_API_KEY environment variable is not set.\n"
|
|
833
|
-
" export ANTHROPIC_API_KEY='your-key-here'"
|
|
834
|
-
)
|
|
835
|
-
return 1
|
|
836
|
-
|
|
837
1190
|
# Load config
|
|
838
1191
|
config_path = Path(args.config)
|
|
839
1192
|
if not config_path.exists():
|
|
@@ -841,18 +1194,89 @@ def main() -> int:
|
|
|
841
1194
|
return 1
|
|
842
1195
|
|
|
843
1196
|
config = load_config(config_path)
|
|
1197
|
+
if not isinstance(config, dict):
|
|
1198
|
+
log.error("Config file is invalid: expected a YAML mapping at %s", config_path)
|
|
1199
|
+
return 1
|
|
1200
|
+
|
|
1201
|
+
reasoning_provider = _resolve_reasoning_provider(config)
|
|
1202
|
+
reasoning_model = _resolve_reasoning_model(config, reasoning_provider)
|
|
1203
|
+
openai_runtime: Optional[Dict[str, Any]] = None
|
|
1204
|
+
openrouter_runtime: Optional[Dict[str, Any]] = None
|
|
1205
|
+
if reasoning_provider == "openai":
|
|
1206
|
+
openai_runtime = _resolve_openai_runtime(config)
|
|
1207
|
+
if reasoning_provider == "openrouter":
|
|
1208
|
+
openrouter_runtime = _resolve_openrouter_runtime(config)
|
|
1209
|
+
log.info("Reasoning provider selected: %s (model: %s)", reasoning_provider, reasoning_model)
|
|
1210
|
+
|
|
1211
|
+
# Validate provider runtime requirements (except in dry-run).
|
|
1212
|
+
if not args.dry_run:
|
|
1213
|
+
if reasoning_provider == "anthropic":
|
|
1214
|
+
if not _HAS_ANTHROPIC:
|
|
1215
|
+
log.error(
|
|
1216
|
+
"Reasoning provider is 'anthropic' but the 'anthropic' package is missing.\n"
|
|
1217
|
+
"Install it:\n"
|
|
1218
|
+
" pip install anthropic\n"
|
|
1219
|
+
"Or: pip install -r requirements.txt"
|
|
1220
|
+
)
|
|
1221
|
+
return 1
|
|
1222
|
+
if not os.environ.get("ANTHROPIC_API_KEY", "").strip():
|
|
1223
|
+
log.error(
|
|
1224
|
+
"Reasoning provider is 'anthropic' but ANTHROPIC_API_KEY is not set.\n"
|
|
1225
|
+
" export ANTHROPIC_API_KEY='your-key-here'"
|
|
1226
|
+
)
|
|
1227
|
+
return 1
|
|
1228
|
+
elif reasoning_provider == "openrouter":
|
|
1229
|
+
assert openrouter_runtime is not None
|
|
1230
|
+
if not openrouter_runtime.get("api_key"):
|
|
1231
|
+
log.error(
|
|
1232
|
+
"Reasoning provider is 'openrouter' but OPENROUTER_API_KEY is not set.\n"
|
|
1233
|
+
" export OPENROUTER_API_KEY='your-key-here'"
|
|
1234
|
+
)
|
|
1235
|
+
return 1
|
|
1236
|
+
elif reasoning_provider == "openai":
|
|
1237
|
+
assert openai_runtime is not None
|
|
1238
|
+
if not openai_runtime.get("api_key"):
|
|
1239
|
+
log.error(
|
|
1240
|
+
"Reasoning provider is 'openai' but OPENAI_API_KEY is not set.\n"
|
|
1241
|
+
" export OPENAI_API_KEY='your-key-here'"
|
|
1242
|
+
)
|
|
1243
|
+
return 1
|
|
1244
|
+
else:
|
|
1245
|
+
log.error(
|
|
1246
|
+
"Unsupported reasoning provider '%s'. Supported: anthropic, openai, openrouter",
|
|
1247
|
+
reasoning_provider,
|
|
1248
|
+
)
|
|
1249
|
+
return 1
|
|
1250
|
+
|
|
844
1251
|
log.info("Loaded config: %s v%s", config.get("agent", {}).get("name", "?"), config.get("agent", {}).get("version", "?"))
|
|
845
1252
|
|
|
846
1253
|
# Determine mode
|
|
847
1254
|
mode = args.mode or config.get("cycle", {}).get("mode", "single")
|
|
848
1255
|
|
|
849
|
-
# Initialize
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1256
|
+
# Initialize provider client.
|
|
1257
|
+
client: Any = None
|
|
1258
|
+
if reasoning_provider == "anthropic" and _HAS_ANTHROPIC and os.environ.get("ANTHROPIC_API_KEY", "").strip():
|
|
1259
|
+
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", "").strip())
|
|
1260
|
+
log.info("Anthropic client initialized (model: %s)", reasoning_model)
|
|
1261
|
+
elif reasoning_provider == "openai" and openai_runtime and openai_runtime.get("api_key"):
|
|
1262
|
+
client = openai_runtime
|
|
1263
|
+
log.info(
|
|
1264
|
+
"OpenAI client initialized (model: %s, base_url: %s)",
|
|
1265
|
+
reasoning_model,
|
|
1266
|
+
openai_runtime.get("base_url"),
|
|
1267
|
+
)
|
|
1268
|
+
elif reasoning_provider == "openrouter" and openrouter_runtime and openrouter_runtime.get("api_key"):
|
|
1269
|
+
client = openrouter_runtime
|
|
1270
|
+
log.info(
|
|
1271
|
+
"OpenRouter client initialized (model: %s, base_url: %s)",
|
|
1272
|
+
reasoning_model,
|
|
1273
|
+
openrouter_runtime.get("base_url"),
|
|
1274
|
+
)
|
|
853
1275
|
else:
|
|
854
|
-
|
|
855
|
-
|
|
1276
|
+
log.info(
|
|
1277
|
+
"No provider client initialized (provider=%s, likely dry-run or missing credentials)",
|
|
1278
|
+
reasoning_provider,
|
|
1279
|
+
)
|
|
856
1280
|
|
|
857
1281
|
# Ensure memory directory exists
|
|
858
1282
|
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -873,7 +1297,14 @@ def main() -> int:
|
|
|
873
1297
|
log.info("Running single cycle (#%d)...", cycle_number)
|
|
874
1298
|
# Check PR feedback before planning
|
|
875
1299
|
check_pr_feedback(config, cycle_number)
|
|
876
|
-
results = run_cycle(
|
|
1300
|
+
results = run_cycle(
|
|
1301
|
+
config=config,
|
|
1302
|
+
client=client,
|
|
1303
|
+
cycle_number=cycle_number,
|
|
1304
|
+
reasoning_provider=reasoning_provider,
|
|
1305
|
+
reasoning_model=reasoning_model,
|
|
1306
|
+
dry_run=args.dry_run,
|
|
1307
|
+
)
|
|
877
1308
|
succeeded = sum(1 for r in results if r.success)
|
|
878
1309
|
failed = sum(1 for r in results if not r.success)
|
|
879
1310
|
log.info("Cycle %d complete: %d succeeded, %d failed", cycle_number, succeeded, failed)
|
|
@@ -888,7 +1319,14 @@ def main() -> int:
|
|
|
888
1319
|
cycles_run = 0
|
|
889
1320
|
while True:
|
|
890
1321
|
check_pr_feedback(config, cycle_number)
|
|
891
|
-
results = run_cycle(
|
|
1322
|
+
results = run_cycle(
|
|
1323
|
+
config=config,
|
|
1324
|
+
client=client,
|
|
1325
|
+
cycle_number=cycle_number,
|
|
1326
|
+
reasoning_provider=reasoning_provider,
|
|
1327
|
+
reasoning_model=reasoning_model,
|
|
1328
|
+
dry_run=args.dry_run,
|
|
1329
|
+
)
|
|
892
1330
|
succeeded = sum(1 for r in results if r.success)
|
|
893
1331
|
failed = sum(1 for r in results if not r.success)
|
|
894
1332
|
log.info("Cycle %d complete: %d succeeded, %d failed", cycle_number, succeeded, failed)
|