Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Security fixes are handled on the default branch until formal releases are cut.
- Give admin accounts strong passwords and enable 2FA where possible.
- Leave high-risk agent tools restricted to admins: shell, Python, file read/write, email send/read, MCP, app API, task/skill/memory management, settings, tokens, and model serving.
- Rotate API keys, webhook secrets, and Odysseus API tokens if they appear in logs, screenshots, demos, or shared chats.
- Treat shell, model-serving, MCP, email, calendar, and vault features as privileged admin functionality.
- Treat shell, model-serving, MCP, email, calendar, and vault features as privileged admin functionality. Both `read_calendar` and `manage_calendar` are admin-only; `read_calendar` is also disabled in plan mode because its owner-scoped CalDAV pull can refresh the local cache.
- Common internal-only ports are Odysseus `7000`, SearXNG `8080`, ntfy `8091`, ChromaDB `8100`, Ollama `11434`, and local model/provider APIs such as `8000-8020`.

## Publishing A Fork
Expand Down
23 changes: 23 additions & 0 deletions docs/calendar-read-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Read-only Calendar tool

`read_calendar` is the bounded, admin-only Calendar facade for agent and voice integrations. It uses the authenticated owner from the tool execution context and supports only:

- `list_calendars`
- `list_events` with explicit ISO `start` and `end` values

The tool attempts an owner-scoped CalDAV pull before reading the local owner-scoped cache. That pull may refresh cached CalDAV rows, but the tool never creates, updates, or deletes a user event. If the pull fails or CalDAV is not configured, the cached read still succeeds with `calendar_freshness="sync_failed"` and a response beginning `Calendar freshness could not be confirmed; cached owner-scoped data follows.` Sync error details are not exposed. Because the refresh can update the local cache, `read_calendar` is disabled in plan mode even though its user-facing Calendar operations are read-only.

`max_results` defaults to 50 and cannot exceed 100. Event ranges must be ordered and cannot exceed 366 days. Returned text fields are treated as untrusted tool data and capped at 500 characters, the formatted response is capped at 20,000 characters, and truncation is reported through `data_truncated` and `response_truncated`.

Example:

```json
{
"action": "list_events",
"start": "2026-07-15T00:00:00",
"end": "2026-07-16T00:00:00",
"max_results": 25
}
```

Use `manage_calendar` for event creation, updates, and deletion. `read_calendar` has no dependency on a specific UI or voice implementation.
2 changes: 1 addition & 1 deletion routes/chat_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,7 @@ async def chat_stream(request: Request) -> StreamingResponse:
"read_file", "write_file", "edit_file",
"create_document", "edit_document", "update_document",
"send_email", "reply_to_email",
"manage_notes", "manage_calendar", "manage_tasks",
"manage_notes", "manage_calendar", "read_calendar", "manage_tasks",
"api_call", "builtin_browser",
})
if _search_enabled:
Expand Down
10 changes: 6 additions & 4 deletions src/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def _load_mcp_disabled_map() -> Dict[str, set]:
- AFTER A TOOL SUCCEEDS, do not second-guess. The success message ("Document edited: v2, 1 edit") means it worked. Reply in ONE short sentence confirming what was done. No re-checking, no replaying the diff in your head, no validation theater.
- AFTER A TOOL FAILS (timeout, error, "Unknown action", "not found"), DO NOT GO SILENT. The user expects a follow-up: either retry with a fix (e.g. correct args, longer-running form, run `tail -f /tmp/foo.log` to see progress, split into smaller steps), OR explicitly tell them "this didn't work, want me to try X instead?". A failed tool is not a stopping condition — only a successful one is.
- YOU DECLARE WHEN THE JOB IS DONE — not a timer. Keep taking concrete steps while the task still needs them; you have plenty of rounds, so don't rush to quit just because you've made a few calls. There are exactly three ways to end a turn: (1) DONE — before you declare it, sanity-check that every concrete thing the user asked for actually exists or succeeded (file written, edit applied, command exited clean); then stop calling tools and write the final answer (that IS your "done" signal); (2) BLOCKED — you genuinely can't proceed (a capability is missing, permission denied, or data you can't obtain), so say plainly what's blocking you, in a sentence or two, and stop; (3) keep going with the single most useful next step. The only wrong moves are trailing off mid-task without one of these, and repeating a call you already ran.
- Calendar: call `manage_calendar` with `action=list_calendars` FIRST before create/update/delete operations.
- Calendar reads use `read_calendar`; create/update/delete operations call `manage_calendar` with `action=list_calendars` first.
- BULK email actions ("delete all those", "mark all as read", "archive these", "delete all spam", "mark these 19 read") → use the `bulk_email` tool ONCE with either the exact `uids` list from the latest `list_emails` result or `all_unread: true`. NEVER just say you deleted/archived/marked messages unless a delete/archive/mark/bulk email tool call succeeded. NEVER loop mark_email_read / archive_email / delete_email one message at a time — that floods the context and can blow the token budget. One bulk_email call handles the whole set.
- Email UIDs are the values after `UID:` in tool output, not list row numbers. For example, row `1.` with `UID: 90186` must use `"90186"`, never `"1"`.
- "Last/latest/newest email" means call `list_emails` with `max_results: 1`, `unread_only: false`, and the right `account`, then read the UID returned by that tool if full content is needed. NEVER use a table row number like "#18" as an email UID.
Expand Down Expand Up @@ -171,7 +171,7 @@ def _load_mcp_disabled_map() -> Dict[str, set]:
- AFTER A TOOL SUCCEEDS, do not second-guess. A success response means it worked. Reply in ONE short sentence confirming what was done. No verification thinking, no re-analyzing — move on.
- AFTER A TOOL FAILS, DO NOT GO SILENT. The user expects a follow-up: retry with a fix, run a diagnostic (`tail`, `ls`, `which`), or explicitly tell them what didn't work and what you'll try next. Failure is not a stopping condition.
- YOU DECLARE WHEN THE JOB IS DONE — not a timer. Keep taking concrete steps while the task still needs them; don't quit early just because you've made a few calls. Three ways to end a turn: (1) DONE — before declaring it, verify every concrete deliverable the user asked for actually exists or succeeded; then stop calling tools and write the final answer (that IS your "done" signal); (2) BLOCKED — you can't proceed (missing capability, permission denied, unobtainable data), so state plainly what's blocking you and stop; (3) keep going with the single most useful next step. Never trail off mid-task without (1) or (2), and never repeat a call you already ran.
- Calendar: call `manage_calendar` with `action=list_calendars` FIRST before create/update/delete operations.
- Calendar reads use `read_calendar`; create/update/delete operations call `manage_calendar` with `action=list_calendars` first.
- "Create/add/write a note" / "notes" / "todos" / "remind me to X at <time>" → use `manage_notes`. Do NOT store notes in `manage_memory`; memory is for persistent facts/preferences about the user, not note content. For reminders, include a `due_date`; for todos, use `note_type=checklist` when appropriate. `manage_tasks` is for RECURRING background AI jobs, NOT for one-off user reminders.
- "Disable/turn off/enable/turn on <tool>" (shell, search, research, browser, documents, incognito, etc.) → call `ui_control` with `toggle <name> <on|off>`. Aliases accepted: shell→bash, search→web, deepresearch→research, documents→document_editor. NEVER record this as a memory — the user wants the toggle flipped, not a note about preferring it.
- "Research X" / "do research on X" / "look into Y" / "deep dive on Z" → call `trigger_research` with `topic`. This starts a live job that appears in the Deep Research sidebar (streams progress + final report). **Do NOT use `web_search` for these** — saw the agent do a plain web_search for "do research on X" when the user wanted the deep-research job. "research X" is a deep-research request, not a quick lookup. (web_search is only for a single quick fact mid-task.) Do NOT POST /api/research/start via app_api either — blocked. After starting, tell the user it's running in the Deep Research sidebar. Only if the user explicitly wants it inline/quick should you fall back to web_search.
Expand Down Expand Up @@ -289,6 +289,7 @@ def _load_mcp_disabled_map() -> Dict[str, set]:
"notes_calendar_tasks": """\
## Notes/calendar/tasks rules
- Notes/todos/reminders use `manage_notes`, not memory.
- Admin calendar lookup and comparison use `read_calendar`, which reports whether its owner-scoped CalDAV refresh succeeded. It is unavailable in plan mode because that refresh can update the local cache.
- Calendar create/update/delete should call `manage_calendar` with `action=list_calendars` first.
- Recurring/automatic/scheduled requests create a `manage_tasks` task; do not just perform the action once.""",
"ui": """\
Expand Down Expand Up @@ -325,7 +326,7 @@ def _load_mcp_disabled_map() -> Dict[str, set]:
"documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"},
"email": {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "archive_email", "delete_email", "mark_email_read", "resolve_contact", "manage_contact"},
"cookbook": {"download_model", "serve_model", "serve_preset", "list_serve_presets", "list_served_models", "stop_served_model", "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "list_cached_models", "list_cookbook_servers", "adopt_served_model"},
"notes_calendar_tasks": {"manage_notes", "manage_calendar", "manage_tasks"},
"notes_calendar_tasks": {"manage_notes", "manage_calendar", "read_calendar", "manage_tasks"},
"ui": {"ui_control"},
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace", "manage_bg_jobs"},
Expand All @@ -340,7 +341,7 @@ def _domain_rules_for_tools(tool_names: set) -> list[str]:
for domain, domain_tools in _DOMAIN_TOOL_MAP.items():
if names & domain_tools:
rules.append(_DOMAIN_RULES[domain])
if names & {"create_session", "list_sessions", "manage_session", "manage_documents", "manage_notes", "manage_calendar", "manage_tasks", "manage_skills", "manage_research"}:
if names & {"create_session", "list_sessions", "manage_session", "manage_documents", "manage_notes", "manage_calendar", "read_calendar", "manage_tasks", "manage_skills", "manage_research"}:
rules.append(_LINK_RULES)
return rules

Expand Down Expand Up @@ -520,6 +521,7 @@ def _domain_rules_for_tools(tool_names: set) -> list[str]:
For a RECURRING event pass `rrule` as an iCalendar RRULE string, e.g. `"FREQ=WEEKLY;BYDAY=MO"` (every Monday), `"FREQ=DAILY;COUNT=10"`, or `"FREQ=MONTHLY;BYMONTHDAY=1"` — create ONE event with the rrule, do not loop creating many events. Do not pass `rrule` for "next Wednesday only", "just this once", or any single occurrence. \
If the user asks for a reminder/alarm before the event, pass `reminder_minutes` as an integer; do not write reminder text into the event description and do NOT also call `manage_notes` for the same reminder because calendar reminders are routed through Notes automatically. \
`calendar` accepts a name ("Main") or short-id prefix.""",
"read_calendar": "- ```read_calendar``` — Admin-only: refresh and read the authenticated user's Calendar without event mutations. Args (JSON): {\"action\":\"list_events|list_calendars\", \"start\":\"ISO datetime\"?, \"end\":\"ISO datetime\"?, \"calendar\":\"name or id\"?, \"max_results\":50?}. `list_events` requires explicit start/end no more than 366 days apart. Results are owner-scoped and bounded; if freshness could not be confirmed, say so explicitly. This tool is unavailable in plan mode because its CalDAV pull may update the local cache.",
"create_session": "- ```create_session``` — Create a new chat. Line 1 = chat name, line 2 = model name. Use for background/parallel work.",
"list_sessions": "- ```list_sessions``` — List chats sorted MOST-RECENT FIRST (the UI calls them 'chats') with clickable chat-title links. Output includes a relative \"last active\" timestamp per row, so the first row is the user's most recent chat. Content = optional filter keyword (matches chat name). When answering, preserve the `[title](#session-id)` links exactly; do not convert them into plain text.",
"send_to_session": "- ```send_to_session``` — Send a message to another session. Line 1 = session_id, rest = message. Use for orchestrating work across sessions.",
Expand Down
2 changes: 1 addition & 1 deletion src/agent_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"suggest_document",
"manage_endpoints", "manage_mcp", "manage_webhooks",
"manage_tokens", "manage_documents", "manage_settings",
"manage_notes", "manage_calendar",
"manage_notes", "manage_calendar", "read_calendar",
"resolve_contact", "manage_contact",
# Email tool names come from BUILTIN_EMAIL_TOOLS (unioned below)
# so the fence regex, dispatch, and non-admin blocklist all cover
Expand Down
2 changes: 1 addition & 1 deletion src/agent_tools/admin_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ def _mask(k, v):
"image": ["generate_image"],
"tasks": ["manage_tasks"],
"notes": ["manage_notes"],
"calendar": ["manage_calendar"],
"calendar": ["read_calendar", "manage_calendar"],
# The full built-in email tool set, in BOTH spellings: the
# qualified mcp__email__* names drive MCP schema hiding, the
# bare names drive function-schema hiding, and the runtime
Expand Down
5 changes: 4 additions & 1 deletion src/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ async def _execute_tool_block_impl(
from src.tool_implementations import (
do_search_chats, do_manage_tasks,
do_manage_skills, do_api_call, do_manage_notes,
do_manage_calendar,
do_manage_calendar, do_read_calendar,
do_download_model, do_serve_model, do_list_served_models, do_stop_served_model,
do_tail_serve_output,
do_list_downloads, do_cancel_download, do_search_hf_models, do_list_cached_models,
Expand Down Expand Up @@ -809,6 +809,9 @@ async def _execute_tool_block_impl(
elif tool == "manage_calendar":
desc = "manage_calendar"
result = await do_manage_calendar(content, owner=owner)
elif tool == "read_calendar":
desc = "read_calendar"
result = await do_read_calendar(content, owner=owner)
elif tool == "download_model":
desc = "download_model"
result = await do_download_model(content, owner=owner)
Expand Down
2 changes: 1 addition & 1 deletion src/tool_implementations.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def __getattr__(name):
# Notes domain extracted to src/tools/notes.py (slice 1, #4082/#4071).
from src.tools.notes import do_manage_notes # noqa: F401
# Calendar domain extracted to src/tools/calendar.py (slice 1, #4082/#4071).
from src.tools.calendar import do_manage_calendar # noqa: F401
from src.tools.calendar import do_manage_calendar, do_read_calendar # noqa: F401
# Image domain extracted to src/tools/image.py (slice 1, #4082/#4071).
from src.tools.image import do_edit_image # noqa: F401
# Research domain extracted to src/tools/research.py (slice 1, #4082/#4071).
Expand Down
3 changes: 2 additions & 1 deletion src/tool_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"manage_contact": "Save / update / delete / list address-book contacts (CardDAV). Use for info about ANOTHER person — name, email, phone, postal address. Args: action=list|add|update|delete, name, email, phones, address, uid (from list). For 'save this for <person>' / address pastes / phone numbers next to a name, this is the right tool — NOT manage_memory. Do NOT use for facts about the USER ('my name is X'); those are manage_memory.",
"manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.",
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. Use rrule only for explicit recurrence; for update_event pass rrule='' to remove repeats. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
"read_calendar": "Admin-only Calendar access for voice and agent answers. Refreshes the authenticated owner's connected CalDAV cache, then lists owner-scoped calendars or events over at most 366 days. Never creates, updates, or deletes events, but is unavailable in plan mode because its pull may update the local cache. Returns at most 100 rows, caps text fields at 500 characters, and reports when freshness could not be confirmed.",
"download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.",
"serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. cmd MUST start with the binary directly — e.g. `vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --port 8003 --tensor-parallel-size 8 …`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||` — those get rejected by the validator. The venv activation (env_prefix) and CUDA env are added automatically from the target host's saved settings. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions. If serve_model fails with 'Invalid characters in cmd', simplify to the bare binary + args.",
"list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.",
Expand Down Expand Up @@ -349,7 +350,7 @@ def retrieve(self, query: str, k: int = 8) -> List[str]:
frozenset({"email", "emails", "mail", "mails", "gmail", "googlemail", "message", "messages", "send", "reply", "replies", "inbox", "unread"}):
{"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
frozenset({"calendar", "event", "meeting", "schedule", "appointment"}):
{"manage_calendar"},
{"manage_calendar", "read_calendar"},
# Detached background `bash` jobs (#!bg): check on / read output / kill.
frozenset({"background job", "background jobs", "bg job", "bg jobs",
"background task", "is the job done", "check the job",
Expand Down
1 change: 1 addition & 0 deletions src/tool_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def web_search_enabled_for_turn(allow_web_search: object, use_web: object = None
"mark_email_read",
"pipeline",
"python",
"read_calendar",
"read_email",
"read_file",
"reply_to_email",
Expand Down
Loading