feat: instance manifest, tool profiles, metrics, and budget enforcement
Land HARNESS-WP-0001 T01/T02/T04/T05: extend ADR-005 schedule.yml with harness fields, named tool-profile registry, ADR-004 metrics writes, and BudgetTracker wiring. CLI gains validate/profiles; task-file path kept.
This commit is contained in:
parent
16f5c29c08
commit
4144eba160
17 changed files with 1387 additions and 60 deletions
301
agent_harness/manifest.py
Normal file
301
agent_harness/manifest.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"""Instance manifest — declarative agent instances in consuming repos.
|
||||
|
||||
Extends ADR-005 `.kaizen/schedule.yml` with harness fields. kaizen-agentic
|
||||
owns the base keys (version, timezone, agents.<name>.{cadence,cron,enabled});
|
||||
agent-harness owns the extension keys (lane, tool_profile, budget, harness,
|
||||
optional blueprint). Same file — no sibling manifest unless kaizen owners
|
||||
later prefer separation.
|
||||
|
||||
See docs/instance-manifest.md for the full contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from agent_harness.profiles import PROFILES, UnknownToolProfileError, get_profile
|
||||
|
||||
MANIFEST_RELATIVE_PATH = Path(".kaizen") / "schedule.yml"
|
||||
MANIFEST_VERSION = "1"
|
||||
VALID_CADENCES = ("daily", "weekly", "monthly")
|
||||
VALID_LANES = ("green", "blue")
|
||||
# Package major this harness release implements (0.x → 0).
|
||||
HARNESS_MAJOR = 0
|
||||
|
||||
|
||||
class ManifestError(ValueError):
|
||||
"""Structural failure loading or parsing a manifest."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentInstance:
|
||||
"""One agent instance declared in a consuming repo."""
|
||||
|
||||
name: str
|
||||
cadence: str
|
||||
enabled: bool = True
|
||||
cron: str | None = None
|
||||
# Harness extension fields (optional in file; defaults applied at load).
|
||||
blueprint: str | None = None # defaults to name
|
||||
lane: str | None = None
|
||||
tool_profile: str | None = None
|
||||
budget: int | None = None # tokens per run; None = unlimited for this field
|
||||
harness: int | None = None # pinned major; None inherits repo-level
|
||||
|
||||
@property
|
||||
def blueprint_name(self) -> str:
|
||||
return self.blueprint or self.name
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceManifest:
|
||||
"""Parsed instance manifest (schedule.yml + harness extensions)."""
|
||||
|
||||
version: str
|
||||
timezone: str | None
|
||||
harness: int | None # repo-level pinned harness major
|
||||
agents: list[AgentInstance] = field(default_factory=list)
|
||||
source_path: Path | None = None
|
||||
|
||||
def agent_for(self, name: str) -> AgentInstance | None:
|
||||
for agent in self.agents:
|
||||
if agent.name == name:
|
||||
return agent
|
||||
return None
|
||||
|
||||
def enabled_agents(self) -> list[AgentInstance]:
|
||||
return [a for a in self.agents if a.enabled]
|
||||
|
||||
def effective_harness(self, agent: AgentInstance) -> int | None:
|
||||
if agent.harness is not None:
|
||||
return agent.harness
|
||||
return self.harness
|
||||
|
||||
|
||||
def manifest_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / MANIFEST_RELATIVE_PATH
|
||||
|
||||
|
||||
def _parse_budget(raw: Any, agent_name: str) -> int | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, bool) or not isinstance(raw, int):
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}': budget must be a positive integer (tokens/run)"
|
||||
)
|
||||
if raw <= 0:
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}': budget must be a positive integer (tokens/run)"
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _parse_harness_major(raw: Any, context: str) -> int | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, bool) or not isinstance(raw, int):
|
||||
raise ManifestError(f"{context}: harness must be a non-negative integer major")
|
||||
if raw < 0:
|
||||
raise ManifestError(f"{context}: harness must be a non-negative integer major")
|
||||
return raw
|
||||
|
||||
|
||||
def parse_manifest(data: Any, source_path: Path | None = None) -> InstanceManifest:
|
||||
"""Parse a raw mapping into InstanceManifest. Structural errors raise."""
|
||||
if not isinstance(data, dict):
|
||||
raise ManifestError("manifest must be a YAML mapping at the top level")
|
||||
|
||||
version = data.get("version")
|
||||
if version is None:
|
||||
raise ManifestError("missing required key: version")
|
||||
version = str(version)
|
||||
|
||||
timezone = data.get("timezone")
|
||||
if timezone is not None and not isinstance(timezone, str):
|
||||
raise ManifestError("timezone must be a string")
|
||||
|
||||
repo_harness = _parse_harness_major(data.get("harness"), "top-level")
|
||||
|
||||
agents_raw = data.get("agents", {})
|
||||
if not isinstance(agents_raw, dict):
|
||||
raise ManifestError("agents must be a mapping of agent-name -> settings")
|
||||
|
||||
agents: list[AgentInstance] = []
|
||||
for name, settings in agents_raw.items():
|
||||
if settings is None:
|
||||
settings = {}
|
||||
if not isinstance(settings, dict):
|
||||
raise ManifestError(f"agent '{name}' settings must be a mapping")
|
||||
|
||||
cron = settings.get("cron")
|
||||
if cron is not None and not isinstance(cron, str):
|
||||
raise ManifestError(f"agent '{name}' cron must be a string")
|
||||
|
||||
blueprint = settings.get("blueprint")
|
||||
if blueprint is not None and not isinstance(blueprint, str):
|
||||
raise ManifestError(f"agent '{name}' blueprint must be a string")
|
||||
|
||||
lane = settings.get("lane")
|
||||
if lane is not None and not isinstance(lane, str):
|
||||
raise ManifestError(f"agent '{name}' lane must be a string")
|
||||
|
||||
tool_profile = settings.get("tool_profile")
|
||||
if tool_profile is not None and not isinstance(tool_profile, str):
|
||||
raise ManifestError(f"agent '{name}' tool_profile must be a string")
|
||||
|
||||
agents.append(
|
||||
AgentInstance(
|
||||
name=str(name),
|
||||
cadence=str(settings.get("cadence", "")),
|
||||
enabled=bool(settings.get("enabled", True)),
|
||||
cron=cron,
|
||||
blueprint=blueprint,
|
||||
lane=lane,
|
||||
tool_profile=tool_profile,
|
||||
budget=_parse_budget(settings.get("budget"), str(name)),
|
||||
harness=_parse_harness_major(
|
||||
settings.get("harness"), f"agent '{name}'"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return InstanceManifest(
|
||||
version=version,
|
||||
timezone=timezone,
|
||||
harness=repo_harness,
|
||||
agents=agents,
|
||||
source_path=source_path,
|
||||
)
|
||||
|
||||
|
||||
def load_manifest(path: Path | str) -> InstanceManifest:
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise ManifestError(f"manifest not found: {path}")
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as exc:
|
||||
raise ManifestError(f"invalid YAML in {path}: {exc}") from exc
|
||||
return parse_manifest(raw, source_path=path)
|
||||
|
||||
|
||||
def load_manifest_for_repo(project_root: Path | str) -> InstanceManifest:
|
||||
return load_manifest(manifest_path(Path(project_root)))
|
||||
|
||||
|
||||
def validate_manifest(
|
||||
manifest: InstanceManifest,
|
||||
*,
|
||||
require_harness_fields: bool = False,
|
||||
) -> list[str]:
|
||||
"""Return human-readable validation errors (empty == valid).
|
||||
|
||||
When *require_harness_fields* is False (default), ADR-005-only manifests
|
||||
are valid: harness extension fields are checked only when present. When
|
||||
True, every enabled agent must declare lane, tool_profile, and an
|
||||
effective harness pin (repo- or agent-level).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if manifest.version != MANIFEST_VERSION:
|
||||
errors.append(
|
||||
f"unsupported version '{manifest.version}' "
|
||||
f"(expected '{MANIFEST_VERSION}')"
|
||||
)
|
||||
|
||||
if not manifest.agents:
|
||||
errors.append("no agents declared under 'agents:'")
|
||||
|
||||
seen: set[str] = set()
|
||||
for agent in manifest.agents:
|
||||
if agent.name in seen:
|
||||
errors.append(f"duplicate agent entry: {agent.name}")
|
||||
seen.add(agent.name)
|
||||
|
||||
if agent.cadence not in VALID_CADENCES:
|
||||
errors.append(
|
||||
f"agent '{agent.name}': invalid cadence '{agent.cadence}' "
|
||||
f"(expected one of {', '.join(VALID_CADENCES)})"
|
||||
)
|
||||
|
||||
if agent.lane is not None and agent.lane not in VALID_LANES:
|
||||
errors.append(
|
||||
f"agent '{agent.name}': invalid lane '{agent.lane}' "
|
||||
f"(expected one of {', '.join(VALID_LANES)})"
|
||||
)
|
||||
|
||||
if agent.tool_profile is not None:
|
||||
try:
|
||||
profile = get_profile(agent.tool_profile)
|
||||
except UnknownToolProfileError as exc:
|
||||
errors.append(f"agent '{agent.name}': {exc}")
|
||||
else:
|
||||
if agent.lane is not None and agent.lane != profile.lane:
|
||||
errors.append(
|
||||
f"agent '{agent.name}': lane '{agent.lane}' does not "
|
||||
f"match tool_profile '{profile.name}' (lane={profile.lane})"
|
||||
)
|
||||
|
||||
if require_harness_fields and agent.enabled:
|
||||
if agent.lane is None:
|
||||
errors.append(
|
||||
f"agent '{agent.name}': lane is required for harness runs "
|
||||
f"(green|blue)"
|
||||
)
|
||||
if agent.tool_profile is None:
|
||||
errors.append(
|
||||
f"agent '{agent.name}': tool_profile is required for harness runs"
|
||||
)
|
||||
if manifest.effective_harness(agent) is None:
|
||||
errors.append(
|
||||
f"agent '{agent.name}': harness major pin required "
|
||||
f"(set top-level harness: or agents.{agent.name}.harness)"
|
||||
)
|
||||
|
||||
effective = manifest.effective_harness(agent)
|
||||
if effective is not None and effective != HARNESS_MAJOR:
|
||||
# Soft pin check: warn-style error so validate fails closed for
|
||||
# mismatched majors (instances must upgrade deliberately).
|
||||
errors.append(
|
||||
f"agent '{agent.name}': pinned harness major {effective} "
|
||||
f"does not match this runtime (major {HARNESS_MAJOR})"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def resolve_run_policy(
|
||||
project_root: Path | str,
|
||||
agent_name: str,
|
||||
*,
|
||||
default_profile: str = "green-commit-only",
|
||||
) -> tuple[str, int | None, str | None]:
|
||||
"""Resolve (tool_profile_name, budget_tokens, lane) for a run.
|
||||
|
||||
If the repo has no manifest or no entry for *agent_name*, returns the
|
||||
default profile with no budget. If the entry names an unknown profile,
|
||||
raises UnknownToolProfileError (refuse to run).
|
||||
"""
|
||||
root = Path(project_root)
|
||||
path = manifest_path(root)
|
||||
if not path.exists():
|
||||
get_profile(default_profile) # validate default exists
|
||||
return default_profile, None, None
|
||||
|
||||
manifest = load_manifest(path)
|
||||
instance = manifest.agent_for(agent_name)
|
||||
if instance is None or not instance.enabled:
|
||||
get_profile(default_profile)
|
||||
return default_profile, None, None
|
||||
|
||||
profile_name = instance.tool_profile or default_profile
|
||||
get_profile(profile_name) # raises if unknown
|
||||
return profile_name, instance.budget, instance.lane
|
||||
|
||||
|
||||
def known_profile_names() -> list[str]:
|
||||
return sorted(PROFILES)
|
||||
Loading…
Add table
Add a link
Reference in a new issue