feat: Add evaluation hooks, skill adaptation and team pipeline config

- Add EvaluationHook for post-execution agent evaluation
- Add SkillAdaptationHook for dynamic skill adaptation
- Add team/ directory with team coordination logic
- Add TEAM_PIPELINE.yaml for smoke_fullstack pipeline config
- Update RuntimeView, TraderView and RuntimeSettingsPanel UI
- Add runtimeApi and websocket services
- Add runtime_state.json to smoke_fullstack state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:52:12 +08:00
parent f4a2b7f3af
commit 4b5ac86b83
87 changed files with 5042 additions and 744 deletions

View File

@@ -3,14 +3,29 @@
from pathlib import Path
import shutil
from typing import Dict, Iterable, List
import tempfile
import zipfile
from typing import Any, Dict, Iterable, Iterator, List, Optional, Set
from urllib.parse import urlparse
from urllib.request import urlretrieve
import yaml
from backend.agents.agent_workspace import load_agent_workspace_config
from backend.agents.skill_metadata import SkillMetadata, parse_skill_metadata
from backend.agents.skill_loader import validate_skill
from backend.config.bootstrap_config import get_bootstrap_config_for_run
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
WATCHDOG_AVAILABLE = True
except ImportError:
WATCHDOG_AVAILABLE = False
Observer = None
FileSystemEventHandler = object
FileSystemEvent = object # type: ignore[misc,assignment]
class SkillsManager:
"""Sync named skills into a run-scoped active skills workspace."""
@@ -178,6 +193,57 @@ class SkillsManager:
)
return skill_dir
def install_external_skill_for_agent(
self,
config_name: str,
agent_id: str,
source: str,
*,
skill_name: str | None = None,
activate: bool = True,
) -> Dict[str, object]:
"""
Install an external skill into one agent's local skill space.
Supports:
- local skill directory containing SKILL.md
- local zip archive containing one skill directory
- http(s) URL to zip archive
"""
source_path = self._resolve_external_source_path(source)
skill_dir = self._resolve_external_skill_dir(source_path)
metadata = parse_skill_metadata(skill_dir, source="external")
final_name = _normalize_skill_name(skill_name or metadata.skill_name or skill_dir.name)
if not final_name:
raise ValueError("Could not determine skill name from external source.")
target_dir = self.get_agent_local_root(config_name, agent_id) / final_name
target_dir.parent.mkdir(parents=True, exist_ok=True)
if target_dir.exists():
shutil.rmtree(target_dir)
shutil.copytree(skill_dir, target_dir)
validation = validate_skill(target_dir)
if not validation.get("valid", False):
shutil.rmtree(target_dir, ignore_errors=True)
raise ValueError(
"Installed skill is invalid: "
+ "; ".join(validation.get("errors", []))
)
if activate:
self.update_agent_skill_overrides(
config_name=config_name,
agent_id=agent_id,
enable=[final_name],
)
return {
"skill_name": final_name,
"target_dir": str(target_dir),
"activated": activate,
"warnings": validation.get("warnings", []),
}
def update_agent_local_skill(
self,
config_name: str,
@@ -239,6 +305,58 @@ class SkillsManager:
"content": body,
}
def _resolve_external_source_path(self, source: str) -> Path:
"""Resolve source into a local path; download URL when needed."""
parsed = urlparse(source)
if parsed.scheme in {"http", "https"}:
suffix = Path(parsed.path).suffix or ".zip"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
temp_path = Path(tmp.name)
urlretrieve(source, temp_path)
return temp_path
return Path(source).expanduser().resolve()
def _resolve_external_skill_dir(self, source_path: Path) -> Path:
"""Resolve external source path to a skill directory containing SKILL.md."""
if not source_path.exists():
raise FileNotFoundError(f"Source does not exist: {source_path}")
if source_path.is_dir():
if (source_path / "SKILL.md").exists():
return source_path
children = [
item for item in source_path.iterdir()
if item.is_dir() and (item / "SKILL.md").exists()
]
if len(children) == 1:
return children[0]
raise ValueError(
"Source directory must contain SKILL.md "
"or exactly one child directory containing SKILL.md."
)
if source_path.suffix.lower() != ".zip":
raise ValueError("External source file must be a .zip archive.")
temp_root = Path(tempfile.mkdtemp(prefix="external_skill_"))
with zipfile.ZipFile(source_path, "r") as archive:
archive.extractall(temp_root)
candidates = [
item.parent
for item in temp_root.rglob("SKILL.md")
if item.is_file()
]
unique = []
for item in candidates:
if item not in unique:
unique.append(item)
if len(unique) != 1:
raise ValueError(
"Zip archive must contain exactly one skill directory with SKILL.md."
)
return unique[0]
def update_agent_skill_overrides(
self,
config_name: str,
@@ -500,6 +618,7 @@ class SkillsManager:
self,
config_name: str,
agent_defaults: Dict[str, Iterable[str]],
auto_reload: bool = False,
) -> Dict[str, List[Path]]:
"""Resolve all agent skills into per-agent installed/active workspaces."""
resolved: Dict[str, List[str]] = {}
@@ -574,6 +693,9 @@ class SkillsManager:
skill_sources=disabled_sources,
)
if auto_reload:
self.watch_active_skills(config_name, agent_defaults)
return active_map
def _is_shared_skill(self, skill_name: str) -> bool:
@@ -583,6 +705,72 @@ class SkillsManager:
return False
return True
def watch_active_skills(
self,
config_name: str,
agent_defaults: Dict[str, Iterable[str]],
callback: Optional[Any] = None,
) -> "_SkillsWatcher":
"""Start file system monitoring on active skill directories.
Args:
config_name: Run configuration name.
agent_defaults: Map of agent_id -> default skill names.
callback: Optional callable invoked on file changes with
(changed_paths: List[Path]).
Returns:
A _SkillsWatcher instance. Call .stop() to halt monitoring.
"""
if not WATCHDOG_AVAILABLE:
raise ImportError(
"watchdog is required for watch_active_skills. "
"Install it with: pip install watchdog"
)
watched_paths: List[Path] = []
for agent_id in agent_defaults:
active_root = self.get_agent_active_root(config_name, agent_id)
if active_root.exists():
watched_paths.append(active_root)
local_root = self.get_agent_local_root(config_name, agent_id)
if local_root.exists():
watched_paths.append(local_root)
handler = _SkillsChangeHandler(watched_paths, callback)
observer = Observer()
for path in watched_paths:
observer.schedule(handler, str(path), recursive=True)
observer.start()
return _SkillsWatcher(observer, handler)
def reload_skills_if_changed(
self,
config_name: str,
agent_defaults: Dict[str, Iterable[str]],
) -> Dict[str, List[Path]]:
"""Check for file changes and reload active skills if needed.
Args:
config_name: Run configuration name.
agent_defaults: Map of agent_id -> default skill names.
Returns:
Map of agent_id -> list of reloaded skill paths, or empty dict
if no changes were detected.
"""
changed = self._pending_skill_changes.get(config_name)
if not changed:
return {}
self._pending_skill_changes[config_name] = set()
return self.prepare_active_skills(config_name, agent_defaults)
# -------------------------------------------------------------------------
# Internal change-tracking state (populated by _SkillsChangeHandler)
# -------------------------------------------------------------------------
_pending_skill_changes: Dict[str, Set[Path]] = {}
def _resolve_disabled_skill_names(
self,
config_name: str,
@@ -613,6 +801,53 @@ class SkillsManager:
]
class _SkillsWatcher:
"""Handle returned by watch_active_skills; call .stop() to halt monitoring."""
def __init__(self, observer: Observer, handler: "_SkillsChangeHandler") -> None:
self._observer = observer
self._handler = handler
def stop(self) -> None:
"""Stop the file system observer."""
self._observer.stop()
self._observer.join()
class _SkillsChangeHandler(FileSystemEventHandler):
"""Collects file-change events on skill directories."""
def __init__(
self,
watched_paths: List[Path],
callback: Optional[Any] = None,
) -> None:
super().__init__()
self._watched_paths = watched_paths
self._callback = callback
def on_any_event(self, event: FileSystemEvent) -> None:
if event.is_directory:
return
src_path = Path(event.src_path)
for watched in self._watched_paths:
if src_path.is_relative_to(watched):
SkillsManager._pending_skill_changes.setdefault(
self._run_id_from_path(src_path), set()
).add(src_path)
if self._callback:
self._callback([src_path])
break
@staticmethod
def _run_id_from_path(path: Path) -> str:
"""Infer config_name from a path like runs/{config_name}/skills/active/..."""
parts = path.parts
for i, part in enumerate(parts):
if part == "runs" and i + 1 < len(parts):
return parts[i + 1]
return "default"
def _dedupe_preserve_order(items: Iterable[str]) -> List[str]:
result: List[str] = []
for item in items: