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
# ---------------------------------------------------------------------------
# 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
async def do_create_document ( content_block : str , session_id : Optional [ str ] = None ) - > Dict :
""" 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 ( )
_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 ( )
async def do_update_document ( content : str , doc_id : Optional [ str ] = None ) - > Dict :
""" 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 :
doc = db . query ( Document ) . filter ( Document . id == target_id ) . first ( )
if not doc :
doc = db . query ( Document ) . order_by ( Document . updated_at . desc ( ) ) . first ( )
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
async def do_edit_document ( content : str , doc_id : Optional [ str ] = None ) - > Dict :
""" 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 :
doc = db . query ( Document ) . filter ( Document . id == target_id ) . first ( )
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.
doc = db . query ( Document ) . order_by ( Document . updated_at . desc ( ) ) . first ( )
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
async def do_suggest_document ( content : str , doc_id : str = None ) - > Dict :
""" 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 :
doc = db . query ( Document ) . filter ( Document . id == target_id ) . first ( )
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 }
md = sm . read_skill_md ( name )
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 }
text = sm . read_skill_reference ( name , ref )
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
ok = sm . update_skill ( name , _skill_dump ( sk_new ) )
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 }
md = sm . read_skill_md ( name )
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 )
ok = sm . update_skill ( name , _skill_dump ( sk_new ) )
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 " ] ) ) )
sm . update_skill ( name , updates )
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 }
ok = sm . delete_skill ( name )
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 ( ) )
name = args . get ( " name " ) or args . get ( " prompt " , args . get ( " action_name " , " Task " ) ) [ : 50 ]
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 :
await mcp . connect_server ( sid )
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 )
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 }
doc = db . query ( Document ) . filter ( Document . id == doc_id , Document . is_active == True ) . first ( )
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 :
doc = db . query ( Document ) . filter ( Document . id == doc_id ) . first ( )
if not doc :
# Fallback: most recently updated doc (likely what the user means)
doc = db . query ( Document ) . filter ( Document . is_active == True ) . order_by ( Document . updated_at . desc ( ) ) . first ( )
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 ) :
return k in _SECRET_KEYS or any ( t in k for t in ( " api_key " , " _key " , " token " , " secret " , " password " ) )
# 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 " ,
" token budget " : " agent_input_token_budget " ,
}
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
items_raw = args . get ( " items " )
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 }
for field in ( " title " , " content " , " note_type " , " color " , " label " , " due_date " ) :
if field in args and args [ field ] is not None :
setattr ( note , field , args [ field ] )
if " items " in args and args [ " items " ] is not None :
note . items = json . dumps ( args [ " items " ] )
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 }
stdout , stderr , rc = await _run_bw ( [ " unlock " , master_password , " --raw " ] )
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 }