Files
odysseus/src/tool_schemas.py
T
botinate 873b3152f4 fix(agent): execute fenced tool calls with inline args and route bare email tool names (#3681)
* fix(agent): execute fenced tool calls with inline args and bare email tool names

Two bugs made local (Ollama) models unable to use email tools, leaving
raw fences like ```list_email_accounts {}``` in the chat:

1. _TOOL_BLOCK_RE required a newline right after the fence tag, so a
   tool call with args on the same line ("```list_email_accounts {}")
   never matched and was never executed. The fence now matches with
   optional spaces/newline after the tag.

2. Even when parsed, bare email tool names had no dispatch branch in
   tool_execution.py and fell through to "Unknown tool type". They now
   route to the email MCP server as mcp__email__<name>, matching how
   function_call_to_tool_block already maps them for native callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): block all bare email tool names for non-admins; harden fence-tag regex

Review follow-up on #3681 (thanks @vgalin):

1. Routing bare email names made 10 of the 14 email tools executable by
   non-admin owners — is_public_blocked_tool() runs on the bare name
   before dispatch, and NON_ADMIN_BLOCKED_TOOLS only listed 4. Define the
   full email tool set once (BUILTIN_EMAIL_TOOLS in tool_security.py) and
   derive the blocklist, the fence tags (TOOL_TAGS), the bare-name
   dispatch, and the native-call mapping from it so they can't drift.
   This also fixes 4 tools (search_emails, draft_email, draft_email_reply,
   ai_draft_email_reply) that were missing from the old tool_schemas copy
   and therefore unreachable even for native function-calling models.

2. The relaxed fence regex from the previous commit could prefix-match
   longer fence tags: ```python3 parsed as tool "python" with content
   "3\nprint(...)" and executed as code. Add a (?![\w-]) boundary after
   the tag.

Tests: test_public_agent_policy_blocks_sensitive_tools now covers all 14
bare email names + the mcp__email__ form; new tests/test_fenced_inline_args.py
pins inline-args parsing, the python3/hyphenated-tag non-matches, and
strip/parse display mirroring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): gate bare and mcp-qualified email names together; stop executing Markdown info strings

Review follow-up on #3681 (thanks @RaresKeY):

1. P1: execute_tool_block() checked disabled_tools / the turn ToolPolicy
   only against the incoming block name, then the bare-email branch
   qualified it to mcp__email__<name> and called the MCP manager. Plan
   mode and the MCP settings toggle write the QUALIFIED name into the
   denylist, so a bare fence like ```list_emails``` sailed past a
   mcp__email__list_emails entry. Both gates now match on both
   spellings (bare <-> mcp__email__-qualified), in either direction.

2. P2: the relaxed fence regex accepted arbitrary same-line text after
   a recognized tag, which made ordinary Markdown info strings
   executable: ```python title="example.py" ran as a python tool call.
   Same-line content now only counts as tool input when it starts with
   { or [ (JSON args); anything else leaves the fence as display text,
   and strip_tool_blocks mirrors that (the fence stays visible).

Tests: disabled-tools alias regression (qualified entry blocks bare
name and vice versa, never reaching the MCP manager), ToolPolicy alias
regression, python/bash title="..." non-execution + display retention,
and inline JSON-array args still parsing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): reject brace-style fence metadata; cover the full email set in the friendly toggle

Review follow-up round 3 on #3681 (thanks @RaresKeY):

1. Brace-style fence metadata no longer executes. The previous narrowing
   still treated any same-line {/[ after a recognized tag as tool input,
   so ```bash {title="setup"} ran as a bash call. The fence header is now
   captured separately and judged by one predicate shared between
   parse_tool_blocks and strip_tool_blocks (_fenced_tool_call), so the
   execute and display decisions can't disagree: same-line content only
   counts as inline args when the tag is NOT a code tag (bash/python
   never take same-line args — that text is Markdown fence attributes)
   AND the inline text (plus any continuation lines) parses as standalone
   JSON. ```bash {title="setup"}, ```python {"title":"example.py"} and
   ```list_emails {title="x"} all stay visible and inert.

2. The friendly `disable_tool email` toggle covered 3 of the 14 email
   tools (mcp__email__{list_emails,read_email,send_email}); the other
   bare aliases this PR routes stayed executable after an operator
   disabled email. The alias now derives from BUILTIN_EMAIL_TOOLS in
   BOTH spellings — bare (function-schema hiding, bare-fence dispatch)
   and mcp__email__* (MCP schema hiding, qualified runtime blocks) —
   so the toggle and the runtime gate can't drift apart.

Tests: brace/bracket metadata regressions for parse and strip symmetry
(code tags, invalid-JSON inline on a JSON tool, multi-line inline JSON
still parsing), and disable_tool/enable_tool email covering all 14 names
in both spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): close remaining email-tool registry drift; classify every email tool for plan mode

Deep self-review follow-up on #3681. Three review rounds each found another
hand-maintained copy of the email tool list that had drifted; this commit
hunts down ALL remaining copies and pins them to BUILTIN_EMAIL_TOOLS.

The same 5 tools (search_emails, draft_email, draft_email_reply,
ai_draft_email_reply, download_attachment) were missing from every
advertising surface, so they were dispatchable but never offered:

- FUNCTION_TOOL_SCHEMAS: native function-calling models never saw them
  (the round-1 fix covered dispatch only); schemas added, mirroring the
  email server's inputSchema definitions.
- TOOL_SECTIONS: fenced-block models were never told about them; prompt
  sections added.
- tool_index: absent from the RAG embedding registry (never retrievable),
  the email keyword hints, and the scheduled assistant's always-available
  set — the latter two now derive from BUILTIN_EMAIL_TOOLS.
- agent_loop._DOMAIN_TOOL_MAP["email"], tool_policy._COMMON_TOOL_NAMES,
  the assistant tool-selector UI groups (assistant.js), and the default
  Assistant crew seed (task_scheduler) now derive from / cover the set.

Plan mode now classifies every email tool explicitly:

- list_email_accounts and search_emails join PLAN_MODE_READONLY_TOOLS.
  Without this, list_email_accounts sat in the plan-mode bare denylist
  (schema-derived) while its qualified form passed the MCP read-only
  filter — and the round-2 bare/qualified alias gate would have blocked
  the qualified call too, regressing read-only email discovery in plan
  mode.
- draft_email, draft_email_reply, ai_draft_email_reply, and
  download_attachment join the fail-closed mutator backstop (drafts
  create documents; download_attachment writes to disk).

Tests: tests/test_email_registry_sync.py pins every registry (including
the email server source and assistant.js) to BUILTIN_EMAIL_TOOLS and
asserts the plan-mode partition, so the next email tool can't drift; a
parse/strip mirror grid covers 192 fence shapes (tag x header x body)
asserting executed <=> stripped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: move the email alias rule into tool_security; extract the assistant seed constant

Code-quality pass over the PR's own changes:

- The bare<->qualified email aliasing rule lived inline in the generic
  dispatcher (_execute_tool_block_impl). It is policy knowledge, so it
  moves next to BUILTIN_EMAIL_TOOLS as email_tool_policy_names(); the
  dispatcher just consumes it, and the rule gets its own unit test
  (including the mcp__email__<not-a-tool> and mcp__other__ non-alias
  cases).

- The default Assistant's enabled_tools list was an inline literal
  inside the CrewMember seed, and its registry-sync test asserted a
  source-code substring. Extracted to DEFAULT_ASSISTANT_ENABLED_TOOLS
  so the test imports and checks the actual value.

- _fenced_tool_call return type tightened to Optional[Tuple[str, str]].

No behavior change; suite green (3295 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* revert: move the email registry consolidation to a follow-up PR

Per review feedback on scope, this PR stays narrow: fenced inline-args
parsing, bare email tool routing, and the directly required safety
gates. This commit reverts the registry/advertising consolidation from
db29046 and 016ce47 (native schemas, prompt sections, RAG description
index + keyword hints, assistant always-available set, guide-only
known-names union, frontend tool-selector groups, default assistant
seed, and their sync tests) — all of that moves to a dedicated
follow-up PR together with the _EMAIL_TOOL_HINTS finding.

Kept here because the narrow scope needs them:
- email_tool_policy_names() in tool_security + its use in the
  execute_tool_block gates and its unit test (refactor of this PR's own
  round-2 alias fix),
- list_email_accounts in PLAN_MODE_READONLY_TOOLS (the alias gate works
  both ways, and the schema-derived plan-mode bare denylist would
  otherwise block the qualified read-only call too),
- the parse/strip mirror grid test (parser scope),
- the narrow registry sync tests (email server <-> BUILTIN_EMAIL_TOOLS
  match, fence-tag coverage, non-admin blocklist coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): execute empty email fences with empty args; reject non-object JSON args

Two gaps found by replaying captured local-model traffic against the
narrowed branch:

1. ```list_email_accounts``` with NO body — a shape gemma really emits
   for no-arg tools — was silently dropped (parse skips empty content),
   so the model concluded email was broken: the original #337 symptom
   through a different door. Empty fences whose tag is a built-in email
   tool now dispatch with {} args and the tool's own validation answers
   (e.g. an empty send_email returns "to is required" instead of
   silence). Empty bash/python/other fences keep skipping, and strip
   stays mirrored (the fence was executed, so it is removed).

2. The fence parser accepts JSON arrays as inline args, but the email
   dispatch parsed only objects — an array silently became {} args.
   Non-object JSON now returns a correctable "arguments must be a JSON
   object" error before reaching the MCP server (same class as #3966).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): classify all email tools for plan mode statically; reject invalid email JSON bodies

Review follow-up round 5 on #3681 (thanks @RaresKeY):

1. This PR makes every BUILTIN_EMAIL_TOOLS name fence-taggable, so each
   one must be explicitly classified for plan mode — the draft tools and
   download_attachment were in neither the read-only allowlist nor the
   static denylist, leaving their bare-alias plan-mode safety dependent
   on the MCP read-only inventory being present and current.
   search_emails joins PLAN_MODE_READONLY_TOOLS (explicit, not
   allowed-by-omission); draft_email, draft_email_reply,
   ai_draft_email_reply, and download_attachment join the fail-closed
   _PLAN_MODE_KNOWN_MUTATORS backstop. (Moved back from the #4053 split:
   the partition is directly required for this PR to merge
   independently.)

2. The classic tag/body fence form reaches execution unvalidated (only
   INLINE args are JSON-checked by the parser), so a body like
   {account: "work"} silently became {} args and read the DEFAULT
   mailbox instead of the intended one. JSON-looking bodies that fail to
   parse now return a correctable "not valid JSON" error before reaching
   the MCP server.

Tests: a partition invariant (every email tool is explicitly read-only
or plan-mode-denied), a mutating-alias probe that uses only the static
denylist with a fake MCP manager (no inventory layer), and the
body-form invalid-JSON regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tool-dispatch): decode inline JSON args for legacy MCP tools; reject all non-object email bodies

Review follow-up round 6 on #3681 (thanks @RaresKeY) — both pre-existing
on this branch, surfaced by the relaxed inline-args parser:

1. The relaxed parser accepts inline JSON for every non-code tag, but
   the legacy line-based arg builders (web_search/web_fetch/read_file/
   write_file/generate_image/manage_memory) wrapped the whole JSON
   string as the query/url/path/prompt — so `web_search {"query": "x"}`
   executed as a search for the literal string `{"query": "x"}`.
   _build_mcp_args now uses a fenced JSON object directly when it carries
   the tool's primary arg key (query/url/path/prompt/action). Keyed off
   membership so it can't drift; an object without the primary key (e.g.
   a freeform JSON query, or bare object content for write_file) falls
   through to the line parser unchanged. Also fixes the same corruption
   for the classic newline-JSON form.

2. The bare-email dispatch only rejected bodies starting with { or [, so
   a non-empty non-JSON body like `account: work` still fell through to
   {} args and silently read the DEFAULT mailbox. Now ANY non-empty body
   must decode to a JSON object or it returns a correctable error; only a
   truly empty body keeps the no-arg path (```list_email_accounts```).

Tests: inline-JSON arg decoding for the five legacy tools plus the
freeform and missing-primary-key fallbacks; the email body rejection
extended to cover the brace-looking and bare `key: value` shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tool-dispatch): drop dead manage_memory JSON-decode entry; pin the live-path invariant

Self-audit catch on the round-6 fix. manage_memory was added to
_MCP_JSON_PRIMARY_KEYS, but _build_mcp_args is only reached via
_call_mcp_tool, which only runs for _MCP_TOOL_MAP tools — and
manage_memory isn't one (its tag routes through dispatch_ai_tool ->
do_manage_memory, which line-parses). So the round-6 decode for
manage_memory was dead code: the unit test exercising _build_mcp_args
passed while a real `manage_memory {"action": ...}` fence still parsed
the whole JSON blob as the action.

Remove the dead entry and add test_mcp_json_primary_keys_are_all_live,
which asserts every JSON-primary tool is in _MCP_TOOL_MAP so a dead
decode can't be added again. The same inline-JSON corruption for
manage_memory and the other tools that route through positional
dispatchers (create_session, ui_control, send_to_session, search_chats,
the document tools, etc.) is pre-existing (dev corrupts their newline
JSON form too) and tracked separately; the proper fix there is to route
fenced JSON through function_call_to_tool_block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tool-dispatch): decode inline JSON in WriteFileTool (its live path); round-6 fix was on the dead MCP path

Self-audit: round 6 claimed to fix inline JSON args for write_file via
_build_mcp_args, but there is no filesystem MCP server, so write_file
always runs through _direct_fallback -> WriteFileTool, never through
_build_mcp_args. WriteFileTool — unlike its siblings ReadFileTool /
WebSearchTool / WebFetchTool, which all decode JSON — took lines[0] as
the path, so `write_file {"path": "/tmp/x", "content": "y"}` wrote to a
file literally named with the JSON blob. The round-6 _build_mcp_args
entry decoded correctly but on a path that never executes (same class
as the manage_memory dead entry), and the round-6 unit test passed on
that dead path.

WriteFileTool now decodes a JSON object carrying "path" (matching
ReadFileTool directly above it), and the comment on _MCP_JSON_PRIMARY_KEYS
records that only generate_image has a live MCP server today — the other
entries are defense-in-depth for the MCP path; the live fix for each
server-less tool is in its handler.

Test: test_write_file_inline_json_args drives the LIVE path
(execute_tool_block with no MCP) and asserts the intended path is used —
verified to fail without the handler fix. web_search/web_fetch/read_file
were already correct (their handlers decode); write_file was the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(strip-fence): derive the live-strip TOOL_TAGS from the real set

Semantic conflict from the dev merge that textual auto-merge didn't flag:
dev added test_live_strip_email_tool_fences.py whose _tool_tags() helper
source-scrapes only the TOOL_TAGS literal `{...}`, which worked on dev
because the email tool names were listed inline there. This branch makes
TOOL_TAGS the single source — `{...} | BUILTIN_EMAIL_TOOLS` — so the email
names are no longer in the literal and the scraper missed them, leaving the
email-fence strip assertions failing even though TOOL_TAGS does contain them
at runtime.

Import the real TOOL_TAGS instead of scraping source, so the test mirrors
exactly what GET /api/tools serves (sorted(TOOL_TAGS)) and the live
EXEC_FENCE_RE derives from — robust to however the set is composed. The
source-level frontend/route guards in the same file are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: botinate <285686135+botinate@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:50:32 +01:00

1420 lines
84 KiB
Python

"""
tool_schemas.py
OpenAI-compatible function tool schemas and the converter that turns
native function calls back into ToolBlocks for the execution pipeline.
Extracted from agent_tools.py to keep schema definitions separate from
tool parsing / execution logic.
"""
import json
import logging
from typing import Optional
from src.agent_tools import ToolBlock, TOOL_TAGS
from src.tool_parsing import _TOOL_NAME_MAP
from src.tool_security import BUILTIN_EMAIL_TOOLS
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# OpenAI-compatible function tool schemas
# ---------------------------------------------------------------------------
FUNCTION_TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "bash",
"description": "Run a shell command (full access). Prefer a dedicated tool whenever one fits the job (reading, writing, editing, searching, or listing files); use bash only for what no dedicated tool covers (installs, git, builds, running programs, system info). Do NOT create or edit files via bash redirects/heredocs/sed -- use the dedicated file tools.",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "The shell command to execute"}
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "python",
"description": "Execute Python code to compute a result or test something. Prefer a dedicated tool whenever one fits the job (reading, writing, or searching files); use python only for computation, data processing, or scripting no dedicated tool covers.",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Python code to execute"}
},
"required": ["code"]
}
}
},
{
"type": "function",
"function": {
"name": "web_search",
"description": "Quick single web lookup for a fact or current event mid-task. NOT for 'research X' / 'do research on X' — those are deep-research jobs; use trigger_research instead.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"time_filter": {"type": "string", "enum": ["day", "week", "month", "year"], "description": "Optional freshness filter for news/latest/today queries"}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "web_fetch",
"description": "Fetch and read the text content of a specific URL the user names (e.g. 'check example.com', 'what's on this page <url>'). Use when you already have a concrete URL/domain. NOT for open-ended searches (use web_search) or 'research X' jobs (use trigger_research). Downloads are size-budgeted; a '[partial content: ...]' notice in the result means the body was cut short and you can re-call with full=true for the rest.",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL or domain to fetch (http/https; a bare domain like example.com is fine)"},
"full": {"type": "boolean", "description": "Raise the download budget to the hard cap for large pages/files. Use only after a result reported partial content."}
},
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file from disk. Optionally read a line range with offset/limit for large files.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read"},
"offset": {"type": "integer", "description": "1-based line to start reading from (optional)"},
"limit": {"type": "integer", "description": "Max number of lines to read from offset (optional)"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "grep",
"description": "Search file contents for a regular expression across a directory tree (uses ripgrep when available, respecting .gitignore). Returns file:line:match. PREFER this over `bash grep/rg` for code search — confined to the allowed roots, structured output.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regular expression to search for"},
"path": {"type": "string", "description": "Directory or file to search (optional; defaults to the project root)"},
"glob": {"type": "string", "description": "Only search files matching this glob, e.g. '*.py' (optional)"},
"ignore_case": {"type": "boolean", "description": "Case-insensitive match (optional)"},
"max_results": {"type": "integer", "description": "Max matches to return (optional)"}
},
"required": ["pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "glob",
"description": "Find files by glob pattern (recursive), newest first. e.g. '**/*.py'. PREFER this over `bash find/ls` for locating files — confined to the allowed roots.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.ts' or 'src/**/test_*.py'"},
"path": {"type": "string", "description": "Base directory (optional; defaults to the project root)"}
},
"required": ["pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "ls",
"description": "List the entries of a directory (folders first, then files with sizes). PREFER this over `bash ls` — confined to the allowed roots.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory to list (optional; defaults to the project root)"}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "get_workspace",
"description": "Return the absolute path of the active workspace folder the user is working in. File tools are confined to it; the shell starts there but is not sandboxed. Call this first when the user refers to 'the project'/'the code'/'this folder' without a path, instead of asking them. Takes no arguments.",
"parameters": {"type": "object", "properties": {}, "required": []}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write/save a file to disk",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to write to"},
"content": {"type": "string", "description": "File content to write"}
},
"required": ["path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "Edit a file ON DISK by exact string replacement (home folder, project files, any real path like ~/sweden.txt or /path/to/file). This is the right tool for files on disk — NOT edit_document (that's for editor-panel documents). PREFER this over bash (sed/echo) — it shows a diff. old_string must match the file exactly and be unique (or set replace_all). Use write_file to create a new file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to edit"},
"old_string": {"type": "string", "description": "Exact text to replace (must match the file, including indentation)"},
"new_string": {"type": "string", "description": "Replacement text"},
"replace_all": {"type": "boolean", "description": "Replace all occurrences instead of requiring a unique match"}
},
"required": ["path", "old_string", "new_string"]
}
}
},
{
"type": "function",
"function": {
"name": "create_document",
"description": "Create a new document in the editor panel. Use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines) AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large code blocks directly in chat — use this tool instead.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Document title"},
"language": {"type": "string", "description": "Programming language or format (e.g. python, javascript, markdown, text)"},
"content": {"type": "string", "description": "The document content"}
},
"required": ["title", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "edit_document",
"description": "Edit a document OPEN IN THE EDITOR PANEL (created via create_document) — NOT a file on disk. For files on disk (home folder, project files, anything with a path like ~/x.txt or /path/to/file) use edit_file instead. Targeted find-and-replace with multiple FIND/REPLACE pairs per call; use for any edit smaller than a full rewrite. Do NOT send the whole file back via update_document for small edits.",
"parameters": {
"type": "object",
"properties": {
"edits": {
"type": "array",
"description": "List of find/replace edits (first match only per edit)",
"items": {
"type": "object",
"properties": {
"find": {"type": "string", "description": "Exact text to find in the document"},
"replace": {"type": "string", "description": "Text to replace it with"}
},
"required": ["find", "replace"]
}
}
},
"required": ["edits"]
}
}
},
{
"type": "function",
"function": {
"name": "suggest_document",
"description": "Suggest improvements to the active document WITHOUT editing it. Creates inline comment bubbles the user can accept or reject. Use when the user asks for suggestions, review, improvements, or feedback.",
"parameters": {
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"description": "List of suggested changes with reasons",
"items": {
"type": "object",
"properties": {
"find": {"type": "string", "description": "Exact text in the document to suggest changing"},
"replace": {"type": "string", "description": "Suggested replacement text"},
"reason": {"type": "string", "description": "Brief explanation of why this change helps"}
},
"required": ["find", "replace", "reason"]
}
}
},
"required": ["suggestions"]
}
}
},
{
"type": "function",
"function": {
"name": "update_document",
"description": "Replace the ENTIRE active document. ONLY use for genuine full rewrites (>50% of lines changed). For any smaller change, use edit_document — echoing back the whole file for small edits is wasteful.",
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Complete new document content"}
},
"required": ["content"]
}
}
},
{
"type": "function",
"function": {
"name": "search_chats",
"description": "Search the user's past session transcripts by keyword. Use when the user asks about previous chats, past conversations, or when direct transcript evidence is better than persistent memory. Returns matching sessions with clickable links and nearby context.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search keyword(s) to find in past conversations"}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "chat_with_model",
"description": "Send a message to another AI model and get its response. Use for getting a second opinion, delegating subtasks, or AI-to-AI communication.",
"parameters": {
"type": "object",
"properties": {
"model": {"type": "string", "description": "Model name (e.g. 'qwen3-32b') or model@endpoint_name"},
"message": {"type": "string", "description": "The message to send to the model"}
},
"required": ["model", "message"]
}
}
},
{
"type": "function",
"function": {
"name": "create_session",
"description": "Create a new chat for ongoing conversations with a specific model. (The UI calls these 'chats'; 'session' is the internal term.)",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name for the new chat"},
"model": {"type": "string", "description": "Model name or model@endpoint_name"}
},
"required": ["name", "model"]
}
}
},
{
"type": "function",
"function": {
"name": "list_sessions",
"description": "List the user's chats (the UI calls them 'chats') as clickable markdown links. Use this to enumerate chats before opening, renaming, archiving, or deleting them. When replying to the user, preserve the returned [title](#session-id) links; do not strip them into plain text. Optionally filter by keyword.",
"parameters": {
"type": "object",
"properties": {
"filter": {"type": "string", "description": "Optional keyword to filter chats by name"}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "send_to_session",
"description": "Send a message to an existing chat and get the model's response. The chat keeps its conversation history.",
"parameters": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "The id of the chat to send the message to"},
"message": {"type": "string", "description": "The message to send"}
},
"required": ["session_id", "message"]
}
}
},
{
"type": "function",
"function": {
"name": "pipeline",
"description": "Run a multi-step AI pipeline where each model's output feeds the next. Example: Draft -> Critique -> Revise.",
"parameters": {
"type": "object",
"properties": {
"steps": {
"type": "array",
"description": "Pipeline steps in order",
"items": {
"type": "object",
"properties": {
"model": {"type": "string", "description": "Model name for this step"},
"instruction": {"type": "string", "description": "What this step should do"}
},
"required": ["model", "instruction"]
}
}
},
"required": ["steps"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_session",
"description": "Manage a chat: rename, archive, unarchive, delete, mark important, truncate history, or fork it. (The UI calls these 'chats'; 'session' is the internal term.) For destructive actions like delete, call list_sessions first and pass the exact id returned there; never invent ids.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["rename", "archive", "unarchive", "delete", "important", "unimportant", "truncate", "fork"],
"description": "The action to perform"},
"session_id": {"type": "string", "description": "Exact target chat id from list_sessions, or 'current' for the active chat where supported"},
"value": {"type": "string", "description": "Action parameter: new name (rename), keep_count (truncate/fork)"}
},
"required": ["action", "session_id"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_memory",
"description": "Manage the user's memory system: list, add, edit, delete, or search memories. Memories persist across sessions.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "add", "edit", "delete", "search"],
"description": "The action to perform"},
"text": {"type": "string", "description": "Memory text (for add/edit) or search query (for search)"},
"memory_id": {"type": "string", "description": "Memory ID (for edit/delete)"},
"category": {"type": "string", "enum": ["fact", "event", "contact", "preference"],
"description": "Memory category (for add/list filter)"}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "list_models",
"description": "List all available AI models across configured endpoints. Optionally filter by keyword.",
"parameters": {
"type": "object",
"properties": {
"filter": {"type": "string", "description": "Optional keyword to filter models"}
},
"required": []
}
}
},
{
"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, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; does NOT send), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute), 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). 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_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, email, sessions, notes, brain/memories, skills, settings, cookbook. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
"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"},
"folder": {"type": "string", "description": "Email folder for open_email_reply (default INBOX)"},
"mode": {"type": "string", "description": "Reply draft mode for open_email_reply: reply, reply-all, or ai-reply"},
"colors": {"type": "object", "description": "For create_theme: the theme colors",
"properties": {
"bg": {"type": "string", "description": "Background color (hex, e.g. #1a1a2e)"},
"fg": {"type": "string", "description": "Foreground/text color (hex)"},
"panel": {"type": "string", "description": "Panel/sidebar background color (hex)"},
"border": {"type": "string", "description": "Border/divider color (hex)"},
"accent": {"type": "string", "description": "Accent color for buttons, brand, highlights (hex)"},
"userBubbleBg": {"type": "string", "description": "User chat bubble background (hex, optional)"},
"aiBubbleBg": {"type": "string", "description": "AI chat bubble background (hex, optional)"},
"bubbleBorder": {"type": "string", "description": "Chat bubble border color (hex, optional)"},
"sidebarBg": {"type": "string", "description": "Sidebar background override (hex, optional)"},
"sectionAccent": {"type": "string", "description": "Section header accent color (hex, optional)"},
"brandColor": {"type": "string", "description": "Brand/logo color (hex, optional)"},
"inputBg": {"type": "string", "description": "Chat input background (hex, optional)"},
"inputBorder": {"type": "string", "description": "Chat input border (hex, optional)"},
"sendBtnBg": {"type": "string", "description": "Send button background (hex, optional)"},
"sendBtnHover": {"type": "string", "description": "Send button hover color (hex, optional)"},
"codeBg": {"type": "string", "description": "Code block background (hex, optional)"},
"codeFg": {"type": "string", "description": "Code block text color (hex, optional)"},
"toggleBg": {"type": "string", "description": "Toggle switch off background (hex, optional)"},
"toggleActive": {"type": "string", "description": "Toggle switch on color (hex, optional)"},
"accentPrimary": {"type": "string", "description": "Primary accent override (hex, optional)"},
"accentError": {"type": "string", "description": "Error/danger color (hex, optional)"}
},
"required": ["bg", "fg", "panel", "border", "accent"]}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "ask_user",
"description": "Ask the user a multiple-choice question to get a decision or clarification when the task is genuinely ambiguous and the answer changes what you do next (e.g. pick between approaches, confirm an assumption, choose a target). The user sees clickable option buttons; calling this ENDS your turn and their selection arrives as your next message. Prefer sensible defaults over asking — only ask when you truly cannot proceed well without the user's input. Do NOT use it to confirm irreversible/destructive actions that have a dedicated confirmation flow.",
"parameters": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
"options": {
"type": "array",
"description": "2-6 choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
"items": {
"type": "object",
"properties": {
"label": {"type": "string", "description": "Concise choice text the user clicks (1-5 words)."},
"description": {"type": "string", "description": "Optional one-line explanation of this choice."}
},
"required": ["label"]
}
},
"multi": {"type": "boolean", "description": "Set true ONLY when the question explicitly allows choosing more than one option. Otherwise omit it or set false. Default false."}
},
"required": ["question", "options"]
}
}
},
{
"type": "function",
"function": {
"name": "update_plan",
"description": "Write back to the ACTIVE PLAN: mark steps done or revise them. Use this while executing an approved plan — after you finish a step, call update_plan with the full checklist and that step marked `- [x]`; when the user asks to change the plan, call it with the revised checklist. The user's docked plan window updates live. Pass the COMPLETE checklist every time (not a diff). No effect if there is no active plan.",
"parameters": {
"type": "object",
"properties": {
"plan": {"type": "string", "description": "The full updated plan as a GitHub-style markdown checklist — one step per line, `- [ ]` for pending and `- [x]` for done. Always send the whole list."}
},
"required": ["plan"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_tasks",
"description": "Manage scheduled/automated tasks: list, create, edit, delete, pause, resume, or run tasks. Use this for ANY recurring/scheduled request ('every morning…', 'each day at 7:30', 'daily summarize…') — create a task rather than doing it once. Task types: llm (AI runs a prompt), research (runs the deep-research pipeline on a question), or action (built-in automation). Triggers can be time-based or event-based.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "create", "edit", "delete", "pause", "resume", "run"],
"description": "The action to perform"},
"task_id": {"type": "string", "description": "Task ID (for edit/delete/pause/resume/run)"},
"name": {"type": "string", "description": "Task name"},
"prompt": {"type": "string", "description": "The instruction (for task_type=llm) or the research question (for task_type=research). Required for both."},
"task_type": {"type": "string", "enum": ["llm", "research", "action"],
"description": "llm = AI runs your prompt; research = runs the deep-research pipeline on the prompt as a question; action = direct built-in function"},
"action_name": {"type": "string", "enum": [
"tidy_sessions", "tidy_documents", "consolidate_memory", "tidy_research",
"summarize_emails", "draft_email_replies", "extract_email_events",
"classify_events", "learn_sender_signatures",
"test_skills", "audit_skills", "check_email_urgency"
],
"description": "Built-in action (for task_type=action)"},
"trigger_type": {"type": "string", "enum": ["schedule", "event"],
"description": "schedule = time-based, event = count-based"},
"schedule": {"type": "string", "enum": ["once", "daily", "weekly", "monthly"],
"description": "Schedule frequency (for trigger_type=schedule)"},
"scheduled_time": {"type": "string", "description": "HH:MM in UTC (for schedule triggers). Convert the user's stated local time using the UTC offset given in the 'Current date and time' context."},
"scheduled_day": {"type": "integer", "description": "Day of week 0=Mon (weekly) or day of month (monthly)"},
"trigger_event": {"type": "string", "enum": ["session_created", "message_sent", "document_created", "memory_added", "research_completed", "email_received", "skill_added"],
"description": "Event name (for trigger_type=event)"},
"trigger_count": {"type": "integer", "description": "Fire every N events (for trigger_type=event)"},
"output_target": {"type": "string", "description": "Where results go. Defaults to 'session' (results land in a dedicated chat session the user reads) — this is the right choice for 'summarize for me' / 'send to me'. Do NOT go hunting for the user's email address; only use an email MCP tool name here if the user explicitly asked to be emailed AND an address is already known."}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_calendar",
"description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Resolve relative dates like today/tomorrow against the 'Current date and time' system context, then pass ISO 8601 datetimes in the user's local wall time; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string",
"enum": ["list_events", "create_event", "update_event", "delete_event", "list_calendars"],
"description": "Action to perform"},
"summary": {"type": "string", "description": "Event title (for create/update)"},
"dtstart": {"type": "string", "description": "Start ISO datetime, or YYYY-MM-DD if all_day"},
"dtend": {"type": "string", "description": "End ISO datetime; defaults to +1h (or +1 day for all_day)"},
"all_day": {"type": "boolean", "description": "Whether this is an all-day event"},
"description": {"type": "string", "description": "Event description / notes"},
"location": {"type": "string", "description": "Event location"},
"uid": {"type": "string", "description": "Event UID (for update/delete)"},
"calendar_href": {"type": "string", "description": "Specific calendar URL (optional; defaults to first calendar)"},
"calendar": {"type": "string", "description": "Filter list_events by calendar name or href"},
"start": {"type": "string", "description": "list_events range start (ISO datetime); defaults to today. Prefer start; backend also accepts start_date, range_start, from, dtstart, since."},
"end": {"type": "string", "description": "list_events range end (ISO datetime); defaults to +14 days. Prefer end; backend also accepts end_date, range_end, to, dtend, until."},
"event_type": {"type": "string", "description": "Tag / category for the event. Common values: work, personal, health, travel, meal, social, admin, other. Aliases accepted: tag, category, type."},
"importance": {"type": "string", "enum": ["low", "normal", "high", "critical"], "description": "Priority level (defaults to 'normal')"},
"reminder_minutes": {"type": "integer", "description": "For create_event: create an Odysseus reminder this many minutes before the event, e.g. 5 for 'reminder 5 min before'."},
"rrule": {"type": "string", "description": "Recurrence rule in iCalendar RRULE format, e.g. 'FREQ=WEEKLY;BYDAY=MO' for weekly on Monday. Use with create_event or update_event."}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_notes",
"description": "Manage notes and checklists (Google Keep-style): list, add, update, delete, toggle_item. IMPORTANT: For to-do lists / checklists, set note_type='checklist' and pass the items as the `checklist_items` array — do NOT serialize them into `content` as plain text. For freeform notes, use note_type='note' and put the body in `content`. `due_date` accepts natural language like 'tomorrow at 9am' (parsed in the user's timezone) and fires a notification — do not also create a calendar event for the same reminder.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string",
"enum": ["list", "add", "update", "delete", "toggle_item"],
"description": "The action to perform"},
"id": {"type": "string", "description": "Note id (for update/delete/toggle_item); 8-char prefix is fine"},
"title": {"type": "string", "description": "Note title (for add/update)"},
"content": {"type": "string", "description": "Freeform body text. Use this for note_type='note'. Do NOT use this for checklists — pass `checklist_items` instead."},
"note_type": {"type": "string", "enum": ["note", "checklist"],
"description": "'note' = freeform text in `content`. 'checklist' = structured to-do items in `checklist_items`. Defaults to 'checklist' if checklist_items is supplied, else 'note'."},
"checklist_items": {"type": "array",
"items": {"type": "object",
"properties": {
"text": {"type": "string", "description": "The to-do item text"},
"done": {"type": "boolean", "description": "Whether the item is checked off"}
},
"required": ["text"]},
"description": "Checklist items for note_type='checklist'. Each item is {text, done}. REQUIRED for checklists — leaving this empty produces a blank note."},
"color": {"type": "string", "description": "Optional color label (e.g. 'yellow', 'blue', 'green')"},
"label": {"type": "string", "description": "Optional category label (also used as a list filter)"},
"pinned": {"type": "boolean", "description": "Pin the note to the top"},
"archived": {"type": "boolean", "description": "For update: archive/unarchive. For list: show archived notes when true."},
"due_date": {"type": "string", "description": "Reminder time. Accepts natural language ('tomorrow at 9am', '11pm today') or ISO 8601. Fires a notification at that time."},
"index": {"type": "integer", "description": "Checklist item index (for toggle_item, 0-based)"}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "api_call",
"description": "Call a registered API integration (RSS reader, git forge, bookmark manager, smart home, etc.). Check the system context for available integrations and their endpoints.",
"parameters": {
"type": "object",
"properties": {
"integration": {"type": "string", "description": "Integration name or ID (e.g. 'Miniflux', 'Gitea')"},
"method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], "description": "HTTP method"},
"path": {"type": "string", "description": "API endpoint path (e.g. '/v1/entries?status=unread&limit=20')"},
"body": {"type": "object", "description": "JSON request body (for POST/PUT/PATCH)"}
},
"required": ["integration", "method", "path"]
}
}
},
{
"type": "function",
"function": {
"name": "ask_teacher",
"description": "Ask a more capable AI model for help when stuck on a difficult problem. The teacher provides guidance that can be saved as a learned skill.",
"parameters": {
"type": "object",
"properties": {
"model": {"type": "string", "description": "Teacher model name (e.g. 'claude-sonnet-4') or 'auto' for configured default"},
"problem": {"type": "string", "description": "Describe the problem or question you need help with"}
},
"required": ["problem"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_skills",
"description": (
"Read or modify the user's skill library. Skills are SKILL.md files "
"(YAML frontmatter + structured body: When to Use / Procedure / "
"Pitfalls / Verification) and follow a draft → published lifecycle. "
"Use progressive disclosure: 'list' to see what exists, 'view' to "
"load full content for a single skill, 'view_ref' for sub-files. "
"Use 'patch' for surgical text edits and 'edit' for full rewrites. "
"'publish' once you've verified the procedure works. For add, "
"always provide an explicit name slug and only tell the user the "
"exact name returned by the tool."
),
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "view", "view_ref", "add", "edit", "patch", "publish", "delete", "search"], "description": "list = name+description summary; view = full SKILL.md; view_ref = sub-file under the skill dir; add = create; edit = full rewrite (content); patch = old_string→new_string; publish = flip status; delete; search = relevance match on published skills."},
"name": {"type": "string", "description": "Slug/name of the skill. Required for add/view/view_ref/edit/patch/publish/delete. For add, choose the exact kebab-case name the user should see and report only the returned name."},
"path": {"type": "string", "description": "Sub-path under the skill directory for view_ref (e.g. 'references/example.md')."},
"description": {"type": "string", "description": "One-line summary surfaced in the skills index (for add)."},
"category": {"type": "string", "description": "Organizational grouping like 'dev', 'email', 'system' (for add)."},
"when_to_use": {"type": "string", "description": "Trigger conditions in plain English (for add)."},
"procedure": {"type": "array", "items": {"type": "string"}, "description": "Numbered steps (for add)."},
"pitfalls": {"type": "array", "items": {"type": "string"}, "description": "Known failure modes + recovery (for add)."},
"verification": {"type": "array", "items": {"type": "string"}, "description": "How to confirm the procedure succeeded (for add)."},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Keyword tags (for add)."},
"platforms": {"type": "array", "items": {"type": "string"}, "description": "Restrict to OSes (for add)."},
"requires_toolsets": {"type": "array", "items": {"type": "string"}, "description": "Hide unless these toolsets are active (for add)."},
"fallback_for_toolsets": {"type": "array", "items": {"type": "string"}, "description": "Hide when these toolsets are active (for add)."},
"status": {"type": "string", "enum": ["draft", "published"], "description": "Defaults to 'draft' on add."},
"version": {"type": "string", "description": "Semver-ish, e.g. '1.0.0' (for add)."},
"confidence": {"type": "number", "description": "0-1 (for add/publish)."},
"content": {"type": "string", "description": "Full SKILL.md text (for edit)."},
"old_string": {"type": "string", "description": "Exact substring to replace (for patch). Must appear exactly once."},
"new_string": {"type": "string", "description": "Replacement text (for patch)."},
"query": {"type": "string", "description": "Search query (for search)."}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_endpoints",
"description": "Manage model API endpoints: list configured endpoints, add new ones, delete, enable or disable them.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "add", "delete", "enable", "disable"]},
"endpoint_id": {"type": "string", "description": "Endpoint ID (for delete/enable/disable)"},
"name": {"type": "string", "description": "Display name (for add)"},
"base_url": {"type": "string", "description": "API base URL e.g. https://api.openai.com/v1 (for add)"},
"api_key": {"type": "string", "description": "API key (for add)"}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_mcp",
"description": "Manage MCP (Model Context Protocol) tool servers: list servers and their tools, add new servers, delete, enable/disable, reconnect, or list all available tools.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "add", "delete", "enable", "disable", "reconnect", "list_tools"]},
"server_id": {"type": "string", "description": "Server ID (for delete/enable/disable/reconnect)"},
"name": {"type": "string", "description": "Server name (for add)"},
"command": {"type": "string", "description": "Command to run e.g. npx (for add)"},
"args": {"type": "array", "items": {"type": "string"}, "description": "Command arguments (for add)"},
"env": {"type": "object", "description": "Environment variables (for add)"}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_webhooks",
"description": "Manage webhooks: list, add, delete, enable or disable webhook endpoints.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "add", "delete", "enable", "disable"]},
"webhook_id": {"type": "string", "description": "Webhook ID (for delete/enable/disable)"},
"name": {"type": "string", "description": "Webhook name (for add)"},
"url": {"type": "string", "description": "Webhook URL (for add)"},
"events": {"type": "string", "description": "Comma-separated event names (for add)"}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_tokens",
"description": "Manage API access tokens: list existing tokens, create new ones, or delete them.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "create", "delete"]},
"token_id": {"type": "string", "description": "Token ID (for delete)"},
"name": {"type": "string", "description": "Token name (for create)"}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_documents",
"description": "Manage documents: list all documents (with optional search/language filter), delete documents, or run tidy cleanup.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "delete", "tidy"]},
"document_id": {"type": "string", "description": "Document ID (for delete)"},
"search": {"type": "string", "description": "Search query (for list)"},
"language": {"type": "string", "description": "Filter by language (for list)"},
"limit": {"type": "integer", "description": "Max results (for list, default 50)"}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_settings",
"description": "Manage user preferences and settings. Use `disable_tool`/`enable_tool`/`list_tools` to turn individual tools on or off globally (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Use list/get/set/delete for free-form preferences.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "enable_tool", "list_tools"]},
"key": {"type": "string", "description": "Setting key (for get/set/delete)"},
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
"tool": {"type": "string", "description": "Tool name to disable/enable (for disable_tool/enable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "download_model",
"description": "Download a HuggingFace model to a server. If `host` is omitted, defaults to the cookbook's currently-selected server (NOT localhost) — call list_cookbook_servers first if you're unsure where it should go.",
"parameters": {
"type": "object",
"properties": {
"repo_id": {"type": "string", "description": "HuggingFace repo (e.g. 'Qwen/Qwen3-8B')"},
"host": {"type": "string", "description": "Target server — use the friendly NAME from list_cookbook_servers (e.g. 'gpu-box', 'workstation') or a raw user@host. Omit to use the cookbook's selected default server."},
"local": {"type": "boolean", "description": "Force download to THIS machine (localhost) instead of the default remote server."},
"include": {"type": "string", "description": "Glob filter for specific files (e.g. '*Q4_K_M*')"},
},
"required": ["repo_id"]
}
}
},
{
"type": "function",
"function": {
"name": "serve_model",
"description": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. If `host` is omitted, defaults to the cookbook's selected server (not localhost). For image/inpainting/diffusion models use the built-in command `python3 scripts/diffusion_server.py --model <repo> --port 8100` rather than inventing a custom diffusers API server. After launching, call list_served_models to check readiness/errors; if it reports a diagnosis with retry suggestions, retry via serve_model using the suggested adjusted cmd.",
"parameters": {
"type": "object",
"properties": {
"repo_id": {"type": "string", "description": "Model repo (e.g. 'Qwen/Qwen3-8B')"},
"cmd": {"type": "string", "description": "Full serve command (e.g. 'vllm serve Qwen/Qwen3-8B --port 8000 --tp 2', 'python3 -m sglang.launch_server --model-path Qwen/Qwen3-8B --port 30000', or for inpainting/image models: 'python3 scripts/diffusion_server.py --model diffusers/stable-diffusion-xl-1.0-inpainting-0.1 --port 8100')"},
"host": {"type": "string", "description": "Target server — friendly NAME from list_cookbook_servers (e.g. 'gpu-box', 'workstation') or raw user@host. Omit to use the cookbook's selected default."},
"local": {"type": "boolean", "description": "Force serve on THIS machine instead of the default remote server."},
},
"required": ["repo_id", "cmd"]
}
}
},
{
"type": "function",
"function": {
"name": "list_served_models",
"description": "List currently running model servers with status, model name, port, throughput, and structured Cookbook diagnoses. If a serve failed, this includes recent logs plus retry suggestions/adjusted commands the agent can use with serve_model.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "stop_served_model",
"description": "Stop a running model server.",
"parameters": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Tmux session ID of the server to stop"},
},
"required": ["session_id"]
}
}
},
{
"type": "function",
"function": {
"name": "tail_serve_output",
"description": "Read the last N lines of a cookbook serve/download task's tmux pane. Use ONLY in this exact sequence: (1) the user asked to serve a model, (2) you launched it via serve_model, (3) list_served_models reports the NEW task as crashed/error, (4) call tail_serve_output on the new sessionId to find the root cause, (5) call serve_model again with adjusted flags. DO NOT call this on old stopped/completed download tasks — they are historical and won't tell you anything about the current attempt. DO NOT investigate past failures before launching; the environment may have changed since.",
"parameters": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Tmux session id from list_served_models (e.g. 'serve-abc12345', 'cookbook-a1b2c3d4')."},
"tail": {"type": "integer", "description": "How many lines of pane scrollback to fetch (default 300, max 4000). Bump this if the error in the visible tail references an earlier line ('see root cause above')."},
},
"required": ["session_id"]
}
}
},
{
"type": "function",
"function": {
"name": "list_downloads",
"description": "List in-progress model downloads in the Cookbook. Shows each download's model name, phase, percent (if available), session ID, and remote host.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "cancel_download",
"description": "Cancel an in-progress model download by killing its tmux session. Use list_downloads first to get the session_id.",
"parameters": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Tmux session ID from list_downloads (e.g. 'cookbook-a1b2c3d4')"},
},
"required": ["session_id"]
}
}
},
{
"type": "function",
"function": {
"name": "search_hf_models",
"description": "Search HuggingFace for models matching a query. Returns a ranked list of repo IDs, sizes (when available), and download counts. Use this when the user wants to find a model to download.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search terms (e.g. 'Qwen 8B', 'flux', 'llama-3 instruct')"},
"limit": {"type": "integer", "description": "Max results (default 10)"},
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "list_cookbook_servers",
"description": "List the cookbook's configured servers (remote GPU boxes + local) and the current default host. Call this before download_model/serve_model when the user didn't specify a host, so models go to the right machine (where the GPUs and model cache are) instead of localhost. If multiple servers and intent is ambiguous, show them and ask the user which.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "list_serve_presets",
"description": "List saved Cookbook serve presets. Each preset is a launch template (name, model, host, port, tmux cmd) the user previously saved from the UI. Call this BEFORE serve_model when the user asks to launch a model by name — there's almost always a working preset for it.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "adopt_served_model",
"description": "Register an existing tmux model server (started manually or outside the cookbook flow) into Cookbook tracking, AND add it as a chat endpoint. Use when the user (or you) launched something via ssh+tmux and now want it visible in the UI / stoppable via stop_served_model / usable in the model picker. Verifies the tmux session + port respond before adding.",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Remote host in user@host form (e.g. 'user@192.0.2.10'). Omit for localhost."},
"tmux_session": {"type": "string", "description": "Existing tmux session name (e.g. 'minimax-m27')"},
"model": {"type": "string", "description": "Model repo_id or display name (e.g. 'cyankiwi/MiniMax-M2.7-AWQ-4bit')"},
"port": {"type": "integer", "description": "Port the server is listening on (default 8000)"},
"name": {"type": "string", "description": "Optional display name (defaults to model basename)"},
"add_endpoint": {"type": "boolean", "description": "Also register as a chat endpoint (default true)"}
},
"required": ["tmux_session", "model"]
}
}
},
{
"type": "function",
"function": {
"name": "serve_preset",
"description": "Launch a saved Cookbook serve preset by name. Reuses the exact tmux command + host the user saved before. This is the preferred way to start a known model (SD3.5, vLLM presets, etc.) — don't fabricate launch commands when a preset exists.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Preset name (exact or case-insensitive substring of one returned by list_serve_presets)"},
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "list_cached_models",
"description": "List models already cached on disk locally or on a remote server. `host` accepts friendly Cookbook server names from list_cookbook_servers (for example ajax) or raw user@host. Also reports completed Cookbook download tasks when the filesystem cache scan cannot locate the HF cache path.",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Friendly Cookbook server name (e.g. 'ajax', 'gpu-box') or raw remote host (e.g. 'user@gpu-box'). Omit for local."},
"model_dir": {"type": "string", "description": "Comma-separated additional model directories to scan beyond ~/.cache/huggingface/hub"},
"ssh_port": {"type": "string", "description": "SSH port for remote host (default 22)"},
"platform": {"type": "string", "enum": ["linux", "windows"], "description": "Remote platform"}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "app_api",
"description": "Generic loopback to allowed internal Odysseus endpoints. Use this when there's no named tool for what the user wants. Hits the same routes the UI buttons hit (cookbook, gallery, library/documents, memory, notes, calendar, tasks, settings, themes, research, compare, etc.). action='endpoints' returns the OpenAPI surface (use `filter` to narrow). action='call' (default) takes method+path+body. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked for safety. Do not use for shell commands; use named command tooling instead. Do not use for package installs, engine rebuilds, PID signalling, or email account discovery; use list_email_accounts for email accounts because /api/email/accounts is owner-filtered in tool context.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["call", "endpoints"], "description": "'call' to hit an endpoint, 'endpoints' to list what's available"},
"path": {"type": "string", "description": "Endpoint path starting with /api/ (e.g. '/api/cookbook/gpus', '/api/gallery/list', '/api/calendar/events')"},
"method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], "description": "HTTP method (default GET)"},
"body": {"type": "object", "description": "JSON request body for POST/PUT/PATCH"},
"query": {"type": "object", "description": "Querystring params as a key-value object"},
"filter": {"type": "string", "description": "For action=endpoints: substring to filter paths/summaries (e.g. 'cookbook', 'gallery')"}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "edit_image",
"description": "Edit a gallery image: upscale, remove background, inpaint, or harmonize.",
"parameters": {
"type": "object",
"properties": {
"image_id": {"type": "string", "description": "Gallery image ID"},
"action": {"type": "string", "enum": ["upscale", "rembg", "inpaint", "harmonize"], "description": "Edit action"},
"prompt": {"type": "string", "description": "For inpaint: what to fill the masked area with"},
"scale": {"type": "number", "description": "For upscale: scale factor (default 2)"},
},
"required": ["image_id", "action"]
}
}
},
{
"type": "function",
"function": {
"name": "trigger_research",
"description": "Start a deep research task on a topic. Returns a task ID for tracking.",
"parameters": {
"type": "object",
"properties": {
"topic": {"type": "string", "description": "Research question or topic"},
},
"required": ["topic"]
}
}
},
{
"type": "function",
"function": {
"name": "resolve_contact",
"description": "Look up a contact by name. Searches CardDAV address book and sent email history. Returns email addresses (when available) or phone numbers. Use when the user says 'message [name]', 'email [name]', or asks for someone's contact details.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Person's name to search for"},
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_contact",
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "add", "update", "delete"],
"description": "list = show all contacts (with uids); add = create; update = edit by uid; delete = remove by uid."},
"uid": {"type": "string", "description": "Contact UID (required for update/delete; get it from action=list)."},
"name": {"type": "string", "description": "Contact's display name (for add/update)."},
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update)."},
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (for update; first is primary)."},
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers (for update)."},
"address": {"type": "string", "description": "Postal/mailing address as a single human-readable string."},
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "list_email_accounts",
"description": "List configured email accounts. Use this before checking mail when the user names a mailbox/account such as Gmail, work, or a custom domain, then pass the returned account name/email/id to the other email tools.",
"parameters": {
"type": "object",
"properties": {},
}
}
},
{
"type": "function",
"function": {
"name": "send_email",
"description": "Send a new email. Use resolve_contact first if you only have a name and need to find the email address. If multiple accounts exist, pass account from list_email_accounts.",
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient email address"},
"subject": {"type": "string", "description": "Email subject line"},
"body": {"type": "string", "description": "Email body text"},
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts, e.g. Gmail or user@example.com"},
},
"required": ["to", "subject", "body"]
}
}
},
{
"type": "function",
"function": {
"name": "list_emails",
"description": "List emails from an account/folder, newest first. Returns subject, sender, date, UID, and account for each email. Use list_email_accounts first when the user mentions Gmail/work/a custom mailbox. For last/latest/newest email requests, use max_results=1 and unread_only=false.",
"parameters": {
"type": "object",
"properties": {
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
"max_results": {"type": "integer", "description": "Max emails to return (default: 20)"},
"limit": {"type": "integer", "description": "Backward-compatible alias for max_results"},
"unread_only": {"type": "boolean", "description": "Only show unread emails. Default false; set true only when the user asks for unread emails."},
"unresponded_only": {"type": "boolean", "description": "Only show unanswered emails. Default false."},
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts, e.g. Gmail or user@example.com"},
},
}
}
},
{
"type": "function",
"function": {
"name": "read_email",
"description": "Read the full content of a specific email by UID.",
"parameters": {
"type": "object",
"properties": {
"uid": {"type": "string", "description": "Email UID to read"},
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts, especially when the UID came from a non-default mailbox"},
},
"required": ["uid"]
}
}
},
{
"type": "function",
"function": {
"name": "reply_to_email",
"description": "SEND a reply email immediately by UID. Do not use this when the user asks to open/start a reply window or draft; use ui_control action=open_email_reply instead. For follow-up 'reply ...' requests where the user clearly wants to send now, use the exact UID from the latest read_email/list_emails result; never invent UID 1. Automatically threads with In-Reply-To/References headers.",
"parameters": {
"type": "object",
"properties": {
"uid": {"type": "string", "description": "Exact UID of the email to reply to from list_emails/read_email; never invent UID 1"},
"body": {"type": "string", "description": "Reply body text"},
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts, especially when the UID came from a non-default mailbox"},
},
"required": ["uid", "body"]
}
}
},
{
"type": "function",
"function": {
"name": "bulk_email",
"description": "Perform one action on many emails at once. Use this for 'delete all those', 'archive these', 'mark all read', or any bulk operation after list_emails. Always pass account when the listed emails came from a named account such as Gmail.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["mark_read", "mark_unread", "archive", "delete", "junk"], "description": "Bulk action to perform"},
"uids": {"type": "array", "items": {"type": "string"}, "description": "UIDs from the latest list_emails result"},
"all_unread": {"type": "boolean", "description": "Operate on all unread messages in folder instead of explicit UIDs"},
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
"permanent": {"type": "boolean", "description": "For delete: hard-delete instead of moving to Trash"},
"account": {"type": "string", "description": "Account name/email/id from list_email_accounts, e.g. Gmail or user@example.com"},
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "delete_email",
"description": "Delete one email by UID. For multiple messages, use bulk_email instead. Always pass account when the email came from a named account such as Gmail.",
"parameters": {
"type": "object",
"properties": {
"uid": {"type": "string", "description": "Email UID from list_emails/read_email"},
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
"permanent": {"type": "boolean", "description": "Hard-delete instead of moving to Trash"},
"account": {"type": "string", "description": "Account name/email/id from list_email_accounts"},
},
"required": ["uid"]
}
}
},
{
"type": "function",
"function": {
"name": "archive_email",
"description": "Archive one email by UID. For multiple messages, use bulk_email instead. Always pass account when the email came from a named account such as Gmail.",
"parameters": {
"type": "object",
"properties": {
"uid": {"type": "string", "description": "Email UID from list_emails/read_email"},
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
"account": {"type": "string", "description": "Account name/email/id from list_email_accounts"},
},
"required": ["uid"]
}
}
},
{
"type": "function",
"function": {
"name": "mark_email_read",
"description": "Mark one email as read or unread by UID. For multiple messages, use bulk_email instead. Always pass account when the email came from a named account such as Gmail.",
"parameters": {
"type": "object",
"properties": {
"uid": {"type": "string", "description": "Email UID from list_emails/read_email"},
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
"read": {"type": "boolean", "description": "True marks read; false marks unread"},
"account": {"type": "string", "description": "Account name/email/id from list_email_accounts"},
},
"required": ["uid"]
}
}
},
{
"type": "function",
"function": {
"name": "manage_bg_jobs",
"description": "Inspect and control detached background `bash` jobs (started with the `#!bg` marker). action='list' shows this chat's jobs with id/status/age/command; action='output' returns a job's captured output so far (use for a still-running job, or to re-read a finished one); action='kill' terminates a runaway job's process tree instead of waiting out its max-runtime. output and kill need job_id from list.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "output", "kill"], "description": "list | output | kill (default: list)"},
"job_id": {"type": "string", "description": "Background job id (required for output/kill; from action='list')"},
},
"required": ["action"]
}
}
},
]
# ---------------------------------------------------------------------------
# Converter: native function call -> ToolBlock
# ---------------------------------------------------------------------------
def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock]:
"""Convert a native function call into a ToolBlock for the existing execution pipeline."""
try:
if not arguments or (isinstance(arguments, str) and not arguments.strip()):
args = {}
else:
args = json.loads(arguments) if isinstance(arguments, str) else arguments
except (json.JSONDecodeError, TypeError):
logger.error(f"Failed to parse function call arguments for {name}: {arguments}")
return None
tool_type = _TOOL_NAME_MAP.get(name, name)
# Some models emit valid JSON that isn't an object (e.g. a bare array
# ["ls -la"], string, or number) as function arguments. Most local tools keep
# the legacy empty-object coercion for stream robustness, but email MCP tools
# must fail closed so a malformed call cannot read the default mailbox.
# Uses the shared BUILTIN_EMAIL_TOOLS (single source of truth) so the
# fail-closed set can't drift from the dispatch/blocklist sets.
if not isinstance(args, dict):
if tool_type.startswith("mcp__email__") or name in BUILTIN_EMAIL_TOOLS:
logger.warning(f"Non-object email function call arguments for {name}: {args!r}; rejecting")
return None
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
args = {}
# Allow MCP tools through (namespaced as mcp__serverid__toolname)
if tool_type.startswith("mcp__"):
content = json.dumps(args) if args else "{}"
return ToolBlock(tool_type, content)
# Email tools are implemented as MCP — route them to email
if name in BUILTIN_EMAIL_TOOLS:
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
if tool_type not in TOOL_TAGS:
logger.warning(f"Unknown function call: {name}")
return None
# Convert structured args back to the text format each tool expects
if tool_type == "bash":
content = args.get("command", "")
elif tool_type == "python":
content = args.get("code", "")
elif tool_type == "web_search":
queries = args.get("queries")
if isinstance(queries, list) and queries:
content = str(queries[0])
elif queries:
content = str(queries)
else:
content = args.get("query", "")
# Preserve the model-requested freshness filter — the web_search schema
# advertises time_filter and the executor parses {"query","time_filter"},
# but a bare query string dropped it. Mirrors the read_file JSON idiom.
tf = args.get("time_filter")
if content and isinstance(tf, str) and tf in ("day", "week", "month", "year"):
content = json.dumps({"query": content, "time_filter": tf})
elif tool_type == "read_file":
# Plain path (back-compat) unless a line range is requested → JSON.
if args.get("offset") or args.get("limit"):
content = json.dumps(args)
else:
content = args.get("path", "")
elif tool_type in ("grep", "glob", "ls"):
content = json.dumps(args) if args else "{}"
elif tool_type == "get_workspace":
content = ""
elif tool_type == "write_file":
content = args.get("path", "") + "\n" + args.get("content", "")
elif tool_type == "edit_file":
content = json.dumps(args)
elif tool_type == "create_document":
parts = [args.get("title", "Untitled")]
if args.get("language"):
parts.append(args["language"])
parts.append(args.get("content", ""))
content = "\n".join(parts)
elif tool_type == "edit_document":
blocks = []
edits = args.get("edits", [])
if not isinstance(edits, list):
edits = []
for edit in edits:
if not isinstance(edit, dict):
continue
blocks.append(
f'<<<FIND>>>\n{edit.get("find", "")}\n<<<REPLACE>>>\n{edit.get("replace", "")}\n<<<END>>>'
)
content = "\n".join(blocks)
elif tool_type == "suggest_document":
blocks = []
suggestions = args.get("suggestions", [])
if not isinstance(suggestions, list):
suggestions = []
for s in suggestions:
if not isinstance(s, dict):
continue
blocks.append(
f'<<<FIND>>>\n{s.get("find", "")}\n<<<SUGGEST>>>\n{s.get("replace", "")}\n<<<REASON>>>\n{s.get("reason", "")}\n<<<END>>>'
)
content = "\n".join(blocks)
elif tool_type == "update_document":
content = args.get("content", "")
elif tool_type == "search_chats":
content = args.get("query", "")
elif tool_type == "chat_with_model":
content = args.get("model", "") + "\n" + args.get("message", "")
elif tool_type == "create_session":
content = args.get("name", "Untitled") + "\n" + args.get("model", "")
elif tool_type == "list_sessions":
content = args.get("filter", "")
elif tool_type == "send_to_session":
content = args.get("session_id", "") + "\n" + args.get("message", "")
elif tool_type == "pipeline":
# Pass as JSON for the pipeline parser
content = json.dumps({"steps": args.get("steps", [])})
elif tool_type == "manage_session":
action = args.get("action", "")
value = args.get("value", "")
# `list` is the only action that takes an OPTIONAL keyword
# filter — never a session_id. Don't leak the "current" default
# into the filter slot (was producing "No sessions found
# matching 'current'" when the agent omitted session_id).
if action == "list":
keyword = args.get("session_id", "") or args.get("keyword", "") or value
content = "list" + (("\n" + keyword) if keyword and keyword.lower() != "current" else "")
else:
sid = args.get("session_id", "current")
content = action + "\n" + sid
if value:
content += "\n" + value
elif tool_type == "manage_memory":
action = args.get("action", "")
if action == "add":
content = "add\n" + args.get("text", "")
if args.get("category"):
content += "\n" + args["category"]
elif action == "edit":
content = "edit\n" + args.get("memory_id", "") + "\n" + args.get("text", "")
elif action == "delete":
content = "delete\n" + args.get("memory_id", "")
elif action == "search":
content = "search\n" + args.get("text", "")
elif action == "list":
content = "list"
if args.get("category"):
content += "\n" + args["category"]
else:
content = action
elif tool_type == "list_models":
content = args.get("filter", "")
elif tool_type == "ui_control":
action = args.get("action", "")
name = args.get("name", "")
value = args.get("value", "")
if action == "toggle":
content = f"toggle {name} {value}"
elif action == "open_panel":
content = f"open_panel {name or value}"
elif action == "open_email_reply":
uid = args.get("uid") or name
folder = args.get("folder") or value or "INBOX"
mode = args.get("mode") or "reply"
content = f"open_email_reply {uid} {folder} {mode}"
elif action == "set_mode":
content = f"set_mode {value or name}"
elif action == "switch_model":
content = f"switch_model {value or name}"
elif action == "set_theme":
content = f"set_theme {value or name}"
elif action == "create_theme":
colors = args.get("colors", {})
theme_name = name or value or "custom"
bg = colors.get("bg", "#282c34")
fg = colors.get("fg", "#9cdef2")
panel = colors.get("panel", "#111111")
border = colors.get("border", "#355a66")
accent = colors.get("accent", "#e06c75")
content = f"create_theme {theme_name} {bg} {fg} {panel} {border} {accent}"
# Append advanced overrides as key=value
adv_keys = [
"userBubbleBg", "aiBubbleBg", "bubbleBorder", "sidebarBg",
"sectionAccent", "brandColor", "inputBg", "inputBorder",
"sendBtnBg", "sendBtnHover", "codeBg", "codeFg",
"toggleBg", "toggleActive", "accentPrimary", "accentError",
]
for ak in adv_keys:
if colors.get(ak):
content += f" {ak}={colors[ak]}"
else:
content = action
elif tool_type in ("manage_tasks", "manage_skills", "api_call",
"manage_endpoints", "manage_mcp", "manage_webhooks",
"manage_tokens", "manage_documents", "manage_settings"):
content = json.dumps(args)
elif tool_type == "ask_teacher":
content = args.get("model", "auto") + "\n" + args.get("problem", "")
elif tool_type == "ask_user":
# Keep user-facing labels readable in the tool trace. The outer SSE
# JSON encoder will escape them for transport and JSON.parse restores
# them once; pre-escaping here caused literal ``\u00f1`` sequences to
# remain visible in the debug panel.
content = json.dumps(args, ensure_ascii=False)
else:
content = json.dumps(args)
return ToolBlock(tool_type, content)