mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 23:02:23 +02:00
Merge commit 'refs/phase3/pre-ajax/publication-tip' into integration/pre-ajax-release
# Conflicts: # routes/chat_routes.py # routes/session_routes.py # src/agent_loop.py # src/agent_tools/filesystem_tools.py # src/teacher_escalation.py # src/tool_capabilities.py # src/tool_execution.py # tests/test_mcp_add_server_args_validation.py # tests/test_token_cache_atomic_swap.py
This commit is contained in:
+17
-45
@@ -12,15 +12,16 @@ 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 .weather_tools import WeatherTool
|
||||
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 +37,17 @@ from .admin_tools import (
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": BashTool().execute,
|
||||
"host_shell": HostShellTool().execute,
|
||||
"python": PythonTool().execute,
|
||||
"web_search": WebSearchTool().execute,
|
||||
"get_weather": WeatherTool().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 +80,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,9 @@ 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": "document_writing_style", "document writing style": "document_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 +703,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 +712,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"],
|
||||
|
||||
@@ -44,6 +44,19 @@ def _status_label(rec: Dict[str, Any]) -> str:
|
||||
return status
|
||||
|
||||
|
||||
def job_lifecycle_facts(rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Typed lifecycle facts from the exact admitted job record.
|
||||
|
||||
Execution evidence only: completion of a job is not verification of any
|
||||
filesystem, service or external state its command was meant to change.
|
||||
"""
|
||||
code = rec.get("exit_code")
|
||||
return {"status": rec.get("status") if rec.get("status") in {"running", "done", "failed"} else "unknown",
|
||||
"exit_code": code if type(code) is int else None,
|
||||
"timed_out": rec.get("timed_out") is True, "killed": rec.get("killed") is True,
|
||||
"died": rec.get("died") is True}
|
||||
|
||||
|
||||
def _row(rec: Dict[str, Any]) -> str:
|
||||
cmd = (rec.get("command") or "").strip().splitlines()[0][:80]
|
||||
return f"[{rec.get('id')}] {_status_label(rec)} | {_age(rec)} | {cmd}"
|
||||
@@ -67,8 +80,20 @@ class ManageBgJobsTool:
|
||||
if not session_id:
|
||||
return {"error": "manage_bg_jobs: no active chat session; background jobs are scoped to a chat.", "exit_code": 1}
|
||||
|
||||
from src.agent_runtime.process_resources import active_process_operation, expected_job, require_process_admission
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
bound = active_process_operation()
|
||||
if bound is None or (bound.owner, bound.thread_id) != (str(ctx.get("owner") or "").strip().casefold(), session_id):
|
||||
return {"error": "manage_bg_jobs: no exact server resource binding", "exit_code": 1,
|
||||
"blocked": True, "failure_kind": "resource_identity_denied"}
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
if bound.operation != ExactOperation.normalize("manage_bg_jobs", raw or "{}"):
|
||||
return {"error": "Job operation changed at producer entry", "exit_code": 1, "blocked": True}
|
||||
require_process_admission(bound)
|
||||
|
||||
if action in _LIST_ACTIONS:
|
||||
jobs: List[Dict[str, Any]] = bg_jobs.list_for_session(session_id)
|
||||
bound.validate()
|
||||
jobs: List[Dict[str, Any]] = [bg_jobs.peek(j.job_id) for j in bound.jobs]
|
||||
if not jobs:
|
||||
return {"output": "No background jobs in this chat.", "exit_code": 0}
|
||||
jobs.sort(key=lambda r: r.get("started_at") or 0, reverse=True)
|
||||
@@ -78,21 +103,31 @@ class ManageBgJobsTool:
|
||||
if action in _OUTPUT_ACTIONS or action in _KILL_ACTIONS:
|
||||
if not job_id:
|
||||
return {"error": f"manage_bg_jobs: action '{action}' requires a job_id (see action='list').", "exit_code": 1}
|
||||
rec = bg_jobs.get(job_id)
|
||||
try:
|
||||
resource = expected_job(job_id, action=action)
|
||||
rec = bg_jobs.get(job_id, expected=resource)
|
||||
except (ResourceIdentityError, OSError, ValueError) as error:
|
||||
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
|
||||
# Scope: only the chat that launched a job may see or control it.
|
||||
if rec is None or rec.get("session_id") != session_id:
|
||||
return {"error": f"manage_bg_jobs: no background job '{job_id}' in this chat.", "exit_code": 1}
|
||||
|
||||
if action in _KILL_ACTIONS:
|
||||
if rec.get("status") != "running":
|
||||
return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0}
|
||||
killed = bg_jobs.kill(job_id)
|
||||
return {"output": f"Killed background job `{job_id}` ({(killed or {}).get('command', '').splitlines()[0][:80]}).", "exit_code": 0}
|
||||
return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0,
|
||||
"job": job_lifecycle_facts(rec)}
|
||||
killed = bg_jobs.kill(job_id, expected=resource)
|
||||
if not killed or not killed.get("killed"):
|
||||
return {"error": f"Could not verify termination of background job `{job_id}`.",
|
||||
"exit_code": 1, "teardown": (killed or {}).get("teardown")}
|
||||
return {"output": f"Killed background job `{job_id}` ({(killed or {}).get('command', '').splitlines()[0][:80]}).", "exit_code": 0,
|
||||
"job": job_lifecycle_facts(killed)}
|
||||
|
||||
out = rec.get("output") or "(no output yet)"
|
||||
return {
|
||||
"output": f"Job `{job_id}` [{_status_label(rec)}, {_age(rec)}]\nCommand: {rec.get('command')}\n\nOutput:\n{out}",
|
||||
"exit_code": 0,
|
||||
"job": job_lifecycle_facts(rec),
|
||||
}
|
||||
|
||||
return {"error": f"manage_bg_jobs: unknown action '{action}'. Use list, output, or kill.", "exit_code": 1}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
import hashlib
|
||||
import html
|
||||
import difflib
|
||||
import logging
|
||||
import re
|
||||
from src.constants import MAX_READ_CHARS
|
||||
@@ -9,6 +12,38 @@ from src.upload_handler import reserve_upload_references
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DOCUMENT_SEARCH_STOPWORDS = frozenset({
|
||||
'a', 'an', 'and', 'any', 'about', 'document', 'documents', 'for', 'in',
|
||||
'my', 'of', 'on', 'or', 'plans', 'the', 'to',
|
||||
})
|
||||
|
||||
|
||||
def _document_search_tokens(value: str) -> list[str]:
|
||||
return [
|
||||
token for token in re.findall(r'[a-z0-9]+', str(value or '').lower())
|
||||
if token not in _DOCUMENT_SEARCH_STOPWORDS
|
||||
]
|
||||
|
||||
|
||||
def _rank_document_search(docs, search_text: str):
|
||||
"""Prefer phrase/all-term matches, then broaden to any meaningful term."""
|
||||
query = str(search_text or '').strip().lower()
|
||||
terms = _document_search_tokens(query)
|
||||
scored = []
|
||||
for position, doc in enumerate(docs):
|
||||
haystack = ' '.join((
|
||||
str(getattr(doc, 'title', '') or ''),
|
||||
str(getattr(doc, 'current_content', '') or ''),
|
||||
)).lower()
|
||||
haystack_terms = set(_document_search_tokens(haystack))
|
||||
matched = sum(term in haystack_terms for term in terms)
|
||||
strict = bool(query and query in haystack) or bool(terms and matched == len(terms))
|
||||
scored.append((doc, strict, matched, position))
|
||||
strict_matches = [row for row in scored if row[1]]
|
||||
candidates = strict_matches or [row for row in scored if row[2] > 0]
|
||||
return [row[0] for row in sorted(candidates, key=lambda row: (-row[2], row[3]))]
|
||||
|
||||
|
||||
def _missing_document_upload(owner: Optional[str], content: Any) -> Optional[str]:
|
||||
"""Reserve explicit upload URLs before an agent persists document text."""
|
||||
return reserve_upload_references(get_upload_handler(), owner, content)
|
||||
@@ -254,17 +289,36 @@ 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|REPLACE_ALL)>>>[ \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)})
|
||||
edit = {"find": m.group(1), "replace": m.group(3)}
|
||||
if m.group(2) == 'REPLACE_ALL':
|
||||
edit['replace_all'] = True
|
||||
edits.append(edit)
|
||||
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 +338,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 +514,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 +646,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
|
||||
@@ -513,6 +665,12 @@ class UpdateDocumentTool:
|
||||
if is_email_doc:
|
||||
doc.language = "email"
|
||||
|
||||
if new_content == (doc.current_content or ""):
|
||||
return {
|
||||
"error": "No update applied — replacement content is unchanged",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
missing_id = _missing_document_upload(owner, new_content)
|
||||
if missing_id:
|
||||
return {
|
||||
@@ -557,6 +715,74 @@ class UpdateDocumentTool:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _document_find_contexts(content, find):
|
||||
"""Give a failed caller exact contextual anchors instead of a blind retry."""
|
||||
contexts = []
|
||||
for index, match in enumerate(re.finditer(re.escape(find), content)):
|
||||
if index >= 3:
|
||||
break
|
||||
start = content.rfind('\n', 0, match.start()) + 1
|
||||
end = content.find('\n', match.end())
|
||||
end = len(content) if end < 0 else end
|
||||
# Rich-text paragraphs are often stored on one HTML line.
|
||||
for tag in ('p', 'div'):
|
||||
paragraph = content.rfind(f'<{tag}', 0, match.start())
|
||||
close = content.find(f'</{tag}>', match.end())
|
||||
if paragraph >= 0 and close >= 0 and close + len(tag) + 3 - paragraph <= 1200:
|
||||
start, end = paragraph, close + len(tag) + 3
|
||||
break
|
||||
context = content[start:end]
|
||||
if len(context) <= 1200 and content.count(context) == 1:
|
||||
contexts.append(context)
|
||||
return '\nExact unique anchors from the current document:\n' + '\n'.join(contexts) if contexts else ''
|
||||
|
||||
|
||||
def _document_find_repair_hint(content, find, count):
|
||||
"""Offer bounded exact source text for an unmatched or ambiguous edit."""
|
||||
if isinstance(count, int) and count > 1:
|
||||
return _document_find_contexts(content, find)[:900]
|
||||
if not isinstance(count, int) or count != 0:
|
||||
return ''
|
||||
# Minified markup may be one enormous line. Parse tag boundaries instead
|
||||
# of comparing a small FIND against that entire line. This is evidence for
|
||||
# a corrected call, never permission to apply a fuzzy replacement.
|
||||
if find.lstrip().startswith('<'):
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class SourceTags(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.tags = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
raw = self.get_starttag_text()
|
||||
if raw and len(raw) <= 1000:
|
||||
self.tags.append(raw)
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
self.handle_starttag(tag, attrs)
|
||||
|
||||
parser = SourceTags()
|
||||
parser.feed(content)
|
||||
matches = difflib.get_close_matches(find, list(dict.fromkeys(parser.tags)), n=2, cutoff=0.7)
|
||||
if matches:
|
||||
return 'Copy an exact source fragment into FIND (including its spacing and quotes): ' + ' | '.join(
|
||||
repr(match) for match in matches)
|
||||
if re.fullmatch(r"[\w'-]{3,40}", find):
|
||||
words = re.findall(r"[\w'-]{3,40}", content)
|
||||
by_lower = {word.casefold(): word for word in words}
|
||||
matches = difflib.get_close_matches(find.casefold(), by_lower, n=5, cutoff=0.6)
|
||||
if matches:
|
||||
return 'Closest words actually in the document: ' + ', '.join(
|
||||
repr(by_lower[word]) for word in matches)
|
||||
paragraphs = re.findall(r'<(?:p|div)\b[^>]*>.*?</(?:p|div)>', content, re.S | re.I)
|
||||
if not paragraphs:
|
||||
paragraphs = content.splitlines()
|
||||
candidates = [p for p in paragraphs if len(p) <= 500]
|
||||
matches = difflib.get_close_matches(find, candidates, n=2, cutoff=0.4)
|
||||
return 'Closest exact passages in the document: ' + ' | '.join(matches) if matches else ''
|
||||
|
||||
|
||||
class EditDocumentTool:
|
||||
async def execute(self, content: str, ctx: dict) -> Dict:
|
||||
"""Apply targeted FIND/REPLACE edits to an existing document."""
|
||||
@@ -582,6 +808,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)
|
||||
@@ -639,30 +867,66 @@ class EditDocumentTool:
|
||||
return {"error": "No edits applied — FIND text cannot be blank"}
|
||||
|
||||
updated_content = doc.current_content
|
||||
applied = 0
|
||||
skipped = 0
|
||||
for edit in edits:
|
||||
_find = edit["find"]
|
||||
if _find in updated_content:
|
||||
updated_content = updated_content.replace(_find, edit["replace"], 1)
|
||||
applied += 1
|
||||
else:
|
||||
# Defensive: the active-doc context shows a "N\t" line-number
|
||||
# gutter for reference. Weaker models sometimes copy that prefix
|
||||
# into FIND. If the exact match failed, retry with a leading
|
||||
# "<digits><tab>" stripped from each FIND line — but only use it
|
||||
# when that stripped form actually matches, so we never corrupt a
|
||||
# legitimately tab-prefixed document.
|
||||
_stripped = "\n".join(re.sub(r"^\d+\t", "", _l) for _l in _find.split("\n"))
|
||||
if _stripped != _find and _stripped in updated_content:
|
||||
updated_content = updated_content.replace(_stripped, edit["replace"], 1)
|
||||
applied += 1
|
||||
logger.info("edit_document: matched after stripping line-number gutter from FIND")
|
||||
else:
|
||||
logger.warning(f"edit_document: FIND text not found, skipping: {_find[:80]!r}")
|
||||
skipped += 1
|
||||
applied, skipped, no_op_edits = 0, 0, 0
|
||||
invalid_edits = []
|
||||
# Validate against evolving content before the database write.
|
||||
# Only exact unique matches may be saved; report every rejected
|
||||
# entry explicitly so a partial batch cannot masquerade as complete.
|
||||
prose = str(doc.language or '').lower() in {'text', 'markdown', 'richtext', 'email', ''}
|
||||
for edit_number, edit in enumerate(edits, 1):
|
||||
find = edit['find']
|
||||
replacement = edit['replace']
|
||||
if find == replacement:
|
||||
skipped += 1
|
||||
no_op_edits += 1
|
||||
continue
|
||||
if find not in updated_content:
|
||||
stripped = "\n".join(re.sub(r"^\d+\t", "", line) for line in find.split("\n"))
|
||||
if stripped != find and stripped in updated_content:
|
||||
find = stripped
|
||||
count = updated_content.count(find) if find else 0
|
||||
replace_all = edit.get('replace_all') is True
|
||||
if count == 0 or (count != 1 and not replace_all):
|
||||
invalid_edits.append((edit_number, count, edit['find']))
|
||||
continue
|
||||
position = updated_content.index(find)
|
||||
positions = [m.start() for m in re.finditer(re.escape(find), updated_content)] if replace_all else [position]
|
||||
if prose and re.fullmatch(r"[\w]+", find):
|
||||
for match_pos in positions:
|
||||
before = updated_content[match_pos - 1:match_pos] if match_pos else ''
|
||||
after = updated_content[match_pos + len(find):match_pos + len(find) + 1]
|
||||
if (before and (before.isalnum() or before == '_')) or (after and (after.isalnum() or after == '_')):
|
||||
invalid_edits.append((edit_number, 'part of a word', edit['find']))
|
||||
break
|
||||
if invalid_edits and invalid_edits[-1][0] == edit_number:
|
||||
continue
|
||||
updated_content = updated_content.replace(find, replacement) if replace_all else updated_content[:position] + replacement + updated_content[position + len(find):]
|
||||
applied += 1
|
||||
|
||||
partial_edits = bool(invalid_edits and applied)
|
||||
if invalid_edits and not partial_edits:
|
||||
details = '; '.join(
|
||||
f'#{number} ({reason} matches): {find[:100]!r}'
|
||||
if isinstance(reason, int) else f'#{number} ({reason}): {find[:100]!r}'
|
||||
for number, reason, find in invalid_edits[:8]
|
||||
)
|
||||
extra = f'; and {len(invalid_edits) - 8} more' if len(invalid_edits) > 8 else ''
|
||||
return {
|
||||
'error': f'No edits applied. Invalid FIND entries: {details}{extra}. '
|
||||
'Do not repeat the unchanged call. Copy FIND exactly from the current source or the hints below, '
|
||||
'then retry the corrected entries. If no hint identifies the target, read the document first. '
|
||||
'Other entries were not saved. ' + ' '.join(
|
||||
f'#{number}: {_document_find_repair_hint(doc.current_content, find, reason)}'
|
||||
for number, reason, find in invalid_edits[:3]
|
||||
if _document_find_repair_hint(doc.current_content, find, reason)
|
||||
),
|
||||
'exit_code': 1, 'applied': 0,
|
||||
'invalid_edit_numbers': [number for number, _, _ in invalid_edits],
|
||||
}
|
||||
|
||||
if applied == 0:
|
||||
if no_op_edits == len(edits):
|
||||
return {"error": "No edits applied: every FIND and REPLACE pair is identical. Write a changed replacement that fulfills the requested revision; keep FIND copied from the current document."}
|
||||
return {"error": f"No edits applied — none of the FIND blocks matched the document content (skipped {skipped})"}
|
||||
|
||||
missing_id = _missing_document_upload(owner, updated_content)
|
||||
@@ -695,7 +959,7 @@ class EditDocumentTool:
|
||||
db.add(ver)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
result = {
|
||||
"action": "edit",
|
||||
"doc_id": target_id,
|
||||
"title": doc.title,
|
||||
@@ -705,6 +969,17 @@ class EditDocumentTool:
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
}
|
||||
if partial_edits:
|
||||
result.update({
|
||||
'partial': True,
|
||||
'rejected': len(invalid_edits),
|
||||
'invalid_edits': [
|
||||
{'number': number, 'matches': reason, 'find': find[:100],
|
||||
'hint': _document_find_repair_hint(updated_content, find, reason)}
|
||||
for number, reason, find in invalid_edits
|
||||
],
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {"error": f"Failed to edit document: {e}"}
|
||||
@@ -737,21 +1012,53 @@ class SuggestDocumentTool:
|
||||
return version_error
|
||||
|
||||
# Validate that FIND text exists in document
|
||||
valid = []
|
||||
for s in suggestions:
|
||||
if s["find"] in doc.current_content:
|
||||
valid, invalid = [], []
|
||||
for number, s in enumerate(suggestions, 1):
|
||||
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:
|
||||
stored = doc.current_content or ''
|
||||
if stored.count(source_find) != 1:
|
||||
invalid.append({'number': number, 'find': find_text[:100],
|
||||
'reason': 'ambiguous',
|
||||
'hint': _document_find_contexts(stored, source_find)[:900]})
|
||||
continue
|
||||
if re.fullmatch(r"[\w]+", source_find):
|
||||
pos = stored.index(source_find)
|
||||
before = stored[pos - 1:pos] if pos else ''
|
||||
after = stored[pos + len(source_find):pos + len(source_find) + 1]
|
||||
if (before and before.isalnum()) or (after and after.isalnum()):
|
||||
invalid.append({'number': number, 'find': find_text[:100],
|
||||
'reason': 'part of a word', 'hint': ''})
|
||||
continue
|
||||
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}")
|
||||
invalid.append({'number': number, 'find': find_text[:100],
|
||||
'reason': 'not found',
|
||||
'hint': _document_find_repair_hint(doc.current_content or '', find_text, 0)[:900]})
|
||||
|
||||
if not valid:
|
||||
return {"error": "No suggestions matched the document content"}
|
||||
details = '; '.join(f"#{item['number']} {item['reason']}: {item['find']!r} {item['hint']}"
|
||||
for item in invalid[:5])
|
||||
return {'error': 'No suggestions created: ' + details,
|
||||
'exit_code': 1, 'rejected': len(invalid)}
|
||||
|
||||
return {
|
||||
"action": "suggest",
|
||||
"doc_id": target_id,
|
||||
"suggestions": valid,
|
||||
"count": len(valid),
|
||||
"partial": bool(invalid),
|
||||
"rejected": len(invalid),
|
||||
"invalid_suggestions": invalid,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -779,6 +1086,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 +1110,34 @@ 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()
|
||||
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()
|
||||
requested_limit = args.get("limit", 50)
|
||||
try:
|
||||
requested_limit = max(1, min(int(requested_limit), 200))
|
||||
except (TypeError, ValueError):
|
||||
requested_limit = 50
|
||||
q = q.order_by(Document.updated_at.desc())
|
||||
# A plain listing must not load the entire document library
|
||||
# (including every document body) before applying its limit.
|
||||
if not search_text:
|
||||
q = q.limit(requested_limit)
|
||||
docs = q.all()
|
||||
if search_text:
|
||||
docs = _rank_document_search(docs, search_text)
|
||||
docs = docs[:requested_limit]
|
||||
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 +1164,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 +1197,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()
|
||||
|
||||
+443
-237
@@ -6,9 +6,11 @@ import difflib
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
import tempfile
|
||||
from typing import Optional, Dict, Any, Tuple, List
|
||||
|
||||
from src.constants import MAX_READ_CHARS, MAX_DIFF_LINES, MAX_OUTPUT_CHARS
|
||||
from src.path_confinement import is_inside
|
||||
|
||||
_CODENAV_SKIP_DIRS = frozenset({
|
||||
".git", ".hg", ".svn", "node_modules", "venv", ".venv", "__pycache__",
|
||||
@@ -19,6 +21,50 @@ _CODENAV_MAX_HITS = 200
|
||||
_CODENAV_MAX_LINE = 400
|
||||
_GREP_TIMEOUT_SECONDS = 20
|
||||
_GREP_STDERR_PREFIX = 20_000
|
||||
_STRUCTURED_DOCUMENT_SUFFIXES = frozenset({
|
||||
".doc", ".docx", ".epub", ".pdf", ".pptx", ".xls", ".xlsx",
|
||||
})
|
||||
_BINARY_ARTIFACT_SUFFIXES = _STRUCTURED_DOCUMENT_SUFFIXES | frozenset({
|
||||
".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".mp3", ".mp4", ".ogg",
|
||||
".png", ".wav", ".webm", ".webp", ".zip",
|
||||
})
|
||||
|
||||
|
||||
def _visible_bound_resource(path):
|
||||
from src.agent_runtime.resource_binding import active_resource_operation
|
||||
bound = active_resource_operation()
|
||||
if bound is None:
|
||||
return True
|
||||
try:
|
||||
bound.resolve_path(path)
|
||||
return True
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
return False
|
||||
|
||||
# Models frequently put source artifacts in a Markdown code fence even when a
|
||||
# tool schema asks for the raw file body. Persisting that fence makes HTML,
|
||||
# CSS, JavaScript, and source files invalid. Restrict normalization to
|
||||
# code-like targets so a user can still write a literal fence to Markdown.
|
||||
_FENCED_SOURCE_SUFFIXES = frozenset({
|
||||
".css", ".csv", ".html", ".htm", ".js", ".json", ".jsx", ".mjs",
|
||||
".py", ".sh", ".sql", ".svg", ".ts", ".tsx", ".xml", ".yaml", ".yml",
|
||||
})
|
||||
|
||||
|
||||
def _unwrap_fenced_source_body(body: str, path: str) -> str:
|
||||
"""Remove an accidental outer Markdown fence from a source artifact.
|
||||
|
||||
An opening fence is enough to normalize: generation can end during a tool
|
||||
call while its argument remains otherwise usable, and retaining the fence
|
||||
corrupts the artifact. This only applies to source-like file extensions.
|
||||
"""
|
||||
if os.path.splitext(path)[1].casefold() not in _FENCED_SOURCE_SUFFIXES:
|
||||
return body
|
||||
match = re.match(r"^(\s*)```[^\r\n]*\r?\n", body)
|
||||
if not match:
|
||||
return body
|
||||
unwrapped = body[match.end():]
|
||||
return re.sub(r"\r?\n```\s*$", "", unwrapped)
|
||||
|
||||
|
||||
def _glob_to_regex(pat: str) -> "re.Pattern":
|
||||
@@ -46,6 +92,22 @@ def _glob_to_regex(pat: str) -> "re.Pattern":
|
||||
return re.compile("".join(out))
|
||||
|
||||
|
||||
def _validate_grep_descriptor(descriptor):
|
||||
"""Revalidate an inert parent snapshot without inherited ContextVars."""
|
||||
path, identity, ancestors = descriptor
|
||||
if os.path.islink(path) or os.path.realpath(path) != path:
|
||||
raise ValueError("grep: resource path changed")
|
||||
for parent, observed in ancestors:
|
||||
info = os.stat(parent, follow_symlinks=False)
|
||||
if not os.path.isdir(parent) or (info.st_dev, info.st_ino) != tuple(observed):
|
||||
raise ValueError("grep: resource ancestor changed")
|
||||
info = os.stat(path, follow_symlinks=False)
|
||||
import stat
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_nlink > 1
|
||||
or (info.st_dev, info.st_ino) != tuple(identity)):
|
||||
raise ValueError("grep: resource identity changed")
|
||||
|
||||
|
||||
def _python_grep_worker(payload: dict, output_queue) -> None:
|
||||
"""Spawn-safe fallback grep worker used when ripgrep is unavailable.
|
||||
|
||||
@@ -66,85 +128,35 @@ def _python_grep_worker(payload: dict, output_queue) -> None:
|
||||
output_queue.put(("error", f"grep: bad pattern: {exc}"))
|
||||
return
|
||||
|
||||
requested_root = payload["root"]
|
||||
skip_dirs = set(payload["skip_dirs"])
|
||||
sensitive = {name.casefold() for name in payload["sensitive_names"]}
|
||||
max_hits = payload["max_hits"]
|
||||
hits = 0
|
||||
|
||||
def within(path: str, root: str) -> bool:
|
||||
try:
|
||||
return os.path.commonpath(
|
||||
[os.path.normcase(path), os.path.normcase(root)]
|
||||
) == os.path.normcase(root)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def safe_file(path: str, target: str) -> Optional[str]:
|
||||
if os.path.islink(path):
|
||||
return None
|
||||
canonical = os.path.realpath(path)
|
||||
if not within(canonical, requested_root) or not within(canonical, target):
|
||||
return None
|
||||
parts = [part.casefold() for part in canonical.split(os.sep)]
|
||||
if any(part in sensitive for part in parts):
|
||||
return None
|
||||
try:
|
||||
if not os.path.isfile(canonical) or os.stat(canonical).st_nlink > 1:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return canonical
|
||||
|
||||
for target in payload["targets"]:
|
||||
for descriptor in payload["files"]:
|
||||
if hits >= max_hits:
|
||||
break
|
||||
if os.path.isfile(target):
|
||||
file_iter = iter((target,))
|
||||
else:
|
||||
def walk_files():
|
||||
for directory, dirnames, filenames in os.walk(
|
||||
target, followlinks=False
|
||||
):
|
||||
dirnames[:] = [
|
||||
name
|
||||
for name in dirnames
|
||||
if name not in skip_dirs
|
||||
and name.casefold() not in sensitive
|
||||
and not os.path.islink(os.path.join(directory, name))
|
||||
]
|
||||
for name in filenames:
|
||||
yield os.path.join(directory, name)
|
||||
|
||||
file_iter = walk_files()
|
||||
|
||||
for candidate in file_iter:
|
||||
path = safe_file(candidate, target)
|
||||
if path is None:
|
||||
continue
|
||||
relative = os.path.relpath(path, requested_root).replace(os.sep, "/")
|
||||
if glob_regex and not (
|
||||
glob_regex.fullmatch(relative)
|
||||
or glob_regex.fullmatch(os.path.basename(path))
|
||||
):
|
||||
continue
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="strict") as handle:
|
||||
for number, line in enumerate(handle, 1):
|
||||
if regex.search(line):
|
||||
output_queue.put((
|
||||
"match",
|
||||
path,
|
||||
number,
|
||||
line.rstrip()[:_CODENAV_MAX_LINE],
|
||||
))
|
||||
hits += 1
|
||||
if hits >= max_hits:
|
||||
break
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
if hits >= max_hits:
|
||||
break
|
||||
path, identity, ancestors = descriptor
|
||||
_validate_grep_descriptor(descriptor)
|
||||
relative = os.path.relpath(path, payload["base"]).replace(os.sep, "/")
|
||||
if glob_regex and not (
|
||||
glob_regex.fullmatch(relative) or glob_regex.fullmatch(os.path.basename(path))
|
||||
):
|
||||
continue
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="strict") as handle:
|
||||
info = os.fstat(handle.fileno())
|
||||
if (info.st_dev, info.st_ino) != tuple(identity) or info.st_nlink > 1:
|
||||
raise ValueError("grep: resource identity changed before read")
|
||||
for number, line in enumerate(handle, 1):
|
||||
if regex.search(line):
|
||||
output_queue.put(("match", path, number, line.rstrip()[:_CODENAV_MAX_LINE]))
|
||||
hits += 1
|
||||
if hits >= max_hits:
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
except OSError as error:
|
||||
output_queue.put(("error", f"grep: {error}"))
|
||||
return
|
||||
output_queue.put(("done",))
|
||||
except BaseException as exc:
|
||||
try:
|
||||
@@ -180,52 +192,75 @@ def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||
"file": os.path.basename(path) or (path or "file"),
|
||||
}
|
||||
|
||||
def _edit_file_text(original: str, old: str, new: str, replace_all: bool) -> tuple[str | None, str]:
|
||||
"""The exact text edit_file writes for ``original``, or None and why not.
|
||||
|
||||
Pure: the effect adapter derives the requested post-state from this same
|
||||
function, so the postcondition is the producer's own transformation.
|
||||
"""
|
||||
count = original.count(old)
|
||||
if count == 0:
|
||||
return None, "not_found"
|
||||
if count > 1 and not replace_all:
|
||||
return None, f"not_unique:{count}"
|
||||
return (original.replace(old, new) if replace_all else original.replace(old, new, 1)), "ok"
|
||||
|
||||
|
||||
class EditFileTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
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:
|
||||
return original, None, "not_found"
|
||||
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:
|
||||
updated, status = _edit_file_text(original, old, new, replace_all)
|
||||
if updated is None:
|
||||
return original, None, status
|
||||
attempted.append(True)
|
||||
with open(path, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(updated)
|
||||
return original, updated, "ok"
|
||||
|
||||
# In-place rewrite: a failure after truncation may leave partial bytes.
|
||||
attempted = []
|
||||
partial = lambda: {"mutation_attempted": True} if attempted else {}
|
||||
try:
|
||||
original, updated, status = await asyncio.to_thread(_apply)
|
||||
except FileNotFoundError:
|
||||
return {"error": f"edit_file: {path}: not found (use write_file to create it)", "exit_code": 1}
|
||||
return {"error": f"edit_file: {path}: not found (use write_file to create it)", "exit_code": 1, **partial()}
|
||||
except (IsADirectoryError, UnicodeDecodeError):
|
||||
return {"error": f"edit_file: {path}: not an editable text file", "exit_code": 1}
|
||||
return {"error": f"edit_file: {path}: not an editable text file", "exit_code": 1, **partial()}
|
||||
except PermissionError:
|
||||
return {"error": f"edit_file: {path}: permission denied", "exit_code": 1}
|
||||
return {"error": f"edit_file: {path}: permission denied", "exit_code": 1, **partial()}
|
||||
except OSError as e:
|
||||
return {"error": f"edit_file: {path}: {e}", "exit_code": 1}
|
||||
return {"error": f"edit_file: {path}: {e}", "exit_code": 1, **partial()}
|
||||
|
||||
if status == "not_found":
|
||||
return {"error": f"edit_file: old_string not found in {path}. Read the file and match it exactly.", "exit_code": 1}
|
||||
@@ -248,17 +283,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
|
||||
@@ -331,44 +385,91 @@ class _EmptyBodyWouldTruncate(Exception):
|
||||
self.path = path
|
||||
self.existing_bytes = existing_bytes
|
||||
|
||||
def _parse_write_intent(content: str) -> tuple[str, str, bool, bool]:
|
||||
"""Classify the original transport, before binding or source normalization.
|
||||
|
||||
The returned clear flag is derived here, never from a caller-supplied key.
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
raise ValueError("write_file: expected string arguments")
|
||||
if content.lstrip().startswith("{"):
|
||||
try:
|
||||
args = json.loads(content)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("write_file: expected valid JSON arguments") from error
|
||||
if not isinstance(args, dict):
|
||||
raise ValueError("write_file: expected a JSON object")
|
||||
path, body = args.get("path"), args.get("content")
|
||||
if not isinstance(body, str):
|
||||
raise ValueError("write_file: content required and must be a string")
|
||||
if not isinstance(path, str):
|
||||
raise ValueError("write_file: path required and must be a string")
|
||||
return path.strip(), body, True, not body.strip()
|
||||
path, delimiter, body = content.partition("\n")
|
||||
return path.strip(), body, bool(delimiter), False
|
||||
|
||||
|
||||
class WriteFileTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
lines = content.split("\n", 1)
|
||||
raw_path = lines[0].strip()
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
# Only the fenced inline-JSON form can say "this file is meant to be empty":
|
||||
# the text form's `path\n` and a body a parser dropped look identical here.
|
||||
declared_clear = False
|
||||
# Decode JSON-object args (the fenced inline-args shape
|
||||
# ```write_file {"path": "...", "content": "..."}```), matching
|
||||
# ReadFileTool above. Without this the whole JSON string becomes the
|
||||
# path and the file is written under a garbage name. This is the live
|
||||
# path: there is no filesystem MCP server, so write_file always runs
|
||||
# here via _direct_fallback, not through _build_mcp_args.
|
||||
_stripped = content.strip()
|
||||
if _stripped.startswith("{"):
|
||||
try:
|
||||
_a = json.loads(_stripped)
|
||||
if isinstance(_a, dict) and "path" in _a:
|
||||
raw_path = str(_a.get("path", "")).strip()
|
||||
_content = _a.get("content")
|
||||
# A `content` key that is literally an empty (or whitespace-only)
|
||||
# string is the caller declaring the file should be cleared. A
|
||||
# missing key or a null is what a parser that lost the body leaves
|
||||
# behind, so neither declares anything. The old
|
||||
# `str(_a.get("content", ""))` also turned null into the 4 bytes
|
||||
# "None", which could be neither refused nor honoured.
|
||||
declared_clear = isinstance(_content, str) and not _content.strip()
|
||||
body = "" if _content is None else (
|
||||
_content if isinstance(_content, str) else str(_content)
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
from src.tool_execution import _display_tool_path, _resolve_tool_path
|
||||
from src.agent_runtime.resource_binding import active_resource_operation
|
||||
bound = active_resource_operation()
|
||||
original = bound.operation.input if bound is not None else content
|
||||
try:
|
||||
_, _, has_section, declared_clear = _parse_write_intent(original)
|
||||
raw_path, body, _, _ = _parse_write_intent(content)
|
||||
except ValueError as error:
|
||||
return {"error": str(error), "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}
|
||||
body = _unwrap_fenced_source_body(body, path)
|
||||
# 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,
|
||||
}
|
||||
# write_file is a UTF-8 text writer. Refuse to silently destroy an
|
||||
# existing PDF, image, archive, or media artifact produced by a
|
||||
# format-aware tool, especially after the agent has verified it.
|
||||
suffix = os.path.splitext(path)[1].casefold()
|
||||
if suffix in _BINARY_ARTIFACT_SUFFIXES:
|
||||
target_existed = os.path.isfile(path)
|
||||
return {
|
||||
"error": (
|
||||
f"write_file: refusing UTF-8 text for binary artifact path {path}. "
|
||||
"Use Python or a format-specific creation tool, then inspect the result."
|
||||
),
|
||||
"exit_code": 1,
|
||||
"binary_artifact_preserved": target_existed,
|
||||
}
|
||||
if not has_section:
|
||||
return {"error": "write_file: content required; missing content section", "exit_code": 1}
|
||||
if raw_path.endswith(("/", "\\")) or os.path.isdir(path):
|
||||
return {"error": "write_file: target is a directory", "exit_code": 1}
|
||||
# This writer truncates in place. Once that stage is reached, a failure
|
||||
# may leave a partial file; report it so effect evidence stays honest.
|
||||
attempted = []
|
||||
try:
|
||||
def _write():
|
||||
old = ""
|
||||
@@ -395,10 +496,12 @@ class WriteFileTool:
|
||||
# Publish whitespace content atomically. Writing it
|
||||
# after exclusive creation could overwrite bytes from
|
||||
# a writer that filled the new placeholder meanwhile.
|
||||
attempted.append(True)
|
||||
_write_new_file_without_overwrite(path, body)
|
||||
else:
|
||||
# An exact empty body needs no staged data, so create
|
||||
# the file exclusively and never write through it.
|
||||
attempted.append(True)
|
||||
with open(path, "x", encoding="utf-8"):
|
||||
pass
|
||||
except FileExistsError:
|
||||
@@ -410,6 +513,7 @@ class WriteFileTool:
|
||||
raise
|
||||
return old, len(body)
|
||||
|
||||
attempted.append(True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(body)
|
||||
return old, len(body)
|
||||
@@ -426,11 +530,19 @@ class WriteFileTool:
|
||||
"exit_code": 1,
|
||||
}
|
||||
except PermissionError:
|
||||
return {"error": f"write_file: {path}: permission denied", "exit_code": 1}
|
||||
return {"error": f"write_file: {path}: permission denied", "exit_code": 1,
|
||||
**({"mutation_attempted": True} if attempted else {})}
|
||||
except OSError as e:
|
||||
return {"error": f"write_file: {path}: {e}", "exit_code": 1}
|
||||
diff = _unified_diff(old_content, body, path)
|
||||
result = {"output": f"Wrote {size} bytes to {path}", "exit_code": 0}
|
||||
return {"error": f"write_file: {path}: {e}", "exit_code": 1,
|
||||
**({"mutation_attempted": True} if attempted else {})}
|
||||
committed_body = old_content if size == 0 and not declared_clear and not body.strip() else body
|
||||
diff = _unified_diff(old_content, committed_body, path)
|
||||
result = {
|
||||
"output": (f"Wrote {size} bytes to {_display_tool_path(path)}" if attempted else
|
||||
f"No write performed for {_display_tool_path(path)} (implicit empty body)"),
|
||||
"exit_code": 0,
|
||||
**({"write_noop": True} if not attempted else {}),
|
||||
}
|
||||
if diff:
|
||||
result["diff"] = diff
|
||||
return result
|
||||
@@ -485,16 +597,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)
|
||||
@@ -612,11 +785,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 (
|
||||
_is_denied_tool_path,
|
||||
_resolve_search_root,
|
||||
_truncate,
|
||||
)
|
||||
from src.tool_execution import _display_tool_path, _is_denied_tool_path, _resolve_search_root, _truncate
|
||||
raw_path = ""
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
@@ -640,7 +809,7 @@ class LsTool:
|
||||
for entry in it:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
if _is_denied_tool_path(os.path.realpath(entry.path)):
|
||||
if _is_denied_tool_path(os.path.realpath(entry.path)) or not _visible_bound_resource(entry.path):
|
||||
continue
|
||||
try:
|
||||
is_dir = entry.is_dir(follow_symlinks=False)
|
||||
@@ -651,7 +820,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:
|
||||
@@ -671,6 +840,8 @@ class GlobTool:
|
||||
_SENSITIVE_BASENAMES,
|
||||
_can_traverse_tool_path,
|
||||
_is_denied_tool_path,
|
||||
_display_tool_path,
|
||||
_is_sensitive_path,
|
||||
_resolve_tool_path,
|
||||
_resolve_search_root,
|
||||
_truncate,
|
||||
@@ -708,18 +879,12 @@ class GlobTool:
|
||||
# confinement that _resolve_search_root applies to the root.
|
||||
# An escaping literal falls through to the walk, which only ever
|
||||
# yields paths under base.
|
||||
nbase = os.path.normcase(rbase)
|
||||
try:
|
||||
inside = cand == rbase or os.path.commonpath(
|
||||
[os.path.normcase(cand), nbase]
|
||||
) == nbase
|
||||
except ValueError:
|
||||
inside = False
|
||||
inside = is_inside(rbase, cand)
|
||||
# A literal that names a deny-listed sensitive file (.env,
|
||||
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
||||
# otherwise glob would surface secret paths that read_file /
|
||||
# grep already refuse to touch.
|
||||
if inside and os.path.exists(cand) and not _is_denied_tool_path(cand):
|
||||
if inside and os.path.exists(cand) and not _is_denied_tool_path(cand) and _visible_bound_resource(cand):
|
||||
return [cand], None
|
||||
# Literal not at exact path — fall through to walk so
|
||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||
@@ -748,7 +913,7 @@ class GlobTool:
|
||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||
# Skip deny-listed sensitive files (.env, id_rsa,
|
||||
# known_hosts, …) the same way grep does.
|
||||
if _is_denied_tool_path(os.path.realpath(full)):
|
||||
if _is_denied_tool_path(os.path.realpath(full)) or not _visible_bound_resource(full):
|
||||
continue
|
||||
try:
|
||||
mtime = os.stat(full).st_mtime
|
||||
@@ -766,8 +931,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}
|
||||
@@ -779,6 +944,8 @@ class GrepTool:
|
||||
_SENSITIVE_FILE_PATTERNS,
|
||||
_agent_readable_data_subdirs,
|
||||
_is_denied_tool_path,
|
||||
_display_tool_path,
|
||||
_can_traverse_tool_path,
|
||||
_is_sensitive_path,
|
||||
_path_within,
|
||||
_resolve_search_root,
|
||||
@@ -818,72 +985,86 @@ class GrepTool:
|
||||
|
||||
rg = shutil.which("rg")
|
||||
real_root = os.path.realpath(root)
|
||||
data_dir = os.path.realpath(DATA_DIR)
|
||||
spans_state = _path_within(data_dir, real_root)
|
||||
|
||||
def is_top_level_safe(path: str, *, partition_generated: bool) -> bool:
|
||||
lexical = os.path.abspath(path)
|
||||
if os.path.islink(lexical):
|
||||
return False
|
||||
canonical = os.path.realpath(lexical)
|
||||
if not _path_within(canonical, real_root):
|
||||
return False
|
||||
if partition_generated and os.path.basename(lexical) in _CODENAV_SKIP_DIRS:
|
||||
return False
|
||||
if _is_sensitive_path(canonical) or _is_denied_tool_path(canonical):
|
||||
return False
|
||||
return True
|
||||
|
||||
def safe_targets() -> tuple[list[str], Optional[str]]:
|
||||
candidates: list[tuple[str, bool]] = []
|
||||
if not spans_state:
|
||||
# Preserve direct-root compatibility: skip-directory policy
|
||||
# prunes descendants, but an explicitly requested allowed
|
||||
# root named node_modules remains searchable.
|
||||
candidates.append((real_root, False))
|
||||
else:
|
||||
current = real_root
|
||||
if current != data_dir:
|
||||
for part in os.path.relpath(data_dir, current).split(os.sep):
|
||||
try:
|
||||
with os.scandir(current) as entries:
|
||||
for entry in entries:
|
||||
if entry.name != part:
|
||||
# Reject a sibling link lexically before
|
||||
# canonicalizing or treating it as a target.
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
candidates.append((entry.path, True))
|
||||
except OSError as exc:
|
||||
return [], f"grep: {exc}"
|
||||
current = os.path.join(current, part)
|
||||
for readable in _agent_readable_data_subdirs():
|
||||
if (
|
||||
_path_within(readable, data_dir)
|
||||
and _path_within(readable, real_root)
|
||||
and os.path.exists(readable)
|
||||
):
|
||||
candidates.append((readable, True))
|
||||
|
||||
targets: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for candidate, partition_generated in candidates:
|
||||
if not is_top_level_safe(
|
||||
candidate, partition_generated=partition_generated
|
||||
):
|
||||
continue
|
||||
canonical = os.path.realpath(candidate)
|
||||
if canonical not in seen:
|
||||
seen.add(canonical)
|
||||
targets.append(canonical)
|
||||
return targets, None
|
||||
|
||||
targets, target_error = safe_targets()
|
||||
if target_error:
|
||||
return None, target_error
|
||||
|
||||
base = real_root if os.path.isdir(real_root) else os.path.dirname(real_root)
|
||||
deadline = time.monotonic() + _GREP_TIMEOUT_SECONDS
|
||||
base = real_root if os.path.isdir(real_root) else os.path.dirname(real_root)
|
||||
from src.agent_runtime.resource_binding import active_resource_operation
|
||||
bound = active_resource_operation()
|
||||
if bound is not None:
|
||||
bound.validate()
|
||||
files = []
|
||||
|
||||
def check_deadline():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("grep: timed out")
|
||||
|
||||
def observe_file(path):
|
||||
check_deadline()
|
||||
if os.path.islink(path):
|
||||
return
|
||||
canonical = os.path.realpath(path)
|
||||
if not _path_within(canonical, base) or _is_denied_tool_path(canonical):
|
||||
return
|
||||
if bound is not None:
|
||||
try:
|
||||
bound.resolve_path(canonical)
|
||||
except ValueError:
|
||||
# Intentionally denied publication control-plane paths
|
||||
# are omitted before any producer is allowed to read.
|
||||
return
|
||||
info = os.stat(canonical, follow_symlinks=False)
|
||||
if not os.path.isfile(canonical):
|
||||
return
|
||||
ancestors = []
|
||||
parent = os.path.dirname(canonical)
|
||||
while _path_within(parent, base):
|
||||
observed = os.stat(parent, follow_symlinks=False)
|
||||
ancestors.append((parent, (observed.st_dev, observed.st_ino)))
|
||||
if parent == base:
|
||||
break
|
||||
parent = os.path.dirname(parent)
|
||||
files.append((canonical, (info.st_dev, info.st_ino), tuple(ancestors)))
|
||||
if len(files) > 100_000:
|
||||
raise ValueError("grep: enumeration limit exceeded; scan incomplete")
|
||||
|
||||
try:
|
||||
if os.path.islink(root):
|
||||
raise ValueError("grep: symlink search root is not allowed")
|
||||
if os.path.isfile(real_root):
|
||||
observe_file(real_root)
|
||||
elif os.path.isdir(real_root):
|
||||
pending_directories = [real_root]
|
||||
enumerated = 0
|
||||
while pending_directories:
|
||||
check_deadline()
|
||||
directory = pending_directories.pop()
|
||||
if not _can_traverse_tool_path(directory):
|
||||
continue
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
check_deadline()
|
||||
enumerated += 1
|
||||
if enumerated > 100_000:
|
||||
raise ValueError("grep: enumeration limit exceeded; scan incomplete")
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
canonical = os.path.realpath(entry.path)
|
||||
if not _path_within(canonical, base):
|
||||
raise ValueError("grep: directory identity changed during enumeration")
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
if (entry.name not in _CODENAV_SKIP_DIRS
|
||||
and _can_traverse_tool_path(canonical)
|
||||
and (bound is None or _is_denied_tool_path(canonical)
|
||||
or _visible_bound_resource(canonical))):
|
||||
pending_directories.append(canonical)
|
||||
else:
|
||||
observe_file(entry.path)
|
||||
else:
|
||||
raise FileNotFoundError(f"grep: {root}: not found")
|
||||
check_deadline()
|
||||
except (OSError, ValueError) as error:
|
||||
return None, str(error) if str(error).startswith("grep:") else f"grep: {error}"
|
||||
descriptors = {record[0]: record for record in files}
|
||||
targets = list(descriptors)
|
||||
lines: list[str] = []
|
||||
|
||||
def parse_rg_result(raw: str) -> Optional[str]:
|
||||
@@ -901,9 +1082,12 @@ class GrepTool:
|
||||
return None
|
||||
absolute = path if os.path.isabs(path) else os.path.join(base, path)
|
||||
canonical = os.path.realpath(absolute)
|
||||
if not _path_within(canonical, real_root) or _is_denied_tool_path(canonical):
|
||||
return None
|
||||
return f"{os.path.abspath(absolute)}:{number}:{text_value.rstrip()[:_CODENAV_MAX_LINE]}"
|
||||
if canonical not in descriptors:
|
||||
raise ValueError("grep: producer returned an undeclared resource")
|
||||
_validate_grep_descriptor(descriptors[canonical])
|
||||
if bound is not None:
|
||||
bound.resolve_path(canonical)
|
||||
return f"{_display_tool_path(canonical)}:{number}:{text_value.rstrip()[:_CODENAV_MAX_LINE]}"
|
||||
|
||||
def run_rg(cmd: list[str]) -> Optional[str]:
|
||||
try:
|
||||
@@ -1000,7 +1184,7 @@ class GrepTool:
|
||||
if rg:
|
||||
# Validate even when policy filtering leaves no search targets.
|
||||
if not targets:
|
||||
error = run_rg([rg, "--json", "--no-config", "--regexp", pattern])
|
||||
error = run_rg([rg, "--json", "--no-config", "--regexp", pattern, "--", "-"])
|
||||
return (None, error) if error else ([], None)
|
||||
relative_targets = [os.path.relpath(target, base) for target in targets]
|
||||
for offset in range(0, len(relative_targets), 128):
|
||||
@@ -1021,7 +1205,15 @@ class GrepTool:
|
||||
for skipped_dir in _CODENAV_SKIP_DIRS:
|
||||
cmd += ["--glob", f"!**/{skipped_dir}/**"]
|
||||
cmd += ["--regexp", pattern, "--", *relative_targets[offset:offset + 128]]
|
||||
error = run_rg(cmd)
|
||||
try:
|
||||
for path in targets[offset:offset + 128]:
|
||||
check_deadline()
|
||||
_validate_grep_descriptor(descriptors[path])
|
||||
if bound is not None:
|
||||
bound.resolve_path(path)
|
||||
error = run_rg(cmd)
|
||||
except (OSError, ValueError) as exc:
|
||||
return None, f"grep: {exc}"
|
||||
if error:
|
||||
return None, error
|
||||
return lines, None
|
||||
@@ -1030,8 +1222,9 @@ class GrepTool:
|
||||
# multithreaded process and can deadlock. Spawn is platform-safe and
|
||||
# PyInstaller-compatible via launcher's early freeze_support().
|
||||
payload = {
|
||||
"root": real_root,
|
||||
"targets": targets,
|
||||
"root": base,
|
||||
"base": base,
|
||||
"files": tuple(files),
|
||||
"pattern": pattern,
|
||||
"ignore_case": ignore_case,
|
||||
"glob": glob_pat,
|
||||
@@ -1090,9 +1283,17 @@ class GrepTool:
|
||||
break
|
||||
_, path, number, text_value = record
|
||||
canonical = os.path.realpath(path)
|
||||
if not _path_within(canonical, real_root) or _is_denied_tool_path(canonical):
|
||||
continue
|
||||
rendered = f"{path}:{number}:{text_value}"
|
||||
if canonical not in descriptors:
|
||||
error = "grep: fallback returned an undeclared resource"
|
||||
break
|
||||
try:
|
||||
_validate_grep_descriptor(descriptors[canonical])
|
||||
if bound is not None:
|
||||
bound.resolve_path(canonical)
|
||||
except (OSError, ValueError) as exc:
|
||||
error = f"grep: {exc}"
|
||||
break
|
||||
rendered = f"{_display_tool_path(canonical)}:{number}:{text_value}"
|
||||
if rendered not in lines:
|
||||
lines.append(rendered)
|
||||
finally:
|
||||
@@ -1107,7 +1308,7 @@ class GrepTool:
|
||||
output_queue.close()
|
||||
if error:
|
||||
return None, error
|
||||
if worker.exitcode not in (0, None) and len(lines) < max_hits:
|
||||
if (not completed or worker.exitcode not in (0, None)) and len(lines) < max_hits:
|
||||
return None, f"grep: fallback worker exited {worker.exitcode}"
|
||||
return lines, None
|
||||
|
||||
@@ -1115,8 +1316,13 @@ class GrepTool:
|
||||
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}
|
||||
@@ -1129,7 +1335,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
@@ -133,6 +133,62 @@ async def list_models(content: str, session_id: Optional[str] = None, owner: Opt
|
||||
|
||||
keyword = content.strip().lower() if content.strip() else None
|
||||
|
||||
# ``list_models`` historically treated every filter as a literal model-ID
|
||||
# substring. For recommendation terms that produced an empty catalog even
|
||||
# though Odysseus already has a hardware detector and fit ranker. Preserve
|
||||
# the catalog behavior for real model/provider filters, but give these
|
||||
# semantic filters their expected read-only meaning.
|
||||
if keyword in {
|
||||
"recommended", "recommendation", "recommendations",
|
||||
"compatible", "hardware", "hardware fit", "best fit",
|
||||
}:
|
||||
from src.tools.system import do_app_api
|
||||
fit_result = await do_app_api(json.dumps({
|
||||
"action": "call",
|
||||
"method": "GET",
|
||||
"path": "/api/hwfit/models",
|
||||
"query": {"fit_only": "true", "limit": 5, "sort": "fit"},
|
||||
}), owner=owner)
|
||||
payload = fit_result.get("json") if isinstance(fit_result, dict) else None
|
||||
system = payload.get("system") if isinstance(payload, dict) else None
|
||||
models = payload.get("models") if isinstance(payload, dict) else None
|
||||
if isinstance(system, dict) and isinstance(models, list):
|
||||
gpu = system.get("gpu_name") or "No GPU detected"
|
||||
vram = system.get("gpu_vram_gb")
|
||||
count = system.get("gpu_count")
|
||||
backend = system.get("backend") or "unknown"
|
||||
lines = [
|
||||
"Detected hardware:",
|
||||
f"- GPU: {gpu}; count={count}; total VRAM={vram} GB; backend={backend}",
|
||||
f"- CPU: {system.get('cpu_name') or 'unknown'}; RAM={system.get('total_ram_gb')} GB",
|
||||
"Ranked compatible models:",
|
||||
]
|
||||
compact_models = []
|
||||
for model_row in models[:5]:
|
||||
if not isinstance(model_row, dict):
|
||||
continue
|
||||
compact = {
|
||||
key: model_row.get(key)
|
||||
for key in (
|
||||
"name", "parameter_count", "quant", "required_gb",
|
||||
"fit_level", "run_mode", "speed_tps", "score", "context",
|
||||
)
|
||||
}
|
||||
compact_models.append(compact)
|
||||
lines.append(
|
||||
"- {name}: params={parameter_count}, quant={quant}, required={required_gb} GB, "
|
||||
"fit={fit_level}, mode={run_mode}, speed={speed_tps} tok/s, score={score}, context={context}".format(
|
||||
**compact
|
||||
)
|
||||
)
|
||||
return {
|
||||
"output": "\n".join(lines),
|
||||
"system": system,
|
||||
"models": compact_models,
|
||||
"exit_code": 0,
|
||||
}
|
||||
return fit_result
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
|
||||
@@ -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)
|
||||
@@ -142,7 +156,7 @@ async def list_sessions(content: str, session_id: Optional[str] = None, owner: O
|
||||
safe_name = (sess.name or "Untitled").replace("[", "\\[").replace("]", "\\]")
|
||||
msg_count = getattr(sess, "message_count", 0) or 0
|
||||
model = getattr(sess, "model", "unknown")
|
||||
marker = " ← most recent" if i == 0 else ""
|
||||
marker = " ← current chat" if sid == session_id else (" ← most recent" if i == 0 else "")
|
||||
lines.append(f"- **[{safe_name}](#session-{sid})** (id: `{sid}`, model: {model}, {msg_count} msgs, last active {_rel(ts)}){marker}")
|
||||
|
||||
if not lines:
|
||||
@@ -152,6 +166,7 @@ async def list_sessions(content: str, session_id: Optional[str] = None, owner: O
|
||||
"results": (
|
||||
f"Found {len(rows)} session(s), sorted most-recent first:\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\nFor the previous/last chat, exclude the row marked current chat. Use the exact returned ID, not an alias. If the target is ambiguous, ask using chat titles before changing anything."
|
||||
+ "\n\nAssistant: when replying to the user, preserve the chat-title markdown links exactly as shown, e.g. `[Chat](#session-id)`. Do not rewrite this as a plain, non-clickable table."
|
||||
)
|
||||
}
|
||||
@@ -180,7 +195,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 +404,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:
|
||||
|
||||
+877
-318
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
"""No-key weather lookup backed by Open-Meteo."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def _get_json(url: str) -> dict:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "Odysseus/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=8) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def weather_location_from_query(query: str) -> str | None:
|
||||
"""Extract a place only from straightforward weather lookup phrasing."""
|
||||
text = re.sub(r"\s+", " ", query).strip(" ?.! ")
|
||||
patterns = (
|
||||
r"^(?:what(?:'s| is) the )?(?:current |today(?:'s)? |tomorrow(?:'s)? )?"
|
||||
r"(?:weather|forecast)(?: like)? (?:in|for|at) (?P<place>.+)$",
|
||||
r"^(?:weather|forecast) (?P<place>.+)$",
|
||||
r"^(?P<place>.+?) (?:weather|forecast)\b.*$",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.match(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
place = re.sub(r"\b(?:today|tomorrow|now|current)\b.*$", "", match.group("place"), flags=re.IGNORECASE).strip(" ,")
|
||||
if 1 <= len(place) <= 100:
|
||||
return place
|
||||
return None
|
||||
|
||||
|
||||
class WeatherTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
try:
|
||||
args = json.loads(content) if content.strip().startswith("{") else {"location": content}
|
||||
if not isinstance(args, dict):
|
||||
return {"error": "get_weather expects a location string or JSON object", "exit_code": 1}
|
||||
location = str(args.get("location") or "").strip()
|
||||
if not location or len(location) > 160:
|
||||
return {"error": "get_weather requires a location (up to 160 characters)", "exit_code": 1}
|
||||
|
||||
geo_url = "https://geocoding-api.open-meteo.com/v1/search?" + urllib.parse.urlencode({
|
||||
"name": location, "count": 1, "language": "en", "format": "json",
|
||||
})
|
||||
geo = await asyncio.to_thread(_get_json, geo_url)
|
||||
places = geo.get("results") or []
|
||||
if not places:
|
||||
return {"error": f"No location found for {location!r}", "exit_code": 1}
|
||||
place = places[0]
|
||||
forecast_url = "https://api.open-meteo.com/v1/forecast?" + urllib.parse.urlencode({
|
||||
"latitude": place["latitude"],
|
||||
"longitude": place["longitude"],
|
||||
"current": "temperature_2m,relative_humidity_2m,precipitation,weather_code,wind_speed_10m",
|
||||
"daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code",
|
||||
"forecast_days": 3,
|
||||
"timezone": place.get("timezone") or "auto",
|
||||
})
|
||||
forecast = await asyncio.to_thread(_get_json, forecast_url)
|
||||
current = forecast.get("current") or {}
|
||||
daily = forecast.get("daily") or {}
|
||||
if not current.get("time") or not daily.get("time"):
|
||||
return {"error": "Weather provider returned incomplete forecast data", "exit_code": 1}
|
||||
place_parts = list(dict.fromkeys(filter(None, [place.get("name"), place.get("admin1"), place.get("country")])))
|
||||
data = {
|
||||
"location": ", ".join(place_parts),
|
||||
"timezone": forecast.get("timezone"),
|
||||
"current": current,
|
||||
"current_units": forecast.get("current_units") or {},
|
||||
"daily": daily,
|
||||
"daily_units": forecast.get("daily_units") or {},
|
||||
"source": forecast_url,
|
||||
"provider": "Open-Meteo",
|
||||
}
|
||||
return {"output": json.dumps(data, ensure_ascii=False), "exit_code": 0, "evidence_status": "available"}
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
return {"error": f"Weather lookup failed: {exc}", "exit_code": 1}
|
||||
+2570
-26
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user