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:
+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