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}")

View File

@@ -13,6 +13,7 @@ Storage path: ~/cow/sessions/conversations.db
from __future__ import annotations
import json
import re
import sqlite3
import threading
import time
@@ -109,6 +110,43 @@ def _extract_display_text(content: Any) -> str:
return ""
# Internal markers written into the session for the agent's own bookkeeping
# (scheduler injection / self-evolution undo). They must stay in the stored
# content (the LLM reads them, e.g. to find a backup_id for undo) but should
# never be shown verbatim to the user in the chat history UI.
_SCHEDULED_DISPLAY_MARKERS = ("[SCHEDULED]", "Scheduled task")
_EVOLUTION_DISPLAY_MARKER = "[EVOLUTION]"
def _is_internal_user_marker(text: str) -> bool:
"""True if a user-turn text is an internal injection marker (hide from UI)."""
t = (text or "").lstrip()
return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS)
def _clean_display_text(text: str) -> str:
"""Strip internal markers from assistant text for user-facing display.
Removes a leading ``[EVOLUTION]`` tag and a trailing ``(backup_id: ...)``
undo hint. The raw stored message is untouched, so undo + LLM context still
work; only the rendered chat bubble is cleaned.
"""
if not text:
return text
cleaned = text
stripped = cleaned.lstrip()
if stripped.startswith(_EVOLUTION_DISPLAY_MARKER):
cleaned = stripped[len(_EVOLUTION_DISPLAY_MARKER):].lstrip()
# Drop a trailing backup_id undo hint line, e.g.
# "(backup_id: 20260607-...; to undo, restore this backup)"
cleaned = re.sub(
r"\n*\(backup_id:[^\)]*\)\s*$",
"",
cleaned,
).rstrip()
return cleaned
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
"""
Extract tool_use blocks from an assistant message content.
@@ -210,7 +248,10 @@ def _group_into_display_turns(
if user_row:
content, created_at, _u_extras = user_row
text = _extract_display_text(content)
if text:
# Hide internal injection markers (scheduler / self-evolution) so the
# user never sees a synthetic "[SCHEDULED] self-evolution" bubble;
# the assistant reply that follows is still rendered.
if text and not _is_internal_user_marker(text):
turns.append({"role": "user", "content": text, "created_at": created_at})
# Build an ordered list of steps preserving the original sequence:
@@ -265,6 +306,14 @@ def _group_into_display_turns(
step["result"] = tr.get("result", "")
step["is_error"] = tr.get("is_error", False)
# Clean internal markers from the user-facing assistant text. Applies to
# both the final content and the mirrored content step so the rendered
# bubble shows clean text while the stored message keeps the markers.
final_text = _clean_display_text(final_text)
for step in steps:
if step.get("type") == "content":
step["content"] = _clean_display_text(step.get("content", ""))
if steps or final_text:
turn = {
"role": "assistant",

View File

@@ -34,13 +34,18 @@ class MemoryService:
# ------------------------------------------------------------------
def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict:
"""
List memory or dream files with metadata (without content).
List memory, dream, or evolution files with metadata (without content).
Args:
category: ``"memory"`` (default) — MEMORY.md + daily files;
``"dream"`` — dream diary files from memory/dreams/
``"dream"`` — dream diary files from memory/dreams/;
``"evolution"`` — self-evolution logs from memory/evolution/
merged with the nightly dream diaries, so
one tab shows everything the agent learned.
"""
if category == "dream":
if category == "evolution":
files = self._list_evolution_files()
elif category == "dream":
files = self._list_dream_files()
else:
files = self._list_memory_files()
@@ -93,6 +98,26 @@ class MemoryService:
return files
def _list_evolution_files(self) -> List[dict]:
"""Self-evolution logs (memory/evolution/*.md) merged with the nightly
dream diaries (memory/dreams/*.md), newest first.
Both are surfaced under the unified "Self-Evolution" tab. A file's
``type`` records its origin so the reader can resolve the right dir.
"""
files: List[dict] = []
for sub, ftype in (("evolution", "evolution"), ("dreams", "dream")):
sub_dir = os.path.join(self.memory_dir, sub)
if not os.path.isdir(sub_dir):
continue
for name in os.listdir(sub_dir):
full = os.path.join(sub_dir, name)
if os.path.isfile(full) and name.endswith(".md"):
files.append(self._file_info(full, name, ftype))
# Sort newest first by filename (date-named); ties favor evolution.
files.sort(key=lambda f: (f["filename"], f["type"] != "evolution"), reverse=True)
return files
# ------------------------------------------------------------------
# content — read a single file
# ------------------------------------------------------------------
@@ -101,7 +126,7 @@ class MemoryService:
Read the full content of a memory or dream file.
:param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md``
:param category: ``"memory"`` or ``"dream"``
:param category: ``"memory"``, ``"dream"`` or ``"evolution"``
:return: dict with ``filename`` and ``content``
:raises FileNotFoundError: if the file does not exist
"""
@@ -125,7 +150,7 @@ class MemoryService:
Dispatch a memory management action.
:param action: ``list`` or ``content``
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"``)
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"`` | ``"evolution"``)
:return: protocol-compatible response dict
"""
payload = payload or {}
@@ -166,6 +191,7 @@ class MemoryService:
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
- ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md``
- ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md``
- ``2026-02-20.md`` (evolution) → ``{workspace_root}/memory/evolution/2026-02-20.md``
Raises ValueError if the resolved path escapes the allowed directory.
"""
@@ -173,6 +199,8 @@ class MemoryService:
base_dir = self.workspace_root
elif category == "dream":
base_dir = os.path.join(self.memory_dir, "dreams")
elif category == "evolution":
base_dir = os.path.join(self.memory_dir, "evolution")
else:
base_dir = self.memory_dir

View File

@@ -347,11 +347,14 @@ class AgentStreamExecutor:
Returns:
Final response text
"""
# Log user message with model info
# Log user message with model info. Truncate very long messages (e.g.
# injected transcripts / large prompts) so logs stay readable.
thinking_enabled = self._is_thinking_enabled()
thinking_label = " | 💭 thinking" if thinking_enabled else ""
logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {user_message}")
_log_msg = user_message if len(user_message) <= 500 else (
user_message[:500] + f" …(+{len(user_message) - 500} chars)"
)
logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {_log_msg}")
# Add user message (Claude format - use content blocks for consistency)
self.messages.append({

View File

@@ -14,6 +14,9 @@ from agent.tools.send.send import Send
from agent.tools.memory.memory_search import MemorySearchTool
from agent.tools.memory.memory_get import MemoryGetTool
# Import self-evolution tools
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
# Import tools with optional dependencies
def _import_optional_tools():
"""Import tools that have optional dependencies"""
@@ -135,6 +138,7 @@ __all__ = [
'Send',
'MemorySearchTool',
'MemoryGetTool',
'EvolutionUndoTool',
'EnvConfig',
'SchedulerTool',
'WebSearch',

View File

@@ -0,0 +1,3 @@
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
__all__ = ["EvolutionUndoTool"]

View File

@@ -0,0 +1,58 @@
"""Evolution undo tool.
Lets the main chat agent roll back a previous self-evolution when the user asks
("undo the last learning"). The rollback itself is a deterministic FILE RESTORE
from the snapshot taken before the evolution — the model only supplies the
backup_id it reads from the [EVOLUTION] record in the conversation. No LLM-driven
re-editing is involved, so a restore can never make things worse.
"""
from agent.tools.base_tool import BaseTool, ToolResult
class EvolutionUndoTool(BaseTool):
"""Restore memory/skill files to the state before a self-evolution."""
name: str = "evolution_undo"
description: str = (
"Undo a previous self-evolution (self-learning) by restoring the "
"memory/skill files to their state before that learning. Use this when "
"the user asks to undo / revert / roll back the last self-learning. "
"Find the backup_id in the most recent [EVOLUTION] record in the "
"conversation and pass it here."
)
params: dict = {
"type": "object",
"properties": {
"backup_id": {
"type": "string",
"description": (
"The backup_id from the [EVOLUTION] record to restore "
"(e.g. '20260607-155551-850')."
),
}
},
"required": ["backup_id"],
}
def execute(self, args: dict):
backup_id = (args.get("backup_id") or "").strip()
if not backup_id:
return ToolResult.fail("Error: backup_id is required")
try:
from agent.memory.config import get_default_memory_config
from agent.evolution.backup import restore_backup
workspace_dir = get_default_memory_config().get_workspace()
ok = restore_backup(workspace_dir, backup_id)
if ok:
return ToolResult.success(
f"Restored memory/skills to the state before evolution "
f"{backup_id}. The previous self-learning has been undone."
)
return ToolResult.fail(
f"Could not find or restore backup {backup_id}. It may have "
f"expired or already been rolled back."
)
except Exception as e:
return ToolResult.fail(f"Error during undo: {e}")

View File

@@ -295,6 +295,13 @@ class AgentBridge:
self.scheduler_initialized = True
except Exception as e:
logger.warning(f"[AgentBridge] Eager scheduler init failed: {e}")
# Start the self-evolution idle trigger (idempotent, daemon thread).
try:
from agent.evolution.trigger import start_evolution_trigger
start_evolution_trigger(self)
except Exception as e:
logger.warning(f"[AgentBridge] Evolution trigger init failed: {e}")
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
"""
Create the super agent with COW integration
@@ -547,6 +554,23 @@ class AgentBridge:
except Exception as e:
logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {e}")
# Record this user turn for the self-evolution idle trigger. Skip
# scheduler-injected / scheduled-task sessions so internal runs do
# not count as user activity.
if session_id and not session_id.startswith("scheduler_") and not (
context and context.get("is_scheduled_task")
):
try:
from agent.evolution.trigger import note_user_turn
ch = (context.get("channel_type") or "") if context else ""
rcv = (context.get("receiver") or "") if context else ""
is_group = bool(context.get("isgroup")) if context else False
# Only enable proactive push for single chats (group push is
# noisy); group sessions still evolve, just without notify.
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
except Exception:
pass
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
# sync any new/removed MCP tools into the live agent in the
# background. Off the critical path so user latency is unaffected;

View File

@@ -760,7 +760,7 @@
</button>
<button id="memory-tab-dreams" onclick="switchMemoryTab('dreams')"
class="memory-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150">
<i class="fas fa-moon mr-1.5"></i><span data-i18n="memory_tab_dreams">梦境日记</span>
<i class="fas fa-seedling mr-1.5"></i><span data-i18n="memory_tab_dreams">自主进化</span>
</button>
</div>
</div>

View File

@@ -140,7 +140,7 @@ const I18N = {
skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用',
skill_toggle_error: '操作失败,请稍后再试',
memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容',
memory_tab_files: '记忆文件', memory_tab_dreams: '梦境日记',
memory_tab_files: '记忆文件', memory_tab_dreams: '自主进化',
memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处',
memory_back: '返回列表',
memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间',
@@ -342,7 +342,7 @@ const I18N = {
skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable',
skill_toggle_error: 'Operation failed, please try again',
memory_title: 'Memory', memory_desc: 'View agent memory files and contents',
memory_tab_files: 'Memory Files', memory_tab_dreams: 'Dream Diary',
memory_tab_files: 'Memory Files', memory_tab_dreams: 'Self-Evolution',
memory_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here',
memory_back: 'Back to list',
memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated',
@@ -4304,13 +4304,14 @@ function toggleSkill(name, currentlyEnabled) {
// Memory View
// =====================================================================
let memoryPage = 1;
let memoryCategory = 'memory'; // 'memory' | 'dream'
let memoryCategory = 'memory'; // 'memory' | 'evolution'
const memoryPageSize = 10;
function switchMemoryTab(tab) {
document.querySelectorAll('.memory-tab').forEach(el => el.classList.remove('active'));
document.getElementById('memory-tab-' + tab).classList.add('active');
memoryCategory = tab === 'dreams' ? 'dream' : 'memory';
// The "dreams" tab now surfaces self-evolution logs (merged with dream diaries).
memoryCategory = tab === 'dreams' ? 'evolution' : 'memory';
loadMemoryView(1);
}
@@ -4327,9 +4328,9 @@ function loadMemoryView(page) {
if (total === 0) {
const emptyIcon = emptyEl.querySelector('i');
const emptyTitle = emptyEl.querySelector('p');
if (memoryCategory === 'dream') {
emptyIcon.className = 'fas fa-moon text-purple-400 text-xl';
emptyTitle.textContent = currentLang === 'zh' ? '暂无梦境日记' : 'No dream diaries yet';
if (memoryCategory === 'evolution') {
emptyIcon.className = 'fas fa-seedling text-emerald-400 text-xl';
emptyTitle.textContent = currentLang === 'zh' ? '暂无进化记录' : 'No evolution records yet';
} else {
emptyIcon.className = 'fas fa-brain text-purple-400 text-xl';
emptyTitle.textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files';
@@ -4346,10 +4347,15 @@ function loadMemoryView(page) {
files.forEach(f => {
const tr = document.createElement('tr');
tr.className = 'border-b border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors';
tr.onclick = () => openMemoryFile(f.filename, memoryCategory);
// In the merged evolution tab, resolve each file by its own origin
// (evolution logs vs dream diaries live in different dirs).
const fileCategory = (f.type === 'dream' || f.type === 'evolution') ? f.type : memoryCategory;
tr.onclick = () => openMemoryFile(f.filename, fileCategory);
let typeLabel;
if (f.type === 'global') {
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">Global</span>';
} else if (f.type === 'evolution') {
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">Evolution</span>';
} else if (f.type === 'dream') {
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-violet-50 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">Dream</span>';
} else {

View File

@@ -251,6 +251,10 @@ available_setting = {
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
"knowledge": True, # whether to enable the knowledge base feature
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
"self_evolution_enabled": False, # master switch (off until release)
"self_evolution_idle_minutes": 15, # idle time before a session is reviewed
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
}

View File

@@ -26,12 +26,12 @@ class Keyword(Plugin):
config_path = os.path.join(curdir, "config.json")
conf = None
if not os.path.exists(config_path):
logger.debug(f"[keyword]不存在配置文件{config_path}")
logger.debug(f"[keyword] config file not found: {config_path}")
conf = {"keyword": {}}
with open(config_path, "w", encoding="utf-8") as f:
json.dump(conf, f, indent=4)
else:
logger.debug(f"[keyword]加载配置文件{config_path}")
logger.debug(f"[keyword] loading config file: {config_path}")
with open(config_path, "r", encoding="utf-8") as f:
conf = json.load(f)
# 加载关键词

660
tests/test_evolution.py Normal file
View File

@@ -0,0 +1,660 @@
"""Self-evolution test harness.
Simulates multiple realistic conversations and checks the evolution pass behaves
correctly: stays silent when it should, evolves (memory/skill) when it should,
backs up before editing, notifies the user, and supports undo.
Two modes:
- stub (default): the review agent's reasoning is replaced by a scripted
output per scenario. Fast, deterministic, validates the WIRING (backup,
record, inject, notify, undo, protection). No model calls.
- real: the review agent runs the configured model for real. Validates the
QUALITY of the judgement (does it correctly decide to act / stay silent).
Run:
python tests/test_evolution.py # stub mode
python tests/test_evolution.py --real # real model mode
"""
import os
import sys
import shutil
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class FakeChannel:
"""Captures channel.send calls instead of sending."""
def __init__(self):
self.sent = []
def send(self, reply, context):
self.sent.append({"content": getattr(reply, "content", str(reply)), "receiver": context.get("receiver")})
class FakeModel:
pass
class FakeAgent:
"""Minimal stand-in for a chat Agent."""
def __init__(self, messages, tools=None):
import threading
self.messages = messages
self.messages_lock = threading.Lock()
self.tools = tools or []
self.model = FakeModel()
self.skill_manager = None
self.memory_manager = None
class FakeReviewAgent:
"""Review agent whose run_stream returns a scripted result (stub mode)."""
def __init__(self, scripted_output, workspace, on_edit=None):
self._out = scripted_output
self._workspace = workspace
self._on_edit = on_edit
self.model = None
def run_stream(self, user_message, clear_history=False, **kwargs):
# Simulate the side effects a real review agent would perform.
if self._on_edit:
self._on_edit(self._workspace)
return self._out
class FakeAgentBridge:
"""Stand-in for AgentBridge wiring used by the executor."""
def __init__(self, agent, scripted_output, on_edit=None):
self.agents = {"session_test": agent}
self.default_agent = agent
self._scripted = scripted_output
self._on_edit = on_edit
self.injected = []
def create_agent(self, **kwargs):
from agent.memory.config import get_default_memory_config
ws = get_default_memory_config().get_workspace()
return FakeReviewAgent(self._scripted, ws, on_edit=self._on_edit)
def remember_scheduled_output(self, session_id, content, channel_type="", task_description=""):
self.injected.append(content)
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
def _setup_workspace():
"""Create a realistic temp workspace: seeded memory + real editable skills.
Mirrors a real CowAgent workspace closely enough that the model has genuine
content to read, reason about, and edit during a real evolution pass.
"""
ws = Path(tempfile.mkdtemp(prefix="evo_test_"))
(ws / "MEMORY.md").write_text(
"# Long-term Memory\n\n"
"## User\n"
"- Name: 大锤 (David)\n"
"- Lives in Shenzhen, works as a backend engineer\n"
"- Company: a fintech startup, team of 8\n\n"
"## Preferences\n"
"- Likes detailed technical explanations\n",
encoding="utf-8",
)
(ws / "memory").mkdir()
(ws / "output").mkdir()
skills = ws / "skills"
# Editable skill 1: weekly report generator (has a structural gap: no risk).
(skills / "weekly-report").mkdir(parents=True)
(skills / "weekly-report" / "SKILL.md").write_text(
"# Weekly Report\n\n"
"Generate a weekly work report from the user's notes.\n\n"
"## Steps\n"
"1. Collect this week's completed items.\n"
"2. Summarize key progress in 3-5 bullets.\n"
"3. List next week's plan.\n\n"
"## Output format\n"
"Markdown with sections: 本周进展 / 下周计划\n",
encoding="utf-8",
)
# Editable skill 2: expense tracker (has a wrong currency-format step).
(skills / "expense-tracker").mkdir(parents=True)
(skills / "expense-tracker" / "SKILL.md").write_text(
"# Expense Tracker\n\n"
"Record an expense into output/expenses.md.\n\n"
"## Steps\n"
"1. Parse amount and category from the user message.\n"
"2. Append a row to output/expenses.md.\n"
"3. Format the amount with a `$` prefix.\n",
encoding="utf-8",
)
# Editable skill 3: an API caller whose SKILL.md hardcodes a WRONG endpoint
# host. The conversation discovers the correct host at runtime; the right
# fix is to edit this file's source, not just log the corrected fact.
(skills / "data-fetch").mkdir(parents=True)
(skills / "data-fetch" / "SKILL.md").write_text(
"# Data Fetch\n\n"
"Fetch records from the data service.\n\n"
"## Steps\n"
"1. Build the request payload from the user's query.\n"
"2. POST it to `https://api.example-wrong.com/v1/fetch`.\n"
"3. Parse and return the `data` field.\n",
encoding="utf-8",
)
# Protected built-in skill: must never be edited by evolution.
(skills / "image-generation").mkdir(parents=True)
(skills / "image-generation" / "SKILL.md").write_text(
"# Image Generation (built-in)\nDo not modify.\n", encoding="utf-8"
)
return ws
def _point_config_at(ws):
"""Force the global memory config to use the temp workspace."""
from agent.memory.config import MemoryConfig, set_global_memory_config
set_global_memory_config(MemoryConfig(workspace_root=str(ws)))
def _make_messages(turns):
msgs = []
for u, a in turns:
msgs.append({"role": "user", "content": u})
msgs.append({"role": "assistant", "content": a})
return msgs
# ---------------------------------------------------------------------------
# Scenarios
# ---------------------------------------------------------------------------
def scenario_silent():
"""Pure small talk -> should stay SILENT (no change, no notify)."""
return {
"name": "闲聊 (should stay SILENT)",
"goal": "none",
"turns": [
("在吗", "在的,有什么可以帮你?"),
("今天周五了,终于要放假了", "是呀,周末好好休息一下。"),
("哈哈是的,那没事了", "好的,随时找我。"),
],
"scripted": "[SILENT]",
"on_edit": None,
"expect_evolved": False,
}
def scenario_memory_preference():
"""User states a durable working preference -> update MEMORY.md."""
def edit(ws):
from datetime import datetime
p = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
p.write_text((p.read_text() if p.exists() else "") + "- Prefers concise replies; avoid long explanations\n", encoding="utf-8")
return {
"name": "记忆-偏好 (should note in daily memory)",
"goal": "memory",
"turns": [
("你能帮我解释下什么是数据库事务吗", "当然可以。数据库事务Transaction是一组要么全部成功、要么全部失败的操作单元它有四个核心特性 ACID原子性、一致性、隔离性、持久性……接着是 5 段详细展开)"),
("打住打住,太长了", "抱歉,我说得太细了。"),
("对,你以后回答我都简短一点,抓重点就行,别长篇大论", "好的,明白了。"),
("嗯这样挺好", "👌"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:你希望回答简短、抓重点\n"
"- 调整:更新了长期记忆 MEMORY.md\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_memory_contains": "concise",
}
def scenario_memory_correction():
"""User corrects a wrong fact the agent stated -> fix MEMORY.md."""
def edit(ws):
from datetime import datetime
p = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
p.write_text((p.read_text() if p.exists() else "") + "- Correction: team size is now 15 (was recorded as 8)\n", encoding="utf-8")
return {
"name": "记忆-纠正事实 (should note correction in daily)",
"goal": "memory",
"turns": [
("我们团队最近又招了几个人", "记得你们是 8 人的小团队,扩张了挺好的。"),
("不对,我们现在是 15 个人了,上次你就记错了", "抱歉记错了,你们现在是 15 人团队。"),
("嗯对,记住啊", "好的,记住了。"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:团队规模已是 15 人(之前记成 8 人)\n"
"- 调整:修正了长期记忆 MEMORY.md\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_memory_contains": "15",
}
def scenario_skill_gap():
"""Using weekly-report reveals a missing section -> patch the skill."""
def edit(ws):
p = ws / "skills" / "weekly-report" / "SKILL.md"
p.write_text(p.read_text() + "4. List risk items / blockers.\n", encoding="utf-8")
return {
"name": "技能-缺陷 (should patch weekly-report skill)",
"goal": "skill",
"turns": [
("用周报技能帮我生成这周的周报素材完成了支付网关重构、修复了3个线上bug、对接了风控接口",
"好的,我用 weekly-report 技能生成:\n\n## 本周进展\n- 完成支付网关重构\n- 修复 3 个线上 bug\n- 对接风控接口\n\n## 下周计划\n- 联调风控接口"),
("不错,但是每次周报都漏了风险项,我们 leader 每次都要问这个", "确实,风险项很重要。"),
("对,你能不能让这个周报技能以后都带上风险项这一节", "可以的,我把它固化进技能。"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:周报需要固定包含「风险项」一节\n"
"- 调整:给 weekly-report 技能补充了风险项步骤\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_skill_contains": ("weekly-report", "risk"),
}
def scenario_skill_error():
"""A skill step is wrong (currency format) -> correct the skill."""
def edit(ws):
p = ws / "skills" / "expense-tracker" / "SKILL.md"
p.write_text(p.read_text().replace("`$` prefix", "`¥` prefix (RMB)"), encoding="utf-8")
return {
"name": "技能-报错纠正 (should fix expense-tracker skill)",
"goal": "skill",
"turns": [
("用记账技能记一笔:午饭 35", "好的,已记录:午饭 $35已写入 output/expenses.md"),
("怎么是美元?我们都是人民币啊", "抱歉,格式用错了,应该是 ¥35。"),
("对,以后这个记账技能都用人民币符号", "明白,我修正技能里的货币格式。"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:记账应使用人民币符号 ¥ 而非 $\n"
"- 调整:修正了 expense-tracker 技能的货币格式步骤\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_skill_contains": ("expense-tracker", "¥"),
}
def scenario_skill_wrong_config():
"""A skill's SKILL.md hardcodes a wrong endpoint; the chat works around it
at runtime. Correct evolution = FIX the skill source, not log a memory note.
"""
def edit(ws):
p = ws / "skills" / "data-fetch" / "SKILL.md"
p.write_text(
p.read_text().replace("api.example-wrong.com", "api.example-correct.com"),
encoding="utf-8",
)
return {
"name": "技能-配置错误 (should fix skill source, not log memory)",
"goal": "skill",
"turns": [
("用 data-fetch 技能拉一下最新数据",
"好的,我按技能里的步骤 POST 到 https://api.example-wrong.com/v1/fetch …… 报错了,连接失败。"),
("哦那个地址不对,正确的是 api.example-correct.com你用这个调",
"好的,换成 https://api.example-correct.com/v1/fetch ,成功拿到数据了 ✅"),
("嗯对,就是这个地址", "明白了,这个才是正确的服务地址。"),
],
# The model must decide on its own to edit the skill; we still provide a
# scripted output for stub mode wiring.
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到data-fetch 的正确服务地址是 api.example-correct.com\n"
"- 调整:修正了 data-fetch 技能里写错的接口地址\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_skill_contains": ("data-fetch", "api.example-correct.com"),
}
def scenario_unfinished_task():
"""A promised deliverable was not produced -> finish it now via tools."""
def edit(ws):
p = ws / "output" / "team-roster.md"
p.write_text("# Team Roster (backend)\n- 张伟\n- 李娜\n- 王强\n- 大锤\n", encoding="utf-8")
return {
"name": "未完成任务 (should finish & write output file)",
"goal": "task",
"turns": [
("帮我把后端团队花名册整理成一个文件保存下,成员有:张伟、李娜、王强,还有我自己(大锤)",
"好的,后端 4 个人:张伟、李娜、王强、大锤。我整理成文件保存到 output/team-roster.md。"),
("好的麻烦了,我先去开个会", "没问题,我现在就处理。"),
("(用户离开,会话中断,文件尚未写入)", "(助手未及写入文件,对话中断)"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 发现:之前答应整理团队花名册但没完成\n"
"- 已完成:把后端成员名单写入 output/team-roster.md\n"
"如果不需要,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_output_file": "team-roster.md",
}
SCENARIOS = [
scenario_silent,
scenario_memory_preference,
scenario_memory_correction,
scenario_skill_gap,
scenario_skill_error,
scenario_skill_wrong_config,
scenario_unfinished_task,
]
# ---------------------------------------------------------------------------
# Runner (stub mode)
# ---------------------------------------------------------------------------
def run_stub():
from agent.evolution.executor import run_evolution_for_session
from agent.evolution import backup as backup_mod
from config import conf
# Evolution is disabled by default now; enable for the test.
conf()["self_evolution_enabled"] = True
passed, failed = 0, 0
for make in SCENARIOS:
sc = make()
ws = _setup_workspace()
try:
_point_config_at(ws)
# Patch channel push to capture instead of send.
channel = FakeChannel()
import agent.evolution.executor as ex
orig_notify = ex._notify_user
ex._notify_user = lambda ct, rcv, summary: channel.send(
type("R", (), {"content": summary})(),
{"receiver": rcv},
)
agent = FakeAgent(_make_messages(sc["turns"]))
bridge = FakeAgentBridge(agent, sc["scripted"], on_edit=sc["on_edit"])
evolved = run_evolution_for_session(
bridge, "session_test", channel_type="telegram", receiver="user_42"
)
ok = True
errs = []
if evolved != sc["expect_evolved"]:
ok = False
errs.append(f"evolved={evolved}, expected {sc['expect_evolved']}")
if sc["expect_evolved"]:
# memory / skill content checks
if "expect_memory_contains" in sc:
# Evolution now writes to the dated daily file, not MEMORY.md.
from datetime import datetime
daily = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
mem = daily.read_text() if daily.exists() else ""
if sc["expect_memory_contains"] not in mem:
ok = False
errs.append("daily memory missing expected content")
if "expect_skill_contains" in sc:
sk, txt = sc["expect_skill_contains"]
content = (ws / "skills" / sk / "SKILL.md").read_text()
if txt not in content:
ok = False
errs.append("skill missing expected content")
# notify happened
if not channel.sent:
ok = False
errs.append("no notification sent")
# injection happened (undo support)
if not bridge.injected or "[EVOLUTION]" not in bridge.injected[0]:
ok = False
errs.append("no [EVOLUTION] record injected")
# protected skill untouched
prot = (ws / "skills" / "image-generation" / "SKILL.md").read_text()
if prot != "# Image Generation (built-in)\nDo not modify.\n":
ok = False
errs.append("PROTECTED skill was modified!")
# backup exists (undo possible)
backups = list((ws / "memory" / ".evolution_backups").glob("*"))
if not backups:
ok = False
errs.append("no backup created")
else:
# SILENT: nothing should have changed / been sent
if channel.sent:
ok = False
errs.append("notification sent on SILENT")
if bridge.injected:
ok = False
errs.append("injected record on SILENT")
ex._notify_user = orig_notify
if ok:
passed += 1
print(f" PASS {sc['name']}")
else:
failed += 1
print(f" FAIL {sc['name']}: {'; '.join(errs)}")
finally:
shutil.rmtree(ws, ignore_errors=True)
# Undo verification (uses the memory scenario's backup path).
print("\n-- undo tool --")
_verify_undo()
print(f"\nStub results: {passed} passed, {failed} failed")
return failed == 0
def _verify_undo():
from agent.evolution.backup import create_backup, restore_backup
ws = _setup_workspace()
try:
_point_config_at(ws)
mem = ws / "MEMORY.md"
bid = create_backup(ws, [mem])
mem.write_text("CORRUPTED", encoding="utf-8")
from agent.tools.evolution_undo import EvolutionUndoTool
r = EvolutionUndoTool().execute({"backup_id": bid})
restored = mem.read_text()
if r.status == "success" and "大锤" in restored:
print(" PASS undo restores pre-evolution state")
else:
print(f" FAIL undo: status={r.status}, content={restored[:40]}")
finally:
shutil.rmtree(ws, ignore_errors=True)
# ---------------------------------------------------------------------------
# Runner (real mode) — minimal: just prints the model's decision per scenario.
# ---------------------------------------------------------------------------
def _snapshot_ws(ws: Path) -> dict:
"""Map every text file under the workspace -> content (skip backups dir)."""
snap = {}
for p in ws.rglob("*"):
if not p.is_file():
continue
rel = str(p.relative_to(ws))
if rel.startswith("memory/.evolution_backups"):
continue
try:
snap[rel] = p.read_text(encoding="utf-8")
except Exception:
pass
return snap
def _print_diff(before: dict, after: dict) -> bool:
"""Print added/changed files. Returns True if anything changed."""
changed = False
keys = sorted(set(before) | set(after))
for rel in keys:
old = before.get(rel)
new = after.get(rel)
if old == new:
continue
changed = True
tag = "NEW FILE" if old is None else "CHANGED"
print(f" ~ {rel} [{tag}]")
old_lines = set((old or "").splitlines())
for line in (new or "").splitlines():
if line not in old_lines:
print(f" + {line}")
return changed
def run_real():
"""Run real model evolution on each scenario and print the actual output.
Uses config.json's configured model via a real AgentBridge, so you see
exactly what the model decides and writes for each conversation.
"""
from bridge.bridge import Bridge
from agent.memory.config import (
MemoryConfig,
set_global_memory_config,
get_default_memory_config,
)
from config import conf, load_config
# Load config.json so real API keys are available to the bots.
load_config()
# Default the test to deepseek-v4-flash (fast, low cost) unless overridden.
override_model = os.environ.get("EVO_TEST_MODEL", "deepseek-v4-flash")
conf()["model"] = override_model
conf()["bot_type"] = os.environ.get("EVO_TEST_BOT_TYPE", "deepseek")
# Force-enable evolution for the test regardless of config.json default.
conf()["self_evolution_enabled"] = True
print(f"[test] model: {override_model} (bot_type={conf().get('bot_type')}, "
f"key={'set' if conf().get('deepseek_api_key') else 'MISSING'})")
from agent.memory.manager import MemoryManager
import agent.evolution.executor as ex
bridge = Bridge()
agent_bridge = bridge.get_agent_bridge()
# Capture the user-facing reply instead of pushing it to a channel.
captured = {"reply": None}
orig_notify = ex._notify_user
ex._notify_user = lambda ct, rcv, summary: captured.__setitem__("reply", summary)
results = [] # (name, goal, evolved, changed, reply_ok)
only = os.environ.get("EVO_TEST_ONLY") # substring filter on goal/name
try:
for make in SCENARIOS:
sc = make()
if only and only not in sc["goal"] and only not in sc["name"]:
continue
ws = _setup_workspace()
captured["reply"] = None
try:
mem_cfg = MemoryConfig(workspace_root=str(ws))
set_global_memory_config(mem_cfg)
sid = "session_evo_real"
# Fully isolated agent: tool cwd + memory_manager -> temp ws.
iso_mem = MemoryManager(mem_cfg)
agent = agent_bridge.create_agent(
system_prompt="You are a helpful assistant.",
tools=None,
workspace_dir=str(ws),
memory_manager=iso_mem,
enable_skills=False,
)
# Notify path needs a channel+receiver to fire; give dummies.
agent_bridge.agents[sid] = agent
with agent.messages_lock:
agent.messages.clear()
agent.messages.extend(_make_messages(sc["turns"]))
before = _snapshot_ws(ws)
print("\n" + "=" * 72)
print(f"场景: {sc['name']} [目标: {sc['goal']}]")
print("-" * 72)
print("【会话输入】")
for u, a in sc["turns"]:
print(f" 用户: {u}")
print(f" 助手: {a}")
from agent.evolution.executor import run_evolution_for_session
evolved = run_evolution_for_session(
agent_bridge, sid, channel_type="telegram", receiver="tester"
)
after = _snapshot_ws(ws)
print("\n【进化结果】 evolved =", evolved)
changed = False
if evolved:
changed = _print_diff(before, after)
if not changed:
print(" (无文件变更)")
else:
print(" (静默,未做任何改动)")
print("\n【给用户的回复】")
if captured["reply"]:
for line in captured["reply"].splitlines():
print(f" {line}")
else:
print(" (无推送)")
reply_ok = bool(captured["reply"]) == bool(evolved)
results.append((sc["name"], sc["goal"], evolved, changed, reply_ok))
agent_bridge.agents.pop(sid, None)
finally:
shutil.rmtree(ws, ignore_errors=True)
finally:
ex._notify_user = orig_notify
# Summary table.
print("\n" + "=" * 72)
print("汇总 (deepseek-v4-flash 真实运行)")
print("-" * 72)
for name, goal, evolved, changed, reply_ok in results:
exp = "静默" if goal == "none" else "应进化"
got = "进化" if evolved else "静默"
mark = "" if (goal == "none") != evolved else ""
print(f" {mark} {name:42s} 预期={exp} 实际={got}")
if __name__ == "__main__":
if "--real" in sys.argv:
run_real()
else:
ok = run_stub()
sys.exit(0 if ok else 1)