mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-07 05:58:37 -04:00
Merge verified Odysseus fixes
This commit is contained in:
@@ -110,6 +110,18 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
|
||||
# Workspace / coding-agent intent. These should promote to the agent
|
||||
# workspace with shell/file tools available, not the "light" typed-tool
|
||||
# path used for notes/calendar/email.
|
||||
("workspace", "repo implementation request", rf"{_PLEASE}(?:fix|debug|implement|change|update|refactor|patch|review|test)\b.{{0,160}}\b(?:repo|repository|codebase|project|app|server|api|frontend|backend|tests?|bug|issue|pr)\b"),
|
||||
("workspace", "assistant repo implementation request", rf"{_ACTION_QUESTION}(?:fix|debug|implement|change|update|refactor|patch|review|test)\b.{{0,160}}\b(?:repo|repository|codebase|project|app|server|api|frontend|backend|tests?|bug|issue|pr)\b"),
|
||||
("workspace", "test/build command request", rf"{_PLEASE}(?:run|execute|start|launch)\b.{{0,80}}\b(?:tests?|pytest|npm\s+test|pnpm\s+test|yarn\s+test|build|lint|typecheck|benchmark|eval|terminal[- ]bench|tbench)\b"),
|
||||
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
|
||||
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
|
||||
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
|
||||
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
|
||||
("workspace", "terminal workspace request", r"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|benchmark|terminal[- ]bench|tbench)\b"),
|
||||
|
||||
# Shell / remote-host intent.
|
||||
("shell", "ssh request", r"\bssh\s+(?:in)?to\b"),
|
||||
("shell", "ssh target request", r"\bssh\s+\w+"),
|
||||
|
||||
+766
-47
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from .subprocess_tools import BashTool, PythonTool
|
||||
from .web_tools import WebSearchTool, WebFetchTool
|
||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, ApplyPatchTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||
from .coding_tools import TodoWriteTool
|
||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||
from .interaction_tools import AskUserTool, UpdatePlanTool
|
||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||
@@ -41,6 +42,8 @@ TOOL_HANDLERS = {
|
||||
"read_file": ReadFileTool().execute,
|
||||
"write_file": WriteFileTool().execute,
|
||||
"edit_file": EditFileTool().execute,
|
||||
"apply_patch": ApplyPatchTool().execute,
|
||||
"todowrite": TodoWriteTool().execute,
|
||||
"ls": LsTool().execute,
|
||||
"glob": GlobTool().execute,
|
||||
"grep": GrepTool().execute,
|
||||
@@ -74,6 +77,7 @@ PYTHON_TIMEOUT = 30
|
||||
|
||||
# Tool types that trigger execution
|
||||
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
|
||||
"apply_patch", "todowrite",
|
||||
"grep", "glob", "ls", "get_workspace", "manage_bg_jobs",
|
||||
"create_document", "update_document", "edit_document",
|
||||
"search_chats",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
|
||||
_TODO_DIR = os.path.join(DATA_DIR, "agent_todos")
|
||||
|
||||
|
||||
def _safe_session_id(value: str) -> str:
|
||||
value = value or "current"
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)[:120] or "current"
|
||||
|
||||
|
||||
class TodoWriteTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
try:
|
||||
args = json.loads(content) if (content or "").strip().startswith("{") else {"todos": []}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {"error": "todowrite: JSON object required", "exit_code": 1}
|
||||
todos = args.get("todos")
|
||||
if not isinstance(todos, list):
|
||||
return {"error": "todowrite: todos must be a list", "exit_code": 1}
|
||||
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
allowed_statuses = {"pending", "in_progress", "completed"}
|
||||
allowed_priorities = {"low", "medium", "high"}
|
||||
active_count = 0
|
||||
for item in todos:
|
||||
if not isinstance(item, dict):
|
||||
return {"error": "todowrite: each todo must be an object", "exit_code": 1}
|
||||
content_text = str(item.get("content") or item.get("text") or "").strip()
|
||||
if not content_text:
|
||||
return {"error": "todowrite: todo content required", "exit_code": 1}
|
||||
status = str(item.get("status") or "pending").strip()
|
||||
if status not in allowed_statuses:
|
||||
return {"error": f"todowrite: invalid status {status!r}", "exit_code": 1}
|
||||
if status == "in_progress":
|
||||
active_count += 1
|
||||
priority = str(item.get("priority") or "medium").strip()
|
||||
if priority not in allowed_priorities:
|
||||
priority = "medium"
|
||||
normalized.append({
|
||||
"content": content_text,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
})
|
||||
if active_count > 1:
|
||||
return {"error": "todowrite: only one todo can be in_progress", "exit_code": 1}
|
||||
|
||||
session_id = _safe_session_id(str(ctx.get("session_id") or args.get("session_id") or "current"))
|
||||
os.makedirs(_TODO_DIR, exist_ok=True)
|
||||
path = os.path.join(_TODO_DIR, f"{session_id}.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({"todos": normalized}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
lines = []
|
||||
for item in normalized:
|
||||
marker = {"pending": " ", "in_progress": ">", "completed": "x"}[item["status"]]
|
||||
lines.append(f"[{marker}] {item['content']} ({item['priority']})")
|
||||
return {
|
||||
"output": "Updated todo list:\n" + ("\n".join(lines) if lines else "(empty)"),
|
||||
"exit_code": 0,
|
||||
"todos": normalized,
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import re
|
||||
import difflib
|
||||
import fnmatch
|
||||
import shutil
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from typing import Optional, Dict, Any, Tuple, List
|
||||
|
||||
from src.constants import MAX_READ_CHARS, MAX_DIFF_LINES, MAX_OUTPUT_CHARS
|
||||
|
||||
@@ -230,6 +230,181 @@ class WriteFileTool:
|
||||
result["diff"] = diff
|
||||
return result
|
||||
|
||||
class ApplyPatchTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
"""Apply a small Codex-style patch using exact context matching.
|
||||
|
||||
This is deliberately stricter than git-apply: if an update hunk's old
|
||||
text is not found exactly once, the whole patch is rejected before any
|
||||
file is changed. That keeps agent edits reviewable and avoids fuzzy
|
||||
corruption when the model patches stale context.
|
||||
"""
|
||||
from src.tool_execution import _resolve_tool_path
|
||||
|
||||
patch_text = content or ""
|
||||
stripped = patch_text.strip()
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
args = json.loads(stripped)
|
||||
if isinstance(args, dict):
|
||||
patch_text = str(args.get("patch_text") or args.get("patchText") or args.get("patch") or "")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if not patch_text.strip():
|
||||
return {"error": "apply_patch: patch_text required", "exit_code": 1}
|
||||
|
||||
try:
|
||||
ops = _parse_agent_patch(patch_text)
|
||||
if not ops:
|
||||
return {"error": "apply_patch: no file operations found", "exit_code": 1}
|
||||
prepared = []
|
||||
for op in ops:
|
||||
path = _resolve_tool_path(op["path"])
|
||||
kind = op["kind"]
|
||||
if kind == "add":
|
||||
if os.path.exists(path):
|
||||
return {"error": f"apply_patch: {op['path']}: already exists", "exit_code": 1}
|
||||
old = ""
|
||||
new = op["content"]
|
||||
elif kind == "delete":
|
||||
if not os.path.isfile(path):
|
||||
return {"error": f"apply_patch: {op['path']}: not found", "exit_code": 1}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
old = f.read()
|
||||
new = ""
|
||||
else:
|
||||
if not os.path.isfile(path):
|
||||
return {"error": f"apply_patch: {op['path']}: not found", "exit_code": 1}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
old = f.read()
|
||||
new = _apply_patch_hunks(old, op["hunks"], op["path"])
|
||||
prepared.append((kind, path, old, new))
|
||||
|
||||
diffs = []
|
||||
for kind, path, old, new in prepared:
|
||||
if kind == "delete":
|
||||
os.remove(path)
|
||||
else:
|
||||
directory = os.path.dirname(path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(new)
|
||||
diff = _unified_diff(old, new, path)
|
||||
if diff:
|
||||
diffs.append(diff)
|
||||
except (ValueError, UnicodeDecodeError, PermissionError, OSError) as e:
|
||||
return {"error": f"apply_patch: {e}", "exit_code": 1}
|
||||
|
||||
added = sum(int(d.get("added") or 0) for d in diffs)
|
||||
removed = sum(int(d.get("removed") or 0) for d in diffs)
|
||||
text_parts = [d.get("text", "") for d in diffs if d.get("text")]
|
||||
diff_text = "\n".join(text_parts)
|
||||
if len(diff_text.splitlines()) > MAX_DIFF_LINES:
|
||||
diff_text = "\n".join(diff_text.splitlines()[:MAX_DIFF_LINES]) + f"\n... diff truncated at {MAX_DIFF_LINES} lines"
|
||||
result = {
|
||||
"output": f"Applied patch ({len(prepared)} file{'s' if len(prepared) != 1 else ''}, +{added}/-{removed})",
|
||||
"exit_code": 0,
|
||||
}
|
||||
if diffs:
|
||||
result["diff"] = {
|
||||
"text": diff_text,
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"new_file": any(d.get("new_file") for d in diffs),
|
||||
"file": "patch",
|
||||
}
|
||||
return result
|
||||
|
||||
def _parse_agent_patch(patch_text: str) -> List[Dict[str, Any]]:
|
||||
lines = patch_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
if not lines or lines[0].strip() != "*** Begin Patch":
|
||||
raise ValueError("patch must start with *** Begin Patch")
|
||||
if lines[-1].strip() != "*** End Patch":
|
||||
raise ValueError("patch must end with *** End Patch")
|
||||
|
||||
ops: List[Dict[str, Any]] = []
|
||||
i = 1
|
||||
while i < len(lines) - 1:
|
||||
line = lines[i]
|
||||
if not line:
|
||||
i += 1
|
||||
continue
|
||||
if line.startswith("*** Add File: "):
|
||||
path = line[len("*** Add File: "):].strip()
|
||||
body = []
|
||||
i += 1
|
||||
while i < len(lines) - 1 and not lines[i].startswith("*** "):
|
||||
if not lines[i].startswith("+"):
|
||||
raise ValueError(f"add file {path}: every content line must start with +")
|
||||
body.append(lines[i][1:])
|
||||
i += 1
|
||||
ops.append({"kind": "add", "path": path, "content": "\n".join(body) + ("\n" if body else "")})
|
||||
continue
|
||||
if line.startswith("*** Delete File: "):
|
||||
path = line[len("*** Delete File: "):].strip()
|
||||
ops.append({"kind": "delete", "path": path})
|
||||
i += 1
|
||||
continue
|
||||
if line.startswith("*** Update File: "):
|
||||
path = line[len("*** Update File: "):].strip()
|
||||
hunks = []
|
||||
current = []
|
||||
i += 1
|
||||
if i < len(lines) - 1 and lines[i].startswith("*** Move to: "):
|
||||
raise ValueError("move operations are not supported")
|
||||
while i < len(lines) - 1 and not lines[i].startswith("*** "):
|
||||
if lines[i].startswith("@@"):
|
||||
if current:
|
||||
hunks.append(current)
|
||||
current = []
|
||||
elif lines[i].startswith((" ", "-", "+")):
|
||||
current.append(lines[i])
|
||||
elif lines[i] == "":
|
||||
current.append(" ")
|
||||
else:
|
||||
raise ValueError(f"update file {path}: invalid patch line {lines[i]!r}")
|
||||
i += 1
|
||||
if current:
|
||||
hunks.append(current)
|
||||
if not hunks:
|
||||
raise ValueError(f"update file {path}: no hunks")
|
||||
ops.append({"kind": "update", "path": path, "hunks": hunks})
|
||||
continue
|
||||
raise ValueError(f"unexpected patch line: {line!r}")
|
||||
return ops
|
||||
|
||||
def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str:
|
||||
updated = original
|
||||
for idx, hunk in enumerate(hunks, 1):
|
||||
old_lines = []
|
||||
new_lines = []
|
||||
for line in hunk:
|
||||
prefix, body = line[:1], line[1:]
|
||||
if prefix in (" ", "-"):
|
||||
old_lines.append(body)
|
||||
if prefix in (" ", "+"):
|
||||
new_lines.append(body)
|
||||
old_text = "\n".join(old_lines)
|
||||
new_text = "\n".join(new_lines)
|
||||
if old_text and old_text in updated:
|
||||
occurrences = updated.count(old_text)
|
||||
if occurrences != 1:
|
||||
raise ValueError(f"{label}: hunk {idx} context matched {occurrences} times")
|
||||
updated = updated.replace(old_text, new_text, 1)
|
||||
elif old_text + "\n" in updated:
|
||||
occurrences = updated.count(old_text + "\n")
|
||||
if occurrences != 1:
|
||||
raise ValueError(f"{label}: hunk {idx} context matched {occurrences} times")
|
||||
updated = updated.replace(old_text + "\n", new_text + "\n", 1)
|
||||
else:
|
||||
raise ValueError(f"{label}: hunk {idx} context not found")
|
||||
return updated
|
||||
|
||||
class LsTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import collections
|
||||
@@ -10,6 +13,175 @@ DEFAULT_PYTHON_TIMEOUT = 60 * 60
|
||||
|
||||
PROGRESS_INTERVAL_S = 2.0
|
||||
PROGRESS_TAIL_LINES = 12
|
||||
TMUX_CAPTURE_LINES = 2000
|
||||
|
||||
|
||||
def _tmux_session_name(session_id: Optional[str]) -> str:
|
||||
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
|
||||
return f"ody-agent-{raw[:80] or 'default'}"
|
||||
|
||||
|
||||
async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return "", "timeout", 124
|
||||
return (
|
||||
out_b.decode("utf-8", errors="replace"),
|
||||
err_b.decode("utf-8", errors="replace"),
|
||||
proc.returncode or 0,
|
||||
)
|
||||
|
||||
|
||||
async def _tmux_has_session(name: str) -> bool:
|
||||
_, _, rc = await _run_exec("tmux", "has-session", "-t", name, timeout=3)
|
||||
return rc == 0
|
||||
|
||||
|
||||
async def _tmux_capture(name: str) -> str:
|
||||
out, _, _ = await _run_exec(
|
||||
"tmux", "capture-pane", "-p", "-J", "-S", f"-{TMUX_CAPTURE_LINES}", "-t", name,
|
||||
timeout=5,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def _tmux_send_line(name: str, line: str) -> None:
|
||||
if line:
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "-l", line, timeout=5)
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "C-m", timeout=5)
|
||||
|
||||
|
||||
async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None:
|
||||
if await _tmux_has_session(name):
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
|
||||
return
|
||||
await _run_exec(
|
||||
"tmux", "new-session", "-d", "-s", name, "-c", cwd,
|
||||
"env",
|
||||
f"TERM={env.get('TERM', 'xterm-256color') if env else 'xterm-256color'}",
|
||||
f"COLUMNS={env.get('COLUMNS', '120') if env else '120'}",
|
||||
f"LINES={env.get('LINES', '40') if env else '40'}",
|
||||
"/bin/bash",
|
||||
"--noprofile",
|
||||
"--norc",
|
||||
timeout=10,
|
||||
)
|
||||
if not await _tmux_has_session(name):
|
||||
raise RuntimeError(f"failed to create tmux session {name}")
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
|
||||
|
||||
|
||||
def _output_after_marker(capture: str, start_marker: str, end_marker: str) -> Tuple[str, bool]:
|
||||
lines = capture.splitlines()
|
||||
start_idx = -1
|
||||
for idx, line in enumerate(lines):
|
||||
if line.strip() == start_marker:
|
||||
start_idx = idx
|
||||
if start_idx < 0:
|
||||
return capture, False
|
||||
end_idx = -1
|
||||
for idx in range(start_idx + 1, len(lines)):
|
||||
if lines[idx].strip().startswith(end_marker):
|
||||
end_idx = idx
|
||||
if end_idx < 0:
|
||||
return "\n".join(lines[start_idx + 1:]), False
|
||||
return "\n".join(lines[start_idx + 1:end_idx]), True
|
||||
|
||||
|
||||
def _extract_marker_rc(capture: str, end_marker: str) -> int:
|
||||
for line in reversed(capture.splitlines()):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(end_marker):
|
||||
suffix = stripped[len(end_marker):].strip()
|
||||
if suffix.isdigit():
|
||||
return int(suffix)
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_tmux_bash(
|
||||
content: str,
|
||||
*,
|
||||
session_id: str,
|
||||
cwd: str,
|
||||
env: Optional[dict],
|
||||
timeout: float,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
) -> Tuple[str, str, Optional[int], bool]:
|
||||
name = _tmux_session_name(session_id)
|
||||
await _ensure_tmux_session(name, cwd, env)
|
||||
|
||||
stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}"
|
||||
start_marker = f"__ODYSSEUS_CMD_START_{stamp}__"
|
||||
end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:"
|
||||
wrapped = (
|
||||
f"printf '\\n{start_marker}\\n'\n"
|
||||
f"{content}\n"
|
||||
f"__ody_rc=$?\n"
|
||||
f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n"
|
||||
)
|
||||
for line in wrapped.splitlines():
|
||||
await _tmux_send_line(name, line)
|
||||
|
||||
started = time.time()
|
||||
last_tail = ""
|
||||
while True:
|
||||
capture = await _tmux_capture(name)
|
||||
body, done = _output_after_marker(capture, start_marker, end_prefix)
|
||||
tail = "\n".join(body.splitlines()[-PROGRESS_TAIL_LINES:])
|
||||
if progress_cb and tail != last_tail:
|
||||
last_tail = tail
|
||||
try:
|
||||
await progress_cb({
|
||||
"elapsed_s": round(time.time() - started, 1),
|
||||
"tail": tail,
|
||||
"tmux_session": name,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
if done:
|
||||
rc = _extract_marker_rc(capture, end_prefix)
|
||||
cleaned = _clean_tmux_command_output(body, wrapped)
|
||||
return cleaned, "", rc, False
|
||||
if time.time() - started > timeout:
|
||||
try:
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
cleaned = _clean_tmux_command_output(body, wrapped)
|
||||
return cleaned, "", 124, True
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
def _clean_tmux_command_output(text: str, wrapped_command: str) -> str:
|
||||
lines = text.splitlines()
|
||||
wrapped_lines = {ln.rstrip() for ln in wrapped_command.splitlines() if ln.strip()}
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
raw = line.rstrip()
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
cleaned.append(raw)
|
||||
continue
|
||||
if stripped in wrapped_lines:
|
||||
continue
|
||||
if stripped.startswith("__ody_rc=") or stripped.startswith("printf "):
|
||||
continue
|
||||
if re.fullmatch(r"(?:bash|sh)-[\d.]+\$ ?", stripped):
|
||||
continue
|
||||
if re.fullmatch(r"[\w.@:/~+-]+[#$] ?", stripped):
|
||||
continue
|
||||
cleaned.append(raw)
|
||||
return "\n".join(cleaned).strip()
|
||||
|
||||
async def _run_subprocess_streaming(
|
||||
proc: asyncio.subprocess.Process,
|
||||
@@ -103,8 +275,38 @@ async def _run_subprocess_streaming(
|
||||
class BashTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import agent_cwd, _truncate
|
||||
if isinstance(content, dict):
|
||||
content = str(content.get("command") or content.get("cmd") or content.get("code") or "")
|
||||
progress_cb = ctx.get("progress_cb")
|
||||
_subproc_env = ctx.get("subproc_env")
|
||||
session_id = ctx.get("session_id")
|
||||
if session_id and shutil.which("tmux"):
|
||||
stdout, stderr, rc, timed_out = await _run_tmux_bash(
|
||||
content,
|
||||
session_id=str(session_id),
|
||||
cwd=agent_cwd(),
|
||||
env=_subproc_env,
|
||||
timeout=DEFAULT_BASH_TIMEOUT,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
if timed_out:
|
||||
return {
|
||||
"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — sent Ctrl-C to tmux session",
|
||||
"exit_code": 124,
|
||||
"stdout": _truncate(stdout, MAX_OUTPUT_CHARS),
|
||||
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
|
||||
"tmux_session": _tmux_session_name(str(session_id)),
|
||||
}
|
||||
output = stdout.rstrip()
|
||||
err = stderr.rstrip()
|
||||
if err:
|
||||
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
|
||||
return {
|
||||
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
|
||||
"exit_code": rc or 0,
|
||||
"tmux_session": _tmux_session_name(str(session_id)),
|
||||
}
|
||||
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
content,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
|
||||
+327
-5
@@ -19,7 +19,7 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
||||
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
@@ -71,9 +71,10 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, resolve_endpoint_runtime
|
||||
from src.image_model_ids import looks_like_image_generation_model, model_id_leaf
|
||||
|
||||
|
||||
def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]:
|
||||
def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[str] = None) -> Tuple[str, str, Dict]:
|
||||
"""Resolve a model specifier to (endpoint_url, model_id, headers).
|
||||
|
||||
Accepts:
|
||||
@@ -97,9 +98,29 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
else:
|
||||
model_name = spec
|
||||
|
||||
def _json_list(value) -> list[str]:
|
||||
try:
|
||||
data = json.loads(value or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [str(x) for x in data if isinstance(x, (str, int, float)) and str(x)]
|
||||
|
||||
def _image_like(name: str) -> bool:
|
||||
n = (name or "").lower()
|
||||
if looks_like_image_generation_model(n):
|
||||
return True
|
||||
return any(k in n for k in (
|
||||
"qwen-image", "qwen/image", "z-image", "flux", "stable-diffusion",
|
||||
"sdxl", "hidream", "boogu", "krea-2", "image-edit",
|
||||
))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if model_type:
|
||||
query = query.filter(ModelEndpoint.model_type == model_type)
|
||||
if target_endpoint_name:
|
||||
query = query.filter(ModelEndpoint.name.ilike(f"%{target_endpoint_name}%"))
|
||||
if owner:
|
||||
@@ -129,11 +150,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
return build_chat_url(base), matched, headers
|
||||
else:
|
||||
# OpenAI-compatible and native Ollama: probe the provider's model list.
|
||||
endpoint_reachable = False
|
||||
try:
|
||||
models_url = build_models_url(base)
|
||||
if models_url:
|
||||
r = httpx.get(models_url, headers=headers, timeout=5)
|
||||
r.raise_for_status()
|
||||
endpoint_reachable = True
|
||||
data = r.json()
|
||||
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||
model_ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
@@ -144,10 +167,21 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
else:
|
||||
endpoint_reachable = True
|
||||
model_ids = json.loads(ep.cached_models or "[]")
|
||||
except Exception:
|
||||
model_ids = []
|
||||
|
||||
# Manual/local image endpoints are often registered with pinned
|
||||
# model ids, while /models may return a runtime alias or only the
|
||||
# served internal id. Include pinned/cached ids in the match set
|
||||
# so chat sessions using the HF repo id still resolve. Do not use
|
||||
# stale cached aliases when the endpoint itself is unreachable.
|
||||
if model_type == "image" and endpoint_reachable:
|
||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
||||
if extra not in model_ids:
|
||||
model_ids.append(extra)
|
||||
|
||||
# Exact match first
|
||||
for mid in model_ids:
|
||||
if mid.lower() == model_name.lower():
|
||||
@@ -158,6 +192,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
if model_name.lower() in mid.lower() or mid.lower() in model_name.lower():
|
||||
return build_chat_url(base), mid, headers
|
||||
|
||||
# Last resort for local image endpoints: if the requested model
|
||||
# name is clearly an image model, use the endpoint's first known
|
||||
# image model id. This prevents a harmless alias mismatch from
|
||||
# blocking image generation.
|
||||
if model_type == "image" and _image_like(model_name) and model_ids:
|
||||
return build_chat_url(base), model_ids[0], headers
|
||||
|
||||
raise ValueError(f"Model '{spec}' not found on any configured endpoint")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -967,16 +1008,36 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
if not model_spec:
|
||||
return {"error": "No image model found. Configure one in Admin → Image Generation."}
|
||||
|
||||
async def _resolve_image_model(model_name: str):
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_name, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
if "model_type" not in str(exc):
|
||||
raise
|
||||
return _resolve_model(model_name, owner=owner)
|
||||
return await asyncio.to_thread(_call)
|
||||
|
||||
# Resolve the model to find the right endpoint
|
||||
try:
|
||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||
try:
|
||||
url, model_id, headers = await _resolve_image_model(model_spec)
|
||||
except ValueError:
|
||||
_lower_model_spec = model_spec.lower()
|
||||
if not (
|
||||
any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e"))
|
||||
or looks_like_image_generation_model(_lower_model_spec)
|
||||
):
|
||||
raise
|
||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'. "
|
||||
"Configure an OpenAI-compatible endpoint with image generation support."}
|
||||
|
||||
# Detect if this is a GPT image model vs DALL-E vs local diffusion
|
||||
is_gpt_image = "gpt-image" in model_id.lower()
|
||||
is_dalle = "dall-e" in model_id.lower()
|
||||
_model_leaf = model_id_leaf(model_id)
|
||||
is_gpt_image = _model_leaf.startswith("gpt-image") or (_model_leaf.startswith("gpt-") and "-image" in _model_leaf)
|
||||
is_dalle = _model_leaf.startswith("dall-e")
|
||||
is_local_diffusion = not is_gpt_image and not is_dalle
|
||||
|
||||
# Build the images endpoint URL from the chat completions URL
|
||||
@@ -1106,6 +1167,267 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
return {"error": f"Image generation error: {str(e)}"}
|
||||
|
||||
|
||||
async def do_edit_image(
|
||||
prompt: str,
|
||||
image_path: str,
|
||||
model_spec: str = "",
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
size: str = "1024x1024",
|
||||
quality: str = "medium",
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Dict:
|
||||
"""Edit an uploaded image using the configured image endpoint."""
|
||||
import base64
|
||||
import httpx
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
prompt = (prompt or "").strip()
|
||||
if not prompt:
|
||||
return {"error": "Image edit prompt is required"}
|
||||
path = Path(image_path)
|
||||
if not path.exists() or not path.is_file():
|
||||
return {"error": "Attached image file was not found"}
|
||||
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
if not model_spec:
|
||||
return {"error": "No image model selected for image editing"}
|
||||
|
||||
try:
|
||||
try:
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
if "model_type" not in str(exc):
|
||||
raise
|
||||
return _resolve_model(model_spec, owner=owner)
|
||||
url, model_id, headers = await asyncio.to_thread(_call)
|
||||
except ValueError:
|
||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
edits_url = base_url + "/images/edits"
|
||||
mime = mimetypes.guess_type(str(path))[0] or "image/png"
|
||||
payload = {
|
||||
"model": model_id,
|
||||
"prompt": prompt,
|
||||
"n": "1",
|
||||
"size": size,
|
||||
"quality": quality if quality in ("low", "medium", "high", "auto") else "medium",
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
request_id = uuid.uuid4().hex
|
||||
payload["request_id"] = request_id
|
||||
|
||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
|
||||
|
||||
def _save_edited_image_to_gallery(filename: str) -> str:
|
||||
try:
|
||||
from src.database import SessionLocal as _GallerySL, GalleryImage
|
||||
new_id = str(uuid.uuid4())
|
||||
_gdb = _GallerySL()
|
||||
_gdb.add(GalleryImage(
|
||||
id=new_id,
|
||||
filename=filename,
|
||||
prompt=prompt,
|
||||
model=model_id,
|
||||
size=size,
|
||||
quality=payload.get("quality", "medium"),
|
||||
session_id=session_id,
|
||||
owner=owner,
|
||||
))
|
||||
_gdb.commit()
|
||||
_gdb.close()
|
||||
return new_id
|
||||
except Exception as _ge:
|
||||
logger.warning("Failed to save edited image gallery record: %s", _ge)
|
||||
return ""
|
||||
|
||||
def _save_image_bytes(image_bytes: bytes, suffix: str = ".png") -> tuple[str, str]:
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||
(img_dir / filename).write_bytes(image_bytes)
|
||||
return f"/api/generated-image/{filename}", _save_edited_image_to_gallery(filename)
|
||||
|
||||
async def _try_local_img2img_fallback(client: httpx.AsyncClient) -> Optional[Dict[str, Any]]:
|
||||
"""Try Odysseus' local diffusion img2img endpoint.
|
||||
|
||||
Some self-hosted SD/SDXL endpoints expose text-to-image plus
|
||||
`/images/harmonize`/img2img, but not OpenAI's multipart
|
||||
`/images/edits`. For chat uploads ("image + prompt"), this gives the
|
||||
expected instruction-edit behavior instead of stopping at a 400.
|
||||
"""
|
||||
harmonize_url = base_url + "/images/harmonize"
|
||||
try:
|
||||
image_bytes = path.read_bytes()
|
||||
image_b64 = base64.b64encode(image_bytes).decode()
|
||||
fallback_payload = {
|
||||
"image": image_b64,
|
||||
"prompt": prompt,
|
||||
"strength": 0.35,
|
||||
"steps": 0,
|
||||
"max_side": 1024,
|
||||
}
|
||||
if progress_callback:
|
||||
await progress_callback({
|
||||
"status": "running",
|
||||
"message": "Trying image-to-image fallback",
|
||||
"step": 0,
|
||||
"total": 0,
|
||||
})
|
||||
fallback_resp = await client.post(harmonize_url, json=fallback_payload, headers=headers)
|
||||
if fallback_resp.status_code == 404:
|
||||
return None
|
||||
if fallback_resp.status_code != 200:
|
||||
error_text = fallback_resp.text[:500]
|
||||
try:
|
||||
err_json = fallback_resp.json()
|
||||
error_text = err_json.get("detail") or err_json.get("error") or error_text
|
||||
except Exception:
|
||||
pass
|
||||
return {"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}"}
|
||||
fallback_data = fallback_resp.json()
|
||||
image_b64 = fallback_data.get("image")
|
||||
if not image_b64:
|
||||
return {"error": "Image edit fallback returned no image"}
|
||||
image_url, image_id = _save_image_bytes(base64.b64decode(image_b64))
|
||||
return {
|
||||
"results": f"Edited image for: {prompt[:100]}",
|
||||
"image_url": image_url,
|
||||
"image_id": image_id,
|
||||
"image_prompt": prompt,
|
||||
"image_model": model_id,
|
||||
"image_size": size,
|
||||
"image_quality": payload.get("quality", "medium"),
|
||||
"edit_route": "img2img",
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Image edit fallback timed out. The model may still be loading or overloaded."}
|
||||
except Exception as fallback_error:
|
||||
logger.warning("Image edit fallback failed: %s", fallback_error)
|
||||
return {"error": f"Image edit fallback error: {fallback_error}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=600.0, write=60.0, pool=30.0)) as client:
|
||||
progress_task = None
|
||||
if progress_callback:
|
||||
progress_url = base_url + f"/images/progress/{request_id}"
|
||||
|
||||
async def _poll_progress():
|
||||
last_sig = None
|
||||
while True:
|
||||
try:
|
||||
pr = await client.get(progress_url, headers=headers, timeout=5.0)
|
||||
if pr.status_code == 404:
|
||||
return
|
||||
if pr.status_code == 200:
|
||||
data = pr.json()
|
||||
sig = (data.get("status"), data.get("step"), data.get("total"), data.get("percent"))
|
||||
if sig != last_sig:
|
||||
last_sig = sig
|
||||
await progress_callback(data)
|
||||
if data.get("status") in {"done", "error"}:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
|
||||
progress_task = asyncio.create_task(_poll_progress())
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
files = {"image": (path.name, f, mime)}
|
||||
resp = await client.post(edits_url, data=payload, files=files, headers=headers)
|
||||
finally:
|
||||
if progress_task:
|
||||
progress_task.cancel()
|
||||
try:
|
||||
await progress_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text[:500]
|
||||
try:
|
||||
err_json = resp.json()
|
||||
err = err_json.get("error")
|
||||
error_text = (
|
||||
err.get("message", error_text)
|
||||
if isinstance(err, dict)
|
||||
else str(err or err_json.get("detail") or error_text)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if resp.status_code in (400, 404, 405, 422):
|
||||
fallback = await _try_local_img2img_fallback(client)
|
||||
if fallback:
|
||||
return fallback
|
||||
if resp.status_code == 404:
|
||||
return {
|
||||
"error": (
|
||||
f"Image model '{model_id}' is reachable, but this endpoint does not expose image editing. "
|
||||
"Use it without an attached image for text-to-image generation, or serve an edit/img2img "
|
||||
"model for attached-image prompts."
|
||||
)
|
||||
}
|
||||
return {"error": f"Image edit failed ({resp.status_code}): {error_text}"}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
if not images:
|
||||
return {"error": "No image returned from edit API"}
|
||||
|
||||
img = images[0]
|
||||
image_url = None
|
||||
image_id = None
|
||||
|
||||
if img.get("b64_json"):
|
||||
image_url, image_id = _save_image_bytes(base64.b64decode(img.get("b64_json")))
|
||||
elif img.get("url"):
|
||||
result_url = img["url"]
|
||||
ok, reason = check_outbound_url(
|
||||
result_url,
|
||||
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
return {"error": f"Image edit API returned unsafe image URL: {reason}"}
|
||||
dl_resp = httpx.get(result_url, timeout=60)
|
||||
if dl_resp.status_code != 200:
|
||||
return {"error": f"Could not download edited image ({dl_resp.status_code})"}
|
||||
image_url, image_id = _save_image_bytes(dl_resp.content)
|
||||
else:
|
||||
return {"error": "Image edit API returned unexpected format (no b64_json or url)"}
|
||||
|
||||
return {
|
||||
"results": f"Edited image for: {prompt[:100]}",
|
||||
"image_url": image_url,
|
||||
"image_id": image_id,
|
||||
"image_prompt": prompt,
|
||||
"image_model": model_id,
|
||||
"image_size": size,
|
||||
"image_quality": payload.get("quality", "medium"),
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
|
||||
except Exception as e:
|
||||
return {"error": f"Image edit error: {str(e)}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher (called from agent_tools.execute_tool_block)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+71
-2
@@ -77,6 +77,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
try:
|
||||
import json
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from src.constants import DATA_DIR
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
from src.memory import MemoryManager
|
||||
@@ -112,6 +113,64 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
ai_reasons = []
|
||||
ai_used = False
|
||||
|
||||
def _normalized_memory_text(mem: dict) -> str:
|
||||
text = (mem.get("text") or "").lower()
|
||||
text = re.sub(r"[^a-z0-9@._+-]+", " ", text)
|
||||
return " ".join(text.split())
|
||||
|
||||
def _memory_rank(mem: dict) -> tuple:
|
||||
text = (mem.get("text") or "").strip()
|
||||
return (
|
||||
1 if mem.get("pinned") else 0,
|
||||
1 if (mem.get("source") or "") == "user" else 0,
|
||||
int(mem.get("uses") or 0),
|
||||
-len(text),
|
||||
int(mem.get("timestamp") or 0),
|
||||
)
|
||||
|
||||
def _same_memory_fact(a: dict, b: dict) -> bool:
|
||||
a_cat = (a.get("category") or "fact").strip().lower()
|
||||
b_cat = (b.get("category") or "fact").strip().lower()
|
||||
if a_cat != b_cat:
|
||||
return False
|
||||
a_text = _normalized_memory_text(a)
|
||||
b_text = _normalized_memory_text(b)
|
||||
if not a_text or not b_text:
|
||||
return False
|
||||
if a_text == b_text:
|
||||
return True
|
||||
shorter, longer = sorted((a_text, b_text), key=len)
|
||||
if len(shorter) >= 24 and shorter in longer:
|
||||
return True
|
||||
return SequenceMatcher(None, a_text, b_text).ratio() >= 0.88
|
||||
|
||||
def _dedupe_group(group_memories: list) -> tuple[list, int]:
|
||||
kept = []
|
||||
removed = 0
|
||||
for mem in group_memories:
|
||||
text = (mem.get("text") or "").strip()
|
||||
if not text:
|
||||
removed += 1
|
||||
if len(removed_examples) < 3:
|
||||
removed_examples.append("(empty)")
|
||||
continue
|
||||
duplicate_idx = next(
|
||||
(idx for idx, kept_mem in enumerate(kept) if _same_memory_fact(mem, kept_mem)),
|
||||
None,
|
||||
)
|
||||
if duplicate_idx is None:
|
||||
kept.append(mem)
|
||||
continue
|
||||
removed += 1
|
||||
if _memory_rank(mem) > _memory_rank(kept[duplicate_idx]):
|
||||
if len(removed_examples) < 3:
|
||||
old_text = (kept[duplicate_idx].get("text") or "").strip()
|
||||
removed_examples.append(old_text[:60] + ("..." if len(old_text) > 60 else ""))
|
||||
kept[duplicate_idx] = mem
|
||||
elif len(removed_examples) < 3:
|
||||
removed_examples.append(text[:60] + ("..." if len(text) > 60 else ""))
|
||||
return kept, removed
|
||||
|
||||
async def _try_ai_tidy_group(group_owner: str, group_memories: list) -> bool:
|
||||
nonlocal all_memories, total_removed, total_cleaned, total_scanned, ai_used
|
||||
if len(group_memories) < 2:
|
||||
@@ -220,7 +279,6 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
kept_all.append(mem)
|
||||
|
||||
removed = sum(1 for m in group_memories if m.get("id") in drop_ids)
|
||||
total_scanned += len(group_memories)
|
||||
if removed or changed_text:
|
||||
all_memories = kept_all
|
||||
total_removed += removed
|
||||
@@ -237,12 +295,23 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
return False
|
||||
|
||||
for group_owner, group_memories in memory_groups.items():
|
||||
total_scanned += len(group_memories)
|
||||
deduped_group, group_removed = _dedupe_group(group_memories)
|
||||
if group_removed:
|
||||
group_ref_ids = {id(m) for m in group_memories}
|
||||
keep_ref_ids = {id(m) for m in deduped_group}
|
||||
all_memories = [
|
||||
m for m in all_memories
|
||||
if id(m) not in group_ref_ids or id(m) in keep_ref_ids
|
||||
]
|
||||
total_removed += group_removed
|
||||
group_memories = deduped_group
|
||||
|
||||
if await _try_ai_tidy_group(group_owner, group_memories):
|
||||
continue
|
||||
|
||||
seen = {}
|
||||
keep_refs = set()
|
||||
total_scanned += len(group_memories)
|
||||
for mem in group_memories:
|
||||
text = (mem.get("text") or "").strip()
|
||||
key = " ".join(text.lower().split())
|
||||
|
||||
+61
-15
@@ -87,6 +87,7 @@ _BUILTIN_NPX_SERVERS = {
|
||||
|
||||
# Global flag to disable MCP if there are compatibility issues
|
||||
MCP_DISABLED = os.environ.get("ODYSSEUS_DISABLE_MCP", "").lower() in ("1", "true", "yes")
|
||||
BROWSER_MCP_REQUIRE_CACHE = os.environ.get("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
# Strong references to the fire-and-forget startup tasks scheduled below.
|
||||
@@ -103,6 +104,46 @@ def _spawn_bg(coro) -> asyncio.Task:
|
||||
task.add_done_callback(_BG_TASKS.discard)
|
||||
return task
|
||||
|
||||
def _find_browser_executable() -> str:
|
||||
"""Find a browser binary for the built-in Playwright MCP server.
|
||||
|
||||
Docker images ship Debian's `chromium`; desktop installs may already have
|
||||
Chrome/Chromium in a conventional location. If nothing is found, return an
|
||||
empty string and let Playwright MCP use its own default browser/channel.
|
||||
"""
|
||||
configured = os.environ.get("ODYSSEUS_BROWSER_EXECUTABLE", "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
for name in ("google-chrome", "chromium", "chromium-browser"):
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
for candidate in (
|
||||
"/opt/google/chrome/chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
):
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _browser_mcp_args(args: list[str]) -> list[str]:
|
||||
"""Return Playwright MCP args with a concrete browser executable when found."""
|
||||
out = list(args or [])
|
||||
if "--executable-path" not in out:
|
||||
browser = _find_browser_executable()
|
||||
if browser:
|
||||
out.extend(["--executable-path", browser])
|
||||
if os.environ.get("ODYSSEUS_BROWSER_ISOLATED", "1").lower() not in ("0", "false", "no"):
|
||||
if "--isolated" not in out and "--user-data-dir" not in out:
|
||||
out.append("--isolated")
|
||||
if os.environ.get("ODYSSEUS_BROWSER_NO_SANDBOX", "1").lower() not in ("0", "false", "no"):
|
||||
if "--no-sandbox" not in out and "--sandbox" not in out:
|
||||
out.append("--no-sandbox")
|
||||
return out
|
||||
|
||||
|
||||
def builtin_python_env(base_dir: str) -> dict[str, str]:
|
||||
"""Environment for built-in Python MCP subprocesses.
|
||||
@@ -162,39 +203,44 @@ async def register_builtin_servers(mcp_manager):
|
||||
async def _start_npx_servers():
|
||||
await asyncio.sleep(3) # let Python servers finish first
|
||||
for server_id, cfg in _BUILTIN_NPX_SERVERS.items():
|
||||
# Skip the server if its npx package isn't cached. Without this
|
||||
# check, npx would try to download/install the package on first
|
||||
# use, which can take minutes (or hang) on fresh installs without
|
||||
# Playwright system deps. Wrapping that in asyncio.wait_for to
|
||||
# bound the wait sounds reasonable, but mcp.client.stdio uses an
|
||||
# internal anyio task group that can't survive the resulting
|
||||
# cross-task cancellation: it raises "Attempted to exit cancel
|
||||
# scope in a different task than it was entered in" in a sibling
|
||||
# task, which cascades cancellations into the rest of the event
|
||||
# loop and downs the app. Detecting installed-state up-front lets
|
||||
# us bail with a useful warning before we ever touch stdio_client.
|
||||
args = cfg["args"]
|
||||
# Browser automation is a shipped built-in, so the default path
|
||||
# lets `npx -y` install @playwright/mcp on first start. Locked-down
|
||||
# installs can opt back into the old no-network startup behavior
|
||||
# with ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE=1.
|
||||
args = _browser_mcp_args(cfg["args"]) if server_id == "builtin_browser" else list(cfg["args"])
|
||||
pkg_spec = _npx_package_from_args(args)
|
||||
if pkg_spec and not await _is_npx_package_cached(npx_path, pkg_spec):
|
||||
if BROWSER_MCP_REQUIRE_CACHE and pkg_spec and not await _is_npx_package_cached(npx_path, pkg_spec):
|
||||
logger.warning(
|
||||
f"{cfg['name']} is not available.\n"
|
||||
f" Reason: npm package {pkg_spec!r} is not installed in the npx cache.\n"
|
||||
f" Impact: tools provided by this MCP server will be unavailable.\n"
|
||||
f" Fix: {os.path.basename(npx_path)} -y {pkg_spec} --version\n"
|
||||
f" (run once, then restart Odysseus)\n"
|
||||
f" Notes: this server is optional; see README.md "
|
||||
f"'Built-in MCP servers' for details."
|
||||
f" Notes: ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE=1 is set, "
|
||||
f"so Odysseus will not install browser automation on startup."
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(f"Starting NPX server: {cfg['name']} ({npx_path} {' '.join(args)})")
|
||||
try:
|
||||
env = None
|
||||
if server_id == "builtin_browser":
|
||||
cache_home = os.environ.get(
|
||||
"ODYSSEUS_BROWSER_MCP_CACHE",
|
||||
os.path.join(base_dir, "data", "local", "playwright-mcp-cache"),
|
||||
)
|
||||
os.makedirs(cache_home, exist_ok=True)
|
||||
env = {
|
||||
"XDG_CACHE_HOME": cache_home,
|
||||
"PLAYWRIGHT_BROWSERS_PATH": os.path.join(cache_home, "browsers"),
|
||||
}
|
||||
ok = await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=cfg["name"],
|
||||
transport="stdio",
|
||||
command=npx_path,
|
||||
args=args,
|
||||
env=env,
|
||||
)
|
||||
if ok:
|
||||
logger.info(f"Built-in NPX server registered: {cfg['name']}")
|
||||
|
||||
+78
-8
@@ -89,6 +89,71 @@ class ChatProcessor:
|
||||
|
||||
# Minimum similarity score for RAG results to be injected
|
||||
RAG_SIMILARITY_THRESHOLD = 0.35
|
||||
MEMORY_CONTEXT_LIMIT = 5
|
||||
PINNED_MEMORY_LIMIT = MEMORY_CONTEXT_LIMIT
|
||||
|
||||
def _is_core_memory(self, memory: Dict[str, Any]) -> bool:
|
||||
"""Return whether a pinned memory is safe to keep globally available."""
|
||||
category = (memory.get("category") or "").lower()
|
||||
if category in {"identity", "contact"}:
|
||||
return True
|
||||
text = (memory.get("text") or "").lower()
|
||||
return any(marker in text for marker in (
|
||||
"my name is",
|
||||
"name is",
|
||||
"call me",
|
||||
"i am ",
|
||||
"i'm ",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
))
|
||||
|
||||
def _select_pinned_memories(self, message: str, pinned: list) -> list:
|
||||
"""Keep pinned memories high-priority without injecting all of them.
|
||||
|
||||
Pinned used to mean "always send every pinned memory to the model".
|
||||
That bloats every request and leaks unrelated personal context into
|
||||
tasks that do not need it. Now only a small set of core identity/contact
|
||||
memories is always available; other pinned memories must match the
|
||||
current request, but are retrieved before ordinary memories.
|
||||
"""
|
||||
if not pinned:
|
||||
return []
|
||||
|
||||
def _recent_first(memory: Dict[str, Any]) -> int:
|
||||
try:
|
||||
return int(memory.get("timestamp") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
core = sorted(
|
||||
[m for m in pinned if self._is_core_memory(m)],
|
||||
key=_recent_first,
|
||||
reverse=True,
|
||||
)[:self.PINNED_MEMORY_LIMIT]
|
||||
|
||||
core_ids = {m.get("id") for m in core if m.get("id")}
|
||||
contextual_candidates = [
|
||||
m for m in pinned
|
||||
if not (m.get("id") and m.get("id") in core_ids)
|
||||
]
|
||||
remaining_slots = max(self.PINNED_MEMORY_LIMIT - len(core), 0)
|
||||
contextual = self._hybrid_retrieve(
|
||||
message,
|
||||
contextual_candidates,
|
||||
k=remaining_slots,
|
||||
) if remaining_slots else []
|
||||
|
||||
selected = []
|
||||
seen = set()
|
||||
for memory in [*core, *contextual]:
|
||||
key = memory.get("id") or memory.get("text")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
selected.append(memory)
|
||||
return selected[:self.PINNED_MEMORY_LIMIT]
|
||||
|
||||
def _hybrid_retrieve(self, message: str, mem_entries: list, k: int = 5) -> list:
|
||||
"""Retrieve memories relevant to the message.
|
||||
@@ -242,7 +307,7 @@ class ChatProcessor:
|
||||
"content": UNTRUSTED_CONTEXT_POLICY,
|
||||
})
|
||||
|
||||
# Memory: pinned (always included) + extended (RAG-retrieved when relevant)
|
||||
# Memory: core pinned facts + relevant pinned/extended recall.
|
||||
self._last_used_memories = [] # track what was injected
|
||||
if use_memory:
|
||||
mem_entries = self.memory_manager.load(owner=owner)
|
||||
@@ -251,19 +316,24 @@ class ChatProcessor:
|
||||
extended = [m for m in mem_entries if not m.get("pinned")]
|
||||
|
||||
_used_ids: list = []
|
||||
if pinned:
|
||||
pinned_text = "\n- ".join([m["text"] for m in pinned])
|
||||
selected_pinned = self._select_pinned_memories(message, pinned)
|
||||
if selected_pinned:
|
||||
pinned_text = "\n- ".join([m["text"] for m in selected_pinned])
|
||||
preface.append(untrusted_context_message(
|
||||
"saved memory: pinned user facts",
|
||||
f"Core facts about the user:\n- {pinned_text}",
|
||||
"saved memory: pinned context",
|
||||
(
|
||||
"Pinned memory context. Some pinned memories are only "
|
||||
f"included when relevant:\n- {pinned_text}"
|
||||
),
|
||||
))
|
||||
for m in pinned:
|
||||
for m in selected_pinned:
|
||||
self._last_used_memories.append({"text": m["text"], "category": m.get("category", "fact"), "type": "pinned"})
|
||||
if m.get("id"):
|
||||
_used_ids.append(m["id"])
|
||||
|
||||
if extended:
|
||||
relevant = self._hybrid_retrieve(message, extended, k=3)
|
||||
remaining_memory_slots = max(self.MEMORY_CONTEXT_LIMIT - len(self._last_used_memories), 0)
|
||||
if extended and remaining_memory_slots:
|
||||
relevant = self._hybrid_retrieve(message, extended, k=remaining_memory_slots)
|
||||
if relevant:
|
||||
ext_text = "\n".join([f"- {m['text']}" for m in relevant])
|
||||
preface.append(untrusted_context_message(
|
||||
|
||||
@@ -7,6 +7,7 @@ Summarizes older messages via the same LLM, preserving key context.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.model_context import get_context_length, estimate_tokens
|
||||
@@ -70,6 +71,14 @@ What is the system/code/task state right now? What was the last thing discussed?
|
||||
Keep the summary under 1000 tokens. Be dense — every token should carry information. Do not include pleasantries or meta-commentary."""
|
||||
|
||||
|
||||
def normalize_compaction_summary(summary: str) -> str:
|
||||
"""Remove redundant leading title text before adding our wrapper."""
|
||||
text = (summary or "").strip()
|
||||
text = re.sub(r"^(?:#{1,3}\s*)?Conversation Summary\s*", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^\*\*Conversation Summary\*\*\s*", "", text, flags=re.IGNORECASE)
|
||||
return text.lstrip()
|
||||
|
||||
|
||||
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
|
||||
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
|
||||
|
||||
@@ -393,6 +402,7 @@ async def maybe_compact(
|
||||
# silently dropping the older half. was_compacted=False signals the
|
||||
# caller nothing was summarized; trim_for_context handles length.
|
||||
return messages, context_length, False
|
||||
summary = normalize_compaction_summary(summary)
|
||||
|
||||
summary_msg = {
|
||||
"role": "system",
|
||||
@@ -439,6 +449,7 @@ def _update_session_history(session, split_point: int, summary: str,
|
||||
# messages so the system prompt survives compaction.
|
||||
system_prefix = list(session.history[:system_msg_count])
|
||||
recent_history = session.history[effective_split:]
|
||||
summary = normalize_compaction_summary(summary)
|
||||
summary_msg = ChatMessage(
|
||||
role="system",
|
||||
content=f"[Conversation summary]\n{summary}",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Small helpers for recognizing image-generation model IDs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_IMAGE_MODEL_PREFIXES = (
|
||||
"gpt-image",
|
||||
"dall-e",
|
||||
"chatgpt-image",
|
||||
"hidream",
|
||||
"qwen-image",
|
||||
"z-image",
|
||||
"flux",
|
||||
"stable-diffusion",
|
||||
"sdxl",
|
||||
"boogu",
|
||||
"krea-2",
|
||||
)
|
||||
|
||||
|
||||
def model_id_leaf(model_id: str) -> str:
|
||||
"""Return the provider-stripped model id leaf in lowercase."""
|
||||
return str(model_id or "").strip().split("/")[-1].lower()
|
||||
|
||||
|
||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||
"""Return True when a model id should use image generation routes.
|
||||
|
||||
API providers can namespace image models, e.g. ``openai/gpt-5-image``.
|
||||
Classify by the leaf so mixed endpoints can expose chat and image models
|
||||
without marking the whole endpoint as image-only.
|
||||
"""
|
||||
mid = str(model_id or "").strip().lower()
|
||||
leaf = model_id_leaf(mid)
|
||||
if not leaf:
|
||||
return False
|
||||
if any(leaf.startswith(prefix) for prefix in _IMAGE_MODEL_PREFIXES):
|
||||
return True
|
||||
# Newer OpenAI image models use names like gpt-5-image instead of
|
||||
# gpt-image-1. Keep this pattern provider-agnostic.
|
||||
return leaf.startswith("gpt-") and "-image" in leaf
|
||||
|
||||
@@ -1040,6 +1040,31 @@ def _provider_label(url: str) -> str:
|
||||
return host or "provider"
|
||||
|
||||
|
||||
def _is_openai_hosted_chat_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
except Exception:
|
||||
return False
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
return _host_match(url, "openai.com") and path.endswith("/chat/completions")
|
||||
|
||||
|
||||
def _model_disallows_reasoning_effort_with_chat_tools(model: str) -> bool:
|
||||
"""OpenAI GPT 5.x variants reject reasoning_effort + tools on chat completions."""
|
||||
m = (model or "").strip().lower()
|
||||
return bool(re.match(r"^(?:openai/)?gpt-5(?:[.\-]\d+)?(?:[-_:].*)?$", m))
|
||||
|
||||
|
||||
def _scrub_openai_chat_tool_reasoning(payload: Dict, target_url: str, model: str) -> None:
|
||||
if not payload.get("tools"):
|
||||
return
|
||||
if not _is_openai_hosted_chat_url(target_url):
|
||||
return
|
||||
if not _model_disallows_reasoning_effort_with_chat_tools(model):
|
||||
return
|
||||
payload["reasoning_effort"] = "none"
|
||||
|
||||
|
||||
def _normalize_chatgpt_subscription_url(url: str) -> str:
|
||||
base = (url or "").strip().rstrip("/")
|
||||
if base.endswith("/responses"):
|
||||
@@ -2205,6 +2230,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||
payload["think"] = False
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
_scrub_openai_chat_tool_reasoning(payload, target_url, model)
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Cleanup helpers for images attached to chat sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _database_models():
|
||||
"""Import DB models at call time so early import stubs cannot stick here."""
|
||||
from core.database import ChatMessage, GalleryImage, SessionLocal
|
||||
|
||||
return ChatMessage, GalleryImage, SessionLocal
|
||||
|
||||
|
||||
def _generated_image_path_for_cleanup(filename: str) -> Path | None:
|
||||
if not isinstance(filename, str) or not filename:
|
||||
return None
|
||||
name = Path(filename).name
|
||||
if name != filename or name in {".", ".."}:
|
||||
return None
|
||||
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||
path = (root / name).resolve()
|
||||
try:
|
||||
if os.path.commonpath([str(root), str(path)]) != str(root):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _image_filename_from_url(url: str) -> str:
|
||||
if not isinstance(url, str) or not url:
|
||||
return ""
|
||||
match = re.search(r"/api/generated-image/([^?#/]+)", url)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def session_image_refs(db, session_id: str) -> tuple[set[str], set[str]]:
|
||||
"""Return gallery image ids and generated-image filenames referenced by a chat."""
|
||||
ChatMessage, GalleryImage, _ = _database_models()
|
||||
image_ids: set[str] = set()
|
||||
filenames: set[str] = set()
|
||||
|
||||
rows = db.query(GalleryImage).filter(GalleryImage.session_id == session_id).all()
|
||||
for img in rows:
|
||||
if img.id:
|
||||
image_ids.add(str(img.id))
|
||||
if img.filename:
|
||||
filenames.add(str(img.filename))
|
||||
|
||||
messages = db.query(ChatMessage.meta_data).filter(ChatMessage.session_id == session_id).all()
|
||||
for row in messages:
|
||||
raw = getattr(row, "meta_data", None)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
events = meta.get("tool_events") if isinstance(meta, dict) else None
|
||||
if not isinstance(events, list):
|
||||
continue
|
||||
for ev in events:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
image_id = ev.get("image_id")
|
||||
if image_id:
|
||||
image_ids.add(str(image_id))
|
||||
filename = _image_filename_from_url(ev.get("image_url") or ev.get("url") or "")
|
||||
if filename:
|
||||
filenames.add(filename)
|
||||
|
||||
return image_ids, filenames
|
||||
|
||||
|
||||
def cleanup_session_images(session_id: str, db=None) -> int:
|
||||
"""Soft-delete Gallery rows and unlink generated files owned by a chat."""
|
||||
_, GalleryImage, SessionLocal = _database_models()
|
||||
owns_db = db is None
|
||||
db = db or SessionLocal()
|
||||
try:
|
||||
image_ids, filenames = session_image_refs(db, session_id)
|
||||
query = db.query(GalleryImage).filter(GalleryImage.session_id == session_id)
|
||||
if image_ids or filenames:
|
||||
from sqlalchemy import or_
|
||||
|
||||
clauses = [GalleryImage.session_id == session_id]
|
||||
if image_ids:
|
||||
clauses.append(GalleryImage.id.in_(list(image_ids)))
|
||||
if filenames:
|
||||
clauses.append(GalleryImage.filename.in_(list(filenames)))
|
||||
query = db.query(GalleryImage).filter(or_(*clauses))
|
||||
|
||||
images = query.all()
|
||||
removed = 0
|
||||
for img in images:
|
||||
img.is_active = False
|
||||
if img.filename:
|
||||
path = _generated_image_path_for_cleanup(img.filename)
|
||||
if path and path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not remove generated image %s for deleted session %s: %s",
|
||||
img.filename,
|
||||
session_id,
|
||||
exc,
|
||||
)
|
||||
removed += 1
|
||||
|
||||
if owns_db and images:
|
||||
db.commit()
|
||||
return removed
|
||||
except Exception as exc:
|
||||
if owns_db:
|
||||
db.rollback()
|
||||
logger.warning("Failed to clean images for deleted session %s: %s", session_id, exc)
|
||||
return 0
|
||||
finally:
|
||||
if owns_db:
|
||||
db.close()
|
||||
+3
-5
@@ -64,11 +64,9 @@ DEFAULT_SETTINGS = {
|
||||
"search_url": "",
|
||||
"search_result_count": 5,
|
||||
# SafeSearch level applied to every provider that exposes one.
|
||||
# "strict" — block adult / explicit results (default; matches what users
|
||||
# expect from a research tool and avoids unrelated NSFW URLs
|
||||
# bleeding in via provider "related" / spam recommendations)
|
||||
# "moderate" — provider-default behavior (filter explicit but allow
|
||||
# suggestive content)
|
||||
# "strict" — apply the provider's strongest filtering level (default;
|
||||
# keeps unrelated low-quality/spam recommendations out)
|
||||
# "moderate" — provider-default filtering behavior
|
||||
# "off" — disable filtering entirely (advanced users only)
|
||||
#
|
||||
# Providers that honor this setting (translated to each provider's native
|
||||
|
||||
@@ -752,6 +752,11 @@ async def _execute_tool_block_impl(
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await _direct_fallback(tool, content, progress_cb=progress_cb) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool in ("apply_patch", "todowrite"):
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}" if first_line else tool
|
||||
result = await _direct_fallback(tool, content, session_id=session_id, owner=owner) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool == "manage_bg_jobs":
|
||||
# Inspect/kill detached `bash` jobs; needs session_id to scope to chat.
|
||||
desc = f"manage_bg_jobs: {content.split(chr(10))[0][:80]}"
|
||||
|
||||
+11
-5
@@ -47,7 +47,7 @@ ALWAYS_AVAILABLE = frozenset({
|
||||
# Tools that the Personal Assistant always has access to during scheduled
|
||||
# check-ins and proactive tasks, in addition to RAG-selected tools.
|
||||
ASSISTANT_ALWAYS_AVAILABLE = frozenset({
|
||||
"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email",
|
||||
"list_email_accounts", "list_emails", "read_email", "scan_email_unsubscribes", "unsubscribe_email", "send_email", "reply_to_email",
|
||||
"bulk_email", "archive_email", "delete_email", "mark_email_read",
|
||||
"manage_calendar", "manage_notes", "manage_tasks",
|
||||
"manage_memory", "web_search", "read_file",
|
||||
@@ -78,6 +78,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"get_workspace": "Return the absolute path of the active workspace folder the user is working in. File tools are confined to it; the shell starts there but is not sandboxed. Call this first when the user refers to 'the project'/'the code'/'this folder' without giving a path, instead of asking them.",
|
||||
"write_file": "Write/create or fully rewrite a file ON DISK (source code, configs, project files). Use for new files or full rewrites — NOT create_document (editor panel) and NOT a bash heredoc.",
|
||||
"edit_file": "Edit an existing file ON DISK by exact string replacement (fix a bug, change a function). Shows a diff. The tool for changing files on disk — NOT edit_document (editor panel) and NOT bash sed/heredoc.",
|
||||
"apply_patch": "Apply a multi-file patch to source files ON DISK. Use for implementation, refactors, and bug fixes where several edits belong together. Workspace-confined and returns a diff. Prefer over bash redirects/heredocs/sed.",
|
||||
"todowrite": "Maintain a structured task list for the current coding session. Use for multi-step code work: inspect, edit, test, and mark statuses current.",
|
||||
"create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.",
|
||||
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
|
||||
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
|
||||
@@ -109,6 +111,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
|
||||
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
|
||||
"scan_email_unsubscribes": "Scan recent email headers for spam/newsletter unsubscribe candidates. Review-only; returns UIDs, reasons, and mailto/web unsubscribe methods.",
|
||||
"unsubscribe_email": "Execute an approved unsubscribe action by UID. Mailto methods are sent/staged; web URL methods return exact browser/web instructions.",
|
||||
"send_email": "Send a new email via SMTP. Provide recipient, subject, body, and optional account from list_email_accounts. For replying to a thread use reply_to_email instead.",
|
||||
"reply_to_email": "SEND a reply email immediately by UID. Do not use for write/draft/open/start reply requests; use ui_control open_email_reply with body so the user can review. Only use when the user explicitly says to send now. For send requests, use the exact UID and account from latest read_email/list_emails output; never invent UID 1. Threads automatically with In-Reply-To/References, prefixes Re:, marks original as Answered.",
|
||||
"archive_email": "Move an email out of the inbox into the Archive folder. Use after handling messages you want to keep but get out of the way.",
|
||||
@@ -127,8 +131,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"list_downloads": "List in-progress HuggingFace model downloads in the Cookbook. Shows model name, phase, percent, session ID. Use for 'what's downloading', 'show my downloads', 'check download progress'.",
|
||||
"cancel_download": "Cancel an in-progress model download by tmux session ID. Use for 'cancel the download', 'stop downloading X', 'kill the download'. Call list_downloads first to get the session_id.",
|
||||
"search_hf_models": "Search HuggingFace for models matching a query (e.g. 'qwen 8B', 'flux', 'llama-3 instruct'). Returns ranked repo IDs with sizes and download counts. Use for 'find a model', 'search huggingface for X', 'what models are there for Y'.",
|
||||
"list_cached_models": "List models already cached on disk locally or on a remote host. Accepts friendly Cookbook server names like ajax. Use for 'what models do I have', 'show cached models', 'is X downloaded', 'list my models'. Avoids re-downloading.",
|
||||
"list_serve_presets": "List saved Cookbook serve presets (templates with model+host+port+cmd). Always call this BEFORE serve_model when the user asks to launch a known model — they probably have a preset for it from the UI.",
|
||||
"list_cached_models": "List models already cached on disk locally or on a remote host. Accepts friendly Cookbook server names like workstation. Use for 'what models do I have', 'show cached models', 'is X downloaded', 'list my models'. Avoids re-downloading.",
|
||||
"list_serve_presets": "List saved Cookbook serve presets (templates with model+host+port+cmd). Call this BEFORE raw serve_model when the user asks to launch a known model manually.",
|
||||
"serve_preset": "Launch a saved Cookbook serve preset by name. Reuses the exact tmux command + host the user already saved. Use for 'run stable diffusion 3.5', 'serve vllm-qwen', 'start the inpaint model' — preset-name matches the user's UI labels.",
|
||||
"adopt_served_model": "Register an existing tmux model server (one started manually or outside the cookbook flow) into Cookbook tracking AND add it as a chat endpoint. Use when the user (or a previous turn) launched something via ssh+tmux and now wants it visible in the UI, stoppable via stop_served_model, and usable in the model picker.",
|
||||
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
|
||||
@@ -347,7 +351,7 @@ class ToolIndex:
|
||||
# whole email toolset and crowding out the relevant tools — the model then
|
||||
# believed it had only email tools and refused web/other tasks (#1707).
|
||||
frozenset({"email", "emails", "mail", "mails", "gmail", "googlemail", "message", "messages", "send", "reply", "replies", "inbox", "unread"}):
|
||||
{"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
|
||||
{"list_email_accounts", "list_emails", "read_email", "scan_email_unsubscribes", "unsubscribe_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
|
||||
frozenset({"calendar", "event", "meeting", "schedule", "appointment"}):
|
||||
{"manage_calendar"},
|
||||
# Detached background `bash` jobs (#!bg): check on / read output / kill.
|
||||
@@ -471,8 +475,10 @@ class ToolIndex:
|
||||
{"list_served_models", "stop_served_model"},
|
||||
# Cookbook serve / launch / preset / server selection
|
||||
frozenset({"serve", "launch", "spin up", "start the model", "run the model",
|
||||
"debug launch", "launch command", "drivers", "driver",
|
||||
"preset", "presets", "which server", "what servers",
|
||||
"gpu box", "cookbook server", "vllm", "on the server", "on the gpu"}):
|
||||
"gpu box", "cookbook server", "vllm", "sglang", "mlx", "llama.cpp",
|
||||
"on the server", "on the gpu"}):
|
||||
{"serve_preset", "serve_model", "list_serve_presets",
|
||||
"list_cookbook_servers", "list_cached_models"},
|
||||
# Cookbook downloads
|
||||
|
||||
@@ -250,6 +250,11 @@ _TOOL_NAME_MAP = {
|
||||
"write": "write_file",
|
||||
"write_file": "write_file",
|
||||
"save": "write_file",
|
||||
"apply_patch": "apply_patch",
|
||||
"patch": "apply_patch",
|
||||
"todowrite": "todowrite",
|
||||
"todo_write": "todowrite",
|
||||
"todo_update": "todowrite",
|
||||
"document": "update_document",
|
||||
"update_document": "update_document",
|
||||
"create_document": "create_document",
|
||||
|
||||
+87
-5
@@ -25,6 +25,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
|
||||
"read_file": ("path",),
|
||||
"write_file": ("path",),
|
||||
"edit_file": ("path",),
|
||||
"apply_patch": ("patch_text", "patchText", "patch"),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -192,6 +193,49 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "apply_patch",
|
||||
"description": "Apply a multi-file source-code patch to disk. Use for real project files in the workspace when several edits belong together. Patch must use *** Begin Patch / *** End Patch with Add File, Update File, or Delete File sections. Prefer this over bash redirects/heredocs/sed.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"patch_text": {
|
||||
"type": "string",
|
||||
"description": "Patch text beginning with *** Begin Patch and ending with *** End Patch"
|
||||
}
|
||||
},
|
||||
"required": ["patch_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todowrite",
|
||||
"description": "Create and maintain a structured task list for the current coding session. Use during multi-step implementation/debug/refactor work and keep statuses current.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"description": "Current task list. Only one item should be in_progress.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "Task description"},
|
||||
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
|
||||
"priority": {"type": "string", "enum": ["low", "medium", "high"]}
|
||||
},
|
||||
"required": ["content", "status"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["todos"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -814,12 +858,12 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "serve_model",
|
||||
"description": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. If `host` is omitted, defaults to the cookbook's selected server (not localhost). For image/inpainting/diffusion models use the built-in command `python3 scripts/diffusion_server.py --model <repo> --port 8100` rather than inventing a custom diffusers API server. After launching, call list_served_models to check readiness/errors; if it reports a diagnosis with retry suggestions, retry via serve_model using the suggested adjusted cmd.",
|
||||
"description": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, MLX Image, or Diffusers. If `host` is omitted, defaults to the cookbook's selected server (not localhost). For MLX image models on Apple Silicon use `python3 scripts/mlx_image_server.py --model <repo> --port 8100`; for non-MLX image/inpainting/diffusion models use `python3 scripts/diffusion_server.py --model <repo> --port 8100`. Never serve image models with `mlx_lm.server`; that is only for text/chat MLX models. After launching, call list_served_models to check readiness/errors; if it reports a diagnosis with retry suggestions, retry via serve_model using the suggested adjusted cmd.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_id": {"type": "string", "description": "Model repo (e.g. 'Qwen/Qwen3-8B')"},
|
||||
"cmd": {"type": "string", "description": "Full serve command (e.g. 'vllm serve Qwen/Qwen3-8B --port 8000 --tp 2', 'python3 -m sglang.launch_server --model-path Qwen/Qwen3-8B --port 30000', or for inpainting/image models: 'python3 scripts/diffusion_server.py --model diffusers/stable-diffusion-xl-1.0-inpainting-0.1 --port 8100')"},
|
||||
"cmd": {"type": "string", "description": "Full serve command (e.g. 'vllm serve <repo> --port 8000 --tp 2', 'python3 -m sglang.launch_server --model-path <repo> --port 30000', for MLX image models: 'python3 scripts/mlx_image_server.py --model <repo> --port 8100', or for non-MLX image models: 'python3 scripts/diffusion_server.py --model <repo> --port 8100')"},
|
||||
"host": {"type": "string", "description": "Target server — friendly NAME from list_cookbook_servers (e.g. 'gpu-box', 'workstation') or raw user@host. Omit to use the cookbook's selected default."},
|
||||
"local": {"type": "boolean", "description": "Force serve on THIS machine instead of the default remote server."},
|
||||
},
|
||||
@@ -913,7 +957,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_serve_presets",
|
||||
"description": "List saved Cookbook serve presets. Each preset is a launch template (name, model, host, port, tmux cmd) the user previously saved from the UI. Call this BEFORE serve_model when the user asks to launch a model by name — there's almost always a working preset for it.",
|
||||
"description": "List saved Cookbook serve presets. Each preset is a launch template (name, model, host, port, tmux cmd) the user previously saved from the UI. Call this BEFORE raw serve_model when the user asks to launch a model by name manually.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
@@ -954,11 +998,11 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_cached_models",
|
||||
"description": "List models already cached on disk locally or on a remote server. `host` accepts friendly Cookbook server names from list_cookbook_servers (for example ajax) or raw user@host. Also reports completed Cookbook download tasks when the filesystem cache scan cannot locate the HF cache path.",
|
||||
"description": "List models already cached on disk locally or on a remote server. `host` accepts friendly Cookbook server names from list_cookbook_servers (for example workstation) or raw user@host. Also reports completed Cookbook download tasks when the filesystem cache scan cannot locate the HF cache path.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Friendly Cookbook server name (e.g. 'ajax', 'gpu-box') or raw remote host (e.g. 'user@gpu-box'). Omit for local."},
|
||||
"host": {"type": "string", "description": "Friendly Cookbook server name (e.g. 'workstation', 'gpu-box') or raw remote host (e.g. 'user@gpu-box'). Omit for local."},
|
||||
"model_dir": {"type": "string", "description": "Comma-separated additional model directories to scan beyond ~/.cache/huggingface/hub"},
|
||||
"ssh_port": {"type": "string", "description": "SSH port for remote host (default 22)"},
|
||||
"platform": {"type": "string", "enum": ["linux", "windows"], "description": "Remote platform"}
|
||||
@@ -1114,6 +1158,40 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "scan_email_unsubscribes",
|
||||
"description": "Scan recent email headers for likely spam/newsletter unsubscribe candidates. Does not unsubscribe anything. Review candidates with the user before acting; mailto methods can be executed with unsubscribe_email, web URL methods require browser/web tools after approval.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder": {"type": "string", "description": "IMAP folder to scan (default: INBOX)"},
|
||||
"limit": {"type": "integer", "description": "Maximum candidates to return (default: 25)"},
|
||||
"max_scan": {"type": "integer", "description": "How many newest emails to inspect (default: 150)"},
|
||||
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "unsubscribe_email",
|
||||
"description": "Execute one approved unsubscribe action for an email UID. Safe mailto List-Unsubscribe methods are sent/staged. Web URL methods return a requires-browser instruction and exact URL; use browser/web tools only after user approval.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uid": {"type": "string", "description": "Email UID from scan_email_unsubscribes/list_emails"},
|
||||
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
|
||||
"method_index": {"type": "integer", "description": "Method index from scan_email_unsubscribes (default: 0)"},
|
||||
"allow_web": {"type": "boolean", "description": "Return browser/web instructions when selected method is URL"},
|
||||
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts"},
|
||||
},
|
||||
"required": ["uid"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1367,6 +1445,10 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "edit_file":
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "apply_patch":
|
||||
content = args.get("patch_text") or args.get("patchText") or args.get("patch") or ""
|
||||
elif tool_type == "todowrite":
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "create_document":
|
||||
parts = [args.get("title", "Untitled")]
|
||||
if args.get("language"):
|
||||
|
||||
@@ -19,6 +19,8 @@ BUILTIN_EMAIL_TOOLS = frozenset({
|
||||
"list_emails",
|
||||
"read_email",
|
||||
"search_emails",
|
||||
"scan_email_unsubscribes",
|
||||
"unsubscribe_email",
|
||||
"send_email",
|
||||
"reply_to_email",
|
||||
"draft_email",
|
||||
@@ -44,6 +46,7 @@ NON_ADMIN_BLOCKED_TOOLS = BUILTIN_EMAIL_TOOLS | {
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"grep",
|
||||
"glob",
|
||||
"ls",
|
||||
@@ -110,6 +113,7 @@ PLAN_MODE_READONLY_TOOLS = {
|
||||
# classified — see the plan-mode partition test in
|
||||
# tests/test_email_registry_sync.py.
|
||||
"search_emails",
|
||||
"scan_email_unsubscribes",
|
||||
"list_served_models",
|
||||
"list_downloads",
|
||||
"list_cached_models",
|
||||
@@ -136,14 +140,15 @@ PLAN_MODE_READONLY_TOOLS = {
|
||||
# here — read-only tools are covered by the allowlist. Keep in sync when adding
|
||||
# new mutating tools.
|
||||
_PLAN_MODE_KNOWN_MUTATORS = {
|
||||
"write_file", "create_document", "edit_document", "update_document",
|
||||
"write_file", "edit_file", "apply_patch", "todowrite",
|
||||
"create_document", "edit_document", "update_document",
|
||||
"suggest_document", "manage_documents", "create_session", "manage_session",
|
||||
"send_to_session", "pipeline", "manage_memory", "manage_skills",
|
||||
"manage_tasks", "manage_notes", "manage_endpoints", "manage_mcp",
|
||||
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
|
||||
"manage_calendar", "api_call", "app_api", "ui_control",
|
||||
"send_email", "reply_to_email", "bulk_email", "delete_email",
|
||||
"archive_email", "mark_email_read",
|
||||
"archive_email", "mark_email_read", "unsubscribe_email",
|
||||
# The draft tools create documents and download_attachment writes to
|
||||
# disk — mutating. They have no native schemas (yet), so without these
|
||||
# static entries plan-mode safety for their bare fence tags would depend
|
||||
|
||||
+186
-1
@@ -33,6 +33,57 @@ def _validate_cookbook_ssh_target(remote_host: Any, ssh_port: Any = "") -> tuple
|
||||
return remote, sport
|
||||
|
||||
|
||||
def _cookbook_label_key(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
|
||||
|
||||
|
||||
def _cookbook_is_exact_repo_id(value: Any) -> bool:
|
||||
return bool(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", str(value or "").strip()))
|
||||
|
||||
|
||||
def _cookbook_match_saved_preset(query: str, presets: List[Any], host: str = "") -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a user-facing model label to a saved serve preset.
|
||||
|
||||
The launch agent should be callable directly. If the model says
|
||||
`repo_id="Qwen3.6-27B-AEON"` because the user used the short UI label, do
|
||||
not force it through `list_serve_presets`; match the saved preset inside
|
||||
the autopilot and let `do_serve_preset` reuse the known-good command.
|
||||
"""
|
||||
q = _cookbook_label_key(query)
|
||||
if not q:
|
||||
return None
|
||||
host = str(host or "")
|
||||
exact_repo = _cookbook_is_exact_repo_id(query)
|
||||
candidates: List[tuple[int, Dict[str, Any]]] = []
|
||||
for p in presets or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
name = str(p.get("name") or "")
|
||||
model = str(p.get("model") or p.get("modelId") or "")
|
||||
phost = str(p.get("host") or p.get("remoteHost") or "")
|
||||
haystacks = [_cookbook_label_key(name), _cookbook_label_key(model)]
|
||||
if exact_repo:
|
||||
# If the user gave a real HF repo, only reuse a preset that names
|
||||
# that exact repo/label. A substring match here is dangerous:
|
||||
# cyankiwi/Qwen3.5-122B-A10B-AWQ-8bit must not launch the saved
|
||||
# generic Qwen/Qwen3.5-122B-A10B preset.
|
||||
if not any(h and q == h for h in haystacks):
|
||||
continue
|
||||
else:
|
||||
if not any(h and (q == h or q in h or h in q) for h in haystacks):
|
||||
continue
|
||||
score = 10
|
||||
if host and phost == host:
|
||||
score += 20
|
||||
if q in haystacks:
|
||||
score += 10
|
||||
candidates.append((score, p))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda item: item[0], reverse=True)
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
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}]}.
|
||||
@@ -200,7 +251,7 @@ async def _ensure_served_endpoint(
|
||||
port = _infer_serve_port(cmd)
|
||||
base_url = f"http://{endpoint_host}:{port}/v1"
|
||||
short_name = model.split("/")[-1] if "/" in model else model
|
||||
is_image = "diffusion_server.py" in (cmd or "")
|
||||
is_image = "diffusion_server.py" in (cmd or "") or "mlx_image_server.py" in (cmd or "")
|
||||
payload = {
|
||||
"name": short_name if not is_image else f"{short_name} (image)",
|
||||
"base_url": base_url,
|
||||
@@ -315,6 +366,7 @@ async def _cookbook_register_task(
|
||||
_MODEL_PROCESS_PATTERNS = [
|
||||
("vLLM", ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
|
||||
("SGLang", ["sglang.launch_server", "sglang/launch_server"]),
|
||||
("MLX Image", ["mlx_image_server.py", "mflux-generate-qwen", "mflux-generate"]),
|
||||
("MLX", ["mlx_lm.server", "mlx-lm"]),
|
||||
("llama.cpp", ["llama-server", "llama_cpp_server", "llamacppserver"]),
|
||||
("Ollama", ["ollama serve", "ollama runner", "/ollama "]),
|
||||
@@ -358,6 +410,139 @@ def _cookbook_apply_retry_suggestion(cmd: str, suggestion: Dict[str, Any]) -> st
|
||||
return cmd
|
||||
|
||||
|
||||
def _cookbook_engine_from_model_info(repo_id: str, info: Optional[Dict[str, Any]], host_meta: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Choose a conservative serve engine from repo metadata and target host.
|
||||
|
||||
This is intentionally heuristic: the model card / file list tells us the
|
||||
official repo and likely format, while the actual serve command still goes
|
||||
through Cookbook and is diagnosed/retried after launch.
|
||||
"""
|
||||
rid = (repo_id or "").lower()
|
||||
info = info or {}
|
||||
host_meta = host_meta or {}
|
||||
platform = (host_meta.get("platform") or "").lower()
|
||||
tags = {str(t).lower() for t in (info.get("tags") or []) if t is not None}
|
||||
siblings = [str(s).lower() for s in (info.get("siblings") or []) if s is not None]
|
||||
files = " ".join(siblings)
|
||||
is_image = (
|
||||
"diffusers" in tags
|
||||
or "text-to-image" in tags
|
||||
or "image-to-image" in tags
|
||||
or any(k in rid for k in ("qwen-image", "z-image", "flux", "stable-diffusion", "sdxl", "hidream", "boogu", "krea-2"))
|
||||
)
|
||||
is_mlx_image = is_image and ("mlx" in tags or "mlx" in rid or "mlx-community/" in rid)
|
||||
if is_mlx_image:
|
||||
return "mlx_image"
|
||||
if is_image:
|
||||
return "diffusers"
|
||||
if "mlx" in tags or "mlx" in rid or "mlx-community/" in rid or platform in {"macos", "darwin"}:
|
||||
return "mlx"
|
||||
if "gguf" in tags or "gguf" in rid or ".gguf" in files:
|
||||
return "llama.cpp"
|
||||
if "sglang" in tags or "sglang" in rid:
|
||||
return "sglang"
|
||||
if any(q in rid or q in files or q in tags for q in ("awq", "fp8", "gptq", "bnb", "bitsandbytes")):
|
||||
return "vllm"
|
||||
return "vllm"
|
||||
|
||||
|
||||
def _cookbook_default_launch_cmd(repo_id: str, engine: str, *, port: int = 8000, info: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build a simple first-attempt command for a selected engine."""
|
||||
engine = (engine or "vllm").lower()
|
||||
port = int(port or 8000)
|
||||
if engine in {"mlx", "mlx-lm", "mlx_lm"}:
|
||||
return f"python3 -m mlx_lm.server --model {repo_id} --host 0.0.0.0 --port {port}"
|
||||
if engine in {"mlx_image", "mlx-image", "mflux"}:
|
||||
return f"python3 scripts/mlx_image_server.py --model {repo_id} --host 0.0.0.0 --port {port}"
|
||||
if engine in {"diffusers", "diffusion", "image"}:
|
||||
return f"python3 scripts/diffusion_server.py --model {repo_id} --host 0.0.0.0 --port {port}"
|
||||
if engine in {"sglang", "sgl"}:
|
||||
return f"python3 -m sglang.launch_server --model-path {repo_id} --host 0.0.0.0 --port {port}"
|
||||
if engine in {"llama.cpp", "llamacpp", "llama"}:
|
||||
siblings = [str(s) for s in ((info or {}).get("siblings") or []) if str(s).lower().endswith(".gguf")]
|
||||
if siblings:
|
||||
# llama-server accepts HF repo + filename separately on recent builds.
|
||||
return f"llama-server -hf {repo_id} -hfr {siblings[0]} --host 0.0.0.0 --port {port}"
|
||||
return f"llama-server -hf {repo_id} --host 0.0.0.0 --port {port}"
|
||||
return f"vllm serve {repo_id} --host 0.0.0.0 --port {port}"
|
||||
|
||||
|
||||
async def _cookbook_hf_model_info(repo_id: str) -> Dict[str, Any]:
|
||||
"""Fetch lightweight official Hugging Face metadata for launch planning.
|
||||
|
||||
Uses the public HF API directly so this works even when huggingface_hub is
|
||||
not installed in Odysseus. Failures return a structured warning rather than
|
||||
blocking launch; cached/private/offline models can still be served.
|
||||
"""
|
||||
import httpx
|
||||
from routes.cookbook_helpers import load_stored_hf_token
|
||||
|
||||
repo_id = (repo_id or "").strip().strip("/")
|
||||
if not repo_id:
|
||||
return {"error": "repo_id is required"}
|
||||
headers: Dict[str, str] = {"Accept": "application/json"}
|
||||
token = load_stored_hf_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
url = f"https://huggingface.co/api/models/{repo_id}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"url": f"https://huggingface.co/{repo_id}",
|
||||
"error": f"HF metadata lookup returned HTTP {resp.status_code}",
|
||||
}
|
||||
data = resp.json() if resp.content else {}
|
||||
except Exception as e:
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"url": f"https://huggingface.co/{repo_id}",
|
||||
"error": f"HF metadata lookup failed: {e}",
|
||||
}
|
||||
siblings = []
|
||||
for s in data.get("siblings") or []:
|
||||
if isinstance(s, dict) and s.get("rfilename"):
|
||||
siblings.append(s["rfilename"])
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"url": f"https://huggingface.co/{repo_id}",
|
||||
"pipeline_tag": data.get("pipeline_tag") or "",
|
||||
"library_name": data.get("library_name") or "",
|
||||
"tags": data.get("tags") or [],
|
||||
"sha": data.get("sha") or "",
|
||||
"private": bool(data.get("private")),
|
||||
"gated": data.get("gated"),
|
||||
"siblings": siblings[:500],
|
||||
"cardData": data.get("cardData") or {},
|
||||
}
|
||||
|
||||
|
||||
def _cookbook_host_meta(host: str, servers: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for item in servers.get("hosts") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if (item.get("host") or "") == (host or ""):
|
||||
return item
|
||||
return {}
|
||||
|
||||
|
||||
def _cookbook_find_task(tasks: List[Dict[str, Any]], session_id: str) -> Optional[Dict[str, Any]]:
|
||||
for task in tasks or []:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
if task.get("session_id") == session_id or task.get("sessionId") == session_id or task.get("id") == session_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def _cookbook_phase(task: Optional[Dict[str, Any]]) -> str:
|
||||
if not task:
|
||||
return "unknown"
|
||||
return str(task.get("phase") or task.get("status") or "unknown").lower()
|
||||
|
||||
|
||||
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
|
||||
|
||||
+29
-2
@@ -32,8 +32,35 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(f"{_INTERNAL_BASE}/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}
|
||||
new_id = data.get("id") or data.get("image_id")
|
||||
if data.get("success") or new_id:
|
||||
result = {
|
||||
"output": f"Image edited ({action}). New image ID: {new_id or '?'}",
|
||||
"exit_code": 0,
|
||||
}
|
||||
if new_id:
|
||||
result["image_id"] = new_id
|
||||
try:
|
||||
from src.database import GalleryImage, SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(GalleryImage.id == new_id)
|
||||
if owner:
|
||||
q = q.filter(GalleryImage.owner == owner)
|
||||
img = q.first()
|
||||
if img and img.filename:
|
||||
result.update({
|
||||
"image_url": f"/api/generated-image/{img.filename}",
|
||||
"image_prompt": img.prompt or args.get("prompt") or action,
|
||||
"image_model": img.model or "edit_image",
|
||||
"image_size": img.size or "",
|
||||
"image_quality": img.quality or "",
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
return {"error": data.get("error", f"{action} failed"), "exit_code": 1}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
+38
-15
@@ -280,7 +280,30 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
if not args.get("action") and any(args.get(k) is not None for k in ("task", "description", "schedule", "time", "day_of_week")):
|
||||
args["action"] = "create"
|
||||
if args.get("task") and not args.get("name"):
|
||||
args["name"] = args["task"]
|
||||
if args.get("task") and not args.get("prompt"):
|
||||
args["prompt"] = args["task"]
|
||||
action = args.get("action", "list")
|
||||
if args.get("description") and not args.get("prompt"):
|
||||
args["prompt"] = args["description"]
|
||||
if args.get("time") and not args.get("scheduled_time"):
|
||||
args["scheduled_time"] = args["time"]
|
||||
if args.get("day_of_week") is not None and args.get("scheduled_day") is None:
|
||||
day = str(args.get("day_of_week")).strip().lower()
|
||||
days = {
|
||||
"monday": 0, "mon": 0,
|
||||
"tuesday": 1, "tue": 1, "tues": 1,
|
||||
"wednesday": 2, "wed": 2,
|
||||
"thursday": 3, "thu": 3, "thur": 3, "thurs": 3,
|
||||
"friday": 4, "fri": 4,
|
||||
"saturday": 5, "sat": 5,
|
||||
"sunday": 6, "sun": 6,
|
||||
}
|
||||
if day in days:
|
||||
args["scheduled_day"] = days[day]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if action == "list":
|
||||
@@ -288,21 +311,21 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
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}
|
||||
if not tasks:
|
||||
return {"response": "No scheduled tasks found.", "exit_code": 0}
|
||||
|
||||
lines = [f"Found {len(tasks)} tasks:"]
|
||||
for idx, t in enumerate(tasks, 1):
|
||||
bits = [t.status or "unknown"]
|
||||
if t.schedule:
|
||||
bits.append(str(t.schedule))
|
||||
if t.scheduled_time:
|
||||
bits.append(str(t.scheduled_time))
|
||||
if t.next_run:
|
||||
bits.append(f"next {t.next_run.isoformat()}Z")
|
||||
detail = ", ".join(bits)
|
||||
lines.append(f"{idx}. {t.name} ({t.id}) — {detail}")
|
||||
return {"response": "\n".join(lines), "exit_code": 0}
|
||||
|
||||
elif action == "create":
|
||||
task_type = args.get("task_type", "llm")
|
||||
|
||||
Reference in New Issue
Block a user