From b126e7aaed89d10b3650ee2c0c4d7f8bf2b6ad9d Mon Sep 17 00:00:00 2001 From: alvis Date: Tue, 21 Jul 2026 12:19:46 +0000 Subject: [PATCH] Add agent registry: persona + prompt + memory + tool scope + trust (kb#134, A2A-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per DESIGN-a2a-agents.md v2.1 §2, §5, §5b: an agent is a persona, not a queue. Sibling of kb#133's model-registry.yaml, same load/get/CLI shape. - agent-registry.yaml: populates adolf (trusted, scoped core tools), claude-coder (trusted, implementer), torgash (sandboxed, marketplace- only, target state - not yet built), researcher (sandboxed/untrusted- input, target state), kimi-endpoint/local-small-endpoint (trivial model-agents from #133), and alvis/elizaveta as first-class human agents with trust=human, own inboxes, own private memory bank targets (adolf-alvis/adolf-elizaveta/adolf-shared - split itself is kb#153). Also carries trust_classes with numeric rank, a runtimes: section for Claude Code (kb#133 explicitly excluded it from model-registry.yaml), and memory_bank_policy documenting §5b's hard rules. - agent_registry.py: effective_card() is what makes "backbone swap is one field" true rather than aspirational - tier/cost_class/lifecycle are never stored statically on an agent, they're resolved at read time from `backbone` via model-registry.yaml or runtimes:. Also trust_rank() and can_reach_vault() (kb#147's vault-access DECIDED rule as a checkable function, not just a comment). Several Cards (torgash, researcher, elizaveta's KB identity, the split memory banks) describe target state for things not yet built/migrated - flagged with status/note fields rather than implied live, matching the task's scope (registry only, no runtime migration). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014t8Qg9gi7H7HtT8MncoXAB --- openai/agent-registry.yaml | 295 +++++++++++++++++++++++++++++++++++++ openai/agent_registry.py | 207 ++++++++++++++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 openai/agent-registry.yaml create mode 100755 openai/agent_registry.py diff --git a/openai/agent-registry.yaml b/openai/agent-registry.yaml new file mode 100644 index 0000000..13832fe --- /dev/null +++ b/openai/agent-registry.yaml @@ -0,0 +1,295 @@ +# Agent registry — agents are personas, not queues. +# +# Per DESIGN-a2a-agents.md v2.1 §2, §5, §5b (commit 714a9ca7), kanboard task +# #134 (A2A-2), sibling of #133's model-registry.yaml. An agent is +# (identity, Card, Policy, State): {persona/system prompt, memory bank(s), +# tool scope, trust class, preferred tier, current backbone}. +# +# THE ONE-FIELD-BACKBONE-SWAP MECHANISM: an agent's `backbone:` field is the +# ONLY thing here that names a concrete model/runtime. Everything a Card +# would otherwise duplicate from the model plane (tier the backbone actually +# delivers, cost_class, availability a(t), context window) is NOT stored +# statically on the agent — it is resolved at read time by dereferencing +# `backbone` into model-registry.yaml (via model_registry.py) or, for +# non-metered flat-subscription runtimes model-registry.yaml deliberately +# excludes (kb#133: "Claude Code ... belongs in the agent registry"), into +# the `runtimes:` section below. See agent_registry.py:effective_card(). +# This is what makes "switching a backbone is one field" true rather than +# aspirational: edit `backbone: kimi` -> `backbone: local-small` and the +# derived tier/cost/a(t) change with it, with nothing else to keep in sync. +# +# `preferred_tier` is a POLICY field (what this persona asks for), distinct +# from the backbone's actual delivered tier (a FACT, resolved above) — they +# usually agree but don't have to (e.g. a large-preferring agent temporarily +# pinned to a small backbone during a quota outage). +# +# v2.1: personas + Cards live in git, deployed to runtimes — never +# live-edited in volumes (kb#156). Several agents below still point at a +# live-volume persona (adolf's SOUL.md) because #156 (git-deploy pipeline) +# hasn't landed yet; this registry records that fact, it doesn't fix it. +# +# Trust class is on every Card (§5) and is what kb#140 (capability routing) +# and kb#147 (grant enforcement / vault access) consume — see +# routing_consumption: at the bottom. Several Cards below (torgash, +# researcher, elizaveta's KB identity) describe TARGET state for agents/ +# accounts that don't exist yet; each is flagged `status: target` / +# `note:` rather than silently implying they're live. Building them is +# explicitly out of kb#134's scope (registry only). +# +# Read with agent_registry.py (same directory): load_registry(), +# get_agent(), effective_card(), trust_rank(). + +schema_version: 1 + +# ── trust classes (§5) ────────────────────────────────────────────────── +# "human > trusted > sandboxed > untrusted". Numeric rank lets routing/grant +# code do `>=` comparisons instead of string-matching an ordered list. +trust_classes: + human: { rank: 3, note: "vault access yes; not MCP-tool-scoped, IS the reasoning" } + trusted: { rank: 2, note: "vault access yes (DECIDED, kb#147); outward actions per ask-first rules" } + sandboxed: { rank: 1, note: "no vault, no outward sends; scoped MCP allowlist; KB access project-scoped" } + untrusted: { rank: 0, note: "anything ingesting the open web; its OUTPUTS are tainted, not just its access restricted" } + +# ── runtimes ───────────────────────────────────────────────────────────── +# Backbones deliberately OUTSIDE model-registry.yaml's `models:` list. +# kb#133 excluded Claude Code explicitly: "a flat-subscription runtime, not +# a metered API deployment — it belongs in the agent registry (#134), not +# here." This is that home. Same lookup contract as a model-registry entry +# (id, tier, cost_class, lifecycle, context_tokens) so agent_registry.py's +# backbone resolver can treat the two sources uniformly. +runtimes: + - id: claude-code-cli + role: "Claude Code CLI — flat Anthropic subscription runtime (this session's own kind)" + tier: large + context_tokens: 200000 # current Sonnet/Opus family context window; re-verify on model upgrades + cost_class: subscription + lifecycle: always-on # gated by Claude Code's own usage windows, not GPU/quota probes + quota_probe: "claude-usage (kanboard/bin/claude-usage); orchestration/quota-gating rules documented in kanboard/CLAUDE.md" + metered: false + opt_in_required: false + +# ── agents ─────────────────────────────────────────────────────────────── +agents: + + # ── Adolf — proactive auditor / personal assistant ────────────────────── + - id: adolf + capabilities: [matrix-chat, task-triage, proactive-monitoring, memory-recall, cron-scheduling, browser] + persona: + role: "proactive auditor / personal assistant, talks to alvis and elizaveta over Matrix" + trivial: false + prompt_source: + current: "container adolf:/home/node/.openclaw/workspace/SOUL.md — lives in the adolf-state Docker VOLUME, live-editable, zero git history. This is the kb#156 migration-debt state; recorded here per kb#134's brief, not migrated." + companion_files: ["AGENTS.md", "IDENTITY.md", "TOOLS.md", "USER.md", "HEARTBEAT.md"] # same workspace, same volume, same debt + target: "git-controlled path once kb#156 lands (e.g. openai/personas/adolf/SOUL.md), deployed read-only into the volume" + trust_class: trusted + preferred_tier: large + backbone: kimi # resolves via model-registry.yaml (adolf-llm Kimi-CLI wrapper endpoint). Swap this ONE field to rebackbone Adolf. + tool_allowlist: + mcp_servers: [hindsight, openclaw-tools, kanboard, marketplace, agap] + gateway_tools: [cron, nodes, browser] # openclaw.json gateway.tools.allow, live 2026-07-21 + vault_access: true # trusted-only per §5 DECIDED; reaches vw_* via the `agap` MCP server + note: > + "scoped core tools" per kb#134's brief — this IS openai/openclaw.json's + live mcp.servers block, not a narrower aspirational allowlist. Per-tool + (not per-server) scoping + LiteLLM virtual-key budgets are kb#147's job; + this field is the input #147 consumes. + memory: + banks: + - { id: adolf-alvis, role: private, interlocutor: alvis } + - { id: adolf-elizaveta, role: private, interlocutor: elizaveta } + - { id: adolf-shared, role: shared, interlocutor: household } + current_state: > + NOT split yet. A single live bank "adolf" (hindsight MCP, + http://hindsight:8888/mcp/adolf/, 269+ facts) serves every + interlocutor today with no per-human isolation — the exact defect + kb#153 exists to fix (depends on this registry existing first). + The three banks above are kb#153's target, not current fact. + kb_identity: { username: adolf, user_id: 3 } + availability_note: "a(t) inherited from backbone at read time (kimi: quota-gated, ~60msg/5h ~300/wk — see model-registry.yaml)" + + # ── claude-coder — the Claude Code loop as an ordinary consumer ───────── + - id: claude-coder + capabilities: [coding, kanboard-dispatch, git, infra-ops, code-review] + persona: + role: "implementer — pulls complex coding tasks from the fabric (design §2 theorem 5, 'never special')" + trivial: false + prompt_source: + current: "git-native, layered: ~/.claude/CLAUDE.md (global user memory) + kanboard/CLAUDE.md (canonical kb orchestration ruleset) + per-repo CLAUDE.md files (e.g. agap_git/CLAUDE.md)" + note: "No single SOUL.md — persona is these CLAUDE.md conventions, already git-controlled. No kb#156 debt for this agent." + trust_class: trusted + preferred_tier: large + backbone: claude-code-cli # resolves via runtimes: above (kb#133 exclusion), NOT model-registry.yaml + tool_allowlist: + mcp_servers: [kanboard, agap] + native_tools: [Bash, Read, Edit, Write, Agent, WebFetch, WebSearch, git] + vault_access: true # trusted + note: > + Widest-scoped agent by design (this session's own tool surface) — + gated by ask-first rules on outward/destructive actions rather than + MCP allowlisting. Per kb#147, a real virtual-key budget still applies. + memory: + banks: [] + model: "session (ephemeral, per invocation) + repo state (git history, CLAUDE.md files, kanboard task/comment history) — no persistent Hindsight bank" + kb_identity: { username: claude, user_id: 2 } + availability_note: "a(t) inherited from backbone at read time (claude-code-cli: always-on, gated by claude-usage windows)" + + # ── torgash — marketplace analyst (sandboxed) ─────────────────────────── + - id: torgash + capabilities: [price-comparison, marketplace-search, cart-ops, product-recommendations] + persona: + role: "marketplace analyst — price comparison / shopping across Ozon, Yandex Market, etc." + trivial: false + prompt_source: + current: null + note: "NOT yet stood up as a running persona — this Card is target state per design §2's agent table and the A2A design-review plan. Building the runtime is out of kb#134's scope (registry only); flagging as follow-up work." + trust_class: sandboxed + preferred_tier: small + backbone: local-small + tool_allowlist: + mcp_servers: [marketplace] + vault_access: false # sandboxed — hard rule §5, no exceptions + outward_sends: false + note: "scoped to mcp__marketplace__* tools only; no gitea/ha/zabbix/radicale, no kanboard project outside its own." + memory: + banks: [{ id: torgash, role: private }] + current_state: "bank not yet created — target state, same as the persona itself" + kb_identity: + username: null + note: "no Kanboard account provisioned yet; §5 calls for KB access project-scoped to a dedicated project once created — gap, not in kb#134's scope." + availability_note: "a(t) inherited from backbone at read time (local-small: always-on, VRAM-bound)" + + # ── researcher — autonomous, sandboxed/untrusted-input loop ───────────── + - id: researcher + capabilities: [web-research, synthesis, low-priority-background-loop] + persona: + role: "autonomous researcher — low-priority self-submitting loop (design §6, §8)" + trivial: false + prompt_source: { current: null, note: "not yet built; target-state Card, same caveat as torgash." } + trust_class: sandboxed + trust_note: > + Ingests the open web, so its OUTPUTS are tainted regardless of its own + sandboxed access level (the untrusted-INPUT rule, §5). Promotion of + tainted output into a trusted agent's memory or into any action + requires a gate — initially a task to alvis's inbox. + preferred_tier: small + backbone: local-small # escalates to large via always-ask policy (§5) for synthesis — never silent retry-on-bigger-model + tool_allowlist: + mcp_servers: [] # target: a scoped web-search/fetch surface once built + native_tools: [WebSearch, WebFetch] + vault_access: false + outward_sends: false + memory: + banks: [{ id: researcher, role: private }] + current_state: "bank not yet created — target state" + kb_identity: + username: null + note: "needs its own KB project(s) per §5 ('researcher gets its own KB project(s)') — not yet created, gap outside kb#134's scope." + availability_note: "a(t) inherited from backbone at read time (local-small: always-on, VRAM-bound)" + + # ── model-agents — trivial personas (from kb#133) ─────────────────────── + # Design §2 table row 1: "LLM endpoint (kimi, gemma3:4b, ...) | persona: + # trivial (identity) | memory: none". These are direct-address targets + # (router mode target=agent-id) for a caller that wants THIS backbone + # specifically, bypassing any persona/tool-scope layer — distinct from + # e.g. `adolf`, which happens to use the `kimi` backbone today but adds a + # persona, memory, and tool scope on top. Only the two chat-capable + # models get an agent entry; bge-m3/tei-reranker are non-chat sidecars in + # model-registry.yaml, not addressable A2A targets. + - id: kimi-endpoint + capabilities: [raw-completion] + persona: { role: "trivial identity — the kimi backbone exposed directly, no persona layer", trivial: true, prompt_source: { current: null } } + trust_class: untrusted # a bare model endpoint makes no trust decisions itself; the caller's trust class governs what reaches it + preferred_tier: large + backbone: kimi + tool_allowlist: { mcp_servers: [], native_tools: [], vault_access: false } + memory: { banks: [], model: "none (trivial persona)" } + kb_identity: { username: null } + + - id: local-small-endpoint + capabilities: [raw-completion] + persona: { role: "trivial identity — the local-small backbone exposed directly, no persona layer", trivial: true, prompt_source: { current: null } } + trust_class: untrusted + preferred_tier: small + backbone: local-small + tool_allowlist: { mcp_servers: [], native_tools: [], vault_access: false } + memory: { banks: [], model: "none (trivial persona)" } + kb_identity: { username: null } + + # ── humans — first-class agents (§2, §5b) ─────────────────────────────── + # "The human being an agent is not a metaphor": approval gates and + # decisions are ordinary tasks submitted to a human's inbox, which IS the + # Kanboard column/assignment he already processes. No `backbone` — humans + # ARE the reasoning, not a resolvable model. + - id: alvis + capabilities: [approval, decision, escalation-target] + persona: { role: "human — primary user/owner of Agap" } + trust_class: human + preferred_tier: null + backbone: null + tool_allowlist: null # n/a — outranks `trusted`, not MCP-scoped + memory: + banks: + - { id: adolf-alvis, role: private } + - { id: adolf-shared, role: shared } + current_state: "today's single unsplit 'adolf' bank mixes alvis + elizaveta content; split pending kb#153" + matrix_id: "@admin:mtx.alogins.net" + kb_identity: + username: admin + user_id: 1 + note: "assumed == alvis (sole non-bot app-admin account); the Kanboard user record's email field is unpopulated so this can't be confirmed via API — verify by hand if it's ever ambiguous." + inbox: "tasks assigned to Kanboard user 'admin' (id 1) across projects — his approval/escalation inbox (design §2)" + availability: "a(t) = waking hours (informal, no fixed function yet); vacation mode sets a(t)=0 and parks his inbox per §5b" + + - id: elizaveta + capabilities: [approval, decision, escalation-target] + persona: { role: "human — household member" } + trust_class: human + preferred_tier: null + backbone: null + tool_allowlist: null + memory: + banks: + - { id: adolf-elizaveta, role: private } + - { id: adolf-shared, role: shared } + current_state: "not yet split out of the single 'adolf' bank — kb#153, flagged urgent-ish there since she is on Adolf's Matrix allowlist TODAY with a shared, unpartitioned bank." + matrix_id: "@elizaveta:mtx.alogins.net" + kb_identity: + username: null + note: "no Kanboard account provisioned for her yet — her only inbox today is the Matrix DM channel (Adolf's dm.allowFrom), not a KB column. Gap / candidate follow-up task, out of kb#134's scope." + inbox: "none in Kanboard yet (see kb_identity note above); Matrix DM is the only channel today" + +# ── memory bank policy (§5b) ───────────────────────────────────────────── +# Hard rules the hindsight-memory plugin's recall/retain hooks must enforce +# once kb#153 implements bank selection by interlocutor. Registry mirror of +# model-registry.yaml's gpu_residency_policy: the parameters a consumer +# reads, not the enforcement logic itself. +memory_bank_policy: + hard_rules: + - "content from one human's conversations must never surface to another human (correctness property, not preference)" + - "promotion private -> shared happens only by the owning human's explicit action or an approval task — never automatically" + - "recall is interlocutor-scoped: the hindsight-memory plugin selects the bank by interlocutor identity (Matrix sender)" + - "sandboxed agents (torgash, researcher) read at most the shared bank, never any private bank" + banks: + - { id: adolf-alvis, owner: alvis, role: private, status: target, note: "kb#153 not yet done" } + - { id: adolf-elizaveta, owner: elizaveta, role: private, status: target, note: "kb#153 not yet done" } + - { id: adolf-shared, owner: household, role: shared, status: target, note: "kb#153 not yet done" } + - { id: adolf, owner: null, role: legacy, status: live-today, note: "single unsplit bank every interlocutor currently reads/writes; superseded by the three rows above once kb#153 lands" } + - { id: torgash, owner: torgash, role: private, status: target, note: "agent not yet built" } + - { id: researcher, owner: researcher, role: private, status: target, note: "agent not yet built" } + +# ── how downstream tasks consume this file ──────────────────────────────── +# Documents the contract kb#140 (routing) and kb#147 (grant enforcement) +# build against, mirroring model-registry.yaml's `routing:` section. +routing_consumption: + trust_gate: > + kb#147: a task requiring vault access may target only an agent whose + trust_classes[...].rank >= trust_classes.trusted.rank (i.e. trusted or + human). Sandboxed/untrusted agents' tool_allowlist.vault_access is + always false by construction above — kb#147's job is to make that + provably true at the MCP/LiteLLM enforcement points, not just on paper. + capability_routing: > + kb#140: given a task's required capabilities + trust constraint, the + router filters agents by capabilities ⊆ agent.capabilities, trust rank + >= required, and current a(t) == 1 (resolved via agent_registry.py's + effective_card(), which dereferences `backbone` into model-registry.yaml + or `runtimes:` above) — no submitter-side hardcoded agent id needed. diff --git a/openai/agent_registry.py b/openai/agent_registry.py new file mode 100755 index 0000000..e7e2870 --- /dev/null +++ b/openai/agent_registry.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""agent_registry — reads agent-registry.yaml (kb#134, A2A-2). + +Sibling of model_registry.py (kb#133): same load/get/CLI shape, same +philosophy ("registry is data, not logic"). This module's one piece of +real logic is effective_card() — the mechanism that makes "switching a +backbone is one field" true rather than aspirational (see the header +comment in agent-registry.yaml): an agent's static fields (persona, trust +class, tool scope, memory banks, preferred tier) never duplicate what the +CURRENT backbone provides (tier, cost_class, availability a(t), context +window). Those are resolved at read time by dereferencing `backbone` into +model-registry.yaml (via model_registry.get_model) or, for runtimes +model-registry.yaml deliberately excludes (kb#133: Claude Code), into this +file's own `runtimes:` section. + +Usage (library): + from agent_registry import load_registry, get_agent, effective_card, trust_rank + reg = load_registry() + adolf = get_agent(reg, "adolf") + card = effective_card(reg, "adolf") # -> persona/trust/tools/memory + resolved tier/cost/a(t) + trust_rank(reg, "torgash") < trust_rank(reg, "adolf") # sandboxed < trusted + +Usage (CLI, for manual verification): + ./agent_registry.py list + ./agent_registry.py get --id adolf + ./agent_registry.py effective-card --id adolf + ./agent_registry.py trust-rank --id torgash + ./agent_registry.py can-reach-vault --id torgash # kb#147 sanity check +""" + +import argparse +import json +import os +import sys + +import yaml + +import model_registry as mr + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_REGISTRY_PATH = os.path.join(HERE, "agent-registry.yaml") + + +class AgentRegistryError(Exception): + pass + + +def load_registry(path=None): + """Load and lightly validate agent-registry.yaml.""" + path = path or DEFAULT_REGISTRY_PATH + with open(path) as f: + reg = yaml.safe_load(f) + if not reg or "agents" not in reg: + raise AgentRegistryError(f"{path}: missing top-level 'agents' list") + ids = [a["id"] for a in reg["agents"]] + if len(ids) != len(set(ids)): + raise AgentRegistryError(f"{path}: duplicate agent ids in {ids}") + runtime_ids = [r["id"] for r in reg.get("runtimes", [])] + if len(runtime_ids) != len(set(runtime_ids)): + raise AgentRegistryError(f"{path}: duplicate runtime ids in {runtime_ids}") + return reg + + +def get_agent(registry, agent_id): + for a in registry["agents"]: + if a["id"] == agent_id: + return a + raise AgentRegistryError(f"unknown agent id: {agent_id!r}") + + +def get_runtime(registry, runtime_id): + for r in registry.get("runtimes", []): + if r["id"] == runtime_id: + return r + raise AgentRegistryError(f"unknown runtime id: {runtime_id!r}") + + +# --------------------------------------------------------------------------- +# trust_rank — §5 "human > trusted > sandboxed > untrusted" as a comparable +# integer, for kb#140 (routing) / kb#147 (grant enforcement) to use directly +# instead of re-deriving an ordering from the string. +# --------------------------------------------------------------------------- + +def trust_rank(registry, agent_id): + a = get_agent(registry, agent_id) + classes = registry.get("trust_classes", {}) + cls = a["trust_class"] + if cls not in classes: + raise AgentRegistryError(f"{agent_id}: unknown trust_class {cls!r} (have: {sorted(classes)})") + return classes[cls]["rank"] + + +def can_reach_vault(registry, agent_id): + """kb#147 sanity check: vault access = trusted-or-human only (DECIDED). + Cross-checks the agent's declared tool_allowlist.vault_access flag + against its trust rank, so a registry typo (vault_access: true on a + sandboxed agent) is a raised error, not a silent enforcement gap.""" + a = get_agent(registry, agent_id) + classes = registry.get("trust_classes", {}) + trusted_rank = classes.get("trusted", {}).get("rank") + declared = ((a.get("tool_allowlist") or {}).get("vault_access")) if a.get("tool_allowlist") else False + rank = trust_rank(registry, agent_id) + if declared and rank < trusted_rank: + raise AgentRegistryError( + f"{agent_id}: tool_allowlist.vault_access=true but trust_class=" + f"{a['trust_class']!r} (rank {rank}) < trusted (rank {trusted_rank}) — " + "registry inconsistency, fix before this is load-bearing for kb#147" + ) + return bool(declared) and rank >= trusted_rank + + +# --------------------------------------------------------------------------- +# effective_card — the full A2A Agent Card: static agent fields merged with +# the CURRENT backbone's resolved tier/cost_class/a(t)/context_tokens. +# --------------------------------------------------------------------------- + +def _resolve_backbone(registry, backbone_id, model_registry=None): + """backbone can point into either this file's runtimes: (flat-subscription + runtimes model-registry.yaml deliberately excludes, kb#133) or into + model-registry.yaml's models: (everything else). Try runtimes first — + it's the smaller, local list.""" + try: + return "runtime", get_runtime(registry, backbone_id) + except AgentRegistryError: + pass + model_registry = model_registry if model_registry is not None else mr.load_registry() + return "model", mr.get_model(model_registry, backbone_id) + + +def effective_card(registry, agent_id, model_registry=None): + """Return the full A2A Agent Card for `agent_id`: its own persona/trust/ + tools/memory fields plus tier/cost_class/context_tokens/lifecycle + resolved from whatever `backbone` currently names. Humans and trivial + endpoints with backbone=None get resolved_backbone=None — there's + nothing to dereference.""" + a = get_agent(registry, agent_id) + card = dict(a) # shallow copy; don't mutate the loaded registry + backbone_id = a.get("backbone") + if backbone_id is None: + card["resolved_backbone"] = None + return card + + source, backbone = _resolve_backbone(registry, backbone_id, model_registry) + card["resolved_backbone"] = { + "source": source, # "runtime" | "model" — which file it came from + "id": backbone_id, + "tier": backbone["tier"], + "cost_class": backbone["cost_class"], + "lifecycle": backbone["lifecycle"], + "context_tokens": backbone.get("context_tokens"), + "metered": backbone.get("metered", False), + } + return card + + +# --------------------------------------------------------------------------- +# CLI — manual verification only, not part of the library contract. +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--registry", default=None, help="path to agent-registry.yaml (default: sibling file)") + ap.add_argument("--model-registry", default=None, help="path to model-registry.yaml (default: sibling file)") + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("get") + p.add_argument("--id", required=True) + + p = sub.add_parser("effective-card") + p.add_argument("--id", required=True) + + p = sub.add_parser("trust-rank") + p.add_argument("--id", required=True) + + p = sub.add_parser("can-reach-vault") + p.add_argument("--id", required=True) + + sub.add_parser("list") + + args = ap.parse_args() + reg = load_registry(args.registry) + model_reg = mr.load_registry(args.model_registry) if args.model_registry else None + + try: + if args.cmd == "get": + print(json.dumps(get_agent(reg, args.id), indent=2)) + elif args.cmd == "effective-card": + print(json.dumps(effective_card(reg, args.id, model_reg), indent=2)) + elif args.cmd == "trust-rank": + print(trust_rank(reg, args.id)) + elif args.cmd == "can-reach-vault": + ok = can_reach_vault(reg, args.id) + print(json.dumps({"id": args.id, "can_reach_vault": ok})) + sys.exit(0 if ok else 1) + elif args.cmd == "list": + for a in reg["agents"]: + backbone = a.get("backbone") or "-" + print(f"{a['id']:22} trust={a['trust_class']:9} " + f"tier={str(a.get('preferred_tier')):6} backbone={backbone:16} " + f"role={a['persona']['role']}") + except AgentRegistryError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(2) + + +if __name__ == "__main__": + main()