agentcode-cli 1.0.0__py3-none-any.whl
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.
- agent.py +442 -0
- agentcode_cli-1.0.0.dist-info/METADATA +303 -0
- agentcode_cli-1.0.0.dist-info/RECORD +12 -0
- agentcode_cli-1.0.0.dist-info/WHEEL +5 -0
- agentcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- agentcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentcode_cli-1.0.0.dist-info/top_level.txt +6 -0
- cli.py +627 -0
- mcp_client.py +194 -0
- router.py +298 -0
- settings.py +185 -0
- tools.py +672 -0
mcp_client.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentCode — MCP (Model Context Protocol) client.
|
|
3
|
+
|
|
4
|
+
Connects to MCP servers defined in .agentcode/mcp.json and exposes
|
|
5
|
+
their tools to the agent loop alongside the built-in tools.
|
|
6
|
+
|
|
7
|
+
Config format (.agentcode/mcp.json or ~/.agentcode/mcp.json):
|
|
8
|
+
{
|
|
9
|
+
"mcpServers": {
|
|
10
|
+
"filesystem": {
|
|
11
|
+
"command": "npx",
|
|
12
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
13
|
+
},
|
|
14
|
+
"github": {
|
|
15
|
+
"command": "npx",
|
|
16
|
+
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
17
|
+
"env": {"GITHUB_TOKEN": "ghp_..."}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Tools are exposed as mcp__<server>__<tool> (e.g. mcp__filesystem__read_file).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import threading
|
|
29
|
+
from contextlib import AsyncExitStack
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from rich.console import Console
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _ServerConnection:
|
|
38
|
+
"""Holds a live stdio connection to one MCP server."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, name: str, config: dict):
|
|
41
|
+
self.name = name
|
|
42
|
+
self.config = config
|
|
43
|
+
self._session = None
|
|
44
|
+
self._stack: AsyncExitStack | None = None
|
|
45
|
+
|
|
46
|
+
async def start(self):
|
|
47
|
+
from mcp import ClientSession, StdioServerParameters
|
|
48
|
+
from mcp.client.stdio import stdio_client
|
|
49
|
+
|
|
50
|
+
env = os.environ.copy()
|
|
51
|
+
env.update(self.config.get("env") or {})
|
|
52
|
+
|
|
53
|
+
params = StdioServerParameters(
|
|
54
|
+
command=self.config["command"],
|
|
55
|
+
args=self.config.get("args", []),
|
|
56
|
+
env=env,
|
|
57
|
+
)
|
|
58
|
+
self._stack = AsyncExitStack()
|
|
59
|
+
read, write = await self._stack.enter_async_context(stdio_client(params))
|
|
60
|
+
self._session = await self._stack.enter_async_context(ClientSession(read, write))
|
|
61
|
+
await self._session.initialize()
|
|
62
|
+
|
|
63
|
+
async def stop(self):
|
|
64
|
+
if self._stack:
|
|
65
|
+
await self._stack.aclose()
|
|
66
|
+
|
|
67
|
+
async def list_tools(self):
|
|
68
|
+
return await self._session.list_tools()
|
|
69
|
+
|
|
70
|
+
async def call_tool(self, name: str, args: dict):
|
|
71
|
+
return await self._session.call_tool(name, args)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MCPManager:
|
|
75
|
+
"""
|
|
76
|
+
Manages connections to one or more MCP servers.
|
|
77
|
+
|
|
78
|
+
Runs a persistent asyncio event loop in a background thread so the
|
|
79
|
+
synchronous agent loop can call MCP tools without restructuring.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self._loop = asyncio.new_event_loop()
|
|
84
|
+
self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
|
|
85
|
+
self._thread.start()
|
|
86
|
+
self._servers: dict[str, _ServerConnection] = {}
|
|
87
|
+
self._tool_map: dict[str, tuple[str, str]] = {} # mcp__server__tool -> (server, tool)
|
|
88
|
+
|
|
89
|
+
def _run(self, coro, timeout: int = 30):
|
|
90
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
91
|
+
return future.result(timeout=timeout)
|
|
92
|
+
|
|
93
|
+
def connect(self, name: str, config: dict) -> None:
|
|
94
|
+
conn = _ServerConnection(name, config)
|
|
95
|
+
self._run(conn.start())
|
|
96
|
+
self._servers[name] = conn
|
|
97
|
+
tools = self._run(conn.list_tools())
|
|
98
|
+
for tool in tools.tools:
|
|
99
|
+
key = f"mcp__{name}__{tool.name}"
|
|
100
|
+
self._tool_map[key] = (name, tool.name)
|
|
101
|
+
|
|
102
|
+
def is_mcp_tool(self, tool_name: str) -> bool:
|
|
103
|
+
return tool_name in self._tool_map
|
|
104
|
+
|
|
105
|
+
def get_tool_definitions(self) -> list[dict]:
|
|
106
|
+
"""Return OpenAI-format tool definitions for all connected MCP tools."""
|
|
107
|
+
defs = []
|
|
108
|
+
for server_name, conn in self._servers.items():
|
|
109
|
+
tools = self._run(conn.list_tools())
|
|
110
|
+
for tool in tools.tools:
|
|
111
|
+
key = f"mcp__{server_name}__{tool.name}"
|
|
112
|
+
schema = tool.inputSchema if hasattr(tool, "inputSchema") else {
|
|
113
|
+
"type": "object", "properties": {}
|
|
114
|
+
}
|
|
115
|
+
defs.append({
|
|
116
|
+
"type": "function",
|
|
117
|
+
"function": {
|
|
118
|
+
"name": key,
|
|
119
|
+
"description": f"[{server_name}] {tool.description or tool.name}",
|
|
120
|
+
"parameters": schema,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
return defs
|
|
124
|
+
|
|
125
|
+
def call_tool(self, tool_name: str, args: dict) -> str:
|
|
126
|
+
if tool_name not in self._tool_map:
|
|
127
|
+
return f"Error: Unknown MCP tool '{tool_name}'"
|
|
128
|
+
server_name, original_name = self._tool_map[tool_name]
|
|
129
|
+
conn = self._servers[server_name]
|
|
130
|
+
try:
|
|
131
|
+
result = self._run(conn.call_tool(original_name, args))
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return f"Error calling MCP tool '{tool_name}': {e}"
|
|
134
|
+
|
|
135
|
+
if not result.content:
|
|
136
|
+
return "Tool returned no content."
|
|
137
|
+
parts = []
|
|
138
|
+
for block in result.content:
|
|
139
|
+
if hasattr(block, "text"):
|
|
140
|
+
parts.append(block.text)
|
|
141
|
+
elif hasattr(block, "data"):
|
|
142
|
+
parts.append(f"[binary data: {len(block.data)} bytes]")
|
|
143
|
+
return "\n".join(parts) or "Tool returned no content."
|
|
144
|
+
|
|
145
|
+
def server_names(self) -> list[str]:
|
|
146
|
+
return list(self._servers.keys())
|
|
147
|
+
|
|
148
|
+
def shutdown(self):
|
|
149
|
+
for conn in self._servers.values():
|
|
150
|
+
try:
|
|
151
|
+
self._run(conn.stop(), timeout=5)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Config loading ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def load_mcp_config(project_dir: str) -> dict:
|
|
160
|
+
config: dict = {}
|
|
161
|
+
for path in [
|
|
162
|
+
Path.home() / ".agentcode" / "mcp.json",
|
|
163
|
+
Path(project_dir) / ".agentcode" / "mcp.json",
|
|
164
|
+
]:
|
|
165
|
+
if path.exists():
|
|
166
|
+
try:
|
|
167
|
+
data = json.loads(path.read_text())
|
|
168
|
+
config.update(data.get("mcpServers", {}))
|
|
169
|
+
except Exception as e:
|
|
170
|
+
console.print(f"[warning]⚠ Failed to parse MCP config {path}: {e}[/warning]")
|
|
171
|
+
return config
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_mcp_manager(project_dir: str) -> "MCPManager | None":
|
|
175
|
+
"""Load config and connect to all configured MCP servers. Returns None if none configured."""
|
|
176
|
+
try:
|
|
177
|
+
import mcp # noqa: check available
|
|
178
|
+
except ImportError:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
config = load_mcp_config(project_dir)
|
|
182
|
+
if not config:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
manager = MCPManager()
|
|
186
|
+
for name, server_config in config.items():
|
|
187
|
+
try:
|
|
188
|
+
manager.connect(name, server_config)
|
|
189
|
+
console.print(f"[dim] MCP: connected to [bold]{name}[/bold] "
|
|
190
|
+
f"({len([k for k in manager._tool_map if k.startswith(f'mcp__{name}__')])} tools)[/dim]")
|
|
191
|
+
except Exception as e:
|
|
192
|
+
console.print(f"[warning]⚠ MCP server '{name}' failed: {e}[/warning]")
|
|
193
|
+
|
|
194
|
+
return manager if manager._servers else None
|
router.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentCode - Cost-aware model router.
|
|
3
|
+
|
|
4
|
+
Automatically picks the cheapest model that can handle the task.
|
|
5
|
+
Simple questions get a fast, cheap model. Complex multi-file refactors
|
|
6
|
+
get a powerful, expensive one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Model Tiers ───────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ModelTier:
|
|
20
|
+
"""A model with its capabilities and pricing."""
|
|
21
|
+
name: str # LiteLLM model string
|
|
22
|
+
tier: str # "light", "medium", "heavy"
|
|
23
|
+
label: str # Human-readable label
|
|
24
|
+
input_cost_per_mtok: float # $ per 1M input tokens
|
|
25
|
+
output_cost_per_mtok: float # $ per 1M output tokens
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Default Model Configs ─────────────────────────────────────────────────────
|
|
29
|
+
# Users can override these via AGENTCODE.md or environment variables.
|
|
30
|
+
# Costs are approximate as of mid-2026.
|
|
31
|
+
|
|
32
|
+
ANTHROPIC_TIERS = [
|
|
33
|
+
ModelTier("claude-haiku-4-5-20251001", "light", "Haiku 4.5", 0.80, 4.00),
|
|
34
|
+
ModelTier("claude-sonnet-4-6", "medium", "Sonnet 4.6", 3.00, 15.00),
|
|
35
|
+
ModelTier("claude-opus-4-6", "heavy", "Opus 4.6", 15.00, 75.00),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
OPENAI_TIERS = [
|
|
39
|
+
ModelTier("gpt-4o-mini", "light", "GPT-4o Mini", 0.15, 0.60),
|
|
40
|
+
ModelTier("gpt-4o", "medium", "GPT-4o", 2.50, 10.00),
|
|
41
|
+
ModelTier("gpt-5.5", "heavy", "GPT-5.5", 15.00, 60.00),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
GEMINI_TIERS = [
|
|
45
|
+
ModelTier("gemini/gemini-2.0-flash", "light", "Gemini 2.0 Flash", 0.10, 0.40),
|
|
46
|
+
ModelTier("gemini/gemini-2.5-flash", "medium", "Gemini 2.5 Flash", 0.25, 1.00),
|
|
47
|
+
ModelTier("gemini/gemini-2.5-pro", "heavy", "Gemini 2.5 Pro", 1.25, 10.00),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Provider configs keyed by prefix
|
|
51
|
+
PROVIDER_TIERS = {
|
|
52
|
+
"anthropic": ANTHROPIC_TIERS,
|
|
53
|
+
"openai": OPENAI_TIERS,
|
|
54
|
+
"gemini": GEMINI_TIERS,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Task Complexity Classification ────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
# Patterns that signal increasing complexity
|
|
61
|
+
LIGHT_PATTERNS = [
|
|
62
|
+
r"\b(explain|what is|what are|what does|how does|describe|list|show|tell me)\b",
|
|
63
|
+
r"\b(read|cat|view|print|display|show me|open)\b",
|
|
64
|
+
r"\b(git status|git log|git diff)\b",
|
|
65
|
+
r"\b(hello|hi|hey|thanks|thank you)\b",
|
|
66
|
+
r"\b(format|lint|indent|rename)\b",
|
|
67
|
+
r"\b(typo|spelling|grammar|comment)\b",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
MEDIUM_PATTERNS = [
|
|
71
|
+
r"\b(write|create|add|implement|build|make|generate)\b",
|
|
72
|
+
r"\b(fix|bug|error|debug|broken|failing|issue)\b",
|
|
73
|
+
r"\b(test|unit test|pytest|spec)\b",
|
|
74
|
+
r"\b(function|class|method|endpoint|route|api)\b",
|
|
75
|
+
r"\b(edit|update|change|modify)\b",
|
|
76
|
+
r"\b(install|setup|configure|deploy)\b",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
HEAVY_PATTERNS = [
|
|
80
|
+
r"\b(refactor|restructure|redesign|rearchitect|overhaul)\b",
|
|
81
|
+
r"\b(migrate|migration|upgrade|convert|port)\b",
|
|
82
|
+
r"\b(entire|whole|all files|every file|codebase|full)\b",
|
|
83
|
+
r"\b(optimize|performance|bottleneck|profil)\b",
|
|
84
|
+
r"\b(security|vulnerability|audit|review all)\b",
|
|
85
|
+
r"\b(multi.?file|across files|multiple files)\b",
|
|
86
|
+
r"\b(design pattern|architecture|system design)\b",
|
|
87
|
+
r"\b(from scratch|ground up|complete|comprehensive)\b",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def classify_complexity(user_input: str) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Classify task complexity as 'light', 'medium', or 'heavy'.
|
|
94
|
+
|
|
95
|
+
Uses pattern matching on the user's prompt. This is intentionally
|
|
96
|
+
simple and transparent — users can see exactly why a model was chosen.
|
|
97
|
+
"""
|
|
98
|
+
text = user_input.lower().strip()
|
|
99
|
+
|
|
100
|
+
# Score each tier
|
|
101
|
+
heavy_score = sum(1 for p in HEAVY_PATTERNS if re.search(p, text, re.IGNORECASE))
|
|
102
|
+
medium_score = sum(1 for p in MEDIUM_PATTERNS if re.search(p, text, re.IGNORECASE))
|
|
103
|
+
light_score = sum(1 for p in LIGHT_PATTERNS if re.search(p, text, re.IGNORECASE))
|
|
104
|
+
|
|
105
|
+
# Length heuristic: very long prompts are usually complex
|
|
106
|
+
if len(text) > 500:
|
|
107
|
+
heavy_score += 2
|
|
108
|
+
elif len(text) > 200:
|
|
109
|
+
medium_score += 1
|
|
110
|
+
|
|
111
|
+
# Multiple file references suggest complexity
|
|
112
|
+
file_refs = len(re.findall(r'\.\w{1,4}\b', text)) # .py, .js, .tsx, etc.
|
|
113
|
+
if file_refs >= 3:
|
|
114
|
+
heavy_score += 2
|
|
115
|
+
elif file_refs >= 2:
|
|
116
|
+
medium_score += 1
|
|
117
|
+
|
|
118
|
+
# Decision logic
|
|
119
|
+
if heavy_score >= 2:
|
|
120
|
+
return "heavy"
|
|
121
|
+
elif heavy_score >= 1 and medium_score >= 1:
|
|
122
|
+
return "heavy"
|
|
123
|
+
elif medium_score >= 1:
|
|
124
|
+
return "medium"
|
|
125
|
+
else:
|
|
126
|
+
return "light"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Router ────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class CostTracker:
|
|
133
|
+
"""Track cumulative costs across a session."""
|
|
134
|
+
total_input_tokens: int = 0
|
|
135
|
+
total_output_tokens: int = 0
|
|
136
|
+
total_cost: float = 0.0
|
|
137
|
+
requests: list[dict] = field(default_factory=list)
|
|
138
|
+
last_turn_input: int = 0
|
|
139
|
+
last_turn_output: int = 0
|
|
140
|
+
last_turn_cost: float = 0.0
|
|
141
|
+
|
|
142
|
+
def begin_turn(self):
|
|
143
|
+
"""Reset per-turn counters at the start of each agent loop call."""
|
|
144
|
+
self.last_turn_input = 0
|
|
145
|
+
self.last_turn_output = 0
|
|
146
|
+
self.last_turn_cost = 0.0
|
|
147
|
+
|
|
148
|
+
def record(self, model: str, input_tokens: int, output_tokens: int, cost: float):
|
|
149
|
+
self.total_input_tokens += input_tokens
|
|
150
|
+
self.total_output_tokens += output_tokens
|
|
151
|
+
self.total_cost += cost
|
|
152
|
+
self.last_turn_input += input_tokens
|
|
153
|
+
self.last_turn_output += output_tokens
|
|
154
|
+
self.last_turn_cost += cost
|
|
155
|
+
self.requests.append({
|
|
156
|
+
"model": model,
|
|
157
|
+
"input_tokens": input_tokens,
|
|
158
|
+
"output_tokens": output_tokens,
|
|
159
|
+
"cost": cost,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
def summary(self) -> str:
|
|
163
|
+
if not self.requests:
|
|
164
|
+
return "No requests yet."
|
|
165
|
+
lines = [
|
|
166
|
+
f"Session total: ${self.total_cost:.4f} "
|
|
167
|
+
f"({self.total_input_tokens:,} in / {self.total_output_tokens:,} out)",
|
|
168
|
+
f"Requests: {len(self.requests)}",
|
|
169
|
+
]
|
|
170
|
+
# Show last 5 requests
|
|
171
|
+
for r in self.requests[-5:]:
|
|
172
|
+
lines.append(
|
|
173
|
+
f" {r['model']}: ${r['cost']:.4f} "
|
|
174
|
+
f"({r['input_tokens']:,} in / {r['output_tokens']:,} out)"
|
|
175
|
+
)
|
|
176
|
+
if len(self.requests) > 5:
|
|
177
|
+
lines.append(f" ... and {len(self.requests) - 5} earlier requests")
|
|
178
|
+
return "\n".join(lines)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class ModelRouter:
|
|
183
|
+
"""
|
|
184
|
+
Cost-aware model router.
|
|
185
|
+
|
|
186
|
+
Automatically selects the cheapest model that can handle the task.
|
|
187
|
+
Falls back to the default model if no tier config is available.
|
|
188
|
+
"""
|
|
189
|
+
provider: str = "anthropic" # "anthropic" or "openai"
|
|
190
|
+
default_model: str = "claude-sonnet-4-6"
|
|
191
|
+
enabled: bool = True # False = always use default_model
|
|
192
|
+
cost_tracker: CostTracker = field(default_factory=CostTracker)
|
|
193
|
+
|
|
194
|
+
# Allow per-tier overrides
|
|
195
|
+
light_model: str | None = None
|
|
196
|
+
medium_model: str | None = None
|
|
197
|
+
heavy_model: str | None = None
|
|
198
|
+
|
|
199
|
+
def get_tiers(self) -> list[ModelTier]:
|
|
200
|
+
"""Get the tier config for the current provider."""
|
|
201
|
+
return PROVIDER_TIERS.get(self.provider, [])
|
|
202
|
+
|
|
203
|
+
def detect_provider(self, model_string: str) -> str:
|
|
204
|
+
"""Detect provider from a model string."""
|
|
205
|
+
m = model_string.lower()
|
|
206
|
+
if "claude" in m or "anthropic" in m:
|
|
207
|
+
return "anthropic"
|
|
208
|
+
elif "gpt" in m or "openai" in m or "o1" in m or "o3" in m:
|
|
209
|
+
return "openai"
|
|
210
|
+
elif "gemini" in m or "google" in m:
|
|
211
|
+
return "gemini"
|
|
212
|
+
else:
|
|
213
|
+
return "unknown"
|
|
214
|
+
|
|
215
|
+
def route(self, user_input: str) -> tuple[str, str, str]:
|
|
216
|
+
"""
|
|
217
|
+
Pick the best model for this task.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
(model_string, tier, reason) — the model to use, its tier,
|
|
221
|
+
and a human-readable explanation of why it was chosen.
|
|
222
|
+
"""
|
|
223
|
+
if not self.enabled:
|
|
224
|
+
return self.default_model, "default", "routing disabled"
|
|
225
|
+
|
|
226
|
+
complexity = classify_complexity(user_input)
|
|
227
|
+
|
|
228
|
+
# Check for per-tier overrides first
|
|
229
|
+
if complexity == "light" and self.light_model:
|
|
230
|
+
return self.light_model, "light", self._reason(user_input, complexity)
|
|
231
|
+
elif complexity == "medium" and self.medium_model:
|
|
232
|
+
return self.medium_model, "medium", self._reason(user_input, complexity)
|
|
233
|
+
elif complexity == "heavy" and self.heavy_model:
|
|
234
|
+
return self.heavy_model, "heavy", self._reason(user_input, complexity)
|
|
235
|
+
|
|
236
|
+
# Use provider tier defaults
|
|
237
|
+
tiers = self.get_tiers()
|
|
238
|
+
if not tiers:
|
|
239
|
+
return self.default_model, "default", "no tier config for provider"
|
|
240
|
+
|
|
241
|
+
tier_map = {t.tier: t for t in tiers}
|
|
242
|
+
tier = tier_map.get(complexity)
|
|
243
|
+
if tier:
|
|
244
|
+
return tier.name, complexity, self._reason(user_input, complexity)
|
|
245
|
+
|
|
246
|
+
return self.default_model, "default", "no matching tier"
|
|
247
|
+
|
|
248
|
+
def _reason(self, user_input: str, complexity: str) -> str:
|
|
249
|
+
"""Generate a human-readable reason for the routing decision."""
|
|
250
|
+
reasons = {
|
|
251
|
+
"light": "simple query — using fast, cheap model",
|
|
252
|
+
"medium": "standard coding task — using balanced model",
|
|
253
|
+
"heavy": "complex multi-step task — using powerful model",
|
|
254
|
+
}
|
|
255
|
+
return reasons.get(complexity, "unknown complexity")
|
|
256
|
+
|
|
257
|
+
def estimate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
|
|
258
|
+
"""Estimate cost for a request."""
|
|
259
|
+
# Search all tiers for this model
|
|
260
|
+
for tiers in PROVIDER_TIERS.values():
|
|
261
|
+
for tier in tiers:
|
|
262
|
+
if tier.name == model:
|
|
263
|
+
cost = (
|
|
264
|
+
(input_tokens / 1_000_000) * tier.input_cost_per_mtok
|
|
265
|
+
+ (output_tokens / 1_000_000) * tier.output_cost_per_mtok
|
|
266
|
+
)
|
|
267
|
+
return cost
|
|
268
|
+
return 0.0 # Unknown model, can't estimate
|
|
269
|
+
|
|
270
|
+
def get_tier_info(self, model: str) -> ModelTier | None:
|
|
271
|
+
"""Get tier info for a model."""
|
|
272
|
+
for tiers in PROVIDER_TIERS.values():
|
|
273
|
+
for tier in tiers:
|
|
274
|
+
if tier.name == model:
|
|
275
|
+
return tier
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def display_routing_decision(model: str, tier: str, reason: str, router: ModelRouter):
|
|
280
|
+
"""Show the user which model was selected and why."""
|
|
281
|
+
tier_colors = {
|
|
282
|
+
"light": "green",
|
|
283
|
+
"medium": "yellow",
|
|
284
|
+
"heavy": "red",
|
|
285
|
+
"default": "cyan",
|
|
286
|
+
}
|
|
287
|
+
color = tier_colors.get(tier, "white")
|
|
288
|
+
|
|
289
|
+
tier_info = router.get_tier_info(model)
|
|
290
|
+
cost_hint = ""
|
|
291
|
+
if tier_info:
|
|
292
|
+
cost_hint = f" [dim](${tier_info.input_cost_per_mtok:.2f}/${tier_info.output_cost_per_mtok:.2f} per 1M tok)[/dim]"
|
|
293
|
+
|
|
294
|
+
console.print(
|
|
295
|
+
f" [{color}]⚡ {tier.upper()}[/{color}] → "
|
|
296
|
+
f"[bold]{model}[/bold]{cost_hint} "
|
|
297
|
+
f"[dim]({reason})[/dim]"
|
|
298
|
+
)
|
settings.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""AgentCode settings — load and merge configuration from global, project, and CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_AUTO_APPROVE = [
|
|
9
|
+
"read_file", "list_directory", "search_files", "search_text",
|
|
10
|
+
"git_status", "git_log", "git_diff", "spawn_subagents",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PermissionsSettings:
|
|
16
|
+
auto_approve_all: bool = False
|
|
17
|
+
auto_approve: list[str] = field(default_factory=lambda: list(DEFAULT_AUTO_APPROVE))
|
|
18
|
+
deny: list[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ModelSettings:
|
|
23
|
+
default: str = "claude-sonnet-4-6"
|
|
24
|
+
routing: bool = True
|
|
25
|
+
light: str | None = None
|
|
26
|
+
medium: str | None = None
|
|
27
|
+
heavy: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class LimitsSettings:
|
|
32
|
+
max_file_size: int = 1_000_000
|
|
33
|
+
max_output: int = 50_000
|
|
34
|
+
max_search_results: int = 100
|
|
35
|
+
max_iterations: int = 25
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Settings:
|
|
40
|
+
permissions: PermissionsSettings = field(default_factory=PermissionsSettings)
|
|
41
|
+
model: ModelSettings = field(default_factory=ModelSettings)
|
|
42
|
+
limits: LimitsSettings = field(default_factory=LimitsSettings)
|
|
43
|
+
hooks: dict[str, str] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Merge helpers ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
49
|
+
"""Recursively merge override into base, returning a new dict."""
|
|
50
|
+
result = base.copy()
|
|
51
|
+
for key, val in override.items():
|
|
52
|
+
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
|
|
53
|
+
result[key] = _deep_merge(result[key], val)
|
|
54
|
+
else:
|
|
55
|
+
result[key] = val
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_json(path: Path) -> dict:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(path.read_text())
|
|
62
|
+
except Exception:
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _dict_to_settings(d: dict) -> "Settings":
|
|
67
|
+
"""Construct a Settings from a merged raw dict, filling in defaults for missing keys."""
|
|
68
|
+
perms_d = d.get("permissions", {})
|
|
69
|
+
model_d = d.get("model", {})
|
|
70
|
+
limits_d = d.get("limits", {})
|
|
71
|
+
hooks_d = d.get("hooks", {})
|
|
72
|
+
|
|
73
|
+
return Settings(
|
|
74
|
+
permissions=PermissionsSettings(
|
|
75
|
+
auto_approve_all=bool(perms_d.get("auto_approve_all", False)),
|
|
76
|
+
auto_approve=list(perms_d.get("auto_approve", DEFAULT_AUTO_APPROVE)),
|
|
77
|
+
deny=list(perms_d.get("deny", [])),
|
|
78
|
+
),
|
|
79
|
+
model=ModelSettings(
|
|
80
|
+
default=str(model_d.get("default", "claude-sonnet-4-6")),
|
|
81
|
+
routing=bool(model_d.get("routing", True)),
|
|
82
|
+
light=model_d.get("light") or None,
|
|
83
|
+
medium=model_d.get("medium") or None,
|
|
84
|
+
heavy=model_d.get("heavy") or None,
|
|
85
|
+
),
|
|
86
|
+
limits=LimitsSettings(
|
|
87
|
+
max_file_size=int(limits_d.get("max_file_size", 1_000_000)),
|
|
88
|
+
max_output=int(limits_d.get("max_output", 50_000)),
|
|
89
|
+
max_search_results=int(limits_d.get("max_search_results", 100)),
|
|
90
|
+
max_iterations=int(limits_d.get("max_iterations", 25)),
|
|
91
|
+
),
|
|
92
|
+
hooks=dict(hooks_d),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def load_settings(project_dir: str, cli_overrides: dict | None = None) -> "Settings":
|
|
99
|
+
"""
|
|
100
|
+
Load settings by merging (later layers override earlier ones):
|
|
101
|
+
1. Built-in defaults
|
|
102
|
+
2. ~/.agentcode/settings.json (global)
|
|
103
|
+
3. <project>/.agentcode/settings.json (project)
|
|
104
|
+
4. cli_overrides (CLI flags, highest priority)
|
|
105
|
+
"""
|
|
106
|
+
merged: dict = {}
|
|
107
|
+
|
|
108
|
+
global_path = Path.home() / ".agentcode" / "settings.json"
|
|
109
|
+
if global_path.exists():
|
|
110
|
+
merged = _deep_merge(merged, _load_json(global_path))
|
|
111
|
+
|
|
112
|
+
project_path = Path(project_dir) / ".agentcode" / "settings.json"
|
|
113
|
+
if project_path.exists():
|
|
114
|
+
merged = _deep_merge(merged, _load_json(project_path))
|
|
115
|
+
|
|
116
|
+
if cli_overrides:
|
|
117
|
+
merged = _deep_merge(merged, cli_overrides)
|
|
118
|
+
|
|
119
|
+
return _dict_to_settings(merged)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def settings_sources(project_dir: str) -> dict[str, str | None]:
|
|
123
|
+
"""Return paths to settings files that currently exist on disk."""
|
|
124
|
+
global_path = Path.home() / ".agentcode" / "settings.json"
|
|
125
|
+
project_path = Path(project_dir) / ".agentcode" / "settings.json"
|
|
126
|
+
return {
|
|
127
|
+
"global": str(global_path) if global_path.exists() else None,
|
|
128
|
+
"project": str(project_path) if project_path.exists() else None,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Starter template ──────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
STARTER_SETTINGS: dict = {
|
|
135
|
+
"permissions": {
|
|
136
|
+
"auto_approve_all": False,
|
|
137
|
+
"auto_approve": [
|
|
138
|
+
"read_file", "list_directory", "search_files", "search_text",
|
|
139
|
+
"git_status", "git_log", "git_diff", "spawn_subagents"
|
|
140
|
+
],
|
|
141
|
+
"deny": []
|
|
142
|
+
},
|
|
143
|
+
"model": {
|
|
144
|
+
"default": "claude-sonnet-4-6",
|
|
145
|
+
"routing": True,
|
|
146
|
+
"light": None,
|
|
147
|
+
"medium": None,
|
|
148
|
+
"heavy": None
|
|
149
|
+
},
|
|
150
|
+
"limits": {
|
|
151
|
+
"max_file_size": 1000000,
|
|
152
|
+
"max_output": 50000,
|
|
153
|
+
"max_search_results": 100,
|
|
154
|
+
"max_iterations": 25
|
|
155
|
+
},
|
|
156
|
+
"hooks": {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
SETTINGS_HELP = """\
|
|
160
|
+
permissions.auto_approve_all true to skip all permission prompts (like --auto-approve)
|
|
161
|
+
permissions.auto_approve tools that run without asking (default: read-only tools)
|
|
162
|
+
permissions.deny tools that are always blocked, regardless of other settings
|
|
163
|
+
model.default default LLM model string (e.g. "claude-sonnet-4-6")
|
|
164
|
+
model.routing enable cost-aware model routing (true/false)
|
|
165
|
+
model.light/medium/heavy override the model used for each routing tier
|
|
166
|
+
limits.max_file_size max bytes for read_file (default: 1,000,000)
|
|
167
|
+
limits.max_output max chars for tool output truncation (default: 50,000)
|
|
168
|
+
limits.max_search_results cap on results from search tools (default: 100)
|
|
169
|
+
limits.max_iterations max tool-call loops per turn (default: 25)
|
|
170
|
+
hooks shell commands run before/after tool calls
|
|
171
|
+
keys: pre_<tool>, post_<tool>, pre_tool, post_tool\
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def generate_starter_settings(project_dir: str) -> tuple[Path, bool]:
|
|
176
|
+
"""
|
|
177
|
+
Write a starter settings.json to <project>/.agentcode/settings.json.
|
|
178
|
+
Returns (path, created): created=False if the file already existed.
|
|
179
|
+
"""
|
|
180
|
+
path = Path(project_dir) / ".agentcode" / "settings.json"
|
|
181
|
+
existed = path.exists()
|
|
182
|
+
if not existed:
|
|
183
|
+
path.parent.mkdir(exist_ok=True)
|
|
184
|
+
path.write_text(json.dumps(STARTER_SETTINGS, indent=2))
|
|
185
|
+
return path, not existed
|