agentram 0.1.3 → 0.1.6
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 +139 -11
- package/agentram/cli.py +462 -44
- package/agentram/mcp_server.py +41 -0
- package/agentram/orchestration.py +33 -1
- package/agentram/storage.py +101 -9
- package/package.json +1 -1
- package/pyproject.toml +4 -1
package/README.md
CHANGED
|
@@ -53,9 +53,10 @@ If using the Claude plugin, `.mcp.json` sets `AGENTRAM_AUTO_INIT=true`, so these
|
|
|
53
53
|
|
|
54
54
|
## Terminal UI CLI
|
|
55
55
|
|
|
56
|
-
AgentRAM
|
|
56
|
+
AgentRAM includes a terminal UI with Claude-like slash autocomplete. It uses `prompt_toolkit` when installed and falls back to plain `input()` otherwise:
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
|
+
pip install -e ".[tui]"
|
|
59
60
|
agentram
|
|
60
61
|
```
|
|
61
62
|
|
|
@@ -357,12 +358,20 @@ Minimum usage pattern:
|
|
|
357
358
|
|
|
358
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.
|
|
359
360
|
|
|
360
|
-
Bind models:
|
|
361
|
-
|
|
362
|
-
```bash
|
|
363
|
-
agentram bind-model claude
|
|
364
|
-
agentram bind-model
|
|
365
|
-
|
|
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
|
+
```
|
|
366
375
|
|
|
367
376
|
Build a dry-run orchestration plan:
|
|
368
377
|
|
|
@@ -387,10 +396,11 @@ agentram_run_coding_orchestration
|
|
|
387
396
|
|
|
388
397
|
TUI slash commands. Plain text only records/retrieves RAM; use `/ask` to call bound models:
|
|
389
398
|
|
|
390
|
-
```text
|
|
391
|
-
/bind-model claude
|
|
392
|
-
/
|
|
393
|
-
/
|
|
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
|
|
394
404
|
/ask xin chào
|
|
395
405
|
```
|
|
396
406
|
|
|
@@ -870,3 +880,121 @@ Inside TUI:
|
|
|
870
880
|
```
|
|
871
881
|
|
|
872
882
|
Do not paste raw API keys into `/bind-model`. `api_key_env` means environment variable name, e.g. `OPENAI_API_KEY`.
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
### Optional Rich TUI
|
|
886
|
+
|
|
887
|
+
For live slash-command dropdown and command metadata, install the optional TUI extra:
|
|
888
|
+
|
|
889
|
+
```bash
|
|
890
|
+
pip install agentram[tui]
|
|
891
|
+
```
|
|
892
|
+
|
|
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.",
|
|
@@ -18,6 +22,37 @@ SUBAGENTS: dict[str, str] = {
|
|
|
18
22
|
"documenter": "Update README or usage notes from current implementation. Keep docs concise and runnable.",
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
26
|
+
("/help", "Show commands"),
|
|
27
|
+
("/init", "Create .agentram files"),
|
|
28
|
+
("/status", "Show RAM status"),
|
|
29
|
+
("/retrieve <query>", "Print relevant RAM context"),
|
|
30
|
+
("/goal", "Show Goal Stack"),
|
|
31
|
+
("/set-goal key=value ...", "Update mission/current_goal/current_task/etc"),
|
|
32
|
+
("/drift <task>", "Check goal drift"),
|
|
33
|
+
("/events", "Show latest events"),
|
|
34
|
+
("/replay", "Replay events into memory"),
|
|
35
|
+
("/agents", "List Claude-style subagent presets"),
|
|
36
|
+
("/agent <name> <task>", "Build Claude Code subagent prompt"),
|
|
37
|
+
("/models", "List bound coding models"),
|
|
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"),
|
|
47
|
+
("/ask <prompt>", "Execute bound models and return outputs"),
|
|
48
|
+
("/claude-install [path]", "Install .claude commands and agents"),
|
|
49
|
+
("/note <text>", "Save decision note"),
|
|
50
|
+
("/clear", "Clear chat"),
|
|
51
|
+
("/exit", "Quit"),
|
|
52
|
+
("Plain text", "Capture coding chat message and retrieve context"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
21
56
|
SLASH_HELP = """Slash commands:
|
|
22
57
|
/help Show commands
|
|
23
58
|
/init Create .agentram files
|
|
@@ -31,15 +66,22 @@ SLASH_HELP = """Slash commands:
|
|
|
31
66
|
/agents List subagent presets
|
|
32
67
|
/agent <name> <task> Build Claude Code subagent prompt
|
|
33
68
|
/models List bound coding models
|
|
34
|
-
/
|
|
35
|
-
/
|
|
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
|
|
36
78
|
/ask <prompt> Execute bound models and return outputs
|
|
37
79
|
/claude-install [path] Install .claude commands and agents
|
|
38
80
|
/note <text> Save decision note
|
|
39
81
|
/clear Clear chat
|
|
40
82
|
/exit Quit
|
|
41
|
-
Plain text Capture coding chat message and retrieve context
|
|
42
|
-
""".strip()
|
|
83
|
+
Plain text Capture coding chat message and retrieve context
|
|
84
|
+
""".strip()
|
|
43
85
|
|
|
44
86
|
|
|
45
87
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -53,14 +95,39 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
53
95
|
subparsers.add_parser("goal", help="Show goal stack.")
|
|
54
96
|
subparsers.add_parser("agents", help="List subagent presets.")
|
|
55
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.")
|
|
56
105
|
|
|
57
106
|
bind_model = subparsers.add_parser("bind-model", help="Bind a coding model endpoint.")
|
|
58
|
-
bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq.")
|
|
59
|
-
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.")
|
|
60
109
|
bind_model.add_argument("--role", default="coder", help="Agent role.")
|
|
61
110
|
bind_model.add_argument("--base-url", default="", help="Provider base URL.")
|
|
62
|
-
bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
|
|
63
|
-
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.")
|
|
64
131
|
|
|
65
132
|
orchestrate = subparsers.add_parser("orchestrate", help="Build or run multi-model coding orchestration.")
|
|
66
133
|
orchestrate.add_argument("task", nargs="?", default="", help="Coding task.")
|
|
@@ -132,9 +199,30 @@ def run_command(args: argparse.Namespace) -> int:
|
|
|
132
199
|
if command == "models":
|
|
133
200
|
print(models_text(context))
|
|
134
201
|
return 0
|
|
135
|
-
if command == "
|
|
136
|
-
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))
|
|
137
207
|
return 0
|
|
208
|
+
if command == "clear-models":
|
|
209
|
+
print(clear_models_text(context))
|
|
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
|
|
138
226
|
if command == "orchestrate":
|
|
139
227
|
task = args.task or input("Task > ").strip()
|
|
140
228
|
print(orchestrate_text(context, task, files=args.file, execute=args.execute))
|
|
@@ -151,37 +239,98 @@ def run_command(args: argparse.Namespace) -> int:
|
|
|
151
239
|
|
|
152
240
|
def run_tui(context: McpContext) -> None:
|
|
153
241
|
context.init_storage()
|
|
154
|
-
|
|
242
|
+
prompt_session = create_prompt_session()
|
|
243
|
+
input_hint = "live slash autocomplete enabled" if prompt_session else "basic input fallback; install prompt_toolkit for live slash dropdown"
|
|
244
|
+
messages = [
|
|
245
|
+
"system > AgentRAM coding-agent CLI ready",
|
|
246
|
+
f"system > RAM root: {context.ram_root}",
|
|
247
|
+
f"system > {input_hint}",
|
|
248
|
+
]
|
|
155
249
|
while True:
|
|
156
250
|
draw_screen(context, messages)
|
|
157
|
-
|
|
251
|
+
try:
|
|
252
|
+
user_input = read_tui_input(prompt_session).strip()
|
|
253
|
+
except (EOFError, KeyboardInterrupt):
|
|
254
|
+
return
|
|
158
255
|
if user_input in {"0", "q", "quit", "exit", "/exit", "/quit"}:
|
|
159
256
|
return
|
|
160
257
|
if user_input in BUTTONS:
|
|
161
258
|
user_input = BUTTONS[user_input]
|
|
162
259
|
if user_input:
|
|
163
260
|
messages.append(f"user > {user_input}")
|
|
164
|
-
response =
|
|
261
|
+
response = run_with_activity(context, messages, user_input)
|
|
165
262
|
if response == "__clear__":
|
|
166
263
|
messages.clear()
|
|
167
264
|
messages.append("system > Chat cleared.")
|
|
168
265
|
elif response:
|
|
169
266
|
messages.append("agentram > " + response)
|
|
170
|
-
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()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def slash_command_names() -> list[str]:
|
|
283
|
+
return [command.split()[0] for command, _ in SLASH_COMMANDS if command.startswith("/")]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def slash_command_meta() -> dict[str, str]:
|
|
287
|
+
return {command.split()[0]: description for command, description in SLASH_COMMANDS if command.startswith("/")}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def create_prompt_session() -> Any | None:
|
|
291
|
+
try:
|
|
292
|
+
from prompt_toolkit import PromptSession
|
|
293
|
+
from prompt_toolkit.completion import WordCompleter
|
|
294
|
+
except ImportError:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
completer = WordCompleter(
|
|
298
|
+
slash_command_names(),
|
|
299
|
+
meta_dict=slash_command_meta(),
|
|
300
|
+
ignore_case=True,
|
|
301
|
+
sentence=True,
|
|
302
|
+
)
|
|
303
|
+
return PromptSession(
|
|
304
|
+
completer=completer,
|
|
305
|
+
complete_while_typing=True,
|
|
306
|
+
reserve_space_for_menu=12,
|
|
307
|
+
bottom_toolbar="Type / for slash commands · /agents for subagents · /ask to call bound models",
|
|
308
|
+
)
|
|
309
|
+
|
|
171
310
|
|
|
311
|
+
def read_tui_input(prompt_session: Any | None) -> str:
|
|
312
|
+
if prompt_session is None:
|
|
313
|
+
return input("You > ")
|
|
314
|
+
return prompt_session.prompt("You > ")
|
|
172
315
|
|
|
173
|
-
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:
|
|
174
317
|
if not text:
|
|
175
318
|
return SLASH_HELP
|
|
319
|
+
if text == "/":
|
|
320
|
+
return slash_palette_text()
|
|
176
321
|
if text.startswith("/"):
|
|
177
322
|
return handle_slash_command(context, text, messages)
|
|
178
|
-
call_tool(
|
|
179
|
-
context,
|
|
180
|
-
"agentram_emit_event",
|
|
181
|
-
{"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
185
334
|
|
|
186
335
|
|
|
187
336
|
def handle_slash_command(context: McpContext, text: str, messages: list[str] | None = None) -> str:
|
|
@@ -212,10 +361,30 @@ def handle_slash_command(context: McpContext, text: str, messages: list[str] | N
|
|
|
212
361
|
return agents_text()
|
|
213
362
|
if command == "/models":
|
|
214
363
|
return models_text(context)
|
|
215
|
-
if command == "/
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
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")
|
|
219
388
|
if command == "/orchestrate":
|
|
220
389
|
execute = "--execute" in rest
|
|
221
390
|
task = raw_rest.replace("--execute", "").strip()
|
|
@@ -313,8 +482,34 @@ def replay_text(context: McpContext, arguments: dict[str, object]) -> str:
|
|
|
313
482
|
|
|
314
483
|
|
|
315
484
|
def agents_text() -> str:
|
|
316
|
-
|
|
317
|
-
|
|
485
|
+
rows = [
|
|
486
|
+
("agentram-memory", "Memory", "Records durable notes. Never edits code."),
|
|
487
|
+
("agentram-planner", "Planner", "Checks Goal Stack and decomposes work."),
|
|
488
|
+
("agentram-reviewer", "Reviewer", "Reviews changes, tests, and drift risk."),
|
|
489
|
+
("agentram-docs", "Docs", "Updates README and usage docs."),
|
|
490
|
+
]
|
|
491
|
+
lines = ["Claude Code subagents", ""]
|
|
492
|
+
for name, label, description in rows:
|
|
493
|
+
lines.append(f" {name.ljust(22)} {label}")
|
|
494
|
+
lines.append(f" {description}")
|
|
495
|
+
lines.append("")
|
|
496
|
+
lines.append("Use: /agent reviewer <task>")
|
|
497
|
+
lines.append("Install: /claude-install .")
|
|
498
|
+
return "\n".join(lines)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def slash_palette_text(filter_text: str = "") -> str:
|
|
502
|
+
normalized = filter_text.strip().lower().lstrip("/")
|
|
503
|
+
rows = []
|
|
504
|
+
for command, description in SLASH_COMMANDS:
|
|
505
|
+
command_key = command.lower().lstrip("/")
|
|
506
|
+
if normalized and normalized not in command_key:
|
|
507
|
+
continue
|
|
508
|
+
rows.append((command, description))
|
|
509
|
+
command_width = max([len(command) for command, _ in rows] + [12])
|
|
510
|
+
lines = ["Slash commands", ""]
|
|
511
|
+
for command, description in rows:
|
|
512
|
+
lines.append(f" {command.ljust(command_width)} {description}")
|
|
318
513
|
return "\n".join(lines)
|
|
319
514
|
|
|
320
515
|
|
|
@@ -343,7 +538,7 @@ def models_text(context: McpContext) -> str:
|
|
|
343
538
|
data = payload(call_tool(context, "agentram_read_coding_models", {}))
|
|
344
539
|
endpoints = data.get("endpoints", [])
|
|
345
540
|
if not endpoints:
|
|
346
|
-
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"
|
|
347
542
|
lines = ["Bound coding models:"]
|
|
348
543
|
for endpoint in endpoints:
|
|
349
544
|
lines.append(f"- {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')} modalities={','.join(endpoint.get('modalities', []))}")
|
|
@@ -372,6 +567,28 @@ def parse_bind_model_args(parts: list[str]) -> dict[str, object]:
|
|
|
372
567
|
return options
|
|
373
568
|
|
|
374
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
|
+
|
|
375
592
|
def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
|
|
376
593
|
api_key_env = str(options.get("api_key_env", ""))
|
|
377
594
|
if api_key_env.startswith(("sk-", "gsk_", "sk_")):
|
|
@@ -393,7 +610,194 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
|
|
|
393
610
|
return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
|
|
394
611
|
|
|
395
612
|
|
|
396
|
-
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: {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:
|
|
397
801
|
if not task.strip():
|
|
398
802
|
return "Usage: /orchestrate <coding task>"
|
|
399
803
|
tool = "agentram_run_coding_orchestration" if execute else "agentram_build_coding_orchestration"
|
|
@@ -409,9 +813,20 @@ def orchestrate_text(context: McpContext, task: str, files: list[str] | None = N
|
|
|
409
813
|
endpoint = result.get("endpoint", {})
|
|
410
814
|
status = "ok" if result.get("ok") else "error"
|
|
411
815
|
output = result.get("output") or result.get("error", "")
|
|
412
|
-
lines.append(f"- {endpoint.get('id')} {status}: {str(output)[:
|
|
816
|
+
lines.append(f"- {endpoint.get('id')} {status}: {model_error_hint(str(output))[:360]}")
|
|
413
817
|
return "\n".join(lines)
|
|
414
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 "missing api key env" in lower:
|
|
827
|
+
return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
|
|
828
|
+
return message
|
|
829
|
+
|
|
415
830
|
def claude_template_root() -> Path:
|
|
416
831
|
return Path(__file__).resolve().parent.parent / ".claude"
|
|
417
832
|
|
|
@@ -478,24 +893,26 @@ BUTTONS = {
|
|
|
478
893
|
}
|
|
479
894
|
|
|
480
895
|
|
|
481
|
-
def draw_screen(context: McpContext, messages: list[str]) -> None:
|
|
896
|
+
def draw_screen(context: McpContext, messages: list[str], activity: str | None = None) -> None:
|
|
482
897
|
clear_terminal()
|
|
483
898
|
width = min(max(shutil.get_terminal_size((110, 34)).columns, 86), 132)
|
|
484
899
|
left_width = 30
|
|
485
900
|
right_width = width - left_width - 3
|
|
486
901
|
print("+" + "-" * (width - 2) + "+")
|
|
487
|
-
print(box_text("AgentRAM Code
|
|
902
|
+
print(box_text("◆ AgentRAM Code | RAM + Claude subagents + slash commands", width))
|
|
488
903
|
print("+" + "-" * (width - 2) + "+")
|
|
489
904
|
|
|
490
905
|
sidebar = sidebar_lines(context)
|
|
491
|
-
|
|
906
|
+
display_messages = [*messages, activity] if activity else messages
|
|
907
|
+
chat = wrap_messages(display_messages, right_width - 4)[-22:]
|
|
492
908
|
rows = max(len(sidebar), len(chat), 22)
|
|
493
909
|
for index in range(rows):
|
|
494
910
|
left = sidebar[index] if index < len(sidebar) else ""
|
|
495
911
|
right = chat[index] if index < len(chat) else ""
|
|
496
912
|
print("| " + left[: left_width - 2].ljust(left_width - 2) + "| " + right[: right_width - 2].ljust(right_width - 2) + "|")
|
|
497
913
|
print("+" + "-" * (left_width - 1) + "+" + "-" * (right_width) + "+")
|
|
498
|
-
|
|
914
|
+
footer = "Thinking/acting..." if activity else "Input: type / for live dropdown. Enter '/' for full palette. /ask calls models."
|
|
915
|
+
print(box_text(footer, width))
|
|
499
916
|
print("+" + "-" * (width - 2) + "+")
|
|
500
917
|
|
|
501
918
|
|
|
@@ -526,10 +943,11 @@ def sidebar_lines(context: McpContext) -> list[str]:
|
|
|
526
943
|
" 9 help 0 exit",
|
|
527
944
|
"",
|
|
528
945
|
"CLAUDE",
|
|
946
|
+
" / = command palette",
|
|
947
|
+
" /agents",
|
|
529
948
|
" /claude-install .",
|
|
530
949
|
" /models",
|
|
531
|
-
" /
|
|
532
|
-
" /orchestrate <task>",
|
|
950
|
+
" /clear-models",
|
|
533
951
|
" /ask <prompt>",
|
|
534
952
|
]
|
|
535
953
|
|
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,8 @@ 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
|
|
7
9
|
import urllib.request
|
|
8
10
|
from typing import Any, Callable
|
|
9
11
|
from uuid import uuid4
|
|
@@ -106,6 +108,8 @@ class MultiModelCodingOrchestrator:
|
|
|
106
108
|
return self._call_openai_compatible(endpoint, prompt)
|
|
107
109
|
if provider in {"anthropic", "claude"}:
|
|
108
110
|
return self._call_anthropic(endpoint, prompt)
|
|
111
|
+
if provider in {"claude-code", "claude-cli", "claude-subagent", "claude-code-subagent"}:
|
|
112
|
+
return self._call_claude_code_subagent(endpoint, prompt)
|
|
109
113
|
raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
|
|
110
114
|
|
|
111
115
|
def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
@@ -142,4 +146,32 @@ class MultiModelCodingOrchestrator:
|
|
|
142
146
|
content = data.get("content", [])
|
|
143
147
|
if content and isinstance(content, list):
|
|
144
148
|
return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
|
|
145
|
-
return str(data)
|
|
149
|
+
return str(data)
|
|
150
|
+
|
|
151
|
+
def _call_claude_code_subagent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
152
|
+
command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CLAUDE_COMMAND", "claude")
|
|
153
|
+
args = shlex.split(command)
|
|
154
|
+
if not args:
|
|
155
|
+
raise ValueError("claude command is required")
|
|
156
|
+
subagent_prompt = "\n".join(
|
|
157
|
+
[
|
|
158
|
+
f"Use Claude Code subagent entrypoint: {endpoint.model}",
|
|
159
|
+
f"Subagent role: {endpoint.role}",
|
|
160
|
+
"If that subagent exists in .claude/agents, follow it. If not, act as this role.",
|
|
161
|
+
"Return concise coding-agent output only.",
|
|
162
|
+
"",
|
|
163
|
+
prompt,
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
process = subprocess.run(
|
|
167
|
+
[*args, "-p", subagent_prompt],
|
|
168
|
+
check=False,
|
|
169
|
+
capture_output=True,
|
|
170
|
+
encoding="utf-8",
|
|
171
|
+
timeout=300,
|
|
172
|
+
)
|
|
173
|
+
output = (process.stdout or "").strip()
|
|
174
|
+
error = (process.stderr or "").strip()
|
|
175
|
+
if process.returncode != 0:
|
|
176
|
+
raise RuntimeError(error or f"claude command failed with exit code {process.returncode}")
|
|
177
|
+
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
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.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "Async, model-agnostic RAM layer for agentic coding tools."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -21,6 +21,9 @@ classifiers = [
|
|
|
21
21
|
requires-python = ">=3.10"
|
|
22
22
|
dependencies = []
|
|
23
23
|
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
tui = ["prompt_toolkit>=3.0"]
|
|
26
|
+
|
|
24
27
|
[project.urls]
|
|
25
28
|
Homepage = "https://github.com/your-org/agentram"
|
|
26
29
|
Repository = "https://github.com/your-org/agentram"
|