krnl-code 1.0.4__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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- krnl_code-1.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Parallel Executor for Phase 5: Parallel Execution.
|
|
2
|
+
|
|
3
|
+
Implements concurrent execution of multiple specialized agents.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AgentTask:
|
|
15
|
+
"""A task to be executed by an agent."""
|
|
16
|
+
agent_name: str
|
|
17
|
+
task: str
|
|
18
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AgentResult:
|
|
23
|
+
"""Result from an agent task execution."""
|
|
24
|
+
agent_name: str
|
|
25
|
+
task: str
|
|
26
|
+
result: Any
|
|
27
|
+
success: bool
|
|
28
|
+
error: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ParallelExecutor:
|
|
32
|
+
"""Executes multiple agent tasks concurrently."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, max_workers: int = 4):
|
|
35
|
+
self.max_workers = max_workers
|
|
36
|
+
|
|
37
|
+
async def execute_tasks(
|
|
38
|
+
self,
|
|
39
|
+
tasks: list[AgentTask],
|
|
40
|
+
agent_handler: Callable[[str, str, dict[str, Any]], Any],
|
|
41
|
+
) -> list[AgentResult]:
|
|
42
|
+
"""Execute multiple agent tasks concurrently.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
tasks: List of AgentTask objects to execute.
|
|
46
|
+
agent_handler: Async function that handles agent execution.
|
|
47
|
+
Signature: (agent_name, task, context) -> result
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of AgentResult objects in the same order as input tasks.
|
|
51
|
+
"""
|
|
52
|
+
results = []
|
|
53
|
+
|
|
54
|
+
async def execute_single(task: AgentTask) -> AgentResult:
|
|
55
|
+
try:
|
|
56
|
+
result = await agent_handler(task.agent_name, task.task, task.context)
|
|
57
|
+
return AgentResult(
|
|
58
|
+
agent_name=task.agent_name,
|
|
59
|
+
task=task.task,
|
|
60
|
+
result=result,
|
|
61
|
+
success=True,
|
|
62
|
+
)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return AgentResult(
|
|
65
|
+
agent_name=task.agent_name,
|
|
66
|
+
task=task.task,
|
|
67
|
+
result=None,
|
|
68
|
+
success=False,
|
|
69
|
+
error=str(e),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Execute tasks concurrently with semaphore to limit concurrency
|
|
73
|
+
semaphore = asyncio.Semaphore(self.max_workers)
|
|
74
|
+
|
|
75
|
+
async def execute_with_semaphore(task: AgentTask) -> AgentResult:
|
|
76
|
+
async with semaphore:
|
|
77
|
+
return await execute_single(task)
|
|
78
|
+
|
|
79
|
+
# Run all tasks concurrently
|
|
80
|
+
results = await asyncio.gather(
|
|
81
|
+
*[execute_with_semaphore(task) for task in tasks],
|
|
82
|
+
return_exceptions=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return results
|
|
86
|
+
|
|
87
|
+
def execute_tasks_sync(
|
|
88
|
+
self,
|
|
89
|
+
tasks: list[AgentTask],
|
|
90
|
+
agent_handler: Callable[[str, str, dict[str, Any]], Any],
|
|
91
|
+
) -> list[AgentResult]:
|
|
92
|
+
"""Execute multiple agent tasks concurrently (synchronous wrapper).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
tasks: List of AgentTask objects to execute.
|
|
96
|
+
agent_handler: Function that handles agent execution.
|
|
97
|
+
Signature: (agent_name, task, context) -> result
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of AgentResult objects in the same order as input tasks.
|
|
101
|
+
"""
|
|
102
|
+
results = []
|
|
103
|
+
|
|
104
|
+
def execute_single(task: AgentTask) -> AgentResult:
|
|
105
|
+
try:
|
|
106
|
+
result = agent_handler(task.agent_name, task.task, task.context)
|
|
107
|
+
return AgentResult(
|
|
108
|
+
agent_name=task.agent_name,
|
|
109
|
+
task=task.task,
|
|
110
|
+
result=result,
|
|
111
|
+
success=True,
|
|
112
|
+
)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
return AgentResult(
|
|
115
|
+
agent_name=task.agent_name,
|
|
116
|
+
task=task.task,
|
|
117
|
+
result=None,
|
|
118
|
+
success=False,
|
|
119
|
+
error=str(e),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
123
|
+
results = list(executor.map(execute_single, tasks))
|
|
124
|
+
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
def get_summary(self, results: list[AgentResult]) -> dict[str, Any]:
|
|
128
|
+
"""Get a summary of execution results."""
|
|
129
|
+
total = len(results)
|
|
130
|
+
successful = sum(1 for r in results if r.success)
|
|
131
|
+
failed = total - successful
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"total_tasks": total,
|
|
135
|
+
"successful": successful,
|
|
136
|
+
"failed": failed,
|
|
137
|
+
"success_rate": successful / total if total > 0 else 0,
|
|
138
|
+
"agents_used": list(set(r.agent_name for r in results)),
|
|
139
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Permission engine — allow / ask / deny rules by tool + glob.
|
|
2
|
+
|
|
3
|
+
Replaces the coarse auto-approve flags with fine-grained control, e.g.
|
|
4
|
+
allow: read_file(**), git_status, git_diff
|
|
5
|
+
ask: run_command(*), write_file(**)
|
|
6
|
+
deny: delete_file(**/.git/**), run_command(rm -rf*)
|
|
7
|
+
|
|
8
|
+
Rules are evaluated in order (first match wins); otherwise per-category defaults
|
|
9
|
+
apply. "Always allow" decisions from an approval prompt are appended and
|
|
10
|
+
persisted to ~/.krnl-agent/permissions.json.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fnmatch
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from .settings import SETTINGS_DIR
|
|
19
|
+
|
|
20
|
+
PERMISSIONS_FILE = SETTINGS_DIR / "permissions.json"
|
|
21
|
+
|
|
22
|
+
_READ_TOOLS = {
|
|
23
|
+
"list_files", "read_file", "search_text", "glob",
|
|
24
|
+
"process_output", "process_list", "git_status", "git_diff",
|
|
25
|
+
}
|
|
26
|
+
_COMMAND_TOOLS = {"run_command", "bash_background"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Rule:
|
|
31
|
+
tool: str # tool name or "*"
|
|
32
|
+
pattern: str # glob matched against the action's target string
|
|
33
|
+
action: str # allow | ask | deny
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _target(tool: str, args: dict) -> str:
|
|
37
|
+
if tool in ("run_command", "bash_background"):
|
|
38
|
+
return args.get("command", "")
|
|
39
|
+
if tool.startswith("mcp__"):
|
|
40
|
+
return f"{tool} {json.dumps(args)[:200]}"
|
|
41
|
+
return args.get("path") or args.get("url") or args.get("query") or ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_rule(spec: str, action: str) -> Rule:
|
|
45
|
+
"""Parse 'tool(pattern)' or 'tool' into a Rule."""
|
|
46
|
+
spec = spec.strip()
|
|
47
|
+
if spec.endswith(")") and "(" in spec:
|
|
48
|
+
tool, pattern = spec[:-1].split("(", 1)
|
|
49
|
+
return Rule(tool.strip() or "*", pattern.strip() or "*", action)
|
|
50
|
+
return Rule(spec or "*", "*", action)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Permissions:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
rules: list[Rule] | None = None,
|
|
57
|
+
*,
|
|
58
|
+
default_writes: str = "ask",
|
|
59
|
+
default_commands: str = "ask",
|
|
60
|
+
default_reads: str = "allow",
|
|
61
|
+
):
|
|
62
|
+
self.rules = rules or []
|
|
63
|
+
self.default_writes = default_writes
|
|
64
|
+
self.default_commands = default_commands
|
|
65
|
+
self.default_reads = default_reads
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_config(cls, perms: dict, *, auto_writes: bool, auto_commands: bool) -> "Permissions":
|
|
69
|
+
rules: list[Rule] = []
|
|
70
|
+
for action in ("deny", "ask", "allow"): # deny first so it's checked first
|
|
71
|
+
for spec in (perms.get(action) or []):
|
|
72
|
+
rules.append(parse_rule(spec, action))
|
|
73
|
+
# persisted user "always allow" rules
|
|
74
|
+
rules += _load_persisted()
|
|
75
|
+
return cls(
|
|
76
|
+
rules,
|
|
77
|
+
default_writes="allow" if auto_writes else "ask",
|
|
78
|
+
default_commands="allow" if auto_commands else "ask",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def decide(self, tool: str, args: dict) -> str:
|
|
82
|
+
target = _target(tool, args)
|
|
83
|
+
for r in self.rules:
|
|
84
|
+
if r.tool in (tool, "*") and fnmatch.fnmatch(target, r.pattern):
|
|
85
|
+
return r.action
|
|
86
|
+
if tool in _READ_TOOLS:
|
|
87
|
+
return self.default_reads
|
|
88
|
+
if tool in _COMMAND_TOOLS or tool.startswith("mcp__"):
|
|
89
|
+
return self.default_commands
|
|
90
|
+
return self.default_writes
|
|
91
|
+
|
|
92
|
+
def add_always_allow(self, tool: str, args: dict) -> None:
|
|
93
|
+
target = _target(tool, args)
|
|
94
|
+
pattern = _generalize(tool, target)
|
|
95
|
+
rule = Rule(tool, pattern, "allow")
|
|
96
|
+
self.rules.insert(0, rule)
|
|
97
|
+
_persist(rule)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _generalize(tool: str, target: str) -> str:
|
|
101
|
+
"""Turn a concrete target into a sensible reusable glob."""
|
|
102
|
+
if tool in ("run_command", "bash_background"):
|
|
103
|
+
first = target.split() [0] if target.split() else target
|
|
104
|
+
return f"{first}*"
|
|
105
|
+
if "/" in target:
|
|
106
|
+
return target # exact path
|
|
107
|
+
return target or "*"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------------------- #
|
|
111
|
+
def _load_persisted() -> list[Rule]:
|
|
112
|
+
try:
|
|
113
|
+
data = json.loads(PERMISSIONS_FILE.read_text(encoding="utf-8"))
|
|
114
|
+
return [Rule(r["tool"], r["pattern"], r["action"]) for r in data.get("rules", [])]
|
|
115
|
+
except Exception:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _persist(rule: Rule) -> None:
|
|
120
|
+
try:
|
|
121
|
+
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
existing = []
|
|
123
|
+
if PERMISSIONS_FILE.exists():
|
|
124
|
+
existing = json.loads(PERMISSIONS_FILE.read_text(encoding="utf-8")).get("rules", [])
|
|
125
|
+
existing.insert(0, {"tool": rule.tool, "pattern": rule.pattern, "action": rule.action})
|
|
126
|
+
PERMISSIONS_FILE.write_text(json.dumps({"rules": existing}, indent=2), encoding="utf-8")
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
krnl_agent/plugins.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Plugins — installable bundles of skills, commands, and MCP servers.
|
|
2
|
+
|
|
3
|
+
A plugin is a folder (or a `.zip` URL) containing any of:
|
|
4
|
+
<plugin>/skills/<name>/SKILL.md # skills
|
|
5
|
+
<plugin>/commands/*.md # custom slash commands
|
|
6
|
+
<plugin>/krnl-plugin.yaml # manifest: name, description, mcp servers
|
|
7
|
+
|
|
8
|
+
Installed plugins live in `~/.krnl-agent/plugins/<name>/`. Their skills,
|
|
9
|
+
commands, and MCP servers are merged into every session automatically.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import shutil
|
|
15
|
+
import zipfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from .settings import SETTINGS_DIR
|
|
21
|
+
|
|
22
|
+
PLUGINS_DIR = SETTINGS_DIR / "plugins"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _manifest(plugin_dir: Path) -> dict:
|
|
26
|
+
for name in ("krnl-plugin.yaml", "krnl-plugin.yml", "krnl-plugin.json"):
|
|
27
|
+
p = plugin_dir / name
|
|
28
|
+
if p.is_file():
|
|
29
|
+
try:
|
|
30
|
+
return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
|
31
|
+
except Exception:
|
|
32
|
+
return {}
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_plugins() -> list[dict]:
|
|
37
|
+
if not PLUGINS_DIR.is_dir():
|
|
38
|
+
return []
|
|
39
|
+
out = []
|
|
40
|
+
for d in sorted(PLUGINS_DIR.iterdir()):
|
|
41
|
+
if d.is_dir():
|
|
42
|
+
m = _manifest(d)
|
|
43
|
+
out.append({"name": d.name, "description": m.get("description", "")})
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install(source: str) -> str:
|
|
48
|
+
"""Install from a local directory or a .zip URL. Returns the plugin name."""
|
|
49
|
+
PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
src = Path(source)
|
|
51
|
+
if src.is_dir():
|
|
52
|
+
name = _manifest(src).get("name") or src.name
|
|
53
|
+
dest = PLUGINS_DIR / name
|
|
54
|
+
if dest.exists():
|
|
55
|
+
shutil.rmtree(dest)
|
|
56
|
+
shutil.copytree(src, dest)
|
|
57
|
+
return name
|
|
58
|
+
if source.startswith(("http://", "https://")) and source.endswith(".zip"):
|
|
59
|
+
import httpx
|
|
60
|
+
|
|
61
|
+
data = httpx.get(source, follow_redirects=True, timeout=60).content
|
|
62
|
+
name = Path(source).stem
|
|
63
|
+
dest = PLUGINS_DIR / name
|
|
64
|
+
if dest.exists():
|
|
65
|
+
shutil.rmtree(dest)
|
|
66
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
67
|
+
zf.extractall(dest)
|
|
68
|
+
# if the zip wrapped everything in a single top folder, flatten it
|
|
69
|
+
entries = [p for p in dest.iterdir()]
|
|
70
|
+
if len(entries) == 1 and entries[0].is_dir():
|
|
71
|
+
inner = entries[0]
|
|
72
|
+
for child in inner.iterdir():
|
|
73
|
+
shutil.move(str(child), str(dest / child.name))
|
|
74
|
+
inner.rmdir()
|
|
75
|
+
return _manifest(dest).get("name") or name
|
|
76
|
+
raise ValueError("source must be a local directory or an http(s) .zip URL")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def remove(name: str) -> bool:
|
|
80
|
+
dest = PLUGINS_DIR / name
|
|
81
|
+
if dest.is_dir():
|
|
82
|
+
shutil.rmtree(dest)
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _plugin_dirs() -> list[Path]:
|
|
88
|
+
if not PLUGINS_DIR.is_dir():
|
|
89
|
+
return []
|
|
90
|
+
return [d for d in PLUGINS_DIR.iterdir() if d.is_dir()]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def plugin_skill_dirs() -> list[Path]:
|
|
94
|
+
return [d / "skills" for d in _plugin_dirs() if (d / "skills").is_dir()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def plugin_command_dirs() -> list[Path]:
|
|
98
|
+
return [d / "commands" for d in _plugin_dirs() if (d / "commands").is_dir()]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def plugin_mcp_servers() -> dict:
|
|
102
|
+
servers: dict = {}
|
|
103
|
+
for d in _plugin_dirs():
|
|
104
|
+
servers.update((_manifest(d).get("mcp", {}) or {}).get("servers", {}) or {})
|
|
105
|
+
return servers
|
krnl_agent/pricing.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Token to cost estimation.
|
|
2
|
+
|
|
3
|
+
Prices are USD per 1M tokens (input, output), matched by substring against the
|
|
4
|
+
model id (longest match wins), so 'gpt-4o-mini-2024-..' resolves correctly.
|
|
5
|
+
|
|
6
|
+
Two ways a model gets priced:
|
|
7
|
+
1. A per-model override from config (`pricing:` in config.yaml) - authoritative.
|
|
8
|
+
2. The built-in table below.
|
|
9
|
+
Unknown models return 0.0 (we only ever estimate, never bill); for those, the CLI
|
|
10
|
+
tells you to add a price under `pricing:`.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
# (input_per_1M, output_per_1M)
|
|
17
|
+
PRICES: dict[str, tuple[float, float]] = {
|
|
18
|
+
# OpenAI
|
|
19
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
20
|
+
"gpt-4o": (2.50, 10.0),
|
|
21
|
+
"gpt-4.1-mini": (0.40, 1.60),
|
|
22
|
+
"gpt-4.1-nano": (0.10, 0.40),
|
|
23
|
+
"gpt-4.1": (2.00, 8.00),
|
|
24
|
+
"gpt-5-nano": (0.05, 0.40),
|
|
25
|
+
"gpt-5-mini": (0.25, 2.00),
|
|
26
|
+
"gpt-5.5": (1.25, 10.0),
|
|
27
|
+
"gpt-5": (1.25, 10.0),
|
|
28
|
+
"o4-mini": (1.10, 4.40),
|
|
29
|
+
"o3-mini": (1.10, 4.40),
|
|
30
|
+
"o3": (2.00, 8.00),
|
|
31
|
+
"o1-mini": (1.10, 4.40),
|
|
32
|
+
"o1": (15.0, 60.0),
|
|
33
|
+
# Anthropic
|
|
34
|
+
"claude-3-5-haiku": (0.80, 4.00),
|
|
35
|
+
"claude-haiku": (1.00, 5.00),
|
|
36
|
+
"claude-3-5-sonnet": (3.00, 15.0),
|
|
37
|
+
"claude-sonnet": (3.00, 15.0),
|
|
38
|
+
"claude-opus": (15.0, 75.0),
|
|
39
|
+
# Google
|
|
40
|
+
"gemini-2.5-pro": (1.25, 10.0),
|
|
41
|
+
"gemini-2.5-flash": (0.30, 2.50),
|
|
42
|
+
"gemini-2.0-flash": (0.10, 0.40),
|
|
43
|
+
"gemini-1.5-flash": (0.075, 0.30),
|
|
44
|
+
"gemini-1.5-pro": (1.25, 5.00),
|
|
45
|
+
"gemini": (0.10, 0.40),
|
|
46
|
+
# others
|
|
47
|
+
"deepseek": (0.27, 1.10),
|
|
48
|
+
"grok": (2.00, 10.0),
|
|
49
|
+
"mistral-large": (2.00, 6.00),
|
|
50
|
+
"mistral": (0.20, 0.60),
|
|
51
|
+
"llama": (0.0, 0.0),
|
|
52
|
+
"qwen": (0.0, 0.0),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _rate_from(table: dict, model: str) -> Optional[tuple[float, float]]:
|
|
57
|
+
m = (model or "").lower()
|
|
58
|
+
for key in sorted(table, key=len, reverse=True): # longest (most specific) wins
|
|
59
|
+
if key.lower() in m:
|
|
60
|
+
v = table[key]
|
|
61
|
+
if isinstance(v, dict):
|
|
62
|
+
return float(v.get("input", 0)), float(v.get("output", 0))
|
|
63
|
+
return float(v[0]), float(v[1])
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rate_for(model: str, overrides: Optional[dict] = None) -> Optional[tuple[float, float]]:
|
|
68
|
+
"""Return (input_per_1M, output_per_1M) or None if the model is unpriced."""
|
|
69
|
+
if overrides:
|
|
70
|
+
r = _rate_from(overrides, model)
|
|
71
|
+
if r is not None:
|
|
72
|
+
return r
|
|
73
|
+
return _rate_from(PRICES, model)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_priced(model: str, overrides: Optional[dict] = None) -> bool:
|
|
77
|
+
return rate_for(model, overrides) is not None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cost_for(model: str, prompt_tokens: int, completion_tokens: int,
|
|
81
|
+
overrides: Optional[dict] = None) -> float:
|
|
82
|
+
rate = rate_for(model, overrides)
|
|
83
|
+
if rate is None:
|
|
84
|
+
return 0.0
|
|
85
|
+
return prompt_tokens / 1_000_000 * rate[0] + completion_tokens / 1_000_000 * rate[1]
|
krnl_agent/prompts.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""System prompt for the coding agent.
|
|
2
|
+
|
|
3
|
+
The prompt is deliberately tight and concrete: clear tool-use rules and a
|
|
4
|
+
plan-first discipline are what let a *small* model behave well on focused tasks.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def system_prompt(workspace_path: str, file_tree: str) -> str:
|
|
10
|
+
return f"""You are Krnl Agent, an expert pair-programmer working INSIDE a user's project.
|
|
11
|
+
You operate by calling tools. You can read files, search, edit/create/delete
|
|
12
|
+
files, and run shell commands — always scoped to the workspace.
|
|
13
|
+
|
|
14
|
+
Workspace root: {workspace_path}
|
|
15
|
+
|
|
16
|
+
Operating rules:
|
|
17
|
+
1. PLAN FIRST. For any non-trivial task, briefly state a short plan (2-5 steps)
|
|
18
|
+
in plain text before acting. Keep it terse.
|
|
19
|
+
2. GROUND YOURSELF. Never guess file contents. Use `read_file` and `search_text`
|
|
20
|
+
to learn the real code before editing. Prefer small, targeted reads.
|
|
21
|
+
3. EDIT SURGICALLY. Use `edit_file` (exact search/replace) for changes to
|
|
22
|
+
existing files — it is safer and cheaper than rewriting. Use `write_file`
|
|
23
|
+
only for whole new files or full rewrites. `old_string` must match the file
|
|
24
|
+
EXACTLY, including indentation, and be unique enough to target one spot.
|
|
25
|
+
4. ONE STEP AT A TIME. Make one logical change, observe the tool result, then
|
|
26
|
+
continue. Do not assume an edit applied — the result tells you.
|
|
27
|
+
5. RESPECT APPROVAL. Edits and commands may be rejected by the user. If a tool
|
|
28
|
+
result says the action was rejected, adapt — do not retry the identical action.
|
|
29
|
+
6. VERIFY when reasonable (run tests, run the file, re-read the edited region),
|
|
30
|
+
but don't run long or destructive commands without good reason.
|
|
31
|
+
7. STOP when done. When the task is complete, reply with a concise final summary
|
|
32
|
+
(what changed and why) and DO NOT call any more tools. That ends the turn.
|
|
33
|
+
|
|
34
|
+
Extra capabilities:
|
|
35
|
+
- `todo_write`: for any task with 3+ steps, keep a checklist updated — exactly one
|
|
36
|
+
item `in_progress` at a time. It keeps you organized and the user informed.
|
|
37
|
+
WARNING: After updating the checklist, you MUST immediately call a real tool
|
|
38
|
+
(write_file, edit_file, run_command, etc.). Never call todo_write twice in a row
|
|
39
|
+
without doing real work in between.
|
|
40
|
+
- `spawn_agent`: delegate a focused, independent sub-task to a sub-agent (e.g. a
|
|
41
|
+
separate investigation). It returns a summary; you stay the orchestrator.
|
|
42
|
+
- `multi_edit`: several edits to one file atomically. `glob`: find files by pattern.
|
|
43
|
+
- `web_search` / `web_fetch`: look up current information when needed.
|
|
44
|
+
- `bash_background` + `process_output`/`process_kill`/`process_list`: long-running
|
|
45
|
+
processes like dev servers (never block on them with run_command)
|
|
46
|
+
- `git_status` / `git_diff` / `git_commit`: inspect and commit changes.
|
|
47
|
+
- `mcp__*` tools (if present) come from connected MCP servers — use them like any tool.
|
|
48
|
+
|
|
49
|
+
Large file strategy: When creating files with 100+ lines (CSS, JS, HTML, etc.):
|
|
50
|
+
1. Use write_file for the first portion (e.g. first 150 lines).
|
|
51
|
+
2. Use edit_file to append remaining sections one at a time.
|
|
52
|
+
3. Never attempt to write more content than can fit in a single tool call output.
|
|
53
|
+
If unsure, write smaller chunks and build up incrementally.
|
|
54
|
+
|
|
55
|
+
Be concise. Favor correctness over cleverness. Match the project's existing
|
|
56
|
+
style and conventions.
|
|
57
|
+
|
|
58
|
+
Current files (truncated):
|
|
59
|
+
{file_tree}
|
|
60
|
+
"""
|
krnl_agent/repomap.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Token-friendly semantic repo map.
|
|
2
|
+
|
|
3
|
+
Instead of reading whole files, the agent can pull a compact *outline* of the
|
|
4
|
+
repository: per-file lists of the top-level symbols (classes, functions, methods,
|
|
5
|
+
exported consts) with their line numbers. This is language-agnostic - it uses
|
|
6
|
+
lightweight regex signatures, so there are no heavy parser dependencies - and the
|
|
7
|
+
output is bounded so it always fits in a small slice of the context window.
|
|
8
|
+
|
|
9
|
+
The agent reads the map first to locate code, then `read_file` only the specific
|
|
10
|
+
ranges it needs. That turns "read 40 files to find the auth handler" into "read
|
|
11
|
+
one outline, then one function".
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# Per-language symbol signatures: (compiled regex, kind). Group 1 is the name.
|
|
20
|
+
_LANG_PATTERNS: dict[str, list[tuple[re.Pattern, str]]] = {
|
|
21
|
+
"py": [
|
|
22
|
+
(re.compile(r"^\s*class\s+([A-Za-z_]\w*)"), "class"),
|
|
23
|
+
(re.compile(r"^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)"), "def"),
|
|
24
|
+
],
|
|
25
|
+
"js": [
|
|
26
|
+
(re.compile(r"^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)"), "class"),
|
|
27
|
+
(re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)"), "function"),
|
|
28
|
+
(re.compile(r"^\s*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(?[^=]*=>"), "const-fn"),
|
|
29
|
+
],
|
|
30
|
+
"ts": [
|
|
31
|
+
(re.compile(r"^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)"), "class"),
|
|
32
|
+
(re.compile(r"^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)"), "interface"),
|
|
33
|
+
(re.compile(r"^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)"), "type"),
|
|
34
|
+
(re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)"), "function"),
|
|
35
|
+
(re.compile(r"^\s*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*[:=]"), "const"),
|
|
36
|
+
],
|
|
37
|
+
"go": [
|
|
38
|
+
(re.compile(r"^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)"), "func"),
|
|
39
|
+
(re.compile(r"^\s*type\s+([A-Za-z_]\w*)\s+(?:struct|interface)"), "type"),
|
|
40
|
+
],
|
|
41
|
+
"rs": [
|
|
42
|
+
(re.compile(r"^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)"), "fn"),
|
|
43
|
+
(re.compile(r"^\s*(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z_]\w*)"), "type"),
|
|
44
|
+
],
|
|
45
|
+
"java": [
|
|
46
|
+
(re.compile(r"^\s*(?:public|private|protected).*\bclass\s+([A-Za-z_]\w*)"), "class"),
|
|
47
|
+
(re.compile(r"^\s*(?:public|private|protected|static).*\b([A-Za-z_]\w*)\s*\([^;{]*\)\s*\{"), "method"),
|
|
48
|
+
],
|
|
49
|
+
"rb": [
|
|
50
|
+
(re.compile(r"^\s*class\s+([A-Za-z_]\w*)"), "class"),
|
|
51
|
+
(re.compile(r"^\s*def\s+([A-Za-z_][\w?!]*)"), "def"),
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
# File extension -> language key.
|
|
55
|
+
_EXT_LANG = {
|
|
56
|
+
".py": "py", ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js",
|
|
57
|
+
".ts": "ts", ".tsx": "ts", ".go": "go", ".rs": "rs", ".java": "java",
|
|
58
|
+
".rb": "rb",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_MAX_FILES = 400
|
|
62
|
+
_MAX_SYMBOLS_PER_FILE = 40
|
|
63
|
+
_MAX_TOTAL_LINES = 1500
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _symbols_in(path: Path, lang: str) -> list[tuple[int, str, str]]:
|
|
67
|
+
"""Return (line_no, kind, name) for symbols in one file."""
|
|
68
|
+
pats = _LANG_PATTERNS.get(lang, [])
|
|
69
|
+
out: list[tuple[int, str, str]] = []
|
|
70
|
+
try:
|
|
71
|
+
with open(path, encoding="utf-8", errors="ignore") as fh:
|
|
72
|
+
for i, line in enumerate(fh, 1):
|
|
73
|
+
if len(line) > 400:
|
|
74
|
+
continue
|
|
75
|
+
for rx, kind in pats:
|
|
76
|
+
m = rx.match(line)
|
|
77
|
+
if m:
|
|
78
|
+
out.append((i, kind, m.group(1)))
|
|
79
|
+
break
|
|
80
|
+
if len(out) >= _MAX_SYMBOLS_PER_FILE:
|
|
81
|
+
out.append((i, "…", "(more symbols truncated)"))
|
|
82
|
+
break
|
|
83
|
+
except Exception:
|
|
84
|
+
return out
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_map(ctx, path: str = ".", lang_filter: str | None = None) -> str:
|
|
89
|
+
"""Build a compact outline of the workspace (or a sub-path).
|
|
90
|
+
|
|
91
|
+
`ctx` is a tools.ToolContext (used for sandboxing + ignore rules).
|
|
92
|
+
"""
|
|
93
|
+
base = ctx.resolve(path)
|
|
94
|
+
if not base.exists():
|
|
95
|
+
return f"Path not found: {path}"
|
|
96
|
+
if base.is_file():
|
|
97
|
+
files = [base]
|
|
98
|
+
else:
|
|
99
|
+
files = []
|
|
100
|
+
for root, dirs, names in os.walk(base):
|
|
101
|
+
rroot = ctx.rel(Path(root))
|
|
102
|
+
dirs[:] = [d for d in sorted(dirs)
|
|
103
|
+
if not ctx.is_ignored(f"{rroot}/{d}".lstrip("./"))]
|
|
104
|
+
for n in sorted(names):
|
|
105
|
+
fp = Path(root) / n
|
|
106
|
+
if fp.suffix.lower() in _EXT_LANG and not ctx.is_ignored(ctx.rel(fp)):
|
|
107
|
+
files.append(fp)
|
|
108
|
+
if len(files) >= _MAX_FILES:
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
lines: list[str] = []
|
|
112
|
+
total = 0
|
|
113
|
+
shown = 0
|
|
114
|
+
for fp in sorted(files):
|
|
115
|
+
lang = _EXT_LANG.get(fp.suffix.lower())
|
|
116
|
+
if not lang or (lang_filter and lang != lang_filter):
|
|
117
|
+
continue
|
|
118
|
+
syms = _symbols_in(fp, lang)
|
|
119
|
+
if not syms:
|
|
120
|
+
continue
|
|
121
|
+
lines.append(f"\n{ctx.rel(fp)}")
|
|
122
|
+
for ln, kind, name in syms:
|
|
123
|
+
lines.append(f" {ln:>5} {kind} {name}")
|
|
124
|
+
total += 1
|
|
125
|
+
shown += 1
|
|
126
|
+
if total >= _MAX_TOTAL_LINES:
|
|
127
|
+
lines.append("\n… (map truncated; narrow with path= or lang=)")
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
if not lines:
|
|
131
|
+
return "(no recognizable source symbols found)"
|
|
132
|
+
header = f"# Repo map — {shown} file(s), {total} symbols (line kind name)"
|
|
133
|
+
return header + "\n" + "\n".join(lines)
|