feat(agents): manifest plumbing + GET /agents/registry (ADR-0014 step 3)
Each agent now exports a module-level MANIFEST declaring id, version, pref_schema, required_consents, ttl_sec, and silenced_in_contexts. The registry surfaces both the agent and its manifest, and rejects on mismatch so the two cannot drift. ml/serving exposes GET /agents/registry; services/api proxies it as GET /api/agents/registry with a 60s in-process cache so admin pageviews don't hammer upstream. Failures aren't cached. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
70
ml/agents/manifest.py
Normal file
70
ml/agents/manifest.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Agent manifest dataclass (ADR-0014).
|
||||
|
||||
A manifest is the single point of registration for an agent. The orchestrator,
|
||||
admin UI, registry endpoint, and inference framework all read from it. Adding
|
||||
an agent is adding a manifest + agent class — never editing a list elsewhere.
|
||||
|
||||
The manifest lives next to the agent code (each agent module in ml/agents/
|
||||
exposes a module-level `MANIFEST` constant). The registry surfaces both the
|
||||
agent instance and its manifest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InferredParam:
|
||||
"""One auto-inferred preference key (#111-#116).
|
||||
|
||||
The inference framework owns scheduling, history reads, persistence, and
|
||||
cold-start. Each agent's `inferred_params` list declares what to infer and
|
||||
how, leaving each agent to implement just `infer()`.
|
||||
"""
|
||||
key: str # e.g. 'quietStart'
|
||||
ttl_sec: int # how often to recompute
|
||||
cold_start_default: Any # value used until min_history is met
|
||||
min_history: int # event count threshold
|
||||
# Pure function: given a UserHistory snapshot, return the inferred value.
|
||||
# Typed as a generic callable here; concrete signature lives in the framework.
|
||||
infer: Callable[[Any], Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentManifest:
|
||||
"""Declarative description of an agent — see ADR-0014 §1."""
|
||||
id: str # 'time-of-day'
|
||||
version: str # bump invalidates cached outputs + inferences
|
||||
description: str # one-line human summary for admin UI
|
||||
pref_schema: dict # JSON Schema for user-tunable knobs
|
||||
context_schema: list[str] # signals it reads, e.g. ['todoist.tasks']
|
||||
required_consents: list[str] # ['data:todoist', 'agent:time-of-day']
|
||||
output_contract: dict # snippet shape (free text + optional tags)
|
||||
ttl_sec: int # snippet freshness for agent_outputs
|
||||
silenced_in_contexts: list[str] = field(default_factory=list) # active context names that suppress this agent
|
||||
inferred_params: list[InferredParam] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialise for the registry endpoint. `inferred_params` drops `infer`
|
||||
(callable) since the wire format only carries metadata."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"version": self.version,
|
||||
"description": self.description,
|
||||
"pref_schema": self.pref_schema,
|
||||
"context_schema": self.context_schema,
|
||||
"required_consents": self.required_consents,
|
||||
"output_contract": self.output_contract,
|
||||
"ttl_sec": self.ttl_sec,
|
||||
"silenced_in_contexts": list(self.silenced_in_contexts),
|
||||
"inferred_params": [
|
||||
{
|
||||
"key": p.key,
|
||||
"ttl_sec": p.ttl_sec,
|
||||
"cold_start_default": p.cold_start_default,
|
||||
"min_history": p.min_history,
|
||||
}
|
||||
for p in self.inferred_params
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user