Merge verified Odysseus fixes

This commit is contained in:
pewdiepie-archdaemon
2026-07-23 14:49:02 +00:00
parent 4c9a8ca115
commit d8a2059df8
117 changed files with 15693 additions and 3191 deletions
+5 -1
View File
@@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
from .subprocess_tools import BashTool, PythonTool
from .web_tools import WebSearchTool, WebFetchTool
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, ApplyPatchTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
from .coding_tools import TodoWriteTool
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
from .interaction_tools import AskUserTool, UpdatePlanTool
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
@@ -41,6 +42,8 @@ TOOL_HANDLERS = {
"read_file": ReadFileTool().execute,
"write_file": WriteFileTool().execute,
"edit_file": EditFileTool().execute,
"apply_patch": ApplyPatchTool().execute,
"todowrite": TodoWriteTool().execute,
"ls": LsTool().execute,
"glob": GlobTool().execute,
"grep": GrepTool().execute,
@@ -74,6 +77,7 @@ PYTHON_TIMEOUT = 30
# Tool types that trigger execution
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
"apply_patch", "todowrite",
"grep", "glob", "ls", "get_workspace", "manage_bg_jobs",
"create_document", "update_document", "edit_document",
"search_chats",
+67
View File
@@ -0,0 +1,67 @@
import json
import os
import re
from typing import Any, Dict, List
from src.constants import DATA_DIR
_TODO_DIR = os.path.join(DATA_DIR, "agent_todos")
def _safe_session_id(value: str) -> str:
value = value or "current"
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)[:120] or "current"
class TodoWriteTool:
async def execute(self, content: str, ctx: dict) -> dict:
try:
args = json.loads(content) if (content or "").strip().startswith("{") else {"todos": []}
except (json.JSONDecodeError, TypeError):
return {"error": "todowrite: JSON object required", "exit_code": 1}
todos = args.get("todos")
if not isinstance(todos, list):
return {"error": "todowrite: todos must be a list", "exit_code": 1}
normalized: List[Dict[str, Any]] = []
allowed_statuses = {"pending", "in_progress", "completed"}
allowed_priorities = {"low", "medium", "high"}
active_count = 0
for item in todos:
if not isinstance(item, dict):
return {"error": "todowrite: each todo must be an object", "exit_code": 1}
content_text = str(item.get("content") or item.get("text") or "").strip()
if not content_text:
return {"error": "todowrite: todo content required", "exit_code": 1}
status = str(item.get("status") or "pending").strip()
if status not in allowed_statuses:
return {"error": f"todowrite: invalid status {status!r}", "exit_code": 1}
if status == "in_progress":
active_count += 1
priority = str(item.get("priority") or "medium").strip()
if priority not in allowed_priorities:
priority = "medium"
normalized.append({
"content": content_text,
"status": status,
"priority": priority,
})
if active_count > 1:
return {"error": "todowrite: only one todo can be in_progress", "exit_code": 1}
session_id = _safe_session_id(str(ctx.get("session_id") or args.get("session_id") or "current"))
os.makedirs(_TODO_DIR, exist_ok=True)
path = os.path.join(_TODO_DIR, f"{session_id}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"todos": normalized}, f, ensure_ascii=False, indent=2)
lines = []
for item in normalized:
marker = {"pending": " ", "in_progress": ">", "completed": "x"}[item["status"]]
lines.append(f"[{marker}] {item['content']} ({item['priority']})")
return {
"output": "Updated todo list:\n" + ("\n".join(lines) if lines else "(empty)"),
"exit_code": 0,
"todos": normalized,
}
+176 -1
View File
@@ -5,7 +5,7 @@ import re
import difflib
import fnmatch
import shutil
from typing import Optional, Dict, Any, Tuple
from typing import Optional, Dict, Any, Tuple, List
from src.constants import MAX_READ_CHARS, MAX_DIFF_LINES, MAX_OUTPUT_CHARS
@@ -230,6 +230,181 @@ class WriteFileTool:
result["diff"] = diff
return result
class ApplyPatchTool:
async def execute(self, content: str, ctx: dict) -> dict:
"""Apply a small Codex-style patch using exact context matching.
This is deliberately stricter than git-apply: if an update hunk's old
text is not found exactly once, the whole patch is rejected before any
file is changed. That keeps agent edits reviewable and avoids fuzzy
corruption when the model patches stale context.
"""
from src.tool_execution import _resolve_tool_path
patch_text = content or ""
stripped = patch_text.strip()
if stripped.startswith("{"):
try:
args = json.loads(stripped)
if isinstance(args, dict):
patch_text = str(args.get("patch_text") or args.get("patchText") or args.get("patch") or "")
except (json.JSONDecodeError, TypeError):
pass
if not patch_text.strip():
return {"error": "apply_patch: patch_text required", "exit_code": 1}
try:
ops = _parse_agent_patch(patch_text)
if not ops:
return {"error": "apply_patch: no file operations found", "exit_code": 1}
prepared = []
for op in ops:
path = _resolve_tool_path(op["path"])
kind = op["kind"]
if kind == "add":
if os.path.exists(path):
return {"error": f"apply_patch: {op['path']}: already exists", "exit_code": 1}
old = ""
new = op["content"]
elif kind == "delete":
if not os.path.isfile(path):
return {"error": f"apply_patch: {op['path']}: not found", "exit_code": 1}
with open(path, "r", encoding="utf-8") as f:
old = f.read()
new = ""
else:
if not os.path.isfile(path):
return {"error": f"apply_patch: {op['path']}: not found", "exit_code": 1}
with open(path, "r", encoding="utf-8") as f:
old = f.read()
new = _apply_patch_hunks(old, op["hunks"], op["path"])
prepared.append((kind, path, old, new))
diffs = []
for kind, path, old, new in prepared:
if kind == "delete":
os.remove(path)
else:
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(new)
diff = _unified_diff(old, new, path)
if diff:
diffs.append(diff)
except (ValueError, UnicodeDecodeError, PermissionError, OSError) as e:
return {"error": f"apply_patch: {e}", "exit_code": 1}
added = sum(int(d.get("added") or 0) for d in diffs)
removed = sum(int(d.get("removed") or 0) for d in diffs)
text_parts = [d.get("text", "") for d in diffs if d.get("text")]
diff_text = "\n".join(text_parts)
if len(diff_text.splitlines()) > MAX_DIFF_LINES:
diff_text = "\n".join(diff_text.splitlines()[:MAX_DIFF_LINES]) + f"\n... diff truncated at {MAX_DIFF_LINES} lines"
result = {
"output": f"Applied patch ({len(prepared)} file{'s' if len(prepared) != 1 else ''}, +{added}/-{removed})",
"exit_code": 0,
}
if diffs:
result["diff"] = {
"text": diff_text,
"added": added,
"removed": removed,
"new_file": any(d.get("new_file") for d in diffs),
"file": "patch",
}
return result
def _parse_agent_patch(patch_text: str) -> List[Dict[str, Any]]:
lines = patch_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
if not lines or lines[0].strip() != "*** Begin Patch":
raise ValueError("patch must start with *** Begin Patch")
if lines[-1].strip() != "*** End Patch":
raise ValueError("patch must end with *** End Patch")
ops: List[Dict[str, Any]] = []
i = 1
while i < len(lines) - 1:
line = lines[i]
if not line:
i += 1
continue
if line.startswith("*** Add File: "):
path = line[len("*** Add File: "):].strip()
body = []
i += 1
while i < len(lines) - 1 and not lines[i].startswith("*** "):
if not lines[i].startswith("+"):
raise ValueError(f"add file {path}: every content line must start with +")
body.append(lines[i][1:])
i += 1
ops.append({"kind": "add", "path": path, "content": "\n".join(body) + ("\n" if body else "")})
continue
if line.startswith("*** Delete File: "):
path = line[len("*** Delete File: "):].strip()
ops.append({"kind": "delete", "path": path})
i += 1
continue
if line.startswith("*** Update File: "):
path = line[len("*** Update File: "):].strip()
hunks = []
current = []
i += 1
if i < len(lines) - 1 and lines[i].startswith("*** Move to: "):
raise ValueError("move operations are not supported")
while i < len(lines) - 1 and not lines[i].startswith("*** "):
if lines[i].startswith("@@"):
if current:
hunks.append(current)
current = []
elif lines[i].startswith((" ", "-", "+")):
current.append(lines[i])
elif lines[i] == "":
current.append(" ")
else:
raise ValueError(f"update file {path}: invalid patch line {lines[i]!r}")
i += 1
if current:
hunks.append(current)
if not hunks:
raise ValueError(f"update file {path}: no hunks")
ops.append({"kind": "update", "path": path, "hunks": hunks})
continue
raise ValueError(f"unexpected patch line: {line!r}")
return ops
def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str:
updated = original
for idx, hunk in enumerate(hunks, 1):
old_lines = []
new_lines = []
for line in hunk:
prefix, body = line[:1], line[1:]
if prefix in (" ", "-"):
old_lines.append(body)
if prefix in (" ", "+"):
new_lines.append(body)
old_text = "\n".join(old_lines)
new_text = "\n".join(new_lines)
if old_text and old_text in updated:
occurrences = updated.count(old_text)
if occurrences != 1:
raise ValueError(f"{label}: hunk {idx} context matched {occurrences} times")
updated = updated.replace(old_text, new_text, 1)
elif old_text + "\n" in updated:
occurrences = updated.count(old_text + "\n")
if occurrences != 1:
raise ValueError(f"{label}: hunk {idx} context matched {occurrences} times")
updated = updated.replace(old_text + "\n", new_text + "\n", 1)
else:
raise ValueError(f"{label}: hunk {idx} context not found")
return updated
class LsTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
+202
View File
@@ -1,4 +1,7 @@
import asyncio
import os
import re
import shutil
import sys
import time
import collections
@@ -10,6 +13,175 @@ DEFAULT_PYTHON_TIMEOUT = 60 * 60
PROGRESS_INTERVAL_S = 2.0
PROGRESS_TAIL_LINES = 12
TMUX_CAPTURE_LINES = 2000
def _tmux_session_name(session_id: Optional[str]) -> str:
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
return f"ody-agent-{raw[:80] or 'default'}"
async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
pass
return "", "timeout", 124
return (
out_b.decode("utf-8", errors="replace"),
err_b.decode("utf-8", errors="replace"),
proc.returncode or 0,
)
async def _tmux_has_session(name: str) -> bool:
_, _, rc = await _run_exec("tmux", "has-session", "-t", name, timeout=3)
return rc == 0
async def _tmux_capture(name: str) -> str:
out, _, _ = await _run_exec(
"tmux", "capture-pane", "-p", "-J", "-S", f"-{TMUX_CAPTURE_LINES}", "-t", name,
timeout=5,
)
return out
async def _tmux_send_line(name: str, line: str) -> None:
if line:
await _run_exec("tmux", "send-keys", "-t", name, "-l", line, timeout=5)
await _run_exec("tmux", "send-keys", "-t", name, "C-m", timeout=5)
async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None:
if await _tmux_has_session(name):
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
return
await _run_exec(
"tmux", "new-session", "-d", "-s", name, "-c", cwd,
"env",
f"TERM={env.get('TERM', 'xterm-256color') if env else 'xterm-256color'}",
f"COLUMNS={env.get('COLUMNS', '120') if env else '120'}",
f"LINES={env.get('LINES', '40') if env else '40'}",
"/bin/bash",
"--noprofile",
"--norc",
timeout=10,
)
if not await _tmux_has_session(name):
raise RuntimeError(f"failed to create tmux session {name}")
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
def _output_after_marker(capture: str, start_marker: str, end_marker: str) -> Tuple[str, bool]:
lines = capture.splitlines()
start_idx = -1
for idx, line in enumerate(lines):
if line.strip() == start_marker:
start_idx = idx
if start_idx < 0:
return capture, False
end_idx = -1
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip().startswith(end_marker):
end_idx = idx
if end_idx < 0:
return "\n".join(lines[start_idx + 1:]), False
return "\n".join(lines[start_idx + 1:end_idx]), True
def _extract_marker_rc(capture: str, end_marker: str) -> int:
for line in reversed(capture.splitlines()):
stripped = line.strip()
if stripped.startswith(end_marker):
suffix = stripped[len(end_marker):].strip()
if suffix.isdigit():
return int(suffix)
return 0
async def _run_tmux_bash(
content: str,
*,
session_id: str,
cwd: str,
env: Optional[dict],
timeout: float,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
) -> Tuple[str, str, Optional[int], bool]:
name = _tmux_session_name(session_id)
await _ensure_tmux_session(name, cwd, env)
stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}"
start_marker = f"__ODYSSEUS_CMD_START_{stamp}__"
end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:"
wrapped = (
f"printf '\\n{start_marker}\\n'\n"
f"{content}\n"
f"__ody_rc=$?\n"
f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n"
)
for line in wrapped.splitlines():
await _tmux_send_line(name, line)
started = time.time()
last_tail = ""
while True:
capture = await _tmux_capture(name)
body, done = _output_after_marker(capture, start_marker, end_prefix)
tail = "\n".join(body.splitlines()[-PROGRESS_TAIL_LINES:])
if progress_cb and tail != last_tail:
last_tail = tail
try:
await progress_cb({
"elapsed_s": round(time.time() - started, 1),
"tail": tail,
"tmux_session": name,
})
except Exception:
pass
if done:
rc = _extract_marker_rc(capture, end_prefix)
cleaned = _clean_tmux_command_output(body, wrapped)
return cleaned, "", rc, False
if time.time() - started > timeout:
try:
await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3)
except Exception:
pass
cleaned = _clean_tmux_command_output(body, wrapped)
return cleaned, "", 124, True
await asyncio.sleep(0.5)
def _clean_tmux_command_output(text: str, wrapped_command: str) -> str:
lines = text.splitlines()
wrapped_lines = {ln.rstrip() for ln in wrapped_command.splitlines() if ln.strip()}
cleaned = []
for line in lines:
raw = line.rstrip()
stripped = raw.strip()
if not stripped:
cleaned.append(raw)
continue
if stripped in wrapped_lines:
continue
if stripped.startswith("__ody_rc=") or stripped.startswith("printf "):
continue
if re.fullmatch(r"(?:bash|sh)-[\d.]+\$ ?", stripped):
continue
if re.fullmatch(r"[\w.@:/~+-]+[#$] ?", stripped):
continue
cleaned.append(raw)
return "\n".join(cleaned).strip()
async def _run_subprocess_streaming(
proc: asyncio.subprocess.Process,
@@ -103,8 +275,38 @@ async def _run_subprocess_streaming(
class BashTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import agent_cwd, _truncate
if isinstance(content, dict):
content = str(content.get("command") or content.get("cmd") or content.get("code") or "")
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
session_id = ctx.get("session_id")
if session_id and shutil.which("tmux"):
stdout, stderr, rc, timed_out = await _run_tmux_bash(
content,
session_id=str(session_id),
cwd=agent_cwd(),
env=_subproc_env,
timeout=DEFAULT_BASH_TIMEOUT,
progress_cb=progress_cb,
)
if timed_out:
return {
"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — sent Ctrl-C to tmux session",
"exit_code": 124,
"stdout": _truncate(stdout, MAX_OUTPUT_CHARS),
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
"tmux_session": _tmux_session_name(str(session_id)),
}
output = stdout.rstrip()
err = stderr.rstrip()
if err:
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
return {
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
"exit_code": rc or 0,
"tmux_session": _tmux_session_name(str(session_id)),
}
proc = await asyncio.create_subprocess_shell(
content,
stdout=asyncio.subprocess.PIPE,