agentram 0.1.20 → 0.1.29

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,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 re
8
- import shlex
9
- import subprocess
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 = os.getenv(endpoint.api_key_env) if endpoint.api_key_env else ""
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
- streamed = parse_openai_sse(raw)
169
- if streamed:
170
- return streamed
171
- raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
172
- return str(data.get("choices", [{}])[0].get("message", {}).get("content", ""))
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 = os.getenv(endpoint.api_key_env) if endpoint.api_key_env else ""
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 = shlex.split(command)
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
- "Return concise coding-agent output only.",
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
- process = subprocess.run(
209
- [*args, "-p", subagent_prompt],
210
- check=False,
211
- capture_output=True,
212
- encoding="utf-8",
213
- timeout=300,
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 parse_openai_sse(raw: str) -> str:
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 strip_thinking_blocks(text: str) -> str:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentram",
3
- "version": "0.1.20",
3
+ "version": "0.1.29",
4
4
  "description": "Async, model-agnostic Working RAM for coding agents.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/trancongnghia/AGENT_RAM#readme",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentram"
7
- version = "0.1.20"
7
+ version = "0.1.29"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"