mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 23:02:23 +02:00
merge: reconcile Wave 1.1 with post-PR40 lab
Merge canonical lab 9557b8d5909eb4a885c3bf49e19a65dd904f8c1d exactly once. Retain invocation journal ownership and lineage, provider terminal ordering, teacher handoff, framed DONE handling, and canonical authority/Ajax routing. Combine dynamic dispatch receipts with lab policy forwarding. Adapt native shell/patch evidence, explicit TUI verifiers, and artifact recovery presentation. Refresh generated configuration source links and strengthen adapter regressions. Validation: focused 2118 passed; Wave 1.1 script 2291 passed; broad runtime 5649 passed; full pytest 11581 passed, 53 skipped, 2 xfailed, 6 subtests passed. Compileall 1689 Python files; syntax 279 JS and 82 MJS files; diff and conflict-marker checks passed.
This commit is contained in:
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
@@ -39,6 +40,7 @@ TOOL_HANDLERS = {
|
||||
"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,
|
||||
|
||||
@@ -1,6 +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
|
||||
@@ -295,10 +296,13 @@ def parse_edit_blocks(content: str) -> list:
|
||||
# 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>>>'
|
||||
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
|
||||
@@ -711,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."""
|
||||
@@ -795,34 +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 == edit["replace"]:
|
||||
logger.warning("edit_document: skipping no-op FIND/REPLACE block")
|
||||
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 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
|
||||
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)
|
||||
@@ -855,7 +959,7 @@ class EditDocumentTool:
|
||||
db.add(ver)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
result = {
|
||||
"action": "edit",
|
||||
"doc_id": target_id,
|
||||
"title": doc.title,
|
||||
@@ -865,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}"}
|
||||
@@ -897,8 +1012,8 @@ class SuggestDocumentTool:
|
||||
return version_error
|
||||
|
||||
# Validate that FIND text exists in document
|
||||
valid = []
|
||||
for s in suggestions:
|
||||
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
|
||||
@@ -906,22 +1021,44 @@ class SuggestDocumentTool:
|
||||
# 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: {find_text[: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()
|
||||
|
||||
@@ -156,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:
|
||||
@@ -166,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."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
+157
-32
@@ -18,6 +18,7 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from core import platform_compat
|
||||
from src.constants import MAX_OUTPUT_CHARS
|
||||
|
||||
PDF_EXTRACT_MAX_BYTES = 80_000_000
|
||||
@@ -123,7 +124,42 @@ def _browser_pid_file_candidates(
|
||||
# Linux exposes one command line per pid under /proc; macOS and Windows do not.
|
||||
# Kept as a module attribute so the procfs-dependent paths stay testable on a
|
||||
# host that has no procfs, and on one that does.
|
||||
_PROC_ROOT = Path("/proc")
|
||||
|
||||
|
||||
def _process_command_line(pid: int) -> str | None:
|
||||
"""Command line of a running process, or ``None`` when it cannot be read.
|
||||
|
||||
``None`` means "this host cannot tell", not "the process is gone". Off
|
||||
Linux there is no procfs to read a command line from, so callers must not
|
||||
treat it as proof that the process exited.
|
||||
"""
|
||||
|
||||
try:
|
||||
return (platform_compat.PROC_ROOT / str(pid) / "cmdline").read_bytes().replace(
|
||||
b"\0", b" "
|
||||
).decode("utf-8", errors="replace")
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _process_is_alive(pid: int) -> bool:
|
||||
"""Whether a pid currently exists.
|
||||
|
||||
Delegates to ``core.platform_compat.pid_alive`` rather than probing with
|
||||
``os.kill(pid, 0)`` directly. That probe is POSIX-only: CPython's Windows
|
||||
``os.kill`` calls ``TerminateProcess(handle, sig)`` for any signal other
|
||||
than CTRL_C / CTRL_BREAK, so it would *kill* the daemon it is asked about.
|
||||
Windows is also where there is no procfs, which is precisely when this
|
||||
function gets called at all.
|
||||
|
||||
``pid_alive`` reads False for a pid that ``os.kill`` reports with
|
||||
``PermissionError`` — a live process owned by another user. Neither caller
|
||||
here wants a different answer: the sweep only unlinks a pid file it wrote
|
||||
itself, and treating somebody else's pid as "not our daemon" is the safe
|
||||
reading in both.
|
||||
"""
|
||||
|
||||
return platform_compat.pid_alive(pid)
|
||||
|
||||
_SCHOLARLY_METADATA_CUE_RE = re.compile(
|
||||
r"\b(?:accept(?:ed|ance)?|publish(?:ed|ing|cation)?|venue|conference|"
|
||||
@@ -328,6 +364,37 @@ class WebSearchTool:
|
||||
"exit_code": 1,
|
||||
"untrusted_content": True,
|
||||
}
|
||||
from .weather_tools import WeatherTool, weather_location_from_query
|
||||
weather_location = weather_location_from_query(query)
|
||||
if not sources and time_filter and weather_location:
|
||||
# A forecast or current fact need not live on a newly published page.
|
||||
try:
|
||||
text, sources = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
lambda: comprehensive_web_search(
|
||||
query, max_pages=max_pages, time_filter=None,
|
||||
return_sources=True,
|
||||
),
|
||||
),
|
||||
timeout=20,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not sources:
|
||||
from src.turn_contract import active_turn_contract
|
||||
contract = active_turn_contract()
|
||||
policy = ctx.get("tool_policy") if isinstance(ctx, dict) else None
|
||||
weather_allowed = (
|
||||
weather_location
|
||||
and "get_weather" not in (ctx.get("disabled_tools") or ())
|
||||
and not (policy and policy.blocks("get_weather"))
|
||||
and not (contract and not contract.permits("get_weather"))
|
||||
)
|
||||
if weather_allowed:
|
||||
weather = await WeatherTool().execute(json.dumps({"location": weather_location}), ctx)
|
||||
if weather.get("exit_code") == 0:
|
||||
return weather
|
||||
if progress_cb:
|
||||
await progress_cb({
|
||||
"elapsed_s": 30,
|
||||
@@ -675,7 +742,7 @@ class WebFetchTool:
|
||||
except Exception as e:
|
||||
return {"error": f"web_fetch: {url}: {e}", "exit_code": 1}
|
||||
err = result.get("error")
|
||||
text = (result.get("content") or "").strip()
|
||||
text = (result.get("linked_content") or result.get("content") or "").strip()
|
||||
title = result.get("title") or ""
|
||||
|
||||
if not text:
|
||||
@@ -717,7 +784,7 @@ class WebFetchTool:
|
||||
"\n\n[...truncated; re-call web_fetch with query terms to retrieve matching passages]"
|
||||
if not query else "\n\n[...truncated]"
|
||||
)
|
||||
return {"output": output, "exit_code": 0}
|
||||
return {"output": output, "exit_code": 0, "page_entries": result.get("page_entries") or []}
|
||||
|
||||
|
||||
class PdfExtractTool:
|
||||
@@ -1846,7 +1913,6 @@ class YouTubeTool:
|
||||
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from services.youtube.youtube_handler import (
|
||||
extract_youtube_id,
|
||||
extract_transcript_async,
|
||||
fetch_youtube_comments,
|
||||
init_youtube,
|
||||
@@ -1884,11 +1950,29 @@ class YouTubeTool:
|
||||
return await self._latest_channel_video(channel, max_results=max_results)
|
||||
|
||||
url_or_id = str(args.get("url") or args.get("video_url") or args.get("video_id") or "").strip()
|
||||
video_id = str(args.get("video_id") or "").strip()
|
||||
if not video_id and url_or_id:
|
||||
video_id = extract_youtube_id(url_or_id) or (url_or_id if _looks_like_youtube_video_id(url_or_id) else "")
|
||||
if not video_id:
|
||||
return {"error": f"youtube_tool {action}: provide a YouTube video URL or video_id", "exit_code": 1}
|
||||
# The shared extractor accepts ID prefixes inside text. At the tool
|
||||
# boundary require the complete target, not a truncated invented ID.
|
||||
if url_or_id.startswith(('http://', 'https://')):
|
||||
parsed = urllib.parse.urlparse(url_or_id)
|
||||
host = (parsed.hostname or '').lower()
|
||||
if host in {'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com'}:
|
||||
parts = parsed.path.strip('/').split('/')
|
||||
candidate = (urllib.parse.parse_qs(parsed.query).get('v', [''])[0]
|
||||
if parsed.path == '/watch' else
|
||||
parts[1] if len(parts) == 2 and parts[0] in {'shorts', 'embed', 'live'} else '')
|
||||
elif host in {'youtu.be', 'www.youtu.be'}:
|
||||
candidate = parsed.path.strip('/')
|
||||
else:
|
||||
candidate = ''
|
||||
else:
|
||||
candidate = url_or_id
|
||||
if (not re.fullmatch(r'[A-Za-z0-9_-]{11}', candidate)
|
||||
or (args.get('video_id') and str(args['video_id']) != candidate)):
|
||||
return {"error": f"youtube_tool {action}: invalid video target. Resolve the actual video URL "
|
||||
"from the user or an observed link. A title or channel page is not a video ID; "
|
||||
"open the referenced video in the browser or use latest_channel_video first.",
|
||||
"exit_code": 1, "failure_kind": "invalid_target"}
|
||||
video_id = candidate
|
||||
url = url_or_id if url_or_id.startswith(("http://", "https://")) else f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
if action == "comments":
|
||||
@@ -1902,7 +1986,9 @@ class YouTubeTool:
|
||||
joined = f"{fallback_error}"
|
||||
if api_error:
|
||||
joined = f"YouTube Data API unavailable: {api_error}; yt-dlp fallback failed: {fallback_error}"
|
||||
return {"error": f"youtube_tool comments: {joined}", "exit_code": 1, "untrusted_content": True}
|
||||
return {"error": f"youtube_tool comments: {joined}", "exit_code": 1,
|
||||
"failure_kind": "comments_unavailable", "video_url": url,
|
||||
"untrusted_content": True}
|
||||
return {"output": self._format_comments(comments_data, url), "exit_code": 0, "untrusted_content": True}
|
||||
|
||||
if action == "transcript":
|
||||
@@ -2322,14 +2408,14 @@ class PrivateBrowserTool:
|
||||
except OSError:
|
||||
return
|
||||
profile_prefix = str(tmpdir / "agent-browser-chrome-")
|
||||
if not _PROC_ROOT.is_dir():
|
||||
if not platform_compat.has_procfs():
|
||||
# Without procfs there is no way to match a reparented Chrome by
|
||||
# its command line, and the sweep is an optimisation rather than a
|
||||
# correctness requirement. Leave those trees to the daemon's own
|
||||
# lifecycle instead of failing the whole shutdown path.
|
||||
return
|
||||
pids: list[int] = []
|
||||
for entry in _PROC_ROOT.iterdir():
|
||||
for entry in platform_compat.PROC_ROOT.iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
@@ -2359,16 +2445,18 @@ class PrivateBrowserTool:
|
||||
for pid_file in pid_files:
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
command_line = (Path("/proc") / str(pid) / "cmdline").read_bytes().replace(
|
||||
b"\0", b" "
|
||||
).decode("utf-8", errors="replace")
|
||||
except FileNotFoundError:
|
||||
# The daemon may have exited between writing its pid file and
|
||||
# this cleanup pass. The exact file is still ours to remove.
|
||||
with contextlib.suppress(FileNotFoundError, PermissionError, OSError):
|
||||
pid_file.unlink()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
command_line = _process_command_line(pid)
|
||||
if command_line is None:
|
||||
# Either the daemon exited between writing its pid file and
|
||||
# this pass, or this host has no procfs to ask. Only the first
|
||||
# justifies forgetting the pid file. Without procfs we cannot
|
||||
# confirm the process is ours, so we neither kill it nor drop
|
||||
# the record that would let a later pass find it.
|
||||
if not _process_is_alive(pid):
|
||||
with contextlib.suppress(FileNotFoundError, PermissionError, OSError):
|
||||
pid_file.unlink()
|
||||
continue
|
||||
if "agent-browser" in command_line:
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||
@@ -2395,10 +2483,17 @@ class PrivateBrowserTool:
|
||||
for pid_file in _browser_pid_file_candidates(runtime_dir, namespace, session_id):
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
command_line = (Path("/proc") / str(pid) / "cmdline").read_bytes().replace(
|
||||
b"\0", b" "
|
||||
).decode("utf-8", errors="replace")
|
||||
except (FileNotFoundError, OSError, UnicodeError, ValueError):
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
command_line = _process_command_line(pid)
|
||||
if command_line is None:
|
||||
# Without procfs we can only tell that something with this pid
|
||||
# is alive, not that it is agent-browser. The pid file is our
|
||||
# own namespaced one, so treat a live pid as a match: answering
|
||||
# "no daemon" here is what lets `close` bootstrap a fresh one
|
||||
# and wait on its browser forever.
|
||||
if _process_is_alive(pid):
|
||||
return True
|
||||
continue
|
||||
if "agent-browser" in command_line:
|
||||
return True
|
||||
@@ -2676,14 +2771,15 @@ class PrivateBrowserTool:
|
||||
if err_text:
|
||||
combined = f"[stderr]\n{err_text}\n\n{combined}".strip()
|
||||
fill_error = ""
|
||||
empty_observation = (proc.returncode or 0) == 0 and self._empty_dom_observation(out)
|
||||
observe_state_change = action in {"open", "fill", "press"} and model_choice
|
||||
failed_interaction = action in {"click", "fill"} and model_choice and (proc.returncode or 0) != 0
|
||||
if failed_interaction or ((action == "click" or observe_state_change) and (proc.returncode or 0) == 0):
|
||||
failed_interaction = action in {"click", "fill"} and (proc.returncode or 0) != 0
|
||||
if empty_observation or failed_interaction or ((action == "click" or observe_state_change) and (proc.returncode or 0) == 0):
|
||||
# A click can navigate, replace the DOM, or open a modal. Return
|
||||
# the settled post-click DOM in the same tool result so callers do
|
||||
# not race navigation with a separate immediate read and so the
|
||||
# next conversational turn receives current element refs. A failed
|
||||
# model-choice interaction also needs refs for a covering dialog
|
||||
# interaction also needs refs for a covering dialog
|
||||
# or changed DOM. A successful fill may run input handlers that
|
||||
# open a modal or replace the field: CLI success is not proof that
|
||||
# the intended value survived. Observe only; never retry an action.
|
||||
@@ -2730,7 +2826,8 @@ class PrivateBrowserTool:
|
||||
if page_errors:
|
||||
combined = f"{combined}\n\n[page errors]\n{page_errors}".strip()
|
||||
if len(combined) > MAX_OUTPUT_CHARS:
|
||||
combined = combined[:MAX_OUTPUT_CHARS] + "\n\n[...truncated]"
|
||||
from src.browser_observation import compact_browser_observation
|
||||
combined = compact_browser_observation(combined, budget=MAX_OUTPUT_CHARS)
|
||||
shopping_hint = self._shopping_landing_hint(combined)
|
||||
if shopping_hint:
|
||||
combined = f"{combined}\n\n[{shopping_hint}]"
|
||||
@@ -2792,7 +2889,7 @@ class PrivateBrowserTool:
|
||||
deadline = loop.time() + min(timeout_s, 20)
|
||||
text, observation_note = "", ""
|
||||
first_rows, rows = [], []
|
||||
for attempt in range(2 if model_choice else 1):
|
||||
for attempt in range(2):
|
||||
proc = None
|
||||
try:
|
||||
async with asyncio.timeout(max(0, deadline - loop.time())):
|
||||
@@ -2833,9 +2930,14 @@ class PrivateBrowserTool:
|
||||
text, rows = observed, observed_rows
|
||||
if not attempt:
|
||||
first_rows = rows
|
||||
if not snapshots or any(snapshot.strip() != '(empty page)' for snapshot in snapshots):
|
||||
if not snapshots or not self._empty_dom_observation(observed):
|
||||
break
|
||||
commands = [["wait", "1000"], ["snapshot"]]
|
||||
if not observation_note and self._empty_dom_observation(text):
|
||||
observation_note = (
|
||||
"Browser observation incomplete: the page still has no readable content after waiting. "
|
||||
"Navigation success is not evidence that results loaded. Do not infer page results."
|
||||
)
|
||||
fill_error = ""
|
||||
if verify_fill:
|
||||
fill_error = unverified
|
||||
@@ -2857,7 +2959,30 @@ class PrivateBrowserTool:
|
||||
text = self._snapshot_observation(text)
|
||||
if observation_note:
|
||||
text += '\n' + observation_note
|
||||
return text[:MAX_OUTPUT_CHARS], fill_error
|
||||
if len(text) > MAX_OUTPUT_CHARS:
|
||||
from src.browser_observation import compact_browser_observation
|
||||
text = compact_browser_observation(text, budget=MAX_OUTPUT_CHARS)
|
||||
return text, fill_error
|
||||
|
||||
@staticmethod
|
||||
def _empty_dom_observation(text: str) -> bool:
|
||||
"""Recognize empty accessibility scaffolding, not an actual no-results message."""
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
payload = None
|
||||
if isinstance(payload, list):
|
||||
snapshots = [row['result']['snapshot'] for row in payload
|
||||
if isinstance(row, dict) and row.get('success') is True
|
||||
and isinstance(row.get('result'), dict)
|
||||
and isinstance(row['result'].get('snapshot'), str)]
|
||||
else:
|
||||
snapshots = [text] if isinstance(text, str) and text.strip() else []
|
||||
scaffolding = {'- generic', '- main', '- none', '- presentation', '(empty page)'}
|
||||
return bool(snapshots) and all(
|
||||
all(line.strip() in scaffolding for line in snapshot.splitlines() if line.strip())
|
||||
for snapshot in snapshots
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _dialog_first_snapshot(snapshot: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user