SHA256
FIX SOME BUGS
This commit is contained in:
@@ -3,27 +3,37 @@
|
||||
|
||||
Terminal coding-agent front end for the sjcode daemon (server/server.py).
|
||||
|
||||
This is a real agent: it can run shell commands and change files in the
|
||||
directory it was started from (the "project directory"). The model emits
|
||||
tool calls, the client executes them locally and feeds the real output
|
||||
back to the model until the task is done. You confirm every command that
|
||||
runs and every file that is written, unless you pass --yes or toggle
|
||||
/auto in the session.
|
||||
|
||||
On start it asks which server to connect to. Servers you used before are
|
||||
remembered in ~/.config/sjcode/client.json together with username and the
|
||||
last model, so next time you just press Enter. New servers are saved too.
|
||||
last model, so next time you just press Enter.
|
||||
|
||||
Flow:
|
||||
coder -> banner, pick saved server (or add one),
|
||||
login, pick model, chat REPL
|
||||
login, pick model, agent REPL
|
||||
coder 10.0.0.5:8420 -> skip the server picker
|
||||
coder --model qwen3-coder -> skip the model picker
|
||||
coder --yes -> auto-approve every tool call (careful)
|
||||
|
||||
REPL commands:
|
||||
/help /server /model /models /limit /clear /quit
|
||||
/help /server /model /models /limit /auto /state /cwd /clear /quit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -35,8 +45,19 @@ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) /
|
||||
CONFIG_FILE = CONFIG_DIR / "client.json"
|
||||
|
||||
DEFAULT_PORT = 8420
|
||||
REQUEST_TIMEOUT = 300
|
||||
HISTORY_TURNS = 10 # prior turns sent with each message for context
|
||||
REQUEST_TIMEOUT = 600
|
||||
HISTORY_TURNS = 10 # prior task/summary pairs sent with each task for context
|
||||
|
||||
# The project directory the agent works in (where ./coder was started).
|
||||
CWD = Path.cwd()
|
||||
|
||||
MAX_TURNS = 30 # model round-trips per user task
|
||||
NO_TOOL_LIMIT = 2 # plain-text replies before the turn ends by itself
|
||||
RUN_TIMEOUT = 300 # seconds per shell command
|
||||
MAX_RUN_CHARS = 10_000 # shell output fed back / shown, per command
|
||||
READ_DEFAULT_LIMIT = 250 # lines per read
|
||||
WRITE_MAX_CHARS = 60_000 # refuse to write bigger files in one go
|
||||
DIFF_PREVIEW_LINES = 60 # diff lines shown in a confirmation prompt
|
||||
|
||||
HOST_RE = re.compile(
|
||||
r"^(?=.{1,253}\z)" # total length limit
|
||||
@@ -48,14 +69,77 @@ HOST_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Makes the model behave like a terminal agent: short lines, check marks.
|
||||
AGENT_PREAMBLE = (
|
||||
"You are sj, a coding agent working in the user's terminal.\n"
|
||||
"Answer concisely and practically.\n"
|
||||
"Describe what you do as short lines; put every action you performed "
|
||||
"or verified on its own line starting with '\u2713 ' (check mark).\n"
|
||||
"End your reply with the single line 'Done.'"
|
||||
# Tool-call protocol: the model answers with normal text plus fenced blocks.
|
||||
# ```run
|
||||
# git status --short
|
||||
# ```
|
||||
# ```sj:write
|
||||
# {"path": "x.txt", "content": "hi"}
|
||||
# ```
|
||||
# The closing fence must be a line containing only ``` so that content which
|
||||
# itself contains backticks (inside a JSON string) cannot end the block early.
|
||||
# (?![a-z]) keeps language fences like ```rust or ```ruby from being read as
|
||||
# run-tool calls.
|
||||
TAG_RE = re.compile(
|
||||
r"(?ms)^[ \t]*```(?:sj:([a-z]+)|run)(?![a-z])[^\n]*\n(.*?)(?=\n[ \t]*```[ \t]*(?:\n|\Z)|\Z)"
|
||||
)
|
||||
STRAY_FENCE_RE = re.compile(r"(?m)^[ \t]*```[^\n]*$")
|
||||
|
||||
AGENT_PREAMBLE = """\
|
||||
You are sj, a real coding agent working in the user's project directory on
|
||||
their machine. You can actually run commands and change files. Work like an
|
||||
experienced engineer:
|
||||
|
||||
- Plan silently, act step by step. After every step, look at the real output
|
||||
you got before deciding the next step.
|
||||
- Explore before you change code you have not seen: list files, read them,
|
||||
then edit. Never guess file contents.
|
||||
- Keep replies short. Say what you are doing, then do it.
|
||||
- Every action you performed or verified goes on its own line starting with
|
||||
"✓ " (check mark).
|
||||
- If something fails, read the error and change your approach; do not repeat
|
||||
a failing call unchanged and do not apologize.
|
||||
- When the whole task is finished, end your reply with the single line "Done."
|
||||
|
||||
You act by emitting tool calls. A tool call is a fenced code block tagged
|
||||
"run" or "sj:<tool>" whose body is plain text (run) or a JSON object (all
|
||||
other tools). Examples:
|
||||
|
||||
```run
|
||||
git status --short
|
||||
```
|
||||
|
||||
```sj:write
|
||||
{"path": "notes/hello.txt", "content": "hi\\n"}
|
||||
```
|
||||
|
||||
```sj:edit
|
||||
{"path": "app.py", "old": "old text", "new": "new text", "all": false}
|
||||
```
|
||||
|
||||
```sj:read
|
||||
{"path": "app.py", "start": 1, "limit": 200}
|
||||
```
|
||||
|
||||
```sj:files
|
||||
{"path": ".", "limit": 200}
|
||||
```
|
||||
|
||||
Tools:
|
||||
- run: run one shell command in the project directory. Every run starts
|
||||
in the project directory again (cd does not persist).
|
||||
- read: read a text file. Args: path, start (line number), limit (lines).
|
||||
- files: list project files. Args: path (subdirectory), limit.
|
||||
- write: create a new file or overwrite a small one. Args: path, content.
|
||||
- edit: replace exact text in a file. Args: path, old, new, all (optional).
|
||||
|
||||
Rules:
|
||||
- Emit at most ONE tool call per reply, then stop. You will receive its real
|
||||
output as the next message and then continue.
|
||||
- The user confirms dangerous actions. If a result says "cancelled by user",
|
||||
accept that, adjust, and move on; never repeat the same call.
|
||||
- Never invent tool output. If you need information, call a tool to get it.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -64,6 +148,7 @@ AGENT_PREAMBLE = (
|
||||
|
||||
class Style:
|
||||
def __init__(self, enabled: bool) -> None:
|
||||
self.enabled = enabled
|
||||
self.dim = "\033[2m" if enabled else ""
|
||||
self.bold = "\033[1m" if enabled else ""
|
||||
self.green = "\033[32m" if enabled else ""
|
||||
@@ -100,13 +185,13 @@ def banner(model: str, project: str, server: str) -> None:
|
||||
for line in lines:
|
||||
print(f"\u2502 {line.ljust(width)} \u2502")
|
||||
print(f"\u2570{'\u2500' * (width + 2)}\u256f")
|
||||
print(f"{ST.dim}server: {server} (\u2018/help\u2019 for commands){ST.reset}")
|
||||
print(f"{ST.dim}server: {server} ('/help' for commands){ST.reset}")
|
||||
|
||||
|
||||
def shorten_home(path: Path) -> str:
|
||||
def shorten_home(path: Path) -> Path:
|
||||
home = Path.home()
|
||||
try:
|
||||
return "~" / path.relative_to(home)
|
||||
return Path("~") / path.relative_to(home)
|
||||
except ValueError:
|
||||
return path
|
||||
|
||||
@@ -171,8 +256,8 @@ def pick_server(state: dict) -> tuple[dict, bool]:
|
||||
servers = state["servers"]
|
||||
print(f"\n{ST.bold}Connect to which server?{ST.reset}")
|
||||
for i, entry in enumerate(servers, 1):
|
||||
default = " (last)" if server_label(entry) == state.get("last") else ""
|
||||
user = f" as {entry['username']}" if entry.get("username") else ""
|
||||
default = f" {ST.dim}(last){ST.reset}" if server_label(entry) == state.get("last") else ""
|
||||
user = f" {ST.dim}({entry['username']}){ST.reset}" if entry.get("username") else ""
|
||||
print(f" {i}) {server_label(entry)}{user}{default}")
|
||||
print(f" n) add a new server\n")
|
||||
|
||||
@@ -198,7 +283,7 @@ def pick_server(state: dict) -> tuple[dict, bool]:
|
||||
|
||||
|
||||
def add_server(state: dict) -> dict:
|
||||
"""Ask for a new server address, probe it and save it."""
|
||||
"""Ask for a new server address, and save it."""
|
||||
while True:
|
||||
raw = ask("server address (domain or ip:port): ").strip()
|
||||
try:
|
||||
@@ -218,8 +303,20 @@ def add_server(state: dict) -> dict:
|
||||
# daemon API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _feed_chunk(line: str, on_chunk, final: dict) -> dict:
|
||||
"""Handle one JSON line of a streamed reply: chunk -> callback, else final."""
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return final
|
||||
if isinstance(obj, dict) and "chunk" in obj:
|
||||
on_chunk(obj)
|
||||
return final
|
||||
return obj if isinstance(obj, dict) else final
|
||||
|
||||
|
||||
def http_json(method: str, base: str, path: str, payload: dict | None = None,
|
||||
timeout: int = REQUEST_TIMEOUT) -> tuple[int, dict]:
|
||||
timeout: int = REQUEST_TIMEOUT, on_chunk=None) -> tuple[int, dict]:
|
||||
url = base + path
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
@@ -228,7 +325,21 @@ def http_json(method: str, base: str, path: str, payload: dict | None = None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
if on_chunk is None:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
# streamed reply: one JSON object per line
|
||||
status = resp.status
|
||||
final: dict = {}
|
||||
buf = ""
|
||||
for raw in resp:
|
||||
buf += raw.decode(errors="replace")
|
||||
while "\n" in buf:
|
||||
line, buf = buf.split("\n", 1)
|
||||
if line.strip():
|
||||
final = _feed_chunk(line.strip(), on_chunk, final)
|
||||
if buf.strip():
|
||||
final = _feed_chunk(buf.strip(), on_chunk, final)
|
||||
return status, final
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
return exc.code, json.loads(exc.read().decode())
|
||||
@@ -266,10 +377,14 @@ def api_models(entry: dict, token: str) -> list[str]:
|
||||
return list(body.get("models", [])) if status == 200 else []
|
||||
|
||||
|
||||
def api_chat(entry: dict, token: str, message: str, model: str) -> tuple[int, dict]:
|
||||
def api_chat(entry: dict, token: str, message: str, model: str,
|
||||
on_chunk=None) -> tuple[int, dict]:
|
||||
base = f"http://{entry['host']}:{entry['port']}"
|
||||
return http_json("POST", base, "/chat",
|
||||
{"token": token, "message": message, "model": model})
|
||||
payload: dict = {"token": token, "message": message, "model": model}
|
||||
if on_chunk is not None:
|
||||
payload["stream"] = True
|
||||
return http_json("POST", base, "/chat", payload, on_chunk=on_chunk)
|
||||
return http_json("POST", base, "/chat", payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -333,48 +448,513 @@ def pick_model(entry: dict, models: list[str]) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the agent look
|
||||
# tools the agent can use (executed locally, in the project directory)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ToolError(Exception):
|
||||
"""A tool refused to run; the message goes back to the model."""
|
||||
|
||||
|
||||
class ToolAbort(Exception):
|
||||
"""The user aborted the whole agent turn at a confirmation prompt."""
|
||||
|
||||
|
||||
ABORT = "!!abort"
|
||||
|
||||
EXCLUDED_DIRS = {
|
||||
".git", "__pycache__", "node_modules", ".venv", "venv", ".mypy_cache",
|
||||
".ruff_cache", ".pytest_cache", ".tox", ".idea", ".vscode", ".eggs",
|
||||
"dist", "build", ".hg", ".svn",
|
||||
}
|
||||
|
||||
def _resolve(path: str) -> Path:
|
||||
"""Resolve a path against the project dir; refuse anything outside it."""
|
||||
if not path:
|
||||
raise ToolError("path required")
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_absolute():
|
||||
p = CWD / p
|
||||
try:
|
||||
rp = p.resolve()
|
||||
except OSError as exc:
|
||||
raise ToolError(f"cannot resolve path: {exc}")
|
||||
inside = rp == CWD or str(rp).startswith(str(CWD) + os.sep)
|
||||
if not inside:
|
||||
raise ToolError(
|
||||
f"outside the project directory ({CWD}): {path!r}")
|
||||
return rp
|
||||
|
||||
|
||||
def _display(p: Path) -> str:
|
||||
try:
|
||||
rel = str(p.relative_to(CWD))
|
||||
return rel or "."
|
||||
except ValueError:
|
||||
return str(p)
|
||||
|
||||
|
||||
def _unified_diff(old: str, new: str, display: str) -> list[str]:
|
||||
diff = difflib.unified_diff(
|
||||
old.splitlines(), new.splitlines(),
|
||||
fromfile=f"{display} (old)", tofile=f"{display} (new)", lineterm="",
|
||||
)
|
||||
lines = list(diff)
|
||||
if len(lines) > DIFF_PREVIEW_LINES:
|
||||
lines = lines[:DIFF_PREVIEW_LINES]
|
||||
lines.append(f"... (+{len(lines)} more diff lines)")
|
||||
return lines
|
||||
|
||||
|
||||
def _run_args(args: dict) -> tuple[str, int, Path]:
|
||||
"""Validate run-tool args -> (command, timeout, working dir)."""
|
||||
cmd_text = str(args.get("command") or args.get("cmd") or "").strip()
|
||||
if not cmd_text:
|
||||
raise ToolError("'command' required")
|
||||
timeout = args.get("timeout")
|
||||
try:
|
||||
timeout = min(max(int(timeout), 5), RUN_TIMEOUT) if timeout else RUN_TIMEOUT
|
||||
except (TypeError, ValueError):
|
||||
timeout = RUN_TIMEOUT
|
||||
wd = _resolve(str(args.get("cwd") or "."))
|
||||
if not wd.is_dir():
|
||||
raise ToolError(f"not a directory: {_display(wd)}")
|
||||
return cmd_text, timeout, wd
|
||||
|
||||
|
||||
def tool_run_confirm(args: dict) -> list[str]:
|
||||
cmd_text, timeout, wd = _run_args(args)
|
||||
return [
|
||||
f"$ {cmd_text}",
|
||||
f"cwd: {_display(wd)} timeout: {timeout}s",
|
||||
]
|
||||
|
||||
|
||||
def tool_run_execute(args: dict) -> list[str]:
|
||||
cmd_text, timeout, wd = _run_args(args)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd_text, shell=True, cwd=str(wd), capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return [f"\u2717 $ {cmd_text}", f"timed out after {timeout}s"]
|
||||
except OSError as exc:
|
||||
return [f"\u2717 $ {cmd_text}", f"cannot run: {exc}"]
|
||||
|
||||
ok = proc.returncode == 0
|
||||
head = (f"\u2713 $ {cmd_text}" if ok
|
||||
else f"\u2717 $ {cmd_text} (exit {proc.returncode})")
|
||||
parts = []
|
||||
if (proc.stdout or "").strip():
|
||||
parts.append(proc.stdout.rstrip())
|
||||
if (proc.stderr or "").strip():
|
||||
parts.append(proc.stderr.rstrip())
|
||||
text = "\n".join(parts).strip()
|
||||
if len(text) > MAX_RUN_CHARS:
|
||||
text = text[:MAX_RUN_CHARS] + f"\n... (output truncated at {MAX_RUN_CHARS} chars)"
|
||||
return [head] + (text.splitlines() if text else [])
|
||||
|
||||
|
||||
def tool_read(args: dict) -> list[str]:
|
||||
path = str(args.get("path", "")).strip()
|
||||
p = _resolve(path)
|
||||
if not p.is_file():
|
||||
raise ToolError(f"not a file: {_display(p)}")
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
raise ToolError(str(exc))
|
||||
lines = text.splitlines()
|
||||
total = len(lines)
|
||||
try:
|
||||
start = max(1, int(args.get("start", 1)))
|
||||
except (TypeError, ValueError):
|
||||
start = 1
|
||||
try:
|
||||
limit = int(args.get("limit", READ_DEFAULT_LIMIT))
|
||||
except (TypeError, ValueError):
|
||||
limit = READ_DEFAULT_LIMIT
|
||||
limit = max(1, min(limit, 1000))
|
||||
start = min(start, max(total, 1))
|
||||
sel = lines[start - 1:start - 1 + limit]
|
||||
out = [f"\u2713 read {_display(p)} ({total} lines)"]
|
||||
for i, line in enumerate(sel, start):
|
||||
out.append(f"{i}\t{line[:400]}")
|
||||
if start - 1 + limit < total:
|
||||
out.append(f"(... {total - (start - 1 + limit)} more lines - "
|
||||
f"read again with 'start'/'limit')")
|
||||
return out
|
||||
|
||||
|
||||
def tool_files(args: dict) -> list[str]:
|
||||
sub = str(args.get("path") or ".").strip()
|
||||
base = _resolve(sub)
|
||||
if not base.is_dir():
|
||||
raise ToolError(f"not a directory: {_display(base)}")
|
||||
try:
|
||||
limit = max(1, min(int(args.get("limit", 200)), 500))
|
||||
except (TypeError, ValueError):
|
||||
limit = 200
|
||||
out: list[str] = []
|
||||
truncated = False
|
||||
for root, dirs, fnames in os.walk(base):
|
||||
dirs[:] = sorted(
|
||||
d for d in dirs
|
||||
if d not in EXCLUDED_DIRS and not d.endswith(".egg-info")
|
||||
)
|
||||
for fn in sorted(fnames):
|
||||
if len(out) >= limit:
|
||||
truncated = True
|
||||
break
|
||||
out.append(_display(Path(root) / fn))
|
||||
if truncated:
|
||||
break
|
||||
head = (f"\u2713 files under {_display(base)} ({len(out)} shown"
|
||||
+ (", truncated - narrow with 'path'" if truncated else "") + ")")
|
||||
return [head] + out
|
||||
|
||||
|
||||
def tool_write_confirm(args: dict) -> list[str]:
|
||||
path = str(args.get("path", "")).strip()
|
||||
if not path:
|
||||
raise ToolError("path required")
|
||||
content = args.get("content")
|
||||
if not isinstance(content, str):
|
||||
raise ToolError("'content' (string) required")
|
||||
if len(content) > WRITE_MAX_CHARS:
|
||||
raise ToolError(
|
||||
f"content too large ({len(content)} chars, max {WRITE_MAX_CHARS}) - "
|
||||
f"write it in smaller pieces or use 'run'")
|
||||
p = _resolve(path)
|
||||
exists = p.is_file()
|
||||
if exists:
|
||||
old = p.read_text(encoding="utf-8", errors="replace")
|
||||
if len(old) > 2000:
|
||||
raise ToolError(
|
||||
f"{_display(p)} exists and is {len(old)} chars; refusing to "
|
||||
f"overwrite it wholesale - use 'edit' instead")
|
||||
else:
|
||||
old = ""
|
||||
summary = [
|
||||
f"{'overwrite' if exists else 'create'} {_display(p)} "
|
||||
f"({len(content.splitlines())} lines)"
|
||||
]
|
||||
summary += _unified_diff(old, content, _display(p))
|
||||
return summary
|
||||
|
||||
|
||||
def tool_write_execute(args: dict) -> list[str]:
|
||||
p = _resolve(str(args.get("path", "")).strip())
|
||||
content = str(args.get("content", ""))
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = content if content.endswith("\n") else content + "\n"
|
||||
p.write_text(text, encoding="utf-8")
|
||||
return [f"\u2713 wrote {_display(p)} ({len(text.splitlines())} lines)"]
|
||||
|
||||
|
||||
def _flag(args: dict, key: str) -> bool:
|
||||
return args.get(key) in (True, "true", "True", 1, "1", "yes")
|
||||
|
||||
|
||||
def tool_edit_confirm(args: dict) -> list[str]:
|
||||
path = str(args.get("path", "")).strip()
|
||||
if not path:
|
||||
raise ToolError("path required")
|
||||
old, new = args.get("old"), args.get("new")
|
||||
if not isinstance(old, str) or not old:
|
||||
raise ToolError("'old' (exact existing text) required")
|
||||
if not isinstance(new, str):
|
||||
raise ToolError("'new' (replacement text) required")
|
||||
p = _resolve(path)
|
||||
if not p.is_file():
|
||||
raise ToolError(f"not a file: {_display(p)}")
|
||||
body = p.read_text(encoding="utf-8", errors="replace")
|
||||
n = body.count(old)
|
||||
if n == 0:
|
||||
raise ToolError(
|
||||
f"search text not found in {_display(p)} - 'read' the file and "
|
||||
f"copy the text exactly")
|
||||
all_flag = _flag(args, "all")
|
||||
if n > 1 and not all_flag:
|
||||
raise ToolError(
|
||||
f"'old' matches {n} times in {_display(p)} - include more "
|
||||
f"surrounding text or set \"all\": true")
|
||||
updated = body.replace(old, new) if all_flag else body.replace(old, new, 1)
|
||||
summary = [
|
||||
f"edit {_display(p)} "
|
||||
f"({'all' if all_flag else 'first'} of {n} match{'es' if n != 1 else ''})"
|
||||
]
|
||||
summary += _unified_diff(body, updated, _display(p))
|
||||
return summary
|
||||
|
||||
|
||||
def tool_edit_execute(args: dict) -> list[str]:
|
||||
p = _resolve(str(args.get("path", "")).strip())
|
||||
old, new = str(args.get("old", "")), str(args.get("new", ""))
|
||||
all_flag = _flag(args, "all")
|
||||
body = p.read_text(encoding="utf-8", errors="replace")
|
||||
updated = body.replace(old, new) if all_flag else body.replace(old, new, 1)
|
||||
p.write_text(updated, encoding="utf-8")
|
||||
return [f"\u2713 edited {_display(p)}"]
|
||||
|
||||
|
||||
# name -> {confirm, execute, confirm_first}
|
||||
TOOLS: dict[str, dict] = {
|
||||
"run": {"confirm": tool_run_confirm, "execute": tool_run_execute, "confirm_first": True},
|
||||
"read": {"confirm": tool_read, "execute": tool_read, "confirm_first": False},
|
||||
"files": {"confirm": tool_files, "execute": tool_files, "confirm_first": False},
|
||||
"write": {"confirm": tool_write_confirm, "execute": tool_write_execute, "confirm_first": True},
|
||||
"edit": {"confirm": tool_edit_confirm, "execute": tool_edit_execute, "confirm_first": True},
|
||||
}
|
||||
|
||||
|
||||
def confirm_tool(name: str, summary: list[str], auto: bool) -> str:
|
||||
"""Ask the user to approve a tool call.
|
||||
|
||||
Returns "y", "n", ABORT, or a free-text instruction from the user.
|
||||
"""
|
||||
if auto:
|
||||
return "y"
|
||||
print(f"{ST.bold}{ST.yellow}? {name}{ST.reset}")
|
||||
for line in summary:
|
||||
print(f" {line}")
|
||||
try:
|
||||
answer = input(
|
||||
f" {ST.bold}[Enter=yes n=no a=abort or type a new instruction]{ST.reset} "
|
||||
).strip()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
return "n"
|
||||
except EOFError:
|
||||
print()
|
||||
return ABORT
|
||||
low = answer.lower()
|
||||
if low in ("", "y", "yes"):
|
||||
return "y"
|
||||
if low in ("n", "no", "c", "cancel"):
|
||||
return "n"
|
||||
if low in ("a", "abort"):
|
||||
return ABORT
|
||||
return answer
|
||||
|
||||
|
||||
def handle_tool_call(name: str, raw_body: str, auto: bool) -> tuple[bool, list[str]]:
|
||||
"""Validate, confirm and execute one tool call. Returns (ok, lines)."""
|
||||
spec = TOOLS.get(name)
|
||||
if spec is None:
|
||||
return False, [f"\u2717 unknown tool '{name}' - "
|
||||
f"available: {', '.join(sorted(TOOLS))}"]
|
||||
if name == "run":
|
||||
# Accept the plain-text command (as documented) or a JSON object
|
||||
# with a "command" key - some models emit JSON anyway.
|
||||
body = (raw_body or "").strip()
|
||||
args = {"command": body}
|
||||
if body.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
if isinstance(parsed, dict) and "command" in parsed:
|
||||
args = parsed
|
||||
except json.JSONDecodeError:
|
||||
pass # e.g. a brace group in shell - run it as-is
|
||||
else:
|
||||
try:
|
||||
args = json.loads(raw_body or "{}")
|
||||
if not isinstance(args, dict):
|
||||
raise ValueError("arguments must be a JSON object")
|
||||
except json.JSONDecodeError as exc:
|
||||
return False, [f"\u2717 bad JSON for '{name}': {exc} - fix it and retry"]
|
||||
except ValueError as exc:
|
||||
return False, [f"\u2717 {exc}"]
|
||||
|
||||
try:
|
||||
prepared = spec["confirm"](args) # validates; for read/files it already runs
|
||||
except ToolError as exc:
|
||||
return False, [f"\u2717 {exc}"]
|
||||
except (KeyError, TypeError, ValueError, OSError) as exc:
|
||||
return False, [f"\u2717 {exc}"]
|
||||
|
||||
if not spec["confirm_first"]: # read/files: safe, results are in
|
||||
return True, prepared
|
||||
|
||||
decision = "y" if auto else confirm_tool(name, prepared, auto)
|
||||
if decision == ABORT:
|
||||
raise ToolAbort()
|
||||
if decision != "y":
|
||||
note = "cancelled by user" if decision == "n" else f"user says: {decision}"
|
||||
return False, [f"\u2717 {note} - do not repeat this exact call; "
|
||||
f"adjust your approach or stop"]
|
||||
|
||||
try:
|
||||
lines = spec["execute"](args)
|
||||
# A failing command is not a tool error, but the model must see it
|
||||
# as a failure: the first output line starts with the cross mark.
|
||||
ok = not (lines and lines[0].startswith("\u2717"))
|
||||
return ok, lines
|
||||
except ToolError as exc:
|
||||
return False, [f"\u2717 {exc}"]
|
||||
except (OSError, ValueError, RuntimeError) as exc:
|
||||
return False, [f"\u2717 {exc}"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# agent turn: model reply -> tool calls -> real output -> model ...
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_tool_tags(raw: str) -> list[tuple[str, str]]:
|
||||
"""Extract tool calls in order: [(name, body), ...]."""
|
||||
tags: list[tuple[str, str]] = []
|
||||
for m in TAG_RE.finditer(raw):
|
||||
name = m.group(1) or "run"
|
||||
tags.append((name, (m.group(2) or "").strip()))
|
||||
return tags
|
||||
|
||||
|
||||
def print_agent_reply(text: str) -> None:
|
||||
print(f"{ST.bold}Agent:{ST.reset}")
|
||||
for line in text.rstrip().splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("\u2713"):
|
||||
print(f" {ST.green}\u2713{ST.reset} {stripped.lstrip('\u2713').strip()}")
|
||||
elif stripped.lower() == "done.":
|
||||
print(f" {ST.bold}{stripped}{ST.reset}")
|
||||
if line.startswith("\u2713"):
|
||||
print(f"{ST.green}{line}{ST.reset}")
|
||||
elif line.startswith("\u2717"):
|
||||
print(f"{ST.red}{line}{ST.reset}")
|
||||
else:
|
||||
print(f" {line.strip() if stripped else ''}")
|
||||
print(line)
|
||||
|
||||
|
||||
def build_prompt(history: list[tuple[str, str]], message: str) -> str:
|
||||
"""Plain-text transcript + new user message (server endpoint is stateless)."""
|
||||
parts = [AGENT_PREAMBLE]
|
||||
def process_reply(raw: str, auto: bool, show_text: bool = True
|
||||
) -> tuple[bool, str, list]:
|
||||
"""Print the reply, run its tool calls. Returns (had_tools, text, results)."""
|
||||
# Tool blocks out, leftover closing-fence lines out, blank edges trimmed.
|
||||
reply_text = STRAY_FENCE_RE.sub("", TAG_RE.sub("", raw)).strip()
|
||||
if reply_text and show_text: # already shown live when streamed
|
||||
print_agent_reply(reply_text)
|
||||
tags = read_tool_tags(raw)
|
||||
if not tags:
|
||||
return False, reply_text, []
|
||||
results: list[tuple[str, bool, list[str]]] = []
|
||||
for name, body in tags:
|
||||
print(f"{ST.dim}\u2014 tool: {name}{ST.reset}")
|
||||
ok, lines = handle_tool_call(name, body, auto)
|
||||
results.append((name, ok, lines))
|
||||
for i, line in enumerate(lines):
|
||||
if i == 0 and not ok:
|
||||
print(f"{ST.red}{line}{ST.reset}")
|
||||
elif line.startswith("\u2713"):
|
||||
print(f"{ST.green}{line}{ST.reset}")
|
||||
else:
|
||||
print(f" {line}")
|
||||
return True, reply_text, results
|
||||
|
||||
|
||||
def agent_turn(entry: dict, token: str, model: str,
|
||||
history: list[tuple[str, str]], task: str, auto: bool
|
||||
) -> tuple[str, str, float | None]:
|
||||
"""Run one user task through the agent loop.
|
||||
|
||||
Returns (status, final_text, remaining_minutes):
|
||||
status is "done", "stopped", "cancelled", "limit" or "error".
|
||||
"""
|
||||
transcript = ""
|
||||
for user_text, answer in history[-HISTORY_TURNS:]:
|
||||
parts.append(f"User: {user_text}\nAssistant: {answer}")
|
||||
parts.append(f"User: {message}\nAssistant:")
|
||||
return "\n\n".join(parts)
|
||||
transcript += f"User: {user_text}\nAssistant: {answer}\n\n"
|
||||
messages = [AGENT_PREAMBLE
|
||||
+ (("\n\n" + transcript) if transcript else "")
|
||||
+ f"\n\nUser: {task}\nAssistant:"]
|
||||
remaining: float | None = None
|
||||
no_tool = 0
|
||||
streamed = {"any": False}
|
||||
|
||||
def on_chunk(obj: dict) -> None:
|
||||
piece = obj.get("chunk")
|
||||
if not piece:
|
||||
return
|
||||
if not streamed["any"]:
|
||||
streamed["any"] = True
|
||||
print(f"{ST.bold}Agent:{ST.reset}")
|
||||
sys.stdout.write(f"{ST.dim}{piece}{ST.reset}" if ST.enabled else piece)
|
||||
sys.stdout.flush()
|
||||
|
||||
try:
|
||||
for turn in range(1, MAX_TURNS + 1):
|
||||
try:
|
||||
status, body = api_chat(
|
||||
entry, token, "\n\n".join(messages), model,
|
||||
on_chunk=on_chunk)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
return "cancelled", "", remaining
|
||||
if status != 200 or body.get("error"):
|
||||
print(f"\n{ST.red}error: "
|
||||
f"{body.get('error', 'unknown')}{ST.reset}")
|
||||
if status == 403:
|
||||
return "limit", "", remaining
|
||||
return "error", "", remaining
|
||||
remaining = body.get("remaining_minutes", remaining)
|
||||
raw = str(body.get("response", ""))
|
||||
if streamed["any"]:
|
||||
print()
|
||||
had_tools, reply_text, results = process_reply(
|
||||
raw, auto, show_text=not streamed["any"])
|
||||
streamed["any"] = False
|
||||
|
||||
if not had_tools:
|
||||
no_tool += 1
|
||||
if no_tool >= NO_TOOL_LIMIT:
|
||||
return "done", reply_text, remaining
|
||||
messages.append(
|
||||
"(no tool call received) If the task is complete, reply "
|
||||
"with exactly: Done.\nOtherwise continue with the next "
|
||||
"tool call.")
|
||||
continue
|
||||
no_tool = 0
|
||||
|
||||
if turn >= MAX_TURNS:
|
||||
print(f"{ST.yellow}step limit ({MAX_TURNS}) reached - "
|
||||
f"stopping{ST.reset}")
|
||||
return "stopped", reply_text, remaining
|
||||
|
||||
out = []
|
||||
for name, ok, lines in results:
|
||||
out.append(f"{name}: {'ok' if ok else 'FAILED'}")
|
||||
out.extend(" " + ln for ln in lines)
|
||||
messages.append(
|
||||
"Tool results (real output from your last tool call):\n"
|
||||
+ ("\n".join(out) or "(no output)")
|
||||
+ "\n\nContinue with the next step.")
|
||||
except ToolAbort:
|
||||
print(f"{ST.yellow}stopped by user{ST.reset}")
|
||||
return "stopped", "", remaining
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
return "cancelled", "", remaining
|
||||
print(f"{ST.yellow}step limit ({MAX_TURNS}) reached - stopping{ST.reset}")
|
||||
return "stopped", "", remaining
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat REPL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP = """
|
||||
HELP = f"""\
|
||||
commands:
|
||||
/help show this help
|
||||
/server show the connected server
|
||||
/models list models on the server
|
||||
/model <name> switch model (also saved for this server)
|
||||
/limit show remaining minutes today (last known)
|
||||
/auto toggle confirmations (auto-approve every tool call)
|
||||
/state show auto mode, project dir, history size
|
||||
/cwd show the project directory
|
||||
/clear forget the conversation history
|
||||
/quit exit (also Ctrl+D)
|
||||
anything else is sent to the model.
|
||||
anything else is a task for the agent. It can run commands and change files
|
||||
in the project directory; you confirm every action unless auto mode is on.
|
||||
|
||||
available tools: {', '.join(sorted(TOOLS))}
|
||||
"""
|
||||
|
||||
|
||||
def chat_loop(entry: dict, token: str, model: str, remaining: float | None) -> None:
|
||||
def chat_loop(entry: dict, token: str, model: str,
|
||||
remaining: float | None, auto: bool) -> None:
|
||||
history: list[tuple[str, str]] = []
|
||||
while True:
|
||||
try:
|
||||
@@ -409,6 +989,18 @@ def chat_loop(entry: dict, token: str, model: str, remaining: float | None) -> N
|
||||
elif cmd == "/limit":
|
||||
left = "unlimited" if remaining is None else f"{remaining:g} min"
|
||||
print(f"time left today: {left}")
|
||||
elif cmd == "/auto":
|
||||
auto = not auto
|
||||
print("auto mode ON - tool calls run without confirmation"
|
||||
if auto else
|
||||
"auto mode OFF - every action asks first")
|
||||
elif cmd == "/state":
|
||||
print(f"auto: {'on' if auto else 'off'}")
|
||||
print(f"project dir: {CWD}")
|
||||
print(f"history: {len(history)} turn(s)")
|
||||
print(f"model: {model or '(daemon default)'}")
|
||||
elif cmd == "/cwd":
|
||||
print(str(CWD))
|
||||
elif cmd == "/clear":
|
||||
history.clear()
|
||||
print("history cleared")
|
||||
@@ -416,23 +1008,12 @@ def chat_loop(entry: dict, token: str, model: str, remaining: float | None) -> N
|
||||
print(f"unknown command {cmd} - try /help")
|
||||
continue
|
||||
|
||||
try:
|
||||
status, body = api_chat(entry, token, build_prompt(history, message), model)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{ST.yellow}request cancelled{ST.reset}")
|
||||
continue
|
||||
|
||||
if status != 200:
|
||||
print(f"{ST.red}error: {body.get('error', 'unknown')}{ST.reset}")
|
||||
if status == 403:
|
||||
return # daily limit reached
|
||||
continue
|
||||
|
||||
answer = str(body.get("response", "")).strip() or "(empty reply)"
|
||||
history.append((message, answer))
|
||||
print()
|
||||
print_agent_reply(answer)
|
||||
remaining = body.get("remaining_minutes")
|
||||
status, final, remaining = agent_turn(
|
||||
entry, token, model, history, message, auto)
|
||||
if status == "limit":
|
||||
return # daily limit reached
|
||||
if status == "done" and final:
|
||||
history.append((message, final))
|
||||
if remaining is not None:
|
||||
print(f"{ST.dim} ({remaining:g} min left today){ST.reset}")
|
||||
print()
|
||||
@@ -453,6 +1034,9 @@ def main() -> None:
|
||||
parser.add_argument("--no-probe", action="store_true",
|
||||
help="skip the reachability check before login (use behind "
|
||||
"HTTP proxies that answer for every port)")
|
||||
parser.add_argument("-y", "--yes", action="store_true",
|
||||
help="auto mode: run tool calls without confirmation "
|
||||
"(the agent edits real files - be careful)")
|
||||
args = parser.parse_args()
|
||||
|
||||
state = load_state()
|
||||
@@ -504,9 +1088,12 @@ def main() -> None:
|
||||
state["last"] = label
|
||||
save_state(state)
|
||||
|
||||
project = shorten_home(Path.cwd())
|
||||
project = shorten_home(CWD)
|
||||
banner(model or "(server default)", str(project), label)
|
||||
chat_loop(entry, token, model, None)
|
||||
if args.yes:
|
||||
print(f"{ST.yellow}auto mode: tool calls run without confirmation{ST.reset}")
|
||||
print(f"{ST.dim}working dir: {CWD}{ST.reset}")
|
||||
chat_loop(entry, token, model, None, args.yes)
|
||||
print("bye")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user