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 .
"""
import json
import logging
import os
import re
from typing import Any , Dict , List , Optional
MAX_OUTPUT_CHARS = 10_000
MAX_READ_CHARS = 20_000
def get_mcp_manager ( ) :
from src import agent_tools
return agent_tools . get_mcp_manager ( )
def _truncate ( text : str , limit : int = MAX_OUTPUT_CHARS ) - > str :
if len ( text ) > limit :
return text [ : limit ] + f " \n ... (truncated, { len ( text ) } chars total) "
return text
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
# ---------------------------------------------------------------------------
# Active document state
# ---------------------------------------------------------------------------
_active_document_id : Optional [ str ] = None
_active_model : Optional [ str ] = None
def set_active_document ( doc_id : Optional [ str ] ) :
""" Set the active document ID for document tool execution. """
global _active_document_id
_active_document_id = doc_id
def set_active_model ( model : Optional [ str ] ) :
""" Set the current model name for version summaries. """
global _active_model
_active_model = model
def get_active_document ( ) :
return _active_document_id
fix: closed document stays active & leaks into new chats (#1160) (#1238)
* fix: closed document no longer stays active and leaks into new chats (#1160)
Closing a document tab calls _detachDocFromSession: a doc with content is
PATCHed to session_id="" (unlinked, session_id -> NULL, is_active stays True),
an empty one is DELETEd. But the in-memory active-document pointer
(tool_implementations._active_document_id) was never cleared on either path.
The chat doc-injection last-resort looks up that pointer by id and injects it
when `not cand.session_id or cand.session_id == session`. An unlinked doc has
session_id NULL, so the stale pointer re-surfaced a closed document in later,
unrelated chats — the agent kept reading/suggesting edits to a doc the user
had closed.
Fix: add clear_active_document(doc_id) and call it when a document is unlinked
(PATCH session_id="") or deleted, so the pointer no longer resurrects a closed
document. clear_active_document only clears when the id matches (or no id), so a
different active doc is left untouched.
Covered by tests/test_active_document_clear.py (4 cases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: add route-level regression for #1160 (detach/delete clears active doc)
Per review: prove the actual API path, not just the helper. Drives
PATCH /api/document/{id} (session_id="") and DELETE /api/document/{id}
through TestClient against a temp SQLite DB under real owner routing, and
asserts get_active_document() is cleared (and untouched when a different
document is closed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: make #1160 route regression hang-proof and dev-DB-independent
The route test could hang in other environments: it set DATABASE_URL at import
time, which is ignored if core.database was already imported, so it fell back to
the real dev DB and could contend for its locks (maintainer saw it hang, exit
124).
Rebind to a DEDICATED temporary SQLite engine (NullPool) and patch the document
route module's SessionLocal to it via an autouse fixture — so the test never
touches the dev DB and is independent of import order. Runs in ~0.3s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: drive #1160 route regression without TestClient (fixes local hang)
The route test used Starlette TestClient (middleware app + threadpool), which
hung in the maintainer's environment. Rework it to call the async route handlers
directly — extracted from the router — with a minimal fake request against a
temp-SQLite-patched SessionLocal. Same real coverage (handler + DB + owner
routing), but it completes reliably (~0.3s) with no TestClient/threadpool.
Verified the maintainer's exact batch now passes:
pytest tests/test_document_close_clears_active_route.py \
tests/test_active_document_clear.py \
tests/test_document_tool_owner_scope.py -> 14 passed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:47:13 +08:00
def clear_active_document ( doc_id : Optional [ str ] = None ) - > bool :
""" Clear the in-memory active-document pointer.
With ` ` doc_id ` ` given , only clears when it matches the current pointer , so a
different active document is left untouched . Returns True if it was cleared .
Called when a document is detached from its session or deleted ( its tab is
closed ) : without this , the stale pointer makes the last - resort doc - injection
path re - surface a closed document in a later , unrelated chat — even one whose
session no longer matches — because an unlinked doc has session_id NULL ( #1160).
"""
global _active_document_id
if doc_id is None or _active_document_id == doc_id :
_active_document_id = None
return True
return False
2026-06-02 02:30:02 +05:30
def _owned_document_query ( query , Document , owner : Optional [ str ] ) :
if owner is None :
2026-06-02 17:20:43 +01:00
# A bare Python `False` is not a valid SQL expression — SQLAlchemy 1.4
# deprecates it and 2.0 raises ArgumentError. Use the SQL `false()`
# literal to return zero rows for an unscoped (owner-less) query.
from sqlalchemy import false
return query . filter ( false ( ) )
2026-06-02 02:30:02 +05:30
return query . filter ( Document . owner == owner )
def _get_owned_document ( db , Document , doc_id : str , owner : Optional [ str ] , active_only : bool = False ) :
q = db . query ( Document ) . filter ( Document . id == doc_id )
if active_only :
q = q . filter ( Document . is_active == True )
q = _owned_document_query ( q , Document , owner )
return q . first ( )
def _most_recent_owned_document ( db , Document , owner : Optional [ str ] , active_only : bool = False ) :
q = db . query ( Document )
if active_only :
q = q . filter ( Document . is_active == True )
q = _owned_document_query ( q , Document , owner )
return q . order_by ( Document . updated_at . desc ( ) ) . first ( )
2026-05-31 23:58:26 +09:00
# ---------------------------------------------------------------------------
# Document tools — create/update/edit/suggest living documents
# ---------------------------------------------------------------------------
def _sniff_doc_language ( text : str ) - > str :
""" Best-effort detect a document ' s language from its content when the model
didn ' t specify one. Defaults to ' markdown ' (prose). Recognizes the common
markup / code types the editor supports so e . g . an SVG isn ' t saved as markdown. " " "
import json as _json , re as _re2
s = ( text or " " ) . strip ( )
if not s :
return " markdown "
head = s [ : 600 ]
hl = head . lower ( )
if _looks_like_email_document ( s ) :
return " email "
# Markup (unambiguous)
if " <svg " in hl :
return " svg "
if hl . startswith ( " <?xml " ) :
return " xml "
if ( hl . startswith ( " <!doctype html " ) or hl . startswith ( " <html " )
or _re2 . search ( r " <(div|body|head|p|span|table|button|h[1-6]|ul|ol|li|img) \ b " , hl ) ) :
return " html "
# JSON
if s [ 0 ] in " { [ " :
try :
_json . loads ( s )
return " json "
except Exception :
pass
# Shebang
first = s . split ( " \n " , 1 ) [ 0 ] . strip ( ) . lower ( )
if first . startswith ( " #! " ) :
return " python " if " python " in first else " bash "
# Code by strong leading signals (line-anchored so prose with stray words won't match)
if _re2 . search ( r " (?m)^ \ s*(def \ w|class \ w|import \ w|from \ w[ \ w.]* import ) " , s ) :
return " python "
if _re2 . search ( r " (?m)^ \ s*(function \ w|const \ w|let \ w|export |import .* from ) " , s ) :
return " javascript "
if _re2 . search ( r " (?mi)^ \ s*(select .* from |create table |insert into |update \ w) " , s ) :
return " sql "
if _re2 . search ( r " (?m)^[.#]?[ \ w-]+ \ s* \ { [^ {} ]*:[^ {} ]*; " , s ) :
return " css "
return " markdown "
def _looks_like_email_document ( text : str = " " , title : str = " " ) - > bool :
import re as _re
title_l = ( title or " " ) . strip ( ) . lower ( )
if title_l in { " new email " , " new mail " , " new message " } :
return True
s = ( text or " " ) . lstrip ( )
if " \n --- \n " in s and _re . search ( r " (?im)^To: \ s* " , s ) and _re . search ( r " (?im)^Subject: \ s* " , s ) :
return True
return bool ( _re . search ( r " (?im)^To: \ s* " , s ) and _re . search ( r " (?im)^Subject: \ s* " , s ) )
def _coerce_email_document_content ( existing : str , incoming : str ) - > str :
""" Keep email docs in the To/Subject/---/body shape even if a model writes
only the body or dumps header labels without the separator . """
import re as _re
old = existing or " "
new = ( incoming or " " ) . strip ( )
if " \n --- \n " in new :
return new
header = old . split ( " \n --- \n " , 1 ) [ 0 ] if " \n --- \n " in old else " To: \n Subject: "
if _looks_like_email_document ( new ) :
lines = new . splitlines ( )
last_header_idx = - 1
header_re = _re . compile ( r " ^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Attachments): " , _re . I )
for i , line in enumerate ( lines ) :
if header_re . match ( line . strip ( ) ) :
last_header_idx = i
body_lines = lines [ last_header_idx + 1 : ] if last_header_idx > = 0 else lines
while body_lines and not body_lines [ 0 ] . strip ( ) :
body_lines . pop ( 0 )
body = " \n " . join ( body_lines ) . strip ( )
else :
body = new
return header . rstrip ( ) + " \n --- \n " + body
2026-06-02 02:30:02 +05:30
async def do_create_document ( content_block : str , session_id : Optional [ str ] = None , owner : Optional [ str ] = None ) - > Dict :
2026-05-31 23:58:26 +09:00
""" Create a new document. Supports two formats:
1 ) Line - based : line 1 = title , line 2 ( optional ) = language , rest = content
2 ) XML - like tags : < title > . . . < / title > < language > . . . < / language > < content > . . . < / content >
Some models mix them — strip any XML - style tags and fall back to line parsing . """
import uuid , re as _re
from src . database import SessionLocal , Document , DocumentVersion , Session as DbSession
raw = content_block or " "
# Known languages the editor understands (match the <select> in HTML)
_KNOWN_LANGS = {
" python " , " javascript " , " typescript " , " html " , " css " , " markdown " , " json " ,
" yaml " , " bash " , " sql " , " rust " , " go " , " java " , " c " , " cpp " , " xml " , " toml " ,
" ini " , " ruby " , " php " , " csv " , " email " , " text " , " plain " , " svg " ,
}
# Try XML tag extraction first
title = None
language = None
content = None
mt = _re . search ( r " <title> \ s*(.*?) \ s*</title> " , raw , _re . DOTALL | _re . IGNORECASE )
ml = _re . search ( r " <language> \ s*(.*?) \ s*</language> " , raw , _re . DOTALL | _re . IGNORECASE )
mc = _re . search ( r " <content> \ s*(.*?) \ s*</content> " , raw , _re . DOTALL | _re . IGNORECASE )
if mt or mc :
title = mt . group ( 1 ) . strip ( ) if mt else None
language = ml . group ( 1 ) . strip ( ) . lower ( ) if ml else None
content = mc . group ( 1 ) if mc else None
# Fall back to line-based parsing. First strip any stray XML-ish tags.
if title is None or content is None :
cleaned = _re . sub ( r " </?(?:title|language|content)> " , " " , raw )
lines = cleaned . strip ( ) . split ( " \n " )
if title is None :
title = lines [ 0 ] . strip ( ) if lines else " Untitled "
lines = lines [ 1 : ]
# Only consume second line as language if it looks like a valid short lang token
if language is None and lines :
candidate = lines [ 0 ] . strip ( ) . lower ( )
if candidate and len ( candidate ) < 20 and " " not in candidate and candidate in _KNOWN_LANGS :
language = candidate
lines = lines [ 1 : ]
if content is None :
content = " \n " . join ( lines )
# Validate language: must be in known set, else default based on content
if language and language not in _KNOWN_LANGS :
language = None
if not language :
# No explicit language — sniff it from the content so an SVG / HTML / JSON
# / code document isn't silently saved as markdown. Prose → markdown.
language = _sniff_doc_language ( content )
if _looks_like_email_document ( content , title ) :
language = " email "
if not title :
title = " Untitled "
if not session_id :
return { " error " : " No session context for document creation " }
db = SessionLocal ( )
try :
doc_id = str ( uuid . uuid4 ( ) )
ver_id = str ( uuid . uuid4 ( ) )
# Inherit ownership from the chat session so the doc survives that
# session later being deleted (session_id → NULL).
_sess = db . query ( DbSession ) . filter ( DbSession . id == session_id ) . first ( )
2026-06-02 02:30:02 +05:30
if owner is not None and ( not _sess or _sess . owner != owner ) :
return { " error " : " Cannot create document in another user ' s session " }
2026-05-31 23:58:26 +09:00
_owner = _sess . owner if _sess else None
doc = Document (
id = doc_id ,
session_id = session_id ,
title = title ,
language = language ,
current_content = content ,
version_count = 1 ,
is_active = True ,
owner = _owner ,
)
ver = DocumentVersion (
id = ver_id ,
document_id = doc_id ,
version_number = 1 ,
content = content ,
summary = f " Created by { _active_model or ' AI ' } " ,
source = " ai " ,
)
db . add ( doc )
db . add ( ver )
db . commit ( )
set_active_document ( doc_id )
try :
from src . event_bus import fire_event
fire_event ( " document_created " , _owner )
except Exception :
logger . debug ( " document_created event dispatch failed " , exc_info = True )
return {
" action " : " create " ,
" doc_id " : doc_id ,
" title " : title ,
" language " : language ,
" content " : content ,
" version " : 1 ,
}
except Exception as e :
db . rollback ( )
return { " error " : f " Failed to create document: { e } " }
finally :
db . close ( )
2026-06-02 02:30:02 +05:30
async def do_update_document ( content : str , doc_id : Optional [ str ] = None , owner : Optional [ str ] = None ) - > Dict :
2026-05-31 23:58:26 +09:00
""" Update an existing document. Content = full new document text. """
import uuid
from src . database import SessionLocal , Document , DocumentVersion
target_id = doc_id or _active_document_id
db = SessionLocal ( )
try :
doc = None
if target_id :
2026-06-02 02:30:02 +05:30
doc = _get_owned_document ( db , Document , target_id , owner )
2026-05-31 23:58:26 +09:00
if not doc :
2026-06-02 02:30:02 +05:30
doc = _most_recent_owned_document ( db , Document , owner )
2026-05-31 23:58:26 +09:00
if doc :
target_id = doc . id
set_active_document ( target_id )
logger . info ( f " update_document: fell back to most recent doc id= { target_id } " )
if not doc :
return { " error " : " No documents exist to update " }
is_email_doc = doc . language == " email " or _looks_like_email_document ( doc . current_content or " " , doc . title or " " )
new_content = _coerce_email_document_content ( doc . current_content or " " , content ) if is_email_doc else content . strip ( )
if is_email_doc :
doc . language = " email "
new_ver = doc . version_count + 1
ver = DocumentVersion (
id = str ( uuid . uuid4 ( ) ) ,
document_id = target_id ,
version_number = new_ver ,
content = new_content ,
summary = f " Updated by { _active_model or ' AI ' } " ,
source = " ai " ,
)
doc . current_content = new_content
doc . version_count = new_ver
db . add ( ver )
db . commit ( )
return {
" action " : " update " ,
" doc_id " : target_id ,
" title " : doc . title ,
" language " : doc . language ,
" content " : new_content ,
" version " : new_ver ,
}
except Exception as e :
db . rollback ( )
return { " error " : f " Failed to update document: { e } " }
finally :
db . close ( )
def parse_edit_blocks ( content : str ) - > list :
""" Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks. """
edits = [ ]
pattern = r ' <<<FIND>>> \ n(.*?) \ n<<<REPLACE>>> \ n(.*?) \ n<<<END>>> '
for m in re . finditer ( pattern , content , re . DOTALL ) :
edits . append ( { " find " : m . group ( 1 ) , " replace " : m . group ( 2 ) } )
return edits
2026-06-02 02:30:02 +05:30
async def do_edit_document ( content : str , doc_id : Optional [ str ] = None , owner : Optional [ str ] = None ) - > Dict :
2026-05-31 23:58:26 +09:00
""" Apply targeted FIND/REPLACE edits to an existing document. """
import uuid
from src . database import SessionLocal , Document , DocumentVersion
target_id = doc_id or _active_document_id
edits = parse_edit_blocks ( content )
if not edits :
return { " error " : " No valid <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks found " }
db = SessionLocal ( )
try :
doc = None
if target_id :
2026-06-02 02:30:02 +05:30
doc = _get_owned_document ( db , Document , target_id , owner )
2026-05-31 23:58:26 +09:00
if not doc :
# Fallback: most recently updated document. Avoids "no active doc" errors
# after server restart or when the agent loses track of which doc to edit.
2026-06-02 02:30:02 +05:30
doc = _most_recent_owned_document ( db , Document , owner )
2026-05-31 23:58:26 +09:00
if doc :
target_id = doc . id
set_active_document ( target_id )
logger . info ( f " edit_document: fell back to most recent doc id= { target_id } title= { doc . title !r} " )
if not doc :
return { " error " : " No documents exist to edit " }
updated_content = doc . current_content
applied = 0
skipped = 0
for edit in edits :
_find = edit [ " find " ]
if _find in updated_content :
updated_content = updated_content . replace ( _find , edit [ " replace " ] , 1 )
applied + = 1
else :
# Defensive: the active-doc context shows a "N\t" line-number
# gutter for reference. Weaker models sometimes copy that prefix
# into FIND. If the exact match failed, retry with a leading
# "<digits><tab>" stripped from each FIND line — but only use it
# when that stripped form actually matches, so we never corrupt a
# legitimately tab-prefixed document.
_stripped = " \n " . join ( re . sub ( r " ^ \ d+ \ t " , " " , _l ) for _l in _find . split ( " \n " ) )
if _stripped != _find and _stripped in updated_content :
updated_content = updated_content . replace ( _stripped , edit [ " replace " ] , 1 )
applied + = 1
logger . info ( " edit_document: matched after stripping line-number gutter from FIND " )
else :
logger . warning ( f " edit_document: FIND text not found, skipping: { _find [ : 80 ] !r} " )
skipped + = 1
if applied == 0 :
return { " error " : f " No edits applied — none of the FIND blocks matched the document content (skipped { skipped } ) " }
new_ver = doc . version_count + 1
ver = DocumentVersion (
id = str ( uuid . uuid4 ( ) ) ,
document_id = target_id ,
version_number = new_ver ,
content = updated_content ,
summary = f " Edited by { _active_model or ' AI ' } ( { applied } edit(s)) " ,
source = " ai " ,
)
doc . current_content = updated_content
doc . version_count = new_ver
db . add ( ver )
db . commit ( )
return {
" action " : " edit " ,
" doc_id " : target_id ,
" title " : doc . title ,
" language " : doc . language ,
" content " : updated_content ,
" version " : new_ver ,
" applied " : applied ,
" skipped " : skipped ,
}
except Exception as e :
db . rollback ( )
return { " error " : f " Failed to edit document: { e } " }
finally :
db . close ( )
def parse_suggest_blocks ( content : str ) - > list :
""" Parse <<<FIND>>>...<<<SUGGEST>>>...<<<REASON>>>...<<<END>>> blocks. """
suggestions = [ ]
_skip_phrases = [ " no change " , " clear " , " fine as " , " looks good " , " no improvement " , " keep as " ]
pattern = r ' <<<FIND>>> \ n(.*?) \ n<<<SUGGEST>>> \ n(.*?) \ n<<<REASON>>> \ n(.*?) \ n<<<END>>> '
for m in re . finditer ( pattern , content , re . DOTALL ) :
find_text = m . group ( 1 )
replace_text = m . group ( 2 )
reason = m . group ( 3 ) . strip ( )
# Skip no-op suggestions where find == replace or reason says no change
if find_text . strip ( ) == replace_text . strip ( ) :
continue
if any ( phrase in reason . lower ( ) for phrase in _skip_phrases ) :
continue
suggestions . append ( {
" id " : f " sugg- { len ( suggestions ) + 1 } " ,
" find " : find_text ,
" replace " : replace_text ,
" reason " : reason ,
} )
return suggestions
2026-06-02 02:30:02 +05:30
async def do_suggest_document ( content : str , doc_id : str = None , owner : Optional [ str ] = None ) - > Dict :
2026-05-31 23:58:26 +09:00
""" Create inline suggestions for the active document WITHOUT modifying it. """
from src . database import SessionLocal , Document
target_id = doc_id or _active_document_id
if not target_id :
return { " error " : " No active document to suggest on " }
suggestions = parse_suggest_blocks ( content )
if not suggestions :
return { " error " : " No valid <<<FIND>>>...<<<SUGGEST>>>...<<<REASON>>>...<<<END>>> blocks found " }
db = SessionLocal ( )
try :
2026-06-02 02:30:02 +05:30
doc = _get_owned_document ( db , Document , target_id , owner )
2026-05-31 23:58:26 +09:00
if not doc :
return { " error " : f " Document { target_id } not found " }
# Validate that FIND text exists in document
valid = [ ]
for s in suggestions :
if s [ " find " ] in doc . current_content :
valid . append ( s )
else :
logger . warning ( f " suggest_document: FIND text not found, skipping: { s [ ' find ' ] [ : 80 ] !r} " )
if not valid :
return { " error " : " No suggestions matched the document content " }
return {
" action " : " suggest " ,
" doc_id " : target_id ,
" suggestions " : valid ,
" count " : len ( valid ) ,
}
finally :
db . close ( )
# ---------------------------------------------------------------------------
# Search chats
# ---------------------------------------------------------------------------
async def do_search_chats ( query : str , limit : int = 20 , owner : str | None = None ) - > Dict :
""" Search past chat messages for the calling user ' s sessions only.
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 .
"""
from src . database import SessionLocal , ChatMessage as DBChatMessage , Session as DBSession
# Escape LIKE wildcards in the user-supplied query so a stray % or _
# doesn't widen the match (and to keep the response deterministic).
safe_q = query . replace ( " \\ " , " \\ \\ " ) . replace ( " % " , " \\ % " ) . replace ( " _ " , " \\ _ " )
db = SessionLocal ( )
try :
q = (
db . query ( DBChatMessage , DBSession . id , DBSession . name )
. join ( DBSession , DBChatMessage . session_id == DBSession . id )
. filter (
DBSession . archived == False ,
DBChatMessage . content . ilike ( f " % { safe_q } % " , escape = " \\ " ) ,
DBChatMessage . role . in_ ( [ " user " , " assistant " ] ) ,
)
)
if owner is not None :
# Restrict to this user's sessions plus legacy null-owner
# rows (so single-user upgrades keep seeing their own data).
q = q . filter ( ( DBSession . owner == owner ) | ( DBSession . owner . is_ ( None ) ) )
rows = q . order_by ( DBChatMessage . timestamp . desc ( ) ) . limit ( limit ) . all ( )
if not rows :
return { " results " : f " No chats found matching \" { query } \" . " }
# Group by session to avoid duplicate links
seen_sessions = { }
for msg , session_id , session_name in rows :
if session_id not in seen_sessions :
content = msg . content or " "
lower_content = content . lower ( )
idx = lower_content . find ( query . lower ( ) )
if idx == - 1 :
snippet = content [ : 150 ]
else :
start = max ( 0 , idx - 60 )
end = min ( len ( content ) , idx + len ( query ) + 60 )
snippet = ( " ... " if start > 0 else " " ) + content [ start : end ] + ( " ... " if end < len ( content ) else " " )
seen_sessions [ session_id ] = {
" name " : session_name or " Untitled " ,
" snippet " : snippet ,
" role " : msg . role ,
" timestamp " : msg . timestamp . isoformat ( ) if msg . timestamp else None ,
}
lines = [ f " Found { len ( seen_sessions ) } session(s) matching \" { query } \" : \n " ]
for sid , info in seen_sessions . items ( ) :
lines . append ( f " - ** { info [ ' name ' ] } ** (# { sid } ) " )
lines . append ( f " Link: [Open chat](# { sid } ) " )
lines . append ( f " > { info [ ' snippet ' ] } " )
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 }
finally :
db . close ( )
# ---------------------------------------------------------------------------
# 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 }
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 [ ] ,
status = args . get ( " status " ) or " draft " ,
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 ( )
# ---------------------------------------------------------------------------
# Document management tool (delete, list, organize)
# ---------------------------------------------------------------------------
async def do_manage_documents ( content : str , owner : Optional [ str ] = None ) - > Dict :
""" Manage documents: list, read/view/open, delete, tidy.
Output format mirrors ` manage_session ` : list rows include a
clickable ` [ Title ] ( #document-<id>)` anchor + relative timestamps
so the user can click straight from chat to open the editor .
"""
from core . database import SessionLocal , Document
from datetime import datetime , timezone
try :
args = _parse_tool_args ( content )
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
action = args . get ( " action " , " list " )
db = SessionLocal ( )
def _rel ( ts ) :
if not ts :
return ' never '
try :
now = datetime . now ( timezone . utc ) if ts . tzinfo is not None else datetime . utcnow ( )
diff = ( now - ts ) . total_seconds ( )
except Exception :
return ' unknown '
if diff < 60 : return ' just now '
if diff < 3600 : return f ' { int ( diff / 60 ) } m ago '
if diff < 86400 : return f ' { int ( diff / 3600 ) } h ago '
if diff < 86400 * 7 : return f ' { int ( diff / 86400 ) } d ago '
return ts . strftime ( ' % Y- % m- %d ' )
try :
if action == " list " :
q = db . query ( Document ) . filter ( Document . is_active == True )
2026-06-02 02:30:02 +05:30
q = _owned_document_query ( q , Document , owner )
2026-05-31 23:58:26 +09:00
if args . get ( " search " ) :
q = q . filter ( Document . title . ilike ( f " % { args [ ' search ' ] } % " ) )
if args . get ( " language " ) :
q = q . filter ( Document . language == args [ " language " ] )
docs = q . order_by ( Document . updated_at . desc ( ) ) . limit ( args . get ( " limit " , 50 ) ) . all ( )
if not docs :
msg = " No documents found " + ( f " matching ' { args [ ' search ' ] } ' " if args . get ( " search " ) else " " ) + " . "
return { " response " : msg , " documents " : [ ] , " exit_code " : 0 }
lines = [ ]
items = [ ]
for i , d in enumerate ( docs ) :
size = len ( d . current_content or " " )
lang = d . language or " text "
ts = getattr ( d , ' updated_at ' , None ) or getattr ( d , ' created_at ' , None )
marker = " ← most recent " if i == 0 else " "
lines . append (
f " - [ { d . title } ](#document- { d . id } ) — { lang } , { size } chars, updated { _rel ( ts ) } { marker } "
)
items . append ( { " id " : d . id , " title " : d . title , " language " : lang , " size " : size } )
header = f " Found { len ( docs ) } document(s), sorted most-recent first. Click a title to open: "
return {
" response " : header + " \n " + " \n " . join ( lines ) ,
" documents " : items ,
" exit_code " : 0 ,
}
elif action in ( " read " , " view " , " open " , " get " ) :
doc_id = args . get ( " document_id " ) or args . get ( " id " ) or args . get ( " uid " )
if not doc_id :
return { " error " : " Need document_id (use action=list to find one) " , " exit_code " : 1 }
2026-06-02 02:30:02 +05:30
doc = _get_owned_document ( db , Document , doc_id , owner , active_only = True )
2026-05-31 23:58:26 +09:00
if not doc :
return { " error " : f " Document ' { doc_id } ' not found " , " exit_code " : 1 }
body = doc . current_content or " "
preview_limit = int ( args . get ( " limit " , MAX_READ_CHARS ) )
truncated = len ( body ) > preview_limit
preview = body [ : preview_limit ] + ( f " \n ... (truncated, { len ( body ) } chars total) " if truncated else " " )
anchor = f " [ { doc . title } ](#document- { doc . id } ) "
return {
" response " : f " { anchor } — click to open in editor. \n \n ``` { doc . language or ' ' } \n { preview } \n ``` " ,
" document " : {
" id " : doc . id ,
" title " : doc . title ,
" language " : doc . language ,
" size " : len ( body ) ,
" content " : preview ,
" truncated " : truncated ,
} ,
" exit_code " : 0 ,
}
elif action == " delete " :
doc_id = args . get ( " document_id " ) or args . get ( " id " ) or args . get ( " uid " ) or _active_document_id
doc = None
if doc_id :
2026-06-02 02:30:02 +05:30
doc = _get_owned_document ( db , Document , doc_id , owner )
2026-05-31 23:58:26 +09:00
if not doc :
# Fallback: most recently updated doc (likely what the user means)
2026-06-02 02:30:02 +05:30
doc = _most_recent_owned_document ( db , Document , owner , active_only = True )
2026-05-31 23:58:26 +09:00
if not doc :
return { " error " : " No document to delete " , " exit_code " : 1 }
title = doc . title
doc . is_active = False
db . commit ( )
if _active_document_id == doc . id :
set_active_document ( None )
return { " response " : f " Deleted document ' { title } ' " , " exit_code " : 0 }
elif action == " tidy " :
from src . document_actions import run_document_tidy
result = await run_document_tidy ( owner or " " )
return { " response " : result , " exit_code " : 0 }
else :
return { " error " : f " Unknown action: { action } " , " exit_code " : 1 }
except Exception as e :
logger . error ( f " manage_documents 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 " ,
" 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 " ] ,
" reminder_channel " : [ " browser " , " email " , " ntfy " ] ,
}
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 )
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 ( )
return { " response " : f " Note created: \" { title or ' (untitled) ' } \" (id: { note . id [ : 8 ] } ) " , " exit_code " : 0 }
elif action == " update " :
note_id = args . get ( " id " , " " )
note = db . query ( Note ) . filter ( Note . id . startswith ( note_id ) ) . first ( ) if note_id else None
if not note :
return { " error " : f " Note ' { note_id } ' not found " , " exit_code " : 1 }
if owner is not None and note . owner and note . owner != owner :
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 " , " " )
note = db . query ( Note ) . filter ( Note . id . startswith ( note_id ) ) . first ( ) if note_id else None
if not note :
return { " error " : f " Note ' { note_id } ' not found " , " exit_code " : 1 }
if owner is not None and note . owner and note . owner != owner :
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 )
note = db . query ( Note ) . filter ( Note . id . startswith ( note_id ) ) . first ( ) if note_id else None
if not note :
return { " error " : f " Note ' { note_id } ' not found " , " exit_code " : 1 }
if owner is not None and note . owner and note . owner != owner :
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 ) )
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 :
if args . get ( " start " ) :
start_dt = _parse_dt ( args [ " start " ] )
else :
start_dt = datetime . utcnow ( ) . replace ( hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
if args . get ( " end " ) :
end_dt = _parse_dt ( args [ " end " ] )
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 :
ev . dtstart = _parse_dt ( args [ " dtstart " ] )
if args . get ( " dtend " ) is not None :
ev . dtend = _parse_dt ( args [ " dtend " ] )
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 ──
# Cookbook routes loopback. The agent's tool calls run in-process but
# need to reach admin-gated cookbook routes; we ride the per-process
# internal token so require_admin lets us through. See core/middleware.py.
_COOKBOOK_BASE = " http://localhost:7000 "
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 :
r = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/state " , headers = _internal_headers ( ) )
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 :
r = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/state " , headers = headers )
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 ,
" gpus " : env_root . get ( " gpus " ) or " " ,
" platform " : platform ,
" hf_token " : env_root . get ( " hfToken " ) or " " ,
" ssh_port " : ssh_port ,
}
async def _cookbook_register_task ( session_id : str , model : str , host : str ,
cmd : str , task_type : str = " serve " ) - > bool :
""" 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 :
r = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/state " , headers = headers )
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 "
f " target: { target } { cmd . split ( ) [ 0 ] if cmd else ' ' } \n "
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 ,
" _endpointAdded " : False ,
} )
state [ " tasks " ] = tasks
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
r = await client . post ( f " { _COOKBOOK_BASE } /api/cookbook/state " ,
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
# administration is too risky to route through an agent surface even
# when the agent is admin-context — accidental "delete account"
# style mistakes have permanent blast radius.
_APP_API_BLOCKLIST_PREFIXES = (
" /api/auth/ " , # login/logout/password
" /api/users/ " , # user CRUD
" /api/tokens/ " , # api token mgmt
" /api/admin/ " , # admin one-shots (wipe etc.)
" /api/backup/restore " , # destructive restore
)
# (method, prefix) pairs to refuse specifically. Used for endpoints
# where GET is fine but writes are destructive — saw the agent wipe
# cookbook_state.json (presets + tasks) by POSTing {"tasks": []} to
# /api/cookbook/state, which overwrote the whole file. Use the
# dedicated preset/task tools instead.
_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 " ) ,
# 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 :
""" Generic loopback to any internal Odysseus API endpoint. Lets the
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
refuses auth / user / admin paths to keep blast radius bounded .
"""
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 ( )
base = _COOKBOOK_BASE
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 ) :
return { " error " : f " Path blocked for safety: { path } . Auth/user/admin endpoints are off-limits via app_api. " , " exit_code " : 1 }
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 }
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
payload = { " repo_id " : repo_id }
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 :
resp = await client . post ( f " { _COOKBOOK_BASE } /api/model/download " ,
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 ,
cmd = f " hf download { repo_id } " , task_type = " download " ,
)
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 " "
return { " output " : f " Download started: { repo_id } on { where } (session: { sid } ) { note } { default_note } " , " session_id " : sid , " host " : host , " exit_code " : 0 }
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 )
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 :
resp = await client . post ( f " { _COOKBOOK_BASE } /api/model/serve " ,
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 , cmd = cmd , task_type = " serve " ,
)
note = " " if registered else " (state-write failed — task may not show in UI) "
return { " output " : f " Serving { repo_id } (session: { sid } ) { note } " , " session_id " : sid , " exit_code " : 0 }
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_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 :
resp = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/tasks/status " ,
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 ,
}
cb_n = len ( cookbook_tasks )
ext_n = len ( external )
header = [ ]
if cb_n :
header . append ( f " { cb_n } cookbook-tracked " )
if ext_n :
header . append ( f " { ext_n } external " )
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 :
lines . append ( " recent log: " )
for line in tail . splitlines ( ) [ - 6 : ] :
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 :
resp = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/state " , headers = headers )
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 :
resp = await client . post ( f " { _COOKBOOK_BASE } /api/shell/exec " ,
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 :
await client . post ( f " { _COOKBOOK_BASE } /api/cookbook/state " ,
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 " ,
)
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 :
resp = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/tasks/status " ,
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 :
resp = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/hf-latest " ,
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 :
r = await client . post ( f " { _COOKBOOK_BASE } /api/shell/exec " ,
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 :
r = await client . post ( f " { _COOKBOOK_BASE } /api/shell/exec " ,
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 :
r = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/state " , headers = headers )
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 :
await client . post ( f " { _COOKBOOK_BASE } /api/cookbook/state " ,
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 :
resp = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/state " ,
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 :
resp = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/state " ,
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 " ]
if env_cfg . get ( " ssh_port " ) : payload [ " ssh_port " ] = env_cfg [ " ssh_port " ]
try :
async with httpx . AsyncClient ( timeout = 30 ) as client :
resp = await client . post ( f " { _COOKBOOK_BASE } /api/model/serve " ,
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 ,
cmd = cmd , task_type = " serve " ,
)
note = " " if registered else " (state-write failed — task may not show in UI) "
return { " output " : f " Launched preset { chosen . get ( ' name ' ) !r} : { repo_id } on { host or ' local ' } (session: { sid } ) { note } " , " session_id " : sid , " exit_code " : 0 }
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 :
""" List models already cached locally (or on a remote host). """
import httpx
try :
args = _parse_tool_args ( content ) if content . strip ( ) else { }
except ValueError :
return { " error " : " Invalid JSON arguments " , " exit_code " : 1 }
params : Dict [ str , str ] = { }
raw_host = ( args . get ( " host " ) or " " ) . strip ( )
host = await _resolve_cookbook_host ( raw_host ) if raw_host else " "
if host :
params [ " host " ] = host
if args . get ( " model_dir " ) :
params [ " model_dir " ] = args [ " model_dir " ]
if args . get ( " ssh_port " ) :
params [ " ssh_port " ] = str ( args [ " ssh_port " ] )
if args . get ( " platform " ) :
params [ " platform " ] = args [ " platform " ]
try :
async with httpx . AsyncClient ( timeout = 60 ) as client :
resp = await client . get ( f " { _COOKBOOK_BASE } /api/model/cached " ,
params = params , headers = _internal_headers ( ) )
data = resp . json ( )
models = data . get ( " models " , [ ] ) if isinstance ( data , dict ) else data
if not models :
# Filesystem cache scans can miss models downloaded into the HF
# default cache when the server has no explicit model_dir configured.
# Still surface completed Cookbook downloads so the agent doesn't
# incorrectly assume a model is absent and re-download it.
downloaded = [ ]
try :
async with httpx . AsyncClient ( timeout = 10 ) as client :
st = await client . get ( f " { _COOKBOOK_BASE } /api/cookbook/state " , headers = _internal_headers ( ) )
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 " "
if host and task_host != host :
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 = [ ]
if downloaded :
host_str = f " on { raw_host or host } " if ( raw_host or host ) else " "
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 }
host_str = f " on { raw_host or host } " if ( raw_host or host ) else " "
return { " output " : f " No cached models found { host_str } . " , " exit_code " : 0 }
lines = [ f " { len ( models ) } cached model(s): " ]
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 } " )
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 :
resp = await client . post ( f " http://localhost:7000/api/gallery/ { action } " , json = payload )
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 ( )
data_dir = _Path ( " data/deep_research " )
# 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 :
resp = await client . post ( f " { _COOKBOOK_BASE } /api/research/start " ,
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 :
resp = await client . get ( " http://localhost:7000/api/email/resolve-contact " , params = { " name " : name } )
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
p = Path ( " data/vault.json " )
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
p = Path ( " data/vault.json " )
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 }