Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent c9dd68d890
commit 84aa9a91de
871 changed files with 265870 additions and 27854 deletions
+15 -45
View File
@@ -12,15 +12,15 @@ Sub-modules:
"""
import logging
from collections import namedtuple
from src.tool_security import BUILTIN_EMAIL_TOOLS
from src.tool_utils import _truncate, get_mcp_manager, set_mcp_manager
from src.tool_types import TOOL_TAGS, ToolBlock
logger = logging.getLogger(__name__)
from .subprocess_tools import BashTool, PythonTool
from .web_tools import WebSearchTool, WebFetchTool
from .subprocess_tools import BashTool, HostShellTool, PythonTool
from .web_tools import WebSearchTool, WebFetchTool, PdfExtractTool, PrivateBrowserTool, YouTubeTool
from .media_tools import ExtractTextTool, InspectMediaTool, TranscribeMediaTool
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
@@ -36,9 +36,16 @@ from .admin_tools import (
TOOL_HANDLERS = {
"bash": BashTool().execute,
"host_shell": HostShellTool().execute,
"python": PythonTool().execute,
"web_search": WebSearchTool().execute,
"web_fetch": WebFetchTool().execute,
"pdf_extract": PdfExtractTool().execute,
"youtube_tool": YouTubeTool().execute,
"private_browser": PrivateBrowserTool().execute,
"inspect_media": InspectMediaTool().execute,
"extract_text": ExtractTextTool().execute,
"transcribe_media": TranscribeMediaTool().execute,
"read_file": ReadFileTool().execute,
"write_file": WriteFileTool().execute,
"edit_file": EditFileTool().execute,
@@ -71,50 +78,13 @@ TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
# Constants (re-exported for backward compatibility — single source of truth
# is src.constants; always prefer importing from there for new code)
# ---------------------------------------------------------------------------
MAX_AGENT_ROUNDS = 50
# Keep an agent turn bounded by default. Callers can still opt into a higher
# limit explicitly, but a stale/repeating tool loop must not consume a whole
# session before the user gets control back.
MAX_AGENT_ROUNDS = 20
SHELL_TIMEOUT = 60
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",
"chat_with_model", "create_session", "list_sessions",
"send_to_session",
"pipeline",
"manage_session", "manage_memory", "list_models",
"ui_control", "generate_image", "ask_user", "update_plan",
"manage_tasks", "api_call", "ask_teacher", "manage_skills",
"suggest_document",
"manage_endpoints", "manage_mcp", "manage_webhooks",
"manage_tokens", "manage_documents", "manage_settings",
"manage_notes", "manage_calendar",
"resolve_contact", "manage_contact",
# Email tool names come from BUILTIN_EMAIL_TOOLS (unioned below)
# so the fence regex, dispatch, and non-admin blocklist all cover
# the same set.
# Cookbook tools (LLM serving + downloads). Without these
# entries, native function calls to e.g. list_served_models
# are rejected as "Unknown function call" before reaching
# the dispatcher — silent failure for the whole cookbook
# surface.
"download_model", "serve_model",
"list_served_models", "stop_served_model",
"list_downloads", "cancel_download",
"search_hf_models", "list_cached_models",
"list_serve_presets", "serve_preset", "adopt_served_model",
"list_cookbook_servers",
# Other tools the agent reaches for that were also missing.
"edit_image", "trigger_research", "manage_research",
# Generic loopback to any UI-button endpoint (cookbook,
# gallery, email folders, etc.) — agent uses this when
# there's no named tool wrapper for the action.
"app_api"} | BUILTIN_EMAIL_TOOLS
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
# ---------------------------------------------------------------------------
# Re-exports from sub-modules
# ---------------------------------------------------------------------------
+4 -2
View File
@@ -560,6 +560,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"hard max": "agent_input_token_hard_max",
"token budget cap": "agent_input_token_hard_max",
"input budget cap": "agent_input_token_hard_max",
"writing style": "email_writing_style", "email writing style": "email_writing_style",
"reply writing style": "email_writing_style", "email reply writing style": "email_writing_style",
}
def _resolve(k):
k2 = (k or "").strip().lower()
@@ -700,7 +702,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
# Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> "builtin_browser",
# "search" -> "web_search", "browser" -> browser tools,
# "documents" -> the document tool set, "memory" ->
# manage_memory, etc.
from src.settings import get_setting, save_settings, load_settings
@@ -709,7 +711,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"terminal": ["bash"],
"search": ["web_search", "web_fetch"],
"web": ["web_search", "web_fetch"],
"browser": ["builtin_browser"],
"browser": ["builtin_browser", "private_browser"],
"documents": ["create_document", "edit_document", "update_document", "suggest_document"],
"doc": ["create_document", "edit_document", "update_document", "suggest_document"],
"memory": ["manage_memory"],
+157 -10
View File
@@ -1,4 +1,6 @@
from typing import Any, Dict, List, Optional
import hashlib
import html
import logging
import re
from src.constants import MAX_READ_CHARS
@@ -254,17 +256,33 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
return header.rstrip() + "\n---\n" + body
def parse_edit_blocks(content: str) -> list:
"""Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks."""
"""Parse canonical or compact FIND/REPLACE edit blocks."""
edits = []
pattern = r'<<<FIND>>>\n(.*?)\n<<<REPLACE>>>\n(.*?)\n<<<END>>>'
# Accept the newline form used in training examples and the compact form
# emitted by some native tool callers. Marker whitespace is structural;
# preserve whitespace inside the actual find/replace text.
pattern = (
r'<<<FIND>>>[ \t]*(?:\r?\n)?(.*?)[ \t]*(?:\r?\n)?'
r'<<<REPLACE>>>[ \t]*(?:\r?\n)?(.*?)[ \t]*(?:\r?\n)?<<<END>>>'
)
for m in re.finditer(pattern, content, re.DOTALL):
edits.append({"find": m.group(1), "replace": m.group(2)})
if not edits and "<<<FIND>>>" in content and "<<<REPLACE>>>" in content:
# Some native callers stop generation immediately after the replace
# body. Treat end-of-content as the terminal marker only in that
# unmistakable two-marker form.
compact_pattern = (
r'<<<FIND>>>[ \t]*(?:\r?\n)?(.*?)'
r'[ \t]*(?:\r?\n)?<<<REPLACE>>>[ \t]*(?:\r?\n)?(.*?)(?:<<<END>>>)?\s*$'
)
for m in re.finditer(compact_pattern, content, re.DOTALL):
edits.append({"find": m.group(1), "replace": m.group(2)})
return edits
def parse_suggest_blocks(content: str) -> list:
"""Parse <<<FIND>>>...<<<SUGGEST>>>...<<<REASON>>>...<<<END>>> blocks."""
suggestions = []
_skip_phrases = ["no change", "clear", "fine as", "looks good", "no improvement", "keep as"]
_skip_phrases = ["no change", "fine as", "looks good", "no improvement", "keep as"]
pattern = r'<<<FIND>>>\n(.*?)\n<<<SUGGEST>>>\n(.*?)\n<<<REASON>>>\n(.*?)\n<<<END>>>'
for m in re.finditer(pattern, content, re.DOTALL):
find_text = m.group(1)
@@ -284,6 +302,102 @@ def parse_suggest_blocks(content: str) -> list:
return suggestions
def _stable_suggestion_id(doc_id: str, suggestion: dict) -> str:
"""Deduplicate the same suggestion without colliding across tool calls."""
payload = "\0".join((
str(doc_id or ''), str(suggestion.get('find') or ''),
str(suggestion.get('replace') or ''), str(suggestion.get('reason') or ''),
))
return 'sugg-' + hashlib.sha256(payload.encode('utf-8')).hexdigest()[:16]
def _visible_text_match_source(source: str, needle: str) -> Optional[str]:
"""Return the source fragment corresponding to visible ``needle`` text.
Rich/email documents are stored as HTML, while browser selections contain
only rendered text. Build a lightweight visible-text index so suggestions
can still be anchored when markup or entities sit between selected words.
"""
if not source or not needle:
return None
# Keep the plain-text path cheap and exact.
canonical_needle = needle.replace("\r\n", "\n").replace("\r", "\n")
if canonical_needle in source:
return canonical_needle
if "<" not in source or ">" not in source:
return None
visible_chars = []
char_spans = []
for match in re.finditer(r"<!--.*?-->|<[^>]*>|[^<]+", source, re.DOTALL):
token = match.group(0)
if token.startswith("<"):
continue
decoded = html.unescape(token)
# Entities decode to fewer characters; map each decoded character to
# the source token so the returned fragment remains source-valid.
for char in decoded:
visible_chars.append(char)
char_spans.append((match.start(), match.end()))
visible = "".join(visible_chars)
normalize = lambda value: re.sub(r"\s+", " ", value.replace("\r\n", "\n").replace("\r", "\n")).strip()
normalized_visible = normalize(visible)
normalized_needle = normalize(canonical_needle)
start = normalized_visible.find(normalized_needle)
if start < 0:
return None
# Map the normalized match back to source positions. Whitespace runs are
# collapsed, so walk the original visible text while building the same
# normalized-character spans.
normalized_chars = []
normalized_spans = []
in_space = False
for index, char in enumerate(visible_chars):
if char.isspace():
if not in_space:
normalized_chars.append(" ")
normalized_spans.append(char_spans[index])
in_space = True
else:
normalized_chars.append(char)
normalized_spans.append(char_spans[index])
in_space = False
while normalized_chars and normalized_chars[0].isspace():
normalized_chars.pop(0)
normalized_spans.pop(0)
while normalized_chars and normalized_chars[-1].isspace():
normalized_chars.pop()
normalized_spans.pop()
normalized_visible = "".join(normalized_chars)
end = start + len(normalized_needle)
if end > len(normalized_spans):
return None
source_start = normalized_spans[start][0]
source_end = normalized_spans[end - 1][1]
# Keep inline wrappers intact when the selection starts/ends inside one.
# Without this, replacing a selection ending in <strong> would leave its
# closing tag outside the replacement fragment and corrupt the HTML.
inline_open = re.search(
r"<(?:strong|em|b|i|u|s|del|strike|a|span|font)(?:\s[^>]*)?>$",
source[:source_start],
re.IGNORECASE,
)
if inline_open:
source_start = inline_open.start()
inline_close = re.match(
r"(?:</(?:strong|em|b|i|u|s|del|strike|a|span|font)>)+",
source[source_end:],
re.IGNORECASE,
)
if inline_close:
source_end += inline_close.end()
return source[source_start:source_end]
def _pdf_source_upload_id(content: str) -> Optional[str]:
try:
from src.pdf_form_doc import find_source_upload_id
@@ -364,7 +478,7 @@ class CreateDocumentTool:
# Known languages the editor understands (match the <select> in HTML)
_KNOWN_LANGS = {
"python", "javascript", "typescript", "html", "css", "markdown", "json",
"python", "javascript", "typescript", "html", "css", "richtext", "markdown", "json",
"yaml", "bash", "sql", "rust", "go", "java", "c", "cpp", "xml", "toml",
"ini", "ruby", "php", "csv", "email", "text", "plain", "svg",
}
@@ -496,6 +610,8 @@ class UpdateDocumentTool:
):
return _approved_document_version_error(None, ctx)
if not doc:
if target_id:
return {"error": "Requested document not found; no other document was changed", "exit_code": 1}
doc = _most_recent_owned_document(db, Document, owner)
if doc:
target_id = doc.id
@@ -582,6 +698,8 @@ class EditDocumentTool:
):
return _approved_document_version_error(None, ctx)
if not doc:
if target_id:
return {"error": "Requested document not found; no other document was changed", "exit_code": 1}
# Fallback: most recently updated document. Avoids "no active doc" errors
# after server restart or when the agent loses track of which doc to edit.
doc = _most_recent_owned_document(db, Document, owner)
@@ -739,10 +857,20 @@ class SuggestDocumentTool:
# Validate that FIND text exists in document
valid = []
for s in suggestions:
if s["find"] in doc.current_content:
find_text = s["find"]
# Browser selections from markdown, rich text, and email are
# rendered text, while the stored document may contain LF
# normalization or HTML markup. Resolve the visible passage
# back to the exact source fragment used by the editor.
source_find = _visible_text_match_source(doc.current_content, find_text)
if source_find is not None:
if source_find != find_text:
s = dict(s)
s["find"] = source_find
s["id"] = _stable_suggestion_id(target_id, s)
valid.append(s)
else:
logger.warning(f"suggest_document: FIND text not found, skipping: {s['find'][:80]!r}")
logger.warning(f"suggest_document: FIND text not found, skipping: {find_text[:80]!r}")
if not valid:
return {"error": "No suggestions matched the document content"}
@@ -779,6 +907,10 @@ class ManageDocumentTool:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
if action in {"search", "find"}:
action = "list"
if not args.get("search"):
args["search"] = args.get("text") or args.get("query") or args.get("title")
db = SessionLocal()
def _rel(ts):
@@ -799,13 +931,22 @@ class ManageDocumentTool:
if action == "list":
q = db.query(Document).filter(Document.is_active == True)
q = _owned_document_query(q, Document, owner)
if args.get("search"):
q = q.filter(Document.title.ilike(f"%{args['search']}%"))
search_text = args.get("search")
if search_text:
# Tolerate unambiguous conversational framing in a title
# fallback without introducing broad fuzzy matching.
search_text = re.sub(
r"\s+from\s+my\s+documents\b", "", str(search_text), flags=re.IGNORECASE
)
search_text = re.sub(
r"\s+(?:instead|please)\s*$", "", search_text, flags=re.IGNORECASE
).strip()
q = q.filter(Document.title.ilike(f"%{search_text}%"))
if args.get("language"):
q = q.filter(Document.language == args["language"])
docs = q.order_by(Document.updated_at.desc()).limit(args.get("limit", 50)).all()
if not docs:
msg = "No documents found" + (f" matching '{args['search']}'" if args.get("search") else "") + "."
msg = "No documents found" + (f" matching '{search_text}'" if search_text else "") + "."
return {"response": msg, "documents": [], "exit_code": 0}
lines = []
items = []
@@ -832,6 +973,7 @@ class ManageDocumentTool:
doc = _get_owned_document(db, Document, doc_id, owner, active_only=True)
if not doc:
return {"error": f"Document '{doc_id}' not found", "exit_code": 1}
set_active_document(doc.id)
body = doc.current_content or ""
try:
preview_limit = max(1, min(int(args.get("limit", MAX_READ_CHARS)), MAX_READ_CHARS))
@@ -864,15 +1006,20 @@ class ManageDocumentTool:
}
elif action == "delete":
doc_id = args.get("document_id") or args.get("id") or args.get("uid") or _active_document_id
doc_id = args.get("document_id") or args.get("id") or args.get("uid") or ctx.get("doc_id") or _active_document_id
doc = None
if doc_id:
doc = _get_owned_document(db, Document, doc_id, owner)
if not doc:
if doc_id:
return {"error": "Requested document not found; no other document was deleted", "exit_code": 1}
# Fallback: most recently updated doc (likely what the user means)
doc = _most_recent_owned_document(db, Document, owner, active_only=True)
if not doc:
return {"error": "No document to delete", "exit_code": 1}
version_error = _approved_document_version_error(doc, ctx)
if version_error:
return version_error
title = doc.title
doc.is_active = False
db.commit()
+190 -36
View File
@@ -5,6 +5,7 @@ import re
import difflib
import fnmatch
import shutil
import tempfile
from typing import Optional, Dict, Any, Tuple, List
from src.constants import MAX_READ_CHARS, MAX_DIFF_LINES, MAX_OUTPUT_CHARS
@@ -16,6 +17,9 @@ _CODENAV_SKIP_DIRS = frozenset({
})
_CODENAV_MAX_HITS = 200
_CODENAV_MAX_LINE = 400
_STRUCTURED_DOCUMENT_SUFFIXES = frozenset({
".doc", ".docx", ".epub", ".pdf", ".pptx", ".xls", ".xlsx",
})
def _glob_to_regex(pat: str) -> "re.Pattern":
@@ -76,25 +80,33 @@ class EditFileTool:
try:
args = json.loads(content) if content.strip().startswith("{") else {}
except (json.JSONDecodeError, TypeError):
args = {}
raw_path = (args.get("path") or "").strip()
old = args.get("old_string", "")
new = args.get("new_string", "")
replace_all = bool(args.get("replace_all", False))
return {"error": "edit_file: expected valid JSON arguments", "exit_code": 1}
if not isinstance(args, dict):
return {"error": "edit_file: expected a JSON object", "exit_code": 1}
raw_path_value = args.get("path")
raw_path = raw_path_value.strip() if isinstance(raw_path_value, str) else ""
old = args.get("old_string")
new = args.get("new_string")
replace_all = args.get("replace_all", False)
if not raw_path:
return {"error": "edit_file: path required", "exit_code": 1}
if not isinstance(old, str) or not old:
return {"error": "edit_file: old_string required (use write_file to create a file)", "exit_code": 1}
if not isinstance(new, str):
return {"error": "edit_file: new_string required", "exit_code": 1}
if not isinstance(replace_all, bool):
return {"error": "edit_file: replace_all must be a boolean", "exit_code": 1}
try:
path = _resolve_tool_path(raw_path)
except ValueError as e:
return {"error": f"edit_file: {e}", "exit_code": 1}
if old == "":
return {"error": "edit_file: old_string required (use write_file to create a file)", "exit_code": 1}
if old == new:
return {"error": "edit_file: old_string and new_string are identical", "exit_code": 1}
def _apply():
"""Helper function that performs the actual string replacement and file writing logic."""
with open(path, "r", encoding="utf-8") as f:
# Exact replacement must not normalize unrelated CRLF/CR newlines.
with open(path, "r", encoding="utf-8", newline="") as f:
original = f.read()
count = original.count(old)
if count == 0:
@@ -102,7 +114,7 @@ class EditFileTool:
if count > 1 and not replace_all:
return original, None, f"not_unique:{count}"
updated = original.replace(old, new) if replace_all else original.replace(old, new, 1)
with open(path, "w", encoding="utf-8") as f:
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(updated)
return original, updated, "ok"
@@ -138,17 +150,36 @@ class ReadFileTool:
if _stripped.startswith("{"):
try:
_a = json.loads(_stripped)
raw_path = str(_a.get("path", "")).strip()
if not isinstance(_a, dict):
return {"error": "read_file: expected a JSON object", "exit_code": 1}
raw_path_value = _a.get("path")
raw_path = raw_path_value.strip() if isinstance(raw_path_value, str) else ""
offset = int(_a.get("offset") or 0)
limit = int(_a.get("limit") or 0)
except (json.JSONDecodeError, TypeError, ValueError):
pass
return {"error": "read_file: expected valid JSON arguments", "exit_code": 1}
if not raw_path:
return {"error": "read_file: path required", "exit_code": 1}
try:
path = _resolve_tool_path(raw_path)
except ValueError as e:
return {"error": f"read_file: {e}", "exit_code": 1}
try:
def _read():
if os.path.splitext(path)[1].lower() in _STRUCTURED_DOCUMENT_SUFFIXES:
from src.document_processor import extract_local_document
extracted = extract_local_document(
path,
display_name=os.path.basename(path),
analyze_embedded_images=False,
)
if offset > 0 or limit > 0:
lines = extracted.splitlines(keepends=True)
start = max(offset, 1) - 1
stop = start + limit if limit > 0 else None
return "".join(lines[start:stop])[:MAX_READ_CHARS]
return extracted[:MAX_READ_CHARS + 1]
if offset > 0 or limit > 0:
start = max(offset, 1)
out, n, budget = [], 0, MAX_READ_CHARS
@@ -196,15 +227,54 @@ class WriteFileTool:
if _stripped.startswith("{"):
try:
_a = json.loads(_stripped)
if isinstance(_a, dict) and "path" in _a:
raw_path = str(_a.get("path", "")).strip()
body = str(_a.get("content", ""))
if not isinstance(_a, dict):
return {"error": "write_file: expected a JSON object", "exit_code": 1}
raw_path_value = _a.get("path")
body_value = _a.get("content")
raw_path = raw_path_value.strip() if isinstance(raw_path_value, str) else ""
if not isinstance(body_value, str):
return {"error": "write_file: content required", "exit_code": 1}
body = body_value
except (json.JSONDecodeError, TypeError, ValueError):
pass
return {"error": "write_file: expected valid JSON arguments", "exit_code": 1}
if not raw_path:
return {"error": "write_file: path required", "exit_code": 1}
try:
path = _resolve_tool_path(raw_path)
except ValueError as e:
return {"error": f"write_file: {e}", "exit_code": 1}
# A frequent multimodal artifact failure is writing SVG markup to a
# path whose extension promises a raster image. The file exists, so
# ordinary artifact checks pass, but image judges cannot decode it.
# Reject the mismatch with an actionable native-tool recovery path:
# save the SVG with an .svg suffix, then use inspect_media to render
# it to the requested PNG/JPEG path.
image_suffixes = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
body_probe = body.lstrip().casefold()
if os.path.splitext(path)[1].casefold() in image_suffixes and (
body_probe.startswith("<svg")
or (body_probe.startswith("<?xml") and "<svg" in body_probe[:2000])
):
return {
"error": (
f"write_file: {path} contains SVG markup but has a raster "
"image extension. Write the SVG to a .svg path first, "
"then call inspect_media with that SVG as path and this "
"path as output_path to render a real raster image."
),
"exit_code": 1,
"artifact_format_error": True,
}
if not body:
return {
"error": (
f"write_file: {path}: content required; refusing to create an "
"empty file. Call write_file again with the exact filename and "
"non-empty content. If you need a directory, create it from "
"bash/python instead."
),
"exit_code": 1,
}
try:
def _write():
old = ""
@@ -280,16 +350,77 @@ class ApplyPatchTool:
new = _apply_patch_hunks(old, op["hunks"], op["path"])
prepared.append((kind, path, old, new))
staged: list[tuple[str, str]] = []
backups: list[tuple[str, str | None]] = []
try:
for kind, path, _old, new in prepared:
if kind == "delete":
continue
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, temp_path = tempfile.mkstemp(
prefix=f".{os.path.basename(path)}.odysseus-",
dir=directory,
)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
handle.write(new)
handle.flush()
os.fsync(handle.fileno())
if os.path.exists(path):
shutil.copymode(path, temp_path)
except BaseException:
try:
os.unlink(temp_path)
except OSError:
pass
raise
staged.append((path, temp_path))
for _kind, path, _old, _new in prepared:
if os.path.exists(path):
directory = os.path.dirname(path) or "."
fd, backup_path = tempfile.mkstemp(
prefix=f".{os.path.basename(path)}.odysseus-backup-",
dir=directory,
)
os.close(fd)
os.unlink(backup_path)
os.replace(path, backup_path)
backups.append((path, backup_path))
else:
backups.append((path, None))
staged_by_path = dict(staged)
for kind, path, _old, _new in prepared:
if kind != "delete":
os.replace(staged_by_path[path], path)
staged.clear()
except BaseException:
for path, backup_path in reversed(backups):
try:
if os.path.exists(path):
os.unlink(path)
if backup_path and os.path.exists(backup_path):
os.replace(backup_path, path)
except OSError:
pass
raise
finally:
for _path, temp_path in staged:
try:
os.unlink(temp_path)
except OSError:
pass
for _path, backup_path in backups:
if backup_path:
try:
os.unlink(backup_path)
except OSError:
pass
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)
for _kind, path, old, new in prepared:
diff = _unified_diff(old, new, path)
if diff:
diffs.append(diff)
@@ -407,7 +538,7 @@ def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str
class LsTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
from src.tool_execution import _display_tool_path, _resolve_search_root, _truncate
raw_path = ""
_s = (content or "").strip()
if _s.startswith("{"):
@@ -440,7 +571,7 @@ class LsTool:
except (PermissionError, OSError) as _e:
return None, f"ls: {_e}"
rows.sort(key=lambda r: (not r[0], r[1].lower()))
lines = [f"{root}:"]
lines = [f"{_display_tool_path(root)}:"]
for is_dir, name, size in rows[:_CODENAV_MAX_HITS]:
lines.append(f" {name}/" if is_dir else f" {name} ({size} B)")
if len(rows) > _CODENAV_MAX_HITS:
@@ -458,6 +589,7 @@ class GlobTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import (
_SENSITIVE_BASENAMES,
_display_tool_path,
_is_sensitive_path,
_resolve_tool_path,
_resolve_search_root,
@@ -549,8 +681,8 @@ class GlobTool:
if err:
return {"error": err, "exit_code": 1}
if not paths:
return {"output": f"No files matching {pattern!r} under {root}", "exit_code": 0}
out = "\n".join(paths)
return {"output": f"No files matching {pattern!r} under {_display_tool_path(root)}", "exit_code": 0}
out = "\n".join(_display_tool_path(path) for path in paths)
if len(paths) >= _CODENAV_MAX_HITS:
out += f"\n... [capped at {_CODENAV_MAX_HITS} files]"
return {"output": _truncate(out), "exit_code": 0}
@@ -559,6 +691,7 @@ class GrepTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import (
_SENSITIVE_FILE_PATTERNS,
_display_tool_path,
_is_sensitive_path,
_resolve_tool_path,
_resolve_search_root,
@@ -591,9 +724,11 @@ class GrepTool:
def _grep():
import re as _re
import shutil
if not os.path.exists(root):
return None, f"grep: search target not found: {_display_tool_path(root)}"
rg = shutil.which("rg")
if rg:
cmd = [rg, "--line-number", "--no-heading", "--color=never",
cmd = [rg, "--line-number", "--with-filename", "--no-heading", "--color=never",
"--max-count", str(max_hits)]
if ignore_case:
cmd.append("--ignore-case")
@@ -611,6 +746,11 @@ class GrepTool:
try:
import subprocess
p = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
# ripgrep: 0 = matches, 1 = no matches, 2 = failed scan.
# Do not present invalid patterns or IO failures as absence.
if p.returncode not in (0, 1):
detail = (p.stderr or '').strip()[:1200]
return None, f"grep: search failed (exit {p.returncode}): {detail or 'no diagnostic available'}"
lines = [ln for ln in (p.stdout or "").splitlines() if ln][:max_hits]
return lines, None
except subprocess.TimeoutExpired:
@@ -622,11 +762,12 @@ class GrepTool:
except _re.error as _e:
return None, f"grep: bad pattern: {_e}"
hits = []
scan_errors = []
if os.path.isfile(root):
file_iter = [root]
else:
file_iter = []
for dp, dns, fns in os.walk(root):
for dp, dns, fns in os.walk(root, onerror=scan_errors.append):
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
for fn in fns:
if glob_pat and not fnmatch.fnmatch(fn, glob_pat):
@@ -635,25 +776,38 @@ class GrepTool:
for fp in file_iter:
if len(hits) >= max_hits:
break
if _is_sensitive_path(os.path.realpath(fp)):
try:
resolved_file = _resolve_tool_path(fp)
except ValueError:
# Apply the same workspace/sensitive-path checks to each
# discovered file, not just the initial search directory.
continue
try:
with open(fp, "r", encoding="utf-8", errors="strict") as f:
with open(resolved_file, "r", encoding="utf-8", errors="strict") as f:
for i, line in enumerate(f, 1):
if rx.search(line):
hits.append(f"{fp}:{i}:{line.rstrip()[:_CODENAV_MAX_LINE]}")
if len(hits) >= max_hits:
break
except (UnicodeDecodeError, OSError):
except UnicodeDecodeError:
continue
except OSError as error:
scan_errors.append(error)
if scan_errors:
return None, "grep: search incomplete; one or more files or directories could not be read"
return hits, None
lines, err = await asyncio.to_thread(_grep)
if err:
return {"error": err, "exit_code": 1}
if not lines:
return {"output": f"No matches for {pattern!r} under {root}", "exit_code": 0}
out = "\n".join(ln[:_CODENAV_MAX_LINE] for ln in lines)
return {"output": f"No matches for {pattern!r} under {_display_tool_path(root)}", "exit_code": 0}
physical_root = os.path.realpath(root)
display_root = _display_tool_path(physical_root)
out = "\n".join(
(display_root + ln[len(physical_root):] if ln.startswith(physical_root) else ln)[:_CODENAV_MAX_LINE]
for ln in lines
)
if len(lines) >= max_hits:
out += f"\n... [capped at {max_hits} matches]"
return {"output": _truncate(out), "exit_code": 0}
@@ -666,7 +820,7 @@ class GetWorkspaceTool:
ws = get_active_workspace()
if ws:
return {
"output": f"{ws}\n(File tools are confined to this folder; the shell starts "
"output": "/workspace\n(File tools are confined to this folder; the shell starts "
f"here but is not sandboxed and can reach outside it.)",
"exit_code": 0,
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
"""Bounded local OCR primitives shared by Odysseus media tools."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import re
_OCR_QUERY_RE = re.compile(r"(?:\b(?:ocr|text|words?|labels?|numbers?|numbered|subtitle|receipt)\b|文字|文本|字幕|编号|数字|标签|票据)", re.I)
_NUMERIC_QUERY_RE = re.compile(r"(?:\b(?:numbers?|numbered|digits?)\b|编号|数字)", re.I)
def query_requests_ocr(query: object) -> bool:
return bool(_OCR_QUERY_RE.search(str(query or "")))
def query_requests_numbers(query: object) -> bool:
return bool(_NUMERIC_QUERY_RE.search(str(query or "")))
@lru_cache(maxsize=1)
def _engine():
try:
from rapidocr import RapidOCR
except ImportError as exc:
raise RuntimeError("local OCR requires the optional rapidocr and onnxruntime packages") from exc
return RapidOCR()
def extract_image_text(path: Path, *, include_layout: bool = False, numeric_only: bool = False,
min_confidence: float = 0.5, max_results: int = 512) -> dict:
result = _engine()(str(path))
lines, accepted = [], 0
boxes = [] if result.boxes is None else result.boxes
texts = [] if result.txts is None else result.txts
scores = [] if result.scores is None else result.scores
for box, raw_text, raw_score in zip(boxes, texts, scores):
text, score = str(raw_text).strip(), float(raw_score)
if not text or score < min_confidence or (numeric_only and not any(c.isdigit() for c in text)):
continue
accepted += 1
if len(lines) >= max_results:
continue
points = [[round(float(x), 1), round(float(y), 1)] for x, y in box]
line = {"t": text, "p": round(score, 3), "xy": [round(sum(p[0] for p in points)/len(points), 1), round(sum(p[1] for p in points)/len(points), 1)]}
if include_layout:
line["box"] = points
lines.append(line)
return {"legend": {"t": "text", "p": "confidence", "xy": "pixel center"}, "count": accepted,
"returned": len(lines), "truncated": accepted > len(lines), "lines": lines}
+30 -8
View File
@@ -40,10 +40,21 @@ async def create_session(content: str, session_id: Optional[str] = None, owner:
if not name:
return {"error": "Session name cannot be empty"}
try:
url, model, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
except ValueError as e:
return {"error": str(e)}
source = _session_manager.get_session(session_id) if session_id else None
source_owner_ok = not owner or (source and getattr(source, "owner", None) == owner)
source_model = str(getattr(source, "model", "") or "") if source_owner_ok else ""
if source_model and source_model.lower() == model_spec.lower():
# A child chat using the current model should inherit the exact working
# runtime. Re-resolving through stored endpoints can select a stale key
# even while the parent request is successfully using an override.
url = str(getattr(source, "endpoint_url", "") or "")
model = source_model
headers = dict(getattr(source, "headers", None) or {})
else:
try:
url, model, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
except ValueError as e:
return {"error": str(e)}
sid = str(uuid.uuid4())[:8]
try:
@@ -54,11 +65,14 @@ async def create_session(content: str, session_id: Optional[str] = None, owner:
model=model,
rag=False,
owner=owner,
headers=headers,
)
# Store headers on session for future calls
# Keep lightweight/fake managers and the live cache in sync with the
# atomically persisted runtime. The DB value remains authoritative on
# later metadata refreshes.
sess = _session_manager.get_session(sid)
if sess and headers:
sess.headers = headers
if sess is not None:
sess.headers = dict(headers or {})
try:
from src.event_bus import fire_event
fire_event("session_created", owner)
@@ -180,7 +194,13 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
target_sid = lines[0].strip()
message = lines[1].strip()
sess = _session_manager.get_session(target_sid)
try:
sess = _session_manager.get_session(target_sid)
except KeyError:
return {"error": f"Session '{target_sid}' not found"}
except Exception as e:
logger.warning("send_to_session failed to load session %s: %s", target_sid, e)
return {"error": f"Session '{target_sid}' could not be loaded"}
if not sess:
return {"error": f"Session '{target_sid}' not found"}
@@ -383,6 +403,8 @@ async def manage_session(content: str, session_id: Optional[str] = None, owner:
ok = _session_manager.delete_session(target_sid)
if not ok:
return {"error": f"Session '{target_sid}' was not deleted because it no longer exists."}
from routes.chat_helpers import remove_session_sft_trace_rows
remove_session_sft_trace_rows(owner, target_sid)
return {"action": "delete", "session_id": target_sid,
"results": f"Session '{db_sess.name or target_sid}' deleted"}
except Exception as e:
+631 -34
View File
@@ -1,48 +1,264 @@
import asyncio
import ast
import os
import re
import shlex
import secrets
import shutil
import subprocess
import sys
import time
import collections
import json
from typing import Optional, Callable, Awaitable, Tuple, Dict
from core.platform_compat import IS_WINDOWS, find_bash
from urllib.parse import urlparse
import httpx
from src.constants import MAX_OUTPUT_CHARS
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
# Agent shell calls must fail fast enough for the loop to recover and choose a
# better tool. A one-hour default can pin an entire benchmark worker on an
# accidental recursive scan, even though ordinary artifact commands complete
# in seconds. Long-running work belongs in manage_bg_jobs.
DEFAULT_BASH_TIMEOUT = 120
DEFAULT_PYTHON_TIMEOUT = 60 * 60
PROGRESS_INTERVAL_S = 2.0
PROGRESS_TAIL_LINES = 12
TMUX_CAPTURE_LINES = 2000
_HOST_SHELL_BRIDGE_HOSTS = {"127.0.0.1", "localhost", "::1", "host.docker.internal"}
IS_WINDOWS = sys.platform.startswith("win")
_HOST_SHELL_CANCEL_TASKS: set[asyncio.Task] = set()
async def _create_bash_subprocess(command: str, **kwargs):
"""Start the agent shell with Bash semantics on every supported OS.
def _ffmpeg_unicode_drawtext_needs_fontfile(command: str) -> bool:
"""Require a deliberate font for non-ASCII text rendered by ffmpeg.
``asyncio.create_subprocess_shell`` delegates to ``cmd.exe`` on native
Windows. That contradicts the Bash tool contract and makes POSIX commands
such as ``pwd``, ``ls -la``, and ``cat`` unreliable even when the launcher
has found Git Bash. Pass the selected workspace as a structural ``cwd``
argument; Git Bash inherits that native Windows directory and exposes it
using its normal ``/c/...`` representation.
Fontconfig's fallback is platform-dependent and commonly resolves to a
font without the requested glyphs. An explicit ``fontfile`` makes the
rendered artifact portable and prevents successful commands that produce
tofu boxes instead of text.
"""
text = str(command or "")
lowered = text.lower()
return (
bool(re.search(r"\bffmpeg\b", lowered))
and "drawtext" in lowered
and "fontfile" not in lowered
and any(ord(char) > 127 for char in text)
)
def _resolve_fontfile_for_text(text: str) -> str:
"""Resolve a host font covering the first requested non-ASCII codepoint."""
codepoint = next((ord(char) for char in str(text or "") if ord(char) > 127), None)
matcher = shutil.which("fc-match")
if codepoint is None or not matcher:
return ""
try:
completed = subprocess.run(
[matcher, "-f", "%{file}", f":charset={codepoint:04x}"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
except (OSError, subprocess.SubprocessError):
return ""
candidate = str(completed.stdout or "").strip().splitlines()[0:1]
if completed.returncode != 0 or not candidate:
return ""
path = candidate[0].strip()
return path if os.path.isfile(path) else ""
async def _cancel_host_shell_bridge_request(
url: str, token: str, request_id: str,
) -> None:
base = url.rsplit("/", 1)[0]
try:
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
async with httpx.AsyncClient(timeout=timeout) as client:
await client.post(
f"{base}/cancel",
json={"request_id": request_id},
headers={"X-Odysseus-TUI-Bridge-Token": token},
)
except Exception:
pass
def find_bash() -> Optional[str]:
"""Find a real Bash executable for native Windows agent runs."""
candidates = [
shutil.which("bash"),
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
]
return next((path for path in candidates if path and os.path.isfile(path)), None)
async def _create_bash_subprocess(
command: str,
*,
cwd: Optional[str] = None,
env: Optional[dict] = None,
):
"""Create Bash structurally, avoiding cmd.exe and stray Windows tmux."""
if IS_WINDOWS:
bash = find_bash()
if not bash:
raise RuntimeError(
"Git Bash is required for the Bash tool on Windows; "
"install Git for Windows and restart Odysseus"
"Git Bash is required for the Bash tool on Windows; install Git for Windows."
)
return await asyncio.create_subprocess_exec(bash, "-c", command, **kwargs)
return await asyncio.create_subprocess_exec(
bash,
"-c",
str(command or ""),
cwd=cwd,
)
kwargs = {"cwd": cwd} if cwd is not None else {}
return await asyncio.create_subprocess_shell(command, **kwargs)
def _host_shell_requires_detach(command: str) -> bool:
"""Recognize commands that must not block an interactive agent turn.
Models occasionally omit ``detach`` even after the host-shell contract
tells them to poll long jobs. Keep the normal synchronous path for short
commands, but make explicit background markers and clearly long sleeps
deterministic so the bridge returns a job id instead of holding the SSE
stream open.
"""
text = str(command or "").strip()
if not text:
return False
first = next((line.strip().lower() for line in text.splitlines() if line.strip()), "")
if first in {"#!bg", "#bg", "# bg", "#background", "# background", "@background", "# @background"}:
return True
match = re.search(r"\bsleep\s+(\d+(?:\.\d+)?)\b", text, re.IGNORECASE)
if match:
try:
return float(match.group(1)) >= 20
except ValueError:
return False
return False
def _host_shell_should_auto_poll(command: str) -> bool:
"""Poll implicit long-sleep jobs so a false completion cannot escape."""
text = str(command or "").lower()
if not _host_shell_requires_detach(command):
return False
return not any(
marker in text
for marker in ("#!bg", "#bg", "# bg", "#background", "# background", "@background")
)
def _docker_default_gateway_ips() -> set[str]:
gateways: set[str] = set()
try:
with open("/proc/net/route", "r", encoding="utf-8", errors="ignore") as fh:
for line in fh.readlines()[1:]:
parts = line.split()
if len(parts) < 3 or parts[1] != "00000000":
continue
raw = parts[2]
if len(raw) != 8:
continue
octets = [str(int(raw[i:i + 2], 16)) for i in range(6, -1, -2)]
gateways.add(".".join(octets))
except Exception:
return set()
return gateways
def _is_private_bridge_ip(host: str) -> bool:
"""LAN + CGNAT/Tailscale (100.64.0.0/10) literal IPs — the ranges a remote
TUI legitimately advertises when the backend is reachable over the LAN or
Tailscale. The 172.16/12 docker-private range is deliberately EXCLUDED:
on a container host those addresses are neighboring containers, not the
TUI — only the actual default gateway (checked separately) is trusted."""
parts = host.split(".")
if len(parts) != 4 or not all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
return False
a, b = int(parts[0]), int(parts[1])
if a == 10:
return True
if a == 192 and b == 168:
return True
if a == 100 and 64 <= b <= 127:
return True
return False
def is_host_shell_bridge_url_allowed(url: str) -> bool:
parsed = urlparse(str(url or "").strip())
host = (parsed.hostname or "").strip().lower().rstrip(".")
if parsed.scheme != "http" or not parsed.netloc or parsed.username or parsed.password:
return False
if (
host not in _HOST_SHELL_BRIDGE_HOSTS
and host not in _docker_default_gateway_ips()
and not _is_private_bridge_ip(host)
):
return False
if parsed.path not in ("", "/run"):
return False
if parsed.query or parsed.fragment:
return False
return True
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'}"
def _replace_workspace_alias(content: str, cwd: str) -> str:
"""Map virtual /workspace paths without corrupting absolute host paths."""
return re.sub(
r"(^|[\s'\"=:(\[,])/workspace(?=$|[/\s'\"`),;\]])",
lambda match: match.group(1) + cwd,
str(content or ""),
)
def _wrap_workspace_namespace(
content: str,
cwd: str,
*,
chdir: str = "/workspace",
) -> str | None:
"""Run a shell command with the active workspace mounted at /workspace.
Rewriting the command line alone is insufficient when a generated Python
script itself contains paths such as ``/workspace/chart.png``. A small
bubblewrap namespace preserves that public contract for each concurrent
agent without creating a process-global /workspace symlink.
"""
if IS_WINDOWS or not shutil.which("bwrap"):
return None
args = [
"bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/",
"--dir", "/usr", "--ro-bind", "/usr", "/usr",
"--symlink", "usr/bin", "/bin",
"--symlink", "usr/lib", "/lib",
"--symlink", "usr/lib64", "/lib64",
"--symlink", "usr/bin", "/sbin",
"--dir", "/etc", "--ro-bind", "/etc", "/etc",
"--dir", "/home", "--bind", "/home", "/home",
"--dir", "/mnt", "--bind", "/mnt", "/mnt",
"--dir", "/tmp", "--tmpfs", "/tmp",
"--dev-bind", "/dev", "/dev", "--proc", "/proc",
"--dir", "/workspace", "--bind", cwd, "/workspace",
"--chdir", chdir, "/bin/bash", "-lc", content,
]
return shlex.join(args)
async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]:
proc = await asyncio.create_subprocess_exec(
*args,
@@ -84,12 +300,30 @@ async def _tmux_send_line(name: str, line: str) -> None:
async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None:
# tmux creates child panes from the long-lived server environment, not
# necessarily from the app process that issued ``new-session``. On hosts
# where tmux predates the Odysseus virtualenv this silently resolves
# ``python`` to the system interpreter, losing plotting/PDF dependencies
# and prompting futile pip-install loops. Reassert the small execution
# environment on both new and reused panes.
forwarded_env = {
key: str(env[key])
for key in ("PATH", "VIRTUAL_ENV", "HOME", "TMPDIR")
if env and env.get(key)
}
if await _tmux_has_session(name):
if forwarded_env:
exports = " ".join(
f"{key}={shlex.quote(value)}" for key, value in forwarded_env.items()
)
await _tmux_send_line(name, f"export {exports}")
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
return
env_args = [f"{key}={value}" for key, value in forwarded_env.items()]
await _run_exec(
"tmux", "new-session", "-d", "-s", name, "-c", cwd,
"env",
*env_args,
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'}",
@@ -145,9 +379,14 @@ async def _run_tmux_bash(
stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}"
start_marker = f"__ODYSSEUS_CMD_START_{stamp}__"
end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:"
# Execute each tool call in a non-interactive child shell. The tmux pane
# is deliberately persistent, but handing its terminal stdin to commands
# lets programs such as ffmpeg block forever on overwrite prompts. EOF is
# the deterministic behavior expected from an agent tool invocation.
child_command = f"/bin/bash -lc {shlex.quote(content)} </dev/null"
wrapped = (
f"printf '\\n{start_marker}\\n'\n"
f"{content}\n"
f"{child_command}\n"
f"__ody_rc=$?\n"
f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n"
)
@@ -179,6 +418,14 @@ async def _run_tmux_bash(
await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3)
except Exception:
pass
# Ctrl-C targets the pane's foreground process group, but a child
# can outlive its wrapper shell and become an orphan. Destroy this
# task-scoped session as the timeout boundary; the next tool call
# recreates it through _ensure_tmux_session.
try:
await _run_exec("tmux", "kill-session", "-t", name, timeout=3)
except Exception:
pass
cleaned = _clean_tmux_command_output(body, wrapped)
return cleaned, "", 124, True
await asyncio.sleep(0.5)
@@ -299,13 +546,58 @@ class BashTool:
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 "")
content = str(content or "").strip()
if not content:
return {
"error": "bash: command is required; no command was executed",
"exit_code": 1,
}
if re.search(r"(?:^|[;&|]\s*)sudo\b|^\s*sudo\b", content, re.IGNORECASE):
return {
"error": "bash: sudo/privilege escalation is unavailable in agent execution",
"exit_code": 1,
}
if re.search(r"\b(?:curl|wget)\b[^\n]*https?://", content, re.IGNORECASE):
return {
"error": (
"bash: ad-hoc HTTP downloads are disabled when native web tools are "
"available. Use pdf_extract for online PDFs, web_fetch for a concrete "
"page, or web_search for discovery. For PDF extraction "
"tasks, treat pdf_extract as the download+scan step: extract the "
"requested values, then create the requested output artifacts directly "
"from that evidence instead of trying curl/wget again."
),
"exit_code": 1,
}
if _ffmpeg_unicode_drawtext_needs_fontfile(content):
resolved_font = _resolve_fontfile_for_text(content)
resolved_hint = (
f" Host fontconfig resolved a covering font at `{resolved_font}`; "
f"pass `fontfile={resolved_font}`."
if resolved_font
else ""
)
return {
"error": (
"bash: ffmpeg drawtext with non-ASCII text requires an explicit "
"fontfile to avoid missing-glyph boxes."
+ resolved_hint
+ " If needed, resolve another suitable installed font with "
"`fc-match -f '%{file}' ':charset=<hex-codepoint>'`, then pass that "
"path as `drawtext=fontfile=...` and rerun the command."
),
"exit_code": 1,
}
isolated_tmp = os.path.join(agent_cwd(), ".tmp")
if "/tmp/" in content:
os.makedirs(isolated_tmp, exist_ok=True)
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
namespaced = _wrap_workspace_namespace(content, agent_cwd())
content = namespaced or _replace_workspace_alias(content, agent_cwd())
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
session_id = ctx.get("session_id")
# tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on
# native Windows must not bypass the Git Bash launcher below: the tmux
# setup hard-codes /bin/bash and cannot safely consume a native cwd.
if session_id and not IS_WINDOWS and shutil.which("tmux"):
if not IS_WINDOWS and session_id and shutil.which("tmux"):
stdout, stderr, rc, timed_out = await _run_tmux_bash(
content,
session_id=str(session_id),
@@ -316,7 +608,7 @@ class BashTool:
)
if timed_out:
return {
"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — sent Ctrl-C to tmux session",
"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — terminated task shell session",
"exit_code": 124,
"stdout": _truncate(stdout, MAX_OUTPUT_CHARS),
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
@@ -333,15 +625,25 @@ class BashTool:
}
try:
proc = await _create_bash_subprocess(
content,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
except RuntimeError as e:
return {"error": f"bash: {e}", "exit_code": 1}
if IS_WINDOWS:
proc = await _create_bash_subprocess(
content,
cwd=agent_cwd(),
env=_subproc_env,
)
else:
# Preserve the existing captured POSIX path; the structural
# helper is primarily needed to avoid cmd.exe on Windows.
proc = await asyncio.create_subprocess_shell(
content,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
except RuntimeError as exc:
return {"error": str(exc), "exit_code": 1}
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc,
timeout=DEFAULT_BASH_TIMEOUT,
@@ -356,18 +658,302 @@ class BashTool:
output = _truncate(output, MAX_OUTPUT_CHARS)
return {"output": output or "(no output)", "exit_code": rc or 0}
class HostShellTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import _truncate
try:
args = json.loads(content) if str(content or "").strip().startswith("{") else {}
except Exception:
args = {}
command = str(
args.get("command")
or args.get("cmd")
or (content if not args else "")
or ""
).strip()
runtime = ctx.get("client_runtime_context")
if not isinstance(runtime, dict):
return {"error": "host_shell: no TUI host bridge advertised", "exit_code": 1}
bridge = runtime.get("host_shell_bridge") or runtime.get("hostShellBridge")
if not isinstance(bridge, dict):
return {"error": "host_shell: no TUI host bridge advertised", "exit_code": 1}
url = str(bridge.get("url") or "").strip()
token = str(bridge.get("token") or "").strip()
parsed = urlparse(url)
if not is_host_shell_bridge_url_allowed(url):
return {"error": "host_shell: invalid bridge URL", "exit_code": 1}
if not token:
return {"error": "host_shell: bridge token missing", "exit_code": 1}
job_id = str(args.get("job_id") or "").strip()
if not command and not job_id:
return {"error": "host_shell: command or job_id required", "exit_code": 1}
try:
requested_timeout = int(args.get("timeout") or 30)
except Exception:
requested_timeout = 30
timeout = max(1, min(requested_timeout, 120))
request_body: dict[str, object] = {"timeout": timeout}
request_id = ""
if job_id:
request_body["job_id"] = job_id
else:
request_body["command"] = command
if bool(args.get("detach")) or _host_shell_requires_detach(command):
request_body["detach"] = True
else:
request_id = secrets.token_urlsafe(18)
request_body["request_id"] = request_id
try:
async with httpx.AsyncClient(timeout=timeout + 5) as client:
resp = await client.post(
url,
json=request_body,
headers={"X-Odysseus-TUI-Bridge-Token": token},
)
if resp.status_code >= 400:
return {
"error": f"host_shell: bridge returned HTTP {resp.status_code}",
"exit_code": 1,
}
data = resp.json()
# A long command may be detached even when the model omitted
# the flag. Complete that implicit job at the transport layer
# so the model cannot report success from a mere start ack.
if (
not job_id
and _host_shell_should_auto_poll(command)
and isinstance(data, dict)
and data.get("job_id")
and data.get("status") == "running"
):
auto_job_id = str(data["job_id"])
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
await asyncio.sleep(0.25)
poll = await client.post(
url,
json={"job_id": auto_job_id},
headers={"X-Odysseus-TUI-Bridge-Token": token},
)
if poll.status_code >= 400:
return {
"error": f"host_shell: bridge returned HTTP {poll.status_code}",
"exit_code": 1,
}
data = poll.json()
if not isinstance(data, dict):
continue
# A bridge may briefly lose the job record while its
# detached worker is being registered. Keep polling;
# do not turn that transient state into exit code 1.
if data.get("status") in {"running", "unknown"}:
continue
if data.get("status") != "running":
break
if isinstance(data, dict) and data.get("status") in {"running", "unknown"}:
data = {
**data,
"status": "running",
"detached": True,
"job_id": auto_job_id,
"output": "host job still running; poll the returned job_id",
"exit_code": 0,
}
except asyncio.CancelledError:
if request_id:
task = asyncio.create_task(
_cancel_host_shell_bridge_request(url, token, request_id),
name=f"cancel-host-shell-{request_id[:24]}",
)
_HOST_SHELL_CANCEL_TASKS.add(task)
task.add_done_callback(_HOST_SHELL_CANCEL_TASKS.discard)
raise
except Exception as e:
return {"error": f"host_shell: bridge call failed: {e}", "exit_code": 1}
if not isinstance(data, dict):
return {"error": "host_shell: bridge returned invalid payload", "exit_code": 1}
if data.get("error"):
return {
"error": _truncate(str(data["error"]), MAX_OUTPUT_CHARS),
"exit_code": 1,
"host_bridge": "tui",
}
stdout = str(data.get("stdout") or data.get("output") or "")
stderr = str(data.get("stderr") or "")
raw_exit_code = data.get("exit_code")
if raw_exit_code is None:
raw_exit_code = data.get("returncode")
if raw_exit_code is None:
raw_exit_code = 0
if isinstance(raw_exit_code, bool) or not isinstance(raw_exit_code, int):
return {
"error": "host_shell: bridge returned an invalid exit_code",
"exit_code": 1,
"host_bridge": "tui",
}
exit_code = raw_exit_code
output = stdout.rstrip()
if stderr.strip():
output = (output + "\nSTDERR: " + stderr.strip()).strip() if output else "STDERR: " + stderr.strip()
result = {
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
"exit_code": exit_code,
"host_bridge": "tui",
}
for key in ("detached", "job_id", "status", "running", "finished", "cwd"):
if key in data:
result[key] = data[key]
return result
def _python_child_runtime_failure(stdout: str, stderr: str, returncode: int) -> str:
"""Return an unmistakable nested-runtime failure hidden by Python exit 0.
Libraries such as Pillow may spawn a viewer and then return normally even
when that child cannot display anything. Keep this deliberately narrow:
arbitrary stderr is often a warning and must not turn a successful data
transformation into a failed tool call.
"""
if returncode != 0 or str(stdout or "").strip():
return ""
err = str(stderr or "").strip()
if re.search(r"(?im)^xdg-open: no method available for opening\b", err):
return err
return ""
def _python_with_visible_final_expression(content: str) -> str:
"""Give the Python tool REPL-like visibility for one final bare value.
The code still runs once as a normal script. Only a final expression is
assigned and rendered; explicit print calls and statement-only programs
retain their historical behavior.
"""
try:
tree = ast.parse(content)
except SyntaxError:
return content
if not tree.body or not isinstance(tree.body[-1], ast.Expr):
return content
final = tree.body[-1]
if (
isinstance(final.value, ast.Call)
and isinstance(final.value.func, ast.Name)
and final.value.func.id == "print"
):
return content
result_name = "__odysseus_final_expression_value__"
tree.body[-1:] = [
ast.Assign(targets=[ast.Name(id=result_name, ctx=ast.Store())], value=final.value),
ast.If(
test=ast.Compare(
left=ast.Name(id=result_name, ctx=ast.Load()),
ops=[ast.IsNot()],
comparators=[ast.Constant(value=None)],
),
body=[ast.Expr(value=ast.Call(
func=ast.Name(id="print", ctx=ast.Load()),
args=[ast.Call(
func=ast.Name(id="repr", ctx=ast.Load()),
args=[ast.Name(id=result_name, ctx=ast.Load())],
keywords=[],
)],
keywords=[],
))],
orelse=[],
),
]
ast.fix_missing_locations(tree)
return ast.unparse(tree)
class PythonTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import agent_cwd, _truncate
if re.search(
r"\b(?:requests\.(?:get|post|put|delete|request)|urllib\.request(?:\.\w+)?|httpx\.(?:get|post|request))\s*\(",
content,
re.IGNORECASE,
) and re.search(r"https?://", content, re.IGNORECASE) or (
re.search(r"[\"'](?:curl|wget)[\"']", content, re.IGNORECASE)
and re.search(r"https?://", content, re.IGNORECASE)
):
return {
"error": (
"python: ad-hoc HTTP access is disabled when native web tools are "
"available. Use pdf_extract for online PDFs, web_fetch for a concrete "
"page, or web_search for discovery. For PDF extraction "
"tasks, treat pdf_extract as the download+scan step: extract the "
"requested values, then create the requested output artifacts directly "
"from that evidence instead of trying requests/urllib again."
),
"exit_code": 1,
}
# Only create a mount namespace when the submitted code actually
# relies on the public virtual path. Ordinary Python probes and
# scripts should retain the real workspace as os.getcwd(); wrapping
# every invocation would make that stable contract appear as
# ``/workspace`` instead.
needs_virtual_namespace = bool(
"/workspace" in content
or re.search(r"\b(?:runpy\.run_path|exec\s*\(|importlib\.)", content)
)
isolated_tmp = os.path.join(agent_cwd(), ".tmp")
if "/tmp/" in content:
os.makedirs(isolated_tmp, exist_ok=True)
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
proc = await asyncio.create_subprocess_exec(
(sys.executable or "python"), "-I", "-c", content,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
# Generated scripts commonly contain the public `/workspace/...`
# paths shown in the tool contract. Rewriting the inline `-c` body
# cannot repair paths embedded in a script loaded via `runpy`, and a
# process-global `/workspace` symlink would break concurrent tasks.
# Give Python the same per-task namespace Bash receives so both inline
# code and loaded scripts see the stable virtual workspace root.
namespaced_content = _python_with_visible_final_expression(content)
python_command = shlex.join((sys.executable or "python", "-I", "-c", namespaced_content))
# Code that explicitly uses the public /workspace path runs inside a
# namespace whose stable cwd is that same bind. Host workspaces under
# /tmp or another unbound parent are intentionally invisible by their
# real path inside the namespace; trying to chdir there makes otherwise
# valid native Python fail before execution.
namespaced = (
_wrap_workspace_namespace(
python_command,
agent_cwd(),
chdir="/workspace",
)
if needs_virtual_namespace
else None
)
if namespaced:
proc = await asyncio.create_subprocess_exec(
"/bin/bash", "-lc", namespaced,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
else:
# Platforms without a usable namespace still receive the same
# alias contract through a conservative source rewrite.
content = _python_with_visible_final_expression(
_replace_workspace_alias(content, agent_cwd())
)
proc = await asyncio.create_subprocess_exec(
(sys.executable or "python"), "-I", "-c", content,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc,
timeout=DEFAULT_PYTHON_TIMEOUT,
@@ -375,6 +961,17 @@ class PythonTool:
)
if timed_out:
return {"error": f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)}
child_failure = _python_child_runtime_failure(stdout, stderr, rc)
if child_failure:
return {
"error": _truncate(
"python: a child operation failed despite a zero Python exit "
"status:\n" + child_failure,
MAX_OUTPUT_CHARS,
),
"exit_code": 1,
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
}
output = stdout.rstrip()
err = stderr.rstrip()
if err:
File diff suppressed because it is too large Load Diff