feat(memory): autoskills (#319)
This commit is contained in:
+132
-4
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}]}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"]}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user