agentram 0.1.21 → 0.1.30
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 +159 -146
- package/agentram/cli.py +447 -91
- package/agentram/orchestration.py +159 -28
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
import json
|
|
6
|
-
import os
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
10
12
|
import urllib.error
|
|
11
13
|
import urllib.request
|
|
12
14
|
from typing import Any, Callable
|
|
@@ -30,6 +32,12 @@ PROVIDER_ALIASES = {
|
|
|
30
32
|
"claudesubagent": "claude-subagent",
|
|
31
33
|
"claude_subagent": "claude-subagent",
|
|
32
34
|
"claude-subagent": "claude-subagent",
|
|
35
|
+
"codex": "codex-cli",
|
|
36
|
+
"codexcli": "codex-cli",
|
|
37
|
+
"codex_cli": "codex-cli",
|
|
38
|
+
"codex-code": "codex-cli",
|
|
39
|
+
"codex_cli_agent": "codex-cli",
|
|
40
|
+
"codex-cli": "codex-cli",
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
|
|
@@ -38,6 +46,38 @@ def normalize_provider_name(provider: str) -> str:
|
|
|
38
46
|
return PROVIDER_ALIASES.get(normalized, normalized)
|
|
39
47
|
|
|
40
48
|
|
|
49
|
+
def split_cli_command(command: str) -> list[str]:
|
|
50
|
+
cleaned = command.strip().strip('"')
|
|
51
|
+
if not cleaned:
|
|
52
|
+
return []
|
|
53
|
+
if os.name == "nt":
|
|
54
|
+
lowered = cleaned.lower()
|
|
55
|
+
for marker in (".cmd", ".exe", ".bat", ".ps1"):
|
|
56
|
+
index = lowered.find(marker)
|
|
57
|
+
if index != -1:
|
|
58
|
+
end = index + len(marker)
|
|
59
|
+
program = cleaned[:end]
|
|
60
|
+
rest = cleaned[end:].strip()
|
|
61
|
+
return [program, *shlex.split(rest)] if rest else [program]
|
|
62
|
+
return shlex.split(cleaned)
|
|
63
|
+
|
|
64
|
+
def env_value(name: str) -> str:
|
|
65
|
+
value = os.getenv(name, "")
|
|
66
|
+
if value:
|
|
67
|
+
return value
|
|
68
|
+
env_path = Path(".env")
|
|
69
|
+
if not env_path.exists():
|
|
70
|
+
return ""
|
|
71
|
+
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
72
|
+
stripped = line.strip()
|
|
73
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
74
|
+
continue
|
|
75
|
+
key, raw_value = stripped.split("=", 1)
|
|
76
|
+
if key.strip() == name:
|
|
77
|
+
return raw_value.strip().strip('"').strip("'")
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
|
|
41
81
|
@dataclass(frozen=True)
|
|
42
82
|
class AgentModelEndpoint:
|
|
43
83
|
provider: str
|
|
@@ -137,10 +177,12 @@ class MultiModelCodingOrchestrator:
|
|
|
137
177
|
return self._call_anthropic(endpoint, prompt)
|
|
138
178
|
if provider in {"claude-code", "claude-cli", "claude-subagent", "claude-code-subagent"}:
|
|
139
179
|
return self._call_claude_code_subagent(endpoint, prompt)
|
|
180
|
+
if provider in {"codex-cli"}:
|
|
181
|
+
return self._call_codex_cli_agent(endpoint, prompt)
|
|
140
182
|
raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
|
|
141
183
|
|
|
142
184
|
def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
143
|
-
api_key =
|
|
185
|
+
api_key = env_value(endpoint.api_key_env) if endpoint.api_key_env else ""
|
|
144
186
|
if not endpoint.base_url:
|
|
145
187
|
raise ValueError("base_url is required")
|
|
146
188
|
if endpoint.api_key_env and not api_key:
|
|
@@ -162,17 +204,20 @@ class MultiModelCodingOrchestrator:
|
|
|
162
204
|
if error.code == 404:
|
|
163
205
|
hint += " | base_url should include /v1 but not /chat/completions; verify provider URL and model name"
|
|
164
206
|
raise RuntimeError(f"{hint} | response={body[:500]}") from error
|
|
165
|
-
try:
|
|
166
|
-
data = json.loads(raw)
|
|
167
|
-
except json.JSONDecodeError as error:
|
|
168
|
-
|
|
169
|
-
if
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
|
|
207
|
+
try:
|
|
208
|
+
data = json.loads(raw)
|
|
209
|
+
except json.JSONDecodeError as error:
|
|
210
|
+
mixed_json = parse_openai_mixed_json(raw)
|
|
211
|
+
if mixed_json:
|
|
212
|
+
return extract_openai_message(mixed_json)
|
|
213
|
+
streamed = parse_openai_sse(raw)
|
|
214
|
+
if streamed:
|
|
215
|
+
return streamed
|
|
216
|
+
raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
|
|
217
|
+
return extract_openai_message(data)
|
|
173
218
|
|
|
174
219
|
def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
175
|
-
api_key =
|
|
220
|
+
api_key = env_value(endpoint.api_key_env) if endpoint.api_key_env else ""
|
|
176
221
|
if endpoint.api_key_env and not api_key:
|
|
177
222
|
raise ValueError(f"missing API key env: {endpoint.api_key_env}")
|
|
178
223
|
base_url = endpoint.base_url.rstrip("/") if endpoint.base_url else "https://api.anthropic.com/v1"
|
|
@@ -192,7 +237,7 @@ class MultiModelCodingOrchestrator:
|
|
|
192
237
|
|
|
193
238
|
def _call_claude_code_subagent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
194
239
|
command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CLAUDE_COMMAND", "claude")
|
|
195
|
-
args =
|
|
240
|
+
args = split_cli_command(command)
|
|
196
241
|
if not args:
|
|
197
242
|
raise ValueError("claude command is required")
|
|
198
243
|
subagent_prompt = "\n".join(
|
|
@@ -200,25 +245,81 @@ class MultiModelCodingOrchestrator:
|
|
|
200
245
|
f"Use Claude Code subagent entrypoint: {endpoint.model}",
|
|
201
246
|
f"Subagent role: {endpoint.role}",
|
|
202
247
|
"If that subagent exists in .claude/agents, follow it. If not, act as this role.",
|
|
203
|
-
"
|
|
248
|
+
"If role is coder/main, you may inspect and edit files using Claude Code tools. If role is memory/small, do not edit files; write notes only.",
|
|
249
|
+
"Return concise coding-agent output, changed files, and verification status.",
|
|
204
250
|
"",
|
|
205
251
|
prompt,
|
|
206
252
|
]
|
|
207
253
|
)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
254
|
+
try:
|
|
255
|
+
process = subprocess.run(
|
|
256
|
+
[*args, "-p", subagent_prompt],
|
|
257
|
+
check=False,
|
|
258
|
+
capture_output=True,
|
|
259
|
+
encoding="utf-8",
|
|
260
|
+
timeout=300,
|
|
261
|
+
)
|
|
262
|
+
except FileNotFoundError as error:
|
|
263
|
+
raise RuntimeError(f"Claude Code command not found: {args[0]}. Install Claude Code CLI or set base_url to the full command path.") from error
|
|
215
264
|
output = (process.stdout or "").strip()
|
|
216
265
|
error = (process.stderr or "").strip()
|
|
217
266
|
if process.returncode != 0:
|
|
218
267
|
raise RuntimeError(error or f"claude command failed with exit code {process.returncode}")
|
|
219
268
|
return output or error
|
|
220
269
|
|
|
221
|
-
def
|
|
270
|
+
def _call_codex_cli_agent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
271
|
+
command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CODEX_COMMAND", "codex exec")
|
|
272
|
+
args = split_cli_command(command)
|
|
273
|
+
if not args:
|
|
274
|
+
raise ValueError("codex command is required")
|
|
275
|
+
if len(args) == 1:
|
|
276
|
+
args.append("exec")
|
|
277
|
+
codex_prompt = "\n".join(
|
|
278
|
+
[
|
|
279
|
+
f"You are AgentRAM {endpoint.role} agent running through Codex CLI.",
|
|
280
|
+
f"Model hint: {endpoint.model}",
|
|
281
|
+
"If role is coder/main, inspect and edit files as needed in the current repo. If role is memory/small, do not edit files; write notes only.",
|
|
282
|
+
"Return concise result, changed files, tests/verification, and blockers.",
|
|
283
|
+
"",
|
|
284
|
+
prompt,
|
|
285
|
+
]
|
|
286
|
+
)
|
|
287
|
+
prompt_path = ""
|
|
288
|
+
try:
|
|
289
|
+
with tempfile.NamedTemporaryFile(
|
|
290
|
+
"w",
|
|
291
|
+
encoding="utf-8",
|
|
292
|
+
delete=False,
|
|
293
|
+
dir=os.getcwd(),
|
|
294
|
+
prefix=".agentram_codex_prompt_",
|
|
295
|
+
suffix=".txt",
|
|
296
|
+
) as prompt_file:
|
|
297
|
+
prompt_file.write(codex_prompt)
|
|
298
|
+
prompt_path = prompt_file.name
|
|
299
|
+
short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
|
|
300
|
+
process = subprocess.run(
|
|
301
|
+
[*args, short_prompt],
|
|
302
|
+
check=False,
|
|
303
|
+
capture_output=True,
|
|
304
|
+
encoding="utf-8",
|
|
305
|
+
timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")),
|
|
306
|
+
)
|
|
307
|
+
except FileNotFoundError as error:
|
|
308
|
+
raise RuntimeError(f"Codex CLI command not found: {args[0]}. Install Codex CLI or set base_url to the full command path.") from error
|
|
309
|
+
finally:
|
|
310
|
+
if prompt_path:
|
|
311
|
+
try:
|
|
312
|
+
os.remove(prompt_path)
|
|
313
|
+
except OSError:
|
|
314
|
+
pass
|
|
315
|
+
output = (process.stdout or "").strip()
|
|
316
|
+
error = (process.stderr or "").strip()
|
|
317
|
+
if process.returncode != 0:
|
|
318
|
+
raise RuntimeError(error or f"codex command failed with exit code {process.returncode}")
|
|
319
|
+
return output or error
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def parse_openai_sse(raw: str) -> str:
|
|
222
323
|
parts: list[str] = []
|
|
223
324
|
for line in raw.splitlines():
|
|
224
325
|
stripped = line.strip()
|
|
@@ -245,9 +346,39 @@ def parse_openai_sse(raw: str) -> str:
|
|
|
245
346
|
message = choice.get("message", {})
|
|
246
347
|
if isinstance(message, dict) and message.get("content"):
|
|
247
348
|
parts.append(str(message.get("content")))
|
|
248
|
-
return strip_thinking_blocks("".join(parts)).strip()
|
|
249
|
-
|
|
250
|
-
def
|
|
349
|
+
return strip_thinking_blocks("".join(parts)).strip()
|
|
350
|
+
|
|
351
|
+
def parse_openai_mixed_json(raw: str) -> dict[str, Any]:
|
|
352
|
+
stripped = raw.strip()
|
|
353
|
+
if not stripped.startswith("{"):
|
|
354
|
+
return {}
|
|
355
|
+
for marker in ("\ndata:", "\r\ndata:"):
|
|
356
|
+
if marker not in stripped:
|
|
357
|
+
continue
|
|
358
|
+
candidate = stripped.split(marker, 1)[0].strip()
|
|
359
|
+
try:
|
|
360
|
+
value = json.loads(candidate)
|
|
361
|
+
except json.JSONDecodeError:
|
|
362
|
+
return {}
|
|
363
|
+
return value if isinstance(value, dict) else {}
|
|
364
|
+
return {}
|
|
365
|
+
|
|
366
|
+
def extract_openai_message(data: dict[str, Any]) -> str:
|
|
367
|
+
choices = data.get("choices", [])
|
|
368
|
+
if not choices or not isinstance(choices, list):
|
|
369
|
+
return ""
|
|
370
|
+
first = choices[0]
|
|
371
|
+
if not isinstance(first, dict):
|
|
372
|
+
return ""
|
|
373
|
+
message = first.get("message", {})
|
|
374
|
+
if isinstance(message, dict):
|
|
375
|
+
return strip_thinking_blocks(str(message.get("content", ""))).strip()
|
|
376
|
+
delta = first.get("delta", {})
|
|
377
|
+
if isinstance(delta, dict):
|
|
378
|
+
return strip_thinking_blocks(str(delta.get("content", ""))).strip()
|
|
379
|
+
return ""
|
|
380
|
+
|
|
381
|
+
def strip_thinking_blocks(text: str) -> str:
|
|
251
382
|
text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
252
383
|
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
253
384
|
text = re.sub(r"^\s*</?(thinking|think)>\s*", "", text, flags=re.IGNORECASE)
|
package/package.json
CHANGED