diff --git a/agent/evolution/__init__.py b/agent/evolution/__init__.py new file mode 100644 index 00000000..f9f665f3 --- /dev/null +++ b/agent/evolution/__init__.py @@ -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", +] diff --git a/agent/evolution/backup.py b/agent/evolution/backup.py new file mode 100644 index 00000000..4f294b96 --- /dev/null +++ b/agent/evolution/backup.py @@ -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//`` 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}") diff --git a/agent/evolution/config.py b/agent/evolution/config.py new file mode 100644 index 00000000..864cbb1e --- /dev/null +++ b/agent/evolution/config.py @@ -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, + ) diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py new file mode 100644 index 00000000..4c0c19d6 --- /dev/null +++ b/agent/evolution/executor.py @@ -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}") diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py new file mode 100644 index 00000000..39f01989 --- /dev/null +++ b/agent/evolution/prompts.py @@ -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: + - Changed: skill / finished > + 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" + "\n" + f"{transcript}\n" + "" + ) diff --git a/agent/evolution/record.py b/agent/evolution/record.py new file mode 100644 index 00000000..751e3a83 --- /dev/null +++ b/agent/evolution/record.py @@ -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}") diff --git a/agent/evolution/trigger.py b/agent/evolution/trigger.py new file mode 100644 index 00000000..8055cbae --- /dev/null +++ b/agent/evolution/trigger.py @@ -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}") diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py index 23a81192..305e2576 100644 --- a/agent/memory/conversation_store.py +++ b/agent/memory/conversation_store.py @@ -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", diff --git a/agent/memory/service.py b/agent/memory/service.py index eb565b13..055fe831 100644 --- a/agent/memory/service.py +++ b/agent/memory/service.py @@ -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 diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index f2be2ab7..1b49baa7 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -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({ diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index 78e7b979..1e508c0c 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -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', diff --git a/agent/tools/evolution_undo/__init__.py b/agent/tools/evolution_undo/__init__.py new file mode 100644 index 00000000..a51a9551 --- /dev/null +++ b/agent/tools/evolution_undo/__init__.py @@ -0,0 +1,3 @@ +from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool + +__all__ = ["EvolutionUndoTool"] diff --git a/agent/tools/evolution_undo/evolution_undo.py b/agent/tools/evolution_undo/evolution_undo.py new file mode 100644 index 00000000..abfbc289 --- /dev/null +++ b/agent/tools/evolution_undo/evolution_undo.py @@ -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}") diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index e8424eb5..09d21c06 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -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; diff --git a/channel/web/chat.html b/channel/web/chat.html index 96bb67bd..35e6ad9b 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -760,7 +760,7 @@ diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 31c8d271..e5ab5f25 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -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 = 'Global'; + } else if (f.type === 'evolution') { + typeLabel = 'Evolution'; } else if (f.type === 'dream') { typeLabel = 'Dream'; } else { diff --git a/config.py b/config.py index da1935d5..cd54d29a 100644 --- a/config.py +++ b/config.py @@ -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__ env vars at startup "mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL) } diff --git a/plugins/keyword/keyword.py b/plugins/keyword/keyword.py index b5fced39..45ebd3ab 100644 --- a/plugins/keyword/keyword.py +++ b/plugins/keyword/keyword.py @@ -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) # 加载关键词 diff --git a/tests/test_evolution.py b/tests/test_evolution.py new file mode 100644 index 00000000..7efe770e --- /dev/null +++ b/tests/test_evolution.py @@ -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)