mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 00:02:21 +01:00
feat(harness): authority, turn contract, tools, clean_agent_preview
Chat/agent harness work: request-authority model, turn contract, tool
policy/parsing/execution, clean_agent_preview streaming/thinking, ask_user
persistence, session probes, and scheduler.
Source: vendor/photocraft (ad1cff44) harness files, 3-way merged onto
upstream/dev (a8c147b2). Harness core files were independently developed
in both lineages; resolution policy documented in pr_work/C_conflicts.md
(vendor kept where ahead, upstream kept where vendor is behind; full delta
preserved in pr_work/C_upstream_delta.patch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a8c147b238
commit
364229f2dc
@@ -54,6 +54,7 @@ def workspace_artifact_is_usable(path: Path) -> bool:
|
||||
class EvidenceKind(str, Enum):
|
||||
TOOL_RESULT = "tool_result"
|
||||
ARTIFACT_MUTATION = "artifact_mutation"
|
||||
DOCUMENT_MUTATION = "document_mutation"
|
||||
ARTIFACT_VALIDATION = "artifact_validation"
|
||||
VERIFIER_RESULT = "verifier_result"
|
||||
MEDIA_INGRESS = "media_ingress"
|
||||
@@ -131,6 +132,20 @@ class CompletionDecision:
|
||||
|
||||
|
||||
_ARTIFACT_PATH = r"(?:/|\./|\.\./)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12}"
|
||||
# Document-editor tools mutate a server-side document rather than a filesystem
|
||||
# path, so their successful results are recorded as a distinct evidence kind.
|
||||
_DOCUMENT_MUTATION_TOOLS = frozenset({"create_document", "update_document", "edit_document"})
|
||||
# Owned private-action writes (calendar, tasks, notes, memory, contacts) mutate
|
||||
# server-side state that carries no filesystem path, so a successful
|
||||
# authoritative result is their mutation evidence. Without this, a verified
|
||||
# calendar/task create is discarded as an "unsupported execution claim".
|
||||
_PRIVATE_WRITE_ACTIONS: Mapping[str, frozenset[str]] = {
|
||||
"manage_calendar": frozenset({"create_event", "update_event", "delete_event"}),
|
||||
"manage_contact": frozenset({"add", "update", "edit", "delete"}),
|
||||
"manage_memory": frozenset({"add", "edit", "delete"}),
|
||||
"manage_notes": frozenset({"add", "update", "delete", "toggle_item"}),
|
||||
"manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}),
|
||||
}
|
||||
_ARTIFACT_REQUEST_RE = re.compile(
|
||||
rf"\b(?:writ(?:e|ten)|creat(?:e|ed)|make|made|sav(?:e|ed)|produc(?:e|ed)|"
|
||||
rf"generat(?:e|ed)|export(?:ed)?|edit(?:ed)?|modif(?:y|ied)|updat(?:e|ed)|"
|
||||
@@ -639,6 +654,9 @@ class EvidenceLedger:
|
||||
# order of actions so receipt evidence and effects share one ordering.
|
||||
self.effects: list[dict[str, Any]] = []
|
||||
self._action_order: dict[str, int] = {}
|
||||
# Successful authoritative owned-state writes (calendar/tasks/notes/
|
||||
# memory/contacts) that an unnamed mutation claim may cite.
|
||||
self._private_action_mutations: list[str] = []
|
||||
|
||||
def record_effects(self, entries: Iterable[Mapping[str, Any]], action_order: Mapping[str, int],
|
||||
partial_reads: Iterable[str] = ()) -> None:
|
||||
@@ -898,6 +916,29 @@ class EvidenceLedger:
|
||||
detail="post-write artifact inspection",
|
||||
)
|
||||
|
||||
if tool in _DOCUMENT_MUTATION_TOOLS:
|
||||
# A successful, authoritative document write is evidence of a
|
||||
# document mutation even though it has no filesystem path.
|
||||
self._append(
|
||||
kind=EvidenceKind.DOCUMENT_MUTATION,
|
||||
success=success,
|
||||
authoritative=authoritative,
|
||||
source=event,
|
||||
detail=tool,
|
||||
)
|
||||
|
||||
if tool in _PRIVATE_WRITE_ACTIONS:
|
||||
try:
|
||||
write_args = json.loads(command or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
write_args = None
|
||||
action = (
|
||||
str(write_args.get("action") or "").strip().lower()
|
||||
if isinstance(write_args, Mapping) else ""
|
||||
)
|
||||
if action in _PRIVATE_WRITE_ACTIONS[tool] and authoritative and success:
|
||||
self._private_action_mutations.append(f"{tool}:{action}")
|
||||
|
||||
if tool in {"bash", "host_shell"} and (command_is_test(_command_text(command)) or _matches_declared_verifier(
|
||||
command,
|
||||
self.requirements.verifier_commands,
|
||||
@@ -969,6 +1010,22 @@ class EvidenceLedger:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _supports_document_mutation(self) -> bool:
|
||||
"""A successful authoritative document write supports an unnamed
|
||||
document-mutation claim, which carries no filesystem path to match."""
|
||||
return any(event.kind == EvidenceKind.DOCUMENT_MUTATION
|
||||
and event.authoritative and event.success
|
||||
for event in self.events)
|
||||
|
||||
def _supports_mutation_claim(self, paths: Sequence[str]) -> bool:
|
||||
"""Match a mutation claim to a file edit, a document write, or a
|
||||
successful owned-state action when the claim names no file path."""
|
||||
if self._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
|
||||
return True
|
||||
if paths:
|
||||
return False
|
||||
return self._supports_document_mutation() or bool(self._private_action_mutations)
|
||||
|
||||
def record_media_ingress(self, metadata: Mapping[str, Any]) -> None:
|
||||
for artifact in metadata.get("artifacts") or []:
|
||||
if not isinstance(artifact, Mapping):
|
||||
|
||||
@@ -316,7 +316,12 @@ class RequestAuthority:
|
||||
|
||||
|
||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"})
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info",
|
||||
"batch"})
|
||||
# Interaction classes are admitted on a strict router (spec) surface whenever
|
||||
# the server itself offered private_browser: the router decides the tool set,
|
||||
# so requiring the verb in the text would break "browse X then click Y".
|
||||
_BROWSER_INTERACTION_ACTIONS = frozenset({"click", "fill", "type", "press", "evaluate", "select"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -363,7 +368,8 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None,
|
||||
spec_surface=False):
|
||||
"""Deterministic server policy over semantic facts, never schema inventory."""
|
||||
if not isinstance(request_text, str):
|
||||
raise TypeError("Authority requires trusted request text")
|
||||
@@ -372,10 +378,25 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
families = intent.capabilities
|
||||
if capabilities is not None:
|
||||
families |= frozenset(capabilities)
|
||||
if spec_surface:
|
||||
# Strict router (spec) surface: agent_loop builds the final tool set in
|
||||
# src/spec_router.py AFTER this runs, so turn_contract.offered is still
|
||||
# the compact inventory here. The router already limits schemas to its
|
||||
# picks + sticky prior tools + ask_user/update_plan, and
|
||||
# _resolve_tool_blocks drops calls to unoffered tools, so admit every
|
||||
# family and let the spec surface be the boundary. Selected-tool
|
||||
# narrowing is skipped for the same reason.
|
||||
families |= frozenset(FAMILY_TOOLS)
|
||||
tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families))
|
||||
selected = intent.selected_tools
|
||||
selected = None if spec_surface else intent.selected_tools
|
||||
if selected is not None:
|
||||
tools = tools & set(selected) if families else set(selected)
|
||||
if "web_search" in tools:
|
||||
# Search selection narrows to the query tool, but reading a source
|
||||
# it surfaces is the same bounded read operation. Without the
|
||||
# sibling reader every discovered result is a dead end and the
|
||||
# turn fails closed on the first web_fetch of a found URL.
|
||||
tools.add("web_fetch")
|
||||
if tools & {"web_search", "web_fetch"}:
|
||||
tools.add("private_browser")
|
||||
operation = intent.required_read
|
||||
@@ -389,13 +410,28 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
actions = inputs = None
|
||||
if name == "private_browser":
|
||||
actions = _BROWSER_READ_ACTIONS
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
if spec_surface:
|
||||
# The strict router itself offered a working browser: admit its
|
||||
# interaction classes too, not only when the verb is in the text.
|
||||
actions |= _BROWSER_INTERACTION_ACTIONS
|
||||
elif re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select")
|
||||
if re.search(r"\b" + action + r"\b", request_text, re.I))
|
||||
if operation is not None and name == canonical_tool(operation.tool):
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input})
|
||||
required_args = dict(operation.args)
|
||||
if set(required_args) <= {"action"}:
|
||||
# An action-only read ("list my tasks/memories") pins the action
|
||||
# class, not the exact JSON: a model that adds benign arguments
|
||||
# (limit/offset/filter) still performs the same admitted read.
|
||||
# Reads that name an identity keep the exact-input seal below.
|
||||
required_action = str(required_args.get("action") or "").strip().casefold() or None
|
||||
if required_action:
|
||||
actions = (frozenset({required_action}) if actions is None
|
||||
else actions & frozenset({required_action}))
|
||||
else:
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(required_args)).input})
|
||||
grants.append(OperationGrant(name, actions, inputs))
|
||||
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
||||
str(workspace or ""), tuple(grants))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""One presentation gate between agent execution and externally visible prose.
|
||||
|
||||
Tool, progress and interaction events stay live. Answer deltas are held until
|
||||
the generator unwinds so a later replacement cannot conceal an earlier false
|
||||
Tool, progress and interaction events stay live. Evidence-safe complete
|
||||
sentences from ordinary answers may stream before the terminal check; execution
|
||||
answers remain gated so a later replacement cannot conceal an earlier false
|
||||
claim. This consumes no provider calls. Cancellation closes the inner generator
|
||||
under the same journal/turn authority; it never emits a successful terminal event.
|
||||
"""
|
||||
@@ -123,7 +124,7 @@ def _supported_prose(text: str, ledger: EvidenceLedger, decision: CompletionDeci
|
||||
or not ledger._supports_verifier_claim(identities, paths)):
|
||||
why = 'no current passing executable verification supports the claim'
|
||||
elif claim == 'mutation':
|
||||
if not ledger._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
|
||||
if not ledger._supports_mutation_claim(paths):
|
||||
why = 'no matching artifact mutation supports the execution claim'
|
||||
elif claim == 'execution':
|
||||
# A generic assertion cannot be tied confidently to a receipt.
|
||||
@@ -167,6 +168,16 @@ def _disclosure(answer: str, ledger: EvidenceLedger) -> str:
|
||||
return ('\n\n' + summary) if answer.strip() else summary
|
||||
|
||||
|
||||
def _complete_prose_prefix(text: str) -> str:
|
||||
"""Return complete statement segments, keeping the current sentence pending."""
|
||||
statements = list(_unquoted_statements(text))
|
||||
if not statements:
|
||||
return ''
|
||||
if statements[-1][0].strip():
|
||||
statements.pop()
|
||||
return ''.join(statement for statement, _ in statements)
|
||||
|
||||
|
||||
def _completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
|
||||
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
|
||||
execution_required = _execution_obligation(ledger.requirements)
|
||||
@@ -232,6 +243,10 @@ def with_completion_gate(func):
|
||||
journal.effects = (parent.effects if parent is not None and parent.effects is not None
|
||||
else EffectLog(journal.run_id))
|
||||
answer_events: list[dict] = []
|
||||
streamed_answer_events: set[int] = set()
|
||||
streamed_safe_text = ''
|
||||
stream_conflict = False
|
||||
first_visible_answer_at = None
|
||||
metrics_events: list[dict] = []
|
||||
answer = ''
|
||||
has_final = False
|
||||
@@ -297,18 +312,36 @@ def with_completion_gate(func):
|
||||
first_answer_at = perf_counter()
|
||||
# Boolean thinking=True marks a reasoning-only delta;
|
||||
# a textual thinking companion must not hide an answer
|
||||
# delta. Both shapes remain buffered until the gate.
|
||||
# delta. Thinking remains buffered until terminal review.
|
||||
if isinstance(data.get('thinking'), str):
|
||||
answer_events.append({'delta': data['thinking'], 'thinking': True})
|
||||
thinking_event = {'delta': data['thinking'], 'thinking': True}
|
||||
answer_events.append(thinking_event)
|
||||
data = {key: value for key, value in data.items() if key != 'thinking'}
|
||||
if 'delta' not in data:
|
||||
continue
|
||||
if data.get('thinking') is not True and 'delta' in data:
|
||||
if has_final:
|
||||
stream_conflict |= bool(streamed_safe_text)
|
||||
streamed_safe_text = ''
|
||||
answer = ''
|
||||
has_final = False
|
||||
answer += str(data.get('delta') or '')
|
||||
answer_events.append(data)
|
||||
if data.get('thinking') is not True and 'delta' in data \
|
||||
and not _execution_obligation(requirements):
|
||||
prefix = _complete_prose_prefix(answer)
|
||||
if prefix:
|
||||
ledger_now = _ledger(journal, requirements)
|
||||
decision_now = ledger_now.evaluate(exhausted=exhausted, awaiting_user=awaiting)
|
||||
safe_prefix, _ = _supported_prose(prefix, ledger_now, decision_now)
|
||||
if safe_prefix.startswith(streamed_safe_text):
|
||||
delta = safe_prefix[len(streamed_safe_text):]
|
||||
if delta:
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event({'delta': delta})
|
||||
streamed_safe_text = safe_prefix
|
||||
elif streamed_safe_text:
|
||||
stream_conflict = True
|
||||
continue
|
||||
yield chunk
|
||||
if provider_error and not answer_events and not metrics_events:
|
||||
@@ -361,7 +394,10 @@ def with_completion_gate(func):
|
||||
disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft
|
||||
or filtered_answer != answer)
|
||||
replaced_answer = not disclosure_only and bool(
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer)
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer or stream_conflict
|
||||
or (provider_error and streamed_safe_text))
|
||||
if streamed_safe_text and not answer.startswith(streamed_safe_text):
|
||||
replaced_answer = True
|
||||
if replaced_answer:
|
||||
reasoning = [event for event in answer_events if event.get('thinking') is True]
|
||||
_, unsafe_reasoning = completion_answer(
|
||||
@@ -369,11 +405,31 @@ def with_completion_gate(func):
|
||||
replace(presentation_decision, can_complete=True))
|
||||
if not unsafe_reasoning:
|
||||
for event in reasoning:
|
||||
yield _event(event)
|
||||
yield _event({'type': 'final_response', 'content': safe_answer})
|
||||
if id(event) not in streamed_answer_events:
|
||||
yield _event(event)
|
||||
yield _event({
|
||||
'type': 'final_response',
|
||||
'content': safe_answer,
|
||||
'render_owner': 'streamed',
|
||||
'replacement_scope': 'turn',
|
||||
})
|
||||
else:
|
||||
for event in answer_events:
|
||||
yield _event(event)
|
||||
if event.get('thinking') is True:
|
||||
if id(event) not in streamed_answer_events:
|
||||
streamed_answer_events.add(id(event))
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event(event)
|
||||
elif event.get('type') == 'final_response':
|
||||
if streamed_safe_text:
|
||||
event = {**event, 'replacement_scope': 'turn'}
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event(event)
|
||||
if not has_final:
|
||||
remainder = answer[len(streamed_safe_text):] if answer.startswith(streamed_safe_text) else answer
|
||||
if remainder:
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event({'delta': remainder})
|
||||
if disclosure_only:
|
||||
yield _event({'delta': disclosure})
|
||||
if provider_error:
|
||||
@@ -386,8 +442,11 @@ def with_completion_gate(func):
|
||||
if ledger.effects:
|
||||
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
|
||||
metadata['completion_gate'] = {
|
||||
'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': released_at - started,
|
||||
'buffer_seconds': max(0, (first_visible_answer_at or released_at) - first_answer_at)
|
||||
if first_answer_at is not None else 0,
|
||||
'evaluation_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': first_visible_answer_at - started
|
||||
if first_visible_answer_at is not None else 0,
|
||||
'additional_provider_calls': 0,
|
||||
'answer_replaced': replaced_answer,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -496,6 +496,7 @@ def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional
|
||||
"content": clean,
|
||||
"version": 1,
|
||||
"source_doc_id": getattr(source_doc, "id", None),
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -618,6 +619,7 @@ class CreateDocumentTool:
|
||||
"language": language,
|
||||
"content": content,
|
||||
"version": 1,
|
||||
"exit_code": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -708,6 +710,7 @@ class UpdateDocumentTool:
|
||||
"language": doc.language,
|
||||
"content": new_content,
|
||||
"version": new_ver,
|
||||
"exit_code": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -863,6 +866,7 @@ class EditDocumentTool:
|
||||
"version": new_ver,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {"error": "No edits applied — FIND text cannot be blank"}
|
||||
|
||||
@@ -968,6 +972,7 @@ class EditDocumentTool:
|
||||
"version": new_ver,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"exit_code": 0,
|
||||
}
|
||||
if partial_edits:
|
||||
result.update({
|
||||
@@ -1059,6 +1064,7 @@ class SuggestDocumentTool:
|
||||
"partial": bool(invalid),
|
||||
"rejected": len(invalid),
|
||||
"invalid_suggestions": invalid,
|
||||
"exit_code": 0,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -14,6 +14,7 @@ import tempfile
|
||||
import time
|
||||
import html
|
||||
import hashlib
|
||||
import pwd
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
@@ -31,7 +32,10 @@ _ACTIVE_BROWSER_SESSIONS: set[str] = set()
|
||||
def _service_home() -> Path:
|
||||
"""Return the account home even when a task overrides ``HOME``."""
|
||||
|
||||
return platform_compat.service_home()
|
||||
try:
|
||||
return Path(pwd.getpwuid(os.getuid()).pw_dir)
|
||||
except (KeyError, OSError):
|
||||
return Path.home()
|
||||
|
||||
|
||||
def _host_npm_roots() -> list[Path]:
|
||||
|
||||
+60
-19
@@ -464,8 +464,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
return {"error": "Edit needs line 2: memory_id, line 3: new text"}
|
||||
memory_id = lines[1].strip()
|
||||
new_text = lines[2].strip()
|
||||
if not memory_id:
|
||||
return {"error": "Edit needs line 2: memory_id"}
|
||||
if not new_text:
|
||||
return {"error": "New text cannot be empty"}
|
||||
|
||||
@@ -499,8 +497,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
if len(lines) < 2:
|
||||
return {"error": "Delete needs line 2: memory_id"}
|
||||
memory_id = lines[1].strip()
|
||||
if not memory_id:
|
||||
return {"error": "Delete needs line 2: memory_id"}
|
||||
|
||||
memories = _memory_manager.load_all()
|
||||
original_len = len(memories)
|
||||
@@ -1155,7 +1151,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:
|
||||
@@ -1314,7 +1353,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,15 +1404,19 @@ 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")))
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
image_id = _save_to_gallery(filename)
|
||||
img_path.write_bytes(image_bytes)
|
||||
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)
|
||||
@@ -1387,13 +1430,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)
|
||||
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:
|
||||
@@ -1430,6 +1467,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
|
||||
@@ -1503,7 +1541,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:
|
||||
@@ -1533,6 +1571,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}"
|
||||
|
||||
@@ -17,10 +17,13 @@ def compact_browser_observation(value, budget=8000):
|
||||
notices.append('Exit code: ' + str(item['exit_code']))
|
||||
if item.get('success') is False:
|
||||
notices.append('Browser command failed.')
|
||||
snapshot = item.get('snapshot') or item.get('text')
|
||||
if item.get('title'):
|
||||
notices.append('Title: ' + str(item['title']))
|
||||
snapshot = item.get('snapshot')
|
||||
if not isinstance(snapshot, str) or not snapshot.strip():
|
||||
snapshot = item.get('text')
|
||||
url = item.get('url') or item.get('origin')
|
||||
title = item.get('title')
|
||||
if isinstance(title, str) and title.strip():
|
||||
notices.append('Title: ' + title.strip())
|
||||
if isinstance(snapshot, str) and snapshot.strip():
|
||||
# Snapshot text already contains labels and refs in DOM order.
|
||||
# The refs mapping repeats them and buries menus in raw JSON.
|
||||
|
||||
+471
-406
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@ def auto_compact_threshold_percent() -> int:
|
||||
return max(50, min(95, value))
|
||||
|
||||
# Cursor-style self-summarization prompt — produces structured, dense summaries
|
||||
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation to preserve context after compaction. Produce a structured summary that lets the conversation continue seamlessly.
|
||||
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation with Ajax, the user's AI assistant, to preserve context after compaction. Produce a structured summary that lets Ajax continue seamlessly. Ajax is the assistant's name; Odysseus is the app name.
|
||||
|
||||
Use this format:
|
||||
|
||||
|
||||
@@ -102,15 +102,15 @@ class MemoryVectorStore:
|
||||
return collections
|
||||
|
||||
def add(self, memory_id: str, text: str):
|
||||
"""Add a single memory entry to the vector index."""
|
||||
"""Insert or refresh a memory entry, preserving unchanged embeddings."""
|
||||
if not self._healthy:
|
||||
return
|
||||
for lane in self._lanes:
|
||||
try:
|
||||
existing = lane.collection.get(ids=[memory_id])
|
||||
if existing["ids"]:
|
||||
existing = lane.collection.get(ids=[memory_id], include=["documents"])
|
||||
if existing["ids"] and existing["documents"] == [text]:
|
||||
continue
|
||||
lane.collection.add(
|
||||
lane.collection.upsert(
|
||||
ids=[memory_id],
|
||||
embeddings=lane.encode([text]),
|
||||
documents=[text],
|
||||
|
||||
@@ -56,7 +56,7 @@ def supports_user_thinking_toggle(value: object) -> bool:
|
||||
return False
|
||||
return any(pattern in leaf for pattern in (
|
||||
"kimi-k2.5", "kimi-k2.6", "kimi-k3",
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner",
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "deepseek-v4",
|
||||
"minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||
"magistral", "mistral-small", "mistral-medium",
|
||||
))
|
||||
|
||||
@@ -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).
|
||||
|
||||
+59
-22
@@ -1969,20 +1969,21 @@ class TaskScheduler:
|
||||
messages.append(datetime_context_msg)
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
# Recover the registered runtime URL and credentials for this chat route.
|
||||
# Resolve headers from the endpoint's API key
|
||||
headers = {}
|
||||
try:
|
||||
from core.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
|
||||
from src.auth_helpers import owner_filter
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
ep = resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None)
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||
endpoint_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||
eps = ep_q.all()
|
||||
for ep in eps:
|
||||
if same_endpoint_base(endpoint_url, ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
break
|
||||
finally:
|
||||
db2.close()
|
||||
except Exception:
|
||||
@@ -2169,23 +2170,26 @@ class TaskScheduler:
|
||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||
if not endpoint_url or not model:
|
||||
raise RuntimeError("No model/endpoint configured for research")
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
# Record the resolved model for the run record (see _execute_task_locked).
|
||||
self._last_run_model = model
|
||||
|
||||
# Authorize the selected URL before normalization can collapse service paths.
|
||||
# Resolve headers
|
||||
try:
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
from core.database import ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
|
||||
from src.auth_helpers import owner_filter
|
||||
db2 = db
|
||||
if not headers_from_resolver:
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||
endpoint_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||
eps = ep_q.all()
|
||||
for ep in eps:
|
||||
if same_endpoint_base(endpoint_url, ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
break
|
||||
except Exception:
|
||||
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)
|
||||
@@ -2501,6 +2505,36 @@ class TaskScheduler:
|
||||
ScheduledTask.task_type == "action",
|
||||
).all() if row[0]
|
||||
}
|
||||
# Built-in housekeeping rows created before the request-authority
|
||||
# seal carry a NULL snapshot, so every scheduled run failed with
|
||||
# "Scheduled action has no matching server request authority".
|
||||
# Replace them: drop the unsealed row and let the seed loop below
|
||||
# recreate an authorized copy (server-owned ingress), preserving the
|
||||
# user's enable/pause choice. We never grant authority to the
|
||||
# existing row, so a model-origin row can never gain a built-in
|
||||
# grant this way.
|
||||
replaced_actions = {}
|
||||
stale_rows = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
ScheduledTask.task_type == "action",
|
||||
ScheduledTask.action.in_(list(HOUSEKEEPING_DEFAULTS.keys())),
|
||||
).all()
|
||||
sealed_actions = {r.action for r in stale_rows
|
||||
if (r.request_authority_json or "").strip()}
|
||||
removed_status = {}
|
||||
for stale in stale_rows:
|
||||
if (stale.request_authority_json or "").strip():
|
||||
continue
|
||||
removed_status.setdefault(stale.action, stale.status)
|
||||
db.delete(stale)
|
||||
for action, status in removed_status.items():
|
||||
if action in sealed_actions:
|
||||
# A sealed copy already exists; the NULL one was just a
|
||||
# stale duplicate. Keep the sealed row and let the seed
|
||||
# loop skip this action.
|
||||
continue
|
||||
replaced_actions[action] = status
|
||||
existing_actions.discard(action)
|
||||
renamed = []
|
||||
builtin_tasks = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
@@ -2661,12 +2695,15 @@ class TaskScheduler:
|
||||
output_target=defs.get("output_target", "none"),
|
||||
notifications_enabled=False,
|
||||
)
|
||||
if action in replaced_actions and replaced_actions[action] in ("active", "paused"):
|
||||
task.status = replaced_actions[action]
|
||||
db.add(task)
|
||||
seeded.append(action)
|
||||
if seeded or renamed or removed_dupes or retired_count:
|
||||
if seeded or renamed or removed_dupes or retired_count or replaced_actions:
|
||||
logger.info(
|
||||
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s",
|
||||
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s authority_replaced=%s",
|
||||
owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count,
|
||||
sorted(replaced_actions),
|
||||
)
|
||||
# Always commit — the orphan-run sweep above may have produced
|
||||
# pending deletes even when no defaults changed.
|
||||
|
||||
+6
-2
@@ -111,7 +111,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"get_weather": "Get current weather and a three-day forecast for a city or place from Open-Meteo without an API key. Use for weather lookups before web_search.",
|
||||
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
|
||||
"pdf_extract": "Extract focused, source-attributed passages and exact table values from an online PDF or task-local /workspace/*.pdf. Use for arXiv papers, reports, manuals, PDF tables, evaluation metrics, and multi-document PDF extraction. Prefer this over Python requests, curl, downloading, pdftotext, or guessing. Include target model names, metrics, and table headings in query.",
|
||||
"youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. Use for YouTube comments/transcript/channel latest-video tasks.",
|
||||
"youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. YouTube ONLY — accepts only youtube.com or youtu.be links (video/shorts URL or 11-char video_id, or a channel /@handle/videos URL); for any other website use web_fetch or web_search instead.",
|
||||
"private_browser": "Trusted metadata for an existing server-registered browser session only. Page/document reads and interactions are unavailable because the configured producer cannot guarantee exact target binding. No model batch or raw browser commands. Use web_search or web_fetch for supported web access.",
|
||||
"inspect_media": "Inspect local workspace images, SVGs, videos, and PDF pages with the current multimodal model. Samples bounded timestamped video frames uniformly, at scene cuts, or from temporally diverse motion peaks; renders SVG to PNG; exports stills or clips; concatenates ranges; changes clip speed while preserving audio pitch; and renders query-relevant PDF pages. Prefer these native operations over raw ffmpeg. Increase max_dimension only for small visual details; saved exports keep source quality.",
|
||||
"extract_text": "Extract exact visible text, confidence, and pixel centers from a local workspace image with Odysseus local OCR. Use for screenshots, scans, labels, numbers, receipts, and text-location tasks; use inspect_media for general visual understanding.",
|
||||
@@ -129,7 +129,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
|
||||
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
|
||||
"suggest_document": "Suggest changes to the active document with explanations. For code review, proofreading, feedback requests.",
|
||||
"generate_image": "Generate an AI image from a text prompt. Specify model, size, and quality. Art, illustrations, photos.",
|
||||
"generate_image": "Generate a NEW AI image from a text prompt (add content not already in the photo, e.g. a moon). Specify model, size, and quality. Art, illustrations, photos. Not for tone/filter edits.",
|
||||
"chat_with_model": "Send a message to a different AI model. Compare responses, get specialized help, delegate tasks.",
|
||||
"ask_teacher": "Ask a more capable model for help with a difficult problem. Escalate complex tasks.",
|
||||
"pipeline": "Run a multi-step AI pipeline with multiple models. Chain tasks together in sequence.",
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
+76
-13
@@ -70,7 +70,7 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
|
||||
command = args.get("command")
|
||||
if isinstance(command, str):
|
||||
path, separator, content = command.partition("\n")
|
||||
if separator and path.strip() and content.strip():
|
||||
if separator and path.strip() and content:
|
||||
args["path"] = path.strip()
|
||||
args["content"] = content
|
||||
logger.warning("Normalized legacy `command` write shape to path/content")
|
||||
@@ -494,14 +494,14 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "youtube_tool",
|
||||
"description": "Read YouTube-specific data without fighting the JS UI. Use for YouTube video comments, transcripts, metadata, or latest video from a channel. For general web lookup use web_search; for interacting with the visible site use private_browser.",
|
||||
"description": "Read YouTube-specific data (comments, transcripts, metadata, or latest video from a channel) without fighting the YouTube JS UI. YOUTUBE ONLY: it accepts only youtube.com or youtu.be links — a video URL, a shorts/live link, or an 11-character video_id, or a channel /@handle/videos URL. It does NOT accept any other website. For a non-YouTube URL do not use this tool: use web_fetch for one specific page or web_search for an open-ended lookup; use private_browser only to drive the visible browser.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["comments", "transcript", "metadata", "latest_channel_video"], "description": "What YouTube data to retrieve. Use latest_channel_video with max_results for latest N uploads from a channel."},
|
||||
"url": {"type": "string", "description": "YouTube video or channel URL"},
|
||||
"video_url": {"type": "string", "description": "YouTube video URL"},
|
||||
"video_id": {"type": "string", "description": "YouTube video id"},
|
||||
"url": {"type": "string", "description": "A youtube.com or youtu.be URL (video, watch?v=, shorts, or a channel /@handle/videos URL). No other hosts."},
|
||||
"video_url": {"type": "string", "description": "A youtube.com or youtu.be video URL"},
|
||||
"video_id": {"type": "string", "description": "The 11-character YouTube video id (e.g. jNQXAC9IVRw)"},
|
||||
"channel_url": {"type": "string", "description": "YouTube channel URL or /@handle/videos URL"},
|
||||
"handle": {"type": "string", "description": "YouTube handle, e.g. @OpenAI"},
|
||||
"max_results": {"type": "integer", "description": "Maximum comments, uploads, or results to return, capped at 50"}
|
||||
@@ -1497,7 +1497,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_image",
|
||||
"description": "Generate an AI image from a text prompt and save it to the gallery. If this tool reports that no image model or endpoint is configured, report that limitation directly; do not substitute Bash, Python, SVG, or another tool.",
|
||||
"description": "Generate a NEW image from a text prompt and place it as a layer in the open editor or save it to the gallery. Use for adding content that is not already in the photo (e.g. 'add a full moon in the sky'). If this tool reports that no image model or endpoint is configured, report that limitation directly; do NOT substitute deterministic editor ops (selection/adjustments/filters), Bash, Python, or SVG.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1527,6 +1527,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": {
|
||||
@@ -2200,13 +2269,7 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
elif tool_type == "get_workspace":
|
||||
content = ""
|
||||
elif tool_type == "write_file":
|
||||
body = args.get("content")
|
||||
if isinstance(body, str) and body.strip():
|
||||
content = args.get("path", "") + "\n" + body
|
||||
else:
|
||||
# Preserve missing/empty intent for WriteFileTool instead of folding
|
||||
# all empty shapes into the same path-plus-newline representation.
|
||||
content = json.dumps(args)
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "edit_file":
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "apply_patch":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -227,7 +227,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
desc = str(raw_args.get("description") or "")
|
||||
if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
|
||||
raw = desc
|
||||
if raw in (None, "", False):
|
||||
if raw is None or raw == "" or raw is False:
|
||||
return None
|
||||
if raw is True:
|
||||
return 10
|
||||
@@ -236,10 +236,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
text = str(raw).strip().lower()
|
||||
if text in {"none", "no", "off", "false"}:
|
||||
return None
|
||||
m = re.search(r"(?<!\d)(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||
m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)))
|
||||
m = re.search(r"(?<!\d)(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||
m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)) * 60)
|
||||
if text.isdigit():
|
||||
@@ -251,7 +251,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
if minutes_before is None:
|
||||
return desc
|
||||
reminder_only = re.compile(
|
||||
r"^\s*(?:remind(?:er)?|alarm)\s*(?::\s*)?\d+\s*"
|
||||
r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
|
||||
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
|
||||
re.I,
|
||||
)
|
||||
@@ -497,8 +497,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
delta = None
|
||||
if dur:
|
||||
import re as _re_d
|
||||
h = _re_d.search(r'(?<!\d)(\d+)\s*(?:h|hr|hours?)', dur)
|
||||
m = _re_d.search(r'(?<!\d)(\d+)\s*(?:m|min|minutes?)', dur)
|
||||
h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
|
||||
m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
|
||||
secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
|
||||
if secs > 0:
|
||||
delta = timedelta(seconds=secs)
|
||||
|
||||
+1
-1
@@ -322,7 +322,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if looks_like_reminder:
|
||||
temporal = re.search(
|
||||
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
|
||||
r"|\b\d{1,2}(?::\d{2})?(?:\s*(?:am|pm))?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
|
||||
r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
|
||||
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
|
||||
lower_combined,
|
||||
)
|
||||
|
||||
@@ -61,3 +61,4 @@ def backend_workspace_path(path: str | None) -> str:
|
||||
return container_root
|
||||
return str(PurePosixPath(container_root) / rel)
|
||||
return expanded
|
||||
|
||||
|
||||
Reference in New Issue
Block a user