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
main.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
warnings.filterwarnings(
|
|
11
|
+
"ignore",
|
|
12
|
+
message="Core Pydantic V1 functionality isn't compatible with Python 3.14",
|
|
13
|
+
category=UserWarning,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
|
|
18
|
+
load_dotenv()
|
|
19
|
+
|
|
20
|
+
from langchain_core.messages import HumanMessage, SystemMessage # noqa: E402
|
|
21
|
+
|
|
22
|
+
from langgraph.errors import GraphRecursionError # noqa: E402
|
|
23
|
+
|
|
24
|
+
import session # noqa: E402
|
|
25
|
+
import ui # noqa: E402
|
|
26
|
+
from agent import SYSTEM_PROMPT, build_graph # noqa: E402
|
|
27
|
+
from guardrails import check_user_input, redact_message # noqa: E402
|
|
28
|
+
from harness import Harness # noqa: E402
|
|
29
|
+
from llm import DEFAULT_MODEL, KNOWN_MODELS, fetch_openrouter_models, get_llm, resolve_model_arg # noqa: E402
|
|
30
|
+
from mcp_tools import get_git_repo_path, get_git_tools # noqa: E402
|
|
31
|
+
from modes import filter_tools_for_mode, mode_system_note # noqa: E402
|
|
32
|
+
from search import SEARCH_TOOLS, bind_search_root # noqa: E402
|
|
33
|
+
from todos import TODO_TOOLS, TodoStore, bind_todo_store # noqa: E402
|
|
34
|
+
from token_tracker import TokenTracker # noqa: E402
|
|
35
|
+
from tools import LOCAL_TOOLS, bind_harness # noqa: E402
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Module-global todo store, bound to the todo tools once at startup and
|
|
39
|
+
# cleared whenever the session changes (new session, /resume, /clear).
|
|
40
|
+
todo_store = TodoStore()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def system_message_for(mode: str) -> SystemMessage:
|
|
44
|
+
return SystemMessage(content=SYSTEM_PROMPT + mode_system_note(mode))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_system_message(messages: list, mode: str) -> None:
|
|
48
|
+
"""Ensure a session's first message is the current system prompt, in the
|
|
49
|
+
right mode note, without clobbering a non-system first message."""
|
|
50
|
+
if messages and getattr(messages[0], "type", "") == "system":
|
|
51
|
+
messages[0] = system_message_for(mode)
|
|
52
|
+
else:
|
|
53
|
+
messages.insert(0, system_message_for(mode))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _looks_like_tool_json(text: str) -> bool:
|
|
57
|
+
"""True when a streamed buffer is a tool call the model is writing out as
|
|
58
|
+
JSON (qwen2.5-coder via Ollama does this instead of native tool_calls).
|
|
59
|
+
Such content is re-parsed into a real tool call in agent.py, so we keep
|
|
60
|
+
it out of the chat panel entirely."""
|
|
61
|
+
stripped = text.lstrip()
|
|
62
|
+
return stripped.startswith("{") and '"name"' in stripped and '"arguments"' in stripped
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def run_turn(graph, messages: list, token_tracker: TokenTracker) -> list:
|
|
66
|
+
"""Streams one user turn via astream_events. Prints tool calls/results
|
|
67
|
+
as they happen, streams the final text response token-by-token into a
|
|
68
|
+
live-updating panel, and tracks token usage along the way.
|
|
69
|
+
|
|
70
|
+
Also races the event stream against an Esc keypress (watched on a
|
|
71
|
+
background thread by ui.EscListener) so the user can bail out of a turn
|
|
72
|
+
that's stuck, taking too long, or headed somewhere they don't want it
|
|
73
|
+
to go, without killing the whole process.
|
|
74
|
+
"""
|
|
75
|
+
ui.print_thinking()
|
|
76
|
+
|
|
77
|
+
live = None
|
|
78
|
+
text_buffer = ""
|
|
79
|
+
final_messages = messages
|
|
80
|
+
turn_start = time.monotonic()
|
|
81
|
+
interrupted = False
|
|
82
|
+
|
|
83
|
+
loop = asyncio.get_running_loop()
|
|
84
|
+
stop_event = asyncio.Event()
|
|
85
|
+
esc = ui.EscListener(loop, stop_event)
|
|
86
|
+
esc.start()
|
|
87
|
+
|
|
88
|
+
agen = graph.astream_events({"messages": messages}, version="v2")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
while True:
|
|
92
|
+
next_task = asyncio.ensure_future(agen.__anext__())
|
|
93
|
+
stop_task = asyncio.ensure_future(stop_event.wait())
|
|
94
|
+
try:
|
|
95
|
+
done, _pending = await asyncio.wait(
|
|
96
|
+
{next_task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
|
97
|
+
)
|
|
98
|
+
except asyncio.CancelledError:
|
|
99
|
+
next_task.cancel()
|
|
100
|
+
stop_task.cancel()
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
if stop_task in done:
|
|
104
|
+
interrupted = True
|
|
105
|
+
next_task.cancel()
|
|
106
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
107
|
+
await next_task
|
|
108
|
+
with contextlib.suppress(Exception):
|
|
109
|
+
await agen.aclose()
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
stop_task.cancel()
|
|
113
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
114
|
+
await stop_task
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
event = next_task.result()
|
|
118
|
+
except StopAsyncIteration:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
kind = event["event"]
|
|
122
|
+
|
|
123
|
+
if kind == "on_chat_model_stream":
|
|
124
|
+
chunk = event["data"]["chunk"]
|
|
125
|
+
if getattr(chunk, "content", None):
|
|
126
|
+
text_buffer += chunk.content
|
|
127
|
+
if _looks_like_tool_json(text_buffer):
|
|
128
|
+
continue
|
|
129
|
+
if live is None:
|
|
130
|
+
live = ui.stream_start()
|
|
131
|
+
ui.stream_update(live, text_buffer)
|
|
132
|
+
|
|
133
|
+
elif kind == "on_chat_model_end":
|
|
134
|
+
output = event["data"].get("output")
|
|
135
|
+
if output is not None:
|
|
136
|
+
token_tracker.add_from_message(output)
|
|
137
|
+
_blocked, reason = redact_message(output)
|
|
138
|
+
if reason:
|
|
139
|
+
ui.print_notice(
|
|
140
|
+
f"Guardrail \u2014 blocked {reason}. The flagged content was "
|
|
141
|
+
"removed from conversation history.",
|
|
142
|
+
style="bold red",
|
|
143
|
+
)
|
|
144
|
+
if live is not None:
|
|
145
|
+
ui.stream_stop(live)
|
|
146
|
+
live = None
|
|
147
|
+
text_buffer = ""
|
|
148
|
+
|
|
149
|
+
elif kind == "on_tool_start":
|
|
150
|
+
ui.print_tool_call(event["name"], event["data"].get("input") or {})
|
|
151
|
+
|
|
152
|
+
elif kind == "on_tool_end":
|
|
153
|
+
output = event["data"].get("output")
|
|
154
|
+
content = getattr(output, "content", None)
|
|
155
|
+
if content is None:
|
|
156
|
+
content = str(output)
|
|
157
|
+
ui.print_tool_result(str(content))
|
|
158
|
+
if event.get("name") == "todo_write":
|
|
159
|
+
ui.print_todos(todo_store.get())
|
|
160
|
+
|
|
161
|
+
elif kind == "on_chain_end" and event.get("name") == "LangGraph":
|
|
162
|
+
output = event["data"].get("output")
|
|
163
|
+
if output and "messages" in output:
|
|
164
|
+
final_messages = output["messages"]
|
|
165
|
+
|
|
166
|
+
except GraphRecursionError:
|
|
167
|
+
# This used to be the visible symptom of the sandbox/workdir bug:
|
|
168
|
+
# write_file silently failing on an absolute-looking path, bash
|
|
169
|
+
# correctly showing an empty dir, and the model retrying the same
|
|
170
|
+
# broken step until the recursion cap kicked in. That root cause is
|
|
171
|
+
# fixed in harness.py now. If this still fires, it means the agent
|
|
172
|
+
# is genuinely stuck in a loop for some other reason (e.g. a
|
|
173
|
+
# command that keeps failing for a real, external reason) — surface
|
|
174
|
+
# that plainly instead of a raw traceback, rather than papering
|
|
175
|
+
# over it by just raising the limit.
|
|
176
|
+
ui.print_notice(
|
|
177
|
+
"Stopped: the agent hit the step limit for this turn without finishing "
|
|
178
|
+
"(likely repeating a failing action). Check the tool calls/results above "
|
|
179
|
+
"for what kept failing, then try again or rephrase the task.",
|
|
180
|
+
style="bold red",
|
|
181
|
+
)
|
|
182
|
+
return messages
|
|
183
|
+
except Exception as e:
|
|
184
|
+
detail = str(e) or repr(e)
|
|
185
|
+
cause = getattr(e, "__cause__", None)
|
|
186
|
+
if cause and str(cause) not in detail:
|
|
187
|
+
detail = f"{detail} (caused by: {cause})"
|
|
188
|
+
ui.print_notice(
|
|
189
|
+
f"Error during this turn [{type(e).__name__}]: {detail}", style="bold red"
|
|
190
|
+
)
|
|
191
|
+
return messages
|
|
192
|
+
finally:
|
|
193
|
+
esc.stop()
|
|
194
|
+
if live is not None:
|
|
195
|
+
ui.stream_stop(live)
|
|
196
|
+
|
|
197
|
+
if interrupted:
|
|
198
|
+
ui.print_notice(
|
|
199
|
+
"Interrupted (Esc) \u2014 turn stopped early; any tool call already in "
|
|
200
|
+
"flight may still finish on its own. History kept up to the last "
|
|
201
|
+
"completed step.",
|
|
202
|
+
style="yellow",
|
|
203
|
+
)
|
|
204
|
+
return final_messages
|
|
205
|
+
|
|
206
|
+
ui.print_turn_complete(time.monotonic() - turn_start)
|
|
207
|
+
return final_messages
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def save_key_to_dotenv(key: str) -> None:
|
|
211
|
+
"""Persist an API key into .env (creating the file if needed), replacing
|
|
212
|
+
any existing OPENROUTER_API_KEY line. .env is gitignored, so the key
|
|
213
|
+
never lands in version control."""
|
|
214
|
+
path = ".env"
|
|
215
|
+
lines: list[str] = []
|
|
216
|
+
if os.path.exists(path):
|
|
217
|
+
with open(path) as f:
|
|
218
|
+
lines = f.read().splitlines()
|
|
219
|
+
updated = False
|
|
220
|
+
for i, line in enumerate(lines):
|
|
221
|
+
if line.strip().startswith("OPENROUTER_API_KEY="):
|
|
222
|
+
lines[i] = f"OPENROUTER_API_KEY={key}"
|
|
223
|
+
updated = True
|
|
224
|
+
if not updated:
|
|
225
|
+
if lines and lines[-1].strip():
|
|
226
|
+
lines.append("")
|
|
227
|
+
lines.append(f"OPENROUTER_API_KEY={key}")
|
|
228
|
+
with open(path, "w") as f:
|
|
229
|
+
f.write("\n".join(lines) + "\n")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def ensure_api_key() -> str:
|
|
233
|
+
"""Make sure an OpenRouter API key is available. If OPENROUTER_API_KEY
|
|
234
|
+
isn't set (env or .env), prompt the user to paste one — hidden input —
|
|
235
|
+
and offer to save it to .env for next time. Exits if no key is given,
|
|
236
|
+
since the agent can't call a model without one."""
|
|
237
|
+
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
|
238
|
+
if key:
|
|
239
|
+
return key
|
|
240
|
+
ui.print_notice("No OPENROUTER_API_KEY found in env or .env.", style="yellow")
|
|
241
|
+
key = ui.prompt_api_key()
|
|
242
|
+
if not key:
|
|
243
|
+
ui.print_notice(
|
|
244
|
+
"No API key provided — the agent can't run without one. "
|
|
245
|
+
"Set OPENROUTER_API_KEY and restart.",
|
|
246
|
+
style="bold red",
|
|
247
|
+
)
|
|
248
|
+
raise SystemExit(1)
|
|
249
|
+
os.environ["OPENROUTER_API_KEY"] = key
|
|
250
|
+
if ui.confirm_save_key():
|
|
251
|
+
save_key_to_dotenv(key)
|
|
252
|
+
ui.print_notice("Saved to .env", style="green")
|
|
253
|
+
return key
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
COMPACT_PROMPT = """You are summarizing a coding-assistant session so work can continue
|
|
257
|
+
in a fresh context window. Write a dense, structured summary covering:
|
|
258
|
+
|
|
259
|
+
1. What the user was trying to accomplish (the overall goal)
|
|
260
|
+
2. What was actually done — files created/modified, commands run, key decisions
|
|
261
|
+
3. Current state — what's working, what's unfinished or broken
|
|
262
|
+
4. Anything the user explicitly asked to remember, plus useful context
|
|
263
|
+
(paths, model choices, config values) needed to continue seamlessly
|
|
264
|
+
|
|
265
|
+
Be concrete: name files, functions, and decisions. Skip greetings and
|
|
266
|
+
small talk. Write it so another agent could pick up exactly where this
|
|
267
|
+
one left off."""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def compact_messages(messages: list, model_override: str = None) -> str:
|
|
271
|
+
"""Summarize the conversation with the plain LLM (no tools) and return
|
|
272
|
+
the summary text. Long tool outputs are truncated so the summarizer
|
|
273
|
+
itself doesn't blow the context window."""
|
|
274
|
+
llm = get_llm(model_override)
|
|
275
|
+
lines = []
|
|
276
|
+
for m in messages:
|
|
277
|
+
role = getattr(m, "type", "?")
|
|
278
|
+
content = m.content if isinstance(m.content, str) else str(m.content)
|
|
279
|
+
if len(content) > 2000:
|
|
280
|
+
content = content[:2000] + "…[truncated]"
|
|
281
|
+
lines.append(f"[{role}] {content}")
|
|
282
|
+
convo = "\n\n".join(lines)
|
|
283
|
+
summary = await llm.ainvoke(
|
|
284
|
+
[SystemMessage(content=COMPACT_PROMPT), HumanMessage(content=convo)]
|
|
285
|
+
)
|
|
286
|
+
return summary.content if isinstance(summary.content, str) else str(summary.content)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def main():
|
|
290
|
+
ensure_api_key()
|
|
291
|
+
|
|
292
|
+
workdir = os.environ.get("AGENT_WORKDIR", "./sandbox")
|
|
293
|
+
auto_approve = os.environ.get("AGENT_AUTO_APPROVE", "false").lower() == "true"
|
|
294
|
+
use_git = os.environ.get("AGENT_ENABLE_GIT", "false").lower() == "true"
|
|
295
|
+
|
|
296
|
+
harness = Harness(workdir=workdir, auto_approve=auto_approve, confirm_fn=ui.confirm)
|
|
297
|
+
bind_harness(harness)
|
|
298
|
+
bind_todo_store(todo_store)
|
|
299
|
+
bind_search_root(str(harness.workdir))
|
|
300
|
+
|
|
301
|
+
all_tools = list(LOCAL_TOOLS) + SEARCH_TOOLS + TODO_TOOLS
|
|
302
|
+
if use_git:
|
|
303
|
+
repo_path = get_git_repo_path()
|
|
304
|
+
try:
|
|
305
|
+
git_tools = await get_git_tools(repo_path)
|
|
306
|
+
all_tools.extend(git_tools)
|
|
307
|
+
except Exception as e:
|
|
308
|
+
ui.print_notice(f"Could not load git MCP tools, continuing without them: {e}", style="yellow")
|
|
309
|
+
|
|
310
|
+
mode = "build"
|
|
311
|
+
model_override = None
|
|
312
|
+
token_tracker = TokenTracker()
|
|
313
|
+
# Models shown by the most recent /models listing — /model <number>
|
|
314
|
+
# resolves against this. Starts as the curated shortlist so numbers
|
|
315
|
+
# work even before /models is ever run.
|
|
316
|
+
listed_models: list = list(KNOWN_MODELS)
|
|
317
|
+
|
|
318
|
+
model_name = model_override or os.environ.get("HF_MODEL_ID") or os.environ.get("OLLAMA_MODEL", DEFAULT_MODEL)
|
|
319
|
+
|
|
320
|
+
# Start fresh, or --continue resume the most-recently-used non-empty session
|
|
321
|
+
# from the SQLite store (metadata restores its mode/model too).
|
|
322
|
+
resume = "--continue" in sys.argv
|
|
323
|
+
current_id = session.latest_session_id() if resume else None
|
|
324
|
+
if current_id is not None:
|
|
325
|
+
loaded = session.load(current_id)
|
|
326
|
+
if loaded:
|
|
327
|
+
info = session.get_session(current_id)
|
|
328
|
+
if info is not None:
|
|
329
|
+
mode = info.mode or mode
|
|
330
|
+
if info.model and not model_override:
|
|
331
|
+
model_override = info.model
|
|
332
|
+
model_name = info.model
|
|
333
|
+
ui.print_notice(
|
|
334
|
+
f"Resumed session #{current_id} ({info.name if info else '(unnamed)'}, {len(loaded)} msgs)",
|
|
335
|
+
style="cyan",
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
current_id = None
|
|
339
|
+
|
|
340
|
+
if current_id is None:
|
|
341
|
+
current_id = session.new_session(model=model_name, mode=mode)
|
|
342
|
+
loaded = None
|
|
343
|
+
|
|
344
|
+
messages = list(loaded) if loaded is not None else [system_message_for(mode)]
|
|
345
|
+
|
|
346
|
+
graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
|
|
347
|
+
ui.set_context(mode, model_name)
|
|
348
|
+
ui.print_banner(model_name, str(harness.workdir), [t.name for t in all_tools])
|
|
349
|
+
|
|
350
|
+
while True:
|
|
351
|
+
try:
|
|
352
|
+
raw = ui.user_prompt(mode)
|
|
353
|
+
except (EOFError, KeyboardInterrupt):
|
|
354
|
+
print()
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
if not raw:
|
|
358
|
+
continue
|
|
359
|
+
if raw.lower() in {"exit", "quit"}:
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
if raw.startswith("/"):
|
|
363
|
+
parts = raw[1:].strip().split(maxsplit=1)
|
|
364
|
+
cmd = parts[0].lower() if parts else ""
|
|
365
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
366
|
+
|
|
367
|
+
if cmd == "plan":
|
|
368
|
+
mode = "plan"
|
|
369
|
+
graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
|
|
370
|
+
set_system_message(messages, mode)
|
|
371
|
+
ui.set_context(mode, model_name)
|
|
372
|
+
ui.print_notice("Switched to PLAN mode \u2014 read-only tools only.", style="magenta")
|
|
373
|
+
elif cmd == "build":
|
|
374
|
+
mode = "build"
|
|
375
|
+
graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
|
|
376
|
+
set_system_message(messages, mode)
|
|
377
|
+
ui.set_context(mode, model_name)
|
|
378
|
+
ui.print_notice("Switched to BUILD mode \u2014 all tools enabled.", style="blue")
|
|
379
|
+
elif cmd == "model":
|
|
380
|
+
if not arg:
|
|
381
|
+
ui.print_notice("Usage: /model <number|model-id> (see /models)", style="yellow")
|
|
382
|
+
else:
|
|
383
|
+
new_model = resolve_model_arg(arg, listed_models)
|
|
384
|
+
if arg.strip().isdigit() and new_model == arg.strip():
|
|
385
|
+
ui.print_notice(
|
|
386
|
+
f"No model #{arg.strip()} in the current list — run /models "
|
|
387
|
+
"first (or pass a full OpenRouter model id).",
|
|
388
|
+
style="yellow",
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
model_override = new_model
|
|
392
|
+
model_name = new_model
|
|
393
|
+
graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
|
|
394
|
+
ui.set_context(mode, model_name)
|
|
395
|
+
ui.print_notice(f"Switched model to {new_model}", style="cyan")
|
|
396
|
+
elif cmd == "models":
|
|
397
|
+
parts = arg.split()
|
|
398
|
+
force = "--refresh" in parts
|
|
399
|
+
query = " ".join(p for p in parts if p != "--refresh").strip().lower()
|
|
400
|
+
ui.print_notice("Fetching model list from OpenRouter…", style="dim")
|
|
401
|
+
models, source = fetch_openrouter_models(force_refresh=force)
|
|
402
|
+
if query:
|
|
403
|
+
models = [m for m in models
|
|
404
|
+
if query in m[0].lower() or query in m[1].lower()]
|
|
405
|
+
if not models:
|
|
406
|
+
ui.print_notice(f"No models match '{query}'.", style="yellow")
|
|
407
|
+
continue
|
|
408
|
+
listed_models = models
|
|
409
|
+
ui.print_models(models, model_name, source=source, query=query or None)
|
|
410
|
+
elif cmd == "key":
|
|
411
|
+
key = ui.prompt_api_key()
|
|
412
|
+
if not key:
|
|
413
|
+
ui.print_notice("No key entered — keeping the current one.", style="yellow")
|
|
414
|
+
else:
|
|
415
|
+
os.environ["OPENROUTER_API_KEY"] = key
|
|
416
|
+
if ui.confirm_save_key():
|
|
417
|
+
save_key_to_dotenv(key)
|
|
418
|
+
ui.print_notice("Saved to .env", style="green")
|
|
419
|
+
graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
|
|
420
|
+
ui.print_notice("API key updated.", style="green")
|
|
421
|
+
elif cmd == "sessions":
|
|
422
|
+
ui.print_sessions(session.list_sessions())
|
|
423
|
+
elif cmd == "resume":
|
|
424
|
+
if not arg:
|
|
425
|
+
ui.print_notice("Usage: /resume <session-id> (see /sessions)", style="yellow")
|
|
426
|
+
continue
|
|
427
|
+
try:
|
|
428
|
+
sid = int(arg)
|
|
429
|
+
except ValueError:
|
|
430
|
+
ui.print_notice("Usage: /resume <session-id> (see /sessions)", style="yellow")
|
|
431
|
+
continue
|
|
432
|
+
info = session.get_session(sid)
|
|
433
|
+
loaded = session.load(sid) if info else None
|
|
434
|
+
if info is None or not loaded:
|
|
435
|
+
ui.print_notice(f"No resumable session #{sid}. See /sessions.", style="yellow")
|
|
436
|
+
continue
|
|
437
|
+
if info.mode:
|
|
438
|
+
mode = info.mode
|
|
439
|
+
if info.model and not model_override:
|
|
440
|
+
model_override = info.model
|
|
441
|
+
model_name = info.model
|
|
442
|
+
graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
|
|
443
|
+
current_id = sid
|
|
444
|
+
messages = list(loaded)
|
|
445
|
+
todo_store.clear()
|
|
446
|
+
set_system_message(messages, mode)
|
|
447
|
+
ui.set_context(mode, model_name)
|
|
448
|
+
ui.print_notice(
|
|
449
|
+
f"Resumed session #{sid} ({info.name or '(unnamed)'}, {len(messages)} msgs)",
|
|
450
|
+
style="cyan",
|
|
451
|
+
)
|
|
452
|
+
elif cmd == "delete":
|
|
453
|
+
if not arg:
|
|
454
|
+
ui.print_notice("Usage: /delete <session-id> (see /sessions)", style="yellow")
|
|
455
|
+
continue
|
|
456
|
+
try:
|
|
457
|
+
sid = int(arg)
|
|
458
|
+
except ValueError:
|
|
459
|
+
ui.print_notice("Usage: /delete <session-id> (see /sessions)", style="yellow")
|
|
460
|
+
continue
|
|
461
|
+
if sid == current_id:
|
|
462
|
+
ui.print_notice("Can't delete the active session. Resume a different one first.", style="yellow")
|
|
463
|
+
continue
|
|
464
|
+
if session.delete_session(sid):
|
|
465
|
+
ui.print_notice(f"Deleted session #{sid}.", style="yellow")
|
|
466
|
+
else:
|
|
467
|
+
ui.print_notice(f"No session #{sid}. See /sessions.", style="yellow")
|
|
468
|
+
elif cmd == "usage":
|
|
469
|
+
ui.print_token_usage(token_tracker.summary())
|
|
470
|
+
elif cmd == "clear":
|
|
471
|
+
messages = [system_message_for(mode)]
|
|
472
|
+
todo_store.clear()
|
|
473
|
+
ui.print_notice("History cleared.")
|
|
474
|
+
elif cmd == "compact":
|
|
475
|
+
if len(messages) <= 2:
|
|
476
|
+
ui.print_notice("Nothing to compact yet.", style="yellow")
|
|
477
|
+
else:
|
|
478
|
+
ui.print_notice("Compacting conversation…", style="dim")
|
|
479
|
+
old_count = len(messages)
|
|
480
|
+
try:
|
|
481
|
+
summary = await compact_messages(messages, model_override)
|
|
482
|
+
except Exception as e:
|
|
483
|
+
ui.print_notice(f"Compaction failed: {e}", style="bold red")
|
|
484
|
+
continue
|
|
485
|
+
messages = [
|
|
486
|
+
system_message_for(mode),
|
|
487
|
+
HumanMessage(
|
|
488
|
+
content="[Summary of earlier conversation]\n" + summary
|
|
489
|
+
),
|
|
490
|
+
]
|
|
491
|
+
session.save(current_id, messages, model=model_name, mode=mode)
|
|
492
|
+
ui.print_notice(
|
|
493
|
+
f"Compacted {old_count} messages into a summary.",
|
|
494
|
+
style="green",
|
|
495
|
+
)
|
|
496
|
+
elif cmd == "help":
|
|
497
|
+
ui.print_help()
|
|
498
|
+
else:
|
|
499
|
+
ui.print_notice(f"Unknown command: /{cmd} (try /help)", style="yellow")
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
guard = check_user_input(raw)
|
|
503
|
+
if guard is not None:
|
|
504
|
+
ui.print_notice(f"Guardrail \u2014 {guard}", style="bold red")
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
messages.append(HumanMessage(content=raw))
|
|
508
|
+
ui.print_user_message(raw)
|
|
509
|
+
messages = await run_turn(graph, messages, token_tracker)
|
|
510
|
+
session.save(current_id, messages, model=model_name, mode=mode)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def cli_entry():
|
|
514
|
+
"""Entry point for the `closecode` console script (see pyproject.toml
|
|
515
|
+
[project.scripts]). Installed via pip/pipx, runs the async main()."""
|
|
516
|
+
asyncio.run(main())
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
if __name__ == "__main__":
|
|
520
|
+
asyncio.run(main())
|
mcp_tools.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def build_mcp_client(repo_path: str) -> MultiServerMCPClient:
|
|
6
|
+
return MultiServerMCPClient(
|
|
7
|
+
{
|
|
8
|
+
"git": {
|
|
9
|
+
"command": "python",
|
|
10
|
+
"args": ["-m", "mcp_server_git", "--repository", repo_path],
|
|
11
|
+
"transport": "stdio",
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def get_git_tools(repo_path: str):
|
|
18
|
+
"""Returns a list of LangChain-compatible tools backed by mcp-server-git.
|
|
19
|
+
Call this once at startup (it's async — see main.py)."""
|
|
20
|
+
client = build_mcp_client(repo_path)
|
|
21
|
+
return await client.get_tools()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_git_repo_path() -> str:
|
|
25
|
+
"""Where the git MCP server should operate. Defaults to the same
|
|
26
|
+
directory as the agent's sandbox, but can be pointed elsewhere."""
|
|
27
|
+
return os.environ.get("GIT_REPO_PATH", os.environ.get("AGENT_WORKDIR", "./sandbox"))
|
modes.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
PLAN_MODE_BLOCKED_EXACT = {"bash", "write_file", "edit_file"}
|
|
3
|
+
PLAN_MODE_BLOCKED_KEYWORDS = [
|
|
4
|
+
"commit", "push", "checkout", "reset", "merge", "rebase",
|
|
5
|
+
"add", "rm", "delete", "remove", "stash", "write", "create_branch",
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def filter_tools_for_mode(tools: list, mode: str) -> list:
|
|
10
|
+
if mode == "build":
|
|
11
|
+
return list(tools)
|
|
12
|
+
|
|
13
|
+
kept = []
|
|
14
|
+
for t in tools:
|
|
15
|
+
name = t.name.lower()
|
|
16
|
+
if name in PLAN_MODE_BLOCKED_EXACT:
|
|
17
|
+
continue
|
|
18
|
+
if any(keyword in name for keyword in PLAN_MODE_BLOCKED_KEYWORDS):
|
|
19
|
+
continue
|
|
20
|
+
kept.append(t)
|
|
21
|
+
return kept
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mode_system_note(mode: str) -> str:
|
|
25
|
+
if mode == "plan":
|
|
26
|
+
return (
|
|
27
|
+
"\n\nYou are currently in PLAN MODE. Tools that write files, edit files, "
|
|
28
|
+
"run shell commands, or change git state are not available to you right now. "
|
|
29
|
+
"Explore and read what you need, then respond with a clear, concrete, "
|
|
30
|
+
"step-by-step plan of the changes you would make. Do not claim to have made "
|
|
31
|
+
"changes you did not actually make — you can't, in this mode."
|
|
32
|
+
)
|
|
33
|
+
return (
|
|
34
|
+
"\n\nYou are currently in BUILD MODE. You have full access to read, write, edit, "
|
|
35
|
+
"run commands, and make git changes as needed to complete the task."
|
|
36
|
+
)
|