Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions nerve/agent/tools/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from nerve.agent.tools.registry import ToolRegistry

from nerve.agent.tools.handlers.config_pr import CONFIG_PR_SPECS
from nerve.agent.tools.handlers.hoa import HOA_SPECS
from nerve.agent.tools.handlers.mcp_admin import MCP_ADMIN_SPECS
from nerve.agent.tools.handlers.memory import MEMORY_SPECS
Expand Down Expand Up @@ -39,6 +40,7 @@ def build_default_registry() -> ToolRegistry:
*SKILL_SPECS,
*NOTIFICATION_SPECS,
*MCP_ADMIN_SPECS,
*CONFIG_PR_SPECS,
*WAKEUP_SPECS,
*WORKFLOW_RUN_SPECS,
*REVIEW_LOOP_SPECS,
Expand Down
109 changes: 109 additions & 0 deletions nerve/agent/tools/handlers/config_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Tool handler: propose_config_change — open a PR against the workspace repo.

This is how the agent changes its own configuration (skills, cron, settings)
when it can't edit the live workspace directly — always under lockdown, and the
recommended reviewed path otherwise. See the `nerve-workspace` skill.
"""

from __future__ import annotations

import asyncio
import logging
import time

from nerve.agent.tools.registry import ToolContext, ToolResult, ToolSpec
from nerve.agent.tools.schemas import PROPOSE_CONFIG_CHANGE_SCHEMA

logger = logging.getLogger(__name__)


async def propose_config_change_handler(ctx: ToolContext, args: dict) -> ToolResult:
from pathlib import Path

from nerve.config_pr import propose_config_change

config = ctx.config
if config is None:
return ToolResult.text("Config not available.", is_error=True)

workspace = Path(config.workspace)
config_dir = Path(config.config_dir) if config.config_dir else workspace
title = args["title"]
body = args.get("body", "")
changes = args["changes"]

try:
result = await asyncio.to_thread(
propose_config_change,
workspace, config_dir, title, body, changes, int(time.time()),
)
except Exception as e: # noqa: BLE001
logger.error("propose_config_change failed: %s", e)
return ToolResult.text(f"Could not open PR: {e}", is_error=True)

if result.ok:
text = f"Opened PR on branch `{result.branch}`:\n{result.pr_url}"
if result.code_paths:
# Validation never runs or parses a bundle's own code, so the
# reviewer is the only check on it. Say it here too: the tool only
# recognizes the effects it knows about, and the agent knows what it
# actually intended.
listed = ", ".join(f"`{p}`" for p in result.code_paths)
text += (
f"\n\nThis PR changes what runs on the instance ({listed}) and the "
"PR body says so. Nothing validates it — repeat it in your own "
"words when you report the PR."
)
return ToolResult.text(text)
if result.validation_errors:
errs = "\n".join(f"- {e}" for e in result.validation_errors)
return ToolResult.text(
f"Change is invalid — no PR opened. Fix these and retry:\n{errs}",
is_error=True,
)
if result.no_remote_configured:
# A workspace with no repo to propose against, which is what an ordinary
# local install is. Said here rather than in config_pr, which is handed a
# workspace and not told whether the instance is locked; and said at the
# point of failure rather than left to the skill, because an agent that
# reasons from the error text is the case that goes wrong — it has been
# told to always propose, the tool has refused, and without this it
# concludes the config cannot be changed on a box where writing the file
# is both allowed and correct.
if config.lockdown:
return ToolResult.text(
f"Cannot open a PR: {result.message}\n\nThis instance is locked, so "
"editing the files directly will not work either — tracked config "
"only changes by syncing a merged change. Ask the operator to point "
"the workspace at a config repo.",
is_error=True,
)
return ToolResult.text(
f"No PR opened: {result.message}\n\nThis instance is not locked and its "
"workspace is local, so there is no review to route through. Edit the "
f"files under {workspace} directly instead, and say what you changed.",
is_error=True,
)
return ToolResult.text(f"Could not open PR: {result.message}", is_error=True)


PROPOSE_CONFIG_CHANGE_SPEC = ToolSpec(
name="propose_config_change",
description=(
"Propose a change to your own configuration (skills, cron jobs, settings) "
"by opening a pull request against the workspace git repo, for human "
"review. Use this — never edit tracked config files directly — when the "
"instance is locked (remote-only), or whenever a reviewed change is "
"wanted. Provide the FULL new content of each file you want to change; "
"paths are relative to the workspace root (e.g. 'config/cron/jobs.yaml') "
"and must be reviewed configuration — 'config/…', 'skills/…', or a "
"workspace-root instruction file. The change is validated before the PR "
"is opened; an invalid change, or any path outside that surface, is "
"rejected with the reasons to fix and no PR is opened."
),
input_schema=PROPOSE_CONFIG_CHANGE_SCHEMA,
handler=propose_config_change_handler,
)


CONFIG_PR_SPECS = [PROPOSE_CONFIG_CHANGE_SPEC]
48 changes: 48 additions & 0 deletions nerve/agent/tools/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,3 +849,51 @@
"properties": {},
"required": [],
}

# ----- Config self-modification -----

PROPOSE_CONFIG_CHANGE_SCHEMA = {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "PR title — a concise summary of the config change.",
},
"body": {
"type": "string",
"description": "PR description: what changed and why (Markdown).",
"default": "",
},
"changes": {
"type": "array",
"description": (
"Files to write in the PR, each the FULL new file content. Paths "
"are relative to the workspace root (e.g. 'config/cron/jobs.yaml', "
"'skills/my-skill/SKILL.md') and must be reviewed configuration: "
"anything under 'config/' or 'skills/', or a workspace-root "
"instruction file (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, "
"TOOLS.md). Runtime state (MEMORY.md, TASK.md, memory/) and the "
"rest of the repository are refused, as is any executable file "
"other than a cron gate plugin at 'config/cron/gates/<name>.py'. "
"A single refused path rejects the whole proposal — nothing is "
"dropped silently."
),
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": (
"Path relative to the workspace root, inside "
"'config/', 'skills/', or a root instruction file."
),
},
"content": {"type": "string", "description": "Full new content of the file."},
},
"required": ["path", "content"],
},
"minItems": 1,
},
},
"required": ["title", "changes"],
}
Loading
Loading