2026-05-31 23:58:26 +09:00
"""
tool_implementations . py
Extracted tool implementation functions ( do_ * and helpers ) from agent_tools . py .
These handle the actual execution logic for each tool type .
"""
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
import asyncio
2026-05-31 23:58:26 +09:00
import json
import logging
import os
import re
from typing import Any , Dict , List , Optional
2026-06-08 20:05:30 -03:00
from src . constants import MAX_READ_CHARS , DEEP_RESEARCH_DIR , VAULT_FILE
from src . tool_utils import get_mcp_manager
2026-06-07 23:22:09 +02:00
from core . constants import internal_api_base
2026-05-31 23:58:26 +09:00
logger = logging . getLogger ( __name__ )
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
def _parse_tool_args ( content ) :
""" Parse a tool-call argument blob.
Accepts either a JSON string or an already - decoded dict . Unwraps the
common ` { " body " : { . . . } } ` envelope that smaller models emit when they
read tool descriptions like " Body is JSON: { ...} " literally — they
pass ` body ` as a field name rather than treating it as a noun .
Returns a dict on success , raises ValueError on bad JSON .
"""
if isinstance ( content , str ) :
try :
args = json . loads ( content ) if content . strip ( ) else { }
except ( json . JSONDecodeError , TypeError ) as e :
raise ValueError ( str ( e ) )
elif isinstance ( content , dict ) :
args = content
else :
args = { }
# Unwrap {"body": {...}} envelope — but only if `body` is the sole key
# and points at a dict. We don't want to clobber a legitimate `body`
# field on tools where it's a real arg (e.g. send_email body text).
if (
isinstance ( args , dict )
and len ( args ) == 1
and " body " in args
and isinstance ( args [ " body " ] , dict )
and " action " in args [ " body " ] # extra safety: only unwrap if the inner dict looks like a tool call
) :
args = args [ " body " ]
return args
# ---------------------------------------------------------------------------
# Search chats
# ---------------------------------------------------------------------------
async def do_search_chats ( query : str , limit : int = 20 , owner : str | None = None ) - > Dict :
2026-06-05 18:08:31 -06:00
""" Search past session transcripts for the calling user ' s sessions only.
2026-05-31 23:58:26 +09:00
Without an owner filter this used to leak EVERY user ' s chat history
into the agent ' s `search_chats` results (v2 review HIGH-11). The
caller in ` tool_execution . execute_tool_block ` now plumbs the owner
through ; legacy callers without owner pass through as before but
will only see legacy / null - owner rows .
"""
try :
2026-06-05 18:08:31 -06:00
from src . session_search import search_session_messages
2026-05-31 23:58:26 +09:00
2026-06-05 18:08:31 -06:00
results = search_session_messages ( query , limit = limit , owner = owner )
if not results :
2026-05-31 23:58:26 +09:00
return { " results " : f " No chats found matching \" { query } \" . " }
# Group by session to avoid duplicate links
seen_sessions = { }
2026-06-05 18:08:31 -06:00
for result in results :
if result . session_id not in seen_sessions :
seen_sessions [ result . session_id ] = result
2026-05-31 23:58:26 +09:00
lines = [ f " Found { len ( seen_sessions ) } session(s) matching \" { query } \" : \n " ]
2026-06-05 18:08:31 -06:00
for sid , result in seen_sessions . items ( ) :
lines . append ( f " - ** { result . session_name } ** (# { sid } ) " )
2026-05-31 23:58:26 +09:00
lines . append ( f " Link: [Open chat](# { sid } ) " )
2026-06-05 18:08:31 -06:00
lines . append ( f " Match ( { result . role } ): { result . content_snippet } " )
if result . context_before :
before = result . context_before [ - 1 ]
lines . append ( f " Before ( { before [ ' role ' ] } ): { before [ ' content ' ] [ : 180 ] } " )
if result . context_after :
after = result . context_after [ 0 ]
lines . append ( f " After ( { after [ ' role ' ] } ): { after [ ' content ' ] [ : 180 ] } " )
2026-05-31 23:58:26 +09:00
lines . append ( " " )
return { " results " : " \n " . join ( lines ) }
except Exception as e :
logger . error ( f " search_chats failed: { e } " )
return { " error " : str ( e ) , " exit_code " : 1 }
# ---------------------------------------------------------------------------
# Skills management tool
# ---------------------------------------------------------------------------
async def do_manage_skills ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Handle manage_skills tool calls.
SKILL . md - backed CRUD with progressive disclosure ( Hermes - style ) . Actions :
list / index — Level 0 : name + description summary .
view { name } — Level 1 : full SKILL . md .
view_ref { name , path } — Level 2 : a sub - file under the skill dir .
add { name , description , when_to_use , procedure [ ] , pitfalls [ ] ,
verification [ ] , tags [ ] , category , status }
— Create a new skill ( draft by default ) .
patch { name , old_string , new_string }
— Token - efficient surgical edit on the
raw SKILL . md text . Fails on ambiguous
` old_string ` ( multiple matches ) .
edit { name , content } — Replace the entire SKILL . md .
publish { name } — Flip status : draft - > published .
delete { name } — Remove the skill directory .
search { query } — Relevance match on published skills .
"""
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = ( args . get ( " action " ) or " " ) . lower ( )
from services . memory . skills import SkillsManager
from services . memory . skill_format import Skill , slugify
from src . constants import DATA_DIR
sm = SkillsManager ( DATA_DIR )
# Accept legacy `skill_id` as an alias for `name`.
name = ( args . get ( " name " ) or args . get ( " skill_id " ) or " " ) . strip ( )
if action in ( " list " , " index " , " " ) :
all_skills = sm . load ( owner = owner )
if not all_skills :
return { " results " : " No skills yet. Create one with action= ' add ' . " }
published = [ s for s in all_skills if s . get ( " status " ) == " published " ]
drafts = [ s for s in all_skills if s . get ( " status " ) == " draft " ]
lines = [ ]
if published :
lines . append ( " ## Published " )
for s in sorted ( published , key = lambda x : x [ " name " ] ) :
lines . append ( f " - ** { s [ ' name ' ] } ** ( { s . get ( ' category ' , ' general ' ) } ): { s . get ( ' description ' , ' ' ) } " )
if drafts :
lines . append ( " \n ## Drafts " )
for s in sorted ( drafts , key = lambda x : x [ " name " ] ) :
lines . append ( f " - ** { s [ ' name ' ] } ** [draft]: { s . get ( ' description ' , ' ' ) } " )
return { " results " : " \n " . join ( lines ) if lines else " No skills yet. " }
if action == " view " :
if not name :
return { " error " : " name is required for view " , " exit_code " : 1 }
fix(skills): scope skill reads to caller owner (#777)
read_skill_md and read_skill_reference walk all skill files via
_iter_skill_files and return the first match by slug, regardless
of owner. In a multi-user deployment where two users have skills
with the same slug under different categories, a caller scoped
to owner='alice' can read Bob's skill content.
This is the same cross-tenant leak class as the update_skill /
delete_skill fix (PR #755, merged), but on the read path.
Changes:
- read_skill_md / read_skill_reference accept owner= param (default
None = match ownerless only, matching the write-path convention).
- 7 callers updated: tool_implementations.py (view, view_ref, patch),
builtin_actions.py (test_skills), skills_routes.py (audit, source,
test routes).
- Tests: read scoping (alice reads hers, not bob's), positive update
scoping (alice can mutate her own), ownerless-match default.
2026-06-02 03:21:27 +01:00
md = sm . read_skill_md ( name , owner = owner )
2026-05-31 23:58:26 +09:00
if md is None :
return { " error " : f " Skill { name !r} not found " , " exit_code " : 1 }
return { " results " : md }
if action == " view_ref " :
if not name :
return { " error " : " name is required for view_ref " , " exit_code " : 1 }
ref = ( args . get ( " path " ) or " " ) . strip ( )
if not ref :
return { " error " : " path is required for view_ref " , " exit_code " : 1 }
fix(skills): scope skill reads to caller owner (#777)
read_skill_md and read_skill_reference walk all skill files via
_iter_skill_files and return the first match by slug, regardless
of owner. In a multi-user deployment where two users have skills
with the same slug under different categories, a caller scoped
to owner='alice' can read Bob's skill content.
This is the same cross-tenant leak class as the update_skill /
delete_skill fix (PR #755, merged), but on the read path.
Changes:
- read_skill_md / read_skill_reference accept owner= param (default
None = match ownerless only, matching the write-path convention).
- 7 callers updated: tool_implementations.py (view, view_ref, patch),
builtin_actions.py (test_skills), skills_routes.py (audit, source,
test routes).
- Tests: read scoping (alice reads hers, not bob's), positive update
scoping (alice can mutate her own), ownerless-match default.
2026-06-02 03:21:27 +01:00
text = sm . read_skill_reference ( name , ref , owner = owner )
2026-05-31 23:58:26 +09:00
if text is None :
return { " error " : f " Reference { ref !r} not found under { name !r} " , " exit_code " : 1 }
return { " results " : text }
if action == " add " :
if not name :
return {
" error " : " name is required for add. Provide the exact slug the user should see, then report the returned name. " ,
" exit_code " : 1 ,
}
proc = args . get ( " procedure " )
if proc is None :
proc = args . get ( " steps " ) or [ ]
if not proc and not args . get ( " body_extra " ) and not args . get ( " solution " ) :
return { " error " : " procedure (or solution body) is required " , " exit_code " : 1 }
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
# Same auto-publish gate as the extractor path — when the user
# has auto_approve_skills on and the caller didn't pin an explicit
# status, publish immediately. Audit later demotes/removes on fail.
_status_arg = args . get ( " status " )
if not _status_arg :
try :
from routes . prefs_routes import _load_for_user as _load_prefs
_prefs = _load_prefs ( owner ) or { }
_status_arg = " published " if _prefs . get ( " auto_approve_skills " , True ) else " draft "
except Exception :
_status_arg = " draft "
2026-05-31 23:58:26 +09:00
entry = sm . add_skill (
name = args . get ( " name " ) ,
description = ( args . get ( " description " ) or args . get ( " title " ) or " " ) . strip ( ) ,
category = args . get ( " category " ) or " general " ,
tags = args . get ( " tags " ) or [ ] ,
platforms = args . get ( " platforms " ) or [ ] ,
requires_toolsets = args . get ( " requires_toolsets " ) or [ ] ,
fallback_for_toolsets = args . get ( " fallback_for_toolsets " ) or [ ] ,
when_to_use = ( args . get ( " when_to_use " ) if args . get ( " when_to_use " ) is not None
else args . get ( " problem " , " " ) ) ,
procedure = proc ,
pitfalls = args . get ( " pitfalls " ) or [ ] ,
verification = args . get ( " verification " ) or [ ] ,
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
status = _status_arg ,
2026-05-31 23:58:26 +09:00
version = args . get ( " version " ) or " 1.0.0 " ,
confidence = args . get ( " confidence " , 0.8 ) ,
source = args . get ( " source " , " learned " ) ,
teacher_model = args . get ( " teacher_model " ) ,
owner = owner ,
title = args . get ( " title " , " " ) ,
problem = args . get ( " problem " , " " ) ,
solution = args . get ( " solution " , " " ) ,
steps = args . get ( " steps " ) or [ ] ,
)
if entry . get ( " _deduped " ) :
return { " results " : (
f " A near-identical skill already exists: ` { entry [ ' name ' ] } ` — not creating "
f " a duplicate. View or edit it with action= ' view ' , name= ' { entry [ ' name ' ] } ' . "
) }
try :
from src . event_bus import fire_event
fire_event ( " skill_added " , owner )
except Exception :
logger . debug ( " skill_added event dispatch failed " , exc_info = True )
verify_hint = " "
if entry . get ( " status " ) == " draft " :
verify_hint = (
" \n \n This skill is a DRAFT. Run through the procedure once to verify, "
f " then publish with action= ' publish ' , name= ' { entry [ ' name ' ] } ' . "
)
return { " results " : f " Created skill ` { entry [ ' name ' ] } ` — { entry . get ( ' description ' , ' ' ) } { verify_hint } " }
if action == " edit " :
if not name :
return { " error " : " name is required for edit " , " exit_code " : 1 }
new_content = args . get ( " content " )
if not isinstance ( new_content , str ) or not new_content . strip ( ) :
return { " error " : " content (full SKILL.md) is required for edit " , " exit_code " : 1 }
try :
sk_new = Skill . from_markdown ( new_content )
except Exception as e :
return { " error " : f " Could not parse content as SKILL.md: { e } " , " exit_code " : 1 }
sk_new . name = slugify ( sk_new . name or name )
existing = sm . load ( owner = owner )
match = next ( ( s for s in existing if s . get ( " name " ) == name ) , None )
if not match :
return { " error " : f " Skill { name !r} not found " , " exit_code " : 1 }
if not sk_new . owner :
sk_new . owner = match . get ( " owner " ) or owner
2026-06-01 21:59:43 +01:00
ok = sm . update_skill ( name , _skill_dump ( sk_new ) , owner = owner )
2026-05-31 23:58:26 +09:00
return { " results " : f " Edited skill ` { sk_new . name } `. " } if ok else { " error " : " Update failed " , " exit_code " : 1 }
if action == " patch " :
if not name :
return { " error " : " name is required for patch " , " exit_code " : 1 }
old = args . get ( " old_string " )
new_str = args . get ( " new_string " , " " )
if not isinstance ( old , str ) or not old :
return { " error " : " old_string is required and must be non-empty " , " exit_code " : 1 }
fix(skills): scope skill reads to caller owner (#777)
read_skill_md and read_skill_reference walk all skill files via
_iter_skill_files and return the first match by slug, regardless
of owner. In a multi-user deployment where two users have skills
with the same slug under different categories, a caller scoped
to owner='alice' can read Bob's skill content.
This is the same cross-tenant leak class as the update_skill /
delete_skill fix (PR #755, merged), but on the read path.
Changes:
- read_skill_md / read_skill_reference accept owner= param (default
None = match ownerless only, matching the write-path convention).
- 7 callers updated: tool_implementations.py (view, view_ref, patch),
builtin_actions.py (test_skills), skills_routes.py (audit, source,
test routes).
- Tests: read scoping (alice reads hers, not bob's), positive update
scoping (alice can mutate her own), ownerless-match default.
2026-06-02 03:21:27 +01:00
md = sm . read_skill_md ( name , owner = owner )
2026-05-31 23:58:26 +09:00
if md is None :
return { " error " : f " Skill { name !r} not found " , " exit_code " : 1 }
count = md . count ( old )
if count == 0 :
return { " error " : " old_string not found in SKILL.md " , " exit_code " : 1 }
if count > 1 :
return { " error " : f " old_string is ambiguous (appears { count } times). Make it more specific. " , " exit_code " : 1 }
new_md = md . replace ( old , new_str , 1 )
try :
sk_new = Skill . from_markdown ( new_md )
except Exception as e :
return { " error " : f " Patched content is not valid SKILL.md: { e } " , " exit_code " : 1 }
sk_new . name = slugify ( sk_new . name or name )
2026-06-01 21:59:43 +01:00
ok = sm . update_skill ( name , _skill_dump ( sk_new ) , owner = owner )
2026-05-31 23:58:26 +09:00
return { " results " : f " Patched skill ` { sk_new . name } `. " } if ok else { " error " : " Patch update failed " , " exit_code " : 1 }
if action == " publish " :
if not name :
return { " error " : " name is required for publish " , " exit_code " : 1 }
all_skills = sm . load ( owner = owner )
match = next ( ( s for s in all_skills if s . get ( " name " ) == name ) , None )
if not match :
return { " error " : f " Skill { name !r} not found " , " exit_code " : 1 }
updates = { " status " : " published " }
if args . get ( " confidence " ) is not None :
updates [ " confidence " ] = max ( 0.0 , min ( 1.0 , float ( args [ " confidence " ] ) ) )
2026-06-01 21:59:43 +01:00
sm . update_skill ( name , updates , owner = owner )
2026-05-31 23:58:26 +09:00
return { " results " : f " ✅ Published ` { name } `. It now appears in the skills index for future turns. " }
if action == " delete " :
if not name :
return { " error " : " name is required for delete " , " exit_code " : 1 }
2026-06-01 21:59:43 +01:00
ok = sm . delete_skill ( name , owner = owner )
2026-05-31 23:58:26 +09:00
return { " results " : f " Deleted skill ` { name } `. " } if ok else { " error " : f " Skill { name !r} not found " , " exit_code " : 1 }
if action == " search " :
query = ( args . get ( " query " ) or " " ) . strip ( )
if not query :
return { " error " : " query is required for search " , " exit_code " : 1 }
results = sm . get_relevant_skills ( query , sm . load ( owner = owner ) , max_items = 5 )
if not results :
return { " results " : " No matching skills found. " }
lines = [ ]
for sk in results :
proc = sk . get ( " procedure " ) or sk . get ( " steps " ) or [ ]
steps_str = " → " . join ( proc [ : 5 ] )
lines . append ( f " ** { sk [ ' name ' ] } **: { sk . get ( ' description ' , ' ' ) } \n When: { sk . get ( ' when_to_use ' , ' ' ) } \n Steps: { steps_str } " )
return { " results " : " \n \n " . join ( lines ) }
return {
" error " : (
f " Unknown action: { action !r} . "
" Use one of: list, view, view_ref, add, edit, patch, publish, delete, search. "
) ,
" exit_code " : 1 ,
}
def _skill_dump ( sk ) - > Dict :
""" Translate a parsed Skill back into the kwargs `update_skill` expects. """
return {
" name " : sk . name ,
" description " : sk . description ,
" version " : sk . version ,
" category " : sk . category ,
" tags " : sk . tags ,
" platforms " : sk . platforms ,
" requires_toolsets " : sk . requires_toolsets ,
" fallback_for_toolsets " : sk . fallback_for_toolsets ,
" status " : sk . status ,
" confidence " : sk . confidence ,
" source " : sk . source ,
" teacher_model " : sk . teacher_model ,
" owner " : sk . owner ,
" when_to_use " : sk . when_to_use ,
" procedure " : sk . procedure ,
" pitfalls " : sk . pitfalls ,
" verification " : sk . verification ,
" body_extra " : sk . body_extra ,
}
# ---------------------------------------------------------------------------
# Task management tool
# ---------------------------------------------------------------------------
async def do_manage_tasks ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Handle manage_tasks tool calls: CRUD on scheduled tasks. """
import uuid as _uuid
from core . database import SessionLocal , ScheduledTask
from src . task_scheduler import compute_next_run
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = args . get ( " action " , " list " )
db = SessionLocal ( )
try :
if action == " list " :
q = db . query ( ScheduledTask )
if owner :
q = q . filter ( ScheduledTask . owner == owner )
tasks = q . order_by ( ScheduledTask . created_at . desc ( ) ) . all ( )
task_list = [ ]
for t in tasks :
task_list . append ( {
" id " : t . id , " name " : t . name , " status " : t . status ,
" task_type " : t . task_type or " llm " ,
" action " : t . action ,
" trigger_type " : t . trigger_type or " schedule " ,
" schedule " : t . schedule ,
" trigger_event " : t . trigger_event ,
" trigger_count " : t . trigger_count ,
" next_run " : t . next_run . isoformat ( ) + " Z " if t . next_run else None ,
" last_run " : t . last_run . isoformat ( ) + " Z " if t . last_run else None ,
" run_count " : t . run_count or 0 ,
} )
return { " response " : f " Found { len ( task_list ) } tasks " , " tasks " : task_list , " exit_code " : 0 }
elif action == " create " :
task_type = args . get ( " task_type " , " llm " )
trigger_type = args . get ( " trigger_type " , " schedule " )
if task_type in ( " llm " , " research " ) and not args . get ( " prompt " ) :
return { " error " : " Prompt is required for llm/research tasks " , " exit_code " : 1 }
if task_type == " action " and not args . get ( " action_name " ) :
return { " error " : " action_name is required for action tasks " , " exit_code " : 1 }
# Compute next_run for schedule triggers
next_run = None
if trigger_type == " schedule " :
schedule = args . get ( " schedule " , " daily " )
next_run = compute_next_run (
schedule , args . get ( " scheduled_time " , " 09:00 " ) ,
args . get ( " scheduled_day " ) ,
)
task_id = str ( _uuid . uuid4 ( ) )
2026-06-02 17:40:21 +01:00
# Guard each fallback with `or`: args.get("prompt", default) returns
# None when the key is present but null, and None[:50] raises.
name = args . get ( " name " ) or ( args . get ( " prompt " ) or args . get ( " action_name " ) or " Task " ) [ : 50 ]
2026-05-31 23:58:26 +09:00
task = ScheduledTask (
id = task_id ,
owner = owner ,
name = name ,
prompt = args . get ( " prompt " ) ,
task_type = task_type ,
action = args . get ( " action_name " ) ,
schedule = args . get ( " schedule " ) if trigger_type == " schedule " else None ,
scheduled_time = args . get ( " scheduled_time " , " 09:00 " ) if trigger_type == " schedule " else None ,
scheduled_day = args . get ( " scheduled_day " ) ,
trigger_type = trigger_type ,
trigger_event = args . get ( " trigger_event " ) ,
trigger_count = args . get ( " trigger_count " ) ,
trigger_counter = 0 ,
next_run = next_run ,
status = " active " ,
output_target = args . get ( " output_target " , " session " ) ,
)
db . add ( task )
db . commit ( )
return { " response " : f " Created task ' { name } ' (id: { task_id } ) " , " task_id " : task_id , " exit_code " : 0 }
elif action == " edit " :
task_id = args . get ( " task_id " )
if not task_id :
return { " error " : " task_id is required for edit " , " exit_code " : 1 }
task = db . query ( ScheduledTask ) . filter ( ScheduledTask . id == task_id ) . first ( )
if not task :
return { " error " : f " Task { task_id } not found " , " exit_code " : 1 }
if owner and task . owner and task . owner != owner :
return { " error " : " Access denied " , " exit_code " : 1 }
changed = [ ]
for field in ( " name " , " prompt " , " output_target " ) :
if args . get ( field ) is not None :
setattr ( task , field , args [ field ] )
changed . append ( field )
if args . get ( " task_type " ) is not None :
task . task_type = args [ " task_type " ]
changed . append ( " task_type " )
if args . get ( " action_name " ) is not None :
task . action = args [ " action_name " ]
changed . append ( " action " )
if args . get ( " trigger_type " ) is not None :
task . trigger_type = args [ " trigger_type " ]
changed . append ( " trigger_type " )
if args . get ( " trigger_event " ) is not None :
task . trigger_event = args [ " trigger_event " ]
changed . append ( " trigger_event " )
if args . get ( " trigger_count " ) is not None :
task . trigger_count = args [ " trigger_count " ]
changed . append ( " trigger_count " )
schedule_changed = False
for field in ( " schedule " , " scheduled_time " , " scheduled_day " ) :
if args . get ( field ) is not None :
setattr ( task , field , args [ field ] )
changed . append ( field )
schedule_changed = True
if schedule_changed and ( task . trigger_type or " schedule " ) == " schedule " :
task . next_run = compute_next_run (
task . schedule , task . scheduled_time , task . scheduled_day ,
)
db . commit ( )
return { " response " : f " Updated task ' { task . name } ' : { ' , ' . join ( changed ) } " , " exit_code " : 0 }
elif action == " delete " :
task_id = args . get ( " task_id " )
if not task_id :
return { " error " : " task_id is required for delete " , " exit_code " : 1 }
task = db . query ( ScheduledTask ) . filter ( ScheduledTask . id == task_id ) . first ( )
if not task :
return { " error " : f " Task { task_id } not found " , " exit_code " : 1 }
if owner and task . owner and task . owner != owner :
return { " error " : " Access denied " , " exit_code " : 1 }
name = task . name
db . delete ( task )
db . commit ( )
return { " response " : f " Deleted task ' { name } ' " , " exit_code " : 0 }
elif action in ( " pause " , " resume " ) :
task_id = args . get ( " task_id " )
if not task_id :
return { " error " : f " task_id is required for { action } " , " exit_code " : 1 }
task = db . query ( ScheduledTask ) . filter ( ScheduledTask . id == task_id ) . first ( )
if not task :
return { " error " : f " Task { task_id } not found " , " exit_code " : 1 }
if owner and task . owner and task . owner != owner :
return { " error " : " Access denied " , " exit_code " : 1 }
if action == " pause " :
task . status = " paused "
else :
task . status = " active "
if ( task . trigger_type or " schedule " ) == " schedule " :
task . next_run = compute_next_run (
task . schedule , task . scheduled_time , task . scheduled_day ,
)
db . commit ( )
return { " response " : f " Task ' { task . name } ' { action } d " , " exit_code " : 0 }
elif action == " run " :
task_id = args . get ( " task_id " )
if not task_id :
return { " error " : " task_id is required for run " , " exit_code " : 1 }
task = db . query ( ScheduledTask ) . filter ( ScheduledTask . id == task_id ) . first ( )
if not task :
return { " error " : f " Task { task_id } not found " , " exit_code " : 1 }
if owner and task . owner and task . owner != owner :
return { " error " : " Access denied " , " exit_code " : 1 }
from src . event_bus import get_task_scheduler
scheduler = get_task_scheduler ( )
if scheduler :
started = await scheduler . run_task_now ( task_id )
if started :
return { " response " : f " Task ' { task . name } ' triggered " , " exit_code " : 0 }
else :
return { " error " : " Task is already running " , " exit_code " : 1 }
return { " error " : " Task scheduler not available " , " exit_code " : 1 }
else :
return { " error " : f " Unknown action: { action } " , " exit_code " : 1 }
except Exception as e :
logger . error ( f " manage_tasks error: { e } " )
return { " error " : str ( e ) , " exit_code " : 1 }
finally :
db . close ( )
# ---------------------------------------------------------------------------
# Endpoint management tool
# ---------------------------------------------------------------------------
async def do_manage_endpoints ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Manage model endpoints: list, add, delete, enable, disable. """
from core . database import SessionLocal , ModelEndpoint
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = args . get ( " action " , " list " )
db = SessionLocal ( )
try :
if action == " list " :
eps = db . query ( ModelEndpoint ) . all ( )
items = [ { " id " : e . id , " name " : e . name , " base_url " : e . base_url ,
" is_enabled " : e . is_enabled } for e in eps ]
return { " response " : f " { len ( items ) } endpoints " , " endpoints " : items , " exit_code " : 0 }
elif action == " add " :
import uuid as _uuid
name = args . get ( " name " , " " )
base_url = args . get ( " base_url " , " " )
api_key = args . get ( " api_key " , " " )
if not base_url :
return { " error " : " base_url is required " , " exit_code " : 1 }
eid = str ( _uuid . uuid4 ( ) ) [ : 8 ]
from datetime import datetime
ep = ModelEndpoint ( id = eid , name = name or base_url , base_url = base_url ,
api_key = api_key , is_enabled = True ,
created_at = datetime . utcnow ( ) , updated_at = datetime . utcnow ( ) )
db . add ( ep )
db . commit ( )
return { " response " : f " Added endpoint ' { name or base_url } ' (id: { eid } ) " , " exit_code " : 0 }
elif action == " delete " :
eid = args . get ( " endpoint_id " , " " )
ep = db . query ( ModelEndpoint ) . filter ( ModelEndpoint . id == eid ) . first ( )
if not ep :
return { " error " : f " Endpoint { eid } not found " , " exit_code " : 1 }
name = ep . name
db . delete ( ep )
db . commit ( )
return { " response " : f " Deleted endpoint ' { name } ' " , " exit_code " : 0 }
elif action in ( " enable " , " disable " ) :
eid = args . get ( " endpoint_id " , " " )
ep = db . query ( ModelEndpoint ) . filter ( ModelEndpoint . id == eid ) . first ( )
if not ep :
return { " error " : f " Endpoint { eid } not found " , " exit_code " : 1 }
ep . is_enabled = ( action == " enable " )
db . commit ( )
return { " response " : f " Endpoint ' { ep . name } ' { action } d " , " exit_code " : 0 }
else :
return { " error " : f " Unknown action: { action } " , " exit_code " : 1 }
except Exception as e :
logger . error ( f " manage_endpoints error: { e } " )
return { " error " : str ( e ) , " exit_code " : 1 }
finally :
db . close ( )
# ---------------------------------------------------------------------------
# MCP server management tool
# ---------------------------------------------------------------------------
async def do_manage_mcp ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Manage MCP servers: list, add, delete, enable, disable, reconnect. """
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = args . get ( " action " , " list " )
if action == " list " :
mcp = get_mcp_manager ( )
if not mcp :
return { " response " : " No MCP manager available " , " servers " : [ ] , " exit_code " : 0 }
from core . database import SessionLocal , McpServer
db = SessionLocal ( )
try :
servers = db . query ( McpServer ) . all ( )
items = [ ]
for s in servers :
st = mcp . get_server_status ( s . id )
status = st . get ( " status " , " disconnected " )
tool_count = st . get ( " tool_count " , 0 )
items . append ( { " id " : s . id , " name " : s . name , " transport " : s . transport ,
" is_enabled " : s . is_enabled , " status " : status ,
" tool_count " : tool_count } )
return { " response " : f " { len ( items ) } MCP servers " , " servers " : items , " exit_code " : 0 }
finally :
db . close ( )
elif action == " add " :
from core . database import SessionLocal , McpServer
import uuid as _uuid
from datetime import datetime
name = args . get ( " name " , " " )
command = args . get ( " command " , " " )
cmd_args = args . get ( " args " , [ ] )
env = args . get ( " env " , { } )
if not name or not command :
return { " error " : " name and command are required " , " exit_code " : 1 }
sid = str ( _uuid . uuid4 ( ) ) [ : 8 ]
db = SessionLocal ( )
try :
srv = McpServer ( id = sid , name = name , transport = " stdio " , command = command ,
args = json . dumps ( cmd_args ) if isinstance ( cmd_args , list ) else cmd_args ,
env = json . dumps ( env ) if isinstance ( env , dict ) else env ,
is_enabled = True , created_at = datetime . utcnow ( ) , updated_at = datetime . utcnow ( ) )
db . add ( srv )
db . commit ( )
finally :
db . close ( )
# Try to connect
mcp = get_mcp_manager ( )
tool_count = 0
if mcp :
try :
await mcp . connect_server (
sid , name , " stdio " , command = command ,
args = cmd_args if isinstance ( cmd_args , list ) else json . loads ( cmd_args ) ,
env = env if isinstance ( env , dict ) else json . loads ( env ) ,
)
st = mcp . get_server_status ( sid )
tool_count = st . get ( " tool_count " , 0 )
except Exception as e :
logger . warning ( f " MCP connect failed for { name } : { e } " )
return { " response " : f " Added MCP server ' { name } ' ( { tool_count } tools) " , " exit_code " : 0 }
elif action == " delete " :
sid = args . get ( " server_id " , " " )
from core . database import SessionLocal , McpServer
db = SessionLocal ( )
try :
srv = db . query ( McpServer ) . filter ( McpServer . id == sid ) . first ( )
if not srv :
return { " error " : f " Server { sid } not found " , " exit_code " : 1 }
name = srv . name
mcp = get_mcp_manager ( )
if mcp :
try :
await mcp . disconnect_server ( sid )
except Exception :
pass
db . delete ( srv )
db . commit ( )
return { " response " : f " Deleted MCP server ' { name } ' " , " exit_code " : 0 }
finally :
db . close ( )
elif action == " reconnect " :
sid = args . get ( " server_id " , " " )
mcp = get_mcp_manager ( )
if not mcp :
return { " error " : " MCP manager not available " , " exit_code " : 1 }
try :
await mcp . disconnect_server ( sid )
from core . database import SessionLocal , McpServer
db2 = SessionLocal ( )
try :
srv = db2 . query ( McpServer ) . filter ( McpServer . id == sid ) . first ( )
if srv :
fix: MCP reconnect via tool passes only server_id to connect_server (#1385)
* fix: MCP reconnect via tool passes only server_id to connect_server
connect_server requires name, transport, command, args, env, and url
but the reconnect path in do_manage_mcp only passed the server_id,
causing a TypeError on every reconnect attempt. Mirror the pattern
used in mcp_routes.py reconnect_server.
* test: verify MCP reconnect passes full server config to connect_server
Mocks the MCP manager and DB to assert that do_manage_mcp reconnect
passes name, transport, command, args, env, and url — not just the
server_id.
2026-06-02 19:46:07 +01:00
_args = json . loads ( srv . args ) if srv . args else [ ]
_env = json . loads ( srv . env ) if srv . env else { }
await mcp . connect_server (
server_id = sid ,
name = srv . name ,
transport = srv . transport ,
command = srv . command ,
args = _args ,
env = _env ,
url = srv . url ,
)
2026-05-31 23:58:26 +09:00
st = mcp . get_server_status ( sid )
return { " response " : f " Reconnected ' { srv . name } ' ( { st . get ( ' tool_count ' , 0 ) } tools) " , " exit_code " : 0 }
return { " error " : f " Server { sid } not found " , " exit_code " : 1 }
finally :
db2 . close ( )
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
elif action in ( " enable " , " disable " ) :
sid = args . get ( " server_id " , " " )
from core . database import SessionLocal , McpServer
db = SessionLocal ( )
try :
srv = db . query ( McpServer ) . filter ( McpServer . id == sid ) . first ( )
if not srv :
return { " error " : f " Server { sid } not found " , " exit_code " : 1 }
srv . is_enabled = ( action == " enable " )
db . commit ( )
return { " response " : f " MCP server ' { srv . name } ' { action } d " , " exit_code " : 0 }
finally :
db . close ( )
elif action == " list_tools " :
mcp = get_mcp_manager ( )
if not mcp :
return { " response " : " No MCP manager " , " tools " : [ ] , " exit_code " : 0 }
tools = mcp . get_all_tools ( )
items = [ { " name " : t [ " name " ] , " server " : t [ " server_name " ] ,
" description " : t . get ( " description " , " " ) [ : 100 ] } for t in tools ]
return { " response " : f " { len ( items ) } MCP tools available " , " tools " : items , " exit_code " : 0 }
else :
return { " error " : f " Unknown action: { action } " , " exit_code " : 1 }
# ---------------------------------------------------------------------------
# Webhook management tool
# ---------------------------------------------------------------------------
async def do_manage_webhooks ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Manage webhooks: list, add, delete, enable, disable, test. """
from core . database import SessionLocal
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = args . get ( " action " , " list " )
db = SessionLocal ( )
try :
from core . database import Webhook
if action == " list " :
hooks = db . query ( Webhook ) . all ( )
items = [ { " id " : h . id , " name " : h . name , " url " : h . url ,
" events " : h . events , " is_active " : h . is_active } for h in hooks ]
return { " response " : f " { len ( items ) } webhooks " , " webhooks " : items , " exit_code " : 0 }
elif action == " add " :
import uuid as _uuid
from datetime import datetime
from src . webhook_manager import validate_events , validate_webhook_url
name = args . get ( " name " , " " )
url = args . get ( " url " , " " )
events = args . get ( " events " , " chat.completed " )
if not url :
return { " error " : " url is required " , " exit_code " : 1 }
try :
url = validate_webhook_url ( url )
events = validate_events ( events )
except ValueError as e :
return { " error " : str ( e ) , " exit_code " : 1 }
wid = str ( _uuid . uuid4 ( ) ) [ : 8 ]
hook = Webhook ( id = wid , name = name or url , url = url ,
events = events , is_active = True ,
created_at = datetime . utcnow ( ) , updated_at = datetime . utcnow ( ) )
db . add ( hook )
db . commit ( )
return { " response " : f " Added webhook ' { name or url } ' " , " exit_code " : 0 }
elif action == " delete " :
wid = args . get ( " webhook_id " , " " )
hook = db . query ( Webhook ) . filter ( Webhook . id == wid ) . first ( )
if not hook :
return { " error " : f " Webhook { wid } not found " , " exit_code " : 1 }
name = hook . name
db . delete ( hook )
db . commit ( )
return { " response " : f " Deleted webhook ' { name } ' " , " exit_code " : 0 }
elif action in ( " enable " , " disable " ) :
wid = args . get ( " webhook_id " , " " )
hook = db . query ( Webhook ) . filter ( Webhook . id == wid ) . first ( )
if not hook :
return { " error " : f " Webhook { wid } not found " , " exit_code " : 1 }
hook . is_active = ( action == " enable " )
db . commit ( )
return { " response " : f " Webhook ' { hook . name } ' { action } d " , " exit_code " : 0 }
else :
return { " error " : f " Unknown action: { action } " , " exit_code " : 1 }
except Exception as e :
logger . error ( f " manage_webhooks error: { e } " )
return { " error " : str ( e ) , " exit_code " : 1 }
finally :
db . close ( )
# ---------------------------------------------------------------------------
# API token management tool
# ---------------------------------------------------------------------------
async def do_manage_tokens ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Manage API tokens: list, create, delete. """
from core . database import SessionLocal , ApiToken
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = args . get ( " action " , " list " )
db = SessionLocal ( )
try :
if action == " list " :
tokens = db . query ( ApiToken ) . all ( )
items = [ { " id " : t . id , " name " : t . name , " token_prefix " : t . token_prefix + " ... " ,
" is_active " : t . is_active } for t in tokens ]
return { " response " : f " { len ( items ) } API tokens " , " tokens " : items , " exit_code " : 0 }
elif action == " create " :
import uuid as _uuid , secrets , bcrypt
from datetime import datetime
name = args . get ( " name " , " API Token " )
raw_token = secrets . token_urlsafe ( 32 )
token_hash = bcrypt . hashpw ( raw_token . encode ( ) , bcrypt . gensalt ( ) ) . decode ( )
tid = str ( _uuid . uuid4 ( ) ) [ : 8 ]
t = ApiToken ( id = tid , name = name , token_hash = token_hash ,
token_prefix = raw_token [ : 8 ] , is_active = True ,
created_at = datetime . utcnow ( ) , updated_at = datetime . utcnow ( ) )
db . add ( t )
db . commit ( )
return { " response " : f " Created token ' { name } ' " , " token " : raw_token , " exit_code " : 0 }
elif action == " delete " :
tid = args . get ( " token_id " , " " )
t = db . query ( ApiToken ) . filter ( ApiToken . id == tid ) . first ( )
if not t :
return { " error " : f " Token { tid } not found " , " exit_code " : 1 }
name = t . name
db . delete ( t )
db . commit ( )
return { " response " : f " Deleted token ' { name } ' " , " exit_code " : 0 }
else :
return { " error " : f " Unknown action: { action } " , " exit_code " : 1 }
except Exception as e :
logger . error ( f " manage_tokens error: { e } " )
return { " error " : str ( e ) , " exit_code " : 1 }
finally :
db . close ( )
# ---------------------------------------------------------------------------
# Settings/preferences management tool
# ---------------------------------------------------------------------------
async def do_manage_settings ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Manage user settings and preferences. """
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = args . get ( " action " , " list " )
from core . database import SessionLocal
db = SessionLocal ( )
try :
# set/get/list/delete operate on the REAL app settings (the same store
# the Settings panel writes), so changing a model / voice / search
# engine / reminder channel from chat actually takes effect.
from src . settings import load_settings , save_settings , DEFAULT_SETTINGS
# Secrets/credentials the agent must NOT write — kept read-only (masked)
# so API keys never flow through chat. User sets these in the panel.
_SECRET_KEYS = {
" brave_api_key " , " google_pse_key " , " google_pse_cx " ,
" tavily_api_key " , " serper_api_key " , " app_public_url " ,
}
def _is_secret ( k ) :
2026-06-02 17:53:47 +01:00
# `token` must be a suffix, not a substring: otherwise the int
# setting `agent_input_token_budget` (which even has a "token budget"
# alias to set it from chat) is wrongly classified as a credential.
return (
k in _SECRET_KEYS
or k . endswith ( " token " )
or any ( t in k for t in ( " api_key " , " _key " , " secret " , " password " ) )
)
2026-05-31 23:58:26 +09:00
# Friendly aliases → real keys, so natural phrasing resolves.
_ALIASES_SET = {
" voice " : " tts_voice " , " tts voice " : " tts_voice " , " tts " : " tts_enabled " ,
" text to speech " : " tts_enabled " , " tts provider " : " tts_provider " ,
" speech speed " : " tts_speed " , " voice speed " : " tts_speed " ,
" stt " : " stt_enabled " , " speech to text " : " stt_enabled " , " transcription " : " stt_enabled " ,
" search engine " : " search_provider " , " search provider " : " search_provider " ,
" search results " : " search_result_count " , " result count " : " search_result_count " ,
" default model " : " default_model " , " chat model " : " default_model " ,
" default endpoint " : " default_endpoint_id " ,
" task model " : " task_model " , " background model " : " task_model " ,
" teacher model " : " teacher_model " , " teacher " : " teacher_enabled " ,
" utility model " : " utility_model " , " research model " : " research_model " ,
" research max tokens " : " research_max_tokens " ,
" vision model " : " vision_model " , " vision " : " vision_enabled " ,
" image model " : " image_model " , " image quality " : " image_quality " ,
" image gen " : " image_gen_enabled " , " image generation " : " image_gen_enabled " ,
" reminder channel " : " reminder_channel " , " reminders " : " reminder_channel " ,
" ntfy topic " : " reminder_ntfy_topic " ,
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:47:57 -04:00
" webhook integration " : " reminder_webhook_integration_id " ,
" webhook template " : " reminder_webhook_payload_template " , " webhook payload " : " reminder_webhook_payload_template " ,
2026-05-31 23:58:26 +09:00
" agent tool calls " : " agent_max_tool_calls " , " max tool calls " : " agent_max_tool_calls " ,
" agent timeout " : " agent_stream_timeout_seconds " , " stream timeout " : " agent_stream_timeout_seconds " ,
2026-06-02 11:36:57 -05:00
" token budget " : " agent_input_token_budget " , " input budget " : " agent_input_token_budget " ,
" hard max " : " agent_input_token_hard_max " ,
" token budget cap " : " agent_input_token_hard_max " ,
" input budget cap " : " agent_input_token_hard_max " ,
2026-05-31 23:58:26 +09:00
}
def _resolve ( k ) :
k2 = ( k or " " ) . strip ( ) . lower ( )
if k2 in DEFAULT_SETTINGS :
return k2
return _ALIASES_SET . get ( k2 , ( k or " " ) . strip ( ) )
_ENUMS = {
" image_quality " : [ " low " , " medium " , " high " ] ,
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:47:57 -04:00
" reminder_channel " : [ " browser " , " email " , " ntfy " , " webhook " ] ,
2026-05-31 23:58:26 +09:00
}
def _coerce ( value , default ) :
if isinstance ( default , bool ) :
return value if isinstance ( value , bool ) else str ( value ) . strip ( ) . lower ( ) in ( " true " , " on " , " yes " , " 1 " , " enable " , " enabled " )
if isinstance ( default , int ) :
return int ( value )
return value
def _model_slug ( value : str ) - > str :
import re as _re
return _re . sub ( r " [^a-z0-9]+ " , " " , ( value or " " ) . lower ( ) )
def _endpoint_model_from_cache ( model_query : str ) :
""" Resolve friendly model text to an enabled endpoint + real model id.
The Settings UI stores both ` < prefix > _endpoint_id ` and
` < prefix > _model ` ; writing only the model leaves the runtime on the
old endpoint . Prefer cached model lists so this stays fast / offline .
"""
import json as _json
import re as _re
from core . database import ModelEndpoint
wanted = ( model_query or " " ) . strip ( )
wanted_slug = _model_slug ( wanted )
wanted_tokens = [ _model_slug ( t ) for t in _re . findall ( r " [A-Za-z0-9]+ " , wanted ) ]
wanted_tokens = [ t for t in wanted_tokens if t ]
if not wanted_slug :
return None
best = None
for ep in db . query ( ModelEndpoint ) . filter ( ModelEndpoint . is_enabled == True ) . all ( ) :
raw_models = [ ]
try :
raw_models = _json . loads ( ep . cached_models or " [] " ) or [ ]
except Exception :
raw_models = [ ]
# If cache is empty, still allow matching against endpoint name
# for callers using model@endpoint elsewhere later.
for mid in raw_models :
mid = str ( mid )
mid_slug = _model_slug ( mid )
if not mid_slug :
continue
exact = mid . lower ( ) == wanted . lower ( )
compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug
token_match = bool ( wanted_tokens ) and all ( tok in mid_slug for tok in wanted_tokens )
if exact or compact_match or token_match :
score = 3 if exact else ( 2 if compact_match else 1 )
if not best or score > best [ 0 ] :
best = ( score , ep . id , mid )
if best :
return { " endpoint_id " : best [ 1 ] , " model " : best [ 2 ] }
return None
def _mask ( k , v ) :
return " ••••• (set in panel) " if _is_secret ( k ) and v else v
if action == " list " :
s = load_settings ( )
shown = { k : _mask ( k , v ) for k , v in s . items ( ) if k in DEFAULT_SETTINGS and not isinstance ( v , dict ) }
return { " response " : f " { len ( shown ) } settings (use get/set with a key) " , " settings " : shown , " exit_code " : 0 }
elif action == " get " :
key = _resolve ( args . get ( " key " , " " ) )
if not key :
return { " error " : " key is required " , " exit_code " : 1 }
if key not in DEFAULT_SETTINGS :
return { " error " : f " Unknown setting ' { args . get ( ' key ' ) } ' . Use action= ' list ' to see them. " , " exit_code " : 1 }
val = load_settings ( ) . get ( key , DEFAULT_SETTINGS . get ( key ) )
return { " response " : f " { key } = { _mask ( key , val ) } " , " value " : _mask ( key , val ) , " exit_code " : 0 }
elif action == " set " :
raw = args . get ( " key " , " " )
value = args . get ( " value " )
if not raw :
return { " error " : " key is required " , " exit_code " : 1 }
key = _resolve ( raw )
if key not in DEFAULT_SETTINGS :
return { " error " : f " Unknown setting ' { raw } ' . Use action= ' list ' to see available settings. " , " exit_code " : 1 }
if _is_secret ( key ) :
return { " response " : f " ' { key } ' is a credential/secret — for security I can ' t set it from chat. Open Settings and set it there. " , " exit_code " : 0 }
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
# have no safe scalar coercion — _coerce would pass a bare string
# straight through and clobber the structure. Refuse them here; they're
# edited in their dedicated panels. (reset/delete still restore the
# default structure, which is safe.)
if isinstance ( DEFAULT_SETTINGS [ key ] , ( dict , list ) ) :
return { " response " : f " ' { key } ' is a structured setting — edit it in its panel, not from chat. (You can reset it to default here.) " , " exit_code " : 0 }
try :
value = _coerce ( value , DEFAULT_SETTINGS [ key ] )
except ( ValueError , TypeError ) :
return { " error " : f " ' { value } ' isn ' t a valid value for { key } (expected { type ( DEFAULT_SETTINGS [ key ] ) . __name__ } ). " , " exit_code " : 1 }
if key in _ENUMS and str ( value ) . lower ( ) not in _ENUMS [ key ] :
return { " error " : f " { key } must be one of: { ' , ' . join ( _ENUMS [ key ] ) } . " , " exit_code " : 1 }
s = load_settings ( )
s [ key ] = value
if key in { " default_model " , " research_model " , " utility_model " , " task_model " , " vision_model " , " image_model " } :
resolved = _endpoint_model_from_cache ( str ( value ) )
if resolved :
prefix = key [ : - 6 ]
s [ f " { prefix } _endpoint_id " ] = resolved [ " endpoint_id " ]
s [ key ] = resolved [ " model " ]
value = resolved [ " model " ]
save_settings ( s )
if key . endswith ( " _model " ) and s . get ( f " { key [ : - 6 ] } _endpoint_id " ) :
return { " response " : f " Set { key } = { value } (endpoint { s . get ( f ' { key [ : - 6 ] } _endpoint_id ' ) } ). " , " exit_code " : 0 }
return { " response " : f " Set { key } = { value } . " , " exit_code " : 0 }
elif action == " delete " or action == " reset " :
key = _resolve ( args . get ( " key " , " " ) )
if key not in DEFAULT_SETTINGS :
return { " error " : f " Unknown setting ' { args . get ( ' key ' ) } ' . " , " exit_code " : 1 }
if _is_secret ( key ) :
return { " response " : f " ' { key } ' is a credential — reset it in the panel. " , " exit_code " : 0 }
s = load_settings ( )
s [ key ] = DEFAULT_SETTINGS [ key ]
save_settings ( s )
return { " response " : f " Reset { key } to default ( { DEFAULT_SETTINGS [ key ] } ). " , " exit_code " : 0 }
elif action in ( " disable_tool " , " enable_tool " , " list_tools " ) :
# Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> "builtin_browser",
# "documents" -> the document tool set, "memory" ->
# manage_memory, etc.
from src . settings import get_setting , save_settings , load_settings
_ALIASES = {
" shell " : [ " bash " ] ,
" terminal " : [ " bash " ] ,
" search " : [ " web_search " ] ,
" web " : [ " web_search " ] ,
" browser " : [ " builtin_browser " ] ,
" documents " : [ " create_document " , " edit_document " , " update_document " , " suggest_document " ] ,
" doc " : [ " create_document " , " edit_document " , " update_document " , " suggest_document " ] ,
" memory " : [ " manage_memory " ] ,
" skills " : [ " manage_skills " ] ,
" images " : [ " generate_image " ] ,
" image " : [ " generate_image " ] ,
" tasks " : [ " manage_tasks " ] ,
" notes " : [ " manage_notes " ] ,
" calendar " : [ " manage_calendar " ] ,
" email " : [ " mcp__email__list_emails " , " mcp__email__read_email " , " mcp__email__send_email " ] ,
" research " : [ " web_search " ] , # research is a per-request flag, not a tool — closest analog
}
if action == " list_tools " :
current = get_setting ( " disabled_tools " , [ ] ) or [ ]
return {
" response " : (
f " Currently disabled: { ' , ' . join ( current ) if current else ' (none) ' } . \n "
" Common toggles: shell (bash), search (web_search), browser, documents, "
" memory, skills, images, tasks, notes, calendar, email. "
) ,
" disabled " : list ( current ) ,
" exit_code " : 0 ,
}
tool_name = ( args . get ( " tool " ) or args . get ( " name " ) or " " ) . strip ( ) . lower ( )
if not tool_name :
return { " error " : " tool name required (e.g. ' shell ' , ' search ' , ' bash ' ) " , " exit_code " : 1 }
targets = _ALIASES . get ( tool_name , [ tool_name ] )
settings = load_settings ( )
current = list ( settings . get ( " disabled_tools " ) or [ ] )
before = set ( current )
if action == " disable_tool " :
for t in targets :
if t not in current :
current . append ( t )
else : # enable_tool
current = [ t for t in current if t not in targets ]
after = set ( current )
settings [ " disabled_tools " ] = current
save_settings ( settings )
verb = " Disabled " if action == " disable_tool " else " Enabled "
changed = sorted ( after . symmetric_difference ( before ) )
return {
" response " : (
f " { verb } { tool_name } ( { ' , ' . join ( targets ) } ). "
f " Now disabled: { ' , ' . join ( current ) if current else ' (none) ' } . "
) ,
" changed " : changed ,
" disabled " : list ( current ) ,
" exit_code " : 0 ,
}
else :
return { " error " : f " Unknown action: { action } " , " exit_code " : 1 }
except Exception as e :
logger . error ( f " manage_settings error: { e } " )
return { " error " : str ( e ) , " exit_code " : 1 }
finally :
db . close ( )
# ---------------------------------------------------------------------------
# API call tool
# ---------------------------------------------------------------------------
async def do_api_call ( content : str ) - > Dict :
""" Execute an API call to a registered integration. """
from src . integrations import execute_api_call , load_integrations
try :
args = json . loads ( content )
except json . JSONDecodeError :
# Try line-based format: integration\nmethod path\nbody
lines = content . strip ( ) . split ( " \n " )
args = { " integration " : lines [ 0 ] . strip ( ) if lines else " " }
if len ( lines ) > 1 :
parts = lines [ 1 ] . strip ( ) . split ( " " , 1 )
args [ " method " ] = parts [ 0 ] if parts else " GET "
args [ " path " ] = parts [ 1 ] if len ( parts ) > 1 else " / "
if len ( lines ) > 2 :
try :
args [ " body " ] = json . loads ( " \n " . join ( lines [ 2 : ] ) )
except json . JSONDecodeError :
pass
integration_name = args . get ( " integration " , " " )
integrations = load_integrations ( )
intg = next ( ( i for i in integrations if i [ " id " ] == integration_name
or i [ " name " ] . lower ( ) == integration_name . lower ( ) ) , None )
if not intg :
available = " , " . join ( i [ " name " ] for i in integrations if i . get ( " enabled " , True ) )
return { " error " : f " No integration matching ' { integration_name } ' . Available: { available or ' none configured ' } " , " exit_code " : 1 }
return await execute_api_call (
intg [ " id " ] ,
args . get ( " method " , " GET " ) ,
args . get ( " path " , " / " ) ,
params = args . get ( " params " ) ,
body = args . get ( " body " ) ,
extra_headers = args . get ( " headers " ) ,
)
# ---------------------------------------------------------------------------
# Notes / checklists management tool
# ---------------------------------------------------------------------------
async def do_manage_notes ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Handle manage_notes tool calls: CRUD on notes and checklists. """
import uuid as _uuid
from core . database import SessionLocal , Note
from sqlalchemy . orm . attributes import flag_modified
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
# Action aliases — match what models actually emit. `create` is the most
# common alternative to `add`. Hyphenated forms also accepted.
action = ( args . get ( " action " ) or " " ) . replace ( " - " , " _ " ) . strip ( ) . lower ( )
_NOTE_ACTION_ALIASES = {
" create " : " add " ,
" new " : " add " ,
" save " : " add " ,
" remind " : " add " ,
" remove " : " delete " ,
" remove_item " : " toggle_item " ,
}
action = _NOTE_ACTION_ALIASES . get ( action , action )
db = SessionLocal ( )
def _norm_note_title ( value : str ) - > str :
text = ( value or " " ) . strip ( ) . lower ( )
text = re . sub ( r " ^ \ s*reminder \ s*: \ s* " , " " , text )
return re . sub ( r " \ s+ " , " " , text )
2026-06-07 12:50:10 +02:00
def _note_visible_to_owner ( note , owner_value : Optional [ str ] ) - > bool :
# Empty owner_value is single-user / auth-disabled mode. A real
# authenticated owner must match exactly; null/empty legacy rows are not
# shared between accounts.
if not owner_value :
return True
return getattr ( note , " owner " , None ) == owner_value
def _note_by_prefix ( note_id : str ) :
if not note_id :
return None
q = db . query ( Note ) . filter ( Note . id . startswith ( note_id ) )
if owner :
q = q . filter ( Note . owner == owner )
return q . first ( )
2026-05-31 23:58:26 +09:00
try :
if action == " list " :
q = db . query ( Note )
if owner is not None :
q = q . filter ( Note . owner == owner )
if args . get ( " label " ) :
q = q . filter ( Note . label == args [ " label " ] )
show_archived = args . get ( " archived " , False )
q = q . filter ( Note . archived == show_archived )
notes = q . order_by ( Note . pinned . desc ( ) , Note . updated_at . desc ( ) ) . all ( )
if not notes :
return { " response " : " No notes found. " , " exit_code " : 0 }
lines = [ ]
for n in notes :
pin = " [PINNED] " if n . pinned else " "
typ = " [checklist] " if n . note_type == " checklist " else " "
lbl = f " # { n . label } " if n . label else " "
title = n . title or " (untitled) "
lines . append ( f " - [ { n . id [ : 8 ] } ] ** { title } ** { pin } { typ } { lbl } " )
if n . note_type == " checklist " and n . items :
try :
items = json . loads ( n . items )
for i , item in enumerate ( items ) :
mark = " x " if item . get ( " done " ) else " "
lines . append ( f " [ { mark } ] { i } : { item . get ( ' text ' , ' ' ) } " )
except ( json . JSONDecodeError , TypeError ) :
pass
elif n . content :
snippet = n . content [ : 80 ] . replace ( " \n " , " " )
lines . append ( f " { snippet } " )
return { " results " : " \n " . join ( lines ) }
elif action == " add " :
# Accept the various field names models emit: `text` is the most
# common stand-in for "title or body content" when the model
# treats the note as a single string. If text was supplied and
# neither title nor content, use it as the title.
title = ( args . get ( " title " ) or " " ) . strip ( )
content_raw = args . get ( " content " )
text_raw = args . get ( " text " ) or args . get ( " body " )
if not title and not content_raw and text_raw :
title = text_raw . strip ( )
elif not content_raw and text_raw :
content_raw = text_raw
Expose manage_notes via native function calling (#759)
The agent's RAG tool selector retrieves manage_notes as relevant for
note / todo / reminder requests, but two gaps stopped it from actually
firing on local llama.cpp / vLLM endpoints:
1. FUNCTION_TOOL_SCHEMAS had no entry for manage_notes. Even when the
tool was marked relevant, no JSON schema was sent on the function
tools list, so native-function-calling models had nothing to call.
In practice the model would describe creating the note in prose
while the actual note stayed blank — the symptom reported in #713
("checklist hallucinated as blank").
2. _API_HOSTS only listed hosted providers (OpenAI, Anthropic, etc.).
For local endpoints like http://localhost:8080 or
http://host.docker.internal:8000, _is_api_model fell back to
keyword-sniffing the model name, so any model whose slug didn't
happen to match the keyword list silently lost native tool
schemas entirely.
Fixes:
- src/tool_schemas.py: add a manage_notes function schema covering
list/add/update/delete/toggle_item with the full Keep-style field
set. note_type is exposed as an enum ("note" | "checklist") so the
model picks the mode explicitly instead of inferring it from
content shape. Items are named checklist_items in the schema —
consistent with the description's wording and avoiding the
Python-built-in name clash that #713 calls out.
- src/tool_implementations.py: do_manage_notes accepts both
checklist_items (new, schema-exposed) and items (legacy /
internal). Direct API callers and existing code paths keep
working unchanged; native function calls following the new
schema route through the same path.
- src/agent_loop.py: add localhost, 127.0.0.1, and
host.docker.internal to _API_HOSTS so the function-tool path is
not gated behind model-name guessing for local servers.
Closes #174.
Closes #713.
2026-06-02 08:03:32 +05:30
# Accept both `items` (legacy/internal field) and `checklist_items`
# (the schema-exposed name used by native function calls). Models
# following the schema emit `checklist_items`; older code paths
# and direct API callers still use `items`.
items_raw = args . get ( " checklist_items " )
if items_raw is None :
items_raw = args . get ( " items " )
2026-05-31 23:58:26 +09:00
items_json = json . dumps ( items_raw ) if items_raw is not None else None
note_type = args . get ( " note_type " , " checklist " if items_raw else " note " )
# Accept natural-language due_date ("tomorrow at 1pm") in
# addition to ISO. Use the user-tz-aware parser so the LLM's
# naive times ("today at 9pm") are anchored to the USER's clock,
# not the server's. Returns ISO with explicit offset so frontend
# `new Date()` resolves the right absolute moment regardless of
# where the user is.
due_raw = args . get ( " due_date " )
due_iso = None
if due_raw :
try :
from routes . calendar_routes import parse_due_for_user as _pdt_user
due_iso = _pdt_user ( due_raw )
except Exception :
due_iso = due_raw # fall through; trust the model
if due_iso and title :
# Calendar event reminders are represented as Notes. If the
# model creates a calendar event with reminder_minutes and then
# also creates a separate note reminder for the same title/time,
# keep the existing note so the user gets only one dispatch.
existing_q = db . query ( Note ) . filter (
Note . archived == False , # noqa: E712
Note . due_date == due_iso ,
)
if owner is not None :
existing_q = existing_q . filter ( Note . owner == owner )
target_title = _norm_note_title ( title )
for existing in existing_q . limit ( 25 ) . all ( ) :
if _norm_note_title ( existing . title or " " ) == target_title :
return {
" response " : f " Reminder already exists: \" { existing . title or title } \" (id: { existing . id [ : 8 ] } ) " ,
" note_id " : existing . id ,
" duplicate " : True ,
" exit_code " : 0 ,
}
note = Note (
id = str ( _uuid . uuid4 ( ) ) ,
owner = owner ,
title = title ,
content = content_raw ,
items = items_json ,
note_type = note_type ,
color = args . get ( " color " ) ,
label = args . get ( " label " ) ,
pinned = args . get ( " pinned " , False ) ,
due_date = due_iso ,
source = " agent " ,
session_id = args . get ( " session_id " ) ,
)
db . add ( note )
db . commit ( )
2026-06-05 14:41:48 +09:00
# Return note_id so the chat-side renderer can build a real
# "View note" button that opens the notes modal at this id.
# Previously the create response only included a prose
# confirmation; the model would type "View note" as a markdown
# link with no target, leaving the user with a click that
# did nothing and uncertainty about whether the note was made.
return {
" response " : f " Note created: \" { title or ' (untitled) ' } \" (id: { note . id [ : 8 ] } ) " ,
" note_id " : note . id ,
" note_title " : title or " " ,
" open_url " : f " /#open=notes¬e= { note . id } " ,
" exit_code " : 0 ,
}
2026-05-31 23:58:26 +09:00
elif action == " update " :
note_id = args . get ( " id " , " " )
2026-06-07 12:50:10 +02:00
note = _note_by_prefix ( note_id )
2026-05-31 23:58:26 +09:00
if not note :
return { " error " : f " Note ' { note_id } ' not found " , " exit_code " : 1 }
2026-06-07 12:50:10 +02:00
if not _note_visible_to_owner ( note , owner ) :
2026-05-31 23:58:26 +09:00
return { " error " : " Note not found " , " exit_code " : 1 }
2026-06-02 13:51:16 +02:00
for field in ( " title " , " content " , " note_type " , " color " , " label " ) :
2026-05-31 23:58:26 +09:00
if field in args and args [ field ] is not None :
setattr ( note , field , args [ field ] )
2026-06-02 13:51:16 +02:00
# Parse due_date the same way the `add` action does. The schema
# advertises natural language ("tomorrow at 9am"), and naive ISO
# strings need the user's tz offset attached so the frontend's
# `new Date()` resolves the right absolute moment. Storing the raw
# value here left updated reminders as unparseable literals that
# never fired.
if args . get ( " due_date " ) is not None :
due_raw = args [ " due_date " ]
try :
from routes . calendar_routes import parse_due_for_user as _pdt_user
note . due_date = _pdt_user ( due_raw )
except Exception :
note . due_date = due_raw # fall through; trust the model
Expose manage_notes via native function calling (#759)
The agent's RAG tool selector retrieves manage_notes as relevant for
note / todo / reminder requests, but two gaps stopped it from actually
firing on local llama.cpp / vLLM endpoints:
1. FUNCTION_TOOL_SCHEMAS had no entry for manage_notes. Even when the
tool was marked relevant, no JSON schema was sent on the function
tools list, so native-function-calling models had nothing to call.
In practice the model would describe creating the note in prose
while the actual note stayed blank — the symptom reported in #713
("checklist hallucinated as blank").
2. _API_HOSTS only listed hosted providers (OpenAI, Anthropic, etc.).
For local endpoints like http://localhost:8080 or
http://host.docker.internal:8000, _is_api_model fell back to
keyword-sniffing the model name, so any model whose slug didn't
happen to match the keyword list silently lost native tool
schemas entirely.
Fixes:
- src/tool_schemas.py: add a manage_notes function schema covering
list/add/update/delete/toggle_item with the full Keep-style field
set. note_type is exposed as an enum ("note" | "checklist") so the
model picks the mode explicitly instead of inferring it from
content shape. Items are named checklist_items in the schema —
consistent with the description's wording and avoiding the
Python-built-in name clash that #713 calls out.
- src/tool_implementations.py: do_manage_notes accepts both
checklist_items (new, schema-exposed) and items (legacy /
internal). Direct API callers and existing code paths keep
working unchanged; native function calls following the new
schema route through the same path.
- src/agent_loop.py: add localhost, 127.0.0.1, and
host.docker.internal to _API_HOSTS so the function-tool path is
not gated behind model-name guessing for local servers.
Closes #174.
Closes #713.
2026-06-02 08:03:32 +05:30
new_items = args . get ( " checklist_items " )
if new_items is None :
new_items = args . get ( " items " )
if new_items is not None :
note . items = json . dumps ( new_items )
2026-05-31 23:58:26 +09:00
flag_modified ( note , " items " )
if " pinned " in args :
note . pinned = args [ " pinned " ]
if " archived " in args :
note . archived = args [ " archived " ]
db . commit ( )
return { " response " : f " Note updated: \" { note . title or ' (untitled) ' } \" " , " exit_code " : 0 }
elif action == " delete " :
note_id = args . get ( " id " , " " )
2026-06-07 12:50:10 +02:00
note = _note_by_prefix ( note_id )
2026-05-31 23:58:26 +09:00
if not note :
return { " error " : f " Note ' { note_id } ' not found " , " exit_code " : 1 }
2026-06-07 12:50:10 +02:00
if not _note_visible_to_owner ( note , owner ) :
2026-05-31 23:58:26 +09:00
return { " error " : " Note not found " , " exit_code " : 1 }
title = note . title
db . delete ( note )
db . commit ( )
return { " response " : f " Deleted note: \" { title or ' (untitled) ' } \" " , " exit_code " : 0 }
elif action == " toggle_item " :
note_id = args . get ( " id " , " " )
index = args . get ( " index " , 0 )
2026-06-07 12:50:10 +02:00
note = _note_by_prefix ( note_id )
2026-05-31 23:58:26 +09:00
if not note :
return { " error " : f " Note ' { note_id } ' not found " , " exit_code " : 1 }
2026-06-07 12:50:10 +02:00
if not _note_visible_to_owner ( note , owner ) :
2026-05-31 23:58:26 +09:00
return { " error " : " Note not found " , " exit_code " : 1 }
if not note . items :
return { " error " : " Note has no checklist items " , " exit_code " : 1 }
items = json . loads ( note . items )
if index < 0 or index > = len ( items ) :
return { " error " : f " Item index { index } out of range (0- { len ( items ) - 1 } ) " , " exit_code " : 1 }
items [ index ] [ " done " ] = not items [ index ] . get ( " done " , False )
note . items = json . dumps ( items )
flag_modified ( note , " items " )
db . commit ( )
mark = " done " if items [ index ] [ " done " ] else " undone "
return { " response " : f " Item ' { items [ index ] . get ( ' text ' , ' ' ) } ' marked { mark } " , " exit_code " : 0 }
else :
return { " error " : f " Unknown action: { action } . Use list/add/update/delete/toggle_item " , " exit_code " : 1 }
except Exception as e :
logger . error ( f " manage_notes error: { e } " )
return { " error " : str ( e ) , " exit_code " : 1 }
finally :
db . close ( )
# ---------------------------------------------------------------------------
# Calendar tool — CalDAV-backed event CRUD
# ---------------------------------------------------------------------------
async def do_manage_calendar ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite). """
from datetime import datetime , timedelta
from core . database import SessionLocal , CalendarCal , CalendarEvent , Note
Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)
Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).
Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests
- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
distinguish occurrences from the same series. Non-recurring events
pass through with is_recurrence=false and series_uid=uid.
- Add _resolve_base_uid() to extract the base series UID from compound
UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
manage_calendar tool so edits/deletes always target the base row.
- Update manage_calendar tool to import and use _resolve_base_uid.
- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
invalidate localStorage cache after success so stale sibling
occurrences aren't shown.
- Add python-dateutil to requirements.txt as an explicit dependency.
- Add 14 regression tests in tests/test_calendar_recurrence.py
covering _resolve_base_uid edge cases, _expand_rrule with
yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
metadata inheritance.
- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.
* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings
Fix three edge cases in _expand_rrule:
1. Malformed-RRULE fallback now checks window overlap. list_events
fetches recurring rows with only dtstart < end_dt, so a broken
old recurring event could appear in unrelated future windows.
Now fallback returns [] unless the base event's dtstart/dtend
actually intersect [start, end).
2. Exclusive end boundary. rule.between(start, end, inc=True) was
inclusive on end, but the route contract and non-recurring SQL
filter both use [start, end). Added occ_start >= end guard.
3. Multi-day crossings. A recurring occurrence that starts before
the window but ends inside it was missed (only occ_start was
checked). Now expands from start - duration and filters by
occ_start < end AND occ_end > start, matching non-recurring
overlap behavior.
Tests: +4 tests for these cases (18 total)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:42:44 +02:00
from routes . calendar_routes import _ensure_default_calendar , _parse_dt , _parse_dt_pair , parse_due_for_user , _resolve_base_uid
2026-05-31 23:58:26 +09:00
import uuid as _uuid
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
# Normalize action — some models emit hyphens ("list-calendars") instead
# of underscores. Treat them as equivalent so we don't bounce a
# cosmetic typo back to the model and waste a round-trip. Also accept
# short forms (`create`, `update`, `delete`) as aliases for the
# full `<verb>_event` names — models keep emitting the short forms.
action = ( args . get ( " action " ) or " list_events " ) . replace ( " - " , " _ " ) . strip ( ) . lower ( )
_ACTION_ALIASES = {
" create " : " create_event " ,
" update " : " update_event " ,
" delete " : " delete_event " ,
" list " : " list_events " ,
}
action = _ACTION_ALIASES . get ( action , action )
db = SessionLocal ( )
def _calendar_query ( ) :
q = db . query ( CalendarCal )
if owner is not None :
q = q . filter ( CalendarCal . owner == owner )
return q
def _event_query ( ) :
q = db . query ( CalendarEvent ) . join ( CalendarCal )
if owner is not None :
q = q . filter ( CalendarCal . owner == owner )
return q
def _reminder_minutes ( raw_args ) - > Optional [ int ] :
raw = (
raw_args . get ( " reminder_minutes " )
or raw_args . get ( " remind_before_minutes " )
or raw_args . get ( " alarm_minutes " )
or raw_args . get ( " reminder " )
or raw_args . get ( " alarm " )
)
if raw in ( None , " " ) :
desc = str ( raw_args . get ( " description " ) or " " )
if re . search ( r " \ b(remind|reminder|alarm) \ b " , desc , re . I ) :
raw = desc
if raw in ( None , " " , False ) :
return None
if raw is True :
return 10
if isinstance ( raw , ( int , float ) ) :
return max ( 0 , int ( raw ) )
text = str ( raw ) . strip ( ) . lower ( )
if text in { " none " , " no " , " off " , " false " } :
return None
m = re . search ( r " ( \ d+) \ s*(?:m|min|minute|minutes) \ b " , text )
if m :
return max ( 0 , int ( m . group ( 1 ) ) )
m = re . search ( r " ( \ d+) \ s*(?:h|hr|hour|hours) \ b " , text )
if m :
return max ( 0 , int ( m . group ( 1 ) ) * 60 )
if text . isdigit ( ) :
return max ( 0 , int ( text ) )
return None
def _event_description ( raw_args , minutes_before : Optional [ int ] ) - > str :
desc = str ( raw_args . get ( " description " , " " ) or " " )
if minutes_before is None :
return desc
reminder_only = re . compile (
r " ^ \ s*(?:remind(?:er)?|alarm) \ s*:? \ s* \ d+ \ s* "
r " (?:m|min|minute|minutes|h|hr|hour|hours) \ b.*$ " ,
re . I ,
)
return " " if reminder_only . match ( desc ) else desc
def _parse_event_dt ( raw : str ) - > tuple [ datetime , bool ] :
""" Parse agent event datetimes in the user ' s timezone when available. """
return _parse_dt_pair ( parse_due_for_user ( raw ) )
2026-06-06 05:47:18 -04:00
def _first_nonempty_arg ( * names : str ) :
for name in names :
value = args . get ( name )
if value not in ( None , " " ) :
return value
return None
2026-05-31 23:58:26 +09:00
def _create_calendar_reminder ( summary : str , location : str , dtstart : datetime ,
all_day : bool , minutes_before : int ,
is_utc : bool = False ) - > tuple [ Optional [ str ] , Optional [ str ] ] :
remind_at = dtstart - timedelta ( minutes = minutes_before )
now = datetime . utcnow ( ) if is_utc else datetime . now ( )
if dtstart < = now :
return None , " event already passed "
if remind_at < = now :
# If the requested "before" time already passed but the event is
# still upcoming, create an immediate Note reminder instead of
# silently dropping it.
remind_at = now
start_fmt = dtstart . strftime ( " %a % b %d " ) if all_day else dtstart . strftime ( " %a % b %d % H: % M " )
loc = f " @ { location } " if location else " "
text = f " { summary } { loc } — { start_fmt } "
due_date = remind_at . isoformat ( ) + ( " Z " if is_utc else " " )
expected_title = f " Reminder: { summary } "
existing_q = db . query ( Note ) . filter (
Note . archived == False , # noqa: E712
Note . due_date == due_date ,
)
if owner is not None :
existing_q = existing_q . filter ( Note . owner == owner )
target_title = re . sub ( r " ^ \ s*reminder \ s*: \ s* " , " " , expected_title . strip ( ) . lower ( ) )
for existing in existing_q . limit ( 25 ) . all ( ) :
existing_title = re . sub ( r " ^ \ s*reminder \ s*: \ s* " , " " , ( existing . title or " " ) . strip ( ) . lower ( ) )
if existing_title == target_title :
return existing . id , " duplicate reminder already exists "
note = Note (
id = str ( _uuid . uuid4 ( ) ) ,
owner = owner ,
title = expected_title ,
items = json . dumps ( [ { " text " : text , " done " : False , " checked " : False } ] ) ,
note_type = " todo " ,
label = " calendar " ,
due_date = due_date ,
source = " calendar " ,
)
db . add ( note )
return note . id , None
try :
if action == " list_calendars " :
_ensure_default_calendar ( db , owner )
cals = _calendar_query ( ) . all ( )
result = [ { " name " : c . name , " href " : c . id } for c in cals ]
if result :
lines = [ f " Found { len ( result ) } calendar(s): " ]
for c in result :
lines . append ( f " - { c [ ' name ' ] } ( { c [ ' href ' ] [ : 8 ] } ) " )
response_text = " \n " . join ( lines )
else :
response_text = " No calendars found. "
return { " response " : response_text , " calendars " : result , " exit_code " : 0 }
elif action == " list_events " :
try :
2026-06-06 05:47:18 -04:00
start_raw = _first_nonempty_arg (
" start " , " start_date " , " range_start " , " from " , " dtstart " , " since "
)
end_raw = _first_nonempty_arg (
" end " , " end_date " , " range_end " , " to " , " dtend " , " until "
)
if start_raw :
start_dt = _parse_dt ( start_raw )
2026-05-31 23:58:26 +09:00
else :
start_dt = datetime . utcnow ( ) . replace ( hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
2026-06-06 05:47:18 -04:00
if end_raw :
end_dt = _parse_dt ( end_raw )
2026-05-31 23:58:26 +09:00
else :
end_dt = start_dt + timedelta ( days = 14 )
except ValueError as e :
return { " error " : f " Invalid date format: { e } " , " exit_code " : 1 }
q = _event_query ( ) . filter (
CalendarEvent . dtstart < end_dt ,
CalendarEvent . dtend > start_dt ,
CalendarEvent . status != " cancelled " ,
)
calendar_filter = args . get ( " calendar " )
if calendar_filter :
q = q . filter (
( CalendarEvent . calendar_id == calendar_filter ) |
( CalendarCal . name == calendar_filter )
)
rows = q . order_by ( CalendarEvent . dtstart ) . all ( )
events = [ ]
for ev in rows :
if ev . all_day :
s , e = ev . dtstart . strftime ( " % Y- % m- %d " ) , ev . dtend . strftime ( " % Y- % m- %d " )
else :
suffix = " Z " if getattr ( ev , " is_utc " , False ) else " "
s , e = ev . dtstart . isoformat ( ) + suffix , ev . dtend . isoformat ( ) + suffix
events . append ( {
" uid " : ev . uid , " summary " : ev . summary or " " , " dtstart " : s , " dtend " : e ,
" all_day " : ev . all_day , " description " : ev . description or " " ,
" location " : ev . location or " " ,
" calendar " : ev . calendar . name if ev . calendar else " " ,
" calendar_href " : ev . calendar_id ,
" event_type " : ev . event_type or " " ,
" importance " : ev . importance or " normal " ,
} )
if not events :
response_text = f " No events between { start_dt . date ( ) . isoformat ( ) } and { end_dt . date ( ) . isoformat ( ) } . "
else :
lines = [ f " Found { len ( events ) } event(s) between { start_dt . date ( ) . isoformat ( ) } and { end_dt . date ( ) . isoformat ( ) } : " ]
for ev in events :
when = ev [ " dtstart " ]
when_str = f " { when } (all day) " if ev . get ( " all_day " ) else f " { when } -> { ev . get ( ' dtend ' , ' ' ) } "
# Clickable anchor — opens the calendar on the event's day.
line = f " - { when_str } : [ { ev [ ' summary ' ] } ](#event- { ev [ ' uid ' ] } ) "
if ev . get ( " event_type " ) :
line + = f " # { ev [ ' event_type ' ] } "
if ev . get ( " importance " ) and ev [ " importance " ] != " normal " :
line + = f " ! { ev [ ' importance ' ] } "
if ev . get ( " location " ) :
line + = f " @ { ev [ ' location ' ] } "
if ev . get ( " calendar " ) :
line + = f " ( { ev [ ' calendar ' ] } ) "
if ev . get ( " description " ) :
desc = ev [ " description " ] . strip ( ) . replace ( " \n " , " " )
if len ( desc ) > 120 :
desc = desc [ : 117 ] + " ... "
line + = f " \n { desc } "
lines . append ( line )
response_text = " \n " . join ( lines )
return { " response " : response_text , " events " : events , " exit_code " : 0 }
elif action == " create_event " :
summary = args . get ( " summary " )
# Accept the various names models like to use for the start
# field: dtstart (canonical), start, start_time, when.
dtstart_str = ( args . get ( " dtstart " ) or args . get ( " start " )
or args . get ( " start_time " ) or args . get ( " when " ) )
if not summary or not dtstart_str :
return { " error " : " summary and dtstart are required " , " exit_code " : 1 }
# Accept either an href OR a calendar name/short-id like "Main"
# or "62e545d8" — saves the model from having to memorize hrefs
# after a `list_calendars` call returned short prefixes.
cal_href = args . get ( " calendar_href " ) or args . get ( " calendar " )
cal = None
if cal_href :
cal = ( _calendar_query ( )
. filter ( CalendarCal . id == cal_href )
. first ( ) )
if not cal :
# Try by name (case-insensitive) or by short-id prefix
cal = ( _calendar_query ( )
. filter ( CalendarCal . name . ilike ( cal_href ) )
. first ( ) )
if not cal :
cal = ( _calendar_query ( )
. filter ( CalendarCal . id . like ( f " { cal_href } % " ) )
. first ( ) )
if not cal :
cal = _ensure_default_calendar ( db , owner )
all_day = bool ( args . get ( " all_day " , False ) )
try :
dtstart , dtstart_is_utc = _parse_event_dt ( dtstart_str )
except ValueError as e :
return { " error " : f " Could not parse dtstart { dtstart_str !r} : { e } " , " exit_code " : 1 }
dtend_raw = args . get ( " dtend " ) or args . get ( " end " ) or args . get ( " end_time " )
if dtend_raw :
try :
dtend , dtend_is_utc = _parse_event_dt ( dtend_raw )
dtstart_is_utc = dtstart_is_utc or dtend_is_utc
except ValueError as e :
return { " error " : f " Could not parse dtend { dtend_raw !r} : { e } " , " exit_code " : 1 }
else :
# Support duration: "1h", "30m", "90min", "1hr30m"
dur = ( args . get ( " duration " ) or " " ) . strip ( ) . lower ( )
delta = None
if dur :
import re as _re_d
h = _re_d . search ( r ' ( \ d+) \ s*(?:h|hr|hours?) ' , dur )
m = _re_d . search ( r ' ( \ d+) \ s*(?:m|min|minutes?) ' , dur )
secs = ( int ( h . group ( 1 ) ) * 3600 if h else 0 ) + ( int ( m . group ( 1 ) ) * 60 if m else 0 )
if secs > 0 :
delta = timedelta ( seconds = secs )
if delta is not None :
dtend = dtstart + delta
elif all_day :
dtend = dtstart + timedelta ( days = 1 )
else :
dtend = dtstart + timedelta ( hours = 1 )
# Dedup: if a non-cancelled event with the same title + start time already
# exists, return its UID instead of creating a fresh copy. Prevents the
# email triage from multiplying events when several emails reference the
# same meeting. Compare case-insensitively since LLM-extracted titles
# can vary in capitalisation.
from sqlalchemy import func as _func
existing = (
_event_query ( )
. filter (
CalendarEvent . dtstart == dtstart ,
CalendarEvent . status != " cancelled " ,
_func . lower ( CalendarEvent . summary ) == summary . lower ( ) ,
)
. first ( )
)
if existing is not None :
reminder_note_id = None
reminder_skipped_reason = None
minutes_before = _reminder_minutes ( args )
if minutes_before is not None :
reminder_note_id , reminder_skipped_reason = _create_calendar_reminder (
existing . summary or summary ,
existing . location or " " ,
existing . dtstart ,
existing . all_day ,
minutes_before ,
bool ( existing . is_utc ) ,
)
if reminder_note_id :
db . commit ( )
reminder_text = " "
if minutes_before is not None :
reminder_text = (
f " ; reminder set { minutes_before } min before "
if reminder_note_id
else f " ; reminder not set ( { reminder_skipped_reason or ' reminder time already passed ' } ) "
)
return {
" response " : (
f " Event already exists: ' { summary } ' on { dtstart_str } "
+ reminder_text
) ,
" uid " : existing . uid ,
" reminder_note_id " : reminder_note_id ,
" reminder_skipped_reason " : reminder_skipped_reason ,
" duplicate " : True ,
" exit_code " : 0 ,
}
# Optional tag/category and importance — friendly aliases.
event_type = ( args . get ( " event_type " ) or args . get ( " tag " )
or args . get ( " category " ) or args . get ( " type " ) or " " ) or None
importance = args . get ( " importance " ) or " normal "
minutes_before = _reminder_minutes ( args )
uid = str ( _uuid . uuid4 ( ) )
ev = CalendarEvent (
uid = uid , calendar_id = cal . id , summary = summary ,
description = _event_description ( args , minutes_before ) ,
location = args . get ( " location " , " " ) or " " ,
dtstart = dtstart , dtend = dtend , all_day = all_day ,
is_utc = dtstart_is_utc and not all_day ,
rrule = args . get ( " rrule " , " " ) or " " ,
event_type = event_type ,
importance = importance ,
)
db . add ( ev )
reminder_note_id = None
reminder_skipped_reason = None
if minutes_before is not None :
reminder_note_id , reminder_skipped_reason = _create_calendar_reminder (
summary ,
args . get ( " location " , " " ) or " " ,
dtstart ,
all_day ,
minutes_before ,
dtstart_is_utc and not all_day ,
)
db . commit ( )
tag_blurb = f " [ { event_type } ] " if event_type else " "
if minutes_before is None :
reminder_blurb = " "
elif reminder_note_id :
reminder_blurb = f " with reminder { minutes_before } min before "
else :
reminder_blurb = f " without reminder ( { reminder_skipped_reason or ' reminder time already passed ' } ) "
# Return a clickable anchor so the agent can surface a link
# that opens the calendar on that day. See the markdown
# anchor convention ([Name](#event-<uid>)).
return {
" response " : f " Created event [ { summary } ](#event- { uid } ) { tag_blurb } on { dtstart_str } { reminder_blurb } " ,
" uid " : uid ,
" anchor " : f " [ { summary } ](#event- { uid } ) " ,
" reminder_note_id " : reminder_note_id ,
" reminder_skipped_reason " : reminder_skipped_reason ,
" exit_code " : 0 ,
}
elif action == " update_event " :
uid = args . get ( " uid " )
if not uid :
return { " error " : " uid is required " , " exit_code " : 1 }
Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)
Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).
Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests
- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
distinguish occurrences from the same series. Non-recurring events
pass through with is_recurrence=false and series_uid=uid.
- Add _resolve_base_uid() to extract the base series UID from compound
UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
manage_calendar tool so edits/deletes always target the base row.
- Update manage_calendar tool to import and use _resolve_base_uid.
- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
invalidate localStorage cache after success so stale sibling
occurrences aren't shown.
- Add python-dateutil to requirements.txt as an explicit dependency.
- Add 14 regression tests in tests/test_calendar_recurrence.py
covering _resolve_base_uid edge cases, _expand_rrule with
yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
metadata inheritance.
- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.
* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings
Fix three edge cases in _expand_rrule:
1. Malformed-RRULE fallback now checks window overlap. list_events
fetches recurring rows with only dtstart < end_dt, so a broken
old recurring event could appear in unrelated future windows.
Now fallback returns [] unless the base event's dtstart/dtend
actually intersect [start, end).
2. Exclusive end boundary. rule.between(start, end, inc=True) was
inclusive on end, but the route contract and non-recurring SQL
filter both use [start, end). Added occ_start >= end guard.
3. Multi-day crossings. A recurring occurrence that starts before
the window but ends inside it was missed (only occ_start was
checked). Now expands from start - duration and filters by
occ_start < end AND occ_end > start, matching non-recurring
overlap behavior.
Tests: +4 tests for these cases (18 total)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:42:44 +02:00
try :
base_uid = _resolve_base_uid ( uid )
except ValueError as e :
return { " error " : str ( e ) , " exit_code " : 1 }
ev = _event_query ( ) . filter ( CalendarEvent . uid == base_uid ) . first ( )
2026-05-31 23:58:26 +09:00
if not ev :
return { " error " : f " Event { uid } not found " , " exit_code " : 1 }
if args . get ( " summary " ) is not None :
ev . summary = args [ " summary " ]
if args . get ( " description " ) is not None :
ev . description = args [ " description " ]
if args . get ( " location " ) is not None :
ev . location = args [ " location " ]
if args . get ( " dtstart " ) is not None :
2026-06-03 05:37:39 +01:00
# Anchor naive/natural-language input to the USER's timezone and
# refresh is_utc, exactly like create_event. Parsing with the
# raw server-local _parse_dt here (and never touching is_utc)
# silently shifted an updated event by the user's UTC offset.
_eff_all_day = (
args [ " all_day " ] if args . get ( " all_day " ) is not None else ev . all_day
)
ev . dtstart , _su = _parse_event_dt ( args [ " dtstart " ] )
ev . is_utc = bool ( _su and not _eff_all_day )
2026-05-31 23:58:26 +09:00
if args . get ( " dtend " ) is not None :
2026-06-03 05:37:39 +01:00
ev . dtend , _eu = _parse_event_dt ( args [ " dtend " ] )
2026-05-31 23:58:26 +09:00
if args . get ( " all_day " ) is not None :
ev . all_day = args [ " all_day " ]
# Tag/category + importance updates (any of these aliases).
_tag = ( args . get ( " event_type " ) or args . get ( " tag " )
or args . get ( " category " ) or args . get ( " type " ) )
if _tag is not None :
ev . event_type = _tag or None
if args . get ( " importance " ) is not None :
ev . importance = args [ " importance " ]
db . commit ( )
return { " response " : f " Updated event { uid } " , " exit_code " : 0 }
elif action == " delete_event " :
uid = args . get ( " uid " )
if not uid :
return { " error " : " uid is required " , " exit_code " : 1 }
Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)
Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).
Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests
- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
distinguish occurrences from the same series. Non-recurring events
pass through with is_recurrence=false and series_uid=uid.
- Add _resolve_base_uid() to extract the base series UID from compound
UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
manage_calendar tool so edits/deletes always target the base row.
- Update manage_calendar tool to import and use _resolve_base_uid.
- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
invalidate localStorage cache after success so stale sibling
occurrences aren't shown.
- Add python-dateutil to requirements.txt as an explicit dependency.
- Add 14 regression tests in tests/test_calendar_recurrence.py
covering _resolve_base_uid edge cases, _expand_rrule with
yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
metadata inheritance.
- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.
* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings
Fix three edge cases in _expand_rrule:
1. Malformed-RRULE fallback now checks window overlap. list_events
fetches recurring rows with only dtstart < end_dt, so a broken
old recurring event could appear in unrelated future windows.
Now fallback returns [] unless the base event's dtstart/dtend
actually intersect [start, end).
2. Exclusive end boundary. rule.between(start, end, inc=True) was
inclusive on end, but the route contract and non-recurring SQL
filter both use [start, end). Added occ_start >= end guard.
3. Multi-day crossings. A recurring occurrence that starts before
the window but ends inside it was missed (only occ_start was
checked). Now expands from start - duration and filters by
occ_start < end AND occ_end > start, matching non-recurring
overlap behavior.
Tests: +4 tests for these cases (18 total)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:42:44 +02:00
try :
base_uid = _resolve_base_uid ( uid )
except ValueError as e :
return { " error " : str ( e ) , " exit_code " : 1 }
ev = _event_query ( ) . filter ( CalendarEvent . uid == base_uid ) . first ( )
2026-05-31 23:58:26 +09:00
if not ev :
return { " error " : f " Event { uid } not found " , " exit_code " : 1 }
db . delete ( ev )
db . commit ( )
return { " response " : f " Deleted event { uid } " , " exit_code " : 0 }
else :
return {
" error " : f " Unknown action: { action } . Use list_events, create_event, update_event, delete_event, list_calendars " ,
" exit_code " : 1 ,
}
except Exception as e :
db . rollback ( )
logger . error ( f " manage_calendar error: { e } " )
return { " error " : str ( e ) , " exit_code " : 1 }
finally :
db . close ( )
# ── Cookbook tools ──
2026-06-07 23:22:09 +02:00
# In-process loopback base for agent tools that call Odysseus's own API
# (cookbook state, model serve, gallery, email, calendar). We ride the
# per-process internal token so require_admin lets us through. See
# core/middleware.py. Resolution (override / APP_PORT / 7000) lives in
# core.constants.internal_api_base().
_INTERNAL_BASE = internal_api_base ( )
2026-05-31 23:58:26 +09:00
def _internal_headers ( owner : Optional [ str ] = None ) - > Dict [ str , str ] :
from core . middleware import INTERNAL_TOOL_HEADER , INTERNAL_TOOL_TOKEN
headers = { INTERNAL_TOOL_HEADER : INTERNAL_TOOL_TOKEN }
if owner :
headers [ " X-Odysseus-Owner " ] = owner
return headers
async def _cookbook_servers ( ) - > Dict [ str , Any ] :
""" Return the cookbook ' s configured servers + the currently-selected
default host . Shape : { default_host , hosts : [ { host , platform , env , envPath } ] } .
The agent uses this to route downloads / serves to the right machine
instead of silently defaulting to localhost . """
import httpx
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
r = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " , headers = _internal_headers ( ) )
2026-05-31 23:58:26 +09:00
state = r . json ( ) if r . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else { }
except Exception :
return { " default_host " : " " , " hosts " : [ ] }
env = ( state or { } ) . get ( " env " ) or { }
if not isinstance ( env , dict ) :
return { " default_host " : " " , " hosts " : [ ] }
hosts = [ ]
for s in ( env . get ( " servers " ) or [ ] ) :
if isinstance ( s , dict ) :
hosts . append ( {
" name " : s . get ( " name " ) or " " ,
" host " : s . get ( " host " ) or " " , # "" = Local
" platform " : s . get ( " platform " ) or " " ,
" env " : s . get ( " env " ) or " " ,
" envPath " : s . get ( " envPath " ) or " " ,
" port " : s . get ( " port " ) or " " ,
} )
return { " default_host " : env . get ( " remoteHost " ) or " " , " hosts " : hosts }
async def _resolve_cookbook_host ( name_or_host : str ) - > str :
""" Map a friendly server NAME ( ' gpu-box ' , ' workstation ' ) to its ssh host
string ( ' user@192.0.2.10 ' ) . If the input already looks like an
ssh host ( contains ' @ ' or matches a known host ) , or matches nothing ,
it ' s returned unchanged. ' local ' / ' localhost ' → ' ' (this machine). " " "
if not name_or_host :
return " "
val = name_or_host . strip ( )
low = val . lower ( )
if low in ( " local " , " localhost " , " this machine " , " here " ) :
return " "
servers = await _cookbook_servers ( )
# Exact host match → already an ssh host
for h in servers . get ( " hosts " ) or [ ] :
if h . get ( " host " ) and h [ " host " ] == val :
return val
# Name match (case-insensitive)
for h in servers . get ( " hosts " ) or [ ] :
if ( h . get ( " name " ) or " " ) . lower ( ) == low :
return h . get ( " host " ) or " " # "" for the Local entry
# Substring name match as a fallback
for h in servers . get ( " hosts " ) or [ ] :
if low and low in ( h . get ( " name " ) or " " ) . lower ( ) :
return h . get ( " host " ) or " "
# No match — assume the caller passed a raw host/alias; return as-is
# (ssh can resolve aliases from ~/.ssh/config).
return val
async def _cookbook_env_for_host ( host : str ) - > Dict [ str , Any ] :
""" Resolve env_prefix / gpus / platform / hf_token / ssh_port for a
given host by looking it up in cookbook_state . env . The user
configures these per - host in the Cookbook UI ; without them , raw
` vllm serve … ` fails with ' command not found ' because vLLM lives
inside a venv that has to be sourced first .
Returns a dict with keys ready to drop into the / api / model / serve
payload : env_prefix , gpus , platform , hf_token , ssh_port .
Falls back to the top - level env settings if no per - host entry exists .
"""
import httpx
headers = _internal_headers ( )
state : Dict [ str , Any ] = { }
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
r = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " , headers = headers )
2026-05-31 23:58:26 +09:00
state = r . json ( ) if r . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else { }
except Exception as e :
logger . debug ( f " cookbook env lookup failed for host= { host !r} : { e } " )
return { }
if not isinstance ( state , dict ) :
return { }
env_root = state . get ( " env " ) or { }
if not isinstance ( env_root , dict ) :
return { }
# Per-host entry takes precedence over top-level.
per_host : Dict [ str , Any ] = { }
for s in ( env_root . get ( " servers " ) or [ ] ) :
if isinstance ( s , dict ) and ( s . get ( " host " ) or " " ) == ( host or " " ) :
per_host = s
break
env_kind = per_host . get ( " env " ) or env_root . get ( " env " ) or " none "
env_path = per_host . get ( " envPath " ) or env_root . get ( " envPath " ) or " "
platform = per_host . get ( " platform " ) or env_root . get ( " platform " ) or " linux "
ssh_port = per_host . get ( " sshPort " ) or env_root . get ( " sshPort " ) or " "
env_prefix = " "
if env_kind == " venv " and env_path :
if platform == " windows " :
activate = env_path if env_path . endswith ( " \\ Scripts \\ Activate.ps1 " ) else env_path . rstrip ( " \\ " ) + " \\ Scripts \\ Activate.ps1 "
env_prefix = f " & { activate } "
else :
activate = env_path if env_path . endswith ( " /bin/activate " ) else env_path . rstrip ( " / " ) + " /bin/activate "
env_prefix = f " source { activate } "
elif env_kind == " conda " and env_path :
if platform == " windows " :
env_prefix = f " conda activate { env_path } "
else :
env_prefix = f ' eval " $(conda shell.bash hook) " && conda activate { env_path } '
return {
" env_prefix " : env_prefix ,
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
" env_type " : env_kind ,
" env_path " : env_path ,
2026-05-31 23:58:26 +09:00
" gpus " : env_root . get ( " gpus " ) or " " ,
" platform " : platform ,
" hf_token " : env_root . get ( " hfToken " ) or " " ,
" ssh_port " : ssh_port ,
}
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
def _infer_serve_port ( cmd : str ) - > int :
""" Infer likely listen port from a serve command. """
if not cmd :
return 8080
m = re . search ( r " --port \\ s+( \\ d+) " , cmd )
if m :
try :
return int ( m . group ( 1 ) )
except Exception :
pass
m = re . search ( r " OLLAMA_HOST=[^ \\ s]*?:( \\ d+) " , cmd )
if m :
try :
return int ( m . group ( 1 ) )
except Exception :
pass
if " ollama " in cmd :
return 11434
return 8080
def _infer_serve_host ( host : str | None ) - > tuple [ str , bool ] :
""" Return (host, container_local) for registering a served endpoint. """
if not ( host or " " ) . strip ( ) :
return " localhost " , True
base_host = host . split ( " @ " , 1 ) [ - 1 ] if " @ " in host else host
return base_host , False
async def _ensure_served_endpoint (
* ,
model : str ,
cmd : str ,
host : str | None ,
) - > Dict [ str , Any ] :
""" Register/fetch a model endpoint for a running serve session. """
import httpx
endpoint_host , container_local = _infer_serve_host ( host )
port = _infer_serve_port ( cmd )
base_url = f " http:// { endpoint_host } : { port } /v1 "
short_name = model . split ( " / " ) [ - 1 ] if " / " in model else model
is_image = " diffusion_server.py " in ( cmd or " " )
payload = {
" name " : short_name if not is_image else f " { short_name } (image) " ,
" base_url " : base_url ,
" skip_probe " : " true " ,
" model_type " : " image " if is_image else " llm " ,
" container_local " : " true " if container_local else " false " ,
}
try :
async with httpx . AsyncClient ( timeout = 30 ) as client :
resp = await client . post (
2026-06-09 20:31:29 +02:00
f " { _INTERNAL_BASE } /api/model-endpoints " ,
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
data = payload ,
headers = _internal_headers ( ) ,
)
data = resp . json ( ) if resp . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else { }
if resp . status_code > = 400 :
logger . debug (
f " ensure endpoint failed for { model !r} : status= { resp . status_code } data= { data } "
)
return { " added " : False , " endpoint_id " : " " , " base_url " : base_url , " error " : data }
ep_id = data . get ( " id " ) if isinstance ( data , dict ) else None
return {
" added " : bool ( ep_id ) ,
" endpoint_id " : ep_id or " " ,
" base_url " : base_url ,
" data " : data ,
}
except Exception as e :
logger . debug ( f " ensure endpoint exception for { model !r} : { e } " )
return { " added " : False , " endpoint_id " : " " , " base_url " : base_url , " error " : str ( e ) }
async def _cookbook_register_task (
session_id : str ,
model : str ,
host : str ,
cmd : str ,
task_type : str = " serve " ,
* ,
endpoint_added : bool = False ,
endpoint_id : str = " " ,
) - > bool :
2026-05-31 23:58:26 +09:00
""" Append a task entry to cookbook_state.json after the agent
launches via / api / model / serve or / api / model / download . The route
spawns tmux but leaves state - writing to the UI ; the agent needs to
do that here so the task shows up in the Cookbook tab .
Returns True on success , False if the write failed ( best - effort ) . """
import httpx
import time as _time
headers = _internal_headers ( )
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
r = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " , headers = headers )
2026-05-31 23:58:26 +09:00
state = r . json ( ) if r . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else { }
except Exception as e :
logger . debug ( f " cookbook state read failed: { e } " )
return False
if not isinstance ( state , dict ) :
state = { }
tasks = state . get ( " tasks " ) if isinstance ( state . get ( " tasks " ) , list ) else [ ]
# Skip duplicate (same session_id) entries
if any ( isinstance ( t , dict ) and t . get ( " sessionId " ) == session_id for t in tasks ) :
return True
display_name = model . split ( " / " ) [ - 1 ] if " / " in model else model
# Placeholder output — the cookbook UI's CSS hides empty <pre>
# via `.cookbook-output-pre:empty { display: none }`, so an
# empty-string output makes the expansion appear broken until the
# frontend's reconnect-polling loop captures tmux output. A short
# placeholder gives the user something to see immediately; it gets
# replaced by real tmux output within a few seconds.
target = f " { host } : " if host else " local: "
placeholder = (
f " Launched via agent — waiting for tmux output… \n "
f " session: { session_id } \n "
2026-06-07 07:44:26 -07:00
f " target: { target } { ( cmd . split ( ) or [ ' ' ] ) [ 0 ] if cmd else ' ' } \n "
2026-05-31 23:58:26 +09:00
f " cmd: { cmd [ : 200 ] } { ' … ' if len ( cmd ) > 200 else ' ' } "
)
tasks . append ( {
" id " : session_id ,
" sessionId " : session_id ,
" name " : display_name ,
" modelId " : model ,
" type " : task_type ,
" status " : " running " ,
" output " : placeholder ,
" ts " : int ( _time . time ( ) * 1000 ) ,
" payload " : { " repo_id " : model , " remote_host " : host or " " , " _cmd " : cmd } ,
" remoteHost " : host or " " ,
" sshPort " : " " ,
" platform " : " linux " ,
" _serveReady " : False ,
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
" _endpointAdded " : bool ( endpoint_added ) ,
" _endpointId " : endpoint_id or " " ,
2026-05-31 23:58:26 +09:00
} )
state [ " tasks " ] = tasks
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
r = await client . post ( f " { _INTERNAL_BASE } /api/cookbook/state " ,
2026-05-31 23:58:26 +09:00
json = state , headers = headers )
return r . status_code < 400
except Exception as e :
logger . debug ( f " cookbook state write failed: { e } " )
return False
# Paths the generic `app_api` tool will refuse to call. Auth/token/user
2026-06-07 16:19:08 +03:00
# administration and host shell execution are too risky to route through an
# agent surface even when the agent is admin-context; accidental account or
# command mistakes have permanent blast radius.
2026-05-31 23:58:26 +09:00
_APP_API_BLOCKLIST_PREFIXES = (
2026-06-03 11:16:56 +09:00
" /api/auth " , # login/logout/password
" /api/users " , # user CRUD (bare /api/users list+create+delete must also block)
" /api/tokens " , # api token mgmt (bare /api/tokens list+create must also block)
" /api/admin " , # admin one-shots (wipe etc.)
2026-06-07 16:19:08 +03:00
" /api/shell " , # host shell execution must stay behind named command tooling
2026-05-31 23:58:26 +09:00
" /api/backup/restore " , # destructive restore
)
# (method, prefix) pairs to refuse specifically. Used for endpoints
2026-06-07 20:20:11 +03:00
# where GET is fine but writes are destructive or host-control shaped.
# Saw the agent wipe cookbook_state.json (presets + tasks) by POSTing
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
# Use dedicated tools or UI flows instead.
2026-05-31 23:58:26 +09:00
_APP_API_BLOCKLIST_METHOD_PATH = (
( " GET " , " /api/email/accounts " ) , # owner-filtered in tool context; use list_email_accounts MCP tool
( " POST " , " /api/cookbook/state " ) , # whole-file overwrite — agent must use serve_preset/serve_model instead
( " DELETE " , " /api/cookbook/state " ) ,
2026-06-07 20:20:11 +03:00
# Host-control routes: package install, engine rebuild, and process
# signalling should not be reachable through the generic API bridge.
( " POST " , " /api/cookbook/packages/install " ) ,
( " POST " , " /api/cookbook/rebuild-engine " ) ,
( " POST " , " /api/cookbook/kill-pid " ) ,
2026-05-31 23:58:26 +09:00
# Use the named tools (download_model / serve_model) — they handle
# host-name resolution, per-host env_prefix, AND register the task
# in cookbook state so it shows in the UI + list_downloads. Hitting
# the raw endpoint via app_api skips all of that → orphan task.
( " POST " , " /api/model/download " ) ,
( " POST " , " /api/model/serve " ) ,
# Use trigger_research — it returns a UI hint so the Deep Research
# sidebar surfaces the session. Raw start works but the agent
# fumbles the payload + the session doesn't reliably show up.
( " POST " , " /api/research/start " ) ,
# Use the named tools — they handle owner attribution, natural-
# language due_date parsing, timezone, dedup, and tag/category
# normalization. Hitting the raw endpoint via app_api saves a
# note/event with the wrong fields, no reminder, or the wrong tz.
( " POST " , " /api/notes " ) ,
( " PUT " , " /api/notes " ) ,
( " DELETE " , " /api/notes " ) ,
( " POST " , " /api/calendar/events " ) ,
( " PUT " , " /api/calendar/events " ) ,
( " DELETE " , " /api/calendar/events " ) ,
)
async def do_app_api ( content : str , owner : Optional [ str ] = None ) - > Dict :
2026-06-07 16:19:08 +03:00
""" Generic loopback to allowed internal Odysseus API endpoints. Lets the
2026-05-31 23:58:26 +09:00
agent reach the full UI - button surface ( cookbook , email , notes ,
calendar , skills , sessions , gallery , research , etc . ) without us
landing a named tool wrapper for every one .
Args ( JSON ) :
action : " call " ( default ) | " endpoints "
path : " /api/cookbook/gpus " # required for call
method : " GET " | " POST " | " PUT " | " PATCH " | " DELETE " ( default GET )
body : < object > # JSON body for POST/PUT/PATCH
query : < object > # querystring params
The ` endpoints ` action returns the OpenAPI surface ( method + path +
summary ) so the agent can discover what ' s reachable. A blocklist
2026-06-07 20:20:11 +03:00
refuses sensitive auth / user / admin / shell paths and method - specific
host - control routes to keep blast radius bounded .
2026-05-31 23:58:26 +09:00
"""
import httpx
try :
args = _parse_tool_args ( content ) if content . strip ( ) else { }
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = ( args . get ( " action " ) or " call " ) . lower ( )
2026-06-07 23:22:09 +02:00
base = _INTERNAL_BASE
2026-05-31 23:58:26 +09:00
if action == " endpoints " :
# Fetch FastAPI's OpenAPI schema so the agent can discover any
# endpoint without us pre-listing them. Filter by an optional
# `filter` keyword (substring match on path or summary).
kw = ( args . get ( " filter " ) or " " ) . lower ( )
try :
async with httpx . AsyncClient ( timeout = 15 ) as client :
resp = await client . get ( f " { base } /openapi.json " ,
headers = _internal_headers ( ) )
data = resp . json ( )
except Exception as e :
return { " error " : f " OpenAPI fetch failed: { e } " , " exit_code " : 1 }
rows : List [ Dict [ str , Any ] ] = [ ]
for path , methods in ( data . get ( " paths " ) or { } ) . items ( ) :
if not isinstance ( methods , dict ) :
continue
if any ( path . startswith ( p ) for p in _APP_API_BLOCKLIST_PREFIXES ) :
continue
for method , op in methods . items ( ) :
if method . lower ( ) not in ( " get " , " post " , " put " , " patch " , " delete " ) :
continue
if any ( method . upper ( ) == m and path . startswith ( p ) for m , p in _APP_API_BLOCKLIST_METHOD_PATH ) :
continue
summary = ( op or { } ) . get ( " summary " ) or ( op or { } ) . get ( " description " ) or " "
if isinstance ( summary , str ) :
summary = summary . strip ( ) . split ( " \n " ) [ 0 ] [ : 140 ]
if kw and kw not in path . lower ( ) and kw not in ( summary or " " ) . lower ( ) :
continue
rows . append ( { " method " : method . upper ( ) , " path " : path , " summary " : summary } )
rows . sort ( key = lambda r : ( r [ " path " ] , r [ " method " ] ) )
if not rows :
return { " output " : f " No endpoints match filter { kw !r} . " if kw else " No endpoints found. " , " exit_code " : 0 }
lines = [ f " { len ( rows ) } endpoint(s) " + ( f " matching { kw !r} " if kw else " " ) + " : " ]
for r in rows [ : 200 ] :
line = f " { r [ ' method ' ] : 6s } { r [ ' path ' ] } "
if r [ " summary " ] :
line + = f " — { r [ ' summary ' ] } "
lines . append ( line )
if len ( rows ) > 200 :
lines . append ( f " ...( { len ( rows ) - 200 } more — filter to narrow) " )
return { " output " : " \n " . join ( lines ) , " endpoints " : rows , " exit_code " : 0 }
# action == "call"
path = args . get ( " path " ) or " "
if not path :
return { " error " : " path is required (e.g. ' /api/cookbook/gpus ' ) " , " exit_code " : 1 }
if not path . startswith ( " / " ) :
path = " / " + path
if any ( path . startswith ( p ) for p in _APP_API_BLOCKLIST_PREFIXES ) :
2026-06-07 16:19:08 +03:00
return { " error " : f " Path blocked for safety: { path } . Sensitive endpoints are off-limits via app_api. " , " exit_code " : 1 }
2026-05-31 23:58:26 +09:00
method = ( args . get ( " method " ) or " GET " ) . upper ( )
if method not in ( " GET " , " POST " , " PUT " , " PATCH " , " DELETE " ) :
return { " error " : f " Unsupported method: { method } " , " exit_code " : 1 }
if any ( method == m and path . startswith ( p ) for m , p in _APP_API_BLOCKLIST_METHOD_PATH ) :
if " /api/email/accounts " in path :
return { " error " : " Don ' t use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email. " , " exit_code " : 1 }
2026-06-07 20:20:11 +03:00
if " /api/cookbook/packages/install " in path :
return { " error " : " Don ' t POST /api/cookbook/packages/install via app_api — package installation is host code execution. Use the dedicated Cookbook dependency UI/flow instead. " , " exit_code " : 1 }
if " /api/cookbook/rebuild-engine " in path :
return { " error " : " Don ' t POST /api/cookbook/rebuild-engine via app_api — engine rebuild mutates local or remote host state. Use the dedicated Cookbook UI/flow instead. " , " exit_code " : 1 }
if " /api/cookbook/kill-pid " in path :
return { " error " : " Don ' t POST /api/cookbook/kill-pid via app_api — process signalling is host control. Use the dedicated Cookbook stop/diagnostic flow instead. " , " exit_code " : 1 }
2026-05-31 23:58:26 +09:00
if " /api/model/download " in path :
return { " error " : " Don ' t POST /api/model/download directly — use the `download_model` tool (it resolves the server name, sets the venv env_prefix, and registers the task so it shows in the UI). " , " exit_code " : 1 }
if " /api/model/serve " in path :
return { " error " : " Don ' t POST /api/model/serve directly — use the `serve_model` or `serve_preset` tool (handles host resolution, env_prefix, and cookbook tracking). " , " exit_code " : 1 }
if " /api/research/start " in path :
return { " error " : " Don ' t POST /api/research/start directly — use the `trigger_research` tool (it surfaces the session in the Deep Research sidebar). " , " exit_code " : 1 }
if " /api/notes " in path :
return { " error " : " Don ' t hit /api/notes via app_api — use the `manage_notes` tool. It accepts natural-language due_date ( ' 11pm today ' , ' tomorrow at 9am ' ), fires reminders from the due_date itself (no separate calendar event), and uses the caller ' s timezone. The raw endpoint requires ISO-UTC + a separate calendar event, both of which the agent tends to get wrong. " , " exit_code " : 1 }
if " /api/calendar/events " in path :
return { " error " : " Don ' t hit /api/calendar/events via app_api — use the `manage_calendar` tool. It handles tz-aware natural-language datetimes and reminder_minutes correctly. If the user wants a note + reminder, prefer `manage_notes` with due_date — it bundles both. " , " exit_code " : 1 }
return { " error " : f " { method } { path } is blocked — it overwrites the whole cookbook state file. Use list_serve_presets / serve_preset / serve_model instead. " , " exit_code " : 1 }
body = args . get ( " body " )
query = args . get ( " query " ) or None
# Pass owner so the backend impersonates the user — without this,
# POSTs (notes, calendar, todos, ...) get owner="internal-tool"
# and the user that asked for them can't see the result.
headers = { * * _internal_headers ( owner = owner ) , " Content-Type " : " application/json " }
try :
async with httpx . AsyncClient ( timeout = 60 ) as client :
resp = await client . request (
method , f " { base } { path } " ,
json = body if body is not None and method in ( " POST " , " PUT " , " PATCH " ) else None ,
params = query ,
headers = headers ,
)
# Try to parse JSON; fall back to raw text.
try :
payload = resp . json ( )
preview = json . dumps ( payload , indent = 2 , default = str )
if len ( preview ) > 4000 :
preview = preview [ : 4000 ] + " \n ... (truncated) "
except Exception :
payload = None
preview = ( resp . text or " " ) [ : 4000 ]
if resp . status_code > = 400 :
return {
" error " : f " { method } { path } -> HTTP { resp . status_code } " ,
" status_code " : resp . status_code ,
" body " : preview ,
" exit_code " : 1 ,
}
return {
" output " : f " { method } { path } -> { resp . status_code } \n { preview } " ,
" status_code " : resp . status_code ,
" json " : payload ,
" exit_code " : 0 ,
}
except Exception as e :
return { " error " : f " { method } { path } failed: { e } " , " exit_code " : 1 }
# Patterns for detecting running LLM/diffusion model servers outside
# the cookbook's task tracker. Each entry: (label, substring-list).
# Match is case-insensitive against the FULL cmdline. First-match wins.
_MODEL_PROCESS_PATTERNS = [
( " vLLM " , [ " vllm.entrypoints " , " vllm serve " , " /vllm/ " , " vllm-openai " ] ) ,
( " SGLang " , [ " sglang.launch_server " , " sglang/launch_server " ] ) ,
( " llama.cpp " , [ " llama-server " , " llama_cpp_server " , " llamacppserver " ] ) ,
( " Ollama " , [ " ollama serve " , " ollama runner " , " /ollama " ] ) ,
( " ComfyUI " , [ " comfyui/main.py " , " /ComfyUI/main.py " , " ComfyUI " ] ) ,
( " A1111 WebUI " , [ " stable-diffusion-webui/webui " , " stable-diffusion-webui/launch " , " webui.sh " ] ) ,
( " Fooocus " , [ " Fooocus/entry_with_update " , " Fooocus/launch " ] ) ,
( " InvokeAI " , [ " invokeai-web " , " invokeai.app " , " invokeai/api_app " ] ) ,
( " Forge WebUI " , [ " stable-diffusion-webui-forge " , " forge/webui " ] ) ,
( " SD.Next " , [ " automatic/webui " , " sd.next " ] ) ,
( " TGI " , [ " text-generation-launcher " , " text_generation_launcher " ] ) ,
( " Aphrodite " , [ " aphrodite.endpoints " , " aphrodite-engine " ] ) ,
( " Triton " , [ " tritonserver " , " triton/main " ] ) ,
( " Diffusers " , [ " diffusers.pipelines " , " StableDiffusionInpaintPipeline " , " DiffusionPipeline " ] ) ,
]
def _cookbook_apply_retry_suggestion ( cmd : str , suggestion : Dict [ str , Any ] ) - > str :
""" Apply a structured Cookbook diagnosis suggestion to a serve command. """
if not cmd or not suggestion :
return cmd
op = suggestion . get ( " op " )
if op == " append " :
arg = ( suggestion . get ( " arg " ) or " " ) . strip ( )
if not arg or arg in cmd :
return cmd
return f " { cmd . rstrip ( ) } { arg } "
if op == " remove " :
flag = ( suggestion . get ( " flag " ) or " " ) . strip ( )
if not flag :
return cmd
return re . sub ( rf " \ s* { re . escape ( flag ) } (?: \ s+ \ S+)? " , " " , cmd ) . strip ( )
if op == " replace " :
flag = ( suggestion . get ( " flag " ) or " " ) . strip ( )
value = str ( suggestion . get ( " value " ) or " " ) . strip ( )
if not flag or not value :
return cmd
repl = f " { flag } { value } "
if re . search ( rf " (^| \ s) { re . escape ( flag ) } ( \ s+ \ S+)? " , cmd ) :
return re . sub ( rf " (^| \ s) { re . escape ( flag ) } (?: \ s+ \ S+)? " , lambda m : ( m . group ( 1 ) or " " ) + repl , cmd ) . strip ( )
return f " { cmd . rstrip ( ) } { repl } "
return cmd
def _scan_running_model_processes ( ) - > List [ Dict [ str , Any ] ] :
""" Scan /proc for running model server processes. Linux-only; returns
[ ] on other platforms or if / proc isn ' t accessible. Each match returns
a dict shaped like a cookbook task so the caller can merge cleanly .
"""
import os
if not os . path . isdir ( " /proc " ) :
return [ ]
out : List [ Dict [ str , Any ] ] = [ ]
seen_keys = set ( )
try :
for pid_dir in os . listdir ( " /proc " ) :
if not pid_dir . isdigit ( ) :
continue
try :
with open ( f " /proc/ { pid_dir } /cmdline " , " rb " ) as f :
raw = f . read ( )
except ( OSError , PermissionError ) :
continue
if not raw :
continue
# cmdline is NUL-separated; join with spaces for matching/display
cmdline = raw . replace ( b " \x00 " , b " " ) . decode ( " utf-8 " , errors = " replace " ) . strip ( )
if not cmdline :
continue
lower = cmdline . lower ( )
for label , needles in _MODEL_PROCESS_PATTERNS :
if any ( n . lower ( ) in lower for n in needles ) :
# Dedupe by (label, first-arg) — multi-worker servers
# spawn N processes; only show one row per server.
key = ( label , cmdline . split ( " " ) [ 0 ] )
if key in seen_keys :
break
seen_keys . add ( key )
# Try to pluck a model name out of the cmdline.
model = " "
for tok in cmdline . split ( ) :
if " / " in tok and any ( s in tok . lower ( ) for s in (
" model " , " checkpoint " , " .safetensors " , " .gguf " , " .bin " , " huggingface "
) ) :
model = tok
break
out . append ( {
" session_id " : f " pid- { pid_dir } " ,
" model " : model or label ,
" phase " : " running (external) " ,
" type " : " serve " ,
" remote " : " local " ,
" pid " : int ( pid_dir ) ,
" label " : label ,
" cmdline_preview " : cmdline [ : 140 ] + ( " … " if len ( cmdline ) > 140 else " " ) ,
" external " : True ,
} )
break
except Exception as e :
logger . debug ( f " _scan_running_model_processes failed: { e } " )
return out
async def do_download_model ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Download a HuggingFace model via the cookbook API. """
import httpx
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
repo_id = args . get ( " repo_id " , " " )
if not repo_id :
return { " error " : " repo_id is required " , " exit_code " : 1 }
host = ( args . get ( " host " ) or " " ) . strip ( )
# Resolve a friendly server NAME ("gpu-box") to its ssh host string.
if host :
host = await _resolve_cookbook_host ( host )
# No host specified → default to the cookbook's currently-selected
# server rather than silently downloading to localhost (which is
# usually NOT where the GPUs / model cache live).
_host_defaulted = False
if not host and not args . get ( " local " ) :
_servers = await _cookbook_servers ( )
if _servers . get ( " default_host " ) :
host = _servers [ " default_host " ]
_host_defaulted = True
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
backend = ( args . get ( " backend " ) or " " ) . strip ( ) . lower ( )
if not backend and " / " not in repo_id and " : " in repo_id :
backend = " ollama "
2026-05-31 23:58:26 +09:00
payload = { " repo_id " : repo_id }
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
if backend :
payload [ " backend " ] = backend
2026-05-31 23:58:26 +09:00
if host :
payload [ " remote_host " ] = host
if args . get ( " include " ) :
payload [ " include " ] = args [ " include " ]
# Per-host env_prefix + hf_token from cookbook_state (same as serve).
env_cfg = await _cookbook_env_for_host ( host )
if env_cfg . get ( " env_prefix " ) : payload [ " env_prefix " ] = env_cfg [ " env_prefix " ]
if env_cfg . get ( " hf_token " ) : payload [ " hf_token " ] = env_cfg [ " hf_token " ]
if env_cfg . get ( " platform " ) : payload [ " platform " ] = env_cfg [ " platform " ]
if env_cfg . get ( " ssh_port " ) : payload [ " ssh_port " ] = env_cfg [ " ssh_port " ]
try :
async with httpx . AsyncClient ( timeout = 30 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . post ( f " { _INTERNAL_BASE } /api/model/download " ,
2026-05-31 23:58:26 +09:00
json = payload , headers = _internal_headers ( ) )
data = resp . json ( )
if data . get ( " ok " ) :
sid = data . get ( " session_id " , " ? " )
registered = await _cookbook_register_task (
session_id = sid , model = repo_id , host = host ,
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
cmd = ( f " ollama pull { repo_id } " if backend == " ollama " else f " hf download { repo_id } " ) ,
task_type = " download " ,
2026-05-31 23:58:26 +09:00
)
note = " " if registered else " (state-write failed — download may not show in UI) "
where = host or " local "
default_note = " (defaulted to the cookbook ' s selected server — pass host= or local=true to override) " if _host_defaulted else " "
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
return {
" output " : f " Download started: { repo_id } on { where } (session: { sid } ) { note } { default_note } " ,
" session_id " : sid ,
" host " : host ,
" task_type " : " download " ,
" phase " : " running " ,
" exit_code " : 0 ,
}
2026-05-31 23:58:26 +09:00
return { " error " : data . get ( " error " , " Download failed " ) , " exit_code " : 1 }
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
async def do_serve_model ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Start serving a model via the cookbook API. """
import httpx
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
repo_id = args . get ( " repo_id " , " " )
cmd = args . get ( " cmd " , " " )
if not repo_id or not cmd :
return { " error " : " repo_id and cmd are required " , " exit_code " : 1 }
host = ( args . get ( " host " ) or " " ) . strip ( )
if host :
host = await _resolve_cookbook_host ( host )
if not host and not args . get ( " local " ) :
_servers = await _cookbook_servers ( )
if _servers . get ( " default_host " ) :
host = _servers [ " default_host " ]
payload = { " repo_id " : repo_id , " cmd " : cmd }
if host :
payload [ " remote_host " ] = host
# Resolve per-host env settings (venv/conda activate, gpus,
# hf_token, platform, ssh_port) from cookbook_state — same path
# the UI uses. Without env_prefix, `vllm serve …` lands in a shell
# without the user's venv and fails 'command not found'.
env_cfg = await _cookbook_env_for_host ( host )
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
# Rewrite bare `vllm` / `python3` leading tokens to the venv's absolute
# binary path when the target host has a venv configured. SSH non-
# interactive shells often leave ~/.local/bin ahead of the venv bin on
# PATH even with the venv activated, so `vllm serve` finds the wrong
# binary and crashes early (e.g. compute_89 torch ABI errors on an old
# user-site torch). This mirrors what static/js/cookbook.js does in
# _buildServeCmd for the UI launch path.
env_path = ( env_cfg . get ( " env_path " ) or " " ) . rstrip ( " / " )
env_type = ( env_cfg . get ( " env_type " ) or env_cfg . get ( " env " ) or " " ) . lower ( )
if env_type == " venv " and env_path :
venv_bin = f " { env_path } /bin "
# Match the FIRST shell-token: skip leading KEY=VAL env-var prefixes
# (CUDA_VISIBLE_DEVICES=… VLLM_USE_FLASHINFER_SAMPLER=…) before the binary.
import re as _re3
tokens = cmd . split ( )
idx = 0
env_re = _re3 . compile ( r " ^[A-Za-z_][A-Za-z0-9_]*= " )
while idx < len ( tokens ) and env_re . match ( tokens [ idx ] ) :
idx + = 1
if idx < len ( tokens ) :
head = tokens [ idx ]
if head in ( " vllm " , " python3 " , " python " ) :
tokens [ idx ] = f " { venv_bin } / { head } "
cmd = " " . join ( tokens )
payload [ " cmd " ] = cmd
2026-05-31 23:58:26 +09:00
if env_cfg . get ( " env_prefix " ) : payload [ " env_prefix " ] = env_cfg [ " env_prefix " ]
if env_cfg . get ( " gpus " ) : payload [ " gpus " ] = env_cfg [ " gpus " ]
if env_cfg . get ( " hf_token " ) : payload [ " hf_token " ] = env_cfg [ " hf_token " ]
if env_cfg . get ( " platform " ) : payload [ " platform " ] = env_cfg [ " platform " ]
if env_cfg . get ( " ssh_port " ) : payload [ " ssh_port " ] = env_cfg [ " ssh_port " ]
try :
async with httpx . AsyncClient ( timeout = 30 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . post ( f " { _INTERNAL_BASE } /api/model/serve " ,
2026-05-31 23:58:26 +09:00
json = payload , headers = _internal_headers ( ) )
data = resp . json ( )
if data . get ( " ok " ) :
sid = data . get ( " session_id " , " ? " )
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
endpoint_id = data . get ( " endpoint_id " ) or " "
if endpoint_id :
endpoint_added = True
else :
endpoint_meta = await _ensure_served_endpoint ( model = repo_id , cmd = cmd , host = host )
endpoint_added = bool ( endpoint_meta . get ( " added " ) )
endpoint_id = endpoint_meta . get ( " endpoint_id " , " " ) or endpoint_id
2026-05-31 23:58:26 +09:00
registered = await _cookbook_register_task (
session_id = sid , model = repo_id ,
host = host , cmd = cmd , task_type = " serve " ,
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
endpoint_added = endpoint_added , endpoint_id = endpoint_id or " " ,
2026-05-31 23:58:26 +09:00
)
note = " " if registered else " (state-write failed — task may not show in UI) "
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
return {
" output " : f " Serving { repo_id } (session: { sid } ) { note } " ,
" session_id " : sid ,
" task_type " : " serve " ,
" phase " : " running " ,
" host " : host ,
" endpoint_id " : endpoint_id ,
" exit_code " : 0 ,
}
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
# FastAPI HTTPException puts the message under `detail`, not `error`.
# Surface BOTH so the agent sees "Invalid characters in cmd" (from
# _validate_serve_cmd rejecting `&&`/`source`/`cd`) instead of
# the generic "Serve failed", which leaves it with nothing to act on.
err_msg = data . get ( " error " ) or data . get ( " detail " ) or " Serve failed "
hint = " "
if isinstance ( err_msg , str ) and " cmd " in err_msg . lower ( ) :
hint = ( " — the cmd must START with an allowlisted binary "
" (vllm, python3, llama-server, ollama, sglang, lmdeploy, node, npx). "
" Do NOT prefix with `cd …`, `source …`, or chain with `&&`. "
" env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
" automatically from the host ' s saved venv settings. " )
return { " error " : f " { err_msg } { hint } " , " exit_code " : 1 }
2026-05-31 23:58:26 +09:00
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
async def do_list_served_models ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" List running model servers — merges cookbook-tracked tasks with
a / proc scan for externally - launched LLM / diffusion processes
( vLLM , sglang , llama . cpp , Ollama , ComfyUI , A1111 , Fooocus , etc . ) . """
import asyncio
import httpx
# Cookbook-tracked tasks (best-effort; don't fail the whole call if
# this is unreachable).
cookbook_tasks : List [ Dict [ str , Any ] ] = [ ]
try :
async with httpx . AsyncClient ( timeout = 15 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/tasks/status " ,
2026-05-31 23:58:26 +09:00
headers = _internal_headers ( ) )
cookbook_tasks = ( resp . json ( ) or { } ) . get ( " tasks " ) or [ ]
except Exception as e :
logger . debug ( f " cookbook tasks/status fetch failed: { e } " )
# Local process scan — runs in a worker thread so it doesn't block.
external = await asyncio . to_thread ( _scan_running_model_processes )
merged : List [ Dict [ str , Any ] ] = [ ]
merged . extend ( cookbook_tasks )
# Dedupe: if a process's PID is already mentioned by a cookbook task
# (cookbook may track the PID via session_id), skip it.
cookbook_pids = set ( )
for t in cookbook_tasks :
if isinstance ( t , dict ) and t . get ( " pid " ) :
cookbook_pids . add ( t [ " pid " ] )
for p in external :
if p . get ( " pid " ) not in cookbook_pids :
merged . append ( p )
if not merged :
return {
" output " : " No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes). " ,
" exit_code " : 0 ,
}
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
# Sort so the agent sees what's actually LIVE first. Stopped/error/
# completed tasks are mostly historical noise — they shouldn't lead
# the list when something is genuinely serving.
_ORDER = {
" ready " : 0 , " running " : 1 , " loading " : 1 , " warming " : 1 ,
" queued " : 2 , " starting " : 2 ,
" error " : 5 , " crashed " : 5 , " failed " : 5 ,
" stopped " : 6 , " killed " : 6 , " cancelled " : 6 , " canceled " : 6 ,
" done " : 7 , " completed " : 7 , " finished " : 7 ,
}
def _rank ( t : Dict [ str , Any ] ) - > int :
phase = ( t . get ( " phase " ) or t . get ( " status " ) or " unknown " ) . lower ( )
return _ORDER . get ( phase , 3 )
merged . sort ( key = _rank )
2026-05-31 23:58:26 +09:00
cb_n = len ( cookbook_tasks )
ext_n = len ( external )
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
live_n = sum ( 1 for t in merged if _rank ( t ) < = 2 )
2026-05-31 23:58:26 +09:00
header = [ ]
if cb_n :
header . append ( f " { cb_n } cookbook-tracked " )
if ext_n :
header . append ( f " { ext_n } external " )
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
if live_n :
header . insert ( 0 , f " { live_n } LIVE " )
2026-05-31 23:58:26 +09:00
lines = [ f " Running: { ' , ' . join ( header ) } . " ]
for t in merged :
phase = t . get ( " phase " ) or t . get ( " status " , " unknown " )
model = t . get ( " model " , " ? " )
remote = t . get ( " remote " , " local " )
sid = t . get ( " session_id " , " ? " )
tag = " [external] " if t . get ( " external " ) else " "
lines . append ( f " - { model } : { phase } ( { remote } , session: { sid } ) { tag } " )
diag = t . get ( " diagnosis " ) if isinstance ( t . get ( " diagnosis " ) , dict ) else None
if diag :
lines . append ( f " diagnosis: { diag . get ( ' message ' ) } " )
cmd = t . get ( " cmd " ) or " "
suggestions = diag . get ( " suggestions " ) or [ ]
actionable = [ ]
for s in suggestions [ : 3 ] :
label = s . get ( " label " ) or " retry "
retry_cmd = _cookbook_apply_retry_suggestion ( cmd , s )
if retry_cmd and retry_cmd != cmd and s . get ( " op " ) in { " append " , " replace " , " remove " } :
actionable . append ( f " { label } : ` { retry_cmd } ` " )
else :
actionable . append ( label )
if actionable :
lines . append ( " suggestions: " + " | " . join ( actionable ) )
if t . get ( " status " ) == " error " and t . get ( " output_tail " ) :
tail = str ( t . get ( " output_tail " ) or " " ) . strip ( )
if tail :
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
# Prefer a window around a Python traceback if one exists,
# falling back to the last 30 lines. The previous 6-line
# tail showed only the post-crash bash prompt / neofetch
# banner ("Locale: C / Ubuntu_Odysseus ❯ ") — useless for
# diagnosis. The traceback we want is usually 50-200 lines
# earlier in the buffer.
_tail_lines = tail . splitlines ( )
_shown = _tail_lines [ - 30 : ]
for _i , _ln in enumerate ( _tail_lines ) :
if " Traceback (most recent call last) " in _ln or " ERROR " in _ln or " Error: " in _ln :
_shown = _tail_lines [ _i : _i + 40 ]
break
2026-05-31 23:58:26 +09:00
lines . append ( " recent log: " )
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
for line in _shown :
2026-05-31 23:58:26 +09:00
lines . append ( f " { line [ : 220 ] } " )
if t . get ( " external " ) and t . get ( " cmdline_preview " ) :
lines . append ( f " cmd: { t [ ' cmdline_preview ' ] } " )
return { " output " : " \n " . join ( lines ) , " tasks " : merged , " exit_code " : 0 }
async def _cookbook_kill_session ( session_id : str , * , remote_host : str = " " ,
ssh_port : str = " " , verb : str = " Stopped " ) - > Dict :
""" Kill a cookbook tmux session — remote-aware — AND mark the task
stopped in cookbook_state . json . Shared by stop_served_model and
cancel_download so both behave identically .
Resolves the task ' s remote host from state when not passed in. A
local - only ` tmux kill - session ` silently no - ops for remote tasks —
that ' s the bug where " stop the download " appeared to work but the
download kept running on the remote host .
"""
import httpx
import shlex
headers = _internal_headers ( )
remote = remote_host or " "
sport = ssh_port or " "
# Look up the task's host + confirm it exists in state.
state : Dict [ str , Any ] = { }
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " , headers = headers )
2026-05-31 23:58:26 +09:00
state = resp . json ( ) or { }
except Exception as e :
logger . debug ( f " cookbook state lookup failed for { session_id } : { e } " )
if not isinstance ( state , dict ) :
state = { }
matched = None
for t in ( state . get ( " tasks " ) or [ ] ) :
if isinstance ( t , dict ) and ( t . get ( " sessionId " ) == session_id or t . get ( " id " ) == session_id ) :
matched = t
if not remote :
remote = t . get ( " remoteHost " ) or " "
if not sport :
sport = t . get ( " sshPort " ) or " "
break
if remote :
_pf = f " -p { shlex . quote ( str ( sport ) ) } " if sport and str ( sport ) != " 22 " else " "
cmd = (
f " ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
f " { _pf } { shlex . quote ( remote ) } ' tmux kill-session -t { shlex . quote ( session_id ) } ' "
)
target_label = f " { session_id } on { remote } "
else :
cmd = f " tmux kill-session -t { shlex . quote ( session_id ) } "
target_label = session_id
try :
async with httpx . AsyncClient ( timeout = 15 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . post ( f " { _INTERNAL_BASE } /api/shell/exec " ,
2026-05-31 23:58:26 +09:00
json = { " command " : cmd } , headers = headers )
if resp . status_code > = 400 :
return { " error " : f " shell/exec returned HTTP { resp . status_code } : { resp . text [ : 200 ] } " , " exit_code " : 1 }
try :
data = resp . json ( )
except Exception :
data = { }
kill_failed = isinstance ( data , dict ) and data . get ( " exit_code " ) not in ( None , 0 )
kill_err = ( ( data . get ( " stderr " ) or data . get ( " error " ) or " " ) . strip ( ) if isinstance ( data , dict ) else " " )
# "no server running" / "can't find session" means it was already
# gone — treat as success (the goal is "not running").
already_gone = any ( s in kill_err . lower ( ) for s in ( " no server running " , " can ' t find session " , " session not found " ) )
if kill_failed and not already_gone :
return { " error " : f " Failed to { verb . lower ( ) } { target_label } : { kill_err or ' kill-session returned non-zero ' } " , " exit_code " : 1 }
# Update state: mark stopped (so the UI + list reflect reality).
if matched is not None :
try :
matched [ " status " ] = " stopped "
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
await client . post ( f " { _INTERNAL_BASE } /api/cookbook/state " ,
2026-05-31 23:58:26 +09:00
json = state , headers = headers )
except Exception as e :
logger . debug ( f " failed to mark { session_id } stopped in state: { e } " )
suffix = " (was already gone) " if already_gone else " "
return { " output " : f " { verb } { target_label } { suffix } " , " exit_code " : 0 }
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
async def do_stop_served_model ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Stop a running model server by killing its tmux session (remote-aware). """
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
session_id = args . get ( " session_id " , " " )
if not session_id :
return { " error " : " session_id is required " , " exit_code " : 1 }
return await _cookbook_kill_session (
session_id ,
remote_host = args . get ( " remote_host " ) or args . get ( " host " ) or " " ,
ssh_port = args . get ( " ssh_port " ) or " " ,
verb = " Stopped server " ,
)
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
async def do_tail_serve_output ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Capture the last N lines of a cookbook task ' s tmux pane — remote-aware.
Used by the agent to debug a failed / stuck serve : list_served_models tells
you the task is ` crashed ` , this tool returns the actual stderr / traceback
so the agent can match it against a known fix ( compute_89 nvcc mismatch ,
flashinfer version mismatch , OOM , missing kernels , etc . ) and decide
whether to relaunch via serve_model with new flags .
"""
import httpx
import shlex
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
session_id = ( args . get ( " session_id " ) or " " ) . strip ( )
if not session_id :
return { " error " : " session_id is required (from list_served_models) " , " exit_code " : 1 }
import re as _re
if not _re . fullmatch ( r " [a-zA-Z0-9_-]+ " , session_id ) :
return { " error " : " Invalid session_id format " , " exit_code " : 1 }
try :
tail = int ( args . get ( " tail " ) or 400 )
except ( TypeError , ValueError ) :
tail = 400
tail = max ( 20 , min ( tail , 4000 ) )
headers = _internal_headers ( )
remote = ( args . get ( " remote_host " ) or args . get ( " host " ) or " " ) . strip ( )
sport = ( args . get ( " ssh_port " ) or " " ) . strip ( )
# Resolve host from cookbook state if caller didn't pass one — same
# lookup _cookbook_kill_session uses.
if not remote :
state : Dict [ str , Any ] = { }
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " , headers = headers )
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
state = resp . json ( ) or { }
except Exception as e :
logger . debug ( f " cookbook state lookup failed for { session_id } : { e } " )
if isinstance ( state , dict ) :
for t in ( state . get ( " tasks " ) or [ ] ) :
if isinstance ( t , dict ) and ( t . get ( " sessionId " ) == session_id or t . get ( " id " ) == session_id ) :
remote = t . get ( " remoteHost " ) or " "
if not sport :
sport = t . get ( " sshPort " ) or " "
break
# Prefer the persisted /tmp/odysseus-tmux/SESSION.log file over the
# live tmux pane. The pane is what the user would see scrolling on
# their screen — including the post-crash neofetch banner and the
# idle bash prompt that overwrites the actual traceback the moment
# vllm exits. The log file is the raw stdout/stderr of the wrapped
# process and survives the crash unchanged. We only fall back to
# the pane when the log file doesn't exist (older sessions launched
# before the tmux+tee wrapper was added).
log_path = f " /tmp/odysseus-tmux/ { session_id } .log "
pane_inner = f " tmux capture-pane -t { shlex . quote ( session_id ) } -p -S - { tail } 2>/dev/null "
file_inner = f " tail -n { tail } { shlex . quote ( log_path ) } 2>/dev/null "
inner = (
f " if [ -s { shlex . quote ( log_path ) } ]; then { file_inner } ; "
f " else { pane_inner } ; fi "
)
if remote :
_pf = f " -p { shlex . quote ( str ( sport ) ) } " if sport and str ( sport ) != " 22 " else " "
cmd = (
f " ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
f " { _pf } { shlex . quote ( remote ) } { shlex . quote ( inner ) } "
)
host_label = remote
else :
cmd = inner
host_label = " local "
try :
async with httpx . AsyncClient ( timeout = 20 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . post ( f " { _INTERNAL_BASE } /api/shell/exec " ,
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
json = { " command " : cmd } , headers = headers )
if resp . status_code > = 400 :
return { " error " : f " shell/exec returned HTTP { resp . status_code } : { resp . text [ : 200 ] } " , " exit_code " : 1 }
data = resp . json ( ) if resp . content else { }
output_text = ( data . get ( " stdout " ) or " " ) . strip ( )
stderr_text = ( data . get ( " stderr " ) or " " ) . strip ( )
rc = data . get ( " exit_code " )
if rc not in ( None , 0 ) and not output_text :
already_gone = any ( s in ( stderr_text or " " ) . lower ( ) for s in ( " no server running " , " can ' t find session " , " session not found " ) )
if already_gone :
return { " output " : f " Tmux session { session_id } on { host_label } is gone (task already exited). " , " exit_code " : 0 , " session_id " : session_id , " host " : host_label }
return { " error " : f " capture-pane failed on { host_label } : { stderr_text or f ' exit { rc } ' } " , " exit_code " : 1 }
# Dedupe download-progress noise. A 100-shard HF download produces
# tens of thousands of `model-NN-of-MM.safetensors: 91%|...` lines
# that all look the same to the agent and drown the actual error.
# Keep only one sample per (file, decile-percent) bucket.
import re as _re2
lines = output_text . splitlines ( )
dedup_lines = [ ]
seen_progress = set ( )
progress_re = _re2 . compile ( r " ^([ \ w./ \ -]+): \ s+( \ d+) % " )
for ln in lines :
m = progress_re . match ( ln . strip ( ) )
if m :
key = ( m . group ( 1 ) , int ( m . group ( 2 ) ) / / 10 ) # bucket by 10%
if key in seen_progress :
continue
seen_progress . add ( key )
dedup_lines . append ( ln )
output_text = " \n " . join ( dedup_lines )
# Hard cap so the agent doesn't blow its token budget.
MAX_CHARS = 8000
if len ( output_text ) > MAX_CHARS :
output_text = " …(earlier output truncated)… \n " + output_text [ - MAX_CHARS : ]
return {
" output " : output_text or " (empty pane) " ,
" session_id " : session_id ,
" host " : host_label ,
" tail_lines " : tail ,
" exit_code " : 0 ,
}
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
2026-05-31 23:58:26 +09:00
async def do_list_downloads ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" List in-flight model downloads (filters /api/cookbook/tasks/status to type=download). """
import httpx
try :
async with httpx . AsyncClient ( timeout = 15 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/tasks/status " ,
2026-05-31 23:58:26 +09:00
headers = _internal_headers ( ) )
data = resp . json ( )
tasks = [ t for t in data . get ( " tasks " , [ ] ) if ( t . get ( " type " ) or " " ) . lower ( ) == " download " ]
if not tasks :
return { " output " : " No downloads in progress. " , " exit_code " : 0 }
lines = [ f " { len ( tasks ) } download(s) in progress: " ]
for t in tasks :
phase = t . get ( " phase " ) or t . get ( " status " , " unknown " )
model = t . get ( " model " , " ? " )
pct = t . get ( " progress_percent " ) or t . get ( " percent " )
pct_str = f " { pct } % " if pct is not None else " "
lines . append ( f " - { model } : { phase } { pct_str } ( { t . get ( ' remote ' , ' local ' ) } , session: { t . get ( ' session_id ' , ' ? ' ) } ) " )
return { " output " : " \n " . join ( lines ) , " downloads " : tasks , " exit_code " : 0 }
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
async def do_cancel_download ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Cancel a model download by killing its tmux session (remote-aware). """
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
session_id = args . get ( " session_id " , " " )
if not session_id :
return { " error " : " session_id is required (from list_downloads) " , " exit_code " : 1 }
return await _cookbook_kill_session (
session_id ,
remote_host = args . get ( " remote_host " ) or args . get ( " host " ) or " " ,
ssh_port = args . get ( " ssh_port " ) or " " ,
verb = " Cancelled download " ,
)
async def do_search_hf_models ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Search HuggingFace via the cookbook /api/cookbook/hf-latest endpoint. """
import httpx
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
query = args . get ( " query " , " " ) or args . get ( " search " , " " )
limit = args . get ( " limit " , 10 )
params : Dict [ str , str ] = { }
if query :
params [ " search " ] = query
if limit :
params [ " limit " ] = str ( limit )
try :
async with httpx . AsyncClient ( timeout = 30 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/hf-latest " ,
2026-05-31 23:58:26 +09:00
params = params , headers = _internal_headers ( ) )
data = resp . json ( )
models = data . get ( " models " ) if isinstance ( data , dict ) else data
if not models :
return { " output " : f " No models found for query: { query !r} " , " exit_code " : 0 }
lines = [ f " Found { len ( models ) } model(s) for { query !r} : " if query else f " { len ( models ) } model(s): " ]
for m in models [ : limit if isinstance ( limit , int ) else 10 ] :
if isinstance ( m , dict ) :
name = m . get ( " repo_id " ) or m . get ( " modelId " ) or m . get ( " id " ) or " ? "
dl = m . get ( " downloads " )
size = m . get ( " size_gb " ) or m . get ( " needed_vram_gb " )
bits = [ ]
if size :
bits . append ( f " ~ { size } GB " )
if dl :
bits . append ( f " { dl } downloads " )
tail = f " ( { ' , ' . join ( bits ) } ) " if bits else " "
lines . append ( f " - { name } { tail } " )
else :
lines . append ( f " - { m } " )
return { " output " : " \n " . join ( lines ) , " models " : models , " exit_code " : 0 }
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
async def do_adopt_served_model ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Register an externally-launched model server (bash + tmux + ssh, or
anything else ) into the Cookbook so it appears in list_served_models ,
can be stopped via stop_served_model , and is added to the user ' s
endpoint list for chat . Use this when a model was started outside
the cookbook ' s serve flow but you want first-class tracking.
Args ( JSON ) :
host : " user@192.0.2.10 " ( or omit for localhost )
tmux_session : " minimax-m27 " ( existing tmux session name )
model : " cyankiwi/MiniMax-M2.7-AWQ-4bit " ( HF repo or display name )
port : 8000
name : optional display name ( defaults to model basename )
add_endpoint : bool ( default true ) — also register as a chat endpoint
"""
import httpx
import shlex
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
host = ( args . get ( " host " ) or args . get ( " remote_host " ) or " " ) . strip ( )
sess = ( args . get ( " tmux_session " ) or args . get ( " session_id " ) or " " ) . strip ( )
model = ( args . get ( " model " ) or args . get ( " repo_id " ) or " " ) . strip ( )
port = args . get ( " port " ) or 8000
display_name = ( args . get ( " name " ) or " " ) . strip ( ) or ( model . split ( " / " ) [ - 1 ] if " / " in model else model )
add_endpoint = args . get ( " add_endpoint " , True )
if not sess or not model :
return { " error " : " tmux_session and model are required " , " exit_code " : 1 }
# Verify tmux session exists on the target host
headers = _internal_headers ( )
if host :
check = f " ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no { shlex . quote ( host ) } ' tmux has-session -t { shlex . quote ( sess ) } 2>&1 ' "
else :
check = f " tmux has-session -t { shlex . quote ( sess ) } 2>&1 "
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
r = await client . post ( f " { _INTERNAL_BASE } /api/shell/exec " ,
2026-05-31 23:58:26 +09:00
json = { " command " : check } , headers = headers )
data = r . json ( ) if r . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else { }
if r . status_code > = 400 or ( data . get ( " exit_code " ) not in ( None , 0 ) ) :
err = ( data . get ( " stderr " ) or data . get ( " error " ) or r . text [ : 200 ] ) . strip ( )
return { " error " : f " tmux session { sess !r} not found on { host or ' local ' } : { err } " , " exit_code " : 1 }
except Exception as e :
return { " error " : f " verify failed: { e } " , " exit_code " : 1 }
# Best-effort health check — does port respond to /v1/models?
if host :
health_cmd = f " ssh -o ConnectTimeout=5 { shlex . quote ( host ) } ' curl -s -m 3 http://localhost: { int ( port ) } /v1/models ' "
else :
health_cmd = f " curl -s -m 3 http://localhost: { int ( port ) } /v1/models "
server_up = False
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
r = await client . post ( f " { _INTERNAL_BASE } /api/shell/exec " ,
2026-05-31 23:58:26 +09:00
json = { " command " : health_cmd } , headers = headers )
body = ( r . json ( ) or { } ) . get ( " stdout " , " " ) if r . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else " "
server_up = ' " data " ' in body or ' " object " ' in body
except Exception :
pass
# Read+modify+write cookbook state. APPEND a task entry; do NOT
# overwrite the whole file (that'd nuke presets).
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
r = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " , headers = headers )
2026-05-31 23:58:26 +09:00
state = r . json ( ) if r . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else { }
except Exception as e :
return { " error " : f " could not read cookbook state: { e } " , " exit_code " : 1 }
if not isinstance ( state , dict ) :
state = { }
tasks = state . get ( " tasks " ) if isinstance ( state . get ( " tasks " ) , list ) else [ ]
# Skip duplicate adopt of the same session
if any ( isinstance ( t , dict ) and t . get ( " sessionId " ) == sess for t in tasks ) :
adopted_already = True
else :
adopted_already = False
import time as _time
new_task = {
" id " : sess ,
" sessionId " : sess ,
" name " : display_name ,
" type " : " serve " ,
" status " : " running " ,
" output " : (
f " Adopted externally-launched session { sess !r} on { host or ' local ' } . \n "
" Reconnect polling will start streaming tmux output shortly. "
) ,
" ts " : int ( _time . time ( ) * 1000 ) ,
" payload " : { " repo_id " : model , " remote_host " : host or " " , " _cmd " : " (adopted — launched outside cookbook) " } ,
" remoteHost " : host or " " ,
" sshPort " : " " ,
" platform " : " linux " ,
" _serveReady " : bool ( server_up ) ,
" _endpointAdded " : False ,
" _adoptedExternally " : True ,
}
tasks . append ( new_task )
state [ " tasks " ] = tasks
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
await client . post ( f " { _INTERNAL_BASE } /api/cookbook/state " ,
2026-05-31 23:58:26 +09:00
json = state , headers = headers )
except Exception as e :
return { " error " : f " could not save cookbook state: { e } " , " exit_code " : 1 }
# Optionally register as a chat endpoint
endpoint_msg = " "
if add_endpoint :
# Resolve host to a URL. SSH form `user@host` → just take host.
host_only = host . split ( " @ " , 1 ) [ - 1 ] if host else " localhost "
endpoint_url = f " http:// { host_only } : { int ( port ) } /v1 "
try :
from src . tool_implementations import do_manage_endpoints # avoid forward ref issues
except Exception :
do_manage_endpoints = None
if do_manage_endpoints is not None :
try :
ep_result = await do_manage_endpoints ( json . dumps ( {
" action " : " add " ,
" name " : display_name ,
" endpoint_url " : endpoint_url ,
" is_local " : False ,
} ) , owner = owner )
if isinstance ( ep_result , dict ) and not ep_result . get ( " error " ) :
endpoint_msg = f " Endpoint { endpoint_url } added as { display_name !r} . "
else :
endpoint_msg = f " Endpoint registration skipped: { ( ep_result or { } ) . get ( ' error ' , ' unknown ' ) } "
except Exception as e :
endpoint_msg = f " Endpoint registration failed: { e } "
return {
" output " : (
f " Adopted session { sess !r} ( { model } ) on { host or ' local ' } : { port } . "
+ ( " Already tracked — skipped state write. " if adopted_already else " Added to cookbook state. " )
+ ( " Server responding. " if server_up else " Server not responding yet (still loading?). " )
+ endpoint_msg
) . strip ( ) ,
" session_id " : sess ,
" host " : host ,
" port " : int ( port ) ,
" server_up " : server_up ,
" exit_code " : 0 ,
}
async def do_list_cookbook_servers ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" List the cookbook ' s configured servers and which one is the
current default . Use this to decide where to download / serve a
model , or to show the user options when the target host is
ambiguous . """
servers = await _cookbook_servers ( )
hosts = servers . get ( " hosts " ) or [ ]
default = servers . get ( " default_host " ) or " "
if not hosts :
return { " output " : " No cookbook servers configured. Downloads/serves default to localhost. " , " servers " : [ ] , " default_host " : " " , " exit_code " : 0 }
# Resolve which server is the default by its friendly name too.
default_name = next ( ( h . get ( " name " ) for h in hosts if h . get ( " host " ) == default and h . get ( " name " ) ) , default or " local " )
lines = [ f " { len ( hosts ) } configured server(s) (default: { default_name } ): " ]
for h in hosts :
name = h . get ( " name " ) or " (unnamed) "
host = h . get ( " host " ) or " local "
mark = " ← default " if h . get ( " host " ) == default else " "
env_bit = f " [ { h . get ( ' env ' ) } : { h . get ( ' envPath ' ) } ] " if h . get ( " env " ) and h . get ( " env " ) != " none " else " "
plat = f " ( { h . get ( ' platform ' ) } ) " if h . get ( " platform " ) else " "
lines . append ( f " - { name } → { host } { plat } { env_bit } { mark } " )
lines . append ( " \n Refer to servers by their name (e.g. download_model with host= \" gpu-box \" ). " )
return { " output " : " \n " . join ( lines ) , " servers " : hosts , " default_host " : default , " exit_code " : 0 }
async def do_list_serve_presets ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" List saved serve presets from cookbook_state.json. Each preset
is a launch template : name , model , host , port , cmd . Use this to
discover what the user has previously configured so you can
launch by preset instead of fabricating tmux commands . """
import httpx
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " ,
2026-05-31 23:58:26 +09:00
headers = _internal_headers ( ) )
state = resp . json ( ) or { }
except Exception as e :
return { " error " : f " Failed to fetch cookbook state: { e } " , " exit_code " : 1 }
presets = state . get ( " presets " ) or [ ]
if not presets :
return {
" output " : " No serve presets saved. Tell the user to save one from the Cookbook UI first, or use serve_model with explicit repo_id + cmd + host. " ,
" presets " : [ ] ,
" exit_code " : 0 ,
}
lines = [ f " { len ( presets ) } saved serve preset(s): " ]
for p in presets :
if not isinstance ( p , dict ) :
continue
name = p . get ( " name " , " ? " )
model = p . get ( " model " ) or p . get ( " modelId " ) or " ? "
host = p . get ( " host " ) or p . get ( " remoteHost " ) or " local "
port = p . get ( " port " , " " )
cmd = ( p . get ( " cmd " ) or " " ) . strip ( )
bits = [ f " - { name } : { model } " , f " host= { host } " ]
if port :
bits . append ( f " port= { port } " )
lines . append ( " " . join ( bits ) )
if cmd :
cmd_preview = cmd if len ( cmd ) < 140 else cmd [ : 140 ] + " … "
lines . append ( f " cmd: { cmd_preview } " )
return { " output " : " \n " . join ( lines ) , " presets " : presets , " exit_code " : 0 }
async def do_serve_preset ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Launch a saved serve preset by name. Resolves the preset ' s
cmd + host + model from cookbook_state . json , then calls the
standard model / serve endpoint . Saves the agent from having to
reinvent tmux launch commands the user already saved . """
import httpx
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
name = ( args . get ( " name " ) or args . get ( " preset " ) or " " ) . strip ( )
if not name :
return { " error " : " name (preset name) is required. Call list_serve_presets to see what ' s available. " , " exit_code " : 1 }
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " ,
2026-05-31 23:58:26 +09:00
headers = _internal_headers ( ) )
state = resp . json ( ) or { }
except Exception as e :
return { " error " : f " Failed to fetch cookbook state: { e } " , " exit_code " : 1 }
presets = state . get ( " presets " ) or [ ]
# Match by exact name first, then case-insensitive substring.
chosen = None
lname = name . lower ( )
for p in presets :
if isinstance ( p , dict ) and ( p . get ( " name " ) or " " ) . lower ( ) == lname :
chosen = p
break
if chosen is None :
for p in presets :
if isinstance ( p , dict ) and lname in ( p . get ( " name " ) or " " ) . lower ( ) :
chosen = p
break
if chosen is None :
sample = " , " . join ( ( p . get ( " name " ) or " ? " ) for p in presets [ : 8 ] if isinstance ( p , dict ) )
return { " error " : f " No preset matching { name !r} . Available: { sample or ' (none) ' } " , " exit_code " : 1 }
repo_id = chosen . get ( " model " ) or chosen . get ( " modelId " ) or " "
cmd = ( chosen . get ( " cmd " ) or " " ) . strip ( )
host = chosen . get ( " host " ) or chosen . get ( " remoteHost " ) or " "
if not repo_id or not cmd :
return { " error " : f " Preset { chosen . get ( ' name ' ) !r} is missing model or cmd — can ' t launch. " , " exit_code " : 1 }
payload : Dict [ str , Any ] = { " repo_id " : repo_id , " cmd " : cmd }
if host :
payload [ " remote_host " ] = host
# Resolve per-host env settings the same way the UI does — pulls
# env_prefix (source ~/vllm-env/bin/activate), gpus, hf_token,
# etc. from cookbook_state.env so launches actually find vllm.
env_cfg = await _cookbook_env_for_host ( host )
if env_cfg . get ( " env_prefix " ) : payload [ " env_prefix " ] = env_cfg [ " env_prefix " ]
if env_cfg . get ( " gpus " ) : payload [ " gpus " ] = env_cfg [ " gpus " ]
if env_cfg . get ( " hf_token " ) : payload [ " hf_token " ] = env_cfg [ " hf_token " ]
if env_cfg . get ( " platform " ) : payload [ " platform " ] = env_cfg [ " platform " ]
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
if env_cfg . get ( " ssh_port " ) :
payload [ " ssh_port " ] = env_cfg [ " ssh_port " ]
2026-05-31 23:58:26 +09:00
try :
async with httpx . AsyncClient ( timeout = 30 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . post ( f " { _INTERNAL_BASE } /api/model/serve " ,
2026-05-31 23:58:26 +09:00
json = payload , headers = _internal_headers ( ) )
data = resp . json ( )
if data . get ( " ok " ) :
sid = data . get ( " session_id " , " ? " )
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
endpoint_id = data . get ( " endpoint_id " ) or " "
if endpoint_id :
endpoint_added = True
else :
endpoint_meta = await _ensure_served_endpoint ( model = repo_id , cmd = cmd , host = host )
endpoint_added = bool ( endpoint_meta . get ( " added " ) )
endpoint_id = endpoint_meta . get ( " endpoint_id " , " " ) or endpoint_id
2026-05-31 23:58:26 +09:00
registered = await _cookbook_register_task (
session_id = sid , model = repo_id , host = host ,
cmd = cmd , task_type = " serve " ,
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
endpoint_added = endpoint_added , endpoint_id = endpoint_id or " " ,
2026-05-31 23:58:26 +09:00
)
note = " " if registered else " (state-write failed — task may not show in UI) "
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.
Highlights:
- Ollama as a first-class backend in the Cookbook:
* Download input accepts ollama-style names (name:tag) → backend=ollama
* /api/cookbook/ollama/library (cached scrape of ollama.com + curated
fallback so classic models like qwen2.5 stay reachable)
* "Browse Ollama library" toggle below Download with size chips
* Engine=Ollama in hwfit toolbar merges the Ollama library into the
main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
pulled up 8px to align with the row's checkboxes, GGUF File dropdown
exposed for Ollama backend, GPU re-render on Edit serve restore,
_forceBackend flag so saved serveState wins over backend detection,
cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
Clear-offline buttons in Added Models toolbar, offline-pill removed
(opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
reachable (vLLM returns 404 on bare /v1, fully working endpoints
were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
return { " output " : f " Launched preset { chosen . get ( ' name ' ) !r} : { repo_id } on { host or ' local ' } (session: { sid } ) { note } " , " session_id " : sid , " host " : host , " endpoint_id " : endpoint_id , " exit_code " : 0 }
2026-05-31 23:58:26 +09:00
return { " error " : data . get ( " error " , " Serve failed " ) , " exit_code " : 1 }
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
async def do_list_cached_models ( content : str , owner : Optional [ str ] = None ) - > Dict :
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
""" List models already cached locally and/or on remote hosts.
With no ` host ` arg , scans EVERY configured Cookbook server ( and local )
and aggregates — so the agent sees the full inventory in one call
instead of having to query each server individually .
"""
2026-05-31 23:58:26 +09:00
import httpx
try :
args = _parse_tool_args ( content ) if content . strip ( ) else { }
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
raw_host = ( args . get ( " host " ) or " " ) . strip ( )
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
headers = _internal_headers ( )
async def _scan_one ( host_label : str , host_val : str , ssh_port : str = " " ,
platform : str = " " , model_dir : str = " " ) - > list :
""" Hit /api/model/cached for one host; tag each returned model with its source. """
p : Dict [ str , str ] = { }
if host_val :
p [ " host " ] = host_val
# Caller-provided override beats per-server config beats nothing.
if args . get ( " model_dir " ) :
p [ " model_dir " ] = args [ " model_dir " ]
elif model_dir :
p [ " model_dir " ] = model_dir
if ssh_port :
p [ " ssh_port " ] = ssh_port
elif args . get ( " ssh_port " ) :
p [ " ssh_port " ] = str ( args [ " ssh_port " ] )
if platform :
p [ " platform " ] = platform
elif args . get ( " platform " ) :
p [ " platform " ] = args [ " platform " ]
try :
async with httpx . AsyncClient ( timeout = 60 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/model/cached " ,
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
params = p , headers = headers )
data = resp . json ( )
ms = data . get ( " models " , [ ] ) if isinstance ( data , dict ) else ( data or [ ] )
for m in ms :
m [ " host " ] = host_label or " local "
return ms or [ ]
except Exception as e :
logger . debug ( f " list_cached_models scan( { host_label } ) failed: { e } " )
return [ ]
# When the caller specifies a host explicitly, scan only that one (old behaviour).
# Otherwise iterate every configured server + local so the agent doesn't
# have to repeat the call per server.
2026-05-31 23:58:26 +09:00
try :
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
# Pull configured servers from cookbook state (used for resolving
# modelDirs both when caller specifies a host and when we scan all).
servers : list = [ ]
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
st = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " , headers = headers )
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
st_data = st . json ( ) if st . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else { }
servers = ( st_data . get ( " env " , { } ) or { } ) . get ( " servers " ) or [ ]
except Exception as e :
logger . debug ( f " server list fetch failed: { e } " )
st_data = { }
def _dirs_for ( server_record : Dict [ str , Any ] ) - > str :
""" Comma-joined modelDirs from a saved server record (Settings).
Filters out the HF cache ( ~ / . cache / huggingface / hub ) — the backend
scan script always scans it by default , so re - passing it as an
extra model_dir is redundant AND confuses some path - handling
edge cases where the extra dir suppresses the deeper scan .
We only need to forward the NON - default dirs ( e . g . / mnt / HADES / models ) .
"""
mds = server_record . get ( " modelDirs " ) if isinstance ( server_record , dict ) else None
HF_DEFAULTS = { " ~/.cache/huggingface/hub " , " ~/.cache/huggingface " }
if isinstance ( mds , list ) :
extras = [ d for d in mds if isinstance ( d , str ) and d . strip ( ) and d . strip ( ) not in HF_DEFAULTS ]
return " , " . join ( extras )
if isinstance ( mds , str ) and mds . strip ( ) not in HF_DEFAULTS :
return mds
return " "
if raw_host :
host = await _resolve_cookbook_host ( raw_host )
# Find this host's saved record so its modelDirs apply too.
srv = next (
( s for s in servers if isinstance ( s , dict )
and ( s . get ( " name " ) == raw_host or s . get ( " host " ) == host or s . get ( " host " ) == raw_host ) ) ,
{ } ,
)
models = await _scan_one ( raw_host , host , model_dir = _dirs_for ( srv ) )
else :
# Always include local. Local's saved record is the one with no host.
local_srv = next ( ( s for s in servers if isinstance ( s , dict ) and not ( s . get ( " host " ) or " " ) . strip ( ) ) , { } )
scans : list = [ _scan_one ( " local " , " " , model_dir = _dirs_for ( local_srv ) ) ]
for s in servers :
if not isinstance ( s , dict ) :
continue
name = s . get ( " name " ) or s . get ( " host " )
host_val = s . get ( " host " ) or " "
if not host_val :
continue
scans . append ( _scan_one (
name ,
host_val ,
ssh_port = str ( s . get ( " port " ) or " " ) ,
platform = s . get ( " platform " ) or " " ,
model_dir = _dirs_for ( s ) ,
) )
results = await asyncio . gather ( * scans , return_exceptions = False )
# Dedupe by (host, repo_id) — same model could appear in both HF cache + Ollama list.
seen = set ( )
models : list = [ ]
for batch in results :
for m in batch :
key = ( m . get ( " host " , " " ) , m . get ( " repo_id " , " " ) )
if key in seen :
continue
seen . add ( key )
models . append ( m )
2026-05-31 23:58:26 +09:00
if not models :
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
# Cache scans can miss models downloaded into the HF default cache
# when the server has no explicit model_dir configured. Surface
# completed Cookbook download tasks so the agent doesn't conclude
# a model is absent and re-download it.
2026-05-31 23:58:26 +09:00
downloaded = [ ]
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
2026-06-07 23:22:09 +02:00
st = await client . get ( f " { _INTERNAL_BASE } /api/cookbook/state " , headers = headers )
2026-05-31 23:58:26 +09:00
state = st . json ( ) if st . headers . get ( " content-type " , " " ) . startswith ( " application/json " ) else { }
for t in ( state . get ( " tasks " ) or [ ] ) :
if not isinstance ( t , dict ) or t . get ( " type " ) != " download " :
continue
if ( t . get ( " status " ) or " " ) . lower ( ) not in { " done " , " completed " } :
continue
task_host = t . get ( " remoteHost " ) or ( t . get ( " payload " ) or { } ) . get ( " remote_host " ) or " "
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
if raw_host and task_host != raw_host :
2026-05-31 23:58:26 +09:00
continue
repo = t . get ( " modelId " ) or t . get ( " repoId " ) or ( t . get ( " payload " ) or { } ) . get ( " repo_id " ) or t . get ( " name " )
if repo and repo not in downloaded :
downloaded . append ( repo )
except Exception :
downloaded = [ ]
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
host_str = f " on { raw_host } " if raw_host else " "
2026-05-31 23:58:26 +09:00
if downloaded :
lines = [ f " No cache paths were detected { host_str } , but Cookbook has completed download task(s): " ]
lines . extend ( f " - { repo } — downloaded via Cookbook task " for repo in downloaded )
return { " output " : " \n " . join ( lines ) , " models " : [ { " repo_id " : repo , " source " : " cookbook_task " } for repo in downloaded ] , " exit_code " : 0 }
return { " output " : f " No cached models found { host_str } . " , " exit_code " : 0 }
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:
* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).
Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.
`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.
Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.
Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.
`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.
Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
# Multi-host scan: group by host so the agent sees inventory per server.
# Single-host scan: flat list (matches old output shape).
if raw_host :
lines = [ f " { len ( models ) } cached model(s) on { raw_host } : " ]
for m in models :
name = m . get ( " repo_id " , " ? " )
sz = m . get ( " size " ) or ( f " { m . get ( ' size_bytes ' , 0 ) / ( 1024 * * 3 ) : .1f } GB " if m . get ( " size_bytes " ) else " " )
inc = " (incomplete) " if m . get ( " has_incomplete " ) else " "
kind = " [diffusion] " if m . get ( " is_diffusion " ) else " "
lines . append ( f " - { name } { kind } — { sz } { inc } " )
else :
from collections import defaultdict as _dd
by_host = _dd ( list )
for m in models :
by_host [ m . get ( " host " , " local " ) ] . append ( m )
lines = [ f " { len ( models ) } cached model(s) across { len ( by_host ) } server(s): " ]
for host_name in sorted ( by_host . keys ( ) ) :
lines . append ( f " \n [ { host_name } ] " )
for m in by_host [ host_name ] :
name = m . get ( " repo_id " , " ? " )
sz = m . get ( " size " ) or ( f " { m . get ( ' size_bytes ' , 0 ) / ( 1024 * * 3 ) : .1f } GB " if m . get ( " size_bytes " ) else " " )
inc = " (incomplete) " if m . get ( " has_incomplete " ) else " "
kind = " [diffusion] " if m . get ( " is_diffusion " ) else " "
backend = f " ( { m . get ( ' backend ' ) } ) " if m . get ( " backend " ) else " "
lines . append ( f " - { name } { kind } { backend } — { sz } { inc } " )
2026-05-31 23:58:26 +09:00
return { " output " : " \n " . join ( lines ) , " models " : models , " exit_code " : 0 }
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
# ── Gallery tools ──
async def do_edit_image ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Edit a gallery image (upscale, rembg, inpaint, harmonize). """
import httpx
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
image_id = args . get ( " image_id " , " " )
action = args . get ( " action " , " " )
if not image_id or not action :
return { " error " : " image_id and action are required " , " exit_code " : 1 }
payload = { " image_id " : image_id }
if args . get ( " prompt " ) :
payload [ " prompt " ] = args [ " prompt " ]
if args . get ( " scale " ) :
payload [ " scale " ] = args [ " scale " ]
try :
async with httpx . AsyncClient ( timeout = 120 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . post ( f " { _INTERNAL_BASE } /api/gallery/ { action } " , json = payload )
2026-05-31 23:58:26 +09:00
data = resp . json ( )
if data . get ( " success " ) or data . get ( " id " ) :
return { " output " : f " Image edited ( { action } ). New image ID: { data . get ( ' id ' , ' ? ' ) } " , " exit_code " : 0 }
return { " error " : data . get ( " error " , f " { action } failed " ) , " exit_code " : 1 }
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
# ── Research tools ──
async def do_manage_research ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" List, read/open, or delete saved deep-research results from the Library.
Args ( JSON ) : { " action " : " list|read|delete " , " id " : " <id> " , " search " : " ... " } .
Research is stored as data / deep_research / < id > . json ( query , summary , sources ) . """
import json as _json
from pathlib import Path as _Path
try :
args = _parse_tool_args ( content ) if content . strip ( ) . startswith ( " { " ) else { }
except ValueError :
args = { }
if not isinstance ( args , dict ) :
args = { }
action = ( args . get ( " action " ) or " list " ) . lower ( )
rid = ( args . get ( " id " ) or args . get ( " session_id " ) or args . get ( " research_id " ) or " " ) . strip ( )
2026-06-08 09:58:52 +02:00
data_dir = _Path ( DEEP_RESEARCH_DIR )
2026-05-31 23:58:26 +09:00
# SECURITY: the research id is interpolated straight into a filesystem
# path (data/deep_research/<rid>.json) for read AND delete. Without this
# gate an agent-supplied id like "../settings" or "../../etc/passwd"
# escapes the research dir — reading exfiltrates arbitrary *.json into
# chat, deleting unlinks arbitrary *.json on disk. Allow only a bare
# token (research session ids are hex/uuid/slug — no separators).
if rid and not re . fullmatch ( r " [A-Za-z0-9_-]+ " , rid ) :
return { " error " : " Invalid research id. " }
def _load ( p ) :
try :
2026-06-01 15:09:47 +09:00
return _json . loads ( p . read_text ( encoding = " utf-8 " ) )
2026-05-31 23:58:26 +09:00
except Exception :
return None
if action in ( " read " , " open " , " view " , " get " ) :
if not rid :
return { " error " : " Provide the research id (from action= ' list ' ). " }
p = data_dir / f " { rid } .json "
if not p . exists ( ) :
return { " error " : f " Research ' { rid } ' not found. " }
d = _load ( p ) or { }
summary = d . get ( " result " ) or d . get ( " raw_report " ) or d . get ( " summary " ) or d . get ( " report " ) or " (no report body) "
srcs = d . get ( " sources " , [ ] ) or [ ]
out = f " # { d . get ( ' query ' , ' (untitled) ' ) } \n \n { summary } "
if srcs :
out + = " \n \n Sources: \n " + " \n " . join (
f " - { s . get ( ' title ' ) or s . get ( ' url ' , ' ' ) } : { s . get ( ' url ' , ' ' ) } " for s in srcs [ : 30 ]
)
return { " output " : out [ : 16000 ] , " exit_code " : 0 }
if action == " delete " :
if not rid :
return { " error " : " Provide the research id to delete (from action= ' list ' ). " }
p = data_dir / f " { rid } .json "
if p . exists ( ) :
try :
p . unlink ( )
except Exception as e :
return { " error " : f " Failed to delete: { e } " }
return { " output " : f " Deleted research ' { rid } ' . " , " exit_code " : 0 }
return { " error " : f " Research ' { rid } ' not found. " }
# default: list — clickable [query](#research-<id>) rows, most-recent first
search = ( args . get ( " search " ) or " " ) . lower ( )
items = [ ]
if data_dir . exists ( ) :
for p in data_dir . glob ( " *.json " ) :
d = _load ( p )
if not d :
continue
q = d . get ( " query " , " " )
if search and search not in q . lower ( ) :
continue
items . append ( ( d . get ( " completed_at " , 0 ) or 0 , p . stem , q , len ( d . get ( " sources " , [ ] ) or [ ] ) ) )
items . sort ( reverse = True )
if not items :
return { " output " : " No research found in the library. " + ( f " (search: { search } ) " if search else " " ) , " exit_code " : 0 }
rows = " \n " . join ( f " - [ { q or ' (untitled) ' } ](#research- { sid } ) — { n } sources " for _ , sid , q , n in items [ : 50 ] )
return { " output " : f " Research library ( { len ( items ) } item { ' s ' if len ( items ) != 1 else ' ' } ): \n { rows } " , " exit_code " : 0 }
async def do_trigger_research ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Start a live deep-research job that appears in the Deep Research
sidebar . Hits / api / research / start ( the same path the sidebar ' s
' Research ' button uses ) so the session is discoverable + streamable
there , rather than creating a scheduled task that never surfaces . """
import httpx
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
topic = args . get ( " topic " , " " ) or args . get ( " query " , " " )
if not topic :
return { " error " : " topic (or query) is required " , " exit_code " : 1 }
payload : Dict [ str , Any ] = { " query " : topic }
# Optional knobs the research panel supports.
if args . get ( " max_rounds " ) is not None :
try : payload [ " max_rounds " ] = int ( args [ " max_rounds " ] )
except ( ValueError , TypeError ) : pass
if args . get ( " max_time " ) is not None :
try : payload [ " max_time " ] = int ( args [ " max_time " ] )
except ( ValueError , TypeError ) : pass
if args . get ( " category " ) :
payload [ " category " ] = args [ " category " ]
if args . get ( " search_provider " ) :
payload [ " search_provider " ] = args [ " search_provider " ]
try :
async with httpx . AsyncClient ( timeout = 30 ) as client :
2026-06-07 23:22:09 +02:00
resp = await client . post ( f " { _INTERNAL_BASE } /api/research/start " ,
2026-05-31 23:58:26 +09:00
json = payload , headers = _internal_headers ( owner ) )
if resp . status_code > = 400 :
return { " error " : f " research/start returned HTTP { resp . status_code } : { resp . text [ : 200 ] } " , " exit_code " : 1 }
data = resp . json ( )
sid = data . get ( " session_id " , " ? " )
return {
" output " : (
f " Deep research started: [ { topic } ](#research- { sid } ). "
" Click to open the Deep Research sidebar and watch progress / read the report. "
) ,
" session_id " : sid ,
" anchor " : f " [ { topic } ](#research- { sid } ) " ,
# UI hint so the frontend can open/refresh the research panel.
" ui_event " : " research_started " ,
" research_session_id " : sid ,
" exit_code " : 0 ,
}
except Exception as e :
return { " error " : str ( e ) , " exit_code " : 1 }
# ── Contact tools ──
async def do_resolve_contact ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Look up a contact by name. Searches: CardDAV -> email history -> memory. """
import httpx
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
name = args . get ( " name " , " " )
if not name :
return { " error " : " name is required " , " exit_code " : 1 }
contacts = { } # email -> {name, source}
# 1. CardDAV (Radicale) — structured contacts. Call in-process: a
# server-side httpx GET to /api/contacts/search carries no session
# cookie and would 401 under require_user.
try :
import asyncio
from routes import contacts_routes as cc
all_contacts = await asyncio . to_thread ( cc . _fetch_contacts )
q = name . lower ( )
for c in ( all_contacts or [ ] ) :
hay_name = ( c . get ( " name " ) or " " ) . lower ( )
match = q in hay_name or any ( q in ( e or " " ) . lower ( ) for e in c . get ( " emails " , [ ] ) )
if not match :
continue
for email in ( c . get ( " emails " ) or [ ] ) :
email = ( email or " " ) . strip ( ) . lower ( )
if email and " @ " in email :
contacts [ email ] = { " name " : c . get ( " name " ) or email , " source " : " contacts " }
except Exception :
pass
async with httpx . AsyncClient ( timeout = 30 ) as client :
# 2. Email history (sent/received)
try :
2026-06-07 23:22:09 +02:00
resp = await client . get ( f " { _INTERNAL_BASE } /api/email/resolve-contact " , params = { " name " : name } )
2026-05-31 23:58:26 +09:00
if resp . status_code == 200 :
for c in ( resp . json ( ) . get ( " contacts " ) or [ ] ) :
email = ( c . get ( " email " ) or " " ) . strip ( ) . lower ( )
if email and email not in contacts :
contacts [ email ] = { " name " : c . get ( " name " ) or email , " source " : " email history " }
except Exception :
pass
if not contacts :
return { " output " : f " No contacts found matching ' { name } ' . " , " exit_code " : 0 }
lines = [ f " Contacts matching ' { name } ' : " ]
for email , info in contacts . items ( ) :
lines . append ( f " - { info [ ' name ' ] } < { email } > ( { info [ ' source ' ] } ) " )
return { " output " : " \n " . join ( lines ) , " exit_code " : 0 }
async def do_manage_contact ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Add / update / delete / list CardDAV contacts. Calls the contacts
helpers IN - PROCESS rather than over HTTP — a server - side httpx call to
/ api / contacts / * carries no session cookie and would be rejected by
require_user ( 401 ) , so the tool would see zero contacts even though
the browser - side UI works fine . """
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = ( args . get ( " action " ) or " " ) . strip ( ) . lower ( )
try :
from routes import contacts_routes as cc
except Exception as e :
return { " error " : f " Contacts module unavailable: { e } " , " exit_code " : 1 }
# The contacts helpers are sync (httpx blocking calls to CardDAV) — run
# them in a thread so we don't block the event loop.
import asyncio
try :
if action == " list " :
rows = await asyncio . to_thread ( cc . _fetch_contacts , True )
if not rows :
return { " output " : " No contacts. " , " exit_code " : 0 }
lines = [ f " { len ( rows ) } contacts: " ]
for c in rows :
em = " , " . join ( c . get ( " emails " ) or [ ] )
lines . append ( f " - { c . get ( ' name ' ) or ' (no name) ' } < { em } > [uid= { c . get ( ' uid ' , ' ' ) } ] " )
return { " output " : " \n " . join ( lines ) , " exit_code " : 0 }
if action == " add " :
email = ( args . get ( " email " ) or " " ) . strip ( )
if not email :
return { " error " : " email is required for add " , " exit_code " : 1 }
name = ( args . get ( " name " ) or " " ) . strip ( ) or email . split ( " @ " ) [ 0 ]
# Dedupe by email (same as the /add route).
existing = await asyncio . to_thread ( cc . _fetch_contacts )
for c in existing :
if email . lower ( ) in [ e . lower ( ) for e in c . get ( " emails " , [ ] ) ] :
return { " output " : f " { email } is already a contact ( { c . get ( ' name ' , ' ' ) } ). " , " exit_code " : 0 }
ok = await asyncio . to_thread ( cc . _create_contact , name , email )
return { " output " : f " { ' Added ' if ok else ' Failed to add ' } { name } < { email } >. " , " exit_code " : 0 if ok else 1 }
if action in ( " update " , " edit " ) :
uid = ( args . get ( " uid " ) or " " ) . strip ( )
if not uid :
return { " error " : " uid is required for update (use action=list to find it) " , " exit_code " : 1 }
name = ( args . get ( " name " ) or " " ) . strip ( )
emails = args . get ( " emails " )
if emails is None and args . get ( " email " ) :
emails = [ args [ " email " ] ]
emails = [ e . strip ( ) for e in ( emails or [ ] ) if e and e . strip ( ) ]
phones = [ p . strip ( ) for p in ( args . get ( " phones " ) or [ ] ) if p and p . strip ( ) ]
if not name and not emails :
return { " error " : " Provide a name or emails to update " , " exit_code " : 1 }
if not name and emails :
name = emails [ 0 ] . split ( " @ " ) [ 0 ]
ok = await asyncio . to_thread ( cc . _update_contact , uid , name , emails , phones )
return { " output " : " Contact updated. " if ok else " Update failed. " , " exit_code " : 0 if ok else 1 }
if action == " delete " :
uid = ( args . get ( " uid " ) or " " ) . strip ( )
if not uid :
return { " error " : " uid is required for delete (use action=list to find it) " , " exit_code " : 1 }
ok = await asyncio . to_thread ( cc . _delete_contact , uid )
return { " output " : " Contact deleted. " if ok else " Delete failed. " , " exit_code " : 0 if ok else 1 }
return { " error " : f " Unknown action ' { action } ' . Use list, add, update, or delete. " , " exit_code " : 1 }
except Exception as e :
return { " error " : f " Contact operation failed: { e } " , " exit_code " : 1 }
# ── Vaultwarden / Bitwarden CLI tools ──
def _load_vault_config ( ) - > Dict :
""" Load Vaultwarden config from data/vault.json. """
from pathlib import Path
2026-06-08 09:58:52 +02:00
p = Path ( VAULT_FILE )
2026-05-31 23:58:26 +09:00
if p . exists ( ) :
try :
2026-06-01 15:09:47 +09:00
return json . loads ( p . read_text ( encoding = " utf-8 " ) )
2026-05-31 23:58:26 +09:00
except Exception :
pass
return { }
async def _run_bw ( args : list , session : Optional [ str ] = None , input_text : Optional [ str ] = None ) - > tuple :
""" Run a bw CLI command with optional session + stdin. Returns (stdout, stderr, returncode). """
import asyncio
env = { }
import os as _os
env . update ( _os . environ )
if session :
env [ " BW_SESSION " ] = session
proc = await asyncio . create_subprocess_exec (
" bw " , * args ,
stdin = asyncio . subprocess . PIPE if input_text else None ,
stdout = asyncio . subprocess . PIPE ,
stderr = asyncio . subprocess . PIPE ,
env = env ,
)
stdout , stderr = await proc . communicate ( input = input_text . encode ( ) if input_text else None )
return stdout . decode ( errors = " replace " ) . strip ( ) , stderr . decode ( errors = " replace " ) . strip ( ) , proc . returncode
async def do_vault_search ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Search the vault by keyword. Returns matching item names + URLs, NO passwords. """
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
query = args . get ( " query " , " " ) . strip ( )
if not query :
return { " error " : " query is required " , " exit_code " : 1 }
cfg = _load_vault_config ( )
session = cfg . get ( " session " )
if not session :
return { " error " : " Vault is locked. Run vault_unlock or provide session key in settings. " , " exit_code " : 1 }
stdout , stderr , rc = await _run_bw ( [ " list " , " items " , " --search " , query ] , session = session )
if rc != 0 :
return { " error " : f " bw failed: { stderr [ : 300 ] } " , " exit_code " : 1 }
try :
items = json . loads ( stdout )
except json . JSONDecodeError :
return { " error " : " Failed to parse bw output " , " exit_code " : 1 }
if not items :
return { " output " : f " No vault items match ' { query } ' . " , " exit_code " : 0 }
lines = [ f " Found { len ( items ) } item(s) matching ' { query } ' : " ]
for it in items [ : 20 ] :
item_id = it . get ( " id " , " ? " )
name = it . get ( " name " , " ? " )
login = it . get ( " login " ) or { }
username = login . get ( " username " , " " )
uris = login . get ( " uris " ) or [ ]
url = uris [ 0 ] . get ( " uri " , " " ) if uris else " "
parts = [ f " [ { item_id [ : 8 ] } ] { name } " ]
if username :
parts . append ( f " user: { username } " )
if url :
parts . append ( f " url: { url } " )
lines . append ( " - " + " · " . join ( parts ) )
lines . append ( " \n Use vault_get(item_id, reason) to retrieve the password. " )
return { " output " : " \n " . join ( lines ) , " exit_code " : 0 }
async def do_vault_get ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Retrieve a full vault entry (including password) by item ID. Logs access to assistant chat. """
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
item_id = args . get ( " item_id " , " " ) . strip ( )
reason = args . get ( " reason " , " " ) . strip ( )
if not item_id :
return { " error " : " item_id is required " , " exit_code " : 1 }
if not reason :
return { " error " : " reason is required — explain WHY you need this password " , " exit_code " : 1 }
cfg = _load_vault_config ( )
session = cfg . get ( " session " )
if not session :
return { " error " : " Vault is locked. Unlock first. " , " exit_code " : 1 }
stdout , stderr , rc = await _run_bw ( [ " get " , " item " , item_id ] , session = session )
if rc != 0 :
return { " error " : f " bw failed: { stderr [ : 300 ] } " , " exit_code " : 1 }
try :
item = json . loads ( stdout )
except json . JSONDecodeError :
return { " error " : " Failed to parse bw output " , " exit_code " : 1 }
login = item . get ( " login " ) or { }
name = item . get ( " name " , " ? " )
# Audit log to assistant chat
try :
from src . assistant_log import log_to_assistant
if owner :
log_to_assistant (
owner ,
f " Retrieved password for ** { name } ** — reason: { reason } " ,
category = " Vault " ,
)
except Exception :
pass
output = [
f " Vault item: { name } " ,
f " Username: { login . get ( ' username ' , ' (none) ' ) } " ,
f " Password: { login . get ( ' password ' , ' (none) ' ) } " ,
]
if login . get ( " totp " ) :
output . append ( f " TOTP secret: { login [ ' totp ' ] } " )
uris = login . get ( " uris " ) or [ ]
if uris :
output . append ( " URLs: " + " , " . join ( u . get ( " uri " , " " ) for u in uris ) )
if item . get ( " notes " ) :
output . append ( f " Notes: { item [ ' notes ' ] } " )
return { " output " : " \n " . join ( output ) , " exit_code " : 0 }
async def do_vault_unlock ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Unlock the vault using a master password. Stores the resulting session key. """
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
master_password = args . get ( " master_password " , " " )
if not master_password :
return { " error " : " master_password is required " , " exit_code " : 1 }
2026-06-02 19:13:51 +02:00
# Do not pass the master password as an argv element. Local process lists
# can expose argv to other users; stdin keeps the secret out of `ps`.
stdout , stderr , rc = await _run_bw ( [ " unlock " , " --raw " ] , input_text = master_password + " \n " )
2026-05-31 23:58:26 +09:00
if rc != 0 :
return { " error " : f " Unlock failed: { stderr [ : 300 ] } " , " exit_code " : 1 }
session = stdout . strip ( )
if not session :
return { " error " : " bw returned empty session " , " exit_code " : 1 }
# Save session to vault.json
from pathlib import Path
2026-06-08 09:58:52 +02:00
p = Path ( VAULT_FILE )
2026-05-31 23:58:26 +09:00
cfg = { }
if p . exists ( ) :
try :
2026-06-01 15:09:47 +09:00
cfg = json . loads ( p . read_text ( encoding = " utf-8 " ) )
2026-05-31 23:58:26 +09:00
except Exception :
pass
cfg [ " session " ] = session
from datetime import datetime as _dt
cfg [ " unlocked_at " ] = _dt . utcnow ( ) . isoformat ( )
2026-06-01 15:09:47 +09:00
p . write_text ( json . dumps ( cfg , indent = 2 ) , encoding = " utf-8 " )
2026-05-31 23:58:26 +09:00
try :
import os as _os
_os . chmod ( str ( p ) , 0o600 )
except Exception :
pass
return { " output " : " Vault unlocked. Session saved. " , " exit_code " : 0 }