agentram 0.1.41 → 0.1.43
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 +3 -1
- package/agentram/config.py +4 -0
- package/agentram/mcp_server.py +3 -2
- package/agentram/orchestration.py +121 -112
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/agentram/cli.py
CHANGED
|
@@ -13,7 +13,7 @@ import time
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
-
from .config import default_ram_root
|
|
16
|
+
from .config import default_ram_root, global_agent_profile_enabled
|
|
17
17
|
from .mcp_server import McpContext, call_tool
|
|
18
18
|
from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
|
|
19
19
|
|
|
@@ -1250,6 +1250,8 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
|
|
|
1250
1250
|
|
|
1251
1251
|
|
|
1252
1252
|
def set_global_agent_if_available(context: McpContext, slot: str, endpoint: AgentModelEndpoint) -> bool:
|
|
1253
|
+
if not global_agent_profile_enabled():
|
|
1254
|
+
return False
|
|
1253
1255
|
try:
|
|
1254
1256
|
context.global_profile_store.set_agent(slot, endpoint)
|
|
1255
1257
|
return True
|
package/agentram/config.py
CHANGED
|
@@ -29,6 +29,10 @@ def global_agent_profile_path() -> Path:
|
|
|
29
29
|
return Path(base) / "AgentRAM" / "agent_profile.jsonl"
|
|
30
30
|
return Path.home() / ".agentram" / "agent_profile.jsonl"
|
|
31
31
|
|
|
32
|
+
def global_agent_profile_enabled() -> bool:
|
|
33
|
+
value = os.getenv("AGENTRAM_DISABLE_GLOBAL_PROFILE", "").strip().lower()
|
|
34
|
+
return value not in {"1", "true", "yes", "on"}
|
|
35
|
+
|
|
32
36
|
def load_env_file(path: str | Path = ".env") -> dict[str, str]:
|
|
33
37
|
env_path = Path(path)
|
|
34
38
|
if not env_path.exists():
|
package/agentram/mcp_server.py
CHANGED
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from .adapter import HeuristicMemoryAdapter
|
|
12
|
-
from .config import default_ram_root, global_agent_profile_path
|
|
12
|
+
from .config import default_ram_root, global_agent_profile_enabled, global_agent_profile_path
|
|
13
13
|
from .merger import MemoryMerger
|
|
14
14
|
from .orchestration import AgentModelEndpoint, CodingOrchestrationRequest, MultiModelCodingOrchestrator
|
|
15
15
|
from .retriever import MemoryRetriever
|
|
@@ -46,7 +46,8 @@ class McpContext:
|
|
|
46
46
|
|
|
47
47
|
@property
|
|
48
48
|
def profile_store(self) -> JsonAgentProfileStore:
|
|
49
|
-
|
|
49
|
+
fallback = global_agent_profile_path() if global_agent_profile_enabled() else None
|
|
50
|
+
return JsonAgentProfileStore(self.ram_root / "agent_profile.jsonl", fallback_path=fallback)
|
|
50
51
|
|
|
51
52
|
@property
|
|
52
53
|
def global_profile_store(self) -> JsonAgentProfileStore:
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
import json
|
|
6
|
-
import os
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
import re
|
|
9
|
-
import shlex
|
|
10
|
-
import subprocess
|
|
11
|
-
import tempfile
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
12
|
import urllib.error
|
|
13
13
|
import urllib.request
|
|
14
14
|
from typing import Any, Callable
|
|
@@ -46,7 +46,7 @@ def normalize_provider_name(provider: str) -> str:
|
|
|
46
46
|
return PROVIDER_ALIASES.get(normalized, normalized)
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def split_cli_command(command: str) -> list[str]:
|
|
49
|
+
def split_cli_command(command: str) -> list[str]:
|
|
50
50
|
cleaned = command.strip().strip('"')
|
|
51
51
|
if not cleaned:
|
|
52
52
|
return []
|
|
@@ -59,23 +59,23 @@ def split_cli_command(command: str) -> list[str]:
|
|
|
59
59
|
program = cleaned[:end]
|
|
60
60
|
rest = cleaned[end:].strip()
|
|
61
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 ""
|
|
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
79
|
|
|
80
80
|
|
|
81
81
|
@dataclass(frozen=True)
|
|
@@ -182,7 +182,7 @@ class MultiModelCodingOrchestrator:
|
|
|
182
182
|
raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
|
|
183
183
|
|
|
184
184
|
def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
185
|
-
api_key = env_value(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 ""
|
|
186
186
|
if not endpoint.base_url:
|
|
187
187
|
raise ValueError("base_url is required")
|
|
188
188
|
if endpoint.api_key_env and not api_key:
|
|
@@ -204,20 +204,20 @@ class MultiModelCodingOrchestrator:
|
|
|
204
204
|
if error.code == 404:
|
|
205
205
|
hint += " | base_url should include /v1 but not /chat/completions; verify provider URL and model name"
|
|
206
206
|
raise RuntimeError(f"{hint} | response={body[:500]}") from error
|
|
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)
|
|
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)
|
|
218
218
|
|
|
219
219
|
def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
220
|
-
api_key = env_value(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 ""
|
|
221
221
|
if endpoint.api_key_env and not api_key:
|
|
222
222
|
raise ValueError(f"missing API key env: {endpoint.api_key_env}")
|
|
223
223
|
base_url = endpoint.base_url.rstrip("/") if endpoint.base_url else "https://api.anthropic.com/v1"
|
|
@@ -252,13 +252,13 @@ class MultiModelCodingOrchestrator:
|
|
|
252
252
|
]
|
|
253
253
|
)
|
|
254
254
|
try:
|
|
255
|
-
process = subprocess.run(
|
|
256
|
-
[*args, "-p", subagent_prompt],
|
|
257
|
-
check=False,
|
|
258
|
-
stdin=subprocess.DEVNULL,
|
|
259
|
-
capture_output=True,
|
|
260
|
-
encoding="utf-8",
|
|
261
|
-
timeout=300,
|
|
255
|
+
process = subprocess.run(
|
|
256
|
+
[*args, "-p", subagent_prompt],
|
|
257
|
+
check=False,
|
|
258
|
+
stdin=subprocess.DEVNULL,
|
|
259
|
+
capture_output=True,
|
|
260
|
+
encoding="utf-8",
|
|
261
|
+
timeout=300,
|
|
262
262
|
)
|
|
263
263
|
except FileNotFoundError as error:
|
|
264
264
|
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
|
|
@@ -275,45 +275,46 @@ class MultiModelCodingOrchestrator:
|
|
|
275
275
|
raise ValueError("codex command is required")
|
|
276
276
|
if len(args) == 1:
|
|
277
277
|
args.append("exec")
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
278
|
+
args = ensure_codex_skip_git_repo_check(args)
|
|
279
|
+
codex_prompt = "\n".join(
|
|
280
|
+
[
|
|
281
|
+
f"You are AgentRAM {endpoint.role} agent running through Codex CLI.",
|
|
281
282
|
f"Model hint: {endpoint.model}",
|
|
282
283
|
"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.",
|
|
283
284
|
"Return concise result, changed files, tests/verification, and blockers.",
|
|
284
285
|
"",
|
|
285
|
-
prompt,
|
|
286
|
-
]
|
|
287
|
-
)
|
|
288
|
-
prompt_path = ""
|
|
289
|
-
try:
|
|
290
|
-
with tempfile.NamedTemporaryFile(
|
|
291
|
-
"w",
|
|
292
|
-
encoding="utf-8",
|
|
293
|
-
delete=False,
|
|
294
|
-
dir=os.getcwd(),
|
|
295
|
-
prefix=".agentram_codex_prompt_",
|
|
296
|
-
suffix=".txt",
|
|
297
|
-
) as prompt_file:
|
|
298
|
-
prompt_file.write(codex_prompt)
|
|
299
|
-
prompt_path = prompt_file.name
|
|
300
|
-
short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
|
|
301
|
-
process = subprocess.run(
|
|
302
|
-
[*args, short_prompt],
|
|
303
|
-
check=False,
|
|
304
|
-
stdin=subprocess.DEVNULL,
|
|
305
|
-
capture_output=True,
|
|
306
|
-
encoding="utf-8",
|
|
307
|
-
timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")),
|
|
308
|
-
)
|
|
286
|
+
prompt,
|
|
287
|
+
]
|
|
288
|
+
)
|
|
289
|
+
prompt_path = ""
|
|
290
|
+
try:
|
|
291
|
+
with tempfile.NamedTemporaryFile(
|
|
292
|
+
"w",
|
|
293
|
+
encoding="utf-8",
|
|
294
|
+
delete=False,
|
|
295
|
+
dir=os.getcwd(),
|
|
296
|
+
prefix=".agentram_codex_prompt_",
|
|
297
|
+
suffix=".txt",
|
|
298
|
+
) as prompt_file:
|
|
299
|
+
prompt_file.write(codex_prompt)
|
|
300
|
+
prompt_path = prompt_file.name
|
|
301
|
+
short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
|
|
302
|
+
process = subprocess.run(
|
|
303
|
+
[*args, short_prompt],
|
|
304
|
+
check=False,
|
|
305
|
+
stdin=subprocess.DEVNULL,
|
|
306
|
+
capture_output=True,
|
|
307
|
+
encoding="utf-8",
|
|
308
|
+
timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")),
|
|
309
|
+
)
|
|
309
310
|
except FileNotFoundError as error:
|
|
310
|
-
raise RuntimeError(f"Codex CLI command not found: {args[0]}. Install Codex CLI or set base_url to the full command path.") from error
|
|
311
|
-
finally:
|
|
312
|
-
if prompt_path:
|
|
313
|
-
try:
|
|
314
|
-
os.remove(prompt_path)
|
|
315
|
-
except OSError:
|
|
316
|
-
pass
|
|
311
|
+
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
|
+
finally:
|
|
313
|
+
if prompt_path:
|
|
314
|
+
try:
|
|
315
|
+
os.remove(prompt_path)
|
|
316
|
+
except OSError:
|
|
317
|
+
pass
|
|
317
318
|
output = (process.stdout or "").strip()
|
|
318
319
|
error = (process.stderr or "").strip()
|
|
319
320
|
if process.returncode != 0:
|
|
@@ -321,7 +322,15 @@ class MultiModelCodingOrchestrator:
|
|
|
321
322
|
return output or error
|
|
322
323
|
|
|
323
324
|
|
|
324
|
-
def
|
|
325
|
+
def ensure_codex_skip_git_repo_check(args: list[str]) -> list[str]:
|
|
326
|
+
if "--skip-git-repo-check" in args:
|
|
327
|
+
return args
|
|
328
|
+
if len(args) >= 2 and args[1] == "exec":
|
|
329
|
+
return [args[0], args[1], "--skip-git-repo-check", *args[2:]]
|
|
330
|
+
return args
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def parse_openai_sse(raw: str) -> str:
|
|
325
334
|
parts: list[str] = []
|
|
326
335
|
for line in raw.splitlines():
|
|
327
336
|
stripped = line.strip()
|
|
@@ -348,39 +357,39 @@ def parse_openai_sse(raw: str) -> str:
|
|
|
348
357
|
message = choice.get("message", {})
|
|
349
358
|
if isinstance(message, dict) and message.get("content"):
|
|
350
359
|
parts.append(str(message.get("content")))
|
|
351
|
-
return strip_thinking_blocks("".join(parts)).strip()
|
|
352
|
-
|
|
353
|
-
def parse_openai_mixed_json(raw: str) -> dict[str, Any]:
|
|
354
|
-
stripped = raw.strip()
|
|
355
|
-
if not stripped.startswith("{"):
|
|
356
|
-
return {}
|
|
357
|
-
for marker in ("\ndata:", "\r\ndata:"):
|
|
358
|
-
if marker not in stripped:
|
|
359
|
-
continue
|
|
360
|
-
candidate = stripped.split(marker, 1)[0].strip()
|
|
361
|
-
try:
|
|
362
|
-
value = json.loads(candidate)
|
|
363
|
-
except json.JSONDecodeError:
|
|
364
|
-
return {}
|
|
365
|
-
return value if isinstance(value, dict) else {}
|
|
366
|
-
return {}
|
|
367
|
-
|
|
368
|
-
def extract_openai_message(data: dict[str, Any]) -> str:
|
|
369
|
-
choices = data.get("choices", [])
|
|
370
|
-
if not choices or not isinstance(choices, list):
|
|
371
|
-
return ""
|
|
372
|
-
first = choices[0]
|
|
373
|
-
if not isinstance(first, dict):
|
|
374
|
-
return ""
|
|
375
|
-
message = first.get("message", {})
|
|
376
|
-
if isinstance(message, dict):
|
|
377
|
-
return strip_thinking_blocks(str(message.get("content", ""))).strip()
|
|
378
|
-
delta = first.get("delta", {})
|
|
379
|
-
if isinstance(delta, dict):
|
|
380
|
-
return strip_thinking_blocks(str(delta.get("content", ""))).strip()
|
|
381
|
-
return ""
|
|
382
|
-
|
|
383
|
-
def strip_thinking_blocks(text: str) -> str:
|
|
360
|
+
return strip_thinking_blocks("".join(parts)).strip()
|
|
361
|
+
|
|
362
|
+
def parse_openai_mixed_json(raw: str) -> dict[str, Any]:
|
|
363
|
+
stripped = raw.strip()
|
|
364
|
+
if not stripped.startswith("{"):
|
|
365
|
+
return {}
|
|
366
|
+
for marker in ("\ndata:", "\r\ndata:"):
|
|
367
|
+
if marker not in stripped:
|
|
368
|
+
continue
|
|
369
|
+
candidate = stripped.split(marker, 1)[0].strip()
|
|
370
|
+
try:
|
|
371
|
+
value = json.loads(candidate)
|
|
372
|
+
except json.JSONDecodeError:
|
|
373
|
+
return {}
|
|
374
|
+
return value if isinstance(value, dict) else {}
|
|
375
|
+
return {}
|
|
376
|
+
|
|
377
|
+
def extract_openai_message(data: dict[str, Any]) -> str:
|
|
378
|
+
choices = data.get("choices", [])
|
|
379
|
+
if not choices or not isinstance(choices, list):
|
|
380
|
+
return ""
|
|
381
|
+
first = choices[0]
|
|
382
|
+
if not isinstance(first, dict):
|
|
383
|
+
return ""
|
|
384
|
+
message = first.get("message", {})
|
|
385
|
+
if isinstance(message, dict):
|
|
386
|
+
return strip_thinking_blocks(str(message.get("content", ""))).strip()
|
|
387
|
+
delta = first.get("delta", {})
|
|
388
|
+
if isinstance(delta, dict):
|
|
389
|
+
return strip_thinking_blocks(str(delta.get("content", ""))).strip()
|
|
390
|
+
return ""
|
|
391
|
+
|
|
392
|
+
def strip_thinking_blocks(text: str) -> str:
|
|
384
393
|
text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
385
394
|
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
386
395
|
text = re.sub(r"^\s*</?(thinking|think)>\s*", "", text, flags=re.IGNORECASE)
|
package/package.json
CHANGED