rein-aharness/agent_harness/manifest.py
tegwick 154e535695 feat: blueprint-aware policy resolve for multi-instance tenants (T07)
resolve_run_policy returns blueprint for persona prepare; add tenant
onboard helper script. Marks HARNESS-WP-0001-T07 done after binky
onboarding handoff.
2026-07-18 11:07:10 +02:00

301 lines
10 KiB
Python

"""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, str]:
"""Resolve (tool_profile_name, budget_tokens, lane, blueprint) for a run.
If the repo has no manifest or no entry for *agent_name*, returns the
default profile with no budget and blueprint=agent_name. 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, agent_name
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, agent_name
profile_name = instance.tool_profile or default_profile
get_profile(profile_name) # raises if unknown
return profile_name, instance.budget, instance.lane, instance.blueprint_name
def known_profile_names() -> list[str]:
return sorted(PROFILES)