mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 16:02:20 +02:00
Consolidate Odysseus agent harness and tool contracts
This commit is contained in:
+90
-17
@@ -17,7 +17,7 @@ from src.upload_handler import reserve_upload_references
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
async def do_manage_calendar(content: str, owner: Optional[str] = None, *, import_event_uid: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
|
||||
from routes.calendar_routes import (
|
||||
@@ -102,6 +102,22 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
q = q.filter(CalendarCal.owner == owner)
|
||||
return q
|
||||
|
||||
def _event_uid_candidates(raw_uid):
|
||||
"""Yield exact UID first, then unambiguous UI-anchor spellings.
|
||||
|
||||
Calendar results render links as ``#event-<uid>``. Models sometimes
|
||||
copy that href (or drop only the leading ``#``) into the UID field.
|
||||
Preserve real UIDs beginning with ``event-`` by trying the exact value
|
||||
first and using the stripped form only as a not-found fallback.
|
||||
"""
|
||||
text = str(raw_uid or "").strip()
|
||||
candidates = [text]
|
||||
if text.startswith("#event-"):
|
||||
candidates.append(text[len("#event-"):])
|
||||
elif text.startswith("event-"):
|
||||
candidates.append(text[len("event-"):])
|
||||
return [item for index, item in enumerate(candidates) if item and item not in candidates[:index]]
|
||||
|
||||
def _first_present_arg(raw_args, *names: str):
|
||||
for name in names:
|
||||
if name in raw_args and raw_args.get(name) is not None:
|
||||
@@ -273,11 +289,11 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"exit_code": 1,
|
||||
}
|
||||
if start_raw:
|
||||
start_dt = _parse_dt(start_raw)
|
||||
start_dt, _ = _parse_event_dt(start_raw)
|
||||
else:
|
||||
start_dt = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if end_raw:
|
||||
end_dt = _parse_dt(end_raw)
|
||||
end_dt, _ = _parse_event_dt(end_raw)
|
||||
else:
|
||||
end_dt = start_dt + timedelta(days=14)
|
||||
except ValueError as e:
|
||||
@@ -421,13 +437,41 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
existing = (
|
||||
_event_query()
|
||||
.filter(
|
||||
CalendarEvent.dtstart == dtstart,
|
||||
CalendarEvent.status != "cancelled",
|
||||
_func.lower(CalendarEvent.summary) == summary.lower(),
|
||||
*([CalendarEvent.uid == import_event_uid] if import_event_uid else [
|
||||
CalendarEvent.dtstart == dtstart,
|
||||
CalendarEvent.status != "cancelled",
|
||||
_func.lower(CalendarEvent.summary) == summary.lower(),
|
||||
]),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
# Repair older email-imported events whose model-generated
|
||||
# location was an unrelated map URL. A concrete meeting URL
|
||||
# is stronger evidence than the existing free-text location.
|
||||
incoming_location = str(args.get("location") or "").strip()
|
||||
changed = False
|
||||
if incoming_location and re.match(
|
||||
r"^https?://(?:teams\.microsoft\.com|(?:[a-z0-9-]+\.)?zoom\.us|meet\.google\.com|(?:[a-z0-9-]+\.)?webex\.com|meet\.jit\.si)/",
|
||||
incoming_location,
|
||||
re.IGNORECASE,
|
||||
) and (
|
||||
not str(existing.location or "").strip()
|
||||
or not re.match(
|
||||
r"^https?://(?:teams\.microsoft\.com|(?:[a-z0-9-]+\.)?zoom\.us|meet\.google\.com|(?:[a-z0-9-]+\.)?webex\.com|meet\.jit\.si)/",
|
||||
str(existing.location or "").strip(),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
):
|
||||
existing.location = incoming_location
|
||||
changed = True
|
||||
for field in ("source_email_uid", "source_email_folder", "source_email_account_id", "source_email_message_id"):
|
||||
incoming = str(args.get(field) or "").strip()
|
||||
if incoming and not getattr(existing, field, None):
|
||||
setattr(existing, field, incoming)
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
reminder_note_id = None
|
||||
reminder_skipped_reason = None
|
||||
minutes_before = _reminder_minutes(args)
|
||||
@@ -486,7 +530,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
uid = str(_uuid.uuid4())
|
||||
uid = import_event_uid or str(_uuid.uuid4())
|
||||
ev = CalendarEvent(
|
||||
uid=uid, calendar_id=cal.id, summary=summary,
|
||||
description=event_description,
|
||||
@@ -496,6 +540,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
rrule=args.get("rrule", "") or "",
|
||||
event_type=event_type,
|
||||
importance=importance,
|
||||
source_email_uid=str(args.get("source_email_uid") or "").strip() or None,
|
||||
source_email_folder=str(args.get("source_email_folder") or "").strip() or None,
|
||||
source_email_account_id=str(args.get("source_email_account_id") or "").strip() or None,
|
||||
source_email_message_id=str(args.get("source_email_message_id") or "").strip() or None,
|
||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||
)
|
||||
db.add(ev)
|
||||
@@ -545,11 +593,17 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
uid = args.get("summary")
|
||||
if not uid:
|
||||
return {"error": "uid is required", "exit_code": 1}
|
||||
try:
|
||||
base_uid = _resolve_base_uid(uid)
|
||||
except ValueError as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||
ev = None
|
||||
base_uid = ""
|
||||
for candidate_uid in _event_uid_candidates(uid):
|
||||
try:
|
||||
candidate_base_uid = _resolve_base_uid(candidate_uid)
|
||||
except ValueError:
|
||||
continue
|
||||
ev = _event_query().filter(CalendarEvent.uid == candidate_base_uid).first()
|
||||
if ev:
|
||||
base_uid = candidate_base_uid
|
||||
break
|
||||
if not ev:
|
||||
title_matches = _event_query().filter(
|
||||
CalendarEvent.summary == str(uid).strip()
|
||||
@@ -581,6 +635,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
ev.description = args["description"]
|
||||
if args.get("location") is not None:
|
||||
ev.location = args["location"]
|
||||
previous_dtstart = ev.dtstart
|
||||
previous_dtend = ev.dtend
|
||||
if args.get("dtstart") is not None:
|
||||
# Anchor naive/natural-language input to the USER's timezone and
|
||||
# refresh is_utc, exactly like create_event. Parsing with the
|
||||
@@ -594,6 +650,13 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
ev.all_day = False
|
||||
ev.dtstart, _su = _parse_event_dt(args["dtstart"])
|
||||
ev.is_utc = bool(_su and not _eff_all_day)
|
||||
if (
|
||||
args.get("dtend") is None
|
||||
and previous_dtstart is not None
|
||||
and previous_dtend is not None
|
||||
and previous_dtend > previous_dtstart
|
||||
):
|
||||
ev.dtend = ev.dtstart + (previous_dtend - previous_dtstart)
|
||||
if args.get("dtend") is not None:
|
||||
ev.dtend, _eu = _parse_event_dt(args["dtend"])
|
||||
if args.get("all_day") is None and bool(ev.all_day) and _looks_like_timed_dt(args["dtend"]):
|
||||
@@ -607,6 +670,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
ev.event_type = _tag or None
|
||||
if args.get("importance") is not None:
|
||||
ev.importance = args["importance"]
|
||||
for field in ("source_email_uid", "source_email_folder", "source_email_account_id", "source_email_message_id"):
|
||||
incoming = str(args.get(field) or "").strip()
|
||||
if incoming:
|
||||
setattr(ev, field, incoming)
|
||||
if args.get("rrule") is not None:
|
||||
ev.rrule = args.get("rrule") or ""
|
||||
elif str(args.get("repeat") or "").strip().lower() in {"none", "no", "off", "false", "single"}:
|
||||
@@ -672,11 +739,17 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"error": "Multiple events have that exact title; uid is required", "exit_code": 1}
|
||||
if not uid:
|
||||
return {"error": "uid or exact summary is required", "exit_code": 1}
|
||||
try:
|
||||
base_uid = _resolve_base_uid(uid)
|
||||
except ValueError as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||
ev = None
|
||||
base_uid = ""
|
||||
for candidate_uid in _event_uid_candidates(uid):
|
||||
try:
|
||||
candidate_base_uid = _resolve_base_uid(candidate_uid)
|
||||
except ValueError:
|
||||
continue
|
||||
ev = _event_query().filter(CalendarEvent.uid == candidate_base_uid).first()
|
||||
if ev:
|
||||
base_uid = candidate_base_uid
|
||||
break
|
||||
if not ev:
|
||||
return {"error": f"Event {uid} not found", "exit_code": 1}
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
|
||||
|
||||
+23
-1
@@ -1829,7 +1829,11 @@ async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Di
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and data.get('error'):
|
||||
raise ValueError('cache endpoint reported an error')
|
||||
scan_errors.append({
|
||||
'host': host_label or 'local',
|
||||
'reason': str(data.get('error'))[:500],
|
||||
})
|
||||
return []
|
||||
ms = data.get("models", []) if isinstance(data, dict) else (data or [])
|
||||
for m in ms:
|
||||
m["host"] = host_label or "local"
|
||||
@@ -1885,7 +1889,25 @@ async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Di
|
||||
and (s.get("name") == raw_host or s.get("host") == host or s.get("host") == raw_host)),
|
||||
{},
|
||||
)
|
||||
error_start = len(scan_errors)
|
||||
models = await _scan_one(raw_host, host, model_dir=_dirs_for(srv))
|
||||
# Friendly Cookbook names commonly double as SSH aliases. If a
|
||||
# saved LAN address goes stale after a reboot/network change,
|
||||
# retry the validated alias before declaring the server offline.
|
||||
# This is read-only and never mutates the saved configuration.
|
||||
if not models and len(scan_errors) > error_start and host != raw_host:
|
||||
try:
|
||||
alias = validate_remote_host(raw_host)
|
||||
except Exception:
|
||||
alias = None
|
||||
if alias:
|
||||
configured_errors = scan_errors[error_start:]
|
||||
del scan_errors[error_start:]
|
||||
models = await _scan_one(
|
||||
raw_host, alias, model_dir=_dirs_for(srv),
|
||||
)
|
||||
if not models:
|
||||
scan_errors[error_start:error_start] = configured_errors
|
||||
else:
|
||||
# Always include local. Local's saved record is the one with no host.
|
||||
local_srv = next((s for s in servers if isinstance(s, dict) and not (s.get("host") or "").strip()), {})
|
||||
|
||||
+17
-6
@@ -16,6 +16,20 @@ from src.upload_handler import reserve_upload_references
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _search_tokens(value: str) -> list[str]:
|
||||
"""Normalize lightweight singular/plural variants without fuzzy matching."""
|
||||
tokens = []
|
||||
for token in re.findall(r"[a-z0-9]+", str(value or "").lower()):
|
||||
if token in {"the", "a", "an", "note", "notes", "checklist", "list", "todo", "todos"}:
|
||||
continue
|
||||
if len(token) > 4 and token.endswith("ies"):
|
||||
token = token[:-3] + "y"
|
||||
elif len(token) > 3 and token.endswith("s") and not token.endswith("ss"):
|
||||
token = token[:-1]
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_notes tool calls: CRUD on notes and checklists."""
|
||||
import uuid as _uuid
|
||||
@@ -206,19 +220,16 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
or ""
|
||||
).strip().lower()
|
||||
if query:
|
||||
query_terms = [
|
||||
term
|
||||
for term in re.findall(r"[a-z0-9]+", query)
|
||||
if term not in {"the", "a", "an", "note", "notes", "checklist", "list", "todo", "todos"}
|
||||
]
|
||||
query_terms = _search_tokens(query)
|
||||
filtered = []
|
||||
for n in notes:
|
||||
haystack = " ".join(
|
||||
str(part or "")
|
||||
for part in (n.title, n.content, n.label, n.items)
|
||||
).lower()
|
||||
haystack_terms = set(_search_tokens(haystack))
|
||||
if query in haystack or (
|
||||
query_terms and all(term in haystack for term in query_terms)
|
||||
query_terms and all(term in haystack_terms for term in query_terms)
|
||||
):
|
||||
filtered.append(n)
|
||||
notes = filtered
|
||||
|
||||
@@ -8,6 +8,7 @@ tools.
|
||||
tool_implementations.py and are pulled back function-locally where needed.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
@@ -92,9 +93,15 @@ async def do_manage_research(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# the `research-` UI prefix, while action=read expects the underlying file
|
||||
# stem. Exposing the exact id prevents agents from guessing or retrying
|
||||
# alternate spellings after a list call.
|
||||
def _completed_label(value):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
except (TypeError, ValueError, OSError):
|
||||
return 'completion time unavailable'
|
||||
|
||||
rows = "\n".join(
|
||||
f"- [{q or '(untitled)'}](#research-{sid}) — id: {sid} — {n} sources"
|
||||
for _, sid, q, n in items[:50]
|
||||
f"- [{q or '(untitled)'}](#research-{sid}) — id: {sid} — completed {_completed_label(completed)} — {n} sources"
|
||||
for completed, sid, q, n in items[:50]
|
||||
)
|
||||
return {"output": f"Research library ({len(items)} item{'s' if len(items) != 1 else ''}):\n{rows}", "exit_code": 0}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user