agentram 0.1.5 → 0.1.7
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 +126 -10
- package/agentram/cli.py +365 -39
- package/agentram/mcp_server.py +41 -0
- package/agentram/orchestration.py +48 -3
- package/agentram/storage.py +101 -9
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/README.md
CHANGED
|
@@ -358,12 +358,20 @@ Minimum usage pattern:
|
|
|
358
358
|
|
|
359
359
|
AgentRAM can bind multiple coding model endpoints and let Claude Code or MCP clients orchestrate them in parallel. This keeps the `free-code` style command registry, but adds project RAM, Goal Stack, and multi-agent model routing.
|
|
360
360
|
|
|
361
|
-
Bind models:
|
|
362
|
-
|
|
363
|
-
```bash
|
|
364
|
-
agentram bind-model claude
|
|
365
|
-
agentram bind-model
|
|
366
|
-
|
|
361
|
+
Bind models:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
agentram bind-model claude-subagent agentram-reviewer --role reviewer
|
|
365
|
+
agentram bind-model claude claude-3-5-sonnet-latest --role reviewer --api-key-env ANTHROPIC_API_KEY
|
|
366
|
+
agentram bind-model openai-compatible qwen2.5-coder --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Claude Code subagent endpoint uses your local Claude CLI entrypoint. `model` is the subagent name from `.claude/agents`, for example `agentram-reviewer` or `agentram-planner`. Default command is `claude -p`. Override command when needed:
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
set AGENTRAM_CLAUDE_COMMAND=claude
|
|
373
|
+
agentram bind-model claude-subagent agentram-reviewer --role reviewer
|
|
374
|
+
```
|
|
367
375
|
|
|
368
376
|
Build a dry-run orchestration plan:
|
|
369
377
|
|
|
@@ -388,10 +396,11 @@ agentram_run_coding_orchestration
|
|
|
388
396
|
|
|
389
397
|
TUI slash commands. Plain text only records/retrieves RAM; use `/ask` to call bound models:
|
|
390
398
|
|
|
391
|
-
```text
|
|
392
|
-
/bind-model claude
|
|
393
|
-
/
|
|
394
|
-
/
|
|
399
|
+
```text
|
|
400
|
+
/bind-model claude-subagent agentram-reviewer role=reviewer
|
|
401
|
+
/bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY
|
|
402
|
+
/models
|
|
403
|
+
/orchestrate review CLI orchestration
|
|
395
404
|
/ask xin chào
|
|
396
405
|
```
|
|
397
406
|
|
|
@@ -882,3 +891,110 @@ pip install agentram[tui]
|
|
|
882
891
|
```
|
|
883
892
|
|
|
884
893
|
Without `prompt_toolkit`, AgentRAM still works with basic terminal input; `/` then Enter opens the full slash palette.
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
### Fix Bad Model Endpoints
|
|
897
|
+
|
|
898
|
+
If `/ask` shows `base_url is required`, old model endpoints were bound without a URL. Remove or clear them:
|
|
899
|
+
|
|
900
|
+
```text
|
|
901
|
+
/models
|
|
902
|
+
/remove-model model_xxx
|
|
903
|
+
# or
|
|
904
|
+
/clear-models
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
If `/ask` shows connection refused, the configured OpenAI-compatible server is not running at `base_url`:
|
|
908
|
+
|
|
909
|
+
```text
|
|
910
|
+
/bind-model openai-compatible Agentic --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
|
|
911
|
+
/ask xin chào
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
Start your local server first, or change `--base-url` to the provider URL.
|
|
915
|
+
|
|
916
|
+
## Prompt Router Model
|
|
917
|
+
|
|
918
|
+
AgentRAM can use a separate small router model before normal prompts. The router classifies each prompt, then sends it to the right path.
|
|
919
|
+
|
|
920
|
+
```text
|
|
921
|
+
coding -> bound coding endpoints / Claude subagents
|
|
922
|
+
planning -> Goal Stack and drift check
|
|
923
|
+
memory -> AgentRAM note ingest
|
|
924
|
+
chat -> RAM retrieve only
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
Bind a router model:
|
|
928
|
+
|
|
929
|
+
```text
|
|
930
|
+
/bind-router openai-compatible qwen2.5:1.5b base_url=http://localhost:8000/v1 api_key_env=OPENAI_API_KEY
|
|
931
|
+
/route on
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
Then prompt without slash:
|
|
935
|
+
|
|
936
|
+
```text
|
|
937
|
+
fix adapter.py
|
|
938
|
+
c?p nh?t goal stack
|
|
939
|
+
ghi nh? d�ng Claude subagent
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
Disable auto routing:
|
|
943
|
+
|
|
944
|
+
```text
|
|
945
|
+
/route off
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
## Supervisor + Small Agent Workflow
|
|
949
|
+
|
|
950
|
+
AgentRAM can run as a two-flow coding agent system, not only as a flat multi-model router.
|
|
951
|
+
|
|
952
|
+
```text
|
|
953
|
+
agentram
|
|
954
|
+
|
|
|
955
|
+
v
|
|
956
|
+
Parse User Request
|
|
957
|
+
|
|
|
958
|
+
v
|
|
959
|
+
Supervisor Memory
|
|
960
|
+
|
|
|
961
|
+
v
|
|
962
|
+
Task Classifier / Supervisor Plan
|
|
963
|
+
|----------------------|
|
|
964
|
+
v v
|
|
965
|
+
Main Agent Small Agent
|
|
966
|
+
(GPT-5.5 Codex) (Qwen Codex)
|
|
967
|
+
planning/coding/fix notes/RAM/coding facts
|
|
968
|
+
| |
|
|
969
|
+
|----------|-----------|
|
|
970
|
+
v
|
|
971
|
+
Merge + Update Memory
|
|
972
|
+
|
|
|
973
|
+
v
|
|
974
|
+
Return Result
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
Bind workflow agents:
|
|
978
|
+
|
|
979
|
+
```text
|
|
980
|
+
/bind-agent supervisor claude-subagent agentram-planner
|
|
981
|
+
/bind-agent main openai-compatible gpt-5.5-codex base_url=https://api.example.com/v1 api_key_env=MAIN_AGENT_KEY
|
|
982
|
+
/bind-agent small openai-compatible qwen-codex base_url=http://localhost:8001/v1 api_key_env=SMALL_AGENT_KEY
|
|
983
|
+
/workflow
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
Then use normal prompts without slash:
|
|
987
|
+
|
|
988
|
+
```text
|
|
989
|
+
refactor orchestration workflow
|
|
990
|
+
fix bug in adapter.py
|
|
991
|
+
plan next milestone
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
Runtime behavior:
|
|
995
|
+
|
|
996
|
+
```text
|
|
997
|
+
Supervisor creates plan -> Main agent executes -> Small agent records RAM notes -> AgentRAM merges result
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
Small agent should not own big direction. It records plans, decisions, files, risks, next tasks, and coding facts into RAM.
|
package/agentram/cli.py
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import argparse
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
3
|
+
import argparse
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import textwrap
|
|
10
|
+
import time
|
|
8
11
|
from pathlib import Path
|
|
9
12
|
from typing import Any
|
|
10
13
|
|
|
11
|
-
from .config import default_ram_root
|
|
12
|
-
from .mcp_server import McpContext, call_tool
|
|
14
|
+
from .config import default_ram_root
|
|
15
|
+
from .mcp_server import McpContext, call_tool
|
|
16
|
+
from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
|
|
13
17
|
|
|
14
18
|
SUBAGENTS: dict[str, str] = {
|
|
15
19
|
"memory": "Summarize important facts, decisions, files, risks, and next tasks into AgentRAM events. Do not edit code.",
|
|
@@ -31,8 +35,15 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
|
31
35
|
("/agents", "List Claude-style subagent presets"),
|
|
32
36
|
("/agent <name> <task>", "Build Claude Code subagent prompt"),
|
|
33
37
|
("/models", "List bound coding models"),
|
|
34
|
-
("/
|
|
35
|
-
("/
|
|
38
|
+
("/remove-model <id>", "Remove bound coding model"),
|
|
39
|
+
("/disable-model <id>", "Disable bound coding model"),
|
|
40
|
+
("/clear-models", "Remove all bound coding models"),
|
|
41
|
+
("/bind-model provider model", "Bind model endpoint"),
|
|
42
|
+
("/bind-router provider model", "Bind intent router model"),
|
|
43
|
+
("/bind-agent slot provider model", "Bind supervisor/main/small agent"),
|
|
44
|
+
("/workflow", "Show supervisor workflow agents"),
|
|
45
|
+
("/route on|off|status", "Toggle auto prompt routing"),
|
|
46
|
+
("/orchestrate <task>", "Build multi-model agent plan"),
|
|
36
47
|
("/ask <prompt>", "Execute bound models and return outputs"),
|
|
37
48
|
("/claude-install [path]", "Install .claude commands and agents"),
|
|
38
49
|
("/note <text>", "Save decision note"),
|
|
@@ -55,15 +66,22 @@ SLASH_HELP = """Slash commands:
|
|
|
55
66
|
/agents List subagent presets
|
|
56
67
|
/agent <name> <task> Build Claude Code subagent prompt
|
|
57
68
|
/models List bound coding models
|
|
58
|
-
/
|
|
59
|
-
/
|
|
69
|
+
/remove-model <id> Remove bound coding model
|
|
70
|
+
/disable-model <id> Disable bound coding model
|
|
71
|
+
/clear-models Remove all bound coding models
|
|
72
|
+
/bind-model provider model Bind model endpoint
|
|
73
|
+
/bind-router provider model Bind intent router model
|
|
74
|
+
/bind-agent slot provider model Bind supervisor/main/small agent
|
|
75
|
+
/workflow Show supervisor workflow agents
|
|
76
|
+
/route on|off|status Toggle auto prompt routing
|
|
77
|
+
/orchestrate <task> Build multi-model agent plan
|
|
60
78
|
/ask <prompt> Execute bound models and return outputs
|
|
61
79
|
/claude-install [path] Install .claude commands and agents
|
|
62
80
|
/note <text> Save decision note
|
|
63
81
|
/clear Clear chat
|
|
64
82
|
/exit Quit
|
|
65
|
-
Plain text Capture coding chat message and retrieve context
|
|
66
|
-
""".strip()
|
|
83
|
+
Plain text Capture coding chat message and retrieve context
|
|
84
|
+
""".strip()
|
|
67
85
|
|
|
68
86
|
|
|
69
87
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -77,14 +95,39 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
77
95
|
subparsers.add_parser("goal", help="Show goal stack.")
|
|
78
96
|
subparsers.add_parser("agents", help="List subagent presets.")
|
|
79
97
|
subparsers.add_parser("models", help="List bound coding model endpoints.")
|
|
98
|
+
subparsers.add_parser("clear-models", help="Remove all bound coding model endpoints.")
|
|
99
|
+
|
|
100
|
+
remove_model = subparsers.add_parser("remove-model", help="Remove one bound coding model endpoint.")
|
|
101
|
+
remove_model.add_argument("id", help="Endpoint id.")
|
|
102
|
+
|
|
103
|
+
disable_model = subparsers.add_parser("disable-model", help="Disable one bound coding model endpoint.")
|
|
104
|
+
disable_model.add_argument("id", help="Endpoint id.")
|
|
80
105
|
|
|
81
106
|
bind_model = subparsers.add_parser("bind-model", help="Bind a coding model endpoint.")
|
|
82
|
-
bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq.")
|
|
83
|
-
bind_model.add_argument("model", help="Model name.")
|
|
107
|
+
bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq, claude-subagent.")
|
|
108
|
+
bind_model.add_argument("model", help="Model name or Claude subagent name.")
|
|
84
109
|
bind_model.add_argument("--role", default="coder", help="Agent role.")
|
|
85
110
|
bind_model.add_argument("--base-url", default="", help="Provider base URL.")
|
|
86
|
-
bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
|
|
87
|
-
bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
|
|
111
|
+
bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
|
|
112
|
+
bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
|
|
113
|
+
|
|
114
|
+
bind_router = subparsers.add_parser("bind-router", help="Bind a separate intent router model endpoint.")
|
|
115
|
+
bind_router.add_argument("provider", help="Provider name, e.g. openai-compatible, groq, claude-subagent.")
|
|
116
|
+
bind_router.add_argument("model", help="Router model name.")
|
|
117
|
+
bind_router.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
|
|
118
|
+
bind_router.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
|
|
119
|
+
|
|
120
|
+
bind_agent = subparsers.add_parser("bind-agent", help="Bind supervisor/main/small workflow agent.")
|
|
121
|
+
bind_agent.add_argument("slot", choices=["supervisor", "main", "small"], help="Workflow slot.")
|
|
122
|
+
bind_agent.add_argument("provider", help="Provider name, e.g. claude-subagent, openai-compatible, groq.")
|
|
123
|
+
bind_agent.add_argument("model", help="Model or subagent name.")
|
|
124
|
+
bind_agent.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
|
|
125
|
+
bind_agent.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
|
|
126
|
+
|
|
127
|
+
subparsers.add_parser("workflow", help="Show supervisor/main/small workflow agents.")
|
|
128
|
+
|
|
129
|
+
route = subparsers.add_parser("route", help="Toggle or show auto prompt routing.")
|
|
130
|
+
route.add_argument("mode", nargs="?", default="status", choices=["on", "off", "status"], help="Auto route mode.")
|
|
88
131
|
|
|
89
132
|
orchestrate = subparsers.add_parser("orchestrate", help="Build or run multi-model coding orchestration.")
|
|
90
133
|
orchestrate.add_argument("task", nargs="?", default="", help="Coding task.")
|
|
@@ -156,9 +199,30 @@ def run_command(args: argparse.Namespace) -> int:
|
|
|
156
199
|
if command == "models":
|
|
157
200
|
print(models_text(context))
|
|
158
201
|
return 0
|
|
159
|
-
if command == "
|
|
160
|
-
print(
|
|
202
|
+
if command == "remove-model":
|
|
203
|
+
print(remove_model_text(context, args.id))
|
|
204
|
+
return 0
|
|
205
|
+
if command == "disable-model":
|
|
206
|
+
print(disable_model_text(context, args.id))
|
|
207
|
+
return 0
|
|
208
|
+
if command == "clear-models":
|
|
209
|
+
print(clear_models_text(context))
|
|
161
210
|
return 0
|
|
211
|
+
if command == "bind-model":
|
|
212
|
+
print(bind_model_text(context, vars(args)))
|
|
213
|
+
return 0
|
|
214
|
+
if command == "bind-router":
|
|
215
|
+
print(bind_router_text(context, vars(args)))
|
|
216
|
+
return 0
|
|
217
|
+
if command == "bind-agent":
|
|
218
|
+
print(bind_agent_text(context, vars(args)))
|
|
219
|
+
return 0
|
|
220
|
+
if command == "workflow":
|
|
221
|
+
print(workflow_text(context))
|
|
222
|
+
return 0
|
|
223
|
+
if command == "route":
|
|
224
|
+
print(route_text(context, args.mode))
|
|
225
|
+
return 0
|
|
162
226
|
if command == "orchestrate":
|
|
163
227
|
task = args.task or input("Task > ").strip()
|
|
164
228
|
print(orchestrate_text(context, task, files=args.file, execute=args.execute))
|
|
@@ -194,13 +258,24 @@ def run_tui(context: McpContext) -> None:
|
|
|
194
258
|
user_input = BUTTONS[user_input]
|
|
195
259
|
if user_input:
|
|
196
260
|
messages.append(f"user > {user_input}")
|
|
197
|
-
response =
|
|
261
|
+
response = run_with_activity(context, messages, user_input)
|
|
198
262
|
if response == "__clear__":
|
|
199
263
|
messages.clear()
|
|
200
264
|
messages.append("system > Chat cleared.")
|
|
201
265
|
elif response:
|
|
202
266
|
messages.append("agentram > " + response)
|
|
203
|
-
del messages[:-10]
|
|
267
|
+
del messages[:-10]
|
|
268
|
+
|
|
269
|
+
def run_with_activity(context: McpContext, messages: list[str], user_input: str) -> str:
|
|
270
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
271
|
+
future = executor.submit(handle_chat_input, context, user_input, messages)
|
|
272
|
+
frames = ["thinking", "thinking.", "thinking..", "thinking...", "acting", "acting.", "acting..", "acting..."]
|
|
273
|
+
index = 0
|
|
274
|
+
while not future.done():
|
|
275
|
+
draw_screen(context, messages, activity=f"agentram > {frames[index % len(frames)]}")
|
|
276
|
+
index += 1
|
|
277
|
+
time.sleep(0.18)
|
|
278
|
+
return future.result()
|
|
204
279
|
|
|
205
280
|
|
|
206
281
|
|
|
@@ -238,20 +313,24 @@ def read_tui_input(prompt_session: Any | None) -> str:
|
|
|
238
313
|
return input("You > ")
|
|
239
314
|
return prompt_session.prompt("You > ")
|
|
240
315
|
|
|
241
|
-
def handle_chat_input(context: McpContext, text: str, messages: list[str] | None = None) -> str:
|
|
316
|
+
def handle_chat_input(context: McpContext, text: str, messages: list[str] | None = None) -> str:
|
|
242
317
|
if not text:
|
|
243
318
|
return SLASH_HELP
|
|
244
319
|
if text == "/":
|
|
245
320
|
return slash_palette_text()
|
|
246
321
|
if text.startswith("/"):
|
|
247
322
|
return handle_slash_command(context, text, messages)
|
|
248
|
-
call_tool(
|
|
249
|
-
context,
|
|
250
|
-
"agentram_emit_event",
|
|
251
|
-
{"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
|
|
323
|
+
call_tool(
|
|
324
|
+
context,
|
|
325
|
+
"agentram_emit_event",
|
|
326
|
+
{"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
|
|
327
|
+
)
|
|
328
|
+
if context.profile_store.list_agents():
|
|
329
|
+
return supervisor_workflow_text(context, text)
|
|
330
|
+
if context.profile_store.auto_route_enabled():
|
|
331
|
+
return route_plain_prompt_text(context, text)
|
|
332
|
+
context_block = retrieve_text(context, text, task_id="codex", limit=6)
|
|
333
|
+
return "Coding chat captured. Relevant context:\n" + context_block
|
|
255
334
|
|
|
256
335
|
|
|
257
336
|
def handle_slash_command(context: McpContext, text: str, messages: list[str] | None = None) -> str:
|
|
@@ -282,10 +361,30 @@ def handle_slash_command(context: McpContext, text: str, messages: list[str] | N
|
|
|
282
361
|
return agents_text()
|
|
283
362
|
if command == "/models":
|
|
284
363
|
return models_text(context)
|
|
285
|
-
if command == "/
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return
|
|
364
|
+
if command == "/remove-model":
|
|
365
|
+
return remove_model_text(context, raw_rest)
|
|
366
|
+
if command == "/disable-model":
|
|
367
|
+
return disable_model_text(context, raw_rest)
|
|
368
|
+
if command == "/clear-models":
|
|
369
|
+
return clear_models_text(context)
|
|
370
|
+
if command == "/bind-model":
|
|
371
|
+
if len(rest) < 2:
|
|
372
|
+
return "Usage: /bind-model <provider> <model> [--role coder] [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
|
|
373
|
+
return bind_model_text(context, parse_bind_model_args(rest))
|
|
374
|
+
if command == "/bind-router":
|
|
375
|
+
if len(rest) < 2:
|
|
376
|
+
return "Usage: /bind-router <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
|
|
377
|
+
return bind_router_text(context, parse_bind_model_args(rest))
|
|
378
|
+
if command == "/bind-agent":
|
|
379
|
+
if len(rest) < 3:
|
|
380
|
+
return "Usage: /bind-agent <supervisor|main|small> <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
|
|
381
|
+
options = parse_bind_model_args(rest[1:])
|
|
382
|
+
options["slot"] = rest[0]
|
|
383
|
+
return bind_agent_text(context, options)
|
|
384
|
+
if command == "/workflow":
|
|
385
|
+
return workflow_text(context)
|
|
386
|
+
if command == "/route":
|
|
387
|
+
return route_text(context, rest[0] if rest else "status")
|
|
289
388
|
if command == "/orchestrate":
|
|
290
389
|
execute = "--execute" in rest
|
|
291
390
|
task = raw_rest.replace("--execute", "").strip()
|
|
@@ -439,7 +538,7 @@ def models_text(context: McpContext) -> str:
|
|
|
439
538
|
data = payload(call_tool(context, "agentram_read_coding_models", {}))
|
|
440
539
|
endpoints = data.get("endpoints", [])
|
|
441
540
|
if not endpoints:
|
|
442
|
-
return "No coding models bound. Use /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY"
|
|
541
|
+
return "No coding models bound. Use /bind-model claude-subagent agentram-reviewer role=reviewer or /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY"
|
|
443
542
|
lines = ["Bound coding models:"]
|
|
444
543
|
for endpoint in endpoints:
|
|
445
544
|
lines.append(f"- {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')} modalities={','.join(endpoint.get('modalities', []))}")
|
|
@@ -468,6 +567,28 @@ def parse_bind_model_args(parts: list[str]) -> dict[str, object]:
|
|
|
468
567
|
return options
|
|
469
568
|
|
|
470
569
|
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def remove_model_text(context: McpContext, endpoint_id: str) -> str:
|
|
573
|
+
endpoint_id = endpoint_id.strip()
|
|
574
|
+
if not endpoint_id:
|
|
575
|
+
return "Usage: /remove-model <id>"
|
|
576
|
+
data = payload(call_tool(context, "agentram_remove_coding_model", {"id": endpoint_id}))
|
|
577
|
+
return f"Removed models: {data.get('removed', 0)}"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def disable_model_text(context: McpContext, endpoint_id: str) -> str:
|
|
581
|
+
endpoint_id = endpoint_id.strip()
|
|
582
|
+
if not endpoint_id:
|
|
583
|
+
return "Usage: /disable-model <id>"
|
|
584
|
+
data = payload(call_tool(context, "agentram_disable_coding_model", {"id": endpoint_id}))
|
|
585
|
+
return f"Disabled models: {data.get('changed', 0)}"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def clear_models_text(context: McpContext) -> str:
|
|
589
|
+
payload(call_tool(context, "agentram_clear_coding_models", {}))
|
|
590
|
+
return "Cleared all bound coding models."
|
|
591
|
+
|
|
471
592
|
def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
|
|
472
593
|
api_key_env = str(options.get("api_key_env", ""))
|
|
473
594
|
if api_key_env.startswith(("sk-", "gsk_", "sk_")):
|
|
@@ -489,7 +610,194 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
|
|
|
489
610
|
return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
|
|
490
611
|
|
|
491
612
|
|
|
492
|
-
def
|
|
613
|
+
def bind_agent_text(context: McpContext, options: dict[str, object]) -> str:
|
|
614
|
+
slot = str(options.get("slot", "")).strip().lower()
|
|
615
|
+
if slot not in {"supervisor", "main", "small"}:
|
|
616
|
+
return "Usage: /bind-agent <supervisor|main|small> <provider> <model>"
|
|
617
|
+
api_key_env = str(options.get("api_key_env", ""))
|
|
618
|
+
if api_key_env.startswith(("sk-", "gsk_", "sk_")):
|
|
619
|
+
return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
|
|
620
|
+
role = {"supervisor": "supervisor", "main": "coder", "small": "memory"}[slot]
|
|
621
|
+
endpoint = AgentModelEndpoint(
|
|
622
|
+
provider=str(options.get("provider", "openai-compatible")),
|
|
623
|
+
model=str(options.get("model", "")),
|
|
624
|
+
role=role,
|
|
625
|
+
base_url=str(options.get("base_url", "")),
|
|
626
|
+
api_key_env=api_key_env,
|
|
627
|
+
modalities=["text"],
|
|
628
|
+
)
|
|
629
|
+
context.profile_store.set_agent(slot, endpoint)
|
|
630
|
+
return f"Bound {slot} agent: provider={endpoint.provider} model={endpoint.model} role={endpoint.role}"
|
|
631
|
+
|
|
632
|
+
def workflow_text(context: McpContext) -> str:
|
|
633
|
+
agents = context.profile_store.list_agents()
|
|
634
|
+
if not agents:
|
|
635
|
+
return "No supervisor workflow agents bound. Use /bind-agent supervisor|main|small <provider> <model>."
|
|
636
|
+
lines = ["Supervisor workflow agents:"]
|
|
637
|
+
for slot in ["supervisor", "main", "small"]:
|
|
638
|
+
endpoint = agents.get(slot)
|
|
639
|
+
if endpoint:
|
|
640
|
+
lines.append(f"- {slot}: {endpoint.provider}:{endpoint.model} role={endpoint.role}")
|
|
641
|
+
else:
|
|
642
|
+
lines.append(f"- {slot}: not bound")
|
|
643
|
+
return "\n".join(lines)
|
|
644
|
+
|
|
645
|
+
def supervisor_workflow_text(context: McpContext, prompt: str) -> str:
|
|
646
|
+
agents = context.profile_store.list_agents()
|
|
647
|
+
supervisor = agents.get("supervisor")
|
|
648
|
+
main_agent = agents.get("main")
|
|
649
|
+
small_agent = agents.get("small")
|
|
650
|
+
memory_context = retrieve_text(context, prompt, task_id="codex", limit=8)
|
|
651
|
+
plan = supervisor_plan(supervisor, prompt, memory_context)
|
|
652
|
+
outputs: dict[str, str] = {}
|
|
653
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
654
|
+
futures = {}
|
|
655
|
+
if main_agent:
|
|
656
|
+
futures[executor.submit(call_agent_endpoint, main_agent, build_main_agent_prompt(prompt, plan, memory_context))] = "main"
|
|
657
|
+
if small_agent:
|
|
658
|
+
futures[executor.submit(call_agent_endpoint, small_agent, build_small_agent_prompt(prompt, plan, memory_context))] = "small"
|
|
659
|
+
for future, slot in list(futures.items()):
|
|
660
|
+
try:
|
|
661
|
+
outputs[slot] = future.result()
|
|
662
|
+
except Exception as error: # noqa: BLE001 - agent boundary
|
|
663
|
+
outputs[slot] = f"error: {model_error_hint(str(error))}"
|
|
664
|
+
if not main_agent:
|
|
665
|
+
outputs["main"] = "No main agent bound. Use /bind-agent main <provider> <model>."
|
|
666
|
+
if small_agent and outputs.get("small") and not outputs["small"].startswith("error:"):
|
|
667
|
+
call_tool(
|
|
668
|
+
context,
|
|
669
|
+
"agentram_emit_event",
|
|
670
|
+
{"type": "DECISION", "payload": {"content": outputs["small"]}, "task_id": "codex", "ingest": True},
|
|
671
|
+
)
|
|
672
|
+
return "\n".join(
|
|
673
|
+
[
|
|
674
|
+
"Workflow: supervisor -> main + small -> merge",
|
|
675
|
+
"Supervisor plan:",
|
|
676
|
+
plan,
|
|
677
|
+
"",
|
|
678
|
+
"Main agent result:",
|
|
679
|
+
outputs.get("main", "-"),
|
|
680
|
+
"",
|
|
681
|
+
"Small memory agent notes:",
|
|
682
|
+
outputs.get("small", "No small agent bound."),
|
|
683
|
+
]
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
def supervisor_plan(supervisor: AgentModelEndpoint | None, prompt: str, memory_context: str) -> str:
|
|
687
|
+
if not supervisor:
|
|
688
|
+
return "No supervisor bound. Fallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
|
|
689
|
+
try:
|
|
690
|
+
return call_agent_endpoint(supervisor, build_supervisor_prompt(prompt, memory_context))
|
|
691
|
+
except Exception as error: # noqa: BLE001 - supervisor boundary
|
|
692
|
+
return f"Supervisor error: {error}\nFallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
|
|
693
|
+
|
|
694
|
+
def call_agent_endpoint(endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
695
|
+
return MultiModelCodingOrchestrator([endpoint])._call_endpoint(endpoint, prompt)
|
|
696
|
+
|
|
697
|
+
def build_supervisor_prompt(prompt: str, memory_context: str) -> str:
|
|
698
|
+
return "\n".join(["You are AgentRAM supervisor. Act like a senior coding-agent boss.", "Create concise plan for main coding agent and small memory agent.", "Return only actionable plan. No markdown table.", "User request:", prompt, "AgentRAM context:", memory_context])
|
|
699
|
+
|
|
700
|
+
def build_main_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
|
|
701
|
+
return "\n".join(["You are AgentRAM main coding agent.", "Follow supervisor plan. Focus on planning, orchestration, coding, refactor, bug fix, and final result.", "Do not invent repo facts. Mention files to inspect/change.", "User request:", prompt, "Supervisor plan:", plan, "AgentRAM context:", memory_context])
|
|
702
|
+
|
|
703
|
+
def build_small_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
|
|
704
|
+
return "\n".join(["You are AgentRAM small memory agent.", "Do not edit code. Note plans, decisions, risks, files, next tasks, and coding facts for RAM.", "Return concise memory notes only.", "User request:", prompt, "Supervisor plan:", plan, "AgentRAM context:", memory_context])
|
|
705
|
+
|
|
706
|
+
def bind_router_text(context: McpContext, options: dict[str, object]) -> str:
|
|
707
|
+
api_key_env = str(options.get("api_key_env", ""))
|
|
708
|
+
if api_key_env.startswith(("sk-", "gsk_", "sk_")):
|
|
709
|
+
return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
|
|
710
|
+
endpoint = AgentModelEndpoint(
|
|
711
|
+
provider=str(options.get("provider", "openai-compatible")),
|
|
712
|
+
model=str(options.get("model", "")),
|
|
713
|
+
role="router",
|
|
714
|
+
base_url=str(options.get("base_url", "")),
|
|
715
|
+
api_key_env=api_key_env,
|
|
716
|
+
modalities=["text"],
|
|
717
|
+
)
|
|
718
|
+
context.profile_store.set_router(endpoint)
|
|
719
|
+
return f"Bound router: provider={endpoint.provider} model={endpoint.model}"
|
|
720
|
+
|
|
721
|
+
def route_text(context: McpContext, mode: str) -> str:
|
|
722
|
+
normalized = mode.strip().lower() or "status"
|
|
723
|
+
if normalized == "on":
|
|
724
|
+
context.profile_store.set_auto_route(True)
|
|
725
|
+
elif normalized == "off":
|
|
726
|
+
context.profile_store.set_auto_route(False)
|
|
727
|
+
elif normalized != "status":
|
|
728
|
+
return "Usage: /route on|off|status"
|
|
729
|
+
router = context.profile_store.get_router()
|
|
730
|
+
return "\n".join(
|
|
731
|
+
[
|
|
732
|
+
f"Auto route: {'on' if context.profile_store.auto_route_enabled() else 'off'}",
|
|
733
|
+
f"Router model: {router.provider}:{router.model}" if router else "Router model: heuristic fallback",
|
|
734
|
+
]
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
def route_plain_prompt_text(context: McpContext, prompt: str) -> str:
|
|
738
|
+
decision = classify_prompt_intent(context, prompt)
|
|
739
|
+
intent = str(decision.get("intent", "chat"))
|
|
740
|
+
confidence = float(decision.get("confidence", 0.0))
|
|
741
|
+
prefix = f"Route: {intent} confidence={confidence:.2f}"
|
|
742
|
+
if intent == "coding":
|
|
743
|
+
if context.profile_store.list_endpoints():
|
|
744
|
+
return prefix + "\n" + orchestrate_text(context, prompt, execute=True)
|
|
745
|
+
context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
|
|
746
|
+
return prefix + "\nNo coding models bound. Use /bind-model.\nCoding chat captured. Relevant context:\n" + context_block
|
|
747
|
+
if intent == "planning":
|
|
748
|
+
return prefix + "\n" + drift_text(context, prompt) + "\n\n" + goal_text(context)
|
|
749
|
+
if intent == "memory":
|
|
750
|
+
return prefix + "\n" + note_text(context, prompt)
|
|
751
|
+
context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
|
|
752
|
+
return prefix + "\nCoding chat captured. Relevant context:\n" + context_block
|
|
753
|
+
|
|
754
|
+
def classify_prompt_intent(context: McpContext, prompt: str) -> dict[str, object]:
|
|
755
|
+
router = context.profile_store.get_router()
|
|
756
|
+
if router:
|
|
757
|
+
try:
|
|
758
|
+
response = MultiModelCodingOrchestrator([router])._call_endpoint(router, build_router_prompt(prompt))
|
|
759
|
+
parsed = json.loads(extract_json_object(response))
|
|
760
|
+
if isinstance(parsed, dict) and parsed.get("intent") in {"coding", "planning", "memory", "chat"}:
|
|
761
|
+
return {"intent": parsed.get("intent"), "confidence": float(parsed.get("confidence", 0.0)), "source": "model"}
|
|
762
|
+
except Exception as error: # noqa: BLE001 - router fallback boundary
|
|
763
|
+
return {**heuristic_intent(prompt), "source": "heuristic", "router_error": str(error)}
|
|
764
|
+
return {**heuristic_intent(prompt), "source": "heuristic"}
|
|
765
|
+
|
|
766
|
+
def build_router_prompt(prompt: str) -> str:
|
|
767
|
+
return "\n".join(
|
|
768
|
+
[
|
|
769
|
+
"Classify user prompt for AgentRAM routing.",
|
|
770
|
+
"Return only JSON, no markdown.",
|
|
771
|
+
"Schema: {\"intent\":\"coding|planning|memory|chat\",\"confidence\":0.0,\"reason\":\"short\"}",
|
|
772
|
+
"coding = edit/review/debug/refactor/test/code/file requests.",
|
|
773
|
+
"planning = goal stack, milestone, mission, next task, drift, roadmap.",
|
|
774
|
+
"memory = remember/note/decision/fact to save.",
|
|
775
|
+
"chat = other normal conversation.",
|
|
776
|
+
f"Prompt: {prompt}",
|
|
777
|
+
]
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def extract_json_object(text: str) -> str:
|
|
781
|
+
start = text.find("{")
|
|
782
|
+
end = text.rfind("}")
|
|
783
|
+
if start == -1 or end == -1 or end < start:
|
|
784
|
+
return "{}"
|
|
785
|
+
return text[start : end + 1]
|
|
786
|
+
|
|
787
|
+
def heuristic_intent(prompt: str) -> dict[str, object]:
|
|
788
|
+
lower = prompt.lower()
|
|
789
|
+
memory_words = ["ghi nhớ", "remember", "note", "decision", "lưu lại", "save this"]
|
|
790
|
+
planning_words = ["goal", "kế hoạch", "ke hoach", "milestone", "mission", "drift", "next task", "roadmap", "mục tiêu", "muc tieu"]
|
|
791
|
+
coding_words = ["sửa", "sua", "fix", "bug", "code", "test", "refactor", "review", "file", ".py", ".js", "endpoint", "class", "function"]
|
|
792
|
+
if any(word in lower for word in memory_words):
|
|
793
|
+
return {"intent": "memory", "confidence": 0.75}
|
|
794
|
+
if any(word in lower for word in planning_words):
|
|
795
|
+
return {"intent": "planning", "confidence": 0.75}
|
|
796
|
+
if any(word in lower for word in coding_words):
|
|
797
|
+
return {"intent": "coding", "confidence": 0.72}
|
|
798
|
+
return {"intent": "chat", "confidence": 0.55}
|
|
799
|
+
|
|
800
|
+
def orchestrate_text(context: McpContext, task: str, files: list[str] | None = None, execute: bool = False) -> str:
|
|
493
801
|
if not task.strip():
|
|
494
802
|
return "Usage: /orchestrate <coding task>"
|
|
495
803
|
tool = "agentram_run_coding_orchestration" if execute else "agentram_build_coding_orchestration"
|
|
@@ -505,9 +813,24 @@ def orchestrate_text(context: McpContext, task: str, files: list[str] | None = N
|
|
|
505
813
|
endpoint = result.get("endpoint", {})
|
|
506
814
|
status = "ok" if result.get("ok") else "error"
|
|
507
815
|
output = result.get("output") or result.get("error", "")
|
|
508
|
-
lines.append(f"- {endpoint.get('id')} {status}: {str(output)[:
|
|
816
|
+
lines.append(f"- {endpoint.get('id')} {status}: {model_error_hint(str(output))[:360]}")
|
|
509
817
|
return "\n".join(lines)
|
|
510
818
|
|
|
819
|
+
|
|
820
|
+
def model_error_hint(message: str) -> str:
|
|
821
|
+
lower = message.lower()
|
|
822
|
+
if "base_url is required" in lower:
|
|
823
|
+
return message + " | Fix: /remove-model <id> or re-bind with --base-url http://localhost:8000/v1"
|
|
824
|
+
if "actively refused" in lower or "connection refused" in lower or "winerror 10061" in lower:
|
|
825
|
+
return message + " | Fix: start your OpenAI-compatible server at base_url, or change --base-url."
|
|
826
|
+
if "http 404" in lower or "404: not found" in lower:
|
|
827
|
+
return message + " | Fix: verify base_url includes /v1, provider supports /chat/completions, and model name exists."
|
|
828
|
+
if "returned non-json" in lower or "expecting value" in lower:
|
|
829
|
+
return message + " | Fix: endpoint returned empty/HTML/non-JSON. Check base_url, auth key, proxy, and server logs."
|
|
830
|
+
if "missing api key env" in lower:
|
|
831
|
+
return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
|
|
832
|
+
return message
|
|
833
|
+
|
|
511
834
|
def claude_template_root() -> Path:
|
|
512
835
|
return Path(__file__).resolve().parent.parent / ".claude"
|
|
513
836
|
|
|
@@ -574,7 +897,7 @@ BUTTONS = {
|
|
|
574
897
|
}
|
|
575
898
|
|
|
576
899
|
|
|
577
|
-
def draw_screen(context: McpContext, messages: list[str]) -> None:
|
|
900
|
+
def draw_screen(context: McpContext, messages: list[str], activity: str | None = None) -> None:
|
|
578
901
|
clear_terminal()
|
|
579
902
|
width = min(max(shutil.get_terminal_size((110, 34)).columns, 86), 132)
|
|
580
903
|
left_width = 30
|
|
@@ -584,14 +907,16 @@ def draw_screen(context: McpContext, messages: list[str]) -> None:
|
|
|
584
907
|
print("+" + "-" * (width - 2) + "+")
|
|
585
908
|
|
|
586
909
|
sidebar = sidebar_lines(context)
|
|
587
|
-
|
|
910
|
+
display_messages = [*messages, activity] if activity else messages
|
|
911
|
+
chat = wrap_messages(display_messages, right_width - 4)[-22:]
|
|
588
912
|
rows = max(len(sidebar), len(chat), 22)
|
|
589
913
|
for index in range(rows):
|
|
590
914
|
left = sidebar[index] if index < len(sidebar) else ""
|
|
591
915
|
right = chat[index] if index < len(chat) else ""
|
|
592
916
|
print("| " + left[: left_width - 2].ljust(left_width - 2) + "| " + right[: right_width - 2].ljust(right_width - 2) + "|")
|
|
593
917
|
print("+" + "-" * (left_width - 1) + "+" + "-" * (right_width) + "+")
|
|
594
|
-
|
|
918
|
+
footer = "Thinking/acting..." if activity else "Input: type / for live dropdown. Enter '/' for full palette. /ask calls models."
|
|
919
|
+
print(box_text(footer, width))
|
|
595
920
|
print("+" + "-" * (width - 2) + "+")
|
|
596
921
|
|
|
597
922
|
|
|
@@ -626,6 +951,7 @@ def sidebar_lines(context: McpContext) -> list[str]:
|
|
|
626
951
|
" /agents",
|
|
627
952
|
" /claude-install .",
|
|
628
953
|
" /models",
|
|
954
|
+
" /clear-models",
|
|
629
955
|
" /ask <prompt>",
|
|
630
956
|
]
|
|
631
957
|
|
package/agentram/mcp_server.py
CHANGED
|
@@ -244,6 +244,29 @@ def tools_list() -> dict[str, Any]:
|
|
|
244
244
|
"required": ["provider", "model"],
|
|
245
245
|
},
|
|
246
246
|
},
|
|
247
|
+
{
|
|
248
|
+
"name": "agentram_remove_coding_model",
|
|
249
|
+
"description": "Remove one bound coding model endpoint by id.",
|
|
250
|
+
"inputSchema": {
|
|
251
|
+
"type": "object",
|
|
252
|
+
"properties": {"id": {"type": "string"}},
|
|
253
|
+
"required": ["id"],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"name": "agentram_disable_coding_model",
|
|
258
|
+
"description": "Disable one bound coding model endpoint by id without deleting it.",
|
|
259
|
+
"inputSchema": {
|
|
260
|
+
"type": "object",
|
|
261
|
+
"properties": {"id": {"type": "string"}},
|
|
262
|
+
"required": ["id"],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"name": "agentram_clear_coding_models",
|
|
267
|
+
"description": "Remove all bound coding model endpoints from this project RAM.",
|
|
268
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
269
|
+
},
|
|
247
270
|
{
|
|
248
271
|
"name": "agentram_read_coding_models",
|
|
249
272
|
"description": "Read coding-agent model endpoints bound to this project RAM.",
|
|
@@ -349,6 +372,24 @@ def call_tool(context: McpContext, name: str, arguments: dict[str, Any]) -> dict
|
|
|
349
372
|
data = context.profile_store.upsert(endpoint)
|
|
350
373
|
return tool_result({"ok": True, "endpoint": endpoint.to_dict(), "profile": data})
|
|
351
374
|
|
|
375
|
+
if name == "agentram_remove_coding_model":
|
|
376
|
+
endpoint_id = str(arguments.get("id", ""))
|
|
377
|
+
if not endpoint_id.strip():
|
|
378
|
+
raise ValueError("id is required")
|
|
379
|
+
data = context.profile_store.remove(endpoint_id)
|
|
380
|
+
return tool_result({"ok": True, **data})
|
|
381
|
+
|
|
382
|
+
if name == "agentram_disable_coding_model":
|
|
383
|
+
endpoint_id = str(arguments.get("id", ""))
|
|
384
|
+
if not endpoint_id.strip():
|
|
385
|
+
raise ValueError("id is required")
|
|
386
|
+
data = context.profile_store.set_enabled(endpoint_id, False)
|
|
387
|
+
return tool_result({"ok": True, **data})
|
|
388
|
+
|
|
389
|
+
if name == "agentram_clear_coding_models":
|
|
390
|
+
data = context.profile_store.clear()
|
|
391
|
+
return tool_result({"ok": True, **data})
|
|
392
|
+
|
|
352
393
|
if name == "agentram_read_coding_models":
|
|
353
394
|
endpoints = context.profile_store.list_endpoints()
|
|
354
395
|
return tool_result({"endpoints": [endpoint.to_dict() for endpoint in endpoints]})
|
|
@@ -4,6 +4,9 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import urllib.error
|
|
7
10
|
import urllib.request
|
|
8
11
|
from typing import Any, Callable
|
|
9
12
|
from uuid import uuid4
|
|
@@ -106,6 +109,8 @@ class MultiModelCodingOrchestrator:
|
|
|
106
109
|
return self._call_openai_compatible(endpoint, prompt)
|
|
107
110
|
if provider in {"anthropic", "claude"}:
|
|
108
111
|
return self._call_anthropic(endpoint, prompt)
|
|
112
|
+
if provider in {"claude-code", "claude-cli", "claude-subagent", "claude-code-subagent"}:
|
|
113
|
+
return self._call_claude_code_subagent(endpoint, prompt)
|
|
109
114
|
raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
|
|
110
115
|
|
|
111
116
|
def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
@@ -121,8 +126,20 @@ class MultiModelCodingOrchestrator:
|
|
|
121
126
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} if api_key else {"Content-Type": "application/json"},
|
|
122
127
|
method="POST",
|
|
123
128
|
)
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
url = endpoint.base_url.rstrip("/") + "/chat/completions"
|
|
130
|
+
try:
|
|
131
|
+
with urllib.request.urlopen(request, timeout=120) as response:
|
|
132
|
+
raw = response.read().decode("utf-8")
|
|
133
|
+
except urllib.error.HTTPError as error:
|
|
134
|
+
body = error.read().decode("utf-8", errors="replace") if error.fp else ""
|
|
135
|
+
hint = f"OpenAI-compatible HTTP {error.code} at {url}"
|
|
136
|
+
if error.code == 404:
|
|
137
|
+
hint += " | base_url should include /v1 but not /chat/completions; verify provider URL and model name"
|
|
138
|
+
raise RuntimeError(f"{hint} | response={body[:500]}") from error
|
|
139
|
+
try:
|
|
140
|
+
data = json.loads(raw)
|
|
141
|
+
except json.JSONDecodeError as error:
|
|
142
|
+
raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
|
|
126
143
|
return str(data.get("choices", [{}])[0].get("message", {}).get("content", ""))
|
|
127
144
|
|
|
128
145
|
def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
@@ -142,4 +159,32 @@ class MultiModelCodingOrchestrator:
|
|
|
142
159
|
content = data.get("content", [])
|
|
143
160
|
if content and isinstance(content, list):
|
|
144
161
|
return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
|
|
145
|
-
return str(data)
|
|
162
|
+
return str(data)
|
|
163
|
+
|
|
164
|
+
def _call_claude_code_subagent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
165
|
+
command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CLAUDE_COMMAND", "claude")
|
|
166
|
+
args = shlex.split(command)
|
|
167
|
+
if not args:
|
|
168
|
+
raise ValueError("claude command is required")
|
|
169
|
+
subagent_prompt = "\n".join(
|
|
170
|
+
[
|
|
171
|
+
f"Use Claude Code subagent entrypoint: {endpoint.model}",
|
|
172
|
+
f"Subagent role: {endpoint.role}",
|
|
173
|
+
"If that subagent exists in .claude/agents, follow it. If not, act as this role.",
|
|
174
|
+
"Return concise coding-agent output only.",
|
|
175
|
+
"",
|
|
176
|
+
prompt,
|
|
177
|
+
]
|
|
178
|
+
)
|
|
179
|
+
process = subprocess.run(
|
|
180
|
+
[*args, "-p", subagent_prompt],
|
|
181
|
+
check=False,
|
|
182
|
+
capture_output=True,
|
|
183
|
+
encoding="utf-8",
|
|
184
|
+
timeout=300,
|
|
185
|
+
)
|
|
186
|
+
output = (process.stdout or "").strip()
|
|
187
|
+
error = (process.stderr or "").strip()
|
|
188
|
+
if process.returncode != 0:
|
|
189
|
+
raise RuntimeError(error or f"claude command failed with exit code {process.returncode}")
|
|
190
|
+
return output or error
|
package/agentram/storage.py
CHANGED
|
@@ -80,15 +80,27 @@ class JsonAgentProfileStore:
|
|
|
80
80
|
self.path = Path(path)
|
|
81
81
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
82
|
|
|
83
|
-
def load(self) -> dict[str, object]:
|
|
84
|
-
if not self.path.exists():
|
|
85
|
-
return {"updated_at": utc_now(), "endpoints": []}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return
|
|
83
|
+
def load(self) -> dict[str, object]:
|
|
84
|
+
if not self.path.exists():
|
|
85
|
+
return {"updated_at": utc_now(), "endpoints": [], "router": None, "auto_route": True, "agents": {}}
|
|
86
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
87
|
+
data.setdefault("endpoints", [])
|
|
88
|
+
data.setdefault("router", None)
|
|
89
|
+
data.setdefault("auto_route", True)
|
|
90
|
+
data.setdefault("agents", {})
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
def save(self, endpoints: list[AgentModelEndpoint]) -> dict[str, object]:
|
|
94
|
+
current = self.load()
|
|
95
|
+
payload = {
|
|
96
|
+
"updated_at": utc_now(),
|
|
97
|
+
"endpoints": [endpoint.to_dict() for endpoint in endpoints],
|
|
98
|
+
"router": current.get("router"),
|
|
99
|
+
"auto_route": bool(current.get("auto_route", True)),
|
|
100
|
+
"agents": current.get("agents", {}),
|
|
101
|
+
}
|
|
102
|
+
self.path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
103
|
+
return payload
|
|
92
104
|
|
|
93
105
|
def list_endpoints(self) -> list[AgentModelEndpoint]:
|
|
94
106
|
data = self.load()
|
|
@@ -108,6 +120,86 @@ class JsonAgentProfileStore:
|
|
|
108
120
|
updated.append(endpoint)
|
|
109
121
|
return self.save(updated)
|
|
110
122
|
|
|
123
|
+
def remove(self, endpoint_id: str) -> dict[str, object]:
|
|
124
|
+
endpoints = self.list_endpoints()
|
|
125
|
+
kept = [endpoint for endpoint in endpoints if endpoint.id != endpoint_id]
|
|
126
|
+
payload = self.save(kept)
|
|
127
|
+
payload["removed"] = len(endpoints) - len(kept)
|
|
128
|
+
return payload
|
|
129
|
+
|
|
130
|
+
def set_enabled(self, endpoint_id: str, enabled: bool) -> dict[str, object]:
|
|
131
|
+
endpoints = self.list_endpoints()
|
|
132
|
+
updated: list[AgentModelEndpoint] = []
|
|
133
|
+
changed = 0
|
|
134
|
+
for endpoint in endpoints:
|
|
135
|
+
if endpoint.id == endpoint_id:
|
|
136
|
+
data = endpoint.to_dict()
|
|
137
|
+
data["enabled"] = enabled
|
|
138
|
+
updated.append(AgentModelEndpoint.from_dict(data))
|
|
139
|
+
changed += 1
|
|
140
|
+
else:
|
|
141
|
+
updated.append(endpoint)
|
|
142
|
+
payload = self.save(updated)
|
|
143
|
+
payload["changed"] = changed
|
|
144
|
+
return payload
|
|
145
|
+
|
|
146
|
+
def clear(self) -> dict[str, object]:
|
|
147
|
+
payload = self.save([])
|
|
148
|
+
payload["cleared"] = True
|
|
149
|
+
return payload
|
|
150
|
+
|
|
151
|
+
def get_router(self) -> AgentModelEndpoint | None:
|
|
152
|
+
router = self.load().get("router")
|
|
153
|
+
if not isinstance(router, dict):
|
|
154
|
+
return None
|
|
155
|
+
return AgentModelEndpoint.from_dict(router)
|
|
156
|
+
|
|
157
|
+
def set_router(self, endpoint: AgentModelEndpoint | None) -> dict[str, object]:
|
|
158
|
+
current = self.load()
|
|
159
|
+
current["updated_at"] = utc_now()
|
|
160
|
+
current["router"] = endpoint.to_dict() if endpoint else None
|
|
161
|
+
self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
162
|
+
return current
|
|
163
|
+
|
|
164
|
+
def auto_route_enabled(self) -> bool:
|
|
165
|
+
return bool(self.load().get("auto_route", True))
|
|
166
|
+
|
|
167
|
+
def set_auto_route(self, enabled: bool) -> dict[str, object]:
|
|
168
|
+
current = self.load()
|
|
169
|
+
current["updated_at"] = utc_now()
|
|
170
|
+
current["auto_route"] = enabled
|
|
171
|
+
self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
172
|
+
return current
|
|
173
|
+
|
|
174
|
+
def get_agent(self, slot: str) -> AgentModelEndpoint | None:
|
|
175
|
+
agents = self.load().get("agents", {})
|
|
176
|
+
if not isinstance(agents, dict):
|
|
177
|
+
return None
|
|
178
|
+
item = agents.get(slot)
|
|
179
|
+
if not isinstance(item, dict):
|
|
180
|
+
return None
|
|
181
|
+
return AgentModelEndpoint.from_dict(item)
|
|
182
|
+
|
|
183
|
+
def set_agent(self, slot: str, endpoint: AgentModelEndpoint | None) -> dict[str, object]:
|
|
184
|
+
current = self.load()
|
|
185
|
+
agents = current.get("agents", {})
|
|
186
|
+
if not isinstance(agents, dict):
|
|
187
|
+
agents = {}
|
|
188
|
+
if endpoint is None:
|
|
189
|
+
agents.pop(slot, None)
|
|
190
|
+
else:
|
|
191
|
+
agents[slot] = endpoint.to_dict()
|
|
192
|
+
current["agents"] = agents
|
|
193
|
+
current["updated_at"] = utc_now()
|
|
194
|
+
self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
195
|
+
return current
|
|
196
|
+
|
|
197
|
+
def list_agents(self) -> dict[str, AgentModelEndpoint]:
|
|
198
|
+
agents = self.load().get("agents", {})
|
|
199
|
+
if not isinstance(agents, dict):
|
|
200
|
+
return {}
|
|
201
|
+
return {slot: AgentModelEndpoint.from_dict(item) for slot, item in agents.items() if isinstance(item, dict)}
|
|
202
|
+
|
|
111
203
|
|
|
112
204
|
def ensure_ram_storage(root: str | Path) -> dict[str, object]:
|
|
113
205
|
ram_root = Path(root)
|
package/package.json
CHANGED