agentram 0.1.43 → 0.1.47
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/agentram/cli.py +16 -22
- package/agentram/orchestration.py +118 -24
- package/agentram/storage.py +22 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/agentram/cli.py
CHANGED
|
@@ -31,33 +31,12 @@ def looks_like_raw_api_key(value: str) -> bool:
|
|
|
31
31
|
return not bool(ENV_NAME_PATTERN.fullmatch(stripped))
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def update_env_file_value(path: Path, key: str, value: str) -> None:
|
|
35
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
-
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
37
|
-
assignment = f"{key}={value}"
|
|
38
|
-
updated = False
|
|
39
|
-
output: list[str] = []
|
|
40
|
-
for line in lines:
|
|
41
|
-
if line.strip().startswith(f"{key}="):
|
|
42
|
-
output.append(assignment)
|
|
43
|
-
updated = True
|
|
44
|
-
else:
|
|
45
|
-
output.append(line)
|
|
46
|
-
if not updated:
|
|
47
|
-
if output and output[-1].strip():
|
|
48
|
-
output.append("")
|
|
49
|
-
output.append("# Added by AgentRAM TUI setup")
|
|
50
|
-
output.append(assignment)
|
|
51
|
-
path.write_text("\n".join(output) + "\n", encoding="utf-8")
|
|
52
|
-
|
|
53
|
-
|
|
54
34
|
def resolve_api_key_env(slot: str, value: str) -> tuple[str, str | None]:
|
|
55
35
|
stripped = value.strip()
|
|
56
36
|
if not stripped:
|
|
57
37
|
return "", None
|
|
58
38
|
if looks_like_raw_api_key(stripped):
|
|
59
39
|
env_name = f"AGENTRAM_{slot.upper()}_API_KEY"
|
|
60
|
-
update_env_file_value(Path(".env"), env_name, stripped)
|
|
61
40
|
os.environ[env_name] = stripped
|
|
62
41
|
return env_name, env_name
|
|
63
42
|
return stripped, None
|
|
@@ -614,7 +593,9 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
614
593
|
self.context.profile_store.set_agent(slot, endpoint)
|
|
615
594
|
set_global_agent_if_available(self.context, slot, endpoint)
|
|
616
595
|
if saved_env_name:
|
|
617
|
-
|
|
596
|
+
save_global_env_if_available(self.context, saved_env_name)
|
|
597
|
+
if saved_env_name:
|
|
598
|
+
return f"{slot}={provider}:{model} key=global-jsonl:{saved_env_name}"
|
|
618
599
|
return f"{slot}={provider}:{model}"
|
|
619
600
|
|
|
620
601
|
def action_save_setup(self) -> None:
|
|
@@ -1249,6 +1230,19 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
|
|
|
1249
1230
|
return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
|
|
1250
1231
|
|
|
1251
1232
|
|
|
1233
|
+
def save_global_env_if_available(context: McpContext, env_name: str) -> bool:
|
|
1234
|
+
if not env_name or not global_agent_profile_enabled():
|
|
1235
|
+
return False
|
|
1236
|
+
value = os.getenv(env_name, "")
|
|
1237
|
+
if not value:
|
|
1238
|
+
return False
|
|
1239
|
+
try:
|
|
1240
|
+
context.global_profile_store.set_env_value(env_name, value)
|
|
1241
|
+
return True
|
|
1242
|
+
except OSError:
|
|
1243
|
+
return False
|
|
1244
|
+
|
|
1245
|
+
|
|
1252
1246
|
def set_global_agent_if_available(context: McpContext, slot: str, endpoint: AgentModelEndpoint) -> bool:
|
|
1253
1247
|
if not global_agent_profile_enabled():
|
|
1254
1248
|
return False
|
|
@@ -8,12 +8,69 @@ from pathlib import Path
|
|
|
8
8
|
import re
|
|
9
9
|
import shlex
|
|
10
10
|
import subprocess
|
|
11
|
+
import sys
|
|
11
12
|
import tempfile
|
|
12
13
|
import urllib.error
|
|
13
14
|
import urllib.request
|
|
14
15
|
from typing import Any, Callable
|
|
15
16
|
from uuid import uuid4
|
|
16
17
|
|
|
18
|
+
from .config import global_agent_profile_enabled, global_agent_profile_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class CliProcessResult:
|
|
23
|
+
returncode: int
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
|
|
27
|
+
def cli_stream_output_enabled() -> bool:
|
|
28
|
+
value = os.getenv("AGENTRAM_STREAM_CLI_OUTPUT", "").strip().lower()
|
|
29
|
+
return value in {"1", "true", "yes", "on"}
|
|
30
|
+
|
|
31
|
+
def run_cli_process(args: list[str], timeout: int) -> CliProcessResult:
|
|
32
|
+
if not cli_stream_output_enabled():
|
|
33
|
+
process = subprocess.run(
|
|
34
|
+
args,
|
|
35
|
+
check=False,
|
|
36
|
+
stdin=subprocess.DEVNULL,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
encoding="utf-8",
|
|
39
|
+
timeout=timeout,
|
|
40
|
+
)
|
|
41
|
+
return CliProcessResult(process.returncode, process.stdout or "", process.stderr or "")
|
|
42
|
+
|
|
43
|
+
process = subprocess.Popen(
|
|
44
|
+
args,
|
|
45
|
+
stdin=subprocess.DEVNULL,
|
|
46
|
+
stdout=subprocess.PIPE,
|
|
47
|
+
stderr=subprocess.STDOUT,
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
errors="replace",
|
|
50
|
+
text=True,
|
|
51
|
+
)
|
|
52
|
+
lines: list[str] = []
|
|
53
|
+
try:
|
|
54
|
+
assert process.stdout is not None
|
|
55
|
+
for line in process.stdout:
|
|
56
|
+
lines.append(line)
|
|
57
|
+
print(line, end="", flush=True)
|
|
58
|
+
returncode = process.wait(timeout=timeout)
|
|
59
|
+
except subprocess.TimeoutExpired:
|
|
60
|
+
process.kill()
|
|
61
|
+
returncode = process.wait()
|
|
62
|
+
lines.append(f"\ncommand timed out after {timeout}s\n")
|
|
63
|
+
finally:
|
|
64
|
+
print("\033[2K\r", end="", flush=True)
|
|
65
|
+
return CliProcessResult(returncode, "".join(lines), "")
|
|
66
|
+
|
|
67
|
+
def add_codex_output_last_message(args: list[str], output_path: str) -> list[str]:
|
|
68
|
+
if "--output-last-message" in args or "-o" in args:
|
|
69
|
+
return args
|
|
70
|
+
if len(args) >= 2 and args[1] == "exec":
|
|
71
|
+
return [args[0], args[1], "--output-last-message", output_path, *args[2:]]
|
|
72
|
+
return args
|
|
73
|
+
|
|
17
74
|
|
|
18
75
|
PROVIDER_ALIASES = {
|
|
19
76
|
"openai compatible": "openai-compatible",
|
|
@@ -61,13 +118,33 @@ def split_cli_command(command: str) -> list[str]:
|
|
|
61
118
|
return [program, *shlex.split(rest)] if rest else [program]
|
|
62
119
|
return shlex.split(cleaned)
|
|
63
120
|
|
|
121
|
+
def global_profile_env_value(name: str) -> str:
|
|
122
|
+
if not global_agent_profile_enabled():
|
|
123
|
+
return ""
|
|
124
|
+
path = global_agent_profile_path()
|
|
125
|
+
if not path.exists():
|
|
126
|
+
return ""
|
|
127
|
+
try:
|
|
128
|
+
lines = [line.strip() for line in path.read_text(encoding="utf-8", errors="ignore").splitlines() if line.strip()]
|
|
129
|
+
if not lines:
|
|
130
|
+
return ""
|
|
131
|
+
data = json.loads(lines[-1])
|
|
132
|
+
except (OSError, json.JSONDecodeError):
|
|
133
|
+
return ""
|
|
134
|
+
env = data.get("env", {})
|
|
135
|
+
if not isinstance(env, dict):
|
|
136
|
+
return ""
|
|
137
|
+
value = env.get(name, "")
|
|
138
|
+
return str(value) if value else ""
|
|
139
|
+
|
|
140
|
+
|
|
64
141
|
def env_value(name: str) -> str:
|
|
65
142
|
value = os.getenv(name, "")
|
|
66
143
|
if value:
|
|
67
144
|
return value
|
|
68
145
|
env_path = Path(".env")
|
|
69
146
|
if not env_path.exists():
|
|
70
|
-
return
|
|
147
|
+
return global_profile_env_value(name)
|
|
71
148
|
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
72
149
|
stripped = line.strip()
|
|
73
150
|
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
@@ -252,14 +329,7 @@ class MultiModelCodingOrchestrator:
|
|
|
252
329
|
]
|
|
253
330
|
)
|
|
254
331
|
try:
|
|
255
|
-
process =
|
|
256
|
-
[*args, "-p", subagent_prompt],
|
|
257
|
-
check=False,
|
|
258
|
-
stdin=subprocess.DEVNULL,
|
|
259
|
-
capture_output=True,
|
|
260
|
-
encoding="utf-8",
|
|
261
|
-
timeout=300,
|
|
262
|
-
)
|
|
332
|
+
process = run_cli_process([*args, "-p", subagent_prompt], timeout=300)
|
|
263
333
|
except FileNotFoundError as error:
|
|
264
334
|
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
|
|
265
335
|
output = (process.stdout or "").strip()
|
|
@@ -275,7 +345,7 @@ class MultiModelCodingOrchestrator:
|
|
|
275
345
|
raise ValueError("codex command is required")
|
|
276
346
|
if len(args) == 1:
|
|
277
347
|
args.append("exec")
|
|
278
|
-
args =
|
|
348
|
+
args = ensure_codex_exec_defaults(args)
|
|
279
349
|
codex_prompt = "\n".join(
|
|
280
350
|
[
|
|
281
351
|
f"You are AgentRAM {endpoint.role} agent running through Codex CLI.",
|
|
@@ -287,6 +357,7 @@ class MultiModelCodingOrchestrator:
|
|
|
287
357
|
]
|
|
288
358
|
)
|
|
289
359
|
prompt_path = ""
|
|
360
|
+
output_path = ""
|
|
290
361
|
try:
|
|
291
362
|
with tempfile.NamedTemporaryFile(
|
|
292
363
|
"w",
|
|
@@ -298,15 +369,18 @@ class MultiModelCodingOrchestrator:
|
|
|
298
369
|
) as prompt_file:
|
|
299
370
|
prompt_file.write(codex_prompt)
|
|
300
371
|
prompt_path = prompt_file.name
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
[*args, short_prompt],
|
|
304
|
-
check=False,
|
|
305
|
-
stdin=subprocess.DEVNULL,
|
|
306
|
-
capture_output=True,
|
|
372
|
+
with tempfile.NamedTemporaryFile(
|
|
373
|
+
"w",
|
|
307
374
|
encoding="utf-8",
|
|
308
|
-
|
|
309
|
-
|
|
375
|
+
delete=False,
|
|
376
|
+
dir=os.getcwd(),
|
|
377
|
+
prefix=".agentram_codex_final_",
|
|
378
|
+
suffix=".txt",
|
|
379
|
+
) as output_file:
|
|
380
|
+
output_path = output_file.name
|
|
381
|
+
args = add_codex_output_last_message(args, output_path)
|
|
382
|
+
short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
|
|
383
|
+
process = run_cli_process([*args, short_prompt], timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")))
|
|
310
384
|
except FileNotFoundError as error:
|
|
311
385
|
raise RuntimeError(f"Codex CLI command not found: {args[0]}. Install Codex CLI or set base_url to the full command path.") from error
|
|
312
386
|
finally:
|
|
@@ -315,19 +389,39 @@ class MultiModelCodingOrchestrator:
|
|
|
315
389
|
os.remove(prompt_path)
|
|
316
390
|
except OSError:
|
|
317
391
|
pass
|
|
318
|
-
|
|
392
|
+
final_output = ""
|
|
393
|
+
if output_path:
|
|
394
|
+
try:
|
|
395
|
+
final_output = Path(output_path).read_text(encoding="utf-8").strip()
|
|
396
|
+
except OSError:
|
|
397
|
+
final_output = ""
|
|
398
|
+
try:
|
|
399
|
+
os.remove(output_path)
|
|
400
|
+
except OSError:
|
|
401
|
+
pass
|
|
402
|
+
output = final_output or (process.stdout or "").strip()
|
|
319
403
|
error = (process.stderr or "").strip()
|
|
320
404
|
if process.returncode != 0:
|
|
321
405
|
raise RuntimeError(error or f"codex command failed with exit code {process.returncode}")
|
|
322
406
|
return output or error
|
|
323
407
|
|
|
324
408
|
|
|
325
|
-
def
|
|
326
|
-
if
|
|
409
|
+
def ensure_codex_exec_defaults(args: list[str]) -> list[str]:
|
|
410
|
+
if len(args) < 2 or args[1] != "exec":
|
|
327
411
|
return args
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
412
|
+
result = list(args)
|
|
413
|
+
if "--skip-git-repo-check" not in result:
|
|
414
|
+
result = [result[0], result[1], "--skip-git-repo-check", *result[2:]]
|
|
415
|
+
sandbox_index = -1
|
|
416
|
+
for flag in ("--sandbox", "-s"):
|
|
417
|
+
if flag in result:
|
|
418
|
+
sandbox_index = result.index(flag)
|
|
419
|
+
break
|
|
420
|
+
if sandbox_index == -1:
|
|
421
|
+
result = [result[0], result[1], "--sandbox", "workspace-write", *result[2:]]
|
|
422
|
+
elif sandbox_index + 1 < len(result) and result[sandbox_index + 1] == "read-only":
|
|
423
|
+
result[sandbox_index + 1] = "workspace-write"
|
|
424
|
+
return result
|
|
331
425
|
|
|
332
426
|
|
|
333
427
|
def parse_openai_sse(raw: str) -> str:
|
package/agentram/storage.py
CHANGED
|
@@ -82,7 +82,7 @@ class JsonAgentProfileStore:
|
|
|
82
82
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
83
|
|
|
84
84
|
def default_payload(self) -> dict[str, object]:
|
|
85
|
-
return {"updated_at": utc_now(), "endpoints": [], "router": None, "auto_route": True, "agents": {}}
|
|
85
|
+
return {"updated_at": utc_now(), "endpoints": [], "router": None, "auto_route": True, "agents": {}, "env": {}}
|
|
86
86
|
|
|
87
87
|
def legacy_json_path(self) -> Path:
|
|
88
88
|
return self.path.with_suffix(".json") if self.path.suffix == ".jsonl" else self.path
|
|
@@ -92,10 +92,11 @@ class JsonAgentProfileStore:
|
|
|
92
92
|
data.setdefault("router", None)
|
|
93
93
|
data.setdefault("auto_route", True)
|
|
94
94
|
data.setdefault("agents", {})
|
|
95
|
+
data.setdefault("env", {})
|
|
95
96
|
return data
|
|
96
97
|
|
|
97
98
|
def is_empty_payload(self, data: dict[str, object]) -> bool:
|
|
98
|
-
return not data.get("endpoints") and not data.get("router") and not data.get("agents")
|
|
99
|
+
return not data.get("endpoints") and not data.get("router") and not data.get("agents") and not data.get("env")
|
|
99
100
|
|
|
100
101
|
def load_fallback(self) -> dict[str, object] | None:
|
|
101
102
|
if not self.fallback_path or self.fallback_path == self.path or not self.fallback_path.exists():
|
|
@@ -135,6 +136,7 @@ class JsonAgentProfileStore:
|
|
|
135
136
|
"router": current.get("router"),
|
|
136
137
|
"auto_route": bool(current.get("auto_route", True)),
|
|
137
138
|
"agents": current.get("agents", {}),
|
|
139
|
+
"env": current.get("env", {}),
|
|
138
140
|
}
|
|
139
141
|
self.write_snapshot(payload)
|
|
140
142
|
return payload
|
|
@@ -208,6 +210,24 @@ class JsonAgentProfileStore:
|
|
|
208
210
|
self.write_snapshot(current)
|
|
209
211
|
return current
|
|
210
212
|
|
|
213
|
+
def set_env_value(self, name: str, value: str) -> dict[str, object]:
|
|
214
|
+
current = self.load()
|
|
215
|
+
env = current.get("env", {})
|
|
216
|
+
if not isinstance(env, dict):
|
|
217
|
+
env = {}
|
|
218
|
+
env[name] = value
|
|
219
|
+
current["env"] = env
|
|
220
|
+
current["updated_at"] = utc_now()
|
|
221
|
+
self.write_snapshot(current)
|
|
222
|
+
return current
|
|
223
|
+
|
|
224
|
+
def get_env_value(self, name: str) -> str:
|
|
225
|
+
env = self.load().get("env", {})
|
|
226
|
+
if not isinstance(env, dict):
|
|
227
|
+
return ""
|
|
228
|
+
value = env.get(name, "")
|
|
229
|
+
return str(value) if value else ""
|
|
230
|
+
|
|
211
231
|
def get_agent(self, slot: str) -> AgentModelEndpoint | None:
|
|
212
232
|
agents = self.load().get("agents", {})
|
|
213
233
|
if not isinstance(agents, dict):
|
package/package.json
CHANGED