Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be distributed and installed with the skills CLI (skills add INDigitalStudio/skills --skill sssf). - Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the visualizer app source) into sssf/. - Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/, Python bytecode, and the machine-specific repos.json. - Make the skill location-independent: install.py now stamps the skill's real path into the stamped justfile's skill_dir (replacing the hardcoded ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI installed the skill. - Update cookbooks to use <skill>/scripts/... instead of the hardcoded path, and document the skills CLI install command. - Update the repo README with install instructions.
142 lines
7.3 KiB
Python
142 lines
7.3 KiB
Python
"""The Run object: config + adw_id + agent_map + tracer + console, bound once.
|
|
|
|
`run.phase(PhaseParams(...))` is the ONE phase primitive — a context manager
|
|
for all three kinds (engineer, agent, code). Success must be earned: every
|
|
phase defaults to fail; only a clean exit flips it (agent phases additionally
|
|
require a parsed envelope + green gates, enforced inside ph.call).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
from . import agents, git_helper
|
|
from .console import Console
|
|
from .data_types import AgentCall, EnvelopeBase, EventRecord, Phase, PhaseParams
|
|
from .utils import ensure_dir, now_iso
|
|
|
|
|
|
class PhaseHandle:
|
|
def __init__(self, run: "Run", phase: Phase):
|
|
self.run = run
|
|
self.phase = phase
|
|
|
|
def log(self, **payload) -> None:
|
|
self.run.tracer.event(EventRecord(adw_id=self.run.adw_id,
|
|
phase_id=self.phase.phase_id,
|
|
type="log", name=self.phase.params.name,
|
|
payload=payload))
|
|
self.run.console.note(", ".join(f"{k}: {v}" for k, v in payload.items()))
|
|
if self.phase.params.kind == "engineer" and "input" in payload:
|
|
self.run.tracer.session_request(self.run.adw_id, str(payload["input"]))
|
|
|
|
def call(self, call: AgentCall) -> EnvelopeBase:
|
|
if self.phase.params.kind != "agent":
|
|
raise RuntimeError("ph.call() is only valid inside an agent phase")
|
|
return agents.execute(self.run, self.phase, call)
|
|
|
|
|
|
class Run:
|
|
def __init__(self, cfg, adw_id: str, tracer, engineer: str):
|
|
self.cfg = cfg
|
|
self.adw_id = adw_id
|
|
self.tracer = tracer
|
|
self.console = Console(tracer, adw_id)
|
|
self.engineer = engineer
|
|
self.phases: list[Phase] = []
|
|
self.tokens = 0
|
|
self.cost = 0.0
|
|
self._seq = tracer.max_phase_seq(adw_id) # a joined run continues the sequence
|
|
self.repo_root = git_helper.repo_root() # where every agent is spawned to work
|
|
self.session_dir = ensure_dir(Path(cfg.defaults.data_dir) / "sessions" / adw_id)
|
|
self.context_handoff_dir = ensure_dir(self.session_dir / "context_handoff")
|
|
self._agent_map_path = self.session_dir / "agent_map.json"
|
|
self.agent_map: dict = (json.loads(self._agent_map_path.read_text())
|
|
if self._agent_map_path.exists() else {})
|
|
|
|
# ── agent map (adw_id -> per-agent coding-agent session ids) ────────────
|
|
def save_agent_map(self, agent: str, entry: dict) -> None:
|
|
self.agent_map[agent] = entry
|
|
self._agent_map_path.write_text(json.dumps(self.agent_map, indent=2))
|
|
|
|
# ── usage (run totals mirror what the tracer accumulates in sqlite) ─────
|
|
def add_usage(self, tokens: int, cost: float) -> None:
|
|
self.tokens += tokens
|
|
self.cost += cost
|
|
self.tracer.session_add_usage(self.adw_id, tokens, cost)
|
|
|
|
# ── the phase primitive ─────────────────────────────────────────────────
|
|
@contextmanager
|
|
def phase(self, params: PhaseParams):
|
|
self._seq += 1
|
|
phase = Phase(phase_id=f"{self.adw_id}_{self._seq:02d}_{params.name}",
|
|
adw_id=self.adw_id, seq=self._seq, params=params,
|
|
status="running", started_at=now_iso())
|
|
self.phases.append(phase)
|
|
self.tracer.phase_upsert(phase)
|
|
self.tracer.event(EventRecord(adw_id=self.adw_id, phase_id=phase.phase_id,
|
|
type="phase_start", name=params.name,
|
|
payload={"kind": params.kind, "owner": params.owner,
|
|
"description": params.description}))
|
|
self.console.phase_started(phase)
|
|
clock = time.monotonic()
|
|
try:
|
|
yield PhaseHandle(self, phase)
|
|
except BaseException as error:
|
|
phase.status = "fail" # success must be earned
|
|
phase.error = str(error)[:1000]
|
|
phase.ended_at = now_iso()
|
|
self.tracer.event(EventRecord(adw_id=self.adw_id, phase_id=phase.phase_id,
|
|
type="error", name=params.name,
|
|
payload={"error": phase.error}))
|
|
self.tracer.event(EventRecord(adw_id=self.adw_id, phase_id=phase.phase_id,
|
|
type="phase_end", name=params.name,
|
|
payload={"status": "fail"}))
|
|
self.tracer.phase_upsert(phase)
|
|
self.tracer.session_finish(self.adw_id, ok=False)
|
|
self.console.phase_ended(phase, time.monotonic() - clock)
|
|
self.console.session_finished(False, self.tokens, self.cost,
|
|
self.cfg.observability.db)
|
|
raise
|
|
else:
|
|
phase.status = "success"
|
|
phase.ended_at = now_iso()
|
|
self.tracer.event(EventRecord(adw_id=self.adw_id, phase_id=phase.phase_id,
|
|
type="phase_end", name=params.name,
|
|
payload={"status": "success"}))
|
|
self.tracer.phase_upsert(phase)
|
|
self.console.phase_ended(phase, time.monotonic() - clock)
|
|
|
|
# ── run outcome ─────────────────────────────────────────────────────────
|
|
def finish(self, accepted: bool = True, reason: str = "") -> int:
|
|
"""Finalize the run and return its exit code. Call this exactly once.
|
|
|
|
Two criteria, not one. Every phase must have passed, AND the ADW's own
|
|
acceptance test must hold. They are different questions on purpose: a
|
|
test phase that ran the suite did its job even when the suite came back
|
|
red, so the PHASE succeeds while the RUN must not.
|
|
|
|
This replaces a `succeeded` property that answered only the first
|
|
question — and, being a property with side effects, wrote the session
|
|
status and printed the banner before the caller's `and test.passed` was
|
|
ever evaluated. A run whose suite never passed was recorded green in the
|
|
db, on the terminal, and in the UI while exiting 1. Anyone reading the
|
|
trace saw success; only a CI job checking `$?` saw the truth. One call
|
|
now settles the db, the banner, and the exit code together, so the three
|
|
cannot disagree.
|
|
"""
|
|
phases_ok = bool(self.phases) and all(p.status == "success" for p in self.phases)
|
|
ok = phases_ok and accepted
|
|
if phases_ok and not accepted:
|
|
note = reason or "the run's acceptance criterion was not met"
|
|
self.tracer.event(EventRecord(
|
|
adw_id=self.adw_id,
|
|
phase_id=self.phases[-1].phase_id if self.phases else "",
|
|
type="error", name="not_accepted", payload={"reason": note}))
|
|
self.console.note(f"not accepted: {note}")
|
|
self.tracer.session_finish(self.adw_id, ok=ok)
|
|
self.console.session_finished(ok, self.tokens, self.cost, self.cfg.observability.db)
|
|
return 0 if ok else 1
|