mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 00:12:21 +02:00
Preserve preview harness, editor, email and task improvements
Snapshot current maintainer-preview application changes and regression fixtures for integration into lab. Excludes local runtime data, evaluation outputs and source backups. Focused Python regression selection: 140 passed; full suite not certified.
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}
|
||||
@@ -322,6 +322,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,
|
||||
@@ -669,7 +700,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:
|
||||
@@ -711,7 +742,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:
|
||||
@@ -1840,7 +1871,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,
|
||||
@@ -1878,11 +1908,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":
|
||||
@@ -1896,7 +1944,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":
|
||||
@@ -2664,14 +2714,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.
|
||||
@@ -2718,7 +2769,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}]"
|
||||
@@ -2780,7 +2832,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())):
|
||||
@@ -2821,9 +2873,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
|
||||
@@ -2845,7 +2902,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