mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 03:03:19 +08:00
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:
19
agent/evolution/__init__.py
Normal file
19
agent/evolution/__init__.py
Normal 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
102
agent/evolution/backup.py
Normal 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
76
agent/evolution/config.py
Normal 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
449
agent/evolution/executor.py
Normal 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
163
agent/evolution/prompts.py
Normal 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
55
agent/evolution/record.py
Normal 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
133
agent/evolution/trigger.py
Normal 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}")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
3
agent/tools/evolution_undo/__init__.py
Normal file
3
agent/tools/evolution_undo/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
|
||||
|
||||
__all__ = ["EvolutionUndoTool"]
|
||||
58
agent/tools/evolution_undo/evolution_undo.py
Normal file
58
agent/tools/evolution_undo/evolution_undo.py
Normal 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}")
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
660
tests/test_evolution.py
Normal 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)
|
||||
Reference in New Issue
Block a user