mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -109,6 +110,43 @@ def _extract_display_text(content: Any) -> str:
|
|||||||
return ""
|
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]]:
|
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Extract tool_use blocks from an assistant message content.
|
Extract tool_use blocks from an assistant message content.
|
||||||
@@ -210,7 +248,10 @@ def _group_into_display_turns(
|
|||||||
if user_row:
|
if user_row:
|
||||||
content, created_at, _u_extras = user_row
|
content, created_at, _u_extras = user_row
|
||||||
text = _extract_display_text(content)
|
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})
|
turns.append({"role": "user", "content": text, "created_at": created_at})
|
||||||
|
|
||||||
# Build an ordered list of steps preserving the original sequence:
|
# 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["result"] = tr.get("result", "")
|
||||||
step["is_error"] = tr.get("is_error", False)
|
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:
|
if steps or final_text:
|
||||||
turn = {
|
turn = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
|
|||||||
@@ -34,13 +34,18 @@ class MemoryService:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict:
|
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:
|
Args:
|
||||||
category: ``"memory"`` (default) — MEMORY.md + daily files;
|
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()
|
files = self._list_dream_files()
|
||||||
else:
|
else:
|
||||||
files = self._list_memory_files()
|
files = self._list_memory_files()
|
||||||
@@ -93,6 +98,26 @@ class MemoryService:
|
|||||||
|
|
||||||
return files
|
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
|
# content — read a single file
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -101,7 +126,7 @@ class MemoryService:
|
|||||||
Read the full content of a memory or dream file.
|
Read the full content of a memory or dream file.
|
||||||
|
|
||||||
:param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md``
|
: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``
|
:return: dict with ``filename`` and ``content``
|
||||||
:raises FileNotFoundError: if the file does not exist
|
:raises FileNotFoundError: if the file does not exist
|
||||||
"""
|
"""
|
||||||
@@ -125,7 +150,7 @@ class MemoryService:
|
|||||||
Dispatch a memory management action.
|
Dispatch a memory management action.
|
||||||
|
|
||||||
:param action: ``list`` or ``content``
|
: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
|
:return: protocol-compatible response dict
|
||||||
"""
|
"""
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
@@ -166,6 +191,7 @@ class MemoryService:
|
|||||||
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
||||||
- ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.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`` (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.
|
Raises ValueError if the resolved path escapes the allowed directory.
|
||||||
"""
|
"""
|
||||||
@@ -173,6 +199,8 @@ class MemoryService:
|
|||||||
base_dir = self.workspace_root
|
base_dir = self.workspace_root
|
||||||
elif category == "dream":
|
elif category == "dream":
|
||||||
base_dir = os.path.join(self.memory_dir, "dreams")
|
base_dir = os.path.join(self.memory_dir, "dreams")
|
||||||
|
elif category == "evolution":
|
||||||
|
base_dir = os.path.join(self.memory_dir, "evolution")
|
||||||
else:
|
else:
|
||||||
base_dir = self.memory_dir
|
base_dir = self.memory_dir
|
||||||
|
|
||||||
|
|||||||
@@ -347,11 +347,14 @@ class AgentStreamExecutor:
|
|||||||
Returns:
|
Returns:
|
||||||
Final response text
|
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_enabled = self._is_thinking_enabled()
|
||||||
thinking_label = " | 💭 thinking" if thinking_enabled else ""
|
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)
|
# Add user message (Claude format - use content blocks for consistency)
|
||||||
self.messages.append({
|
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_search import MemorySearchTool
|
||||||
from agent.tools.memory.memory_get import MemoryGetTool
|
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
|
# Import tools with optional dependencies
|
||||||
def _import_optional_tools():
|
def _import_optional_tools():
|
||||||
"""Import tools that have optional dependencies"""
|
"""Import tools that have optional dependencies"""
|
||||||
@@ -135,6 +138,7 @@ __all__ = [
|
|||||||
'Send',
|
'Send',
|
||||||
'MemorySearchTool',
|
'MemorySearchTool',
|
||||||
'MemoryGetTool',
|
'MemoryGetTool',
|
||||||
|
'EvolutionUndoTool',
|
||||||
'EnvConfig',
|
'EnvConfig',
|
||||||
'SchedulerTool',
|
'SchedulerTool',
|
||||||
'WebSearch',
|
'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
|
self.scheduler_initialized = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Eager scheduler init failed: {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:
|
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
|
||||||
"""
|
"""
|
||||||
Create the super agent with COW integration
|
Create the super agent with COW integration
|
||||||
@@ -547,6 +554,23 @@ class AgentBridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {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
|
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
|
||||||
# sync any new/removed MCP tools into the live agent in the
|
# sync any new/removed MCP tools into the live agent in the
|
||||||
# background. Off the critical path so user latency is unaffected;
|
# background. Off the critical path so user latency is unaffected;
|
||||||
|
|||||||
@@ -760,7 +760,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button id="memory-tab-dreams" onclick="switchMemoryTab('dreams')"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ const I18N = {
|
|||||||
skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用',
|
skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用',
|
||||||
skill_toggle_error: '操作失败,请稍后再试',
|
skill_toggle_error: '操作失败,请稍后再试',
|
||||||
memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容',
|
memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容',
|
||||||
memory_tab_files: '记忆文件', memory_tab_dreams: '梦境日记',
|
memory_tab_files: '记忆文件', memory_tab_dreams: '自主进化',
|
||||||
memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处',
|
memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处',
|
||||||
memory_back: '返回列表',
|
memory_back: '返回列表',
|
||||||
memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间',
|
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',
|
skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable',
|
||||||
skill_toggle_error: 'Operation failed, please try again',
|
skill_toggle_error: 'Operation failed, please try again',
|
||||||
memory_title: 'Memory', memory_desc: 'View agent memory files and contents',
|
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_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here',
|
||||||
memory_back: 'Back to list',
|
memory_back: 'Back to list',
|
||||||
memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated',
|
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
|
// Memory View
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
let memoryPage = 1;
|
let memoryPage = 1;
|
||||||
let memoryCategory = 'memory'; // 'memory' | 'dream'
|
let memoryCategory = 'memory'; // 'memory' | 'evolution'
|
||||||
const memoryPageSize = 10;
|
const memoryPageSize = 10;
|
||||||
|
|
||||||
function switchMemoryTab(tab) {
|
function switchMemoryTab(tab) {
|
||||||
document.querySelectorAll('.memory-tab').forEach(el => el.classList.remove('active'));
|
document.querySelectorAll('.memory-tab').forEach(el => el.classList.remove('active'));
|
||||||
document.getElementById('memory-tab-' + tab).classList.add('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);
|
loadMemoryView(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4327,9 +4328,9 @@ function loadMemoryView(page) {
|
|||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
const emptyIcon = emptyEl.querySelector('i');
|
const emptyIcon = emptyEl.querySelector('i');
|
||||||
const emptyTitle = emptyEl.querySelector('p');
|
const emptyTitle = emptyEl.querySelector('p');
|
||||||
if (memoryCategory === 'dream') {
|
if (memoryCategory === 'evolution') {
|
||||||
emptyIcon.className = 'fas fa-moon text-purple-400 text-xl';
|
emptyIcon.className = 'fas fa-seedling text-emerald-400 text-xl';
|
||||||
emptyTitle.textContent = currentLang === 'zh' ? '暂无梦境日记' : 'No dream diaries yet';
|
emptyTitle.textContent = currentLang === 'zh' ? '暂无进化记录' : 'No evolution records yet';
|
||||||
} else {
|
} else {
|
||||||
emptyIcon.className = 'fas fa-brain text-purple-400 text-xl';
|
emptyIcon.className = 'fas fa-brain text-purple-400 text-xl';
|
||||||
emptyTitle.textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files';
|
emptyTitle.textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files';
|
||||||
@@ -4346,10 +4347,15 @@ function loadMemoryView(page) {
|
|||||||
files.forEach(f => {
|
files.forEach(f => {
|
||||||
const tr = document.createElement('tr');
|
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.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;
|
let typeLabel;
|
||||||
if (f.type === 'global') {
|
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>';
|
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') {
|
} 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>';
|
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 {
|
} else {
|
||||||
|
|||||||
@@ -251,6 +251,10 @@ available_setting = {
|
|||||||
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
|
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
|
||||||
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
|
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
|
||||||
"knowledge": True, # whether to enable the knowledge base feature
|
"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
|
"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)
|
"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")
|
config_path = os.path.join(curdir, "config.json")
|
||||||
conf = None
|
conf = None
|
||||||
if not os.path.exists(config_path):
|
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": {}}
|
conf = {"keyword": {}}
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(conf, f, indent=4)
|
json.dump(conf, f, indent=4)
|
||||||
else:
|
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:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
conf = json.load(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