resolve_run_policy returns blueprint for persona prepare; add tenant onboard helper script. Marks HARNESS-WP-0001-T07 done after binky onboarding handoff.
301 lines
10 KiB
Python
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)
|