fix(evolution): correct [SILENT] verdict and enable guarded bash for skill creation

This commit is contained in:
zhayujie
2026-06-09 09:29:30 +08:00
parent 16134bd150
commit 1f1abdd7b6
2 changed files with 97 additions and 17 deletions

View File

@@ -19,6 +19,7 @@ remember_scheduled_output, channel_factory) rather than introducing a fork.
from __future__ import annotations from __future__ import annotations
import re
import threading import threading
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -37,8 +38,10 @@ from agent.evolution.prompts import (
from agent.evolution.record import append_session_evolution from agent.evolution.record import append_session_evolution
# Tools the isolated evolution agent is allowed to use. Everything else is # Tools the isolated evolution agent is allowed to use. Everything else is
# withheld so a review pass can only read context and edit memory/skill files. # withheld so a review pass can only read context, run workspace scripts, and
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "memory_search", "memory_get"} # edit memory/skill files. bash is needed by skill-creator's init script and is
# confined to the workspace by _BashWorkspaceGuard.
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "bash", "memory_search", "memory_get"}
# Cap concurrent evolution passes so a burst of idle sessions can't spawn many # Cap concurrent evolution passes so a burst of idle sessions can't spawn many
# background model runs at once. Extra sessions simply wait for the next scan. # background model runs at once. Extra sessions simply wait for the next scan.
@@ -159,12 +162,81 @@ class _WorkspaceWriteGuard:
return self._inner.execute(args) return self._inner.execute(args)
class _BashWorkspaceGuard:
"""Wraps the bash tool so evolution can only run commands inside the
workspace.
Evolution needs bash for skill-creator's init script, but it runs
unattended in the background, so a raw shell is too broad. This guard:
- forces the command to execute with cwd = workspace,
- rejects commands that reference an absolute path or ``..`` segment
pointing OUTSIDE the workspace (the common ways to escape it).
It is a coarse textual check, not a sandbox — paired with the model's
instruction to only run skill-creator scripts, it keeps writes local.
"""
def __init__(self, inner, workspace_dir: str):
self._inner = inner
self._ws = Path(workspace_dir).resolve()
# Pin the shell's working directory to the workspace.
try:
self._inner.cwd = str(self._ws)
except Exception:
pass
self.name = inner.name
self.description = inner.description
self.params = inner.params
def __getattr__(self, item):
return getattr(self._inner, item)
def execute_tool(self, params):
try:
return self.execute(params)
except Exception as e:
logger.error(f"[Evolution] guarded bash error: {e}")
from agent.tools.base_tool import ToolResult
return ToolResult.fail(f"Error: {e}")
def _escapes_workspace(self, command: str) -> bool:
# Absolute paths that are not under the workspace.
for tok in re.findall(r'(?:^|\s)(/[^\s\'";|&]+)', command):
try:
resolved = Path(tok).resolve()
except Exception:
continue
if self._ws != resolved and self._ws not in resolved.parents:
return True
# Parent-dir traversal that climbs above the workspace.
for tok in re.findall(r'[^\s\'";|&]*\.\.[^\s\'";|&]*', command):
try:
resolved = (self._ws / tok).resolve()
except Exception:
continue
if self._ws != resolved and self._ws not in resolved.parents:
return True
return False
def execute(self, args):
from agent.tools.base_tool import ToolResult
command = (args.get("command") or "").strip()
if command and self._escapes_workspace(command):
return ToolResult.fail(
"Error: evolution may only run commands inside the workspace; "
"this command references a path outside it and was blocked."
)
return self._inner.execute(args)
def _guard_tools(tools: list, workspace_dir: str) -> list: def _guard_tools(tools: list, workspace_dir: str) -> list:
"""Wrap write/edit tools with the workspace guard; leave others as-is.""" """Wrap write/edit/bash tools with workspace guards; leave others as-is."""
guarded = [] guarded = []
for t in tools: for t in tools:
if getattr(t, "name", None) in _WRITE_TOOLS: name = getattr(t, "name", None)
if name in _WRITE_TOOLS:
guarded.append(_WorkspaceWriteGuard(t, workspace_dir)) guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
elif name == "bash":
guarded.append(_BashWorkspaceGuard(t, workspace_dir))
else: else:
guarded.append(t) guarded.append(t)
return guarded return guarded
@@ -366,22 +438,28 @@ def run_evolution_for_session(
# only looks at messages added after this point (silent or not). # only looks at messages added after this point (silent or not).
agent._evo_done_msg_count = total_msgs agent._evo_done_msg_count = total_msgs
if not result or SILENT_TOKEN in result: # Respect an explicit silent verdict: empty, exactly [SILENT], or text
# that STARTS with [SILENT] means the model chose to stay quiet.
if not result or result.startswith(SILENT_TOKEN):
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])") logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
return False return False
# Hard gate: an evolution only counts (and only notifies) if a workspace # Anti-nag backstop: if the model wrote a summary but actually changed no
# file ACTUALLY changed. If the model did real work (wrote memory / # watched file, stay silent — never notify about work that didn't happen.
# patched a skill / finished a task) the user is told; if it merely
# produced text without changing anything, we stay silent. This is the
# key anti-nag rule — no notification unless something was actually done.
if not _workspace_changed(workspace_dir, pre_snapshot): if not _workspace_changed(workspace_dir, pre_snapshot):
logger.info( logger.info(
f"[Evolution] ✗ session={session_id}: model produced text but " f"[Evolution] ✗ session={session_id}: text produced but no file "
f"changed no file — treating as silent" f"changed — staying silent"
) )
return False return False
# The model produced a real summary. Strip any stray [SILENT] tokens it
# left mid-text, then notify.
result = result.replace(SILENT_TOKEN, "").strip()
if not result:
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
return False
logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}") logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}")
append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id) append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id)
# Inject an [EVOLUTION] note so the main agent can honor "undo". # Inject an [EVOLUTION] note so the main agent can honor "undo".

View File

@@ -52,11 +52,13 @@ them. When their signal is clear, act; do not be shy here.
the relevant skill file under the skills directory and make a small the relevant skill file under the skills directory and make a small
incremental edit so it never recurs. incremental edit so it never recurs.
b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that
no existing skill covers and the user is likely to want again. To create no existing skill covers and the user is likely to want again. Follow the
one, follow the `skill-creator` skill's conventions (read its SKILL.md for `skill-creator` skill's conventions (read its SKILL.md for the required
the required structure) and write the new skill under the workspace structure), then create `skills/<name>/SKILL.md` by WRITING the file
`skills/` directory. Only create when the workflow is genuinely reusable directly with the write tool — this is the simplest reliable path. (bash
not for a one-off task. is available and confined to the workspace if a helper script is truly
needed, but a direct write is preferred.) Only create when the workflow is
genuinely reusable — not for a one-off task.
CRITICAL — fix the SOURCE, do not just remember the symptom: when the root CRITICAL — fix the SOURCE, do not just remember the symptom: when the root
cause of a problem lives IN a skill file itself (its instructions, content, cause of a problem lives IN a skill file itself (its instructions, content,