feat(evolution): add self-evolution subsystem

Add a self-evolution subsystem that reviews idle conversations in an
isolated agent and durably learns from them — patching/creating skills,
finishing unfinished tasks, and backfilling missed memory.

- Trigger: background idle scan, fires when a session is idle >= N min AND
  (>= N turns OR context usage > 80%). In-memory cursor reviews only new
  messages so a session never re-learns old content.
- Isolated review agent: same model, restricted toolset, hard write-guard
  confining edits to the workspace (built-in skills are protected).
- Safety: file-level backup before edits + evolution_undo tool; notify the
  user ONLY when a workspace file actually changed (no-nag rule); capped
  concurrency.
- Records to memory/evolution/<date>.md, surfaced in the memory UI's
  renamed "Self-Evolution" tab (merged with dream diaries).
- Hide internal [SCHEDULED]/[EVOLUTION]/backup_id markers from chat history
  display (also fixes scheduler marker leakage) while keeping them in stored
  content for undo.
- Flat config: self_evolution_enabled (default off until release),
  self_evolution_idle_minutes (15), self_evolution_min_turns (6).
- Tests: tests/test_evolution.py (stub + real model modes, 7 scenarios).
This commit is contained in:
zhayujie
2026-06-07 18:55:33 +08:00
parent 0e4da1d1c5
commit ba777ed706
19 changed files with 1856 additions and 20 deletions

View File

@@ -0,0 +1,19 @@
"""
Self-evolution subsystem for CowAgent.
Runs a lightweight, isolated review pass after a conversation goes idle to
decide whether anything is worth durably learning (memory / skill) or whether
an unfinished task can be pushed forward. Conservative by design: most
conversations should produce no change at all.
Public entry points:
from agent.evolution import get_evolution_config
from agent.evolution.trigger import start_evolution_trigger, note_user_turn
"""
from agent.evolution.config import EvolutionConfig, get_evolution_config
__all__ = [
"EvolutionConfig",
"get_evolution_config",
]

102
agent/evolution/backup.py Normal file
View File

@@ -0,0 +1,102 @@
"""File backup / rollback support for self-evolution.
Before the evolution agent edits MEMORY.md or a skill file, we snapshot the
current state into ``memory/.evolution_backups/<backup_id>/`` so a later "undo"
can restore it. File-level restore only — simple and reliable.
"""
from __future__ import annotations
import json
import shutil
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from common.log import logger
_BACKUP_DIRNAME = ".evolution_backups"
_MANIFEST_NAME = "manifest.json"
# Keep only the most recent N backups to bound disk usage.
_MAX_BACKUPS = 10
def _backups_root(workspace_dir: Path) -> Path:
return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME
def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]:
"""Snapshot ``files`` (those that exist) under a new backup id.
Returns the backup_id, or None when there is nothing to back up.
"""
existing = [Path(f) for f in files if Path(f).exists()]
if not existing:
return None
backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000)
root = _backups_root(workspace_dir)
target = root / backup_id
try:
target.mkdir(parents=True, exist_ok=True)
ws = Path(workspace_dir)
manifest = []
for idx, src in enumerate(existing):
# Store under a flat index plus the relative path so restore knows
# where it came from, even for nested skill files.
try:
rel = str(src.relative_to(ws))
except ValueError:
rel = src.name
dst = target / f"{idx}.bak"
shutil.copy2(src, dst)
manifest.append({"rel": rel, "bak": f"{idx}.bak"})
(target / _MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
_prune_old_backups(root)
# Caller logs a combined backup+review line; keep this at debug.
logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))")
return backup_id
except Exception as e:
logger.warning(f"[Evolution] Failed to create backup: {e}")
return None
def restore_backup(workspace_dir: Path, backup_id: str) -> bool:
"""Restore all files captured under ``backup_id``. Returns success."""
if not backup_id:
return False
target = _backups_root(workspace_dir) / backup_id
manifest_path = target / _MANIFEST_NAME
if not manifest_path.exists():
logger.warning(f"[Evolution] Backup not found: {backup_id}")
return False
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
ws = Path(workspace_dir)
for entry in manifest:
bak = target / entry["bak"]
dst = ws / entry["rel"]
if bak.exists():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(bak, dst)
logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))")
return True
except Exception as e:
logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}")
return False
def _prune_old_backups(root: Path) -> None:
"""Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological)."""
try:
dirs = sorted(
[d for d in root.iterdir() if d.is_dir()],
key=lambda p: p.name,
)
for old in dirs[:-_MAX_BACKUPS]:
shutil.rmtree(old, ignore_errors=True)
except Exception as e:
logger.debug(f"[Evolution] Backup prune skipped: {e}")

76
agent/evolution/config.py Normal file
View File

@@ -0,0 +1,76 @@
"""Configuration for the self-evolution subsystem.
Reads flat ``self_evolution_*`` keys from config.json. All fields have safe
defaults so the feature degrades gracefully when keys are absent.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# Defaults — conservative (see executor module docstring). Disabled by default
# until release; enable via ``self_evolution_enabled``.
DEFAULT_ENABLED = False
DEFAULT_IDLE_MINUTES = 15
DEFAULT_MIN_TURNS = 6
# Max review steps for the isolated evolution agent. Kept small (not exposed as
# config): the review is meant to be cheap and focused, not a long autonomous run.
DEFAULT_MAX_STEPS = 12
@dataclass
class EvolutionConfig:
"""Resolved self-evolution settings."""
enabled: bool = DEFAULT_ENABLED
idle_minutes: int = DEFAULT_IDLE_MINUTES
min_turns: int = DEFAULT_MIN_TURNS
max_steps: int = DEFAULT_MAX_STEPS
@property
def idle_seconds(self) -> int:
return max(60, self.idle_minutes * 60)
def _as_bool(value: Any, fallback: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
v = value.strip().lower()
if v in ("true", "1", "yes", "on"):
return True
if v in ("false", "0", "no", "off"):
return False
return fallback
def _as_pos_int(value: Any, fallback: int) -> int:
try:
n = int(value)
return n if n > 0 else fallback
except (TypeError, ValueError):
return fallback
def get_evolution_config() -> EvolutionConfig:
"""Build EvolutionConfig from the live config.json ``self_evolution_*`` keys."""
try:
from config import conf
c = conf()
except Exception:
c = {}
def _get(key, default):
try:
return c.get(key, default)
except Exception:
return default
return EvolutionConfig(
enabled=_as_bool(_get("self_evolution_enabled", None), DEFAULT_ENABLED),
idle_minutes=_as_pos_int(_get("self_evolution_idle_minutes", None), DEFAULT_IDLE_MINUTES),
min_turns=_as_pos_int(_get("self_evolution_min_turns", None), DEFAULT_MIN_TURNS),
max_steps=DEFAULT_MAX_STEPS,
)

449
agent/evolution/executor.py Normal file
View File

@@ -0,0 +1,449 @@
"""Self-evolution executor.
Runs an isolated review agent over an idle conversation's transcript and, if a
clear signal is found, lets it edit memory / skills via a restricted toolset.
Conservative by design: most runs return ``[SILENT]`` and change nothing.
Flow:
1. Build a transcript from the session's new (since last pass) messages.
2. Snapshot MEMORY.md + daily file + editable skills (for undo) -> backup_id.
3. Run an isolated agent (same model, restricted tools, evolution prompt).
4. If output is [SILENT], or no workspace file actually changed -> done.
5. Otherwise -> record to the evolution log, inject an [EVOLUTION] note into
the user session (so the main agent can honor "undo"), and push the
summary to the user's channel.
Reuses existing infrastructure (AgentBridge.create_agent, ToolManager,
remember_scheduled_output, channel_factory) rather than introducing a fork.
"""
from __future__ import annotations
import threading
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from common.log import logger
from agent.evolution.backup import create_backup
from agent.evolution.config import get_evolution_config
from agent.evolution.prompts import (
EVOLUTION_MARKER,
EVOLUTION_SYSTEM_PROMPT,
SILENT_TOKEN,
build_review_user_message,
)
from agent.evolution.record import append_session_evolution
# 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.
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "memory_search", "memory_get"}
# 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.
_MAX_CONCURRENT = 2
_running_lock = threading.Lock()
_running_count = 0
def _builtin_skill_names() -> set:
"""Names of skills shipped with the product (project-root ``skills/``).
These are protected: the evolution agent must never edit them, even though
a same-named copy exists in the workspace at runtime. The project dir is the
authoritative list of what counts as built-in.
"""
try:
# executor.py -> agent/evolution -> agent -> project root
project_root = Path(__file__).resolve().parents[2]
builtin_dir = project_root / "skills"
if not builtin_dir.is_dir():
return set()
names = set()
for entry in builtin_dir.iterdir():
if entry.is_dir() and not entry.name.startswith("."):
names.add(entry.name)
return names
except Exception:
return set()
def _build_transcript(messages: List[dict], max_chars: int = 12000) -> str:
"""Render the session messages into a compact text transcript."""
lines: List[str] = []
for msg in messages:
role = msg.get("role", "")
if role not in ("user", "assistant"):
continue
content = msg.get("content", "")
text = _extract_text(content)
if not text.strip():
continue
speaker = "User" if role == "user" else "Assistant"
lines.append(f"{speaker}: {text.strip()}")
transcript = "\n".join(lines)
# Keep the most RECENT context if oversized (tail is most relevant).
if len(transcript) > max_chars:
transcript = "...(earlier omitted)...\n" + transcript[-max_chars:]
return transcript
def _extract_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
elif isinstance(block, str):
parts.append(block)
return "\n".join(parts)
return ""
def _select_tools(all_tools: list) -> list:
return [t for t in all_tools if getattr(t, "name", None) in _ALLOWED_TOOLS]
# Tools whose writes must be confined to the workspace during evolution.
_WRITE_TOOLS = {"write", "edit"}
class _WorkspaceWriteGuard:
"""Wraps a write/edit tool so it can ONLY write inside the workspace.
Hard engineering guard (not prompt-based): any write resolving outside the
workspace — e.g. the project's bundled ``skills/`` dir — is rejected. This
protects built-in skills regardless of what the model attempts.
"""
def __init__(self, inner, workspace_dir: str):
self._inner = inner
self._ws = Path(workspace_dir).resolve()
# Mirror the attributes the agent runtime reads off a tool.
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):
# The agent runtime calls execute_tool (not execute); route it through
# our guarded execute so the path checks always run.
try:
return self.execute(params)
except Exception as e:
logger.error(f"[Evolution] guarded tool error: {e}")
from agent.tools.base_tool import ToolResult
return ToolResult.fail(f"Error: {e}")
def execute(self, args):
path = (args.get("path") or "").strip()
if path:
try:
resolved = Path(self._inner._resolve_path(path)).resolve()
from agent.tools.base_tool import ToolResult
# Confine writes to the workspace. This protects the product's
# bundled skills (which live outside the workspace) from ever
# being modified, no matter what path the model attempts.
if self._ws not in resolved.parents and resolved != self._ws:
return ToolResult.fail(
"Error: evolution may only write inside the workspace; "
f"path '{path}' is outside and was blocked."
)
except Exception:
pass
return self._inner.execute(args)
def _guard_tools(tools: list, workspace_dir: str) -> list:
"""Wrap write/edit tools with the workspace guard; leave others as-is."""
guarded = []
for t in tools:
if getattr(t, "name", None) in _WRITE_TOOLS:
guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
else:
guarded.append(t)
return guarded
# Workspace subtrees worth watching for evolution-induced changes.
_WATCH_SUBDIRS = ("MEMORY.md", "skills", "knowledge", "output")
# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly
# dream diary, none of which count as a user-facing change signal.
_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution")
def _workspace_snapshot(workspace_dir) -> dict:
"""Map relative path -> (mtime, size) for watched files. Cheap, no reads."""
ws = Path(workspace_dir)
snap: dict = {}
for name in _WATCH_SUBDIRS:
root = ws / name
if root.is_file():
try:
st = root.stat()
snap[name] = (st.st_mtime, st.st_size)
except OSError:
pass
continue
if not root.is_dir():
continue
for p in root.rglob("*"):
if not p.is_file():
continue
try:
st = p.stat()
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
except OSError:
pass
# Watch the daily memory files (memory/*.md and per-user dailies) since
# evolution now records learnings there. Skip backups/dreams bookkeeping.
mem_dir = ws / "memory"
if mem_dir.is_dir():
for p in mem_dir.rglob("*.md"):
rel_parts = p.relative_to(mem_dir).parts
if rel_parts and rel_parts[0] in _MEMORY_IGNORE:
continue
try:
st = p.stat()
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
except OSError:
pass
return snap
def _workspace_changed(workspace_dir, pre: dict) -> bool:
"""True if any watched file was added, removed, or modified since ``pre``."""
return _workspace_snapshot(workspace_dir) != pre
def run_evolution_for_session(
agent_bridge,
session_id: str,
channel_type: str = "",
receiver: str = "",
user_id: Optional[str] = None,
idle_minutes: float = 0.0,
) -> bool:
"""Run one evolution pass for a session. Returns True if it changed anything.
Safe to call from a background thread. All failures are swallowed and
logged — evolution must never disrupt the main pipeline.
"""
cfg = get_evolution_config()
if not cfg.enabled:
return False
# Concurrency gate: bound how many evolution passes run at once.
global _running_count
with _running_lock:
if _running_count >= _MAX_CONCURRENT:
logger.info(
f"[Evolution] busy ({_running_count}/{_MAX_CONCURRENT} running); "
f"skipping session={session_id} this scan"
)
return False
_running_count += 1
try:
agent = agent_bridge.agents.get(session_id) or agent_bridge.default_agent
if not agent:
return False
with agent.messages_lock:
all_messages = list(agent.messages)
total_msgs = len(all_messages)
# In-memory evolution cursor: only review messages added since the last
# pass so a long session doesn't re-judge (and re-write) old content.
# Stored on the agent instance; lost on restart (acceptable — at worst
# one redundant pass right after a restart, gated by the file-change
# check downstream so it won't double-write identical memory).
done = int(getattr(agent, "_evo_done_msg_count", 0))
if done > total_msgs:
done = 0 # history was trimmed/reset; start fresh
new_messages = all_messages[done:]
transcript = _build_transcript(new_messages)
if not transcript.strip():
logger.info(f"[Evolution] session={session_id}: no new messages, skip")
# Advance the cursor anyway so we don't re-scan the same tail.
agent._evo_done_msg_count = total_msgs
return False
logger.info(
f"[Evolution] ▶ Reviewing session={session_id} "
f"(idle {idle_minutes:.1f}min, {len(new_messages)} new/{total_msgs} msgs, "
f"~{len(transcript)} chars)"
)
# Resolve workspace + files to snapshot for undo.
from agent.memory.config import get_default_memory_config
mem_cfg = get_default_memory_config()
workspace_dir = mem_cfg.get_workspace()
if user_id:
memory_file = Path(workspace_dir) / "memory" / "users" / user_id / "MEMORY.md"
else:
memory_file = Path(workspace_dir) / "MEMORY.md"
skills_dir = mem_cfg.get_skills_dir()
# Snapshot MEMORY.md + every NON-protected skill's SKILL.md. Protected
# built-in skills are excluded from backup because they must never be
# edited in the first place.
protected_names = _builtin_skill_names()
# Back up both MEMORY.md and today's daily file: evolution now writes to
# the daily file, but MEMORY.md is cheap to snapshot and keeps undo safe
# if the model ever edits it.
today_daily = Path(workspace_dir) / "memory" / (
datetime.now().strftime("%Y-%m-%d") + ".md"
)
if user_id:
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
datetime.now().strftime("%Y-%m-%d") + ".md"
)
backup_files = [Path(memory_file), today_daily]
if skills_dir.exists():
for skill_md in skills_dir.rglob("SKILL.md"):
# The skill dir is the SKILL.md's parent (or an ancestor for
# collections); guard by checking the immediate top-level dir.
try:
top = skill_md.relative_to(skills_dir).parts[0]
except (ValueError, IndexError):
continue
if top in protected_names:
continue
backup_files.append(skill_md)
backup_id = create_backup(workspace_dir, backup_files)
_backup_n = sum(1 for f in backup_files if Path(f).exists())
# Snapshot the whole workspace (path -> mtime/size) so we can reliably
# detect ANY file change — including new output files written when
# finishing an unfinished task, which are not in backup_files.
pre_snapshot = _workspace_snapshot(workspace_dir)
# Build the isolated review agent: same model, restricted tools, with a
# hard guard that confines all writes to the workspace (protects the
# project's bundled skills from ever being modified).
review_tools = _guard_tools(
_select_tools(list(getattr(agent, "tools", []) or [])),
str(workspace_dir),
)
review_agent = agent_bridge.create_agent(
system_prompt=EVOLUTION_SYSTEM_PROMPT,
tools=review_tools,
description="Self-evolution review agent",
max_steps=cfg.max_steps,
workspace_dir=str(workspace_dir),
skill_manager=getattr(agent, "skill_manager", None),
memory_manager=getattr(agent, "memory_manager", None),
enable_skills=False,
)
# Reuse the live model so it follows the user's configured model.
review_agent.model = agent.model
logger.info(
f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent"
)
user_msg = build_review_user_message(transcript, protected_skills=list(protected_names))
result = review_agent.run_stream(user_msg, clear_history=True)
result = (result or "").strip()
# These messages are now reviewed; advance the cursor so the next pass
# only looks at messages added after this point (silent or not).
agent._evo_done_msg_count = total_msgs
if not result or SILENT_TOKEN in result:
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
return False
# Hard gate: an evolution only counts (and only notifies) if a workspace
# file ACTUALLY changed. If the model did real work (wrote memory /
# 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):
logger.info(
f"[Evolution] ✗ session={session_id}: model produced text but "
f"changed no file — treating as silent"
)
return False
logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}")
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_evolution_record(agent_bridge, session_id, channel_type, result, backup_id)
# Push the summary to the user's channel. The "did a file actually
# change" gate above is the only throttle we need: real evolutions are
# rare, so no extra opt-in switch or daily-count limit is required.
if channel_type and receiver:
_notify_user(channel_type, receiver, result)
return True
except Exception as e:
logger.warning(f"[Evolution] Run failed for session={session_id}: {e}")
return False
finally:
with _running_lock:
_running_count -= 1
def _inject_evolution_record(
agent_bridge, session_id: str, channel_type: str, summary: str, backup_id: Optional[str]
) -> None:
"""Add an [EVOLUTION] note to the user session so the main agent can undo."""
try:
note = f"{EVOLUTION_MARKER} {summary}"
if backup_id:
note += f"\n(backup_id: {backup_id}; to undo, restore this backup)"
# Reuse the scheduler-output injection path: isolated execution, only a
# compact record lands in the user session.
agent_bridge.remember_scheduled_output(
session_id=session_id,
content=note,
channel_type=channel_type,
task_description="self-evolution",
)
except Exception as e:
logger.debug(f"[Evolution] Failed to inject evolution record: {e}")
def _notify_user(channel_type: str, receiver: str, summary: str) -> None:
"""Push the evolution summary to the user's channel as a new message."""
try:
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.channel_factory import create_channel
context = Context(ContextType.TEXT, summary)
context["receiver"] = receiver
context["isgroup"] = False
context["session_id"] = receiver
# Channels that reply to an original message need msg=None for a fresh push.
if channel_type in ("feishu", "dingtalk", "wecom_bot", "qq"):
context["msg"] = None
if channel_type == "feishu":
context["receive_id_type"] = "open_id"
channel = create_channel(channel_type)
if not channel:
return
# Web is request-response: a background push needs a synthetic request_id
# plus a request->session mapping so the channel can route the message to
# the user's polling queue (same approach the scheduler uses).
if channel_type == "web":
import uuid
request_id = f"evolution_{uuid.uuid4().hex[:8]}"
context["request_id"] = request_id
if hasattr(channel, "request_to_session"):
channel.request_to_session[request_id] = receiver
channel.send(Reply(ReplyType.TEXT, summary), context)
logger.info(f"[Evolution] Notified user via {channel_type}")
except Exception as e:
logger.warning(f"[Evolution] Failed to notify user: {e}")

163
agent/evolution/prompts.py Normal file
View File

@@ -0,0 +1,163 @@
"""Prompts for the self-evolution review agent.
The system prompt is intentionally English-only: it governs the agent's
internal reasoning and is more stable / cheaper to maintain in one language.
The user-facing summary the agent produces should follow the user's own
language (instructed at the end of the prompt).
Design goals (see ref/hermes-agent background_review for inspiration):
- Default to doing NOTHING. Evolution is the exception, not the rule.
- Three signal types: memory, skill, unfinished task.
- An explicit "do NOT capture" list to avoid self-poisoning over time.
- Generic examples only — never bake in domain-specific business terms.
"""
# Sentinel the agent emits when there is nothing worth evolving.
SILENT_TOKEN = "[SILENT]"
# Marker prefix for the evolution record injected into the user session, so the
# main chat agent can recognize past evolutions and honor an "undo" request.
EVOLUTION_MARKER = "[EVOLUTION]"
EVOLUTION_SYSTEM_PROMPT = """You are a self-evolution review agent for an AI assistant.
You are given a transcript of a conversation that just went idle. Your job is to
decide whether anything from it is worth durably learning so future
conversations go better — and if so, to make that change.
# Top principle: default to doing NOTHING
Most ordinary conversations need no evolution. Only act when there is a CLEAR
signal below. If there is none, reply with exactly `[SILENT]` and stop. Staying
silent is the normal, correct outcome — not a failure.
Greetings, small talk, acknowledgements ("ok", "thanks", "got it"), and casual
chat are NOT signals. For these, output exactly `[SILENT]` immediately — do not
explore files, do not write a summary, do not be polite. Just `[SILENT]`.
IMPORTANT: A summary is only allowed if you ACTUALLY made a file change via a
tool (write/edit) in this pass. If you did not change any file, you MUST output
exactly `[SILENT]` — never describe a change you only intended to make.
# Signals worth acting on (act only if at least one clearly appears)
SKILL and UNFINISHED TASK are your PRIMARY value — no other mechanism handles
them. When their signal is clear, act; do not be shy here.
1. SKILL — two cases:
a) PATCH an existing skill: a skill used here showed a STRUCTURAL problem (a
missing step/section, a wrong or outdated detail, an error in its
content), or its OUTPUT repeatedly misses something the user flagged. Read
the relevant skill file under the skills directory and make a small
incremental edit so it never recurs.
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
one, follow the `skill-creator` skill's conventions (read its SKILL.md for
the required structure) and write the new skill under the workspace
`skills/` directory. 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
cause of a problem lives IN a skill file itself (its instructions, content,
or configuration are wrong/outdated), the correct action is to EDIT that
skill so the problem cannot recur. Recording the corrected fact in memory
does NOT prevent recurrence — only fixing the skill does. Never log "skill X
has wrong detail Y" as a memory note in place of editing skill X.
2. UNFINISHED TASK — a specific deliverable you promised but didn't produce,
AND you already have everything needed to finish it. DO IT now with the
available tools and produce the result (e.g. write the file you said you'd
write). If key info is missing, or the task is merely waiting on the user's
reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user.
You only ever notify the user as a side effect of having actually done work.
3. MEMORY — LAST resort, and you are only a SAFETY NET here, not the primary
writer. The main assistant already writes memory DURING the conversation, and
a nightly pass consolidates daily notes into long-term memory. Prefer fixing
a skill (above) over writing memory whenever the fact belongs in a skill.
Act ONLY on something the main assistant clearly MISSED that does not belong
in any skill.
- MEMORY.md is the curated long-term index, auto-loaded into EVERY future
conversation. Treat it as precious: writing here is RARE and reserved for
CORRECTING a wrong fact already in MEMORY.md (edit that line in place).
Do NOT append new entries to MEMORY.md — that is the nightly pass's job.
- For a genuinely important NEW durable fact the chat missed, append ONE
short bullet to today's `memory/YYYY-MM-DD.md` (not MEMORY.md). When unsure,
the daily file is the safe place — but first ask whether this really
belongs in a skill instead.
- Keep it to ONE short bullet. Never write paragraphs, never re-summarize the
conversation, never copy what the main assistant already recorded.
- If it is already captured anywhere (check MEMORY.md AND the daily file
first), do NOTHING.
# Do NOT capture (these poison future behavior)
- Environment failures: missing binaries, unset credentials, uninstalled
packages, "command not found". The user can fix these; they are not durable
rules.
- Negative claims about tools or features ("tool X does not work"). These
harden into refusals the agent cites against itself later.
- One-off task narratives (e.g. summarizing today's content). Not a class of
reusable work.
- Transient errors that resolved on retry within the conversation.
# Execution constraints
- Before changing memory or a skill, READ the current content first and make a
small INCREMENTAL edit. Never fabricate, never rewrite large sections.
- AVOID DUPLICATES. Before writing memory, READ both MEMORY.md AND today's
daily file `memory/YYYY-MM-DD.md`. If the fact/preference is already recorded
in EITHER (even if worded differently), do NOT add it again. The main
assistant likely already wrote it during the chat — only add what is
genuinely new or a correction not yet reflected anywhere.
- You may only edit files inside the workspace. Built-in skills shipped with
the product live outside it and are write-protected; do not try to edit them.
- Make at most the few edits the signals justify; do not go looking for work.
# Output
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
- Otherwise, after performing the edits, output a short user-facing summary in
the SAME LANGUAGE the user used in the conversation. Tell the user, briefly:
1) that you just did a self-learning pass,
2) what you learned and what you changed (in plain terms — no need to cite
exact file paths; "remembered X" / "improved the weekly-report skill" is
enough).
Keep it to 1-3 lines. Generic shape (do not copy domain words):
"I just did a self-learning pass.
- Learned: <what you learned>
- Changed: <remembered it / improved the <name> skill / finished <task>>
Reply 'undo the last learning' if this is wrong."
"""
def build_review_user_message(transcript: str, protected_skills: list = None) -> str:
"""Wrap the conversation transcript as the review agent's user message.
``protected_skills`` lists skill names that must never be edited (built-in
skills shipped with the product). Surfaced so the agent avoids them.
"""
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
protected_note = ""
if protected_skills:
names = ", ".join(sorted(protected_skills))
protected_note = (
"\n\nPROTECTED skills (built-in — never edit these): "
f"{names}\n"
)
return (
"Here is the conversation transcript that just went idle. Review it per "
"your instructions and act on any clear signal. Prefer fixing a skill at "
"its source over writing memory whenever the fact belongs in a skill.\n"
f"Today is {today}. Only if a fact genuinely belongs in memory (and not "
f"in a skill): append one short bullet to the daily file "
f"`memory/{today}.md` for a new fact, or edit MEMORY.md in place to "
f"correct an existing wrong fact."
f"{protected_note}\n"
"<transcript>\n"
f"{transcript}\n"
"</transcript>"
)

55
agent/evolution/record.py Normal file
View File

@@ -0,0 +1,55 @@
"""Self-evolution record log.
Session-level evolutions are appended to their OWN per-day file under
``memory/evolution/YYYY-MM-DD.md`` (separate from the nightly Deep Dream diary
in ``memory/dreams/``). Each day's file accumulates one short section per
evolution pass — tagged with a timestamp and a backup id for undo — so the
memory UI can surface "what the agent learned/changed today" on one timeline
without ever mixing into the dream diary or the main conversation memory.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Optional
from common.log import logger
def _evolution_dir(workspace_dir: Path, user_id: Optional[str] = None) -> Path:
base = Path(workspace_dir) / "memory"
if user_id:
return base / "users" / user_id / "evolution"
return base / "evolution"
def append_session_evolution(
workspace_dir: Path,
summary: str,
backup_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> None:
"""Append a session-evolution entry to today's evolution log."""
if not summary or not summary.strip():
return
try:
evo_dir = _evolution_dir(workspace_dir, user_id)
evo_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now().strftime("%Y-%m-%d")
log_file = evo_dir / f"{today}.md"
ts = datetime.now().strftime("%H:%M")
header = f"## {ts}"
body = summary.strip()
if backup_id:
body += f"\n\n_backup_id: {backup_id}_"
# Create with a title if the file is new, otherwise append a section.
if not log_file.exists():
log_file.write_text(f"# Self-Evolution: {today}\n\n", encoding="utf-8")
with open(log_file, "a", encoding="utf-8") as f:
f.write(f"\n{header}\n\n{body}\n")
logger.info(f"[Evolution] Recorded session evolution to {log_file.name}")
except Exception as e:
logger.warning(f"[Evolution] Failed to record session evolution: {e}")

133
agent/evolution/trigger.py Normal file
View File

@@ -0,0 +1,133 @@
"""Idle-based evolution trigger.
A single background thread periodically scans live agent sessions and runs an
evolution pass for any session that is idle for >= idle_minutes AND has enough
accumulated signal, where "enough signal" is EITHER:
- >= min_turns user turns since the last evolution, OR
- the live context has grown past _CONTEXT_RATIO of the agent's token budget
(mirrors how OpenClacky / Claude Code consolidate under context pressure).
Turn counting is per user turn (not per message), measured from the last
evolution (or session start). After a pass runs, the baseline resets so a long
session can evolve multiple times without re-judging old content.
Per-session evolution state is stored on the agent instance via lightweight
attributes set by AgentBridge.agent_reply (see _note_user_turn).
"""
from __future__ import annotations
import threading
import time
from common.log import logger
from agent.evolution.config import get_evolution_config
from agent.evolution.executor import run_evolution_for_session
_SCAN_INTERVAL_SECONDS = 60
# Context-pressure trigger: evolve once the live context exceeds this fraction
# of the agent's token budget, even if min_turns hasn't been reached. Kept as a
# module constant (not user config) for now. Fallback budget matches
# agent_initializer / config.py (agent_max_context_tokens default = 50000).
_CONTEXT_RATIO = 0.8
_FALLBACK_CONTEXT_BUDGET = 50000
def _context_pressure_reached(agent) -> bool:
"""True if the agent's live context exceeds _CONTEXT_RATIO of its budget.
Uses the agent's own (estimated) token accounting so behavior matches the
existing context-trimming path. Best-effort: any error -> False.
"""
try:
with agent.messages_lock:
messages = list(agent.messages)
if not messages:
return False
est = sum(agent._estimate_message_tokens(m) for m in messages)
budget = getattr(agent, "max_context_tokens", None) or _FALLBACK_CONTEXT_BUDGET
return est / budget > _CONTEXT_RATIO
except Exception:
return False
def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
"""Record activity for a session's agent. Called once per real user turn.
Maintains, on the agent instance:
_evo_last_active : epoch seconds of the last user turn
_evo_turns : user turns since the last evolution
_evo_channel_type : originating channel (for later notify)
_evo_receiver : push target for notify
"""
try:
agent._evo_last_active = time.time()
agent._evo_turns = int(getattr(agent, "_evo_turns", 0)) + 1
if channel_type:
agent._evo_channel_type = channel_type
if receiver:
agent._evo_receiver = receiver
except Exception:
pass
def start_evolution_trigger(agent_bridge) -> None:
"""Start the idle-scan thread once per process (idempotent)."""
if getattr(agent_bridge, "_evolution_trigger_started", False):
return
agent_bridge._evolution_trigger_started = True
t = threading.Thread(
target=_scan_loop, args=(agent_bridge,), daemon=True, name="evolution-trigger"
)
t.start()
logger.info("[Evolution] Idle trigger started")
def _scan_loop(agent_bridge) -> None:
while True:
try:
time.sleep(_SCAN_INTERVAL_SECONDS)
cfg = get_evolution_config()
if not cfg.enabled:
continue
_scan_once(agent_bridge, cfg)
except Exception as e:
logger.warning(f"[Evolution] Scan loop error: {e}")
time.sleep(_SCAN_INTERVAL_SECONDS)
def _scan_once(agent_bridge, cfg) -> None:
now = time.time()
# Snapshot to avoid holding the dict while running long evolutions.
sessions = list(getattr(agent_bridge, "agents", {}).items())
for session_id, agent in sessions:
try:
last_active = getattr(agent, "_evo_last_active", 0)
turns = int(getattr(agent, "_evo_turns", 0))
# Enough signal = enough turns OR enough context pressure.
enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent)
if not enough_signal:
continue
idle = now - last_active if last_active > 0 else -1
if last_active <= 0 or idle < cfg.idle_seconds:
continue
channel_type = getattr(agent, "_evo_channel_type", "") or ""
receiver = getattr(agent, "_evo_receiver", "") or ""
# Reset baseline BEFORE running so a long pass / new messages during
# it don't double-trigger; turns accrue fresh from here.
agent._evo_turns = 0
run_evolution_for_session(
agent_bridge,
session_id=session_id,
channel_type=channel_type,
receiver=receiver,
idle_minutes=(now - last_active) / 60 if last_active > 0 else 0.0,
)
except Exception as e:
logger.warning(f"[Evolution] Failed to evaluate session={session_id}: {e}")