mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
Merge current dev and refresh PR #6739 from the 7011 integration
This commit is contained in:
+1
-1
@@ -15204,7 +15204,7 @@ _ADMIN_TOOLS = {
|
||||
"manage_documents", "manage_settings", "create_session", "list_sessions",
|
||||
"send_to_session", "search_chats", "pipeline", "ask_teacher", "list_models",
|
||||
"list_cached_models", "list_downloads", "list_cookbook_servers",
|
||||
"list_serve_presets", "list_served_models",
|
||||
"list_serve_presets", "list_served_models", "tail_serve_output", "search_hf_models",
|
||||
}
|
||||
|
||||
def _build_base_prompt(
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass, replace
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from uuid import uuid4
|
||||
@@ -140,11 +141,20 @@ class RequestAuthority:
|
||||
raise ValueError("Malformed request authority")
|
||||
if self.resource_roots is None:
|
||||
roots = ()
|
||||
if self.workspace:
|
||||
try:
|
||||
roots = (FilesystemRoot.seal(self.workspace, owner=self.owner),)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass # An unresolved workspace grants no filesystem root.
|
||||
# With no workspace, bash/python run in the agent workspace
|
||||
# (agent_cwd), so the file tools get that same root (#6679).
|
||||
# Imported here: authority -> execution is a new edge, and
|
||||
# tool_execution imports this module at load time.
|
||||
from src import tool_execution
|
||||
try:
|
||||
root = self.workspace
|
||||
if not root:
|
||||
# Vetted like agent_cwd(): a symlinked agent workspace
|
||||
# raises and so grants no root.
|
||||
root = tool_execution.default_agent_workspace()
|
||||
roots = (FilesystemRoot.seal(root, owner=self.owner),)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass # An unresolved workspace grants no filesystem root.
|
||||
object.__setattr__(self, "resource_roots", roots)
|
||||
if (not isinstance(self.resource_roots, tuple)
|
||||
or any(not isinstance(r, FilesystemRoot) or (r.owner and r.owner != self.owner)
|
||||
|
||||
@@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool
|
||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||
from .bg_job_tools import ManageBgJobsTool
|
||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
|
||||
from .admin_tools import (
|
||||
ADMIN_TOOL_HANDLERS,
|
||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
|
||||
"list_sessions": ListSessionsTool().execute,
|
||||
"send_to_session": SendToSessionTool().execute,
|
||||
"manage_session": ManageSessionTool().execute,
|
||||
"plan_edit": PlanEditTool().execute,
|
||||
"run_editor_command": RunEditorCommandTool().execute,
|
||||
"get_editor_screenshot": GetEditorScreenshotTool().execute,
|
||||
"ai_edit_image": AiEditImageTool().execute,
|
||||
}
|
||||
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
||||
|
||||
@@ -38,11 +38,25 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
|
||||
|
||||
elif action == "add":
|
||||
import uuid as _uuid
|
||||
from src.url_safety import check_outbound_url
|
||||
name = args.get("name", "")
|
||||
base_url = args.get("base_url", "")
|
||||
api_key = args.get("api_key", "")
|
||||
if not base_url:
|
||||
return {"error": "base_url is required", "exit_code": 1}
|
||||
# SSRF hardening: validate the URL before storing it. Every entry
|
||||
# point that accepts an external base_url must run this guard.
|
||||
# The HTTP route (POST /api/model-endpoints) gets the same guard
|
||||
# in #6316; this tool bypasses that route entirely, so it needs
|
||||
# its own validation. A prompt-injection payload smuggled into a
|
||||
# skill, note, fetched page, or email could instruct the model to
|
||||
# register a cloud-metadata or internal-network endpoint.
|
||||
ok, reason = check_outbound_url(
|
||||
base_url,
|
||||
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
return {"error": f"Rejected endpoint URL: {reason}", "exit_code": 1}
|
||||
eid = str(_uuid.uuid4())[:8]
|
||||
from datetime import datetime
|
||||
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
|
||||
@@ -75,7 +89,7 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_endpoints error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
return {"error": "An internal error occurred while managing endpoints", "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
+72
-20
@@ -965,7 +965,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
|
||||
elif action == "open_panel":
|
||||
# Open a top-level panel/modal: documents/library, gallery,
|
||||
# calendar, email, sessions, notes, memories, skills, settings, theme, cookbook.
|
||||
# calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook.
|
||||
panel = parts[1].lower() if len(parts) > 1 else ""
|
||||
view = ""
|
||||
view_label = ""
|
||||
@@ -993,6 +993,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
"note": "notes",
|
||||
"todo": "notes",
|
||||
"todos": "notes",
|
||||
"tasks": "tasks",
|
||||
"task": "tasks",
|
||||
"memories": "memories",
|
||||
"memory": "memories",
|
||||
"brain": "memories",
|
||||
@@ -1010,7 +1012,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
target = _panel_aliases.get(panel)
|
||||
if not target:
|
||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, memories, skills, settings, theme, cookbook."}
|
||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook."}
|
||||
if target == "cookbook":
|
||||
cookbook_views = {
|
||||
"models": ("Search", "models"), "model": ("Search", "models"),
|
||||
@@ -1155,7 +1157,50 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
# Image generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
# Message used whenever a generative image edit (object add/remove/replace,
|
||||
# image generation) is requested but no image provider is configured. The
|
||||
# photo-editor agent and the image tools surface this verbatim so the user gets
|
||||
# a plain, actionable explanation instead of a silent substitute edit.
|
||||
IMAGE_PROVIDER_REQUIRED_MESSAGE = (
|
||||
"Removing/adding objects needs an image generation provider; enable one in "
|
||||
"Settings > Models."
|
||||
)
|
||||
|
||||
|
||||
def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
"""True when an enabled, owner-visible image endpoint can serve AI edits.
|
||||
|
||||
Generative operations (object add/remove/replace, image generation) require
|
||||
a configured image model. Local transforms (remove-bg, upscale, denoise,
|
||||
enhance-face) do not, but the photo-editor agent gates the whole generative
|
||||
request on this so it never falls back to deterministic approximations.
|
||||
"""
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
|
||||
if not load_settings().get("image_gen_enabled", True):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from src.database import SessionLocal, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True, # noqa: E712
|
||||
ModelEndpoint.model_type == "image",
|
||||
)
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
return q.first() is not None
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||
|
||||
Content format:
|
||||
@@ -1255,6 +1300,14 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
|
||||
async def _resolve_image_model(model_name: str):
|
||||
def _call():
|
||||
# Try a plain by-name lookup first. Gateway endpoints (OpenRouter)
|
||||
# register as `llm` yet serve image models, and the image-typed
|
||||
# query's last-resort fallback would otherwise hijack a cloud model
|
||||
# onto the local image endpoint (gpt-5-image -> qwen-image-edit).
|
||||
try:
|
||||
return _resolve_model(model_name, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_name, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1314,7 +1367,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
else:
|
||||
payload["quality"] = "medium"
|
||||
|
||||
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={prompt[:80]}")
|
||||
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={'[private]' if incognito else prompt[:80]}")
|
||||
|
||||
try:
|
||||
# GPT image models can take 30-120s+ depending on quality
|
||||
@@ -1365,18 +1418,22 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
logger.warning(f"Failed to save gallery record: {_ge}")
|
||||
return ""
|
||||
|
||||
# GPT image models always return b64_json; DALL-E may return url
|
||||
if img.get("b64_json"):
|
||||
def _store_image(image_bytes):
|
||||
if incognito:
|
||||
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
|
||||
img_path.write_bytes(image_bytes)
|
||||
if owner:
|
||||
from src.generated_images import register_generated_image
|
||||
register_generated_image(filename, owner)
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
image_id = _save_to_gallery(filename)
|
||||
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
|
||||
|
||||
# Private results stay in the response, never in the gallery or on disk.
|
||||
if img.get("b64_json"):
|
||||
image_url, image_id = _store_image(base64.b64decode(img['b64_json']))
|
||||
|
||||
elif img.get("url"):
|
||||
# Download external URL and save locally (DALL-E returns temp URLs)
|
||||
@@ -1390,16 +1447,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
dl_resp = httpx.get(result_url, timeout=60)
|
||||
if dl_resp.status_code == 200:
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
img_path.write_bytes(dl_resp.content)
|
||||
if owner:
|
||||
from src.generated_images import register_generated_image
|
||||
register_generated_image(filename, owner)
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
image_id = _save_to_gallery(filename)
|
||||
image_url, image_id = _store_image(dl_resp.content)
|
||||
else:
|
||||
image_url = result_url # fallback to external URL
|
||||
except Exception as _dl_e:
|
||||
@@ -1436,6 +1484,7 @@ async def do_edit_image(
|
||||
size: str = "auto",
|
||||
quality: str = "medium",
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
incognito: bool = False,
|
||||
) -> Dict:
|
||||
"""Edit an uploaded image using the configured image endpoint."""
|
||||
import base64
|
||||
@@ -1509,7 +1558,7 @@ async def do_edit_image(
|
||||
request_id = uuid.uuid4().hex
|
||||
payload["request_id"] = request_id
|
||||
|
||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
|
||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, '[private]' if incognito else path.name, '[private]' if incognito else prompt[:80])
|
||||
|
||||
def _save_edited_image_to_gallery(filename: str) -> str:
|
||||
try:
|
||||
@@ -1539,6 +1588,9 @@ async def do_edit_image(
|
||||
from PIL import Image
|
||||
with Image.open(BytesIO(image_bytes)) as output:
|
||||
size = f"{output.width}x{output.height}"
|
||||
image_mime = Image.MIME.get(output.format, 'image/png')
|
||||
if incognito:
|
||||
return 'data:' + image_mime + ';base64,' + base64.b64encode(image_bytes).decode('ascii'), ''
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||
|
||||
+13
-13
@@ -1,8 +1,8 @@
|
||||
"""Editor agent/planner model resolution.
|
||||
|
||||
The image-editor Quick Edit planner (`/api/editor/plan`, `/api/editor/agent`)
|
||||
runs on a LOCAL Qwen3.5-9B vision model served on seneca when one is
|
||||
reachable, instead of the hosted chat model. This module owns:
|
||||
runs on an explicitly configured local vision model when one is reachable,
|
||||
with the configured chat model as a fallback. This module owns:
|
||||
|
||||
* the local model base URLs / model id (settings-overridable),
|
||||
* an ordered candidate list (per-request ``agent_model`` override, then the
|
||||
@@ -30,18 +30,12 @@ import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The local Qwen3.5-9B vision server (seneca). :8097 (tmux AGENT_VLM) is the
|
||||
# canonical editor-agent endpoint. Override with the ``editor_agent_base_url``
|
||||
# setting (single URL wins) or the ``ODYSSEUS_EDITOR_AGENT_BASES`` env
|
||||
# (comma-separated). NOTE: :8095 is a DIFFERENT server (Qwen3-VL-30B) used ONLY
|
||||
# for the caption fallback below — never list it here as an agent model.
|
||||
DEFAULT_LOCAL_BASES = [
|
||||
"http://100.69.120.65:8097/v1",
|
||||
]
|
||||
# Local endpoints are opt-in through settings or ODYSSEUS_EDITOR_AGENT_BASES.
|
||||
DEFAULT_LOCAL_BASES = []
|
||||
DEFAULT_LOCAL_MODEL = "Qwen/Qwen3.5-9B"
|
||||
|
||||
# Vision caption model used when the editor-agent model cannot accept images.
|
||||
DEFAULT_CAPTION_BASE = "http://100.69.120.65:8095/v1"
|
||||
DEFAULT_CAPTION_BASE = ""
|
||||
DEFAULT_CAPTION_MODEL = "Qwen/Qwen3.5-9B"
|
||||
|
||||
# Served-model-id cache per base (populated on first use). The local servers 400
|
||||
@@ -278,6 +272,8 @@ async def caption_image(data_url: str, owner: Optional[str] = None, timeout: flo
|
||||
}
|
||||
]
|
||||
base = caption_base()
|
||||
if not base:
|
||||
return None
|
||||
model = await _resolve_model(base, caption_model(), local_model())
|
||||
return await _post_chat(base, model, messages, None, timeout, max_tokens=200)
|
||||
|
||||
@@ -320,6 +316,9 @@ async def self_check(
|
||||
return {"done": True, "issues": [], "retry_hint": "",
|
||||
"reason": "unavailable", "text": "DONE"}
|
||||
bases = local_bases() or list(DEFAULT_LOCAL_BASES)
|
||||
if not bases:
|
||||
return {"done": True, "issues": [], "retry_hint": "",
|
||||
"reason": "unavailable", "text": "DONE"}
|
||||
base = bases[0]
|
||||
mdl = await _resolve_model(base, str(model or local_model()), local_model())
|
||||
messages = [{
|
||||
@@ -482,8 +481,9 @@ def _probe_worker() -> None:
|
||||
_PROBE[base] = ok
|
||||
logger.info("editor agent local base %s reachable=%s", base, ok)
|
||||
cap = caption_base()
|
||||
_PROBE[cap] = probe_models(cap)
|
||||
logger.info("editor agent caption base %s reachable=%s", cap, _PROBE[cap])
|
||||
if cap:
|
||||
_PROBE[cap] = probe_models(cap)
|
||||
logger.info("editor agent caption base %s reachable=%s", cap, _PROBE[cap])
|
||||
|
||||
|
||||
def startup_probe() -> None:
|
||||
|
||||
+23
-16
@@ -126,9 +126,8 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
|
||||
"email": row["account_email"], "default": not accounts})
|
||||
return {
|
||||
"accounts": list(accounts.values()),
|
||||
"output": "\n".join(f"{row['name']} <{row['email']}> ({row['id']})" for row in accounts.values())
|
||||
or "No fixture email accounts found.",
|
||||
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
||||
"output": "Fixture mailbox (default)",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
@@ -139,7 +138,14 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
rows = [
|
||||
_row(i, m) for i, m in enumerate(messages)
|
||||
if _matches(m, query) and _folder_matches(m.get("folder"), folder)
|
||||
and _account_matches(m, account)
|
||||
and (
|
||||
not account
|
||||
or account in {
|
||||
str(m.get("account") or "").strip().casefold(),
|
||||
str(m.get("account_email") or "").strip().casefold(),
|
||||
str(m.get("account_id") or "").strip().casefold(),
|
||||
}
|
||||
)
|
||||
]
|
||||
limit = args.get("max_results", args.get("limit", 20))
|
||||
try:
|
||||
@@ -150,7 +156,11 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
return {"output": "No emails found.", "emails": [], "exit_code": 0}
|
||||
output_lines = [f"Found {len(rows)} email(s):", ""]
|
||||
for index, r in enumerate(rows, start=1):
|
||||
summary = r["summary"].strip()
|
||||
source = next(
|
||||
(m for i, m in enumerate(messages) if _uid(i) == r["uid"]),
|
||||
{},
|
||||
)
|
||||
summary = str(source.get("summary") or source.get("body") or "").strip()
|
||||
output_lines.extend([
|
||||
f"{index}. **{r['subject']}**",
|
||||
f" From: {r['from']}",
|
||||
@@ -168,17 +178,14 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
uid = str(args.get("uid") or "")
|
||||
message_id = str(args.get("message_id") or "")
|
||||
folder = str(args.get("folder") or "INBOX")
|
||||
if not uid and not message_id:
|
||||
return {"error": "read_email requires a UID or Message-ID.", "exit_code": 1}
|
||||
matches = [(m, _row(i, m)) for i, m in enumerate(messages)
|
||||
if _folder_matches(m.get("folder"), folder) and _account_matches(m, args.get("account"))
|
||||
and (not uid or str(m.get("uid") or _uid(i)) == uid)
|
||||
and (not message_id or str(m.get("message_id") or "") == message_id)]
|
||||
if not matches:
|
||||
return {"error": f"Email UID/Message-ID {uid or message_id} not found.", "exit_code": 1}
|
||||
if len(matches) != 1:
|
||||
return {"error": "Email identity is ambiguous; specify its account and folder.", "exit_code": 1}
|
||||
message, row = matches[0]
|
||||
try:
|
||||
index = int(uid) - 1
|
||||
except (TypeError, ValueError):
|
||||
index = -1
|
||||
if index < 0 or index >= len(messages) or not _folder_matches(messages[index].get("folder"), folder):
|
||||
return {"error": f"Email UID {uid} not found.", "exit_code": 1}
|
||||
message = messages[index]
|
||||
row = _row(index, message)
|
||||
output = (
|
||||
f"UID: {row['uid']}\nSubject: {row['subject']}\nFrom: {row['from']}\n"
|
||||
f"Date: {row['date']}\n\n{message.get('body') or ''}"
|
||||
|
||||
@@ -47,6 +47,13 @@ DEFAULT_SETTINGS = {
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_quality": "medium",
|
||||
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
|
||||
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
|
||||
# back to the hosted chat model. See src/editor_agent_model.py.
|
||||
"editor_agent_base_url": "",
|
||||
"editor_agent_model": "",
|
||||
"editor_caption_base_url": "",
|
||||
"editor_caption_model": "",
|
||||
"vision_model": "",
|
||||
"vision_enabled": True,
|
||||
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
||||
|
||||
+33
-3
@@ -355,6 +355,36 @@ def _normalize_chat_endpoint(url: str) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def _resolve_research_settings(get_setting_fn):
|
||||
"""Resolve the scheduled-research settings with crash-proof fallbacks.
|
||||
|
||||
A hand-edited or agent-written data/settings.json can hold non-numeric
|
||||
values for the research_* keys; the bare int() reads previously raised
|
||||
inside TaskScheduler._execute_research_task and killed every scheduled
|
||||
research run until settings.json was fixed by hand (#5049).
|
||||
src/research_handler.py already guards the same reads defensively —
|
||||
this mirrors that for the scheduler. Valid values pass through
|
||||
unchanged (numeric strings like "4096" still parse).
|
||||
"""
|
||||
try:
|
||||
max_tokens = int(get_setting_fn("research_max_tokens", 8192))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
max_tokens = 8192
|
||||
try:
|
||||
extraction_timeout = int(
|
||||
get_setting_fn("research_extraction_timeout_seconds", 90) or 90
|
||||
)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
extraction_timeout = 90
|
||||
try:
|
||||
extraction_concurrency = int(
|
||||
get_setting_fn("research_extraction_concurrency", 3) or 3
|
||||
)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
extraction_concurrency = 3
|
||||
return max_tokens, extraction_timeout, extraction_concurrency
|
||||
|
||||
|
||||
class TaskScheduler:
|
||||
def __init__(self, session_manager):
|
||||
self._session_manager = session_manager
|
||||
@@ -2187,9 +2217,9 @@ class TaskScheduler:
|
||||
pass
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
|
||||
max_tokens = int(get_setting("research_max_tokens", 8192))
|
||||
extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90)
|
||||
extraction_concurrency = int(get_setting("research_extraction_concurrency", 3) or 3)
|
||||
max_tokens, extraction_timeout, extraction_concurrency = (
|
||||
_resolve_research_settings(get_setting)
|
||||
)
|
||||
|
||||
researcher = DeepResearcher(
|
||||
llm_endpoint=endpoint_url,
|
||||
|
||||
+10
-4
@@ -425,9 +425,13 @@ class ToolApprovalStore:
|
||||
# approval exists to admit an ungranted one. Seal its launch
|
||||
# boundary now, in the request's workspace, the same way an
|
||||
# ungranted filesystem action gets its root below (#6635).
|
||||
# With no workspace bound, bash/python run in the agent
|
||||
# workspace (agent_cwd), so the launch is sealed there.
|
||||
roots = request_authority.resource_roots
|
||||
if not roots and workspace:
|
||||
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
|
||||
if not roots:
|
||||
from src.tool_execution import default_agent_workspace
|
||||
roots = (FilesystemRoot.seal(workspace or default_agent_workspace(),
|
||||
owner=_normalized_owner(owner)),)
|
||||
if len(roots) == 1:
|
||||
from dataclasses import replace
|
||||
process_authority = replace(
|
||||
@@ -455,8 +459,10 @@ class ToolApprovalStore:
|
||||
if tool_name in NATIVE_FILESYSTEM_TOOLS and backend_operation is not None and isinstance(backend_operation.resource, NativeBackendResource):
|
||||
try:
|
||||
roots = request_authority.resource_roots if request_authority is not None else ()
|
||||
if not roots and workspace and (request_authority is None or not request_authority.inherited):
|
||||
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
|
||||
if not roots and (request_authority is None or not request_authority.inherited):
|
||||
from src.tool_execution import default_agent_workspace
|
||||
roots = (FilesystemRoot.seal(workspace or default_agent_workspace(),
|
||||
owner=_normalized_owner(owner)),)
|
||||
resource_operation = resolve_filesystem_operation(
|
||||
ExactOperation.normalize(tool_name, content), roots=roots, workspace=workspace or "",
|
||||
request_id=request_authority.request_id if request_authority is not None else "")
|
||||
|
||||
@@ -216,6 +216,20 @@ _register(
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"ai_edit_image"},
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"get_editor_screenshot"},
|
||||
ToolEffect.READ_PRIVATE,
|
||||
)
|
||||
_register(
|
||||
{"plan_edit", "run_editor_command"},
|
||||
ToolEffect.UI_SIDE_EFFECT,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"archive_email",
|
||||
|
||||
+29
-3
@@ -1068,6 +1068,10 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
if raw_path is None or not str(raw_path).strip():
|
||||
raise ValueError("path is required")
|
||||
expanded = os.path.expanduser(str(raw_path).strip())
|
||||
# Relative paths resolve where bash/python run (agent_cwd), not against
|
||||
# the server's cwd, which is outside the allowed roots (#6679).
|
||||
if not os.path.isabs(expanded):
|
||||
expanded = os.path.join(_AGENT_WORKDIR, expanded)
|
||||
resolved = os.path.realpath(expanded)
|
||||
|
||||
if _is_app_state_path(resolved):
|
||||
@@ -1221,6 +1225,12 @@ def agent_cwd() -> str:
|
||||
if vetted is None:
|
||||
raise RuntimeError("active workspace is not a safe real directory")
|
||||
return vetted
|
||||
return default_agent_workspace()
|
||||
|
||||
|
||||
def default_agent_workspace() -> str:
|
||||
"""The agent workspace under the data dir, where subprocesses run when no
|
||||
workspace is bound."""
|
||||
resolved = os.path.realpath(_AGENT_WORKDIR)
|
||||
if resolved not in _agent_readable_data_subdirs() or vet_workspace(resolved) != resolved:
|
||||
raise RuntimeError("agent workspace is not a safe real directory")
|
||||
@@ -1278,6 +1288,16 @@ _ADMIN_TOOLS = {
|
||||
"serve_preset",
|
||||
"stop_served_model",
|
||||
"cancel_download",
|
||||
# Read-only Cookbook tools reach require_admin routes over the internal
|
||||
# loopback without an owner check (see NON_ADMIN_BLOCKED_TOOLS in
|
||||
# src/tool_security.py), so they are admin-only at dispatch as well.
|
||||
"list_served_models",
|
||||
"tail_serve_output",
|
||||
"list_downloads",
|
||||
"search_hf_models",
|
||||
"list_cached_models",
|
||||
"list_serve_presets",
|
||||
"list_cookbook_servers",
|
||||
}
|
||||
|
||||
|
||||
@@ -1538,6 +1558,10 @@ def _agent_subprocess_env() -> dict:
|
||||
return base
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def _direct_fallback(
|
||||
tool: str,
|
||||
content: str,
|
||||
@@ -2173,7 +2197,8 @@ async def _execute_tool_block_impl(
|
||||
if tool == "generate_image":
|
||||
from src.ai_interaction import do_generate_image
|
||||
desc = "generate_image"
|
||||
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner))
|
||||
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
||||
and (active_resource_operation() is not None or tool != "apply_patch"
|
||||
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
||||
@@ -2333,7 +2358,8 @@ async def _execute_tool_block_impl(
|
||||
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
|
||||
elif tool == "edit_image":
|
||||
desc = "edit_image"
|
||||
result = await dispatched(do_edit_image(content, owner=owner))
|
||||
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||
result = await dispatched(do_edit_image(content, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||
elif tool == "edit_file":
|
||||
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
|
||||
desc = result.get("output") or result.get("error") or "edit_file"
|
||||
@@ -2577,4 +2603,4 @@ def format_tool_result(description: str, result: Dict) -> str:
|
||||
# External execution bridges are allowed to return canonical result shapes
|
||||
# without using the built-in tools' output cap. Bound the final model-facing
|
||||
# text here so one verbose command cannot consume the next request window.
|
||||
return _truncate("\n".join(parts))
|
||||
return _truncate("\n".join(parts))
|
||||
@@ -191,6 +191,10 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
|
||||
"app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
|
||||
"edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.",
|
||||
"plan_edit": "Plan a multi-step edit for the open image editor and return ordered editor ops to run.",
|
||||
"run_editor_command": "Run one deterministic editor op (adjustments, masked filters, selection, layers, import/place, export, screenshot) on the open image editor.",
|
||||
"get_editor_screenshot": "Capture the open image editor's current canvas as a PNG data URL for inspection.",
|
||||
"ai_edit_image": "Apply an AI image edit (remove/inpaint objects, generative replace, remove-bg, upscale, denoise, enhance-face) to the open image and place the result as a new layer. Use for object removal/addition/replacement; never approximate those with selection/adjustment/filter ops.",
|
||||
"trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.",
|
||||
"manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.",
|
||||
}
|
||||
|
||||
+83
-3
@@ -8,6 +8,7 @@ Extracted from agent_tools.py to keep schema definitions separate from
|
||||
tool parsing / execution logic.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -113,6 +114,15 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
|
||||
args["url"] = path
|
||||
args.pop("path", None)
|
||||
args.pop("file_path", None)
|
||||
elif (
|
||||
tool_type == "ui_control"
|
||||
and str(args.get("action") or "").casefold() == "open_panel"
|
||||
and not str(args.get("name") or "").strip()
|
||||
):
|
||||
panel = _first_nonempty_string(args, ("panel",))
|
||||
if panel:
|
||||
args["name"] = panel
|
||||
args.pop("panel", None)
|
||||
if tool_type == "manage_documents" and "limit" not in args and "max_results" in args:
|
||||
# Collection APIs use both names across the native tool surface. The
|
||||
# document contract calls this integer ``limit``.
|
||||
@@ -904,13 +914,13 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ui_control",
|
||||
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, calendar/schedule, email, sessions, notes, memories/brain, skills, settings, theme, cookbook; calendar supports month/week/year/agenda plus a date; Cookbook supports models/download, launch/serve, active/running, dependencies, and settings views), open_email_reply (legacy UI-only reply opener; prefer email MCP draft_email_reply for assistant-written reply drafts so a normal document-backed email draft is created), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation), get_theme, and get_toggles. When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
||||
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, calendar/schedule, email, sessions, notes, tasks, memories/brain, skills, settings, theme, cookbook; calendar supports month/week/year/agenda plus a date; Cookbook supports models/download, launch/serve, active/running, dependencies, and settings views), open_email_reply (legacy UI-only reply opener; prefer email MCP draft_email_reply for assistant-written reply drafts so a normal document-backed email draft is created), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation), get_theme, and get_toggles. When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["toggle", "open_panel", "open_email_reply", "set_mode", "switch_model", "set_theme", "create_theme", "get_theme", "get_toggles"],
|
||||
"description": "The UI action. Use set_theme for presets, create_theme to build a custom theme with any hex colors"},
|
||||
"name": {"type": "string", "description": "For toggle: web, bash, research, incognito, document_editor (aliases: shell, search, deepresearch, documents). For open_panel: documents, gallery, calendar/schedule, email, sessions, notes, brain/memories, skills, settings, theme/themes, cookbook; models and serve are Cookbook-view aliases. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
|
||||
"name": {"type": "string", "description": "For toggle: web, bash, research, incognito, document_editor (aliases: shell, search, deepresearch, documents). For open_panel: documents, gallery, calendar/schedule, email, sessions, notes, tasks, brain/memories, skills, settings, theme/themes, cookbook; models and serve are Cookbook-view aliases. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
|
||||
"view": {"type": "string", "description": "Optional open_panel subview: calendar day/week/month/year/agenda, or Cookbook models/download, launch/serve, active/running, dependencies, settings."},
|
||||
"value": {"type": "string", "description": "Value: on/off for toggle, agent/chat for set_mode, model name for switch_model, theme name for set_theme, or folder for open_email_reply"},
|
||||
"uid": {"type": "string", "description": "Email UID for open_email_reply"},
|
||||
@@ -1510,6 +1520,75 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "plan_edit",
|
||||
"description": "Plan a photo-editing program for the currently open image editor. Returns an ordered list of editor ops to run against the canvas. Route object removal, added content, or replaced content to ai_edit_image/generate_image; use deterministic ops only for tone/colour/filter/crop/text/selection edits. If no image provider is configured for a generative request, say so instead of planning a substitute deterministic edit. Use the canonical adjustment/filter shapes (adjustments.apply/applyMasked params {\"type\", \"params\"}; filters.apply/applyMasked params {\"id\"}); never invent adjustment types or filter ids. New ops: photo.autoTone; retouch.skin/blemish/teeth; selection.face/background. Portrait/look-better ('fix the skin', 'fix my teeth') -> selection.face then retouch.skin (and retouch.teeth for teeth) then ONE gentle photo.autoTone; never a timid single step. Low-key/'fix the lighting' on a dark image: at most ONE global lift (prefer photo.autoTone once or shadows-highlights); never stack autoTone/brightness/exposure/levels, and keep highlights <= 0. Temperature sign: moody/cool/cinematic -> white-balance temperature NEGATIVE; warm/golden/sunset -> POSITIVE. Background softer -> selection.background then filters.applyMasked {id:'blur-gaussian'} (never a plain global blur). A vague prompt with no concrete defect ('clean this up') -> return {\"reply\": <question>, \"steps\": [], \"ask_user\": {\"question\":..., \"options\":[...]}} with 2-3 concrete options instead of guessing.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "What the user wants done to the image"},
|
||||
"state": {"type": "object", "description": "Optional current editor state (layers, selection, document info)"}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_editor_command",
|
||||
"description": "Run a single deterministic operation on the open image editor and return its result. Use catalog ops such as adjustments.*, filters.applyMasked, selection.*, layers.*, document.importImage, layers.placeImage, export.png, screenshot. Do NOT use for AI/masked edits (use ai_edit_image) or new imagery (use generate_image). Canonical shapes: adjustments.apply/applyMasked params = {\"type\": <brightness-contrast|exposure|white-balance|hue-saturation|vibrance|black-white|shadows-highlights|levels|curves|color-balance|photo-filter|selective-color|gradient-map|invert|posterize|threshold|channel-mixer|color-lookup>, \"params\": {...}}; filters.apply/applyMasked params = {\"id\": <blur-gaussian|blur-motion|blur-zoom|sharpen|sharpen-edges|unsharp-mask|noise-add|noise-despeckle|pixelate-mosaic|pixelate-crystallize|stylize-emboss|stylize-find-edges|stylize-solarize|distort-twirl|distort-pinch|distort-spherize|distort-ripple|render-clouds>}. Never nest a numeric bag under {\"adjustments\": {...}} and never invent adjustment types or filter ids (no \"vignette\" or \"clarity\"). Additional canonical ops: photo.autoTone {}; retouch.skin {smooth,opacity,blemish,radius,threshold}; retouch.blemish {radius,threshold}; retouch.teeth {desat,bright}; selection.face {mode}; selection.background {mode,subject,fuzziness}. At most one global brightness/exposure lift (prefer shadows-highlights; keep highlights <= 0). Never stack global lifts.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
|
||||
"params": {"type": "object", "description": "Parameters for the op"},
|
||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
|
||||
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
||||
},
|
||||
"required": ["op"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_editor_screenshot",
|
||||
"description": "Capture the open image editor's current canvas as a PNG data URL. Use to inspect the result before choosing the next edit.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ai_edit_image",
|
||||
"description": "Apply an AI image edit and place the result as a NEW layer, never overwriting the original. Two modes. mode='instruction' (default): send the WHOLE image with a clear prompt and no mask — use a rephrased instruction that NAMES what must stay unchanged, e.g. 'replace the background with a sunny beach, keep the woman exactly the same'. Use this for background/scene replacement, changing the season or style, adding an object to the scene (a moon, a person), and removing diffuse distractions. mode='masked': masked inpaint on a selected region — use ONLY for an explicit regional removal of a clearly identified object ('remove the person on the left'); select the object first so the mask is tight. NEVER approximate object removal/addition/replacement with selection, adjustment, filter or transform ops; if no image provider is configured, report that instead of substituting a deterministic edit. Other ops: remove-bg, upscale (2x/4x), denoise, enhance-face.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": {"type": "string", "enum": ["remove-bg", "upscale", "denoise", "enhance-face", "inpaint", "generative"], "description": "AI edit operation"},
|
||||
"mode": {"type": "string", "enum": ["instruction", "masked"], "description": "instruction = whole-image edit with no mask (background/season/style/add-object/diffuse removal); masked = tight-mask regional removal of a selected object. Default instruction."},
|
||||
"prompt": {"type": "string", "description": "Instruction for inpaint/generative (in instruction mode, name what must stay unchanged)"},
|
||||
"image": {"type": "string", "description": "Optional source (data URL or gallery image id); defaults to the current canvas/screenshot"},
|
||||
"mask": {"type": "string", "description": "Optional base64 PNG mask (white = region to edit); required for mode='masked'"},
|
||||
"scale": {"type": "number", "description": "Upscale factor (2 or 4)"},
|
||||
"strength": {"type": "number", "description": "Denoise strength (0-1)"},
|
||||
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
|
||||
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
|
||||
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
|
||||
"session": {"type": "string", "description": "Editor session id"}
|
||||
},
|
||||
"required": ["op"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1922,6 +2001,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
]
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Converter: native function call -> ToolBlock
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2380,4 +2460,4 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
else:
|
||||
content = json.dumps(args)
|
||||
|
||||
return ToolBlock(tool_type, content)
|
||||
return ToolBlock(tool_type, content)
|
||||
@@ -79,6 +79,22 @@ NON_ADMIN_BLOCKED_TOOLS = BUILTIN_EMAIL_TOOLS | {
|
||||
"stop_served_model",
|
||||
"cancel_download",
|
||||
"adopt_served_model",
|
||||
# Read-only Cookbook inspection tools. They look harmless, but every one
|
||||
# of them calls a require_admin route over the internal loopback with the
|
||||
# process-wide INTERNAL_TOOL_TOKEN and never checks the session owner, so
|
||||
# a non-admin agent would inherit admin reach through them:
|
||||
# list_cached_models forwards a caller-chosen `host` into
|
||||
# /api/model/cached, which SSHes to that host with the app user's keys and
|
||||
# walks caller-chosen `model_dir`s; list_served_models / tail_serve_output
|
||||
# expose serve/download logs and running process command lines. Model
|
||||
# serving is admin-only in THREAT_MODEL.md, so block the read side too.
|
||||
"list_served_models",
|
||||
"tail_serve_output",
|
||||
"list_downloads",
|
||||
"search_hf_models",
|
||||
"list_cached_models",
|
||||
"list_serve_presets",
|
||||
"list_cookbook_servers",
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +143,7 @@ PLAN_MODE_READONLY_TOOLS = {
|
||||
"resolve_contact",
|
||||
"chat_with_model",
|
||||
"ask_teacher",
|
||||
"get_editor_screenshot",
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +180,7 @@ _PLAN_MODE_KNOWN_MUTATORS = {
|
||||
"download_model", "serve_model",
|
||||
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
||||
"generate_image", "edit_image", "trigger_research", "manage_research",
|
||||
"plan_edit", "run_editor_command", "ai_edit_image",
|
||||
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
||||
# mode even if the schema list fails to load.
|
||||
"bash", "python",
|
||||
|
||||
@@ -28,5 +28,6 @@ TOOL_TAGS = {
|
||||
"tail_serve_output", "list_downloads", "cancel_download", "search_hf_models",
|
||||
"list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model",
|
||||
"list_cookbook_servers", "edit_image", "trigger_research", "manage_research",
|
||||
"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image",
|
||||
"app_api",
|
||||
} | BUILTIN_EMAIL_TOOLS
|
||||
|
||||
@@ -325,6 +325,26 @@ def _task_structured_schedule(args, fallback_time=None):
|
||||
'cron_expression': f'{parsed.minute} {parsed.hour} * * {cron_days}'}
|
||||
|
||||
|
||||
def _admin_task_action_denied(owner: Optional[str], task_type: Optional[str], action_name: Optional[str]) -> Optional[Dict]:
|
||||
"""Apply the shared admin-only task-action policy to a tool call.
|
||||
|
||||
``POST /api/tasks`` and the scheduler both refuse ``run_local``,
|
||||
``run_script``, ``ssh_command`` and ``cookbook_serve`` action tasks for
|
||||
owners without admin task privileges (``src.task_action_policy``).
|
||||
Those actions reach ``subprocess.run(shell=True)`` / SSH with no sandbox,
|
||||
so the model-facing tool must apply the same policy at create/edit/
|
||||
resume/run time instead of storing a task the scheduler will only pause
|
||||
later. Returns the tool error dict to hand back, or None when allowed.
|
||||
"""
|
||||
from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
)
|
||||
if is_admin_only_task_action(task_type, action_name) and not owner_has_admin_task_privileges(owner):
|
||||
return {"error": f"Action '{action_name}' requires admin privileges", "exit_code": 1}
|
||||
return None
|
||||
|
||||
|
||||
async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_tasks tool calls: CRUD on scheduled tasks."""
|
||||
import uuid as _uuid
|
||||
@@ -462,6 +482,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"error": "Prompt is required for llm/research tasks", "exit_code": 1}
|
||||
if task_type == "action" and not args.get("action_name"):
|
||||
return {"error": "action_name is required for action tasks", "exit_code": 1}
|
||||
denied = _admin_task_action_denied(owner, task_type, args.get("action_name"))
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
# Compute next_run for schedule triggers
|
||||
next_run = None
|
||||
@@ -525,6 +548,14 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if owner and task.owner != owner:
|
||||
return {"error": "Access denied", "exit_code": 1}
|
||||
|
||||
# Check the policy against the task as it would be after the
|
||||
# edit, so an llm task can't be flipped into a shell action.
|
||||
next_task_type = args["task_type"] if args.get("task_type") is not None else task.task_type
|
||||
next_action = args["action_name"] if args.get("action_name") is not None else task.action
|
||||
denied = _admin_task_action_denied(owner, next_task_type, next_action)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
if 'weekdays' in args or 'day_of_month' in args:
|
||||
clock = task.scheduled_time
|
||||
if task.schedule == 'cron':
|
||||
@@ -624,6 +655,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if action == "pause":
|
||||
task.status = "paused"
|
||||
else:
|
||||
denied = _admin_task_action_denied(owner, task.task_type, task.action)
|
||||
if denied:
|
||||
return denied
|
||||
task.status = "active"
|
||||
if (task.trigger_type or "schedule") == "schedule":
|
||||
task.next_run = compute_next_run(
|
||||
@@ -644,6 +678,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return error
|
||||
if owner and task.owner != owner:
|
||||
return {"error": "Access denied", "exit_code": 1}
|
||||
denied = _admin_task_action_denied(owner, task.task_type, task.action)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
from src.event_bus import get_task_scheduler
|
||||
scheduler = get_task_scheduler()
|
||||
|
||||
+26
-3
@@ -37,6 +37,7 @@ FAMILY_TOOLS = {
|
||||
"sessions": frozenset({"list_sessions", "manage_session", "create_session", "send_to_session", "chat_with_model", "pipeline"}),
|
||||
"image_generation": frozenset({"generate_image"}),
|
||||
"image_editing": frozenset({"edit_image"}),
|
||||
"photo_editor": frozenset({"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image", "generate_image"}),
|
||||
"transcription": frozenset({"transcribe_media"}),
|
||||
"media_inspection": frozenset({"inspect_media"}),
|
||||
"ocr": frozenset({"extract_text"}),
|
||||
@@ -487,7 +488,8 @@ _BOUND_EDITOR_WRITE = re.compile(
|
||||
re.I,
|
||||
)
|
||||
_BOUND_EDITOR_IMPLICIT_REVISION = re.compile(
|
||||
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|"
|
||||
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|go\s+more\s+into|"
|
||||
r"g[aå]\s+mer\s+(?:in|ut)\s+p[aå]|"
|
||||
r"clean\s+(?:this|it|the\s+(?:text|draft|document|doc))\s+up|"
|
||||
r"give\s+(?:me\s+)?(?:feedback|a\s+critique|suggestions?|comments?)|"
|
||||
r"remove\b[^.;\n]{0,100}\b(?:mistakes?|errors?|inaccurac(?:y|ies)|misinformation)|"
|
||||
@@ -633,6 +635,7 @@ _REQUIRED_TOOLS = {
|
||||
"calendar": "manage_calendar", "notes": "manage_notes",
|
||||
"tasks": "manage_tasks", "skills": "manage_skills",
|
||||
"image_generation": "generate_image", "image_editing": "edit_image",
|
||||
"photo_editor": "plan_edit",
|
||||
"transcription": "transcribe_media", "media_inspection": "inspect_media", "ocr": "extract_text",
|
||||
}
|
||||
|
||||
@@ -2994,6 +2997,18 @@ def _natural_safe_inventory_operation(message: str, maximum: int | None = None)
|
||||
return RequiredReadOperation(tool, {"action": action} if action else {}, maximum)
|
||||
|
||||
|
||||
def note_read_intent(user_text: str) -> tuple[str, bool]:
|
||||
"""Return a quoted mention query and whether the note body is needed."""
|
||||
text = str(user_text or "")
|
||||
mention = re.search(r"\bmentions?\s+(['\"])([^'\"]{1,120})\1", text, re.I)
|
||||
query = mention.group(2).strip() if mention else ""
|
||||
needs_body = bool(
|
||||
re.search(r"\b(?:summari[sz]e|summary|main\s+topics)\b", text, re.I)
|
||||
or re.search(r"\b(?:show|open|read|view)\b.{0,80}\b(?:first|newest|most\s+recent)\s+note\b", text, re.I)
|
||||
)
|
||||
return query, needs_body
|
||||
|
||||
|
||||
def required_read_operation_for_request(message: str, history: Iterable = ()) -> RequiredReadOperation | None:
|
||||
"""Resolve complete list/read requests, or repeat an exact prior read.
|
||||
|
||||
@@ -4149,7 +4164,15 @@ def _families_for_tool(tool: str) -> frozenset[str]:
|
||||
if bare in {"list_sessions", "manage_session", "create_session", "send_to_session",
|
||||
"chat_with_model", "pipeline"}:
|
||||
return frozenset({"sessions"})
|
||||
return frozenset(family for family, tools in FAMILY_TOOLS.items() if bare in tools)
|
||||
# The photo_editor family is context-only: it is only ever selected by an
|
||||
# explicit editor request at the route, never inferred from message text.
|
||||
# Overlapping tools such as generate_image must still resolve to their real
|
||||
# product family, so exclude photo_editor rather than short-circuiting.
|
||||
return frozenset(
|
||||
family
|
||||
for family, tools in FAMILY_TOOLS.items()
|
||||
if bare in tools and family != "photo_editor"
|
||||
)
|
||||
|
||||
|
||||
def _clause_capabilities(text: str) -> set[str]:
|
||||
@@ -6325,7 +6348,7 @@ def resolve_full_inventory_contract(*, schemas: Iterable[dict], policy: ToolPoli
|
||||
"""Experimental trained inventory: permissions filter offers; model chooses actions."""
|
||||
families = frozenset({"calendar", "notes", "tasks", "skills", "memory", "documents",
|
||||
"email", "search_browser", "shell_files", "cookbook_admin",
|
||||
"image_editing", "image_generation"})
|
||||
"image_editing", "image_generation", "photo_editor"})
|
||||
# ``ui_control`` is the executable bridge for explicit client-interface
|
||||
# requests (for example, opening the gallery). It is not one of the ten
|
||||
# persisted-data families, but omitting it here makes the full-inventory
|
||||
|
||||
+70
-9
@@ -63,19 +63,80 @@ INTERNAL_UPLOAD_URL_RE = re.compile(
|
||||
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
|
||||
r"(?=$|[\s\"'<>\[\](){},;!?:&#]|\.(?![A-Za-z0-9]))"
|
||||
)
|
||||
PDF_SOURCE_UPLOAD_RE = re.compile(
|
||||
r"<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id="
|
||||
r"[\"']([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)[\"'][^>]*-->",
|
||||
# `<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id=["'](id)["'][^>]*-->` and
|
||||
# `\[Attachment:[^\]\r\n]*\|\s*id=(id)(?:\s*\||\s*\])` are matched by the
|
||||
# forward-only scanners below. As single regexes, every opener in a run with no
|
||||
# closing `>` / `]` rescanned to the end of that run: O(n^2) on chat content,
|
||||
# which is unbounded on persisted assistant output (CodeQL py/polynomial-redos).
|
||||
_PDF_SOURCE_OPEN_RE = re.compile(r"<!--\s*pdf(?:_form)?_source\b", re.IGNORECASE)
|
||||
_PDF_SOURCE_ID_RE = re.compile(
|
||||
r"\bupload_id=[\"']([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)[\"']",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
ATTACHMENT_REFERENCE_LINE_RE = re.compile(
|
||||
r"\[Attachment:[^\]\r\n]*\|\s*id="
|
||||
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
|
||||
r"(?:\s*\||\s*\])",
|
||||
_ATTACHMENT_REFERENCE_OPEN_RE = re.compile(r"\[Attachment:", re.IGNORECASE)
|
||||
_ATTACHMENT_REFERENCE_STOP_RE = re.compile(r"[\]\r\n]")
|
||||
_ATTACHMENT_REFERENCE_TAIL_RE = re.compile(
|
||||
r"\|\s*id=([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)(?:\s*\||\s*\])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _pdf_source_upload_ids(value: str) -> list[str]:
|
||||
"""IDs from `<!-- pdf_source ... upload_id="<id>" ... -->` comments.
|
||||
|
||||
Every opener before the next `>` shares that `>`: the comment matches only
|
||||
if `--` sits right before it, and then with the last `upload_id=` (the
|
||||
greedy `[^>]*` backtracks from the right). Otherwise all of those openers
|
||||
fail together, so the scan resumes after the `>`.
|
||||
"""
|
||||
found: list[str] = []
|
||||
pos = 0
|
||||
while True:
|
||||
opener = _PDF_SOURCE_OPEN_RE.search(value, pos)
|
||||
if opener is None:
|
||||
return found
|
||||
close = value.find(">", opener.end())
|
||||
if close < 0:
|
||||
return found
|
||||
if value[close - 2:close] == "--":
|
||||
last = None
|
||||
for last in _PDF_SOURCE_ID_RE.finditer(value, opener.end(), close):
|
||||
pass
|
||||
if last is not None:
|
||||
found.append(last.group(1))
|
||||
pos = close + 1
|
||||
|
||||
|
||||
def _attachment_reference_ids(value: str) -> list[str]:
|
||||
"""IDs from `[Attachment: name | id=<id> | ...]` reference lines.
|
||||
|
||||
The label scan stops at the first `]`/CR/LF, so every opener before that
|
||||
stop shares it and can only use the last `|` (scanning right to left)
|
||||
whose tail matches. If none does, all of those openers fail together and
|
||||
the scan resumes at the stop.
|
||||
"""
|
||||
found: list[str] = []
|
||||
pos = 0
|
||||
while True:
|
||||
opener = _ATTACHMENT_REFERENCE_OPEN_RE.search(value, pos)
|
||||
if opener is None:
|
||||
return found
|
||||
stop = _ATTACHMENT_REFERENCE_STOP_RE.search(value, opener.end())
|
||||
end = stop.start() if stop else len(value)
|
||||
tail = None
|
||||
pipe = value.rfind("|", opener.end(), end)
|
||||
while pipe >= 0:
|
||||
tail = _ATTACHMENT_REFERENCE_TAIL_RE.match(value, pipe)
|
||||
if tail is not None:
|
||||
break
|
||||
pipe = value.rfind("|", opener.end(), pipe)
|
||||
if tail is None:
|
||||
pos = end
|
||||
else:
|
||||
found.append(tail.group(1))
|
||||
pos = tail.end()
|
||||
|
||||
|
||||
def is_valid_upload_id(upload_id: str) -> bool:
|
||||
"""Return True when *upload_id* matches the canonical uploads.json id format."""
|
||||
return UPLOAD_ID_RE.fullmatch(upload_id or "") is not None
|
||||
@@ -110,8 +171,8 @@ def extract_internal_upload_ids(value: Any) -> set[str]:
|
||||
return set()
|
||||
return (
|
||||
set(INTERNAL_UPLOAD_URL_RE.findall(value))
|
||||
| set(PDF_SOURCE_UPLOAD_RE.findall(value))
|
||||
| set(ATTACHMENT_REFERENCE_LINE_RE.findall(value))
|
||||
| set(_pdf_source_upload_ids(value))
|
||||
| set(_attachment_reference_ids(value))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ _SHARED_ADDRESS_SPACE_V4 = ipaddress.ip_network("100.64.0.0/10")
|
||||
# membership test excludes and which stays rejected as reserved.
|
||||
_NAT64_WELL_KNOWN_PREFIX_V6 = ipaddress.ip_network("64:ff9b::/96")
|
||||
|
||||
_LOCALHOST_NAMES = frozenset({"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"})
|
||||
# Alibaba's metadata endpoint is shared address space, not link-local.
|
||||
_METADATA_IPS = {ipaddress.ip_address("100.100.100.200")}
|
||||
|
||||
|
||||
def _nat64_well_known_embedded_ipv4(
|
||||
ip: ipaddress._BaseAddress,
|
||||
@@ -92,6 +96,8 @@ def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[st
|
||||
if reason:
|
||||
return f"NAT64 translated destination blocked: {reason}"
|
||||
return None
|
||||
if ip in _METADATA_IPS:
|
||||
return f"metadata endpoint blocked: {ip}"
|
||||
if ip.is_link_local:
|
||||
return f"link-local address blocked (SSRF metadata risk): {ip}"
|
||||
# IPv6 loopback is also reserved; its policy must match IPv4 loopback.
|
||||
@@ -161,6 +167,58 @@ def check_outbound_url(
|
||||
|
||||
|
||||
|
||||
def check_outbound_host(
|
||||
host: str,
|
||||
*,
|
||||
block_private: bool = False,
|
||||
resolver: Optional[Callable[[str], List[str]]] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Validate a bare mail hostname/IP, rejecting unresolved destinations.
|
||||
|
||||
Saving an unresolvable hostname is unsafe: another consumer of the stored
|
||||
configuration may later connect after its DNS records change. Live mail
|
||||
connections separately pin their sockets with connect_outbound_tcp.
|
||||
"""
|
||||
if not isinstance(host, str):
|
||||
return False, "host must be a string"
|
||||
host = host.strip()
|
||||
if not host:
|
||||
return False, "host is required"
|
||||
if any(ch.isspace() for ch in host) or any(ch in host for ch in "/\\@?#"):
|
||||
return False, "host must be a bare hostname or IP address"
|
||||
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||||
try:
|
||||
ip = ipaddress.ip_address(literal.split("%")[0])
|
||||
except ValueError:
|
||||
if ":" in literal:
|
||||
return False, "host must be a bare hostname or IP address"
|
||||
else:
|
||||
reason = _classify(ip, block_private=block_private)
|
||||
return (False, reason) if reason else (True, "ok")
|
||||
name = host.lower().rstrip(".")
|
||||
if name in _LOCALHOST_NAMES or name.endswith(".localhost"):
|
||||
if block_private:
|
||||
return False, f"private/shared/loopback address blocked: {host}"
|
||||
return True, "ok"
|
||||
try:
|
||||
ips = (resolver or _default_resolver)(host)
|
||||
if not ips:
|
||||
return False, "host does not resolve to an IP"
|
||||
saw_ip = False
|
||||
for raw in ips:
|
||||
# Malformed answers fail closed, including mixed valid/invalid ones.
|
||||
ip = ipaddress.ip_address(raw.split("%")[0])
|
||||
saw_ip = True
|
||||
reason = _classify(ip, block_private=block_private)
|
||||
if reason:
|
||||
return False, reason
|
||||
except Exception as exc:
|
||||
return False, f"host does not resolve to valid IP addresses: {exc}"
|
||||
if not saw_ip:
|
||||
return False, "host does not resolve to an IP"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
class OutboundAddressBlocked(PermissionError):
|
||||
"""A non-HTTP outbound host resolves into a disallowed address range."""
|
||||
|
||||
@@ -183,7 +241,15 @@ def connect_outbound_tcp(
|
||||
a rebinding name pass the check with a public answer and then connect to
|
||||
metadata/private space. TLS callers keep wrapping the returned socket with
|
||||
``server_hostname=host``, so SNI and certificate checks are unchanged.
|
||||
|
||||
An injected resolver must implement socket.getaddrinfo's argument and
|
||||
result contract. Resolver errors propagate without an unvetted retry.
|
||||
"""
|
||||
if isinstance(port, bool) or not isinstance(port, (int, str)):
|
||||
raise ValueError("port must be an integer between 1 and 65535")
|
||||
port = int(port)
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("port must be an integer between 1 and 65535")
|
||||
resolve = resolver or socket.getaddrinfo
|
||||
infos = resolve(host, port, 0, socket.SOCK_STREAM)
|
||||
for _family, _type, _proto, _canon, sockaddr in infos:
|
||||
|
||||
Reference in New Issue
Block a user