feat(memory): autoskills (#319)

This commit is contained in:
dinos
2026-07-03 11:16:33 +02:00
committed by GitHub
parent 7568a6bc1f
commit 2b244888ec
38 changed files with 3748 additions and 223 deletions
+132 -4
View File
@@ -788,7 +788,7 @@ class ReadOnlyFilesystemBackend(FilesystemBackend):
"""
Read-only filesystem backend.
Allows read, ls, grep, glob operations but blocks write and edit.
Allows read, ls, grep, glob operations but blocks write, edit, and upload.
Used for skills directory — agent can read skill definitions but cannot
modify them.
"""
@@ -809,6 +809,15 @@ class ReadOnlyFilesystemBackend(FilesystemBackend):
error="This directory is read-only. Edit operations are not permitted here."
)
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
return [
FileUploadResponse(
path=file_path,
error="This directory is read-only. Upload operations are not permitted here.",
)
for file_path, _ in files
]
class MemoryFilesystemBackend(FilesystemBackend):
"""Filesystem backend for memory files with structured-write enforcement.
@@ -852,8 +861,12 @@ class MemoryFilesystemBackend(FilesystemBackend):
]
def build_memory_agent_backend(*, workspace_dir: str | Path, memory_dir: str | Path):
"""Build the workspace backend with guarded `/memories/` routing."""
def build_memory_agent_backend(
*,
workspace_dir: str | Path,
memory_dir: str | Path,
):
"""Build the standard memory-agent backend with guarded `/memories/` routing."""
from deepagents.backends import CompositeBackend
return CompositeBackend(
@@ -867,6 +880,70 @@ def build_memory_agent_backend(*, workspace_dir: str | Path, memory_dir: str | P
)
def build_memory_worker_backend(
*,
workspace_dir: str | Path,
memory_dir: str | Path,
):
"""Build the memory-worker backend.
Workers may update profile memory through /memories/profile/... and write
observations through structured tools. The workspace itself is read-only.
"""
from deepagents.backends import CompositeBackend
return CompositeBackend(
default=ReadOnlyFilesystemBackend(
root_dir=str(workspace_dir),
virtual_mode=True,
),
routes={
"/memories/": MemoryFilesystemBackend(
root_dir=str(memory_dir),
virtual_mode=True,
)
},
)
def build_autoskill_agent_backend(
*,
memory_dir: str | Path,
proposals_dir: str | Path,
sandbox_timeout: int = 300,
):
"""Build the AutoSkills backend.
AutoSkills has a different security model from ordinary memory
maintenance: it can read memories and installed skills, write proposal
folders, and run shell validation from the proposal root.
"""
from deepagents.backends import CompositeBackend
return CompositeBackend(
default=AutoskillProposalSandboxBackend(
root_dir=str(proposals_dir),
timeout=sandbox_timeout,
),
routes={
"/memories/": ReadOnlyFilesystemBackend(
root_dir=str(memory_dir),
virtual_mode=True,
),
"/skills/": MergedSkillsBackend(
primary_dir=str(paths.USER_SKILLS_DIR),
global_dir=str(paths.GLOBAL_SKILLS_DIR),
secondary_dir=str(_BUILTIN_SKILLS_DIR),
writable_primary=False,
),
"/autoskill-proposals/": FilesystemBackend(
root_dir=str(proposals_dir),
virtual_mode=True,
),
},
)
class MergedSkillsBackend(BackendProtocol):
"""Skills backend that merges up to three skill directories.
@@ -885,8 +962,12 @@ class MergedSkillsBackend(BackendProtocol):
primary_dir: str,
secondary_dir: str,
global_dir: str | None = None,
writable_primary: bool = True,
):
self._primary = FilesystemBackend(root_dir=primary_dir, virtual_mode=True)
primary_backend = (
FilesystemBackend if writable_primary else ReadOnlyFilesystemBackend
)
self._primary = primary_backend(root_dir=primary_dir, virtual_mode=True)
self._global = (
ReadOnlyFilesystemBackend(root_dir=global_dir, virtual_mode=True)
if global_dir
@@ -1191,3 +1272,50 @@ class CustomSandboxBackend(LocalShellBackend):
)
return response
class AutoskillProposalSandboxBackend(CustomSandboxBackend):
"""Shell backend rooted at the autoskill proposal directory.
File-tool writes through this backend are blocked; proposal writes go
through the `/autoskill-proposals/` route. Shell commands run with cwd set
to the proposal root so validation commands can inspect generated skill
folders without executing in the user's project workspace.
"""
_RAW_WRITE_ERROR = (
"Raw workspace writes are blocked for AutoSkills. Write proposal files "
"under /autoskill-proposals/<skill-name>/."
)
@staticmethod
def _rewrite_autoskill_mount(command: str) -> str:
return re.sub(
r"(^|[\s'\"(=<>])/autoskill-proposals(?=/|$|[\s'\";|&)])",
r"\1.",
command,
)
def write(self, file_path: str, content: str) -> WriteResult:
return WriteResult(error=self._RAW_WRITE_ERROR)
def edit(
self,
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
return EditResult(error=self._RAW_WRITE_ERROR)
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
return [
FileUploadResponse(path=file_path, error=self._RAW_WRITE_ERROR)
for file_path, _ in files
]
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
return super().execute(
self._rewrite_autoskill_mount(command),
timeout=timeout,
)
+40
View File
@@ -471,11 +471,46 @@ def _ensure_async_subagent_server(config: Any, *, workspace_dir: str) -> None:
spinner="dots",
):
ensure_langgraph_dev(config, workspace_dir=workspace_dir)
_reconcile_autoskill_schedule(config, workspace_dir=workspace_dir)
except WorkspaceMismatchError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
def _reconcile_autoskill_schedule(config: Any, *, workspace_dir: str) -> None:
"""Best-effort reconciliation for EvoMemory's hidden AutoSkills cron."""
try:
from ..memory.autoskills.schedule import reconcile_autoskill_schedule
reconcile_autoskill_schedule(config, workspace_dir=workspace_dir)
except Exception:
logging.getLogger(__name__).warning(
"Failed to reconcile EvoMemory AutoSkills schedule", exc_info=True
)
def _pending_skill_proposals_message(
workspace_dir: str | Path | None = None,
) -> str | None:
"""Return a concise review reminder when autoskill proposals are waiting."""
try:
from .. import paths
from ..memory.autoskills.proposals import pending_skill_proposal_count
count = pending_skill_proposal_count(
paths.MEMORIES_DIR,
workspace_dir=workspace_dir or paths.WORKSPACE_ROOT,
)
except Exception:
return None
if not count:
return None
return (
f"EvoMemory has {count} autoskill proposal(s) ready for review. "
"Run /autoskills review."
)
async def _sync_background_agent_server_workspace(
config: Any,
*,
@@ -501,6 +536,11 @@ async def _sync_background_agent_server_workspace(
config,
workspace_dir=workspace_dir,
)
await asyncio.to_thread(
_reconcile_autoskill_schedule,
config,
workspace_dir=workspace_dir,
)
def _resolve_context_window(
+10
View File
@@ -718,6 +718,13 @@ def cmd_interactive(
# in scope — define the ``rich_ui`` adapter here rather than
# at the outer function level.
def _print_pending_skill_proposals_notice() -> None:
from .commands import _pending_skill_proposals_message
message = _pending_skill_proposals_message(state.get("workspace_dir"))
if message:
console.print(message, style="yellow")
async def _on_start_new_session() -> None:
"""NewCommand callback — rotate workspace (if not fixed),
issue a new thread id, reset session-scoped status fields,
@@ -742,6 +749,7 @@ def cmd_interactive(
f"[dim]Workspace:[/dim] [cyan]"
f"{_shorten_path(state['workspace_dir'])}[/cyan]\n"
)
_print_pending_skill_proposals_notice()
async def _on_handle_session_resume(
thread_id: str, workspace_dir: str | None
@@ -800,6 +808,7 @@ def cmd_interactive(
)
console.print()
await _render_history(thread_id)
_print_pending_skill_proposals_notice()
# Rich CLI collapses ``request_quit`` / ``force_quit`` into the
# same "break the prompt loop" effect — there's no equivalent
@@ -851,6 +860,7 @@ def cmd_interactive(
provider,
state["ui_backend"],
)
_print_pending_skill_proposals_notice()
# ---- Channel queue processing (bus → main thread) ----
+20 -7
View File
@@ -690,6 +690,7 @@ def run_textual_interactive(
self._background_tasks.add(refresh_task)
refresh_task.add_done_callback(self._background_tasks.discard)
self.append_system(f"New session: {self._conversation_tid}", style="green")
self._append_pending_skill_proposals_notice()
async def handle_session_resume(
self, thread_id: str, workspace_dir: str | None = None
@@ -710,10 +711,8 @@ def run_textual_interactive(
# WorkspaceMismatchError leaves the session pointing at the
# existing workspace. Other sync failures resume locally in
# the TUI while background workers may be unavailable.
from ..langgraph_dev.manager import (
WorkspaceMismatchError,
ensure_langgraph_dev,
)
from ..langgraph_dev.manager import WorkspaceMismatchError
from .commands import _sync_background_agent_server_workspace
from .widgets.workspace_sync_widget import WorkspaceSyncWidget
sync_widget = WorkspaceSyncWidget()
@@ -721,8 +720,7 @@ def run_textual_interactive(
await container.mount(sync_widget)
container.scroll_end(animate=False)
try:
await asyncio.to_thread(
ensure_langgraph_dev,
await _sync_background_agent_server_workspace(
config,
workspace_dir=workspace_dir,
)
@@ -768,6 +766,7 @@ def run_textual_interactive(
self._render_status()
self.append_system(f"Resumed session: {thread_id}", style="green")
await self._render_history(thread_id)
self._append_pending_skill_proposals_notice()
async def flush(self) -> None:
"""No-op for TUI, messages are already delivered incrementally."""
@@ -816,6 +815,7 @@ def run_textual_interactive(
# Show resume status
if self._resume_warning:
self._append_system(self._resume_warning, style="yellow")
self._append_pending_skill_proposals_notice()
elif self._resumed:
self._append_system(
f"Resumed session: {self._conversation_tid}",
@@ -823,9 +823,11 @@ def run_textual_interactive(
)
self.call_later(
lambda: asyncio.ensure_future(
self._render_history(self._conversation_tid)
self._render_history_then_pending_notice(self._conversation_tid)
)
)
else:
self._append_pending_skill_proposals_notice()
# Startup notifications
self.notify(
"EvoScientist is your research buddy.\n"
@@ -3092,6 +3094,17 @@ def run_textual_interactive(
)
)
async def _render_history_then_pending_notice(self, thread_id: str) -> None:
await self._render_history(thread_id)
self._append_pending_skill_proposals_notice()
def _append_pending_skill_proposals_notice(self) -> None:
from .commands import _pending_skill_proposals_message
message = _pending_skill_proposals_message(self._workspace_dir)
if message:
self._append_system(message, style="yellow")
def _render_status(self) -> None:
status = self.query_one("#status", Static)
width = (
@@ -1,8 +1,19 @@
from __future__ import annotations
from . import channel, general, mcp, model, model_fallback, schedule, session, skills
from . import (
autoskills,
channel,
general,
mcp,
model,
model_fallback,
schedule,
session,
skills,
)
__all__ = [
"autoskills",
"channel",
"general",
"mcp",
@@ -0,0 +1,435 @@
from __future__ import annotations
import asyncio
from enum import Enum
from typing import ClassVar
from rich.table import Table
from ..base import Command, CommandContext, SubCommand
from ..manager import manager
AUTOSKILLS_COMMAND = "/autoskills"
_PROPOSAL_STATUSES = {
"review": "pending",
"approved": "approved",
"rejected": "rejected",
}
class AutoSkillsCommand(Command):
"""Manage EvoMemory AutoSkills proposals."""
name = AUTOSKILLS_COMMAND
alias: ClassVar[list[str]] = ["/skills-review"]
description = "Review EvoMemory autoskill proposals"
subcommands: ClassVar[list[SubCommand]] = [
SubCommand("status", "Show AutoSkills config and proposals for review"),
SubCommand("help", "Show AutoSkills command examples"),
SubCommand("list", "List autoskill proposals, optionally filtered by status"),
SubCommand("review", "Review autoskill proposals awaiting a decision"),
SubCommand("approve", "Approve an autoskill proposal by id"),
SubCommand("reject", "Reject an autoskill proposal by id"),
SubCommand("run", "Run AutoSkills once now"),
SubCommand("on", "Enable periodic AutoSkills"),
SubCommand("off", "Disable periodic AutoSkills"),
SubCommand("mode", "Set review or auto approval mode"),
SubCommand("cadence", "Set nightly, weekly, or monthly cadence"),
SubCommand("time", "Set local run time as HH:MM"),
]
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
sub = args[0].lower() if args else "help"
rest = args[1:]
if sub in {"help", "-h", "--help", "?"}:
self._show_help(ctx)
elif sub in {"status", "show"}:
await self._status(ctx)
elif sub in {"list", "ls", "proposals"}:
await self._list_command(ctx, rest)
elif sub == "review":
await self._list(ctx, status="pending")
elif sub in {"approve", "accept"}:
await self._approve(ctx, self._first_arg(rest))
elif sub in {"reject", "deny", "decline"}:
await self._reject(ctx, self._first_arg(rest))
elif sub in {"run", "now"}:
await self._run(ctx)
elif sub in {"on", "enable"}:
await self._set_config(ctx, "memory_skill_synthesis_enabled", "true")
elif sub in {"off", "disable"}:
await self._set_config(ctx, "memory_skill_synthesis_enabled", "false")
elif sub == "mode":
await self._set_config(
ctx,
"memory_skill_synthesis_mode",
self._first_arg(rest),
)
elif sub in {"auto", "automatic"}:
await self._set_config(ctx, "memory_skill_synthesis_mode", "auto")
elif sub == "manual":
await self._set_config(ctx, "memory_skill_synthesis_mode", "review")
elif sub == "cadence":
await self._set_config(
ctx,
"memory_skill_synthesis_cadence",
self._first_arg(rest),
)
elif sub in {"nightly", "weekly", "monthly"}:
await self._set_config(ctx, "memory_skill_synthesis_cadence", sub)
elif sub == "time":
await self._set_config(
ctx,
"memory_skill_synthesis_time",
self._first_arg(rest),
)
else:
self._show_help(ctx, prefix=f"Unknown AutoSkills command: {sub}")
async def _status(self, ctx: CommandContext) -> None:
from ... import paths
from ...config import get_effective_config
from ...memory.autoskills.proposals import list_skill_proposals
from ...memory.autoskills.schedule import alist_autoskill_schedules
cfg = get_effective_config()
workspace_dir = self._workspace_dir(ctx)
pending = list_skill_proposals(
paths.MEMORIES_DIR,
status="pending",
workspace_dir=workspace_dir,
)
ctx.ui.append_system(
(
"AutoSkills: "
f"{'on' if cfg.memory_skill_synthesis_enabled else 'off'} | "
f"mode={cfg.memory_skill_synthesis_mode.value} | "
f"cadence={cfg.memory_skill_synthesis_cadence.value} | "
f"time={cfg.memory_skill_synthesis_time}"
),
style="dim",
)
ctx.ui.append_system(
f"AutoSkill proposal(s) ready for review: {len(pending)}",
style="yellow" if pending else "dim",
)
if pending:
ctx.ui.append_system(
(
f"Next: {AUTOSKILLS_COMMAND} review, then "
f"{AUTOSKILLS_COMMAND} approve <id> or "
f"{AUTOSKILLS_COMMAND} reject <id>."
),
style="dim",
)
elif cfg.memory_skill_synthesis_enabled:
ctx.ui.append_system(
f"Next: {AUTOSKILLS_COMMAND} run to search now, or "
f"{AUTOSKILLS_COMMAND} help for commands.",
style="dim",
)
else:
ctx.ui.append_system(
f"Next: {AUTOSKILLS_COMMAND} run to search once, or "
f"{AUTOSKILLS_COMMAND} on to enable scheduled runs.",
style="dim",
)
if cfg.memory_skill_synthesis_enabled:
try:
rows = await alist_autoskill_schedules(cfg, limit=1)
except Exception:
rows = []
if rows:
ctx.ui.append_system(
f"Background schedule id: {str(rows[0].get('cron_id', ''))[:8]}",
style="dim",
)
async def _list_command(self, ctx: CommandContext, args: list[str]) -> None:
if not args or args[0].lower() == "all":
await self._list(ctx)
return
status = _PROPOSAL_STATUSES.get(args[0].lower())
if status is None:
ctx.ui.append_system(
f"Usage: {AUTOSKILLS_COMMAND} list [review|approved|rejected|all]",
style="yellow",
)
return
await self._list(ctx, status=status)
async def _list(self, ctx: CommandContext, *, status: str | None = None) -> None:
from ... import paths
from ...memory.autoskills.proposals import list_skill_proposals
workspace_dir = self._workspace_dir(ctx)
proposals = list_skill_proposals(
paths.MEMORIES_DIR,
status=status,
workspace_dir=workspace_dir,
)
if not proposals:
if status:
label = self._status_label(status)
ctx.ui.append_system(
f"No autoskill proposals {label}.",
style="dim",
)
else:
ctx.ui.append_system("No autoskill proposals.", style="dim")
return
title = "EvoMemory AutoSkill Proposals"
if status:
title = (
f"EvoMemory AutoSkill Proposals {self._status_label(status).title()}"
)
table = Table(title=title, show_header=True)
table.add_column("ID", style="cyan")
table.add_column("Action", style="magenta")
table.add_column("AutoSkill", style="green")
table.add_column("Status", style="yellow")
table.add_column("Observations", justify="right")
table.add_column("Description", style="dim")
for proposal in proposals:
table.add_row(
proposal.proposal_id,
proposal.operation,
proposal.skill_name,
proposal.status,
str(len(proposal.source_observation_ids)),
proposal.description,
)
ctx.ui.mount_renderable(table)
ctx.ui.append_system(
f"Use {AUTOSKILLS_COMMAND} approve <id> or "
f"{AUTOSKILLS_COMMAND} reject <id>.",
style="dim",
)
async def _approve(self, ctx: CommandContext, proposal_id: str | None) -> None:
from ... import paths
from ...memory.autoskills.proposals import approve_skill_proposal
if not proposal_id:
ctx.ui.append_system(
f"Usage: {AUTOSKILLS_COMMAND} approve <id>",
style="yellow",
)
ctx.ui.append_system(
f"Run {AUTOSKILLS_COMMAND} review to copy a proposal ID.",
style="dim",
)
return
workspace_dir = self._workspace_dir(ctx)
result = await asyncio.to_thread(
approve_skill_proposal,
paths.MEMORIES_DIR,
proposal_id,
workspace_dir=workspace_dir,
)
if result.get("approved"):
verb = "Updated" if result.get("operation") == "update" else "Approved"
ctx.ui.append_system(
f"{verb} autoskill: {result['skill_name']} ({result['path']})",
style="green",
)
ctx.ui.append_system(
"Reload with /new to apply the new skill.", style="dim"
)
else:
ctx.ui.append_system(f"Approval failed: {result.get('error')}", style="red")
async def _reject(self, ctx: CommandContext, proposal_id: str | None) -> None:
from ... import paths
from ...memory.autoskills.proposals import reject_skill_proposal
if not proposal_id:
ctx.ui.append_system(
f"Usage: {AUTOSKILLS_COMMAND} reject <id>",
style="yellow",
)
ctx.ui.append_system(
f"Run {AUTOSKILLS_COMMAND} review to copy a proposal ID.",
style="dim",
)
return
workspace_dir = self._workspace_dir(ctx)
result = await asyncio.to_thread(
reject_skill_proposal,
paths.MEMORIES_DIR,
proposal_id,
workspace_dir=workspace_dir,
)
if result.get("rejected"):
ctx.ui.append_system(
f"Rejected proposal: {result['proposal_id']}",
style="green",
)
else:
ctx.ui.append_system(f"Reject failed: {result.get('error')}", style="red")
async def _run(self, ctx: CommandContext) -> None:
from ...config import get_effective_config
from ...memory.autoskills.schedule import arun_autoskill_now
workspace_dir = self._workspace_dir(ctx)
try:
result = await arun_autoskill_now(
get_effective_config(),
workspace_dir=workspace_dir,
)
except Exception as exc:
ctx.ui.append_system(f"Failed to start AutoSkills: {exc}", style="red")
return
ctx.ui.append_system(
f"Started AutoSkills run {result['run_id']}.",
style="green",
)
async def _set_config(
self,
ctx: CommandContext,
key: str,
value: str | None,
) -> None:
from ...config import get_effective_config, set_config_value
from ...memory.autoskills.schedule import reconcile_autoskill_schedule
workspace_dir = self._workspace_dir(ctx)
if not value:
cfg = get_effective_config()
current = self._display_value(getattr(cfg, key))
ctx.ui.append_system(
f"Current {self._config_label(key)}: {current}",
style="dim",
)
ctx.ui.append_system(
f"Usage: {self._config_usage(key)}",
style="yellow",
)
return
if not await asyncio.to_thread(set_config_value, key, value):
valid = self._config_values(key)
suffix = f" Valid values: {valid}." if valid else ""
ctx.ui.append_system(
f"Invalid value for {self._config_label(key)}: {value}.{suffix}",
style="red",
)
return
cfg = get_effective_config()
if ctx.config is not None and hasattr(ctx.config, key):
setattr(ctx.config, key, getattr(cfg, key))
await asyncio.to_thread(
reconcile_autoskill_schedule,
cfg,
workspace_dir=workspace_dir,
)
ctx.ui.append_system(
f"Updated {self._config_label(key)} = {self._display_value(getattr(cfg, key))}",
style="green",
)
@staticmethod
def _config_label(key: str) -> str:
labels = {
"memory_skill_synthesis_enabled": "AutoSkills",
"memory_skill_synthesis_mode": "AutoSkills mode",
"memory_skill_synthesis_cadence": "AutoSkills cadence",
"memory_skill_synthesis_time": "AutoSkills time",
}
return labels.get(key, key)
@staticmethod
def _display_value(value: object) -> object:
return getattr(value, "value", value)
@staticmethod
def _enum_values(enum_type: type[Enum], *, separator: str = ", ") -> str:
return separator.join(str(member.value) for member in enum_type)
@classmethod
def _config_usage(cls, key: str) -> str:
from ...config import MemorySkillSynthesisCadence, MemorySkillSynthesisMode
if key == "memory_skill_synthesis_mode":
values = cls._enum_values(MemorySkillSynthesisMode, separator="|")
return f"{AUTOSKILLS_COMMAND} mode {values}"
if key == "memory_skill_synthesis_cadence":
values = cls._enum_values(MemorySkillSynthesisCadence, separator="|")
return f"{AUTOSKILLS_COMMAND} cadence {values}"
if key == "memory_skill_synthesis_time":
return f"{AUTOSKILLS_COMMAND} time HH:MM"
return f"{AUTOSKILLS_COMMAND} <value>"
@classmethod
def _config_values(cls, key: str) -> str | None:
from ...config import MemorySkillSynthesisCadence, MemorySkillSynthesisMode
if key == "memory_skill_synthesis_mode":
return cls._enum_values(MemorySkillSynthesisMode)
if key == "memory_skill_synthesis_cadence":
return cls._enum_values(MemorySkillSynthesisCadence)
if key == "memory_skill_synthesis_time":
return "24-hour local time, for example 03:00"
return None
@staticmethod
def _status_label(status: str) -> str:
if status == "pending":
return "ready for review"
return status
@staticmethod
def _show_help(ctx: CommandContext, *, prefix: str | None = None) -> None:
if prefix:
ctx.ui.append_system(prefix, style="yellow")
ctx.ui.append_system(
(
f"Usage: {AUTOSKILLS_COMMAND} "
"[status|review|approve|reject|run|on|off|mode|cadence|time]"
),
style="bold",
)
table = Table(title="AutoSkills Commands", show_header=True)
table.add_column("Command", style="cyan")
table.add_column("Use when", style="dim")
rows = [
(AUTOSKILLS_COMMAND, "Show this command reference"),
(f"{AUTOSKILLS_COMMAND} status", "Show config and the next useful action"),
(f"{AUTOSKILLS_COMMAND} review", "Review proposals waiting for a decision"),
(f"{AUTOSKILLS_COMMAND} approve <id>", "Install a reviewed autoskill"),
(f"{AUTOSKILLS_COMMAND} reject <id>", "Dismiss a reviewed proposal"),
(f"{AUTOSKILLS_COMMAND} run", "Start a one-off background autoskill run"),
(f"{AUTOSKILLS_COMMAND} on|off", "Enable or disable scheduled runs"),
(f"{AUTOSKILLS_COMMAND} auto|manual", "Switch approval behavior"),
(
f"{AUTOSKILLS_COMMAND} nightly|weekly|monthly",
"Set the built-in schedule cadence",
),
(f"{AUTOSKILLS_COMMAND} time 03:00", "Set the local schedule time"),
(
f"{AUTOSKILLS_COMMAND} list [status]",
"List all proposals or filter by review, approved, or rejected",
),
]
for command, description in rows:
table.add_row(command, description)
ctx.ui.mount_renderable(table)
ctx.ui.append_system(
"Aliases: /skills-review, ls, proposals, accept, deny, enable, disable, now.",
style="dim",
)
@staticmethod
def _workspace_dir(ctx: CommandContext) -> str:
from ... import paths
return str(ctx.workspace_dir or paths.WORKSPACE_ROOT)
@staticmethod
def _first_arg(args: list[str]) -> str | None:
return args[0] if args else None
manager.register(AutoSkillsCommand())
+5 -17
View File
@@ -66,7 +66,7 @@ class ModelCommand(Command):
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
from ...EvoScientist import _ensure_config
from ...llm.models import list_models_by_provider
from ...llm.models import list_model_picker_entries
cfg = _ensure_config()
current_model = cfg.model
@@ -97,22 +97,10 @@ class ModelCommand(Command):
)
return
entries = list_models_by_provider()
# Ollama models are locally-installed — probe the daemon for the list
# the user has actually pulled. Gated on ollama_base_url being set
# (issue non-goal forbids implicit localhost detection).
ollama_base_url = getattr(cfg, "ollama_base_url", None)
if ollama_base_url:
from ...llm.ollama_discovery import discover_ollama_models
detected = await discover_ollama_models(ollama_base_url, timeout=1.5)
for detected_name in detected:
entries.append((detected_name, detected_name, "ollama"))
# Always append the sentinel so users can type a name even when
# the daemon is down or no models have been pulled yet. The widget
# swaps the sentinel name for the typed value before posting Picked.
entries.append(("Custom Ollama model...", "__custom_ollama__", "ollama"))
entries = await list_model_picker_entries(
getattr(cfg, "ollama_base_url", None),
include_custom_ollama=True,
)
result = await ctx.ui.wait_for_model_pick(
entries,
@@ -178,19 +178,13 @@ class ModelFallbackCommand(Command):
return None
from ...EvoScientist import _ensure_config
from ...llm.models import list_models_by_provider
from ...llm.models import list_model_picker_entries
cfg = _ensure_config()
entries = list_models_by_provider()
ollama_base_url = getattr(cfg, "ollama_base_url", None)
if ollama_base_url:
from ...llm.ollama_discovery import discover_ollama_models
detected = await discover_ollama_models(ollama_base_url, timeout=1.5)
for detected_name in detected:
entries.append((detected_name, detected_name, "ollama"))
entries.append(("Custom Ollama model...", "__custom_ollama__", "ollama"))
entries = await list_model_picker_entries(
getattr(cfg, "ollama_base_url", None),
include_custom_ollama=True,
)
result = await ctx.ui.wait_for_model_pick(
entries,
+4
View File
@@ -13,6 +13,8 @@ from .settings import (
MemoryControls,
MemoryObservationTarget,
MemoryObservationWriter,
MemorySkillSynthesisCadence,
MemorySkillSynthesisMode,
apply_config_to_env,
get_config_dir,
get_config_path,
@@ -30,6 +32,8 @@ __all__ = [
"MemoryControls",
"MemoryObservationTarget",
"MemoryObservationWriter",
"MemorySkillSynthesisCadence",
"MemorySkillSynthesisMode",
"apply_config_to_env",
# settings
"get_config_dir",
+116 -23
View File
@@ -11,8 +11,9 @@ import logging
import os
from dataclasses import asdict, dataclass, fields
from enum import StrEnum
from functools import lru_cache
from pathlib import Path
from typing import Any, Literal
from typing import Any, Literal, get_type_hints
import yaml
from dotenv import find_dotenv, load_dotenv
@@ -59,7 +60,43 @@ class MemoryObservationWriter(StrEnum):
)
class MemorySkillSynthesisMode(StrEnum):
"""Configured AutoSkills approval behavior."""
REVIEW = "review"
AUTO = "auto"
class MemorySkillSynthesisCadence(StrEnum):
"""Preset cadence for the built-in AutoSkills schedule."""
NIGHTLY = "nightly"
WEEKLY = "weekly"
MONTHLY = "monthly"
DEFAULT_MEMORY_OBSERVATION_WRITER = MemoryObservationWriter.ALL
DEFAULT_MEMORY_SKILL_SYNTHESIS_MODE = MemorySkillSynthesisMode.REVIEW
DEFAULT_MEMORY_SKILL_SYNTHESIS_CADENCE = MemorySkillSynthesisCadence.WEEKLY
DEFAULT_MEMORY_SKILL_SYNTHESIS_TIME = "03:00"
def _normalize_hhmm(value: Any) -> str | None:
parts = str(value).strip().split(":")
if len(parts) != 2:
return None
hour, minute = parts
if not (hour.isdecimal() and minute.isdecimal()):
return None
try:
hour_int = int(hour)
minute_int = int(minute)
except ValueError:
return None
if not (0 <= hour_int <= 23 and 0 <= minute_int <= 59):
return None
return f"{hour_int:02d}:{minute_int:02d}"
# =============================================================================
# Configuration paths
@@ -220,6 +257,16 @@ class EvoScientistConfig:
# Post-turn and post-subagent memory workers. Disable for no-background-memory
# controls while still allowing live agents to read configured memory.
memory_workers_enabled: bool = True
# Slow EvoMemory maintenance that periodically scans observation clusters
# and drafts reusable skills.
memory_skill_synthesis_enabled: bool = True
memory_skill_synthesis_mode: MemorySkillSynthesisMode = (
DEFAULT_MEMORY_SKILL_SYNTHESIS_MODE
)
memory_skill_synthesis_cadence: MemorySkillSynthesisCadence = (
DEFAULT_MEMORY_SKILL_SYNTHESIS_CADENCE
)
memory_skill_synthesis_time: str = DEFAULT_MEMORY_SKILL_SYNTHESIS_TIME
# Workspace Settings
default_mode: Literal["daemon", "run"] = "daemon"
@@ -411,18 +458,18 @@ class EvoScientistConfig:
if self.dangerous_mode:
self.auto_approve = True
try:
writer = MemoryObservationWriter(
str(self.memory_observation_writer).strip().lower()
)
except ValueError:
_normalize_str_enum_fields(self)
synthesis_time = _normalize_hhmm(self.memory_skill_synthesis_time)
if synthesis_time is None:
logging.getLogger(__name__).warning(
"Invalid memory_observation_writer %r; falling back to %s.",
self.memory_observation_writer,
DEFAULT_MEMORY_OBSERVATION_WRITER.value,
"Invalid memory_skill_synthesis_time %r; falling back to %s.",
self.memory_skill_synthesis_time,
DEFAULT_MEMORY_SKILL_SYNTHESIS_TIME,
)
writer = DEFAULT_MEMORY_OBSERVATION_WRITER
self.memory_observation_writer = writer
self.memory_skill_synthesis_time = DEFAULT_MEMORY_SKILL_SYNTHESIS_TIME
else:
self.memory_skill_synthesis_time = synthesis_time
@dataclass(frozen=True)
@@ -518,9 +565,7 @@ def reset_config() -> None:
def _config_to_dict(config: EvoScientistConfig) -> dict[str, Any]:
"""Return a plain serializable config dict."""
data = asdict(config)
data["memory_observation_writer"] = config.memory_observation_writer.value
return data
return {key: _plain_config_value(value) for key, value in asdict(config).items()}
# =============================================================================
@@ -542,6 +587,8 @@ def _coerce_value(value: Any, field_type: Any) -> Any:
ValueError: If the value cannot be coerced.
TypeError: If the value cannot be coerced.
"""
if _is_str_enum_type(field_type):
return field_type(str(value).strip().lower())
if field_type == "bool" or field_type is bool:
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "on")
@@ -553,6 +600,48 @@ def _coerce_value(value: Any, field_type: Any) -> Any:
return str(value)
def _plain_config_value(value: Any) -> Any:
"""Return the persisted/user-facing representation for a config value."""
return value.value if isinstance(value, StrEnum) else value
@lru_cache(maxsize=1)
def _config_field_types() -> dict[str, Any]:
"""Return resolved dataclass annotations for config fields."""
return get_type_hints(EvoScientistConfig)
def _config_field_type(key: str, fallback: Any) -> Any:
"""Return the resolved dataclass annotation for a config field."""
return _config_field_types().get(key, fallback)
def _is_str_enum_type(field_type: Any) -> bool:
return isinstance(field_type, type) and issubclass(field_type, StrEnum)
def _normalize_str_enum_fields(config: EvoScientistConfig) -> None:
"""Normalize all StrEnum config fields, falling back to field defaults."""
for field in fields(config):
field_type = _config_field_type(field.name, field.type)
if not _is_str_enum_type(field_type):
continue
raw_value = getattr(config, field.name)
try:
value = _coerce_value(raw_value, field_type)
except (ValueError, TypeError):
default = field.default
logging.getLogger(__name__).warning(
"Invalid %s %r; falling back to %s.",
field.name,
raw_value,
_plain_config_value(default),
)
value = default
setattr(config, field.name, value)
def get_config_value(key: str) -> Any:
"""Get a single configuration value.
@@ -564,9 +653,7 @@ def get_config_value(key: str) -> Any:
"""
config = load_config()
value = getattr(config, key, None)
if isinstance(value, MemoryObservationWriter):
return value.value
return value
return _plain_config_value(value)
def set_config_value(key: str, value: Any) -> bool:
@@ -587,7 +674,7 @@ def set_config_value(key: str, value: Any) -> bool:
# Type coercion based on field type
field_info = next(f for f in fields(EvoScientistConfig) if f.name == key)
field_type = field_info.type
field_type = _config_field_type(key, field_info.type)
# __post_init__ only clamps on load, so validate here too. Reject bool before coercion
# (_coerce_value(True, int) would turn it into 1 and slip past).
@@ -601,10 +688,9 @@ def set_config_value(key: str, value: Any) -> bool:
if key == "sandbox_execute_timeout" and value <= 0:
return False
if key == "memory_observation_writer":
try:
value = MemoryObservationWriter(str(value).strip().lower())
except ValueError:
if key == "memory_skill_synthesis_time":
value = _normalize_hhmm(value)
if value is None:
return False
setattr(config, key, value)
@@ -681,6 +767,10 @@ _ENV_MAPPINGS = {
"memory_observations_enabled": "EVOSCIENTIST_MEMORY_OBSERVATIONS_ENABLED",
"memory_observation_writer": "EVOSCIENTIST_MEMORY_OBSERVATION_WRITER",
"memory_workers_enabled": "EVOSCIENTIST_MEMORY_WORKERS_ENABLED",
"memory_skill_synthesis_enabled": "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_ENABLED",
"memory_skill_synthesis_mode": "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE",
"memory_skill_synthesis_cadence": "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE",
"memory_skill_synthesis_time": "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME",
}
@@ -715,7 +805,10 @@ def get_effective_config(
f for f in fields(EvoScientistConfig) if f.name == config_key
)
try:
data[config_key] = _coerce_value(env_value, field_info.type)
data[config_key] = _coerce_value(
env_value,
_config_field_type(config_key, field_info.type),
)
except (ValueError, TypeError):
pass
+12 -24
View File
@@ -17,39 +17,27 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from langgraph_sdk.schema import Cron, Run
from ..langgraph_dev.sdk import (
configured_langgraph_dev_url,
default_scheduler_timezone,
get_langgraph_sync_client,
messages_input,
)
SCHEDULER_GRAPH_ID = "scheduler"
SCHEDULED_RUN_KIND = "scheduled_task"
def _scheduler_url() -> str:
from ..EvoScientist import _ensure_config
cfg = _ensure_config()
port = int(getattr(cfg, "langgraph_dev_port", 6174))
return f"http://localhost:{port}"
return configured_langgraph_dev_url()
def _client():
from langgraph_sdk import get_sync_client
return get_sync_client(url=_scheduler_url(), headers={"x-auth-scheme": "langsmith"})
return get_langgraph_sync_client(url=_scheduler_url())
def _default_timezone() -> str | None:
from ..EvoScientist import _ensure_config
tz = getattr(_ensure_config(), "scheduler_default_timezone", "") or ""
if tz:
return tz
# Resolve the host's real IANA zone (e.g. "Asia/Shanghai") so absolute-time
# schedules fire in local time and track DST. Falls back to None (-> UTC in
# the cron backend) when the local zone can't be determined.
try:
from tzlocal import get_localzone_name
return get_localzone_name()
except Exception:
return None
return default_scheduler_timezone()
def is_available() -> bool:
@@ -68,7 +56,7 @@ def create_schedule(
return _client().crons.create(
assistant_id=SCHEDULER_GRAPH_ID,
schedule=schedule,
input={"messages": [{"role": "user", "content": prompt}]},
input=messages_input(prompt),
metadata={"run_kind": SCHEDULED_RUN_KIND, "name": name, "prompt": prompt},
timezone=timezone or _default_timezone(),
)
@@ -109,7 +97,7 @@ def run_now(prompt: str) -> Run:
return client.runs.create(
thread_id=str(thread["thread_id"]),
assistant_id=SCHEDULER_GRAPH_ID,
input={"messages": [{"role": "user", "content": prompt}]},
input=messages_input(prompt),
metadata={
"run_kind": SCHEDULED_RUN_KIND,
"name": "manual-run",
+7 -7
View File
@@ -23,6 +23,11 @@ from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, TypedDict
from ..langgraph_dev.sdk import (
configured_langgraph_dev_url,
langgraph_dev_headers,
)
if TYPE_CHECKING:
from langgraph_sdk.schema import Config, Input, Run, Thread
@@ -33,7 +38,6 @@ DEFAULT_BACKGROUND_RUN_TERMINAL_STATUSES = frozenset(
)
DEFAULT_BACKGROUND_RUN_POLL_INTERVAL_SECONDS = 1.0
DEFAULT_BACKGROUND_RUN_MAX_POLL_FAILURES = 3
DEFAULT_BACKGROUND_RUN_HEADERS = {"x-auth-scheme": "langsmith"}
_background_run_watcher_tasks: set[asyncio.Task[None]] = set()
@@ -163,15 +167,11 @@ class BackgroundRunWatcherConfig:
def default_background_run_url() -> str:
"""Return the configured local ``langgraph dev`` URL."""
from ..EvoScientist import _ensure_config
cfg = _ensure_config()
port = int(getattr(cfg, "langgraph_dev_port", 6174))
return f"http://localhost:{port}"
return configured_langgraph_dev_url()
def _headers(headers: Mapping[str, str] | None) -> dict[str, str]:
return dict(DEFAULT_BACKGROUND_RUN_HEADERS if headers is None else headers)
return langgraph_dev_headers(headers)
def _create_thread(
+2
View File
@@ -23,6 +23,7 @@ attribute), not the yaml-driven factory.
"""
from EvoScientist.memory.agents import (
build_autoskills_graph,
build_memory_worker_graph,
build_observation_linker_graph,
)
@@ -35,3 +36,4 @@ scheduler = build_async_subagent_graph("scheduler")
evomemory_subagent_worker = build_memory_worker_graph(MemorySourceType.SUBAGENT)
evomemory_turn_worker = build_memory_worker_graph(MemorySourceType.TURN)
evomemory_observation_linker = build_observation_linker_graph()
evomemory_autoskills = build_autoskills_graph()
+5 -8
View File
@@ -30,7 +30,7 @@ from starlette.responses import JSONResponse
from starlette.routing import Route
from EvoScientist.config import get_effective_config
from EvoScientist.llm.models import list_models_by_provider
from EvoScientist.llm.models import list_model_picker_entries
async def get_models(_request: Request) -> JSONResponse:
@@ -62,14 +62,11 @@ async def get_models(_request: Request) -> JSONResponse:
cfg = await asyncio.to_thread(get_effective_config)
entries = [
{"name": name, "model_id": model_id, "provider": provider}
for name, model_id, provider in list_models_by_provider()
for name, model_id, provider in await list_model_picker_entries(
getattr(cfg, "ollama_base_url", None),
include_custom_ollama=False,
)
]
ollama_base_url = getattr(cfg, "ollama_base_url", None)
if ollama_base_url:
from EvoScientist.llm.ollama_discovery import discover_ollama_models
for name in await discover_ollama_models(ollama_base_url, timeout=1.5):
entries.append({"name": name, "model_id": name, "provider": "ollama"})
return JSONResponse(
{
"entries": entries,
+2 -1
View File
@@ -7,7 +7,8 @@
"scheduler": "EvoScientist.langgraph_dev.graphs:scheduler",
"evomemory-subagent-worker": "EvoScientist.langgraph_dev.graphs:evomemory_subagent_worker",
"evomemory-turn-worker": "EvoScientist.langgraph_dev.graphs:evomemory_turn_worker",
"evomemory-observation-linker": "EvoScientist.langgraph_dev.graphs:evomemory_observation_linker"
"evomemory-observation-linker": "EvoScientist.langgraph_dev.graphs:evomemory_observation_linker",
"evomemory-autoskills": "EvoScientist.langgraph_dev.graphs:evomemory_autoskills"
},
"checkpointer": {
"backend": "custom",
+2
View File
@@ -94,6 +94,8 @@ def needs_langgraph_dev(config: EvoScientistConfig) -> bool:
return True
if config.enable_scheduler:
return True
if config.memory_skill_synthesis_enabled:
return True
memory_controls = MemoryControls.from_config(config)
return memory_controls.worker_needed(
MemoryObservationTarget.TURN_WORKER
+66
View File
@@ -0,0 +1,66 @@
"""Shared LangGraph SDK plumbing for the local langgraph-dev server."""
from __future__ import annotations
from collections.abc import Mapping
DEFAULT_LANGGRAPH_DEV_PORT = 6174
LANGGRAPH_DEV_AUTH_HEADERS = {"x-auth-scheme": "langsmith"}
def langgraph_dev_url(config: object | None = None, *, port: int | None = None) -> str:
"""Return the local langgraph-dev base URL for a config or explicit port."""
selected_port = (
int(port)
if port is not None
else int(getattr(config, "langgraph_dev_port", DEFAULT_LANGGRAPH_DEV_PORT))
)
return f"http://localhost:{selected_port}"
def configured_langgraph_dev_url() -> str:
"""Return the local langgraph-dev URL from the effective application config."""
from ..EvoScientist import _ensure_config
return langgraph_dev_url(_ensure_config())
def langgraph_dev_headers(headers: Mapping[str, str] | None = None) -> dict[str, str]:
"""Return SDK headers, defaulting to the local langgraph-dev auth scheme."""
return dict(LANGGRAPH_DEV_AUTH_HEADERS if headers is None else headers)
def get_langgraph_sync_client(*, url: str, headers: Mapping[str, str] | None = None):
"""Build a sync LangGraph SDK client with EvoScientist's default headers."""
from langgraph_sdk import get_sync_client
return get_sync_client(url=url, headers=langgraph_dev_headers(headers))
def get_langgraph_async_client(*, url: str, headers: Mapping[str, str] | None = None):
"""Build an async LangGraph SDK client with EvoScientist's default headers."""
from langgraph_sdk import get_client
return get_client(url=url, headers=langgraph_dev_headers(headers))
def default_scheduler_timezone(config: object | None = None) -> str | None:
"""Return configured scheduler timezone, falling back to the host timezone."""
if config is None:
from ..EvoScientist import _ensure_config
config = _ensure_config()
timezone = str(getattr(config, "scheduler_default_timezone", "") or "")
if timezone:
return timezone
try:
from tzlocal import get_localzone_name
return get_localzone_name()
except Exception:
return None
def messages_input(content: str) -> dict[str, list[dict[str, str]]]:
"""Return the standard LangGraph chat input shape for one user message."""
return {"messages": [{"role": "user", "content": content}]}
+20
View File
@@ -592,6 +592,26 @@ def list_models_by_provider() -> list[tuple[str, str, str]]:
return result
async def list_model_picker_entries(
ollama_base_url: str | None,
*,
include_custom_ollama: bool,
) -> list[tuple[str, str, str]]:
"""Return model picker entries, optionally including local Ollama models."""
entries = list_models_by_provider()
if ollama_base_url:
from .ollama_discovery import discover_ollama_models
for detected_name in await discover_ollama_models(
ollama_base_url,
timeout=1.5,
):
entries.append((detected_name, detected_name, "ollama"))
if include_custom_ollama:
entries.append(("Custom Ollama model...", "__custom_ollama__", "ollama"))
return entries
def get_model_info(model: str) -> tuple[str, str] | None:
"""Get the (model_id, provider) tuple for a short name.
+2
View File
@@ -1,11 +1,13 @@
"""Background memory agent implementations."""
from .autoskills import build_autoskills_graph
from .memory_worker import build_memory_worker_graph
from .observation_linker import (
build_observation_linker_graph,
)
__all__ = [
"build_autoskills_graph",
"build_memory_worker_graph",
"build_observation_linker_graph",
]
+113
View File
@@ -0,0 +1,113 @@
"""Shared construction helpers for background EvoMemory agents."""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from deepagents.backends.protocol import BackendProtocol
from langchain.agents.middleware.types import AgentMiddleware
from langchain_core.tools import BaseTool
from langgraph.graph.state import CompiledStateGraph
from pydantic import BaseModel
from ... import paths as _paths
MEMORY_AGENT_RECURSION_LIMIT = 100
MEMORY_MAINTENANCE_EXCLUDED_TOOLS = frozenset(
{
"edit_file",
"execute",
"task",
"write_file",
"write_todos",
}
)
@dataclass(frozen=True, slots=True)
class MemoryAgentPaths:
memory_dir: Path
workspace_dir: Path
def resolve_memory_agent_paths(
*,
memory_dir: str | Path | None = None,
workspace_dir: str | Path | None = None,
) -> MemoryAgentPaths:
"""Resolve the default workspace and memory roots for memory graphs."""
resolved_memory_dir = Path(
_paths.MEMORIES_DIR if memory_dir is None else memory_dir
).expanduser()
resolved_workspace_dir = Path(
_paths.WORKSPACE_ROOT if workspace_dir is None else workspace_dir
).expanduser()
return MemoryAgentPaths(
memory_dir=resolved_memory_dir,
workspace_dir=resolved_workspace_dir,
)
def memory_agent_middleware(
*extra_middleware: AgentMiddleware,
excluded_tools: Iterable[str] = MEMORY_MAINTENANCE_EXCLUDED_TOOLS,
) -> list[AgentMiddleware]:
"""Compose the standard middleware stack for unattended memory agents."""
from deepagents.middleware._tool_exclusion import _ToolExclusionMiddleware
from ...middleware.tool_error_handler import ToolErrorHandlerMiddleware
middleware: list[AgentMiddleware] = [
ToolErrorHandlerMiddleware(),
*extra_middleware,
]
excluded = frozenset(excluded_tools)
if excluded:
middleware.append(_ToolExclusionMiddleware(excluded=excluded))
return middleware
def build_memory_agent_graph(
*,
name: str,
system_prompt: str,
memory_dir: str | Path,
workspace_dir: str | Path,
tools: Sequence[BaseTool],
middleware: Sequence[AgentMiddleware],
recursion_limit: int = MEMORY_AGENT_RECURSION_LIMIT,
response_format: type[BaseModel] | None = None,
skills: list[str] | None = None,
backend: BackendProtocol | None = None,
) -> CompiledStateGraph:
"""Build a background memory graph with the shared model/backend wiring."""
from deepagents import create_deep_agent
from ...backends import build_memory_agent_backend
from ...EvoScientist import _ensure_auxiliary_chat_model
kwargs: dict[str, Any] = {}
if response_format is not None:
kwargs["response_format"] = response_format
if backend is None:
backend = build_memory_agent_backend(
workspace_dir=workspace_dir,
memory_dir=memory_dir,
)
agent = create_deep_agent(
name=name,
model=_ensure_auxiliary_chat_model(),
system_prompt=system_prompt,
tools=list(tools),
backend=backend,
middleware=list(middleware),
subagents=[],
skills=skills,
**kwargs,
)
return agent.with_config({"recursion_limit": recursion_limit})
+128
View File
@@ -0,0 +1,128 @@
"""Slow background agent for proposing AutoSkills from EvoMemory."""
from __future__ import annotations
from pathlib import Path
from langchain_core.tools import BaseTool
from langgraph.graph.state import CompiledStateGraph
from ...backends import build_autoskill_agent_backend
from ...config import get_effective_config
from ..autoskills.proposals import autoskill_proposals_dir
from ..autoskills.tools import (
create_inspect_autoskill_candidates_tool,
create_submit_autoskill_proposal_tool,
)
from ..project import resolve_project_id
from ._factory import (
build_memory_agent_graph,
memory_agent_middleware,
resolve_memory_agent_paths,
)
_AUTOSKILLS_EXCLUDED_TOOLS = frozenset({"task", "write_todos"})
def _autoskills_system_prompt() -> str:
return (
"You synthesize reusable skills from EvoMemory observation clusters.\n\n"
"This is slow, conservative background maintenance. Always call "
"`inspect_autoskill_candidates` first. Consider only candidates that "
"are not already processed and do not already have a pending proposal. "
"Propose a skill only when the cluster shows a repeated, procedural "
"pattern that would materially improve future agent work.\n\n"
"The inspection result also lists installed workspace/global skills "
"eligible for updates. If a candidate clearly improves, corrects, or "
"adds caveats to an existing skill, propose an update instead of a new "
"skill. Do not update built-in/system skills; only update skills "
"returned in `installed_skills`. For an update, read the existing "
"`/skills/<skill>/SKILL.md` first and preserve useful existing "
"references or scripts unless the observations justify removing them.\n\n"
"Candidate relations are context, not automatic approval or rejection "
"rules. Use `complements` to understand supporting observations, "
"`contradicts` to capture caveats or conditions where a practice fails, "
"and `supersedes` to prefer newer guidance over older guidance. If the "
"relations reveal that no coherent reusable procedure exists, do not "
"propose a skill.\n\n"
"Use the installed `skill-creator` skill for skill design guidance. "
"Read its SKILL.md before drafting a proposal. Choose a concise, "
"lowercase kebab-case skill name; this name is the proposal id. For "
"updates, use the exact existing skill name.\n\n"
"Create the proposal as an actual skill folder under "
"`/autoskill-proposals/<skill-name>/` using `write_file` and "
"`edit_file`. The folder must contain `SKILL.md` with valid YAML "
"frontmatter whose `name` matches `<skill-name>` and whose "
"`description` states when future agents should use it. Add bundled "
"references or scripts only when they remove real complexity. Keep the "
"skill concise and operational. Update proposals overlay the existing "
"workspace/global skill: proposal files replace files with the same "
"relative path, and omitted installed files are preserved.\n\n"
"Use `execute` for lightweight validation when useful. Shell commands "
"run from the autoskill proposal root; keep generated files and logs "
"under `/autoskill-proposals/`. Do not shell into `/skills` or "
"`/memories`; read those through file tools instead.\n\n"
"Do not create a skill for one-off project facts, ordinary summaries, "
"raw logs, weakly related observations, or clusters dominated by "
"semantic facts without a reusable procedure. Do not manually edit "
"`/skills` or `/memories`.\n\n"
"When the folder is ready, call `submit_autoskill_proposal` with the "
"exact skill_name, cluster_hash, source observation IDs, rationale, "
'and `operation`. Use `operation="create"` for a new skill and '
'`operation="update"` plus `target_skill_name=<skill-name>` for an '
"existing skill update. If it reports validation errors, edit the "
"proposal folder and submit again."
)
def _autoskills_tools(
*,
memory_dir: str | Path,
workspace_dir: str | Path,
) -> list[BaseTool]:
project_id = resolve_project_id(workspace_dir)
return [
create_inspect_autoskill_candidates_tool(
memory_dir=memory_dir,
project_id=project_id,
workspace_dir=workspace_dir,
),
create_submit_autoskill_proposal_tool(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
project_id=project_id,
),
]
def build_autoskills_graph(
*,
memory_dir: str | Path | None = None,
workspace_dir: str | Path | None = None,
) -> CompiledStateGraph:
"""Build the registered LangGraph AutoSkills agent."""
cfg = get_effective_config()
agent_paths = resolve_memory_agent_paths(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
)
proposals_dir = autoskill_proposals_dir(agent_paths.memory_dir)
return build_memory_agent_graph(
name="evomemory-autoskills",
system_prompt=_autoskills_system_prompt(),
tools=_autoskills_tools(
memory_dir=agent_paths.memory_dir,
workspace_dir=agent_paths.workspace_dir,
),
memory_dir=agent_paths.memory_dir,
workspace_dir=agent_paths.workspace_dir,
middleware=memory_agent_middleware(
excluded_tools=_AUTOSKILLS_EXCLUDED_TOOLS,
),
skills=["/skills/"],
backend=build_autoskill_agent_backend(
memory_dir=agent_paths.memory_dir,
proposals_dir=proposals_dir,
sandbox_timeout=cfg.sandbox_execute_timeout,
),
)
+26 -35
View File
@@ -18,7 +18,6 @@ from langgraph.graph.state import CompiledStateGraph
from langgraph.runtime import Runtime
from pydantic import BaseModel, Field
from ... import paths as _paths
from ...config import (
MemoryControls,
MemoryObservationTarget,
@@ -26,11 +25,17 @@ from ...config import (
get_effective_config,
)
from ..types import MemorySourceType
from ._factory import (
build_memory_agent_graph,
memory_agent_middleware,
resolve_memory_agent_paths,
)
logger = logging.getLogger(__name__)
MEMORY_WORKER_RECURSION_LIMIT = 100
_MEMORY_WORKER_EXCLUDED_TOOLS = frozenset({"execute", "task", "write_todos"})
_MEMORY_WORKER_EXCLUDED_TOOLS = frozenset(
{"execute", "task", "write_file", "write_todos"}
)
def _memory_worker_observation_target(
@@ -423,10 +428,7 @@ def _memory_worker_middleware(
enable_observation_memory: bool = True,
):
"""Build middleware for memory workers, excluding task execution tools."""
from deepagents.middleware._tool_exclusion import _ToolExclusionMiddleware
from ...middleware.memory import create_memory_middleware
from ...middleware.tool_error_handler import ToolErrorHandlerMiddleware
memory_controls = MemoryControls(
profile_enabled=enable_profile_memory,
@@ -437,8 +439,7 @@ def _memory_worker_middleware(
enable_observation_tool = memory_controls.observation_tool_enabled(
_memory_worker_observation_target(source_type)
)
return [
ToolErrorHandlerMiddleware(),
return memory_agent_middleware(
create_memory_middleware(
str(memory_dir),
workspace_dir=workspace_dir,
@@ -448,10 +449,8 @@ def _memory_worker_middleware(
enable_observation_memory=enable_observation_memory,
enable_observation_tool=enable_observation_tool,
),
_ToolExclusionMiddleware(
excluded=_MEMORY_WORKER_EXCLUDED_TOOLS,
),
]
excluded_tools=_MEMORY_WORKER_EXCLUDED_TOOLS,
)
def _build_memory_worker_agent(
@@ -467,22 +466,14 @@ def _build_memory_worker_agent(
middleware: list[AgentMiddleware] | None = None,
) -> CompiledStateGraph:
"""Create a background memory worker agent for one lifecycle hook."""
from deepagents import create_deep_agent
from ...backends import build_memory_worker_backend
from ...backends import build_memory_agent_backend
from ...EvoScientist import _ensure_auxiliary_chat_model
agent = create_deep_agent(
return build_memory_agent_graph(
name=_memory_worker_agent_name(source_type),
# Memory workers are background helper agents; use the auxiliary model
# and fall back to the main model when auxiliary_* is unset.
model=_ensure_auxiliary_chat_model(),
system_prompt=system_prompt,
tools=[],
backend=build_memory_agent_backend(
workspace_dir=workspace_dir,
memory_dir=memory_dir,
),
memory_dir=memory_dir,
workspace_dir=workspace_dir,
middleware=[
*_memory_worker_middleware(
memory_dir=memory_dir,
@@ -494,10 +485,12 @@ def _build_memory_worker_agent(
),
*(middleware or []),
],
subagents=[],
response_format=response_format,
backend=build_memory_worker_backend(
workspace_dir=workspace_dir,
memory_dir=memory_dir,
),
)
return agent.with_config({"recursion_limit": MEMORY_WORKER_RECURSION_LIMIT})
class _SubagentSummaryWriterMiddleware(AgentMiddleware):
@@ -588,17 +581,15 @@ def build_memory_worker_graph(
_memory_worker_observation_target(source_type)
)
worker_memory_dir = Path(
_paths.MEMORIES_DIR if memory_dir is None else memory_dir
).expanduser()
worker_workspace_dir = Path(
_paths.WORKSPACE_ROOT if workspace_dir is None else workspace_dir
).expanduser()
agent_paths = resolve_memory_agent_paths(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
)
middleware: list[AgentMiddleware] = []
response_format: type[BaseModel] | None = None
if source_type == MemorySourceType.SUBAGENT:
middleware.append(
_SubagentSummaryWriterMiddleware(memory_dir=worker_memory_dir)
_SubagentSummaryWriterMiddleware(memory_dir=agent_paths.memory_dir)
)
response_format = SubagentMemoryDecision
return _build_memory_worker_agent(
@@ -609,8 +600,8 @@ def build_memory_worker_graph(
enable_observation_tool=enable_observation_tool,
),
response_format=response_format,
memory_dir=worker_memory_dir,
workspace_dir=worker_workspace_dir,
memory_dir=agent_paths.memory_dir,
workspace_dir=agent_paths.workspace_dir,
enable_profile_memory=memory_controls.profile_enabled,
enable_observation_memory=memory_controls.observations_enabled,
observation_writer=memory_controls.observation_writer,
@@ -5,31 +5,23 @@ from __future__ import annotations
import logging
from pathlib import Path
from langchain.agents.middleware.types import AgentMiddleware
from langchain_core.tools import BaseTool
from langgraph.graph.state import CompiledStateGraph
from ... import paths as _paths
from ..observations import (
create_link_observations_tool,
create_read_memory_tool,
create_search_observations_tool,
)
from ..project import resolve_project_id
from ._factory import (
build_memory_agent_graph,
memory_agent_middleware,
resolve_memory_agent_paths,
)
logger = logging.getLogger(__name__)
OBSERVATION_LINKER_RECURSION_LIMIT = 100
_OBSERVATION_LINKER_EXCLUDED_TOOLS = frozenset(
{
"edit_file",
"execute",
"task",
"write_file",
"write_todos",
}
)
def _observation_linker_system_prompt() -> str:
return (
@@ -79,40 +71,19 @@ def build_observation_linker_graph(
workspace_dir: str | Path | None = None,
) -> CompiledStateGraph:
"""Build the registered LangGraph observation linker."""
from deepagents.middleware._tool_exclusion import _ToolExclusionMiddleware
from ...middleware.tool_error_handler import ToolErrorHandlerMiddleware
worker_memory_dir = Path(
_paths.MEMORIES_DIR if memory_dir is None else memory_dir
).expanduser()
worker_workspace_dir = Path(
_paths.WORKSPACE_ROOT if workspace_dir is None else workspace_dir
).expanduser()
middleware: list[AgentMiddleware] = [
ToolErrorHandlerMiddleware(),
_ToolExclusionMiddleware(excluded=_OBSERVATION_LINKER_EXCLUDED_TOOLS),
]
tools = _observation_linker_tools(
memory_dir=worker_memory_dir,
workspace_dir=worker_workspace_dir,
agent_paths = resolve_memory_agent_paths(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
)
from deepagents import create_deep_agent
from ...backends import build_memory_agent_backend
from ...EvoScientist import _ensure_auxiliary_chat_model
agent = create_deep_agent(
tools = _observation_linker_tools(
memory_dir=agent_paths.memory_dir,
workspace_dir=agent_paths.workspace_dir,
)
return build_memory_agent_graph(
name="evomemory-observation-linker",
model=_ensure_auxiliary_chat_model(),
system_prompt=_observation_linker_system_prompt(),
tools=tools,
backend=build_memory_agent_backend(
workspace_dir=worker_workspace_dir,
memory_dir=worker_memory_dir,
),
middleware=middleware,
subagents=[],
memory_dir=agent_paths.memory_dir,
workspace_dir=agent_paths.workspace_dir,
middleware=memory_agent_middleware(),
)
return agent.with_config({"recursion_limit": OBSERVATION_LINKER_RECURSION_LIMIT})
@@ -0,0 +1 @@
"""EvoMemory AutoSkills implementation modules."""
@@ -0,0 +1,221 @@
"""Observation graph candidate extraction for AutoSkills."""
from __future__ import annotations
import hashlib
import json
from collections import Counter, deque
from pathlib import Path
from typing import TypedDict
from ..observations import list_observation_documents
from ..types import (
MemoryScope,
MemoryType,
ObservationRelation,
ObservationSearchDocument,
)
from .proposals import cluster_hashes_by_status, processed_cluster_hashes
MIN_CLUSTER_SIZE = 3
MIN_PROCEDURAL_OBSERVATIONS = 2
class AutoskillCandidateObservation(TypedDict):
id: str
memory_type: MemoryType
scope: MemoryScope
summary: str
path: str
class AutoskillCandidateRelation(TypedDict):
source: str
target: str
relation: ObservationRelation
reason: str
class AutoskillCandidate(TypedDict):
cluster_hash: str
observation_ids: list[str]
observation_count: int
procedural_count: int
semantic_count: int
episodic_count: int
observations: list[AutoskillCandidateObservation]
relations: list[AutoskillCandidateRelation]
existing_pending_proposal: bool
already_processed: bool
def _stable_json(value: object) -> str:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
def _short_hash(value: object, *, n: int = 16) -> str:
return hashlib.sha256(_stable_json(value).encode("utf-8")).hexdigest()[:n]
def _ordered_pair(left: str, right: str) -> tuple[str, str]:
return (left, right) if left <= right else (right, left)
def _graph_edges(
documents: list[ObservationSearchDocument],
) -> tuple[set[tuple[str, str]], list[AutoskillCandidateRelation]]:
graph_edges: set[tuple[str, str]] = set()
relation_rows: list[AutoskillCandidateRelation] = []
document_ids = {document.observation_id for document in documents}
for document in documents:
for related in document.related_observations:
target = str(related["observation_id"])
if target not in document_ids:
continue
relation = related.get("relation", ObservationRelation.COMPLEMENTS)
relation_value = (
relation.value
if isinstance(relation, ObservationRelation)
else str(relation)
)
try:
normalized_relation = ObservationRelation(relation_value)
except ValueError:
continue
relation_value = normalized_relation.value
source = document.observation_id
dest = target
graph_edges.add(_ordered_pair(source, dest))
# Cluster connectivity is undirected, but supersedes is meaningful
# only in its original source-to-target direction.
if relation_value != ObservationRelation.SUPERSEDES.value:
source, dest = _ordered_pair(source, dest)
row: AutoskillCandidateRelation = {
"source": source,
"target": dest,
"relation": normalized_relation,
"reason": str(related.get("reason", "")),
}
relation_rows.append(row)
return graph_edges, relation_rows
def _dedupe_relation_rows(
rows: list[AutoskillCandidateRelation],
) -> list[AutoskillCandidateRelation]:
by_key: dict[tuple[str, str, str, str], AutoskillCandidateRelation] = {}
for row in rows:
key = (
row["source"],
row["target"],
row["relation"].value,
row["reason"],
)
by_key[key] = row
return [by_key[key] for key in sorted(by_key)]
def _components(
document_ids: set[str],
edges: set[tuple[str, str]],
) -> list[set[str]]:
adjacency: dict[str, set[str]] = {
observation_id: set() for observation_id in document_ids
}
for source, target in edges:
adjacency.setdefault(source, set()).add(target)
adjacency.setdefault(target, set()).add(source)
seen: set[str] = set()
components: list[set[str]] = []
for observation_id in sorted(document_ids):
if observation_id in seen:
continue
queue = deque([observation_id])
seen.add(observation_id)
component: set[str] = set()
while queue:
current = queue.popleft()
component.add(current)
for neighbor in sorted(adjacency.get(current, ())):
if neighbor not in seen:
seen.add(neighbor)
queue.append(neighbor)
components.append(component)
return components
def autoskill_candidates(
*,
memory_dir: str | Path,
project_id: str,
workspace_dir: str | Path | None = None,
) -> list[AutoskillCandidate]:
"""Return observation graph components worth showing to the AutoSkills agent."""
documents = list_observation_documents(memory_dir=memory_dir, project_id=project_id)
documents_by_id = {document.observation_id: document for document in documents}
edges, relation_rows = _graph_edges(documents)
relation_rows = _dedupe_relation_rows(relation_rows)
proposed_hashes = cluster_hashes_by_status(
memory_dir,
workspace_dir=workspace_dir,
)
processed_hashes = processed_cluster_hashes(memory_dir)
candidates: list[AutoskillCandidate] = []
for component in _components(set(documents_by_id), edges):
component_docs = [
documents_by_id[observation_id] for observation_id in sorted(component)
]
memory_type_counts = Counter(
document.memory_type for document in component_docs
)
procedural_count = memory_type_counts[MemoryType.PROCEDURAL]
if len(component_docs) < MIN_CLUSTER_SIZE:
continue
if procedural_count < MIN_PROCEDURAL_OBSERVATIONS:
continue
component_relations = [
row
for row in relation_rows
if row["source"] in component and row["target"] in component
]
observation_rows: list[AutoskillCandidateObservation] = [
{
"id": document.observation_id,
"memory_type": document.memory_type,
"scope": document.scope,
"summary": document.summary,
"path": document.path,
}
for document in component_docs
]
observation_ids = [row["id"] for row in observation_rows]
cluster_hash = _short_hash({"observation_ids": observation_ids})
candidates.append(
{
"cluster_hash": cluster_hash,
"observation_ids": observation_ids,
"observation_count": len(observation_rows),
"procedural_count": procedural_count,
"semantic_count": memory_type_counts[MemoryType.SEMANTIC],
"episodic_count": memory_type_counts[MemoryType.EPISODIC],
"observations": observation_rows,
"relations": component_relations,
"existing_pending_proposal": cluster_hash in proposed_hashes["pending"],
"already_processed": (
cluster_hash in proposed_hashes["approved"]
or cluster_hash in proposed_hashes["rejected"]
or cluster_hash in processed_hashes
),
}
)
return candidates
+705
View File
@@ -0,0 +1,705 @@
"""AutoSkills proposal validation and review lifecycle."""
from __future__ import annotations
import json
import re
import shutil
from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import yaml
from ... import paths
AUTOSKILL_PROPOSALS_DIR = "autoskills/proposals"
AUTOSKILL_PROCESSED_DIR = "autoskills/processed"
_SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
_SKILL_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\s*\n", re.DOTALL)
_ALLOWED_SKILL_FRONTMATTER = {
"allowed-tools",
"compatibility",
"description",
"license",
"metadata",
"name",
}
@dataclass(frozen=True)
class SkillProposal:
proposal_id: str
skill_name: str
description: str
status: str
operation: str
path: Path
created_at: str
updated_at: str
cluster_hash: str
source_observation_ids: tuple[str, ...]
target_skill_name: str | None = None
workspace_dir: str | None = None
project_id: str | None = None
approved_skill_path: str | None = None
_PROPOSAL_OPERATIONS = frozenset({"create", "update"})
def _now() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def _proposal_root(memory_dir: str | Path) -> Path:
return Path(memory_dir).expanduser() / AUTOSKILL_PROPOSALS_DIR
def _processed_root(memory_dir: str | Path) -> Path:
return Path(memory_dir).expanduser() / AUTOSKILL_PROCESSED_DIR
def autoskill_proposals_dir(memory_dir: str | Path) -> Path:
"""Return the real directory exposed as `/autoskill-proposals/` to agents."""
return _proposal_root(memory_dir)
def sanitize_skill_name(name: str) -> str | None:
"""Return a valid skill name or None when no valid name remains."""
candidate = re.sub(r"[^a-z0-9-]+", "-", name.strip().lower()).strip("-")
while "--" in candidate:
candidate = candidate.replace("--", "-")
if not candidate:
return None
candidate = candidate[:64].strip("-")
if _SKILL_NAME_RE.fullmatch(candidate) and "--" not in candidate:
return candidate
return None
def proposal_virtual_path(skill_name: str) -> str:
return f"/autoskill-proposals/{skill_name}"
def _read_manifest(path: Path) -> dict[str, Any] | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
def _write_manifest(path: Path, payload: dict[str, Any]) -> None:
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def _manifest_timestamp(manifest: dict[str, Any], key: str) -> str | None:
value = manifest.get(key)
if isinstance(value, str) and value.strip():
return value
return None
def _created_at_for_manifest(existing: dict[str, Any] | None) -> str:
if existing is None:
return _now()
return _manifest_timestamp(existing, "created_at") or _now()
def _normalize_operation(value: object) -> str | None:
text = str(value or "create").strip().lower()
return text if text in _PROPOSAL_OPERATIONS else None
def _normalize_workspace_dir(workspace_dir: str | Path | None) -> str | None:
if workspace_dir is None:
return None
text = str(workspace_dir).strip()
if not text:
return None
return str(Path(text).expanduser().resolve())
def _proposal_from_manifest(
path: Path, manifest: dict[str, Any]
) -> SkillProposal | None:
created_at = _manifest_timestamp(manifest, "created_at")
updated_at = _manifest_timestamp(manifest, "updated_at")
if created_at is None or updated_at is None:
return None
try:
source_ids = tuple(str(item) for item in manifest["source_observation_ids"])
return SkillProposal(
proposal_id=str(manifest["proposal_id"]),
skill_name=str(manifest["skill_name"]),
description=str(manifest["description"]),
status=str(manifest["status"]),
operation=_normalize_operation(manifest.get("operation")) or "create",
path=path,
created_at=created_at,
updated_at=updated_at,
cluster_hash=str(manifest["cluster_hash"]),
source_observation_ids=source_ids,
target_skill_name=(
str(manifest["target_skill_name"])
if manifest.get("target_skill_name")
else None
),
workspace_dir=(
str(manifest["workspace_dir"])
if manifest.get("workspace_dir")
else None
),
project_id=str(manifest["project_id"])
if manifest.get("project_id")
else None,
approved_skill_path=(
str(manifest["approved_skill_path"])
if manifest.get("approved_skill_path")
else None
),
)
except (KeyError, TypeError, ValueError):
return None
def list_skill_proposals(
memory_dir: str | Path,
*,
status: str | None = None,
workspace_dir: str | Path | None = None,
) -> list[SkillProposal]:
"""List autoskill proposals recorded in memory."""
proposals: list[SkillProposal] = []
normalized_workspace = _normalize_workspace_dir(workspace_dir)
root = _proposal_root(memory_dir)
if not root.exists():
return proposals
for manifest_path in sorted(root.glob("*/manifest.json")):
manifest = _read_manifest(manifest_path)
if manifest is None:
continue
proposal = _proposal_from_manifest(manifest_path.parent, manifest)
if proposal is None:
continue
if (
normalized_workspace is not None
and proposal.workspace_dir != normalized_workspace
):
continue
if status is not None and proposal.status != status:
continue
proposals.append(proposal)
return proposals
def pending_skill_proposal_count(
memory_dir: str | Path,
*,
workspace_dir: str | Path | None = None,
) -> int:
return len(
list_skill_proposals(
memory_dir,
status="pending",
workspace_dir=workspace_dir,
)
)
def cluster_hashes_by_status(
memory_dir: str | Path,
*,
workspace_dir: str | Path | None = None,
) -> dict[str, set[str]]:
by_status: dict[str, set[str]] = defaultdict(set)
for proposal in list_skill_proposals(memory_dir, workspace_dir=workspace_dir):
by_status[proposal.status].add(proposal.cluster_hash)
return by_status
def processed_cluster_hashes(memory_dir: str | Path) -> set[str]:
root = _processed_root(memory_dir)
if not root.exists():
return set()
return {path.stem for path in root.glob("*.json")}
def mark_cluster_processed(memory_dir: str | Path, cluster_hash: str) -> None:
root = _processed_root(memory_dir)
root.mkdir(parents=True, exist_ok=True)
path = root / f"{cluster_hash}.json"
payload = {"cluster_hash": cluster_hash, "processed_at": _now()}
_write_manifest(path, payload)
def _read_skill_markdown(skill_md: Path) -> tuple[str | None, str | None]:
try:
return skill_md.read_text(encoding="utf-8"), None
except OSError as exc:
return None, f"Cannot read SKILL.md: {exc}"
def _parse_skill_frontmatter(
content: str,
) -> tuple[dict[str, Any] | None, str | None, re.Match[str] | None]:
match = _SKILL_FRONTMATTER_RE.match(content)
if match is None:
return None, "SKILL.md must start with YAML frontmatter delimited by ---", None
try:
frontmatter = yaml.safe_load(match.group(1))
except yaml.YAMLError as exc:
return None, f"Invalid YAML frontmatter: {exc}", None
if not isinstance(frontmatter, dict):
return None, "SKILL.md frontmatter must be a YAML mapping", None
return frontmatter, None, match
def _validate_skill_proposal_dir(
*,
memory_dir: str | Path,
skill_name: str,
) -> tuple[bool, list[str], str | None]:
errors: list[str] = []
normalized_name = sanitize_skill_name(skill_name)
if normalized_name != skill_name:
errors.append(
"skill_name must be lowercase kebab-case and match the proposal directory"
)
return False, errors, None
proposal_dir = _proposal_root(memory_dir) / skill_name
if not proposal_dir.is_dir():
errors.append(
f"Missing proposal directory: {proposal_virtual_path(skill_name)}"
)
return False, errors, None
skill_md = proposal_dir / "SKILL.md"
if not skill_md.is_file():
errors.append(f"Missing {proposal_virtual_path(skill_name)}/SKILL.md")
return False, errors, None
content, error = _read_skill_markdown(skill_md)
if error is not None:
errors.append(error)
return False, errors, None
assert content is not None
frontmatter, error, match = _parse_skill_frontmatter(content)
if error is not None:
errors.append(error)
return False, errors, None
assert frontmatter is not None
assert match is not None
unexpected = set(frontmatter) - _ALLOWED_SKILL_FRONTMATTER
if unexpected:
errors.append(
"Unexpected SKILL.md frontmatter key(s): "
+ ", ".join(sorted(str(key) for key in unexpected))
)
frontmatter_name = str(frontmatter.get("name", "")).strip()
if frontmatter_name != skill_name:
errors.append(
f"SKILL.md frontmatter name must be {skill_name!r}, got {frontmatter_name!r}"
)
description = frontmatter.get("description")
if not isinstance(description, str) or not description.strip():
errors.append("SKILL.md frontmatter description must be a non-empty string")
description_text = None
else:
description_text = " ".join(description.strip().split())
if "<" in description_text or ">" in description_text:
errors.append(
"SKILL.md frontmatter description cannot contain angle brackets"
)
if len(description_text) > 1024:
errors.append("SKILL.md frontmatter description must be at most 1024 chars")
body = content[match.end() :]
if re.search(r"\[TODO:|\bTODO\b", body):
errors.append("SKILL.md body must not contain TODO placeholders")
return not errors, errors, description_text
def _skill_frontmatter_name(skill_dir: Path) -> str | None:
content, error = _read_skill_markdown(skill_dir / "SKILL.md")
if error is not None or content is None:
return None
frontmatter, error, _match = _parse_skill_frontmatter(content)
if error is not None or frontmatter is None:
return None
name = str(frontmatter.get("name", "")).strip()
return name or None
def _find_installed_user_skill(
skill_name: str,
*,
skills_dir: str | Path | None = None,
) -> Path | None:
roots = (
[Path(skills_dir).expanduser()]
if skills_dir is not None
else [Path(paths.USER_SKILLS_DIR).expanduser(), Path(paths.GLOBAL_SKILLS_DIR)]
)
for root in roots:
if not root.exists():
continue
direct = root / skill_name
if direct.is_dir() and (direct / "SKILL.md").is_file():
if _skill_frontmatter_name(direct) == skill_name:
return direct
for entry in root.iterdir():
if not entry.is_dir() or not (entry / "SKILL.md").is_file():
continue
if _skill_frontmatter_name(entry) == skill_name:
return entry
return None
def _copy_proposed_skill(
proposal_dir: Path,
destination: Path,
*,
base_dir: Path | None = None,
) -> None:
if not destination.exists():
if base_dir is not None:
shutil.copytree(base_dir, destination)
else:
destination.mkdir(parents=True, exist_ok=False)
for source_path in proposal_dir.rglob("*"):
if not source_path.is_file():
continue
relative = source_path.relative_to(proposal_dir)
if relative.as_posix() in {"manifest.json", "RATIONALE.md"}:
continue
target = destination / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, target)
def submit_autoskill_proposal(
*,
memory_dir: str | Path,
skill_name: str,
cluster_hash: str,
source_observation_ids: list[str],
rationale: str,
operation: str = "create",
target_skill_name: str | None = None,
workspace_dir: str | Path | None = None,
project_id: str | None = None,
) -> dict[str, Any]:
"""Validate and register a skill proposal folder written by the agent."""
normalized_name = sanitize_skill_name(skill_name)
if normalized_name != skill_name:
return {
"submitted": False,
"error": "skill_name must be lowercase kebab-case",
"skill_name": skill_name,
}
normalized_operation = _normalize_operation(operation)
if normalized_operation is None:
return {
"submitted": False,
"error": "operation must be 'create' or 'update'",
"skill_name": skill_name,
}
if normalized_operation == "create" and target_skill_name is not None:
return {
"submitted": False,
"error": "target_skill_name is only valid for update proposals",
"skill_name": skill_name,
}
if normalized_operation == "update":
target_name = sanitize_skill_name(target_skill_name or skill_name)
if target_name != (target_skill_name or skill_name):
return {
"submitted": False,
"error": "target_skill_name must be lowercase kebab-case",
"skill_name": skill_name,
}
if target_name != skill_name:
return {
"submitted": False,
"error": "update proposals must use the target skill name as skill_name",
"skill_name": skill_name,
"target_skill_name": target_name,
}
if _find_installed_user_skill(skill_name) is None:
return {
"submitted": False,
"error": f"No installed workspace/global skill named {skill_name!r} to update",
"skill_name": skill_name,
"operation": normalized_operation,
}
cluster_text = cluster_hash.strip()
if not cluster_text:
return {"submitted": False, "error": "cluster_hash must not be empty"}
source_ids = tuple(
sorted({item.strip() for item in source_observation_ids if item.strip()})
)
if not source_ids:
return {
"submitted": False,
"error": "source_observation_ids must not be empty",
}
valid, errors, description_text = _validate_skill_proposal_dir(
memory_dir=memory_dir,
skill_name=skill_name,
)
if not valid:
return {
"submitted": False,
"skill_name": skill_name,
"errors": errors,
"path": proposal_virtual_path(skill_name),
}
proposal_dir = _proposal_root(memory_dir) / skill_name
manifest_path = proposal_dir / "manifest.json"
existing = _read_manifest(manifest_path) if manifest_path.exists() else None
existing_status = str(existing.get("status", "")) if existing else ""
if existing_status == "rejected":
return {
"submitted": False,
"proposal_id": skill_name,
"status": existing_status,
"path": proposal_virtual_path(skill_name),
}
existing_is_approved = existing_status == "approved"
if existing_is_approved and normalized_operation != "update":
return {
"submitted": False,
"proposal_id": skill_name,
"status": existing_status,
"path": proposal_virtual_path(skill_name),
}
normalized_workspace = _normalize_workspace_dir(workspace_dir)
existing_workspace = (
_normalize_workspace_dir(existing.get("workspace_dir")) if existing else None
)
if (
existing is not None
and normalized_workspace is not None
and existing_workspace is not None
and existing_workspace != normalized_workspace
):
return {
"submitted": False,
"proposal_id": skill_name,
"status": existing.get("status", "pending"),
"error": "A pending proposal with this skill name belongs to another workspace",
"path": proposal_virtual_path(skill_name),
}
created_at = _now() if existing_is_approved else _created_at_for_manifest(existing)
(proposal_dir / "RATIONALE.md").write_text(
rationale.strip() + "\n", encoding="utf-8"
)
manifest = {
"proposal_id": skill_name,
"skill_name": skill_name,
"description": description_text,
"status": "pending",
"operation": normalized_operation,
"created_at": created_at,
"updated_at": _now(),
"cluster_hash": cluster_text,
"source_observation_ids": list(source_ids),
}
if normalized_operation == "update":
manifest["target_skill_name"] = skill_name
if normalized_workspace is not None:
manifest["workspace_dir"] = normalized_workspace
if project_id:
manifest["project_id"] = str(project_id)
_write_manifest(manifest_path, manifest)
return {
"submitted": True,
"created": existing is None,
"proposal_id": skill_name,
"status": "pending",
"skill_name": skill_name,
"operation": normalized_operation,
"target_skill_name": skill_name if normalized_operation == "update" else None,
"path": proposal_virtual_path(skill_name),
}
def _proposal_dir_by_id(
memory_dir: str | Path,
proposal_id: str,
*,
workspace_dir: str | Path | None = None,
) -> Path | None:
requested = proposal_id.strip()
if not requested:
return None
matches = [
proposal.path
for proposal in list_skill_proposals(memory_dir, workspace_dir=workspace_dir)
if proposal.proposal_id.startswith(requested)
]
return matches[0] if len(matches) == 1 else None
def approve_skill_proposal(
memory_dir: str | Path,
proposal_id: str,
*,
skills_dir: str | Path | None = None,
workspace_dir: str | Path | None = None,
) -> dict[str, Any]:
"""Promote one pending proposal into the workspace-local skills tier."""
proposal_dir = _proposal_dir_by_id(
memory_dir,
proposal_id,
workspace_dir=workspace_dir,
)
if proposal_dir is None:
return {
"approved": False,
"error": f"No unique proposal matching {proposal_id!r}",
}
manifest_path = proposal_dir / "manifest.json"
manifest = _read_manifest(manifest_path)
if manifest is None:
return {"approved": False, "error": "Proposal manifest is missing or invalid"}
if manifest.get("status") != "pending":
return {
"approved": False,
"proposal_id": manifest.get("proposal_id"),
"status": manifest.get("status"),
"error": "Only pending proposals can be approved",
}
skill_name = str(manifest.get("skill_name", "")).strip()
if sanitize_skill_name(skill_name) != skill_name:
return {"approved": False, "error": "Proposal skill name is invalid"}
operation = _normalize_operation(manifest.get("operation")) or "create"
target_skill_name = str(manifest.get("target_skill_name") or skill_name).strip()
valid, errors, _description = _validate_skill_proposal_dir(
memory_dir=memory_dir,
skill_name=skill_name,
)
if not valid:
return {
"approved": False,
"proposal_id": manifest["proposal_id"],
"error": "Proposal skill folder is invalid",
"errors": errors,
}
if skills_dir is not None:
destination_root = Path(skills_dir).expanduser()
else:
destination_root = Path(paths.USER_SKILLS_DIR).expanduser()
destination = destination_root / skill_name
base_skill_dir: Path | None = None
if operation == "create" and destination.exists():
return {
"approved": False,
"proposal_id": manifest["proposal_id"],
"error": f"Skill already exists: {destination}",
}
if operation == "update":
if target_skill_name != skill_name:
return {
"approved": False,
"proposal_id": manifest["proposal_id"],
"error": "Update proposal target must match the proposed skill name",
}
local_match = _find_installed_user_skill(
skill_name,
skills_dir=destination_root,
)
if local_match is not None:
destination = local_match
else:
existing_global = _find_installed_user_skill(skill_name)
if existing_global is None:
return {
"approved": False,
"proposal_id": manifest["proposal_id"],
"error": f"No installed workspace/global skill named {skill_name!r} to update",
}
if destination.exists():
return {
"approved": False,
"proposal_id": manifest["proposal_id"],
"error": (
f"Local skill path already exists but does not match "
f"{skill_name!r}: {destination}"
),
}
base_skill_dir = existing_global
_copy_proposed_skill(proposal_dir, destination, base_dir=base_skill_dir)
manifest["status"] = "approved"
manifest["updated_at"] = _now()
manifest["approved_skill_path"] = str(destination)
_write_manifest(manifest_path, manifest)
mark_cluster_processed(memory_dir, str(manifest["cluster_hash"]))
return {
"approved": True,
"proposal_id": manifest["proposal_id"],
"skill_name": skill_name,
"operation": operation,
"path": str(destination),
}
def reject_skill_proposal(
memory_dir: str | Path,
proposal_id: str,
*,
workspace_dir: str | Path | None = None,
) -> dict[str, Any]:
"""Mark one pending proposal rejected."""
proposal_dir = _proposal_dir_by_id(
memory_dir,
proposal_id,
workspace_dir=workspace_dir,
)
if proposal_dir is None:
return {
"rejected": False,
"error": f"No unique proposal matching {proposal_id!r}",
}
manifest_path = proposal_dir / "manifest.json"
manifest = _read_manifest(manifest_path)
if manifest is None:
return {"rejected": False, "error": "Proposal manifest is missing or invalid"}
if manifest.get("status") != "pending":
return {
"rejected": False,
"proposal_id": manifest.get("proposal_id"),
"status": manifest.get("status"),
"error": "Only pending proposals can be rejected",
}
manifest["status"] = "rejected"
manifest["updated_at"] = _now()
_write_manifest(manifest_path, manifest)
mark_cluster_processed(memory_dir, str(manifest["cluster_hash"]))
return {
"rejected": True,
"proposal_id": manifest["proposal_id"],
"skill_name": manifest["skill_name"],
}
+200
View File
@@ -0,0 +1,200 @@
"""LangGraph scheduling helpers for EvoMemory AutoSkills."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ...config import EvoScientistConfig
from ...langgraph_dev.sdk import (
default_scheduler_timezone,
get_langgraph_async_client,
get_langgraph_sync_client,
langgraph_dev_url,
messages_input,
)
AUTOSKILL_GRAPH_ID = "evomemory-autoskills"
AUTOSKILL_RUN_KIND = "evomemory_autoskills"
AUTOSKILL_SCHEDULE_SEARCH_LIMIT = 100
def autoskill_cron(cadence: str, time_hhmm: str) -> str:
"""Translate public cadence settings to a 5-field cron expression."""
hour_text, minute_text = time_hhmm.split(":", 1)
hour = int(hour_text)
minute = int(minute_text)
cadence_value = str(getattr(cadence, "value", cadence)).strip().lower()
if cadence_value == "nightly":
return f"{minute} {hour} * * *"
if cadence_value == "weekly":
return f"{minute} {hour} * * 0"
if cadence_value == "monthly":
return f"{minute} {hour} 1 * *"
raise ValueError(f"Unsupported AutoSkills cadence: {cadence!r}")
def _autoskill_input() -> dict[str, Any]:
return messages_input(
"Run EvoMemory AutoSkills maintenance. Inspect candidate "
"observation clusters, then propose at most a small number "
"of high-confidence skills."
)
def _autoskill_metadata(
*,
config: EvoScientistConfig,
workspace_dir: str | Path,
schedule: str,
) -> dict[str, str]:
return {
"run_kind": AUTOSKILL_RUN_KIND,
"name": "EvoMemory AutoSkills",
"workspace_dir": str(Path(workspace_dir).expanduser().resolve()),
"mode": config.memory_skill_synthesis_mode.value,
"cadence": config.memory_skill_synthesis_cadence.value,
"time": config.memory_skill_synthesis_time,
"schedule": schedule,
}
def list_autoskill_schedules(
config: EvoScientistConfig,
*,
limit: int = AUTOSKILL_SCHEDULE_SEARCH_LIMIT,
) -> list[dict[str, Any]]:
"""Return internal AutoSkills cron records."""
return list(
get_langgraph_sync_client(url=langgraph_dev_url(config)).crons.search(
metadata={"run_kind": AUTOSKILL_RUN_KIND},
limit=limit,
)
)
async def alist_autoskill_schedules(
config: EvoScientistConfig,
*,
limit: int = AUTOSKILL_SCHEDULE_SEARCH_LIMIT,
) -> list[dict[str, Any]]:
"""Async variant of :func:`list_autoskill_schedules`."""
rows = await get_langgraph_async_client(url=langgraph_dev_url(config)).crons.search(
metadata={"run_kind": AUTOSKILL_RUN_KIND},
limit=limit,
)
return list(rows)
def reconcile_autoskill_schedule(
config: EvoScientistConfig,
*,
workspace_dir: str | Path,
) -> dict[str, Any]:
"""Ensure the hidden AutoSkills cron matches config."""
from ...langgraph_dev.manager import is_langgraph_dev_running
if not is_langgraph_dev_running(base_url=langgraph_dev_url(config)):
return {"status": "unavailable"}
client = get_langgraph_sync_client(url=langgraph_dev_url(config))
existing = list_autoskill_schedules(
config,
limit=AUTOSKILL_SCHEDULE_SEARCH_LIMIT,
)
if not config.memory_skill_synthesis_enabled:
for row in existing:
client.crons.delete(str(row["cron_id"]))
return {"status": "disabled", "deleted": len(existing)}
schedule = autoskill_cron(
config.memory_skill_synthesis_cadence,
config.memory_skill_synthesis_time,
)
metadata = _autoskill_metadata(
config=config,
workspace_dir=workspace_dir,
schedule=schedule,
)
matching = [
row
for row in existing
if row.get("schedule") == schedule
and bool(row.get("enabled", True))
and (row.get("metadata") or {}).get("workspace_dir")
== metadata["workspace_dir"]
and (row.get("metadata") or {}).get("mode") == metadata["mode"]
]
if len(matching) == 1 and len(existing) == 1:
return {"status": "unchanged", "cron_id": matching[0].get("cron_id")}
for row in existing:
client.crons.delete(str(row["cron_id"]))
created = client.crons.create(
assistant_id=AUTOSKILL_GRAPH_ID,
schedule=schedule,
input=_autoskill_input(),
metadata=metadata,
timezone=default_scheduler_timezone(config),
)
return {
"status": "created",
"cron_id": created.get("cron_id"),
"schedule": schedule,
}
def run_autoskill_now(
config: EvoScientistConfig,
*,
workspace_dir: str | Path,
) -> dict[str, Any]:
"""Launch a one-off AutoSkills run immediately."""
client = get_langgraph_sync_client(url=langgraph_dev_url(config))
thread = client.threads.create(
graph_id=AUTOSKILL_GRAPH_ID,
metadata={
"run_kind": AUTOSKILL_RUN_KIND,
"workspace_dir": str(Path(workspace_dir).expanduser().resolve()),
},
)
run = client.runs.create(
thread_id=str(thread["thread_id"]),
assistant_id=AUTOSKILL_GRAPH_ID,
input=_autoskill_input(),
metadata=_autoskill_metadata(
config=config,
workspace_dir=workspace_dir,
schedule="manual",
),
config={"configurable": {"thread_id": str(thread["thread_id"])}},
)
return {"thread_id": thread["thread_id"], "run_id": run["run_id"]}
async def arun_autoskill_now(
config: EvoScientistConfig,
*,
workspace_dir: str | Path,
) -> dict[str, Any]:
"""Async variant of :func:`run_autoskill_now`."""
client = get_langgraph_async_client(url=langgraph_dev_url(config))
thread = await client.threads.create(
graph_id=AUTOSKILL_GRAPH_ID,
metadata={
"run_kind": AUTOSKILL_RUN_KIND,
"workspace_dir": str(Path(workspace_dir).expanduser().resolve()),
},
)
run = await client.runs.create(
thread_id=str(thread["thread_id"]),
assistant_id=AUTOSKILL_GRAPH_ID,
input=_autoskill_input(),
metadata=_autoskill_metadata(
config=config,
workspace_dir=workspace_dir,
schedule="manual",
),
config={"configurable": {"thread_id": str(thread["thread_id"])}},
)
return {"thread_id": thread["thread_id"], "run_id": run["run_id"]}
+157
View File
@@ -0,0 +1,157 @@
"""Agent-facing tools for the AutoSkills graph."""
from __future__ import annotations
import json
from pathlib import Path
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from ...config import MemorySkillSynthesisMode, get_effective_config
from ...tools.skills_manager import list_skills
from .candidates import autoskill_candidates
from .proposals import approve_skill_proposal, submit_autoskill_proposal
class SubmitAutoskillProposalArgs(BaseModel):
"""Model-facing arguments for submitting an autoskill proposal folder."""
skill_name: str = Field(
min_length=1,
description=(
"Exact lowercase kebab-case skill directory name already created "
"under /autoskill-proposals/."
),
)
cluster_hash: str = Field(
min_length=1,
description="Exact candidate cluster_hash returned by inspect_autoskill_candidates.",
)
source_observation_ids: list[str] = Field(
min_length=1,
description="Observation IDs that justify the skill.",
)
rationale: str = Field(
min_length=1,
description=(
"Concise explanation of the repeated pattern and why it belongs in "
"a reusable skill."
),
)
operation: str = Field(
default="create",
description=(
"Use 'create' for a new skill or 'update' when the proposal is a "
"change to an existing workspace/global skill."
),
)
target_skill_name: str | None = Field(
default=None,
description=(
"For operation='update', the existing skill being updated. It must "
"match skill_name."
),
)
def _installed_skills_for_autoskill_context() -> list[dict[str, str]]:
return [
{
"name": skill.name,
"description": skill.description,
"source": skill.source,
"path": f"/skills/{skill.path.name}",
}
for skill in list_skills(include_system=False)
if skill.source in {"workspace", "global"}
]
def create_inspect_autoskill_candidates_tool(
*,
memory_dir: str | Path,
project_id: str,
workspace_dir: str | Path,
) -> BaseTool:
"""Build the read-only candidate-inspection tool for AutoSkills."""
def _inspect_autoskill_candidates() -> str:
candidates = autoskill_candidates(
memory_dir=memory_dir,
project_id=project_id,
workspace_dir=workspace_dir,
)
return json.dumps(
{
"candidates": candidates,
"installed_skills": _installed_skills_for_autoskill_context(),
},
ensure_ascii=False,
default=str,
)
return StructuredTool.from_function(
func=_inspect_autoskill_candidates,
name="inspect_autoskill_candidates",
description=(
"Inspect linked observation-memory clusters that may justify a "
"new reusable skill or an update to an existing skill. Call this "
"before proposing any skill."
),
)
def create_submit_autoskill_proposal_tool(
*,
memory_dir: str | Path,
workspace_dir: str | Path,
project_id: str,
) -> BaseTool:
"""Build the proposal-registration tool for AutoSkills."""
def _submit_autoskill_proposal(
skill_name: str,
cluster_hash: str,
source_observation_ids: list[str],
rationale: str,
operation: str = "create",
target_skill_name: str | None = None,
) -> str:
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name=skill_name,
cluster_hash=cluster_hash,
source_observation_ids=source_observation_ids,
rationale=rationale,
operation=operation,
target_skill_name=target_skill_name,
workspace_dir=workspace_dir,
project_id=project_id,
)
if (
proposal.get("status") == "pending"
and get_effective_config().memory_skill_synthesis_mode
== MemorySkillSynthesisMode.AUTO
):
approved = approve_skill_proposal(
memory_dir,
str(proposal["proposal_id"]),
workspace_dir=workspace_dir,
)
proposal["auto_approval"] = approved
return json.dumps(proposal, ensure_ascii=False, default=str)
return StructuredTool.from_function(
func=_submit_autoskill_proposal,
name="submit_autoskill_proposal",
description=(
"Validate and register an autoskill proposal after creating its "
"folder under /autoskill-proposals/<skill-name>. Set operation to "
"'update' when changing an existing workspace/global skill. In "
"auto mode, the tool promotes the proposal only if validation and "
"collision checks pass."
),
args_schema=SubmitAutoskillProposalArgs,
infer_schema=False,
)
+4 -17
View File
@@ -16,6 +16,7 @@ from ..gateway.background_runs import (
alaunch_background_run,
launch_background_run,
)
from ..langgraph_dev.sdk import messages_input
from .observations import build_observation_linker_index_context
from .scheduler import ObservationLinkerContext
from .source_context import MemorySourceContext, _trajectory_for_prompt
@@ -108,14 +109,7 @@ def _memory_worker_run_payload(
metadata = _memory_worker_metadata(context)
payload: BackgroundRunPayload = {
"assistant_id": _memory_worker_graph_id(context.source_type),
"input": {
"messages": [
{
"role": "user",
"content": _memory_worker_user_prompt(context),
}
]
},
"input": messages_input(_memory_worker_user_prompt(context)),
"metadata": metadata,
"config": {
"configurable": {
@@ -185,14 +179,7 @@ def _observation_linker_run_payload(
) -> BackgroundRunPayload:
payload: BackgroundRunPayload = {
"assistant_id": OBSERVATION_LINKER_GRAPH_ID,
"input": {
"messages": [
{
"role": "user",
"content": _observation_linker_user_prompt(context),
}
]
},
"input": messages_input(_observation_linker_user_prompt(context)),
"metadata": _observation_linker_metadata(context),
"config": {
"configurable": {
@@ -297,7 +284,7 @@ def _memory_worker_launch_hooks(
on_finished=on_finished,
on_aborted=on_aborted,
on_status_unknown=on_status_unknown,
on_watcher_start_failed=on_status_unknown,
on_watcher_start_failed=on_aborted,
)
+20 -6
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import hashlib
import json
from dataclasses import replace
from datetime import UTC, datetime
from datetime import UTC, date, datetime
from pathlib import Path
import yaml
@@ -68,21 +68,23 @@ class ObservationSourceFrontmatter(BaseModel):
type: MemorySourceType
agent: str = Field(min_length=1, strict=True)
session_id: str = Field(min_length=1, strict=True)
session_id: str | None = Field(default=None, min_length=1, strict=True)
@field_validator("agent", "session_id")
@classmethod
def _non_blank(cls, value: str) -> str:
if not value.strip():
def _non_blank(cls, value: str | None) -> str | None:
if value is not None and not value.strip():
raise ValueError("must not be blank")
return value
def to_frontmatter_dict(self) -> dict[str, str]:
return {
payload = {
"type": self.type.value,
"agent": self.agent,
"session_id": self.session_id,
}
if self.session_id is not None:
payload["session_id"] = self.session_id
return payload
class ObservationFrontmatter(BaseModel):
@@ -104,6 +106,18 @@ class ObservationFrontmatter(BaseModel):
raise ValueError("must not be blank")
return value
@field_validator("created_at", mode="before")
@classmethod
def _coerce_yaml_timestamp(cls, value: object) -> object:
if isinstance(value, datetime):
if value.tzinfo is not None:
value = value.astimezone(UTC)
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
return value.strftime("%Y-%m-%dT%H:%M:%S")
if isinstance(value, date):
return value.isoformat()
return value
def to_frontmatter_dict(self) -> ObservationFrontmatterPayload:
payload: ObservationFrontmatterPayload = {
"id": self.id,
+8 -13
View File
@@ -204,6 +204,10 @@ def _runtime_config_value(runtime: ToolRuntime | None, key: str) -> str | None:
return value if isinstance(value, str) and value else None
def _runtime_project_id(runtime: ToolRuntime | None, default_project_id: str) -> str:
return _runtime_config_value(runtime, "evomemory_project_id") or default_project_id
def _runtime_session_id(runtime: ToolRuntime | None) -> str | None:
"""Extract the source thread id from tool runtime metadata when present."""
source_session_id = _runtime_config_value(runtime, "evomemory_source_session_id")
@@ -229,7 +233,7 @@ def _resolve_observation_context(
if source_session_id is None:
return None
return _ObservationContext(
project_id=_runtime_config_value(runtime, "evomemory_project_id") or project_id,
project_id=_runtime_project_id(runtime, project_id),
source_session_id=source_session_id,
source_agent=_runtime_config_value(runtime, "evomemory_source_agent")
or source_agent,
@@ -252,12 +256,9 @@ def create_search_observations_tool(
runtime: Annotated[ToolRuntime | None, InjectedToolArg] = None,
) -> str:
search_mode = ObservationSearchMode(mode)
effective_project_id = (
_runtime_config_value(runtime, "evomemory_project_id") or project_id
)
results = search_observation_files(
memory_dir=memory_dir,
project_id=effective_project_id,
project_id=_runtime_project_id(runtime, project_id),
query=query,
scope=scope,
memory_type=memory_type,
@@ -300,12 +301,9 @@ def create_read_memory_tool(
runtime: Annotated[ToolRuntime | None, InjectedToolArg] = None,
) -> str:
requested_id = observation_id.strip()
effective_project_id = (
_runtime_config_value(runtime, "evomemory_project_id") or project_id
)
result = read_observation_file(
memory_dir=memory_dir,
project_id=effective_project_id,
project_id=_runtime_project_id(runtime, project_id),
observation_id=requested_id,
)
if result is None:
@@ -414,12 +412,9 @@ def create_link_observations_tool(
bidirectional: bool = True,
runtime: Annotated[ToolRuntime | None, InjectedToolArg] = None,
) -> str:
effective_project_id = (
_runtime_config_value(runtime, "evomemory_project_id") or project_id
)
result = link_observation_files(
memory_dir=memory_dir,
project_id=effective_project_id,
project_id=_runtime_project_id(runtime, project_id),
source_observation_id=source_observation_id,
target_observation_id=target_observation_id,
reason=reason,
+8 -4
View File
@@ -107,18 +107,22 @@ def cancel_scheduled_task(cron_id: str) -> str:
if not crons.is_available():
return "Scheduler unavailable: the langgraph dev backend is not running."
if not cron_id.strip():
if not (requested_id := cron_id.strip()):
# Empty prefix would match (and delete) the only cron — refuse it.
return "Provide the id (or a prefix) of the task to cancel."
try:
rows = crons.list_schedules()
# B2: collect ALL prefix matches before acting to detect ambiguity.
matches = [r for r in rows if str(r.get("cron_id", "")).startswith(cron_id)]
matches = [
r for r in rows if str(r.get("cron_id", "")).startswith(requested_id)
]
if not matches:
return f"No scheduled task matching '{cron_id}'."
return f"No scheduled task matching '{requested_id}'."
if len(matches) > 1:
ids = ", ".join(str(r.get("cron_id", ""))[:8] for r in matches)
return f"Multiple schedules match '{cron_id}' ({ids}) — use a longer id."
return (
f"Multiple schedules match '{requested_id}' ({ids}) — use a longer id."
)
target = str(matches[0]["cron_id"])
crons.delete_schedule(target)
except Exception as e:
+991
View File
@@ -0,0 +1,991 @@
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from EvoScientist import paths
from EvoScientist.config import (
EvoScientistConfig,
MemorySkillSynthesisCadence,
MemorySkillSynthesisMode,
save_config,
set_config_value,
)
from EvoScientist.memory.autoskills.candidates import autoskill_candidates
from EvoScientist.memory.autoskills.proposals import (
approve_skill_proposal,
autoskill_proposals_dir,
list_skill_proposals,
pending_skill_proposal_count,
reject_skill_proposal,
submit_autoskill_proposal,
)
from EvoScientist.memory.autoskills.schedule import (
AUTOSKILL_GRAPH_ID,
AUTOSKILL_RUN_KIND,
AUTOSKILL_SCHEDULE_SEARCH_LIMIT,
alist_autoskill_schedules,
autoskill_cron,
reconcile_autoskill_schedule,
)
from EvoScientist.memory.autoskills.tools import create_submit_autoskill_proposal_tool
from EvoScientist.memory.observations import (
MemoryScope,
MemorySourceType,
MemoryType,
ObservationRelation,
link_observation_files,
record_observation_file,
)
def _record(
memory_dir,
*,
summary: str,
observation: str,
memory_type: MemoryType = MemoryType.PROCEDURAL,
):
return record_observation_file(
memory_dir=memory_dir,
project_id="P-project",
memory_type=memory_type,
summary=summary,
observation=observation,
why_it_matters=f"Future agents can reuse this pattern: {summary}",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
def _write_skill_folder(memory_dir, skill_name: str, description: str, body: str):
proposal_dir = autoskill_proposals_dir(memory_dir) / skill_name
proposal_dir.mkdir(parents=True, exist_ok=True)
(proposal_dir / "SKILL.md").write_text(
f"---\nname: {skill_name}\ndescription: {description}\n---\n\n{body}\n",
encoding="utf-8",
)
return proposal_dir
def _write_installed_skill(root, skill_name: str, description: str, body: str):
skill_dir = root / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {skill_name}\ndescription: {description}\n---\n\n{body}\n",
encoding="utf-8",
)
return skill_dir
def _write_installed_skill_in_dir(
root,
directory_name: str,
*,
skill_name: str,
description: str,
body: str,
):
skill_dir = root / directory_name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {skill_name}\ndescription: {description}\n---\n\n{body}\n",
encoding="utf-8",
)
return skill_dir
def test_autoskill_cron_uses_presets():
assert autoskill_cron("nightly", "03:00") == "0 3 * * *"
assert autoskill_cron("weekly", "04:30") == "30 4 * * 0"
assert autoskill_cron("monthly", "22:05") == "5 22 1 * *"
def test_autoskill_candidates_use_linked_procedural_clusters(tmp_path):
memory_dir = tmp_path / "memories"
first = _record(
memory_dir,
summary="Use focused pytest before full suite.",
observation="Run the focused pytest file before the full test suite.",
)
second = _record(
memory_dir,
summary="Use ruff on changed Python modules.",
observation="Run ruff on changed modules before broad validation.",
)
third = _record(
memory_dir,
summary="Validation workflow benefits from narrow checks.",
observation="Narrow validation catches regressions before expensive checks.",
memory_type=MemoryType.SEMANTIC,
)
for source, target in ((first, second), (second, third)):
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=source["observation_id"],
target_observation_id=target["observation_id"],
reason="These observations describe the same validation workflow.",
)
candidates = autoskill_candidates(
memory_dir=memory_dir,
project_id="P-project",
)
assert len(candidates) == 1
assert set(candidates[0]["observation_ids"]) == {
first["observation_id"],
second["observation_id"],
third["observation_id"],
}
assert candidates[0]["procedural_count"] == 2
assert candidates[0]["semantic_count"] == 1
assert candidates[0]["episodic_count"] == 0
assert candidates[0]["existing_pending_proposal"] is False
assert candidates[0]["already_processed"] is False
def test_autoskill_candidates_surface_contradiction_clusters(tmp_path):
memory_dir = tmp_path / "memories"
first = _record(
memory_dir,
summary="Use cached package metadata for offline installs.",
observation="Cached package metadata works when the network is unavailable.",
)
second = _record(
memory_dir,
summary="Avoid cached metadata for editable dependency changes.",
observation="Cached package metadata can hide editable dependency changes.",
)
third = _record(
memory_dir,
summary="Package validation should check cache freshness.",
observation="Validation should distinguish offline cache use from stale cache risks.",
memory_type=MemoryType.SEMANTIC,
)
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=second["observation_id"],
relation=ObservationRelation.CONTRADICTS,
reason="Cached metadata helps offline installs but can hide editable changes.",
)
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=second["observation_id"],
target_observation_id=third["observation_id"],
reason="Both observations describe cache-aware package validation.",
)
candidates = autoskill_candidates(
memory_dir=memory_dir,
project_id="P-project",
)
assert len(candidates) == 1
assert set(candidates[0]["observation_ids"]) == {
first["observation_id"],
second["observation_id"],
third["observation_id"],
}
assert any(
relation["relation"] == ObservationRelation.CONTRADICTS
for relation in candidates[0]["relations"]
)
def test_autoskill_candidate_hash_ignores_mutable_relations(tmp_path):
memory_dir = tmp_path / "memories"
first = _record(
memory_dir,
summary="Use focused pytest before full suite.",
observation="Run the focused pytest file before the full test suite.",
)
second = _record(
memory_dir,
summary="Use ruff on changed Python modules.",
observation="Run ruff on changed modules before broad validation.",
)
third = _record(
memory_dir,
summary="Validation workflow benefits from narrow checks.",
observation="Narrow validation catches regressions before expensive checks.",
memory_type=MemoryType.SEMANTIC,
)
for source, target in ((first, second), (second, third)):
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=source["observation_id"],
target_observation_id=target["observation_id"],
reason="These observations describe the same validation workflow.",
)
before = autoskill_candidates(memory_dir=memory_dir, project_id="P-project")[0]
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=third["observation_id"],
reason="A later linker pass found another relation in the same cluster.",
)
after = autoskill_candidates(memory_dir=memory_dir, project_id="P-project")[0]
assert after["cluster_hash"] == before["cluster_hash"]
assert after["observation_ids"] == before["observation_ids"]
assert len(after["relations"]) > len(before["relations"])
def test_skill_proposal_lifecycle_promotes_to_workspace_skill(tmp_path):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
_write_skill_folder(
memory_dir,
"focused-validation",
"Use when validating code changes with staged checks.",
"# Focused validation\n\nRun narrow checks before broad ones.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="focused-validation",
cluster_hash="cluster-1",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Three observations describe the same staged validation practice.",
)
assert proposal["submitted"] is True
assert pending_skill_proposal_count(memory_dir) == 1
pending = list_skill_proposals(memory_dir, status="pending")
assert pending[0].skill_name == "focused-validation"
assert pending[0].proposal_id == "focused-validation"
approved = approve_skill_proposal(
memory_dir,
pending[0].proposal_id,
skills_dir=skills_dir,
)
assert approved["approved"] is True
skill_md = skills_dir / "focused-validation" / "SKILL.md"
assert skill_md.exists()
assert "name: focused-validation" in skill_md.read_text(encoding="utf-8")
assert pending_skill_proposal_count(memory_dir) == 0
assert list_skill_proposals(memory_dir)[0].status == "approved"
def test_update_skill_proposal_replaces_workspace_skill(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
_write_installed_skill(
skills_dir,
"focused-validation",
"Use when validating code changes with staged checks.",
"# Focused validation\n\nOld workflow.",
)
_write_skill_folder(
memory_dir,
"focused-validation",
"Use when validating code changes with staged checks and caveats.",
"# Focused validation\n\nUpdated workflow with caveats.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="focused-validation",
cluster_hash="cluster-update",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="New observations refine the existing validation skill.",
operation="update",
target_skill_name="focused-validation",
)
pending = list_skill_proposals(memory_dir, status="pending")
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
skill_md = skills_dir / "focused-validation" / "SKILL.md"
saved = skill_md.read_text(encoding="utf-8")
assert proposal["submitted"] is True
assert proposal["operation"] == "update"
assert pending[0].operation == "update"
assert pending[0].target_skill_name == "focused-validation"
assert approved["approved"] is True
assert approved["operation"] == "update"
assert "Updated workflow with caveats." in saved
assert "Old workflow." not in saved
assert list_skill_proposals(memory_dir)[0].status == "approved"
def test_update_skill_proposal_preserves_existing_workspace_files(
tmp_path,
monkeypatch,
):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
skill_dir = _write_installed_skill(
skills_dir,
"focused-validation",
"Use when validating code changes with staged checks.",
"# Focused validation\n\nOld workflow.",
)
script_path = skill_dir / "scripts" / "run.sh"
script_path.parent.mkdir(parents=True)
script_path.write_text("#!/bin/sh\necho validate\n", encoding="utf-8")
_write_skill_folder(
memory_dir,
"focused-validation",
"Use when validating code changes with staged checks and caveats.",
"# Focused validation\n\nUpdated workflow with caveats.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="focused-validation",
cluster_hash="cluster-update",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="New observations refine the existing validation skill.",
operation="update",
target_skill_name="focused-validation",
)
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
assert approved["approved"] is True
assert "Updated workflow with caveats." in (skill_dir / "SKILL.md").read_text(
encoding="utf-8"
)
assert script_path.read_text(encoding="utf-8") == "#!/bin/sh\necho validate\n"
def test_update_can_reopen_completed_autoskill_proposal(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
_write_skill_folder(
memory_dir,
"reopen-update",
"Use when testing completed autoskill proposals.",
"# Reopen update\n\nInitial workflow.",
)
first = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reopen-update",
cluster_hash="cluster-initial",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Initial proposal.",
)
approved = approve_skill_proposal(memory_dir, first["proposal_id"])
_write_skill_folder(
memory_dir,
"reopen-update",
"Use when testing completed autoskill proposal updates.",
"# Reopen update\n\nUpdated workflow.",
)
reopened = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reopen-update",
cluster_hash="cluster-update",
source_observation_ids=["O-4", "O-5", "O-6"],
rationale="Later observations refine the existing skill.",
operation="update",
target_skill_name="reopen-update",
)
proposal = list_skill_proposals(memory_dir, status="pending")[0]
assert approved["approved"] is True
assert reopened["submitted"] is True
assert reopened["created"] is False
assert proposal.operation == "update"
assert proposal.cluster_hash == "cluster-update"
def test_update_cannot_reopen_rejected_autoskill_proposal(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
_write_skill_folder(
memory_dir,
"reject-update",
"Use when testing rejected autoskill proposal updates.",
"# Reject update\n\nInitial workflow.",
)
first = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reject-update",
cluster_hash="cluster-initial",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Initial proposal.",
)
rejected = reject_skill_proposal(memory_dir, first["proposal_id"])
_write_installed_skill(
skills_dir,
"reject-update",
"Use when testing rejected autoskill proposal updates.",
"# Reject update\n\nInstalled workflow.",
)
_write_skill_folder(
memory_dir,
"reject-update",
"Use when testing rejected autoskill proposal updates.",
"# Reject update\n\nUpdated workflow.",
)
reopened = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reject-update",
cluster_hash="cluster-update",
source_observation_ids=["O-4", "O-5", "O-6"],
rationale="Later observations refine the existing skill.",
operation="update",
target_skill_name="reject-update",
)
assert rejected["rejected"] is True
assert reopened["submitted"] is False
assert reopened["status"] == "rejected"
assert list_skill_proposals(memory_dir)[0].status == "rejected"
def test_update_replaces_workspace_skill_matched_by_frontmatter(
tmp_path,
monkeypatch,
):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
installed_dir = _write_installed_skill_in_dir(
skills_dir,
"legacy-directory",
skill_name="frontmatter-match",
description="Use when testing frontmatter skill matching.",
body="# Frontmatter match\n\nOld workflow.",
)
_write_skill_folder(
memory_dir,
"frontmatter-match",
"Use when testing frontmatter skill update matching.",
"# Frontmatter match\n\nUpdated workflow.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="frontmatter-match",
cluster_hash="cluster-frontmatter",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Later observations refine the existing skill.",
operation="update",
target_skill_name="frontmatter-match",
)
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
assert approved["approved"] is True
assert approved["path"] == str(installed_dir)
assert "Updated workflow." in (installed_dir / "SKILL.md").read_text(
encoding="utf-8"
)
assert not (skills_dir / "frontmatter-match").exists()
def test_update_skill_proposal_requires_existing_skill(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", tmp_path / "skills")
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
_write_skill_folder(
memory_dir,
"missing-target",
"Use when testing missing autoskill update targets.",
"# Missing target\n",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="missing-target",
cluster_hash="cluster-missing",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="This should not submit without an installed target.",
operation="update",
target_skill_name="missing-target",
)
assert proposal["submitted"] is False
assert "No installed workspace/global skill" in proposal["error"]
assert pending_skill_proposal_count(memory_dir) == 0
def test_update_global_skill_creates_workspace_shadow(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
workspace_skills = tmp_path / "workspace-skills"
global_skills = tmp_path / "global-skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", workspace_skills)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", global_skills)
_write_installed_skill(
global_skills,
"global-validation",
"Use when validating code changes from a global skill.",
"# Global validation\n\nGlobal workflow.",
)
global_script = global_skills / "global-validation" / "scripts" / "run.sh"
global_script.parent.mkdir(parents=True)
global_script.write_text("#!/bin/sh\necho global\n", encoding="utf-8")
_write_skill_folder(
memory_dir,
"global-validation",
"Use when validating code changes from an updated global skill.",
"# Global validation\n\nWorkspace shadow update.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="global-validation",
cluster_hash="cluster-global-update",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Observations refine a global skill for this workspace.",
operation="update",
target_skill_name="global-validation",
)
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
assert approved["approved"] is True
assert approved["operation"] == "update"
assert (workspace_skills / "global-validation" / "SKILL.md").exists()
assert "Workspace shadow update." in (
workspace_skills / "global-validation" / "SKILL.md"
).read_text(encoding="utf-8")
assert (workspace_skills / "global-validation" / "scripts" / "run.sh").read_text(
encoding="utf-8"
) == "#!/bin/sh\necho global\n"
assert "Global workflow." in (
global_skills / "global-validation" / "SKILL.md"
).read_text(encoding="utf-8")
def test_update_global_skill_does_not_overwrite_nonmatching_local_dir(
tmp_path,
monkeypatch,
):
memory_dir = tmp_path / "memories"
workspace_skills = tmp_path / "workspace-skills"
global_skills = tmp_path / "global-skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", workspace_skills)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", global_skills)
_write_installed_skill_in_dir(
workspace_skills,
"global-validation",
skill_name="different-local-skill",
description="Use when testing nonmatching local skill collisions.",
body="# Different local skill\n\nKeep this local content.",
)
_write_installed_skill(
global_skills,
"global-validation",
"Use when validating code changes from a global skill.",
"# Global validation\n\nGlobal workflow.",
)
_write_skill_folder(
memory_dir,
"global-validation",
"Use when validating code changes from an updated global skill.",
"# Global validation\n\nWorkspace shadow update.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="global-validation",
cluster_hash="cluster-global-update",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Observations refine a global skill for this workspace.",
operation="update",
target_skill_name="global-validation",
)
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
assert proposal["submitted"] is True
assert approved["approved"] is False
assert "does not match" in approved["error"]
assert "Keep this local content." in (
workspace_skills / "global-validation" / "SKILL.md"
).read_text(encoding="utf-8")
def test_create_proposal_rejects_target_skill_name(tmp_path):
memory_dir = tmp_path / "memories"
_write_skill_folder(
memory_dir,
"target-on-create",
"Use when testing create proposal target validation.",
"# Target on create\n",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="target-on-create",
cluster_hash="cluster-target",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Create proposals should not name an update target.",
target_skill_name="target-on-create",
)
assert proposal["submitted"] is False
assert "only valid for update" in proposal["error"]
assert pending_skill_proposal_count(memory_dir) == 0
def test_submit_autoskill_proposal_defaults_missing_created_at(tmp_path):
memory_dir = tmp_path / "memories"
_write_skill_folder(
memory_dir,
"timestamp-default",
"Use when testing autoskill proposal timestamp defaults.",
"# Timestamp default\n",
)
first = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="timestamp-default",
cluster_hash="cluster-1",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Initial proposal.",
)
manifest_path = (
autoskill_proposals_dir(memory_dir) / "timestamp-default" / "manifest.json"
)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest.pop("created_at")
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
second = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="timestamp-default",
cluster_hash="cluster-1",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Resubmitted proposal.",
)
saved = json.loads(manifest_path.read_text(encoding="utf-8"))
proposal = list_skill_proposals(memory_dir)[0]
assert first["submitted"] is True
assert second["submitted"] is True
assert saved["created_at"] != "None"
assert saved["created_at"].endswith("Z")
assert proposal.created_at == saved["created_at"]
def test_approve_skill_proposal_is_scoped_to_recorded_workspace(
tmp_path,
monkeypatch,
):
memory_dir = tmp_path / "memories"
workspace_a = tmp_path / "workspace-a"
workspace_b = tmp_path / "workspace-b"
active_skills_dir = tmp_path / "active-skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", active_skills_dir)
workspace_a.mkdir()
workspace_b.mkdir()
_write_skill_folder(
memory_dir,
"workspace-owned",
"Use when validating workspace ownership for autoskills.",
"# Workspace owned\n",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="workspace-owned",
cluster_hash="cluster-workspace-a",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="This proposal belongs to workspace A.",
workspace_dir=workspace_a,
project_id="P-a",
)
assert proposal["submitted"] is True
assert list_skill_proposals(memory_dir, workspace_dir=workspace_b) == []
wrong_workspace = approve_skill_proposal(
memory_dir,
proposal["proposal_id"],
workspace_dir=workspace_b,
)
right_workspace = approve_skill_proposal(
memory_dir,
proposal["proposal_id"],
workspace_dir=workspace_a,
)
assert wrong_workspace["approved"] is False
assert not (workspace_b / "skills" / "workspace-owned").exists()
assert right_workspace["approved"] is True
assert (active_skills_dir / "workspace-owned" / "SKILL.md").exists()
def test_submit_autoskill_proposal_does_not_overwrite_other_workspace(tmp_path):
memory_dir = tmp_path / "memories"
workspace_a = tmp_path / "workspace-a"
workspace_b = tmp_path / "workspace-b"
workspace_a.mkdir()
workspace_b.mkdir()
_write_skill_folder(
memory_dir,
"shared-name",
"Use when testing cross-workspace proposal name collisions.",
"# Shared name\n",
)
first = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="shared-name",
cluster_hash="cluster-a",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Workspace A owns this pending proposal.",
workspace_dir=workspace_a,
project_id="P-a",
)
second = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="shared-name",
cluster_hash="cluster-b",
source_observation_ids=["O-4", "O-5", "O-6"],
rationale="Workspace B must not take over the same proposal id.",
workspace_dir=workspace_b,
project_id="P-b",
)
assert first["submitted"] is True
assert second["submitted"] is False
assert "another workspace" in second["error"]
assert list_skill_proposals(memory_dir, workspace_dir=workspace_b) == []
def test_submit_tool_reads_live_autoskill_mode_without_rebuild(
tmp_path,
monkeypatch,
):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config-home"))
monkeypatch.setattr(
"EvoScientist.config.settings.find_dotenv",
lambda *a, **k: str(tmp_path / ".env"),
)
monkeypatch.delenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE", raising=False)
save_config(
EvoScientistConfig(
memory_skill_synthesis_mode=MemorySkillSynthesisMode.REVIEW,
)
)
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
active_skills_dir = tmp_path / "active-skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", active_skills_dir)
workspace_dir.mkdir()
tool = create_submit_autoskill_proposal_tool(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
project_id="P-project",
)
_write_skill_folder(
memory_dir,
"review-mode-skill",
"Use when testing review mode autoskill submissions.",
"# Review mode skill\n",
)
review_payload = json.loads(
tool.run(
{
"skill_name": "review-mode-skill",
"cluster_hash": "cluster-review",
"source_observation_ids": ["O-1", "O-2", "O-3"],
"rationale": "Review mode should only stage this proposal.",
}
)
)
set_config_value("memory_skill_synthesis_mode", "auto")
_write_skill_folder(
memory_dir,
"auto-mode-skill",
"Use when testing auto mode autoskill submissions.",
"# Auto mode skill\n",
)
auto_payload = json.loads(
tool.run(
{
"skill_name": "auto-mode-skill",
"cluster_hash": "cluster-auto",
"source_observation_ids": ["O-4", "O-5", "O-6"],
"rationale": "Auto mode should promote this proposal.",
}
)
)
assert review_payload["status"] == "pending"
assert "auto_approval" not in review_payload
assert auto_payload["auto_approval"]["approved"] is True
assert (active_skills_dir / "auto-mode-skill" / "SKILL.md").exists()
def test_submit_autoskill_proposal_rejects_invalid_generated_folder(tmp_path):
memory_dir = tmp_path / "memories"
_write_skill_folder(
memory_dir,
"focused-validation",
"Use when validating code changes with staged checks.",
"# Focused validation\n\nTODO: fill this in.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="focused-validation",
cluster_hash="cluster-1",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Three observations describe the same staged validation practice.",
)
assert proposal["submitted"] is False
assert proposal["path"] == "/autoskill-proposals/focused-validation"
assert "TODO placeholders" in proposal["errors"][0]
assert pending_skill_proposal_count(memory_dir) == 0
def test_reject_skill_proposal_marks_processed(tmp_path):
memory_dir = tmp_path / "memories"
_write_skill_folder(
memory_dir,
"reject-me",
"Use when testing rejected proposals.",
"# Reject me\n",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reject-me",
cluster_hash="cluster-rejected",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Test rejection.",
)
rejected = reject_skill_proposal(memory_dir, proposal["proposal_id"])
assert rejected["rejected"] is True
assert list_skill_proposals(memory_dir)[0].status == "rejected"
assert (memory_dir / "autoskills" / "processed" / "cluster-rejected.json").exists()
class _FakeCrons:
def __init__(self):
self.rows: list[dict] = []
self.created: list[dict] = []
self.deleted: list[str] = []
self.searches: list[dict] = []
def search(self, **kwargs):
self.searches.append(kwargs)
return list(self.rows)
def create(self, **kwargs):
row = {
"cron_id": f"cron-{len(self.rows) + 1}",
"assistant_id": kwargs["assistant_id"],
"schedule": kwargs["schedule"],
"input": kwargs["input"],
"metadata": kwargs["metadata"],
"timezone": kwargs["timezone"],
"enabled": True,
}
self.rows.append(row)
self.created.append(row)
return row
def delete(self, cron_id: str):
self.deleted.append(cron_id)
self.rows = [row for row in self.rows if row["cron_id"] != cron_id]
class _AsyncFakeCrons:
def __init__(self):
self.searches: list[dict] = []
async def search(self, **kwargs):
self.searches.append(kwargs)
return [{"cron_id": "cron-async"}]
def test_alist_autoskill_schedules_uses_async_client_and_explicit_limit(monkeypatch):
crons = _AsyncFakeCrons()
client = SimpleNamespace(crons=crons)
monkeypatch.setattr("langgraph_sdk.get_client", lambda **_kwargs: client)
rows = asyncio.run(
alist_autoskill_schedules(
EvoScientistConfig(),
limit=3,
)
)
assert rows == [{"cron_id": "cron-async"}]
assert crons.searches == [
{
"metadata": {"run_kind": AUTOSKILL_RUN_KIND},
"limit": 3,
}
]
def test_reconcile_autoskill_schedule_creates_updates_and_disables(
tmp_path,
monkeypatch,
):
crons = _FakeCrons()
client = SimpleNamespace(crons=crons)
monkeypatch.setattr(
"EvoScientist.langgraph_dev.manager.is_langgraph_dev_running",
lambda **_kwargs: True,
)
monkeypatch.setattr("langgraph_sdk.get_sync_client", lambda **_kwargs: client)
cfg = EvoScientistConfig(
memory_skill_synthesis_enabled=True,
memory_skill_synthesis_cadence=MemorySkillSynthesisCadence.WEEKLY,
memory_skill_synthesis_time="03:00",
scheduler_default_timezone="UTC",
)
created = reconcile_autoskill_schedule(cfg, workspace_dir=tmp_path)
unchanged = reconcile_autoskill_schedule(cfg, workspace_dir=tmp_path)
updated = reconcile_autoskill_schedule(
EvoScientistConfig(
memory_skill_synthesis_enabled=True,
memory_skill_synthesis_cadence=MemorySkillSynthesisCadence.NIGHTLY,
memory_skill_synthesis_time="03:00",
scheduler_default_timezone="UTC",
),
workspace_dir=tmp_path,
)
disabled = reconcile_autoskill_schedule(
EvoScientistConfig(memory_skill_synthesis_enabled=False),
workspace_dir=tmp_path,
)
assert created["status"] == "created"
assert unchanged["status"] == "unchanged"
assert updated["status"] == "created"
assert disabled == {"status": "disabled", "deleted": 1}
assert crons.deleted == ["cron-1", "cron-1"]
assert crons.rows == []
assert created["schedule"] == "0 3 * * 0"
assert updated["schedule"] == "0 3 * * *"
assert created["cron_id"] == "cron-1"
assert all(
search["limit"] == AUTOSKILL_SCHEDULE_SEARCH_LIMIT for search in crons.searches
)
assert [row["assistant_id"] for row in crons.created] == [
AUTOSKILL_GRAPH_ID,
AUTOSKILL_GRAPH_ID,
]
+65
View File
@@ -12,6 +12,7 @@ from EvoScientist.backends import (
CustomSandboxBackend,
MemoryFilesystemBackend,
MergedSkillsBackend,
ReadOnlyFilesystemBackend,
convert_virtual_paths_in_command,
prepare_sandbox_command,
validate_command,
@@ -701,6 +702,27 @@ class TestVirtualMountResolution:
assert result[1] == paths.GLOBAL_SKILLS_DIR
assert result[2] == backends._BUILTIN_SKILLS_DIR
def test_merged_skills_read_only_primary_blocks_uploads(self, tmp_path):
user_dir = tmp_path / "user"
global_dir = tmp_path / "global"
builtin_dir = tmp_path / "builtin"
user_dir.mkdir()
global_dir.mkdir()
builtin_dir.mkdir()
backend = MergedSkillsBackend(
primary_dir=str(user_dir),
secondary_dir=str(builtin_dir),
global_dir=str(global_dir),
writable_primary=False,
)
responses = backend.upload_files([("/new-skill/SKILL.md", b"content")])
assert len(responses) == 1
assert responses[0].error is not None
assert "read-only" in responses[0].error
assert not (user_dir / "new-skill" / "SKILL.md").exists()
def test_execute_e2e_workspace_tier_skill(self, monkeypatch, tmp_path):
"""End-to-end: a skill in the workspace tier (USER_SKILLS_DIR) must
execute successfully. Regression guard: USER_SKILLS_DIR must be in
@@ -867,6 +889,16 @@ class TestMemoryFilesystemBackend:
assert not (tmp_path / "profile" / "NEW.md").exists()
assert not (tmp_path / "observations" / "projects" / "P-1" / "O-1.md").exists()
def test_read_only_backend_blocks_uploads(self, tmp_path):
backend = ReadOnlyFilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
responses = backend.upload_files([("/blocked.txt", b"blocked")])
assert len(responses) == 1
assert responses[0].error is not None
assert "read-only" in responses[0].error
assert not (tmp_path / "blocked.txt").exists()
def test_build_memory_agent_backend_routes_guarded_memories(self, tmp_path):
workspace = tmp_path / "workspace"
memories = tmp_path / "memories"
@@ -891,6 +923,39 @@ class TestMemoryFilesystemBackend:
assert blocked_write.error == MemoryFilesystemBackend._RAW_WRITE_ERROR
assert not (memories / "observations" / "global" / "O-1.md").exists()
def test_build_memory_worker_backend_allows_profile_edits_only(self, tmp_path):
workspace = tmp_path / "workspace"
memories = tmp_path / "memories"
profile = memories / "profile" / "USER_PROFILE.md"
workspace.mkdir()
profile.parent.mkdir(parents=True)
(workspace / "README.md").write_text("workspace text\n", encoding="utf-8")
profile.write_text("old profile\n", encoding="utf-8")
backend = backends.build_memory_worker_backend(
workspace_dir=workspace,
memory_dir=memories,
)
workspace_edit = backend.edit("/README.md", "workspace", "changed")
profile_edit = backend.edit(
"/memories/profile/USER_PROFILE.md",
"old profile",
"new profile",
)
uploads = backend.upload_files([("/created.txt", b"created")])
assert workspace_edit.error is not None
assert "read-only" in workspace_edit.error
assert (workspace / "README.md").read_text(encoding="utf-8") == (
"workspace text\n"
)
assert profile_edit.error is None
assert profile.read_text(encoding="utf-8") == "new profile\n"
assert uploads[0].error is not None
assert "read-only" in uploads[0].error
assert not (workspace / "created.txt").exists()
# === CustomSandboxBackend._resolve_path ===
+1
View File
@@ -36,6 +36,7 @@ def _make_config(
memory_observations_enabled=True,
memory_observation_writer=MemoryObservationWriter.ALL,
memory_workers_enabled=False,
memory_skill_synthesis_enabled=False,
provider="anthropic",
anthropic_auth_mode="api_key",
openai_auth_mode="api_key",
+54
View File
@@ -11,6 +11,8 @@ from EvoScientist.config import (
MemoryControls,
MemoryObservationTarget,
MemoryObservationWriter,
MemorySkillSynthesisCadence,
MemorySkillSynthesisMode,
apply_config_to_env,
get_config_dir,
get_config_path,
@@ -68,6 +70,10 @@ def temp_config_dir(tmp_path, monkeypatch):
"EVOSCIENTIST_MEMORY_OBSERVATIONS_ENABLED",
"EVOSCIENTIST_MEMORY_OBSERVATION_WRITER",
"EVOSCIENTIST_MEMORY_WORKERS_ENABLED",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_ENABLED",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME",
"EVOSCIENTIST_AUXILIARY_MODEL",
"EVOSCIENTIST_AUXILIARY_PROVIDER",
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE",
@@ -91,6 +97,10 @@ def clean_env(monkeypatch):
"EVOSCIENTIST_MEMORY_OBSERVATIONS_ENABLED",
"EVOSCIENTIST_MEMORY_OBSERVATION_WRITER",
"EVOSCIENTIST_MEMORY_WORKERS_ENABLED",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_ENABLED",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME",
"EVOSCIENTIST_AUXILIARY_MODEL",
"EVOSCIENTIST_AUXILIARY_PROVIDER",
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE",
@@ -125,6 +135,12 @@ class TestEvoScientistConfig:
assert config.memory_observations_enabled is True
assert config.memory_observation_writer == MemoryObservationWriter.ALL
assert config.memory_workers_enabled is True
assert config.memory_skill_synthesis_enabled is True
assert config.memory_skill_synthesis_mode == MemorySkillSynthesisMode.REVIEW
assert (
config.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.WEEKLY
)
assert config.memory_skill_synthesis_time == "03:00"
assert config.ollama_base_url == ""
assert config.channel_debug_tracing is False
assert config.imessage_enabled is False
@@ -398,6 +414,32 @@ class TestGetSetValues:
MemoryObservationTarget.SUBAGENT_WORKER
)
def test_set_memory_skill_synthesis_values_validate(
self, temp_config_dir, clean_env
):
save_config(EvoScientistConfig())
assert set_config_value("memory_skill_synthesis_mode", "auto") is True
assert get_config_value("memory_skill_synthesis_mode") == "auto"
assert set_config_value("memory_skill_synthesis_mode", "always") is False
assert get_config_value("memory_skill_synthesis_mode") == "auto"
assert set_config_value("memory_skill_synthesis_cadence", "nightly") is True
assert get_config_value("memory_skill_synthesis_cadence") == "nightly"
assert set_config_value("memory_skill_synthesis_cadence", "hourly") is False
assert get_config_value("memory_skill_synthesis_cadence") == "nightly"
assert set_config_value("memory_skill_synthesis_time", "3:05") is True
assert get_config_value("memory_skill_synthesis_time") == "03:05"
assert set_config_value("memory_skill_synthesis_time", "24:00") is False
assert get_config_value("memory_skill_synthesis_time") == "03:05"
loaded = load_config()
assert loaded.memory_skill_synthesis_mode == MemorySkillSynthesisMode.AUTO
assert (
loaded.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.NIGHTLY
)
def test_list_config(self, temp_config_dir, clean_env):
"""Test listing all config values."""
config = EvoScientistConfig(provider="openai", model="gpt-4o")
@@ -746,11 +788,23 @@ def test_scheduler_config_defaults_and_env(monkeypatch):
c = EvoScientistConfig()
assert c.enable_scheduler is True
assert c.scheduler_default_timezone == ""
assert c.memory_skill_synthesis_enabled is True
assert c.memory_skill_synthesis_mode == MemorySkillSynthesisMode.REVIEW
assert c.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.WEEKLY
assert c.memory_skill_synthesis_time == "03:00"
monkeypatch.setenv("EVOSCIENTIST_ENABLE_SCHEDULER", "false")
eff = get_effective_config({})
assert eff.enable_scheduler is False
monkeypatch.setenv("EVOSCIENTIST_SCHEDULER_DEFAULT_TIMEZONE", "America/New_York")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_ENABLED", "false")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE", "auto")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE", "monthly")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME", "4:30")
eff2 = get_effective_config({})
assert eff2.scheduler_default_timezone == "America/New_York"
assert eff2.memory_skill_synthesis_enabled is False
assert eff2.memory_skill_synthesis_mode == MemorySkillSynthesisMode.AUTO
assert eff2.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.MONTHLY
assert eff2.memory_skill_synthesis_time == "04:30"
+2
View File
@@ -245,6 +245,7 @@ class TestEnsureLanggraphDev:
cfg.enable_async_subagents = False
cfg.memory_workers_enabled = False
cfg.enable_scheduler = False # scheduler crons also require the backend
cfg.memory_skill_synthesis_enabled = False
cfg.langgraph_dev_port = 6174
cfg.langgraph_dev_file_persistence = True
with (
@@ -271,6 +272,7 @@ class TestEnsureLanggraphDev:
cfg = EvoScientistConfig()
cfg.enable_async_subagents = False
cfg.memory_workers_enabled = False
cfg.memory_skill_synthesis_enabled = False
cfg.enable_scheduler = True
assert manager.needs_langgraph_dev(cfg) is True
cfg.enable_scheduler = False
+131
View File
@@ -42,6 +42,8 @@ from EvoScientist.memory.observations import (
create_read_memory_tool,
create_search_observations_tool,
link_observation_files,
list_observation_documents,
read_observation_document,
read_observation_file,
read_observation_id_from_path,
record_observation_file,
@@ -676,6 +678,97 @@ def test_malformed_observation_frontmatter_is_skipped(tmp_path):
assert worker_activity.snapshot_observation_relations(memories) == frozenset()
def test_legacy_observation_source_without_session_id_still_reads(tmp_path):
memories = tmp_path / "memories"
global_dir = memories / "observations" / "global"
global_dir.mkdir(parents=True)
legacy = global_dir / "O-legacy.md"
legacy.write_text(
"---\n"
"id: O-legacy\n"
"created_at: 2026-01-01T00:00:00Z\n"
"summary: Legacy observation without session id.\n"
"memory_type: procedural\n"
"scope: global\n"
"source:\n"
" type: turn\n"
" agent: EvoScientist\n"
"---\n"
"Legacy body text.\n",
encoding="utf-8",
)
documents = list_observation_documents(
memory_dir=memories,
project_id="P-project",
)
read = read_observation_file(
memory_dir=memories,
project_id="P-project",
observation_id="O-legacy",
)
hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="Legacy body",
)
assert [document.observation_id for document in documents] == ["O-legacy"]
assert read is not None
assert read["observation_id"] == "O-legacy"
assert hits[0]["observation_id"] == "O-legacy"
def test_unquoted_naive_yaml_timestamp_does_not_claim_utc(tmp_path):
observation = tmp_path / "O-naive.md"
observation.write_text(
"---\n"
"id: O-naive\n"
"created_at: 2026-01-01 12:30:00\n"
"summary: Legacy observation with naive YAML timestamp.\n"
"memory_type: procedural\n"
"scope: global\n"
"source:\n"
" type: turn\n"
" agent: EvoScientist\n"
" session_id: thread-1\n"
"---\n"
"Legacy body text.\n",
encoding="utf-8",
)
document = read_observation_document(observation)
assert document is not None
metadata, _body = document
assert metadata.created_at == "2026-01-01T12:30:00"
def test_unquoted_aware_yaml_timestamp_normalizes_to_utc(tmp_path):
observation = tmp_path / "O-aware.md"
observation.write_text(
"---\n"
"id: O-aware\n"
"created_at: 2026-01-01 12:30:00+02:00\n"
"summary: Legacy observation with aware YAML timestamp.\n"
"memory_type: procedural\n"
"scope: global\n"
"source:\n"
" type: turn\n"
" agent: EvoScientist\n"
" session_id: thread-1\n"
"---\n"
"Legacy body text.\n",
encoding="utf-8",
)
document = read_observation_document(observation)
assert document is not None
metadata, _body = document
assert metadata.created_at == "2026-01-01T10:30:00Z"
def test_link_observation_files_keeps_supersedes_directional(tmp_path):
memories = tmp_path / "memories"
source = record_observation_file(
@@ -2007,6 +2100,44 @@ def test_memory_worker_abort_queues_written_observations_for_linking(tmp_path):
assert status.observations_recorded == 1
def test_memory_worker_watcher_start_failure_queues_written_observations_for_linking(
tmp_path,
):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_aborted=coordinator.record_worker_aborted,
)
worker_run = _memory_worker_run(
thread_id="worker-thread",
run_id="run-1",
workspace_dir=str(workspace_dir),
)
assert hooks.on_before_run is not None
assert hooks.on_started is not None
assert hooks.on_watcher_start_failed is not None
hooks.on_before_run(worker_run.thread_id)
hooks.on_started(worker_run)
observation = _record_test_observation(memory_dir)
hooks.on_watcher_start_failed(worker_run)
assert launched == [
_linker_context(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
observation_ids=(observation["observation_id"],),
)
]
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.observations_recorded == 1
def test_observation_linker_launch_request_encodes_batch_context(tmp_path):
context = _linker_context(
memory_dir=tmp_path / "memories",