closecode-ai 0.1.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 +74 -0
- closecode_ai-0.1.0.dist-info/METADATA +138 -0
- closecode_ai-0.1.0.dist-info/RECORD +19 -0
- closecode_ai-0.1.0.dist-info/WHEEL +5 -0
- closecode_ai-0.1.0.dist-info/entry_points.txt +2 -0
- closecode_ai-0.1.0.dist-info/top_level.txt +14 -0
- debug_response.py +25 -0
- guardrails.py +347 -0
- harness.py +200 -0
- llm.py +145 -0
- main.py +520 -0
- mcp_tools.py +27 -0
- modes.py +36 -0
- search.py +130 -0
- session.py +197 -0
- todos.py +99 -0
- token_tracker.py +42 -0
- tools.py +137 -0
- ui.py +422 -0
search.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Code search tools: glob (find files by pattern) and grep (search contents).
|
|
2
|
+
|
|
3
|
+
Read-only, so both are available in plan mode too — unlike bash, which plan
|
|
4
|
+
mode blocks entirely. Skips dependency/build directories so results stay
|
|
5
|
+
relevant and fast.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from langchain_core.tools import tool
|
|
15
|
+
|
|
16
|
+
_root: Optional[Path] = None
|
|
17
|
+
|
|
18
|
+
# Directories never descended into during search.
|
|
19
|
+
SKIP_DIRS = {
|
|
20
|
+
".git",
|
|
21
|
+
"node_modules",
|
|
22
|
+
"__pycache__",
|
|
23
|
+
".venv",
|
|
24
|
+
"venv",
|
|
25
|
+
".cache",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
".idea",
|
|
29
|
+
".vscode",
|
|
30
|
+
"target",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
MAX_RESULTS = 50
|
|
34
|
+
MAX_LINE_LEN = 160
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bind_search_root(path: str) -> None:
|
|
38
|
+
"""Call once at startup — searches are confined to this root."""
|
|
39
|
+
global _root
|
|
40
|
+
_root = Path(path).resolve()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _require_root() -> Path:
|
|
44
|
+
if _root is None:
|
|
45
|
+
raise RuntimeError("Search root not bound. Call bind_search_root() before running the agent.")
|
|
46
|
+
_root.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
return _root
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _walk_files(base: Path):
|
|
51
|
+
"""Yield files under base, pruning skipped directories."""
|
|
52
|
+
for dirpath, dirnames, filenames in os.walk(base):
|
|
53
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
54
|
+
for name in filenames:
|
|
55
|
+
if name.startswith("."):
|
|
56
|
+
continue
|
|
57
|
+
yield Path(dirpath) / name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@tool
|
|
61
|
+
def glob(pattern: str, path: str = ".") -> str:
|
|
62
|
+
"""Find files matching a glob pattern (e.g. "**/*.py", "src/*.ts").
|
|
63
|
+
Searches under path (default: working directory root), skipping
|
|
64
|
+
dependency and build directories. Prefer this over bash find/ls."""
|
|
65
|
+
root = _require_root()
|
|
66
|
+
base = (root / path).resolve()
|
|
67
|
+
if root not in base.parents and base != root:
|
|
68
|
+
return f"Path '{path}' escapes the working directory."
|
|
69
|
+
if not base.is_dir():
|
|
70
|
+
return f"Not a directory: {path}"
|
|
71
|
+
matches = []
|
|
72
|
+
for f in _walk_files(base):
|
|
73
|
+
rel = f.relative_to(base).as_posix()
|
|
74
|
+
if fnmatch.fnmatchcase(rel, pattern) or fnmatch.fnmatchcase(f.name, pattern):
|
|
75
|
+
matches.append(rel)
|
|
76
|
+
if len(matches) >= MAX_RESULTS:
|
|
77
|
+
break
|
|
78
|
+
matches.sort()
|
|
79
|
+
if not matches:
|
|
80
|
+
return f"No files match '{pattern}' under {path}."
|
|
81
|
+
suffix = f"\n…truncated at {MAX_RESULTS}, narrow the pattern." if len(matches) >= MAX_RESULTS else ""
|
|
82
|
+
return "\n".join(matches) + suffix
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@tool
|
|
86
|
+
def grep(pattern: str, path: str = ".", include: str = "*", max_results: int = 50) -> str:
|
|
87
|
+
"""Search file contents with a regex pattern. path: where to search
|
|
88
|
+
(default: working directory root). include: file glob to restrict to
|
|
89
|
+
(e.g. "*.py"). Returns file:line: match, newest relevant first —
|
|
90
|
+
prefer this over bash grep/rg, and it's available in plan mode."""
|
|
91
|
+
root = _require_root()
|
|
92
|
+
base = (root / path).resolve()
|
|
93
|
+
if root not in base.parents and base != root:
|
|
94
|
+
return f"Path '{path}' escapes the working directory."
|
|
95
|
+
if not base.is_dir():
|
|
96
|
+
return f"Not a directory: {path}"
|
|
97
|
+
try:
|
|
98
|
+
rx = re.compile(pattern)
|
|
99
|
+
except re.error as e:
|
|
100
|
+
return f"Invalid regex: {e}"
|
|
101
|
+
safe_max = max(1, min(int(max_results), 200))
|
|
102
|
+
hits = []
|
|
103
|
+
for f in _walk_files(base):
|
|
104
|
+
if not (fnmatch.fnmatchcase(f.relative_to(base).as_posix(), include)
|
|
105
|
+
or fnmatch.fnmatchcase(f.name, include)):
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
text = f.read_text(errors="strict")
|
|
109
|
+
except Exception:
|
|
110
|
+
continue # binary or unreadable — skip
|
|
111
|
+
if "\x00" in text[:8192]:
|
|
112
|
+
continue
|
|
113
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
114
|
+
if rx.search(line):
|
|
115
|
+
rel = f.relative_to(root).as_posix()
|
|
116
|
+
line = line.strip()
|
|
117
|
+
if len(line) > MAX_LINE_LEN:
|
|
118
|
+
line = line[:MAX_LINE_LEN] + "…"
|
|
119
|
+
hits.append(f"{rel}:{i}: {line}")
|
|
120
|
+
if len(hits) >= safe_max:
|
|
121
|
+
break
|
|
122
|
+
if len(hits) >= safe_max:
|
|
123
|
+
break
|
|
124
|
+
if not hits:
|
|
125
|
+
return f"No matches for '{pattern}' under {path}."
|
|
126
|
+
suffix = f"\n…truncated at {safe_max}, narrow the pattern." if len(hits) >= safe_max else ""
|
|
127
|
+
return "\n".join(hits) + suffix
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
SEARCH_TOOLS = [glob, grep]
|
session.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
import sqlite3
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from langchain_core.messages import messages_from_dict, messages_to_dict
|
|
10
|
+
|
|
11
|
+
SESSIONS_DIR = Path("./sessions")
|
|
12
|
+
DB_PATH = SESSIONS_DIR / "sessions.db"
|
|
13
|
+
|
|
14
|
+
_SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
name TEXT NOT NULL DEFAULT '',
|
|
18
|
+
model TEXT NOT NULL DEFAULT '',
|
|
19
|
+
mode TEXT NOT NULL DEFAULT 'build',
|
|
20
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
21
|
+
created_at REAL NOT NULL,
|
|
22
|
+
updated_at REAL NOT NULL,
|
|
23
|
+
message_count INTEGER NOT NULL DEFAULT 0
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
session_id INTEGER NOT NULL,
|
|
29
|
+
seq INTEGER NOT NULL,
|
|
30
|
+
payload TEXT NOT NULL,
|
|
31
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
|
32
|
+
UNIQUE (session_id, seq)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SessionInfo:
|
|
41
|
+
id: int
|
|
42
|
+
name: str
|
|
43
|
+
model: str
|
|
44
|
+
mode: str
|
|
45
|
+
summary: str
|
|
46
|
+
created_at: float
|
|
47
|
+
updated_at: float
|
|
48
|
+
message_count: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _connect() -> sqlite3.Connection:
|
|
52
|
+
conn = sqlite3.connect(DB_PATH)
|
|
53
|
+
conn.row_factory = sqlite3.Row
|
|
54
|
+
return conn
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _init() -> None:
|
|
58
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
with _connect() as conn:
|
|
60
|
+
conn.executescript(_SCHEMA)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _migrate_from_json() -> None:
|
|
64
|
+
"""Fold any legacy session_*.json files into the DB once, then leave them."""
|
|
65
|
+
with _connect() as conn:
|
|
66
|
+
count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
|
67
|
+
if count:
|
|
68
|
+
return
|
|
69
|
+
files = sorted(SESSIONS_DIR.glob("session_*.json"))
|
|
70
|
+
for path in files:
|
|
71
|
+
try:
|
|
72
|
+
msgs = messages_from_dict(json.loads(path.read_text()))
|
|
73
|
+
except Exception:
|
|
74
|
+
continue
|
|
75
|
+
if not msgs:
|
|
76
|
+
continue
|
|
77
|
+
mtime = path.stat().st_mtime
|
|
78
|
+
with _connect() as conn:
|
|
79
|
+
cur = conn.execute(
|
|
80
|
+
"INSERT INTO sessions (name, model, mode, summary, created_at, updated_at, message_count) "
|
|
81
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
82
|
+
("", "", "build", "", mtime, mtime, len(msgs)),
|
|
83
|
+
)
|
|
84
|
+
sid = cur.lastrowid
|
|
85
|
+
for seq, m in enumerate(msgs):
|
|
86
|
+
conn.execute(
|
|
87
|
+
"INSERT INTO messages (session_id, seq, payload) VALUES (?, ?, ?)",
|
|
88
|
+
(sid, seq, json.dumps(messages_to_dict([m])[0])),
|
|
89
|
+
)
|
|
90
|
+
conn.commit()
|
|
91
|
+
name = _derive_name(msgs)
|
|
92
|
+
if name:
|
|
93
|
+
conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, sid))
|
|
94
|
+
conn.commit()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _derive_name(messages: list) -> str:
|
|
98
|
+
"""Short, human-readable name from the first user message."""
|
|
99
|
+
for m in messages:
|
|
100
|
+
if getattr(m, "type", "") == "human":
|
|
101
|
+
text = str(getattr(m, "content", "") or "").strip()
|
|
102
|
+
text = " ".join(text.split())
|
|
103
|
+
return text[:48]
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def new_session(model: str = "", mode: str = "build") -> int:
|
|
108
|
+
"""Create a new session row and return its id."""
|
|
109
|
+
now = time.time()
|
|
110
|
+
with _connect() as conn:
|
|
111
|
+
cur = conn.execute(
|
|
112
|
+
"INSERT INTO sessions (model, mode, created_at, updated_at, message_count) VALUES (?, ?, ?, ?, 0)",
|
|
113
|
+
(model, mode, now, now),
|
|
114
|
+
)
|
|
115
|
+
conn.commit()
|
|
116
|
+
return int(cur.lastrowid)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save(session_id: int, messages: list, model: str = "", mode: str = "build") -> None:
|
|
120
|
+
"""Replace a session's stored messages and refresh its metadata."""
|
|
121
|
+
payloads = [json.dumps(d) for d in messages_to_dict(messages)]
|
|
122
|
+
now = time.time()
|
|
123
|
+
with _connect() as conn:
|
|
124
|
+
conn.execute(
|
|
125
|
+
"UPDATE sessions SET model = ?, mode = ?, updated_at = ?, message_count = ? WHERE id = ?",
|
|
126
|
+
(model, mode, now, len(payloads), session_id),
|
|
127
|
+
)
|
|
128
|
+
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
129
|
+
conn.executemany(
|
|
130
|
+
"INSERT INTO messages (session_id, seq, payload) VALUES (?, ?, ?)",
|
|
131
|
+
[(session_id, seq, p) for seq, p in enumerate(payloads)],
|
|
132
|
+
)
|
|
133
|
+
row = conn.execute("SELECT name FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
134
|
+
if row and not row["name"]:
|
|
135
|
+
name = _derive_name(messages)
|
|
136
|
+
if name:
|
|
137
|
+
conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id))
|
|
138
|
+
conn.commit()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load(session_id: int) -> list:
|
|
142
|
+
"""Rehydrate a session's messages in order. Skips any corrupt rows."""
|
|
143
|
+
with _connect() as conn:
|
|
144
|
+
rows = conn.execute(
|
|
145
|
+
"SELECT payload FROM messages WHERE session_id = ? ORDER BY seq", (session_id,)
|
|
146
|
+
).fetchall()
|
|
147
|
+
payloads = []
|
|
148
|
+
for row in rows:
|
|
149
|
+
try:
|
|
150
|
+
payloads.append(json.loads(row["payload"]))
|
|
151
|
+
except Exception:
|
|
152
|
+
continue
|
|
153
|
+
return messages_from_dict(payloads)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_session(session_id: int) -> Optional[SessionInfo]:
|
|
157
|
+
with _connect() as conn:
|
|
158
|
+
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
159
|
+
if row is None:
|
|
160
|
+
return None
|
|
161
|
+
return SessionInfo(**{k: row[k] for k in row.keys()})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def list_sessions() -> list[SessionInfo]:
|
|
165
|
+
with _connect() as conn:
|
|
166
|
+
rows = conn.execute(
|
|
167
|
+
"SELECT * FROM sessions ORDER BY updated_at DESC"
|
|
168
|
+
).fetchall()
|
|
169
|
+
return [SessionInfo(**{k: row[k] for k in row.keys()}) for row in rows]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def latest_session_id() -> Optional[int]:
|
|
173
|
+
with _connect() as conn:
|
|
174
|
+
row = conn.execute(
|
|
175
|
+
"SELECT id FROM sessions WHERE message_count > 0 ORDER BY updated_at DESC LIMIT 1"
|
|
176
|
+
).fetchone()
|
|
177
|
+
return int(row["id"]) if row else None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def delete_session(session_id: int) -> bool:
|
|
181
|
+
with _connect() as conn:
|
|
182
|
+
cur = conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
183
|
+
conn.commit()
|
|
184
|
+
return cur.rowcount > 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def rename_session(session_id: int, name: str) -> bool:
|
|
188
|
+
with _connect() as conn:
|
|
189
|
+
cur = conn.execute(
|
|
190
|
+
"UPDATE sessions SET name = ? WHERE id = ?", (name.strip()[:80], session_id)
|
|
191
|
+
)
|
|
192
|
+
conn.commit()
|
|
193
|
+
return cur.rowcount > 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_init()
|
|
197
|
+
_migrate_from_json()
|
todos.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Todo tracking for the agent (Claude Code-style TodoWrite/TodoRead).
|
|
2
|
+
|
|
3
|
+
Todos live in memory, scoped to the current session — main.py clears the
|
|
4
|
+
store whenever the session changes (/clear, /resume, new session). The
|
|
5
|
+
pattern mirrors tools.py: a module-global store bound once at startup via
|
|
6
|
+
bind_todo_store().
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from langchain_core.tools import tool
|
|
12
|
+
|
|
13
|
+
_store: Optional["TodoStore"] = None
|
|
14
|
+
|
|
15
|
+
VALID_STATUSES = ("pending", "in_progress", "completed")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TodoStore:
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.items: list[dict] = []
|
|
21
|
+
|
|
22
|
+
def set(self, items: list[dict]) -> list[dict]:
|
|
23
|
+
"""Replace the whole list. Sanitizes entries: drops empties,
|
|
24
|
+
coerces bad statuses to pending, and enforces exactly one
|
|
25
|
+
in_progress at a time (extras are demoted to pending)."""
|
|
26
|
+
cleaned = []
|
|
27
|
+
for it in items:
|
|
28
|
+
if not isinstance(it, dict):
|
|
29
|
+
continue
|
|
30
|
+
content = str(it.get("content", "")).strip()
|
|
31
|
+
if not content:
|
|
32
|
+
continue
|
|
33
|
+
status = it.get("status", "pending")
|
|
34
|
+
if status not in VALID_STATUSES:
|
|
35
|
+
status = "pending"
|
|
36
|
+
cleaned.append(
|
|
37
|
+
{
|
|
38
|
+
"content": content[:200],
|
|
39
|
+
"status": status,
|
|
40
|
+
"activeForm": str(it.get("activeForm", ""))[:200],
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
seen_active = False
|
|
44
|
+
for it in cleaned:
|
|
45
|
+
if it["status"] == "in_progress":
|
|
46
|
+
if seen_active:
|
|
47
|
+
it["status"] = "pending"
|
|
48
|
+
seen_active = True
|
|
49
|
+
self.items = cleaned
|
|
50
|
+
return self.items
|
|
51
|
+
|
|
52
|
+
def get(self) -> list[dict]:
|
|
53
|
+
return list(self.items)
|
|
54
|
+
|
|
55
|
+
def clear(self) -> None:
|
|
56
|
+
self.items = []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bind_todo_store(store: TodoStore) -> None:
|
|
60
|
+
"""Call once at startup before the graph runs any tool calls."""
|
|
61
|
+
global _store
|
|
62
|
+
_store = store
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _require_store() -> TodoStore:
|
|
66
|
+
if _store is None:
|
|
67
|
+
raise RuntimeError("TodoStore not bound. Call bind_todo_store() before running the agent.")
|
|
68
|
+
return _store
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_todos(items: list[dict]) -> str:
|
|
72
|
+
"""One-line-per-todo text form, also used for tool results."""
|
|
73
|
+
if not items:
|
|
74
|
+
return "(no todos)"
|
|
75
|
+
icons = {"pending": "○", "in_progress": "◐", "completed": "✓"}
|
|
76
|
+
return "\n".join(
|
|
77
|
+
f"{icons.get(it['status'], '?')} {it['content']}" for it in items
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@tool
|
|
82
|
+
def todo_write(todos: list[dict]) -> str:
|
|
83
|
+
"""Create or update the agent's task list. Call with the FULL list every
|
|
84
|
+
time (it replaces the previous list). Each item: {"content": "do X",
|
|
85
|
+
"status": "pending"|"in_progress"|"completed", "activeForm": "Doing X"}.
|
|
86
|
+
Use for any task with 3+ steps: write the plan first, keep exactly one
|
|
87
|
+
item in_progress, flip each to completed as you finish it, and re-write
|
|
88
|
+
the list when the plan changes. This list is how the user sees progress."""
|
|
89
|
+
items = _require_store().set(todos if isinstance(todos, list) else [])
|
|
90
|
+
return "Todo list updated:\n" + format_todos(items)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@tool
|
|
94
|
+
def todo_read() -> str:
|
|
95
|
+
"""Read the current task list with each item's status."""
|
|
96
|
+
return format_todos(_require_store().get())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
TODO_TOOLS = [todo_write, todo_read]
|
token_tracker.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class TokenTracker:
|
|
7
|
+
prompt_tokens: int = 0
|
|
8
|
+
completion_tokens: int = 0
|
|
9
|
+
total_tokens: int = 0
|
|
10
|
+
calls: int = 0
|
|
11
|
+
calls_without_usage_data: int = 0
|
|
12
|
+
|
|
13
|
+
def add_from_message(self, message) -> None:
|
|
14
|
+
usage = getattr(message, "usage_metadata", None)
|
|
15
|
+
if usage:
|
|
16
|
+
self.prompt_tokens += usage.get("input_tokens", 0) or 0
|
|
17
|
+
self.completion_tokens += usage.get("output_tokens", 0) or 0
|
|
18
|
+
self.total_tokens += usage.get("total_tokens", 0) or 0
|
|
19
|
+
self.calls += 1
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
meta = getattr(message, "response_metadata", None) or {}
|
|
23
|
+
tu = meta.get("token_usage") or meta.get("usage")
|
|
24
|
+
if tu:
|
|
25
|
+
p = tu.get("prompt_tokens", 0) or 0
|
|
26
|
+
c = tu.get("completion_tokens", 0) or 0
|
|
27
|
+
self.prompt_tokens += p
|
|
28
|
+
self.completion_tokens += c
|
|
29
|
+
self.total_tokens += tu.get("total_tokens", p + c) or (p + c)
|
|
30
|
+
self.calls += 1
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
self.calls += 1
|
|
34
|
+
self.calls_without_usage_data += 1
|
|
35
|
+
|
|
36
|
+
def summary(self) -> str:
|
|
37
|
+
if self.calls == 0:
|
|
38
|
+
return "no model calls yet this session"
|
|
39
|
+
line = f"{self.calls} model calls \u00b7 {self.prompt_tokens} in / {self.completion_tokens} out / {self.total_tokens} total tokens"
|
|
40
|
+
if self.calls_without_usage_data:
|
|
41
|
+
line += f" ({self.calls_without_usage_data} calls reported no usage data \u2014 provider limitation, not a bug)"
|
|
42
|
+
return line
|
tools.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from langchain_core.tools import tool
|
|
7
|
+
|
|
8
|
+
from harness import Harness
|
|
9
|
+
|
|
10
|
+
_harness: Optional[Harness] = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def bind_harness(harness: Harness) -> None:
|
|
14
|
+
"""Call this once at startup before the graph runs any tool calls."""
|
|
15
|
+
global _harness
|
|
16
|
+
_harness = harness
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_harness() -> Harness:
|
|
20
|
+
if _harness is None:
|
|
21
|
+
raise RuntimeError("Harness not bound. Call bind_harness() before running the agent.")
|
|
22
|
+
return _harness
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@tool
|
|
26
|
+
def bash(command: str, timeout: int = 30) -> str:
|
|
27
|
+
"""Run a shell command in the sandboxed working directory and return its combined stdout/stderr.
|
|
28
|
+
timeout: max seconds to wait (default 30). Increase this for slow operations like
|
|
29
|
+
package installs or builds (e.g. timeout=120 for a two-minute cap) — the units are
|
|
30
|
+
SECONDS, not milliseconds. Capped at 300s (5 minutes) regardless of what's requested."""
|
|
31
|
+
safe_timeout = max(1, min(int(timeout), 300))
|
|
32
|
+
return _require_harness().run_bash(command, timeout=safe_timeout)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@tool
|
|
36
|
+
def read_file(path: str) -> str:
|
|
37
|
+
"""Read a text file's contents. Path is relative to the agent's working directory."""
|
|
38
|
+
return _require_harness().read_file(path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@tool
|
|
42
|
+
def write_file(path: str, content: str) -> str:
|
|
43
|
+
"""Write text content to a file, creating parent directories if needed. Path is relative to the working directory."""
|
|
44
|
+
return _require_harness().write_file(path, content)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@tool
|
|
48
|
+
def list_dir(path: str = ".") -> str:
|
|
49
|
+
"""List files and subdirectories at a given path (default: the working directory root). Directories are marked with a trailing '/'."""
|
|
50
|
+
return _require_harness().list_dir(path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@tool
|
|
54
|
+
def edit_file(path: str, old_text: str, new_text: str) -> str:
|
|
55
|
+
"""Replace one exact occurrence of old_text with new_text in a file. Use this instead of write_file for small changes — it's cheaper and fails safely if old_text isn't found or isn't unique, rather than risking an overwrite of the wrong content."""
|
|
56
|
+
return _require_harness().edit_file(path, old_text, new_text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@tool
|
|
60
|
+
def run_tests(command: str = "pytest") -> str:
|
|
61
|
+
"""Run the project's test suite (default command: 'pytest') and report PASSED/FAILED with output. Use this to verify a change actually works, not just that it was written."""
|
|
62
|
+
return _require_harness().run_tests(command)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_TAVILY_API_URL = "https://api.tavily.com/search"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@tool
|
|
69
|
+
def tavily_search(query: str, max_results: int = 5) -> str:
|
|
70
|
+
"""Search the web using Tavily and return the top results (title, url, content snippet, relevance score). Handy for checking current API docs, finding library versions, resolving error messages, or verifying facts that changed after your training cutoff. Requires a TAVILY_API_KEY in .env."""
|
|
71
|
+
api_key = os.environ.get("TAVILY_API_KEY")
|
|
72
|
+
if not api_key:
|
|
73
|
+
return "Error: TAVILY_API_KEY is not set. Add it to the .env file and restart."
|
|
74
|
+
safe_max = max(1, min(int(max_results), 10))
|
|
75
|
+
try:
|
|
76
|
+
resp = requests.post(
|
|
77
|
+
_TAVILY_API_URL,
|
|
78
|
+
json={"api_key": api_key, "query": query, "max_results": safe_max, "search_depth": "basic"},
|
|
79
|
+
timeout=30,
|
|
80
|
+
)
|
|
81
|
+
resp.raise_for_status()
|
|
82
|
+
data = resp.json()
|
|
83
|
+
except Exception as e:
|
|
84
|
+
return f"Tavily search failed: {e}"
|
|
85
|
+
|
|
86
|
+
results = data.get("results") or []
|
|
87
|
+
if not results:
|
|
88
|
+
return f"No results found for: {query}"
|
|
89
|
+
lines = [f"Query: {query}\n"]
|
|
90
|
+
for i, r in enumerate(results, 1):
|
|
91
|
+
title = r.get("title", "")
|
|
92
|
+
url = r.get("url", "")
|
|
93
|
+
content = (r.get("content") or "").strip()
|
|
94
|
+
lines.append(f"{i}. {title}\n URL: {url}\n {content}")
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Local (non-MCP) tools. main.py combines this with any MCP-provided tools
|
|
99
|
+
# (e.g. git) before binding to the model.
|
|
100
|
+
LOCAL_TOOLS = [bash, read_file, write_file, list_dir, edit_file, run_tests, tavily_search]
|
|
101
|
+
|
|
102
|
+
# Tools considered safe in "plan" mode: read-only, no filesystem/shell
|
|
103
|
+
# mutation. bash is excluded entirely even though some commands are
|
|
104
|
+
# harmless (e.g. `ls`) — there's no reliable way to tell a read-only shell
|
|
105
|
+
# command from a destructive one without actually parsing it, so plan mode
|
|
106
|
+
# blocks bash outright rather than trying to guess.
|
|
107
|
+
_PLAN_SAFE_LOCAL_NAMES = {"read_file", "list_dir", "tavily_search"}
|
|
108
|
+
|
|
109
|
+
# Heuristic for filtering MCP tools (e.g. git) in plan mode: block anything
|
|
110
|
+
# whose name suggests it mutates state. This is a name-based guess, not a
|
|
111
|
+
# guarantee — if you add other MCP servers, sanity-check their tool names
|
|
112
|
+
# fall into one of these buckets as expected.
|
|
113
|
+
_MUTATING_KEYWORDS = ("commit", "push", "reset", "checkout", "branch", "merge", "add", "rm", "stash", "revert", "rebase", "delete", "write", "create")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def filter_tools_for_mode(tools: list, mode: str) -> list:
|
|
117
|
+
"""mode == 'build': every tool is available.
|
|
118
|
+
mode == 'plan': only read-only tools are available, so the agent can
|
|
119
|
+
explore and reason about a task but literally cannot call anything
|
|
120
|
+
that writes to disk, runs arbitrary shell, or mutates git state —
|
|
121
|
+
enforced by never binding those tools to the model at all, which is a
|
|
122
|
+
stronger guarantee than trusting the model to just not call them."""
|
|
123
|
+
if mode == "build":
|
|
124
|
+
return tools
|
|
125
|
+
|
|
126
|
+
filtered = []
|
|
127
|
+
for t in tools:
|
|
128
|
+
name = getattr(t, "name", "")
|
|
129
|
+
if name in _PLAN_SAFE_LOCAL_NAMES:
|
|
130
|
+
filtered.append(t)
|
|
131
|
+
elif name in {"bash", "write_file", "edit_file", "run_tests"}:
|
|
132
|
+
continue
|
|
133
|
+
elif not any(keyword in name.lower() for keyword in _MUTATING_KEYWORDS):
|
|
134
|
+
# Likely an MCP tool (e.g. git status/diff/log) that doesn't
|
|
135
|
+
# match a known-mutating keyword — allow it through.
|
|
136
|
+
filtered.append(t)
|
|
137
|
+
return filtered
|