mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 16:02:20 +02:00
Squash Odysseus development history
This commit is contained in:
+15
-45
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+3135
-14
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user