forked from enderofwings/NexusOS
feat: self-alteration tools (edit_playbook, edit_settings, edit_source)
Gives the assistant three new ACTION tools to change its own playbooks,
runtime settings, and (source checkout only) its own source code, all
reusing the existing run_snippet/remember approval framework — but with
a hardcoded floor (self_edit.ALWAYS_ASK_TOOLS) so these three always pause
for per-call human approval regardless of the global action_tool_policy
setting. Flipping that policy for an unrelated tool must never silently
also unlock unattended self-modification.
synapse/self_edit.py is the new module doing the actual work, documented
in the same explicit "here's what is and isn't a security boundary" style
as code_run.py:
- edit_source is confined to settings.project_root via the same
realpath + Path.parents boundary check that just closed a sibling-
directory bypass in /icons/image, plus a denylist of dangerous
subtrees (.git, the venv, node_modules, build output, runtime state).
Gated on settings.source_checkout — refuses cleanly in a wheel
install, where there's no live repo to edit or commit into.
- The model sends full file content, never a diff; the server computes
the diff itself via difflib against what's actually on disk, so a
human reviews ground truth, not a description the model wrote.
- Every applied source edit best-effort commits to git as an audit
trail — independent of, not a substitute for, the approval gate.
- edit_playbook merges instead of replacing (main.py's prior
_persist_playbook did a raw replace, which was only safe because the
frontend form always sent a complete object — unsafe for a tool a
model calls with a partial argument set, so this also fixes that
latent bug). Becoming the active system prompt requires an explicit
make_active flag, never a side effect of an ordinary edit.
- edit_settings reuses the existing _SETTINGS_DEFAULTS allowlist.
The approval UI (Chatbot.jsx) previously rendered a tool call's arguments
as Object.values(args).join(", ") in a single-line badge — unusable for
reviewing a diff. It now renders a real, server-computed preview (diff
for source, before/after for playbook/settings) via a new shared
diff-view.js helper, with a loud banner when a change would become the
active system prompt or touch action_tool_policy/system_prompt. A new
nexus-edit fence (self-edit-langs.js + Markdown.jsx's EditBlock) shows
the same diff after an edit is applied, mirroring nexus-run.
Verified: 212 backend tests pass (29 new in test_self_edit.py; the 12
pre-existing C/C++/Rust toolchain failures are unrelated and unchanged),
57 frontend node:test cases pass (15 new), eslint and vite build clean,
and the full approval-preview render path was exercised against the real
built UI with a mocked SSE stream covering all four preview branches
(source diff, playbook becomes-main, settings policy-change, and a
rejected/failing preview).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+23
-6
@@ -10,6 +10,7 @@ from typing import AsyncGenerator, Dict, List, Optional, Any
|
||||
from .nexus_config import settings, DEFAULT_CHAT_MODEL
|
||||
from .ollama_manager import get_ollama_manager
|
||||
from . import tools as _tools
|
||||
from . import self_edit
|
||||
|
||||
# Cap on tool-call round-trips before the final answer — stops a confused small
|
||||
# model from looping forever.
|
||||
@@ -349,9 +350,17 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
messages.append(msg)
|
||||
|
||||
# If any action tool needs per-call approval, pause and wait for the user.
|
||||
# edit_playbook/edit_settings/edit_source always require it, regardless of
|
||||
# `policy` — a global "allow" set for convenience on an unrelated tool
|
||||
# (web_search, say) must never silently also unlock unattended
|
||||
# self-modification. See self_edit.ALWAYS_ASK_TOOLS.
|
||||
decisions = None
|
||||
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
|
||||
if policy == "ask" and action_calls:
|
||||
needs_approval = policy == "ask" or any(
|
||||
c.get("function", {}).get("name", "") in self_edit.ALWAYS_ASK_TOOLS
|
||||
for c in action_calls
|
||||
)
|
||||
if needs_approval and action_calls:
|
||||
event = asyncio.Event()
|
||||
# Single-use capability token, delivered only to the client that owns
|
||||
# this stream. /chat/approve requires it, so knowing the (guessable,
|
||||
@@ -359,13 +368,21 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
# else's pending action.
|
||||
token = secrets.token_urlsafe(32)
|
||||
pending_approvals[conversation_id] = {"event": event, "decisions": {}, "token": token}
|
||||
|
||||
def _action_entry(c):
|
||||
name = c.get("function", {}).get("name", "")
|
||||
args = c.get("function", {}).get("arguments")
|
||||
entry = {"name": name, "arguments": args}
|
||||
if name in self_edit.PREVIEWABLE:
|
||||
try:
|
||||
entry["preview"] = self_edit.preview_for(name, args or {})
|
||||
except Exception as e:
|
||||
entry["preview"] = {"ok": False, "error": f"preview failed: {e}"}
|
||||
return entry
|
||||
|
||||
yield "__approve__" + _json.dumps({
|
||||
"token": token,
|
||||
"actions": [
|
||||
{"name": c.get("function", {}).get("name", ""),
|
||||
"arguments": c.get("function", {}).get("arguments")}
|
||||
for c in action_calls
|
||||
],
|
||||
"actions": [_action_entry(c) for c in action_calls],
|
||||
})
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=_APPROVAL_TIMEOUT)
|
||||
|
||||
+9
-31
@@ -28,6 +28,7 @@ from .chat import generate_chat_response, stream_chat_response, _synapse_trace
|
||||
from . import chat as _chat
|
||||
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
||||
from . import frontend_manager as _frontend_manager
|
||||
from . import playbook_manager
|
||||
from .playbook_manager import PlaybookManager
|
||||
from . import tools as _tools
|
||||
|
||||
@@ -205,7 +206,7 @@ async def _generate_conversation_title(first_message: str, model: str) -> Option
|
||||
|
||||
|
||||
from .memory.store import store, MemoryItem
|
||||
from .playbooks.store import playbook_store, PlaybookItem
|
||||
from .playbooks.store import playbook_store
|
||||
from .search import needs_web_search, web_search
|
||||
|
||||
MEMORY_SERVICE = settings.memory_url
|
||||
@@ -1031,37 +1032,14 @@ def _find_playbook_by_id(playbook_id: str) -> Tuple[Optional[Any], Optional[str]
|
||||
|
||||
|
||||
def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Persist a playbook dict to the store by converting to PlaybookItem.
|
||||
"""
|
||||
"""Persist a playbook dict to the store. Thin wrapper over the shared,
|
||||
mergeable implementation in playbook_manager — this call site always does a
|
||||
full replace (merge=False), matching the frontend form's behavior of always
|
||||
submitting a complete object."""
|
||||
try:
|
||||
# Preserve existing order on update; use provided order (or tail) on create
|
||||
existing = playbook_store.get_playbook(str(playbook_dict["id"]))
|
||||
order = existing.order if existing else playbook_dict.get("order", len(playbook_store.all_playbooks()))
|
||||
|
||||
playbook_item = PlaybookItem(
|
||||
id=str(playbook_dict["id"]),
|
||||
title=playbook_dict.get("title", ""),
|
||||
goal=playbook_dict.get("goal", ""),
|
||||
instructions=playbook_dict.get("instructions", ""),
|
||||
tags=playbook_dict.get("tags", []),
|
||||
tools=playbook_dict.get("tools", []),
|
||||
model=playbook_dict.get("model", ""),
|
||||
order=order
|
||||
)
|
||||
|
||||
playbook_store.add_playbook(playbook_item)
|
||||
|
||||
# Return as dict
|
||||
return {
|
||||
"id": playbook_item.id,
|
||||
"title": playbook_item.title,
|
||||
"goal": playbook_item.goal,
|
||||
"instructions": playbook_item.instructions,
|
||||
"tags": playbook_item.tags,
|
||||
"tools": playbook_item.tools,
|
||||
"model": playbook_item.model,
|
||||
}
|
||||
return playbook_manager.persist_playbook(dict(playbook_dict), merge=False)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
|
||||
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
from .playbooks.store import playbook_store, PlaybookItem
|
||||
|
||||
# Fields a caller can supply; anything else in a persist dict is ignored.
|
||||
# `order` is deliberately excluded — see persist_playbook.
|
||||
_EDITABLE_FIELDS = ("title", "goal", "instructions", "tags", "tools", "model")
|
||||
_FIELD_DEFAULTS = {"title": "", "goal": "", "instructions": "", "tags": [], "tools": [], "model": ""}
|
||||
|
||||
|
||||
def persist_playbook(data: dict, *, merge: bool) -> dict:
|
||||
"""Write a playbook dict to the store, returning it as a plain dict.
|
||||
|
||||
`merge=False` is today's behavior (main.py's HTTP form handlers): a full
|
||||
replace, with pydantic defaults for anything omitted. `merge=True` (used by
|
||||
the edit_playbook tool) instead keeps each field's *existing* value when the
|
||||
caller's dict doesn't supply it — a model calling with a partial argument
|
||||
set must not silently blank out the fields it didn't mention.
|
||||
|
||||
`order` is never taken from `data` in merge mode: it is preserved from the
|
||||
existing playbook on update, or appended at the tail on create. Position 0
|
||||
is unconditionally the active system prompt (see get_main_playbook) — moving
|
||||
a playbook there is `make_main`'s job, never an accidental side effect of an
|
||||
ordinary field edit.
|
||||
"""
|
||||
existing_id = str(data.get("id") or "")
|
||||
existing = playbook_store.get_playbook(existing_id) if existing_id else None
|
||||
|
||||
if merge:
|
||||
fields = {}
|
||||
for key in _EDITABLE_FIELDS:
|
||||
if key in data and data[key] is not None:
|
||||
fields[key] = data[key]
|
||||
elif existing is not None:
|
||||
fields[key] = getattr(existing, key)
|
||||
else:
|
||||
fields[key] = _FIELD_DEFAULTS[key]
|
||||
if existing is None and not (fields["title"] and fields["goal"] and fields["instructions"]):
|
||||
raise ValueError("title, goal, and instructions are required to create a new playbook")
|
||||
else:
|
||||
fields = {key: data.get(key, _FIELD_DEFAULTS[key]) for key in _EDITABLE_FIELDS}
|
||||
|
||||
order = existing.order if existing else data.get("order", len(playbook_store.all_playbooks()))
|
||||
item = PlaybookItem(id=existing_id or str(uuid4()), order=order, **fields)
|
||||
playbook_store.add_playbook(item)
|
||||
return item.model_dump()
|
||||
|
||||
|
||||
def make_main(playbook_id: str) -> None:
|
||||
"""Reorder so `playbook_id` is position 0 (the active system prompt)."""
|
||||
all_ids = [p.id for p in playbook_store.all_playbooks()]
|
||||
if playbook_id not in all_ids:
|
||||
raise ValueError(f"no playbook with id {playbook_id!r}")
|
||||
ordered = [playbook_id] + [pid for pid in all_ids if pid != playbook_id]
|
||||
playbook_store.reorder_playbooks(ordered)
|
||||
|
||||
|
||||
class PlaybookManager:
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""The assistant's ability to change what it is: its own playbooks, its own
|
||||
runtime settings, and (in a source checkout only) its own source files.
|
||||
|
||||
WHAT THIS IS NOT
|
||||
----------------
|
||||
Not a way to skip human review. Every function here is either read-only
|
||||
(the `preview_*`/`diff_text` functions, safe to call before anything is
|
||||
approved) or is only ever reached after the per-call approval round-trip in
|
||||
chat.py — and `edit_playbook`/`edit_settings`/`edit_source` are *always*
|
||||
gated that way, regardless of the global `action_tool_policy` setting (see
|
||||
`ALWAYS_ASK_TOOLS`). This module is the mechanism the approval actually acts
|
||||
on, layered:
|
||||
|
||||
1. Consent edit_* tools are ACTION tools with a hardcoded approval
|
||||
floor — chat.py pauses for Approve/Deny even when the
|
||||
global policy is "allow", so flipping that setting for an
|
||||
unrelated tool (web_search, say) can never silently unlock
|
||||
unattended self-modification too.
|
||||
2. Real diff the human reviews a diff computed here, server-side, from
|
||||
what is actually on disk versus the model's proposed
|
||||
`new_content` — never a diff or description the model wrote
|
||||
itself. A model can misdescribe a change; it cannot make
|
||||
difflib misreport one.
|
||||
3. Boundary edit_source is confined to one root (settings.project_root)
|
||||
via the same realpath + `Path.parents` check that closed a
|
||||
sibling-directory bypass in main.py's /icons/image, plus a
|
||||
denylist of dangerous subtrees inside that root (.git, the
|
||||
venv, node_modules, build output, runtime state).
|
||||
4. Audit trail every applied source edit is committed to git (best-effort;
|
||||
a missing git binary or a non-repo root degrades the result
|
||||
to `commit: None`, it never blocks the write). This is a
|
||||
reversibility net, not a substitute for layer 1 — the
|
||||
approval already happened before anything is written.
|
||||
5. Size caps MAX_FILE_CHARS/MAX_DIFF_CHARS bound what a single call can
|
||||
submit or what the approval UI has to render, mirroring
|
||||
code_run.py's MAX_SOURCE/MAX_OUTPUT.
|
||||
|
||||
A file edit does not hot-reload the running process. Python does not re-import
|
||||
a changed module on its own, and the frontend's production build is the static
|
||||
`dist/` the backend serves — editing interface/web/src/*.jsx only affects a
|
||||
developer's own `npm run dev` session, if one happens to be running. Every
|
||||
successful edit_source result says so explicitly, because both the model and
|
||||
the human reviewing it will otherwise reasonably expect an instant effect that
|
||||
does not happen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .memory.store import store
|
||||
from .nexus_config import settings
|
||||
from .playbooks.store import playbook_store
|
||||
from . import playbook_manager
|
||||
|
||||
MAX_FILE_CHARS = 200_000 # chars of proposed file content accepted
|
||||
MAX_DIFF_CHARS = 20_000 # chars of diff text shown/returned, clipped like code_run.MAX_OUTPUT
|
||||
|
||||
# Subdirectories of settings.project_root that edit_source must never touch,
|
||||
# even though they're inside the one allowed root. `data`/`runtime` land here
|
||||
# in a source checkout (see nexus_config.py DATA_DIR/RUNTIME_DIR) and are owned
|
||||
# by their own tools (edit_settings, the memory store), not raw file writes.
|
||||
_DENYLIST_SUBDIRS = frozenset({
|
||||
".git", "Promethean", "node_modules", "dist", "__pycache__",
|
||||
"runtime", "data", ".venv", "venv",
|
||||
})
|
||||
|
||||
# Tools that must always pause for human approval, regardless of the global
|
||||
# action_tool_policy setting. Shared by tools.py (ACTION_TOOLS membership) and
|
||||
# chat.py (the approval-gating condition) so there is one definition of the
|
||||
# floor, not two that could drift apart.
|
||||
ALWAYS_ASK_TOOLS = frozenset({"edit_playbook", "edit_settings", "edit_source"})
|
||||
|
||||
# Tool names whose approval payload gets a computed, human-readable preview
|
||||
# attached before the human ever sees the Approve/Deny prompt.
|
||||
PREVIEWABLE = frozenset({"edit_source", "edit_playbook", "edit_settings"})
|
||||
|
||||
WINDOWS = os.name == "nt"
|
||||
_NO_WINDOW = subprocess.CREATE_NO_WINDOW if WINDOWS else 0
|
||||
|
||||
|
||||
class PathError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_source_path(rel_path: str) -> Path:
|
||||
"""A path the model gave us, resolved and boundary-checked against
|
||||
settings.project_root. Raises PathError with a human-readable reason on
|
||||
any rejection — the same message is shown in the pre-approval preview and
|
||||
returned as the tool's error, so both audiences see exactly why."""
|
||||
raw = (rel_path or "").strip()
|
||||
if not raw:
|
||||
raise PathError("path is required")
|
||||
# Cheap rejection before ever touching the filesystem: an absolute path or
|
||||
# a Windows drive prefix is never a legitimate "file in this project" path.
|
||||
if raw.startswith(("/", "\\")) or (len(raw) > 1 and raw[1] == ":"):
|
||||
raise PathError(f"path must be relative to the project root, not absolute: {raw!r}")
|
||||
|
||||
root = Path(os.path.realpath(str(settings.project_root)))
|
||||
candidate = root / raw
|
||||
real = Path(os.path.realpath(str(candidate)))
|
||||
|
||||
# Same real == root or root in real.parents pattern as
|
||||
# icons/compositor.py::_is_allowed_path — a bare str.startswith() here
|
||||
# would let a sibling directory that merely shares a prefix pass, exactly
|
||||
# the bug just fixed in main.py's /icons/image.
|
||||
if not (real == root or root in real.parents):
|
||||
raise PathError(f"path escapes the project root: {raw!r}")
|
||||
|
||||
try:
|
||||
top = real.relative_to(root).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
top = ""
|
||||
if top in _DENYLIST_SUBDIRS:
|
||||
raise PathError(f"{top}/ is off-limits to edit_source — use its own tool if there is one")
|
||||
|
||||
return real
|
||||
|
||||
|
||||
def source_checkout_required() -> None:
|
||||
"""Raise if this install has no live, editable source tree to write into.
|
||||
|
||||
In a wheel install, synapse/ lives inside site-packages with no sibling
|
||||
repo — settings.project_root would just be the installed package dir, and
|
||||
there is nothing to commit into. Same precedent as nexusos_cli/ncp.py
|
||||
refusing to start the Vite dev server in a wheel install."""
|
||||
if not settings.source_checkout:
|
||||
raise RuntimeError(
|
||||
"edit_source is unavailable — this is not a source checkout, so "
|
||||
"there is no live project tree to edit or commit into."
|
||||
)
|
||||
|
||||
|
||||
def diff_text(path_display: str, before: str, after: str) -> str:
|
||||
lines = difflib.unified_diff(
|
||||
before.splitlines(keepends=True),
|
||||
after.splitlines(keepends=True),
|
||||
fromfile=path_display,
|
||||
tofile=path_display,
|
||||
)
|
||||
text = "".join(lines)
|
||||
if len(text) <= MAX_DIFF_CHARS:
|
||||
return text
|
||||
return text[:MAX_DIFF_CHARS] + f"\n... [truncated at {MAX_DIFF_CHARS} characters, {len(text)} total]"
|
||||
|
||||
|
||||
def preview_source_edit(path: str, new_content: str) -> dict:
|
||||
"""Read-only: never raises. Computes the real diff between what's on disk
|
||||
and the proposed new_content, so the approval UI shows ground truth before
|
||||
anything is written. Degrades to {"ok": False, "error": ...} on any
|
||||
rejection (bad path, wheel install, oversized content) rather than
|
||||
crashing the approval payload — the human still sees why it would fail."""
|
||||
try:
|
||||
source_checkout_required()
|
||||
real = _resolve_source_path(path)
|
||||
new_content = new_content or ""
|
||||
if len(new_content) > MAX_FILE_CHARS:
|
||||
return {"ok": False, "error": f"new_content is over {MAX_FILE_CHARS} characters"}
|
||||
before = real.read_text(encoding="utf-8") if real.is_file() else ""
|
||||
display = str(real.relative_to(Path(os.path.realpath(str(settings.project_root)))))
|
||||
return {
|
||||
"ok": True,
|
||||
"path": display,
|
||||
"is_new_file": not real.is_file(),
|
||||
"diff": diff_text(display, before, new_content),
|
||||
}
|
||||
except (PathError, RuntimeError) as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"could not compute preview: {e}"}
|
||||
|
||||
|
||||
def _git_commit(real_path: Path, summary: str) -> str | None:
|
||||
"""Best-effort audit-trail commit. Never raises, never undoes the write
|
||||
that already happened — a missing git binary or a project root that isn't
|
||||
a repo just means commit stays None."""
|
||||
root = str(settings.project_root)
|
||||
try:
|
||||
rel = str(real_path.relative_to(Path(os.path.realpath(root))))
|
||||
message = f"self-edit: {(summary or rel)[:180]}"
|
||||
subprocess.run(
|
||||
["git", "add", "--", rel], cwd=root, check=True,
|
||||
capture_output=True, creationflags=_NO_WINDOW,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", message, "--", rel], cwd=root, check=True,
|
||||
capture_output=True, creationflags=_NO_WINDOW,
|
||||
)
|
||||
sha = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"], cwd=root, check=True,
|
||||
capture_output=True, text=True, creationflags=_NO_WINDOW,
|
||||
)
|
||||
return sha.stdout.strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def apply_source_edit(path: str, new_content: str, summary: str = "") -> dict:
|
||||
"""Write an approved edit_source call. Re-validates everything preview did
|
||||
— never trust that nothing changed between preview and approval — then
|
||||
writes, diffs against the pre-write content, and commits."""
|
||||
try:
|
||||
source_checkout_required()
|
||||
real = _resolve_source_path(path)
|
||||
new_content = new_content or ""
|
||||
if len(new_content) > MAX_FILE_CHARS:
|
||||
return {"ok": False, "error": f"new_content is over {MAX_FILE_CHARS} characters"}
|
||||
except (PathError, RuntimeError) as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
before = real.read_text(encoding="utf-8") if real.is_file() else ""
|
||||
display = str(real.relative_to(Path(os.path.realpath(str(settings.project_root)))))
|
||||
real.parent.mkdir(parents=True, exist_ok=True)
|
||||
real.write_text(new_content, encoding="utf-8")
|
||||
return {
|
||||
"ok": True,
|
||||
"path": display,
|
||||
"diff": diff_text(display, before, new_content),
|
||||
"commit": _git_commit(real, summary),
|
||||
}
|
||||
|
||||
|
||||
def preview_playbook_edit(args: dict) -> dict:
|
||||
"""Read-only before/after preview for edit_playbook. Mirrors the merge
|
||||
semantics of playbook_manager.persist_playbook(merge=True) without writing
|
||||
anything, so the approval UI shows exactly what will actually change."""
|
||||
try:
|
||||
pb_id = str(args.get("id") or "")
|
||||
existing = playbook_store.get_playbook(pb_id) if pb_id else None
|
||||
make_active = bool(args.get("make_active"))
|
||||
fields = {}
|
||||
for key in ("title", "goal", "instructions", "tags", "tools", "model"):
|
||||
if key in args and args[key] is not None:
|
||||
fields[key] = args[key]
|
||||
elif existing is not None:
|
||||
fields[key] = getattr(existing, key)
|
||||
else:
|
||||
fields[key] = [] if key in ("tags", "tools") else ""
|
||||
if existing is None and not (fields["title"] and fields["goal"] and fields["instructions"]):
|
||||
return {"ok": False, "error": "title, goal, and instructions are required to create a new playbook"}
|
||||
is_new = existing is None
|
||||
becomes_main = make_active or (is_new and not playbook_store.all_playbooks())
|
||||
return {
|
||||
"ok": True,
|
||||
"is_new": is_new,
|
||||
"before": existing.model_dump() if existing else None,
|
||||
"after": fields,
|
||||
"becomes_main_playbook": becomes_main,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"could not compute preview: {e}"}
|
||||
|
||||
|
||||
def preview_settings_edit(args: dict) -> dict:
|
||||
"""Read-only before/after preview for edit_settings, split into keys that
|
||||
will actually apply versus ones update_settings would silently ignore —
|
||||
the approval UI must never imply an unknown key will take effect."""
|
||||
try:
|
||||
changes = args.get("changes") or {}
|
||||
current = store.get_settings()
|
||||
applied: dict[str, dict[str, Any]] = {}
|
||||
ignored_unknown: list[str] = []
|
||||
for key, value in changes.items():
|
||||
if key in store._SETTINGS_DEFAULTS:
|
||||
applied[key] = {"before": current.get(key), "after": value}
|
||||
else:
|
||||
ignored_unknown.append(key)
|
||||
return {
|
||||
"ok": True,
|
||||
"applied": applied,
|
||||
"ignored_unknown": ignored_unknown,
|
||||
"policy_change": "action_tool_policy" in applied,
|
||||
"system_prompt_change": "system_prompt" in applied,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"could not compute preview: {e}"}
|
||||
|
||||
|
||||
def preview_for(name: str, args: dict) -> dict:
|
||||
"""Single dispatch entry point chat.py calls to enrich an approval payload."""
|
||||
if name == "edit_source":
|
||||
return preview_source_edit(args.get("path", ""), args.get("new_content", ""))
|
||||
if name == "edit_playbook":
|
||||
return preview_playbook_edit(args)
|
||||
if name == "edit_settings":
|
||||
return preview_settings_edit(args)
|
||||
return {"ok": False, "error": f"no previewer for {name}"}
|
||||
+189
-6
@@ -3,10 +3,15 @@
|
||||
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
||||
`message.tool_calls`, and this module is just the registry + dispatch.
|
||||
|
||||
Most tools READ local state (memory, history, documents, models). A few act:
|
||||
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
|
||||
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
|
||||
boundary — an action tool only fires when a playbook explicitly lists it.
|
||||
Most tools READ local state (memory, history, documents, models). Some act:
|
||||
`web_search`/`fetch_url` make outbound HTTP requests, `remember` WRITES a
|
||||
memory fact, and `edit_playbook`/`edit_settings`/`edit_source` change the
|
||||
assistant's own playbooks, settings, and (source checkout only) source code —
|
||||
see synapse/self_edit.py for what that last group actually does and does not
|
||||
protect against. The per-playbook allowlist (`PlaybookItem.tools`) is the
|
||||
first gate — an action tool only fires when a playbook explicitly lists it —
|
||||
and the three self-edit tools additionally always pause for per-call approval
|
||||
regardless of the global action_tool_policy (self_edit.ALWAYS_ASK_TOOLS).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,6 +19,8 @@ import json
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from . import code_run
|
||||
from . import playbook_manager
|
||||
from . import self_edit
|
||||
from .memory.store import store, MemoryItem
|
||||
from .ollama_manager import get_ollama_manager
|
||||
|
||||
@@ -888,6 +895,81 @@ async def _run_snippet(
|
||||
})
|
||||
|
||||
|
||||
# Result fence for the three self-edit tools: same "carry the change and what
|
||||
# happened to it in one block" idea as _run_fence, so the model can't paste a
|
||||
# claimed diff that doesn't match what was actually applied.
|
||||
_EDIT_FENCE_LANG = "nexus-edit"
|
||||
|
||||
|
||||
def _edit_fence(payload: dict) -> str:
|
||||
body = json.dumps(payload, ensure_ascii=False).replace("`", "\\u0060")
|
||||
return f"```{_EDIT_FENCE_LANG}\n{body}\n```"
|
||||
|
||||
|
||||
async def _edit_source(path: str = "", new_content: str = "", summary: str = "", **_) -> str:
|
||||
"""ACTION tool: writes a file in the live project tree and commits it. See
|
||||
synapse/self_edit.py for the boundary check, the size cap, and exactly what
|
||||
the git commit does and doesn't guarantee."""
|
||||
import asyncio as _a
|
||||
|
||||
result = await _a.to_thread(self_edit.apply_source_edit, path, new_content or "", summary or "")
|
||||
if result.get("ok"):
|
||||
result["instruction"] = (
|
||||
"This was written and committed for real. Paste the fence unchanged, then "
|
||||
"tell the user plainly that a restart is needed for it to take effect — "
|
||||
"this file is not reloaded into the running process."
|
||||
)
|
||||
result["fence"] = _edit_fence({"kind": "source", **result})
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
async def _edit_playbook(
|
||||
id: str = "", title: str = "", goal: str = "", instructions: str = "",
|
||||
tags: list | None = None, tools: list | None = None, model: str = "",
|
||||
make_active: bool = False, **_,
|
||||
) -> str:
|
||||
"""ACTION tool: create or update a playbook. Fields left unset keep their
|
||||
current value — this merges, it does not replace. make_active is a
|
||||
separate, explicit flag: without it, an edit can never accidentally become
|
||||
the active system prompt."""
|
||||
try:
|
||||
result = playbook_manager.persist_playbook(
|
||||
{
|
||||
"id": id, "title": title, "goal": goal, "instructions": instructions,
|
||||
"tags": tags, "tools": tools, "model": model,
|
||||
},
|
||||
merge=True,
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"ok": False, "error": str(e)})
|
||||
if make_active:
|
||||
playbook_manager.make_main(result["id"])
|
||||
result["is_main_playbook"] = True
|
||||
result["ok"] = True
|
||||
result["fence"] = _edit_fence({"kind": "playbook", **result})
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
async def _edit_settings(changes: dict | None = None, **_) -> str:
|
||||
"""ACTION tool: change one or more runtime settings. Unknown keys are
|
||||
silently ignored, exactly like PUT /settings already does."""
|
||||
changes = changes if isinstance(changes, dict) else {}
|
||||
if not changes:
|
||||
return json.dumps({"ok": False, "error": "changes must be a non-empty object"})
|
||||
preview = self_edit.preview_settings_edit({"changes": changes})
|
||||
if not preview.get("ok"):
|
||||
return json.dumps(preview)
|
||||
if not preview.get("applied"):
|
||||
return json.dumps({
|
||||
"ok": False,
|
||||
"error": "no recognized settings keys in changes",
|
||||
"ignored_unknown": preview.get("ignored_unknown", []),
|
||||
})
|
||||
store.update_settings({k: v["after"] for k, v in preview["applied"].items()})
|
||||
preview["ok"] = True
|
||||
preview["fence"] = _edit_fence({"kind": "settings", **preview})
|
||||
return json.dumps(preview)
|
||||
|
||||
|
||||
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
@@ -1121,13 +1203,114 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
},
|
||||
_remember,
|
||||
),
|
||||
"edit_source": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_source",
|
||||
"description": (
|
||||
"Rewrite a file in this project's own source tree and commit the "
|
||||
"change. Requires human approval every time — the person reviews a "
|
||||
"real diff before anything is written. Send the COMPLETE new file "
|
||||
"content, not a patch; the server computes the diff itself. `path` "
|
||||
"is relative to the project root (e.g. \"synapse/tools.py\"), never "
|
||||
"absolute. Only available in a source checkout, not a packaged "
|
||||
"install. Writing the file does not restart the running process — "
|
||||
"say so plainly once it's applied."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Project-relative path to the file, e.g. synapse/tools.py",
|
||||
},
|
||||
"new_content": {
|
||||
"type": "string",
|
||||
"description": "The complete replacement content of the file.",
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "One line describing the change, used as the commit message.",
|
||||
},
|
||||
},
|
||||
"required": ["path", "new_content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_edit_source,
|
||||
),
|
||||
"edit_playbook": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_playbook",
|
||||
"description": (
|
||||
"Create or update a playbook — the instructions that shape how the "
|
||||
"assistant behaves. Requires human approval every time. Fields you "
|
||||
"omit keep their current value; this merges into the existing "
|
||||
"playbook, it does not replace it. Set make_active=true only when "
|
||||
"this playbook should become the active system prompt — never as a "
|
||||
"side effect of an ordinary edit. Omit `id` to create a new playbook."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Existing playbook id to update; omit to create new."},
|
||||
"title": {"type": "string", "description": "Short name for the playbook."},
|
||||
"goal": {"type": "string", "description": "One-line statement of what this playbook is for."},
|
||||
"instructions": {"type": "string", "description": "The actual instructions/system prompt text."},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Routing tags."},
|
||||
"tools": {"type": "array", "items": {"type": "string"}, "description": "Tool names this playbook grants."},
|
||||
"model": {"type": "string", "description": "Preferred Ollama model for this playbook, or blank for auto."},
|
||||
"make_active": {
|
||||
"type": "boolean",
|
||||
"description": "Set true to make this the active system prompt. Default false.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
_edit_playbook,
|
||||
),
|
||||
"edit_settings": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_settings",
|
||||
"description": (
|
||||
"Change one or more runtime settings (e.g. model, temperature, "
|
||||
"action_tool_policy, memory_model). Requires human approval every "
|
||||
"time. Send only the keys you actually want to change — unrecognized "
|
||||
"keys are silently ignored, and existing values are left untouched."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"changes": {
|
||||
"type": "object",
|
||||
"description": "Partial map of setting name to new value.",
|
||||
},
|
||||
},
|
||||
"required": ["changes"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_edit_settings,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Tools that act (write local state or reach the network). These require an
|
||||
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
|
||||
# allowlist — a playbook granting one isn't enough on its own.
|
||||
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember", "run_snippet"})
|
||||
# allowlist — a playbook granting one isn't enough on its own. The three
|
||||
# self-edit tools additionally always pause for per-call approval regardless
|
||||
# of that global policy — see self_edit.ALWAYS_ASK_TOOLS and chat.py.
|
||||
ACTION_TOOLS = frozenset({
|
||||
"web_search", "fetch_url", "remember", "run_snippet",
|
||||
"edit_playbook", "edit_settings", "edit_source",
|
||||
})
|
||||
|
||||
# Action tools offered on a *cue* rather than only via a playbook allowlist —
|
||||
# the run track is a standing UI capability like the render window, but unlike
|
||||
|
||||
Reference in New Issue
Block a user