Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions agent/src/registry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Agent asset registry client + reference grammar (#246).

The agent is a *read-only* consumer of the registry: it receives already-resolved
assets in its task payload and, where it needs to look one up directly, talks to
the substrate through the ``RegistryClient`` port (never a raw AWS SDK client).

Public surface:
- ``parse_ref`` / ``ParsedRef`` (``ref``) — the strict ``registry://`` grammar,
mirrored byte-for-byte by ``cdk/src/handlers/shared/registry/ref.ts`` and the
``contracts/registry-resolution/`` parity corpus.

See ``docs/design/REGISTRY.md`` and ``ISSUE_246_AGENTCORE_FINDINGS.md``.
"""
186 changes: 186 additions & 0 deletions agent/src/registry/agentcore_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""Read-side AgentCore implementation of the ``RegistryClient`` port (#246).

The agent only reads: ``get_record`` and ``resolve``. This is the one Python file
that talks to the AgentCore control plane (via boto3) — everything upstream uses
the port, so a substrate swap is confined here. Mirrors the read half of
``cdk/src/handlers/shared/registry/agentcore-client.ts``.
"""

from __future__ import annotations

import base64
import json
import re
from typing import TYPE_CHECKING, Any

import yaml

from registry.client import RegistryResolutionError, ResolvedAsset
from registry.resolver import select_highest

if TYPE_CHECKING:
from registry.ref import ParsedRef

_NAME_SEP = "/"
# Option-A record name is `kind/namespace/name`; the name may itself contain `/`.
_NAME_MIN_PARTS = 2
# The reverse-DNS key under which ABCA runtime config rides in a native `_meta`
# block (matches RUNTIME_META_KEY in registry/types.ts).
_RUNTIME_META_KEY = "dev.abca.runtime"
# Frontmatter key carrying the runtime payload (JSON) in a native AGENT_SKILLS
# SKILL.md — mirrors SKILL_RUNTIME_FM_KEY in registry/agentcore-client.ts.
_SKILL_RUNTIME_FM_KEY = "x-abca-runtime"
# Match the whole frontmatter block between the first ---/--- pair. We parse that
# block as one YAML document (not a per-line regex) so a caller-controlled
# `description` containing a newline cannot inject a shadowing runtime key
# (#246 review B1/B2) — mirrors parseSkillFrontmatter in agentcore-client.ts.
_SKILL_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
_RESOLVABLE_STATUSES = ("APPROVED", "DEPRECATED")


class AgentCoreRegistryClient:
"""Read-only registry access backed by AgentCore."""

def __init__(self, registry_id: str, client: Any) -> None:
# ``client`` is a boto3 ``bedrock-agentcore-control`` client, injected so
# the agent's scoped-session helper (aws_session) owns credential wiring.
self._registry_id = registry_id
self._client = client

# --- name (Option A) decode -------------------------------------------------

@staticmethod
def _decode_name(record_name: str) -> tuple[str, str, str]:
parts = record_name.split(_NAME_SEP)
kind = parts[0] if parts else ""
namespace = parts[1] if len(parts) > 1 else ""
name = _NAME_SEP.join(parts[_NAME_MIN_PARTS:]) if len(parts) > _NAME_MIN_PARTS else ""
return kind, namespace, name

@staticmethod
def _id_from_arn(arn: str) -> str:
return arn.split("/")[-1] if "/" in arn else arn

# --- record extraction ------------------------------------------------------

def _extract_runtime(self, raw: dict[str, Any]) -> dict[str, Any]:
descriptors = raw.get("descriptors", {}) or {}
descriptor_type = raw.get("descriptorType")
if descriptor_type == "CUSTOM":
body = json.loads(descriptors.get("custom", {}).get("inlineContent", "{}"))
return body.get("runtime", {})
if descriptor_type == "AGENT_SKILLS":
# SKILL.md is Markdown frontmatter, not JSON — recover the runtime
# from the `x-abca-runtime` frontmatter key (mirrors the TS adapter).
skill_md = (
descriptors.get("agentSkills", {}).get("skillMd", {}).get("inlineContent", "")
)
m = _SKILL_FRONTMATTER_RE.search(skill_md)
if not m:
return {}
try:
fm = yaml.safe_load(m.group(1))
except yaml.YAMLError:
return {}
if not isinstance(fm, dict):
return {}
raw_value = fm.get(_SKILL_RUNTIME_FM_KEY)
if not isinstance(raw_value, str):
return {}
# Legacy form: raw JSON (YAML already unwrapped its single-quoting, so
# the value starts with `{`). New form: base64-encoded JSON.
if raw_value.lstrip().startswith("{"):
return json.loads(raw_value)
return json.loads(base64.b64decode(raw_value).decode("utf-8"))
# MCP: JSON server.json with the runtime in a `_meta` block.
inline = descriptors.get("mcp", {}).get("server", {}).get("inlineContent") or "{}"
body = json.loads(inline)
return body.get("_meta", {}).get(_RUNTIME_META_KEY, {})

def _list_records(self, kind: str, namespace: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
next_token: str | None = None
while True:
kwargs: dict[str, Any] = {"registryId": self._registry_id, "maxResults": 50}
if next_token:
kwargs["nextToken"] = next_token
page = self._client.list_registry_records(**kwargs)
for summary in page.get("registryRecords", []) or []:
dkind, dns, _ = self._decode_name(summary.get("name", ""))
if dkind != kind or dns != namespace:
continue
record_id = (
self._id_from_arn(summary["recordArn"])
if summary.get("recordArn")
else summary.get("recordId")
)
if not record_id:
continue
full = self._client.get_registry_record(
registryId=self._registry_id, recordId=record_id
)
out.append(full)
next_token = page.get("nextToken")
if not next_token:
break
return out

# --- port surface -----------------------------------------------------------

def get_record(
self, kind: str, namespace: str, name: str, version: str
) -> dict[str, Any] | None:
for raw in self._list_records(kind, namespace):
_, _, dname = self._decode_name(raw.get("name", ""))
if dname == name and raw.get("recordVersion") == version:
return raw
return None

def resolve(self, ref: ParsedRef) -> ResolvedAsset:
ref_str = f"registry://{ref.kind}/{ref.namespace}/{ref.name}@{ref.constraint.raw}"
records = self._list_records(ref.kind, ref.namespace)
candidates = [
r
for r in records
if self._decode_name(r.get("name", ""))[2] == ref.name
and r.get("status") in _RESOLVABLE_STATUSES
]
by_version = {r.get("recordVersion", ""): r for r in candidates}
winning = select_highest(list(by_version.keys()), ref.constraint)
if winning is None:
raise RegistryResolutionError(
"NO_MATCHING_VERSION",
ref_str,
f"no approved version of {ref.kind}/{ref.namespace}/{ref.name} "
f"satisfies {ref.constraint.raw}",
)
winner = by_version[winning]
# Fail closed: a resolvable record whose runtime payload is empty or
# unreadable must NOT resolve to {} — that would let a task load nothing
# while the audit claims the pin was honored (REGISTRY.md §8). A corrupt
# _meta/CUSTOM body or an out-of-band write can produce this.
try:
runtime = self._extract_runtime(winner)
except (ValueError, json.JSONDecodeError) as exc:
raise RegistryResolutionError(
"REMOVED",
ref_str,
f"resolved {ref.kind}/{ref.namespace}/{ref.name}@{winning} "
f"has an unreadable runtime payload: {exc}",
) from exc
if not isinstance(runtime, dict) or not runtime:
raise RegistryResolutionError(
"REMOVED",
ref_str,
f"resolved {ref.kind}/{ref.namespace}/{ref.name}@{winning} "
f"has no loadable runtime payload",
)
warnings = ["DEPRECATED"] if winner.get("status") == "DEPRECATED" else []
return ResolvedAsset(
kind=ref.kind,
namespace=ref.namespace,
name=ref.name,
version=winning,
runtime=runtime,
warnings=warnings,
)
60 changes: 60 additions & 0 deletions agent/src/registry/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""The read-side ``RegistryClient`` port for the agent (#246).

The agent is a read-only consumer: it resolves refs and fetches records, but never
publishes or governs. It talks to the substrate through this Protocol, never a raw
AWS SDK client — the one implementation is ``AgentCoreRegistryClient``
(``registry.agentcore_client``), so a substrate swap is confined there.

The write-side verbs (publish / submit / approve) live only on the TypeScript port
(``cdk/src/handlers/shared/registry/client.ts``); the agent has no business
mutating the registry.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol

if TYPE_CHECKING:
from registry.ref import ParsedRef


@dataclass(frozen=True)
class ResolvedAsset:
"""One resolved asset: enough to load it without knowing where bytes live."""

kind: str
namespace: str
name: str
version: str
runtime: dict[str, Any]
warnings: list[str] = field(default_factory=list)


class RegistryResolutionError(Exception):
"""Raised when a ref cannot be resolved. ``reason`` matches the TS token set
(``NO_MATCHING_VERSION`` / ``REMOVED`` / ``INVALID_CONSTRAINT`` /
``INVALID_REGISTRY_REF``) so both languages agree on *why*."""

def __init__(self, reason: str, ref: str, message: str) -> None:
super().__init__(message)
self.reason = reason
self.ref = ref


class RegistryClient(Protocol):
"""Read-only registry access. See the TS port for the full (write) surface."""

def get_record(
self, kind: str, namespace: str, name: str, version: str
) -> dict[str, Any] | None:
"""Fetch a single record by exact coordinates, or ``None`` if absent."""
...

def resolve(self, ref: ParsedRef) -> ResolvedAsset:
"""Resolve a parsed ref to a single APPROVED (or DEPRECATED+warn) asset.

Fail-closed: raises ``RegistryResolutionError`` with a specific reason on
any unresolved ref.
"""
...
118 changes: 118 additions & 0 deletions agent/src/registry/ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Strict ``registry://`` reference grammar for the agent asset registry (#246).

::

registry://<kind>/<namespace>/<name>@<constraint>
kind = [a-z][a-z0-9_]* snake_case: mcp_server, cedar_policy_module
namespace = [a-z][a-z0-9-]*
name = [a-z0-9][a-z0-9._-]*
constraint = [^~]?MAJOR.MINOR.PATCH[-prerelease] exact / caret / tilde only

The ``@<constraint>`` pin is MANDATORY (fail-closed: no implicit "latest").

This module mirrors ``cdk/src/handlers/shared/registry/ref.ts`` byte-for-byte and
is exercised by the ``contracts/registry-resolution/`` parity corpus. Keep the two
in lockstep — a change here without the matching TS change (or vice versa) is a
parity break that CI must catch.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

# MVP asset kinds the registry loads end-to-end or stages.
REGISTRY_KINDS = ("mcp_server", "cedar_policy_module", "skill")
# Reserved kinds accepted by the grammar but rejected at publish (no loader yet).
RESERVED_KINDS = ("plugin", "subagent", "prompt_fragment", "capability")

# Structural split — scheme + 3 path segments + the (mandatory) constraint.
# ``\Z`` (absolute end of string), not ``$``: Python's ``$`` also matches just
# before a trailing newline, so ``$`` would accept ``…@1.0.0\n`` that the JS
# mirror (ref.ts, no ``m`` flag) rejects — a byte-for-byte parity break.
_REF_SHAPE = re.compile(
r"^registry://([a-z][a-z0-9_]*)/([a-z][a-z0-9-]*)/([a-z0-9][a-z0-9._-]*)@(.+)\Z"
)
# exact / caret / tilde over MAJOR.MINOR.PATCH with an optional prerelease.
# Rejects ``*``, ``latest``, ``>=``, ``<=``, x-ranges, and bare prerelease modifiers.
_CONSTRAINT = re.compile(
r"^([\^~]?)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?\Z"
)
_OP_BY_PREFIX = {"": "exact", "^": "caret", "~": "tilde"}

# JS ``Number.MAX_SAFE_INTEGER`` (2**53 - 1). Python int is arbitrary-precision,
# but the TS parser rounds components above this, so both sides must reject them
# to agree on version selection (#246 parity — see registry/ref.ts).
_MAX_SAFE_INT = 9007199254740991


class RefError(ValueError):
"""Raised when a ``registry://`` ref is malformed.

``reason`` is one of ``INVALID_REGISTRY_REF`` / ``INVALID_CONSTRAINT`` — the
same reason tokens the TS resolver reports, so both sides agree on *why* a
ref failed, not just *that* it failed.
"""

def __init__(self, reason: str, message: str) -> None:
super().__init__(message)
self.reason = reason


@dataclass(frozen=True)
class ParsedConstraint:
op: str # exact | caret | tilde
major: int
minor: int
patch: int
prerelease: str | None
raw: str


@dataclass(frozen=True)
class ParsedRef:
kind: str
namespace: str
name: str
constraint: ParsedConstraint


def parse_constraint(raw: str) -> ParsedConstraint | None:
"""Parse + validate a constraint string in isolation. ``None`` if invalid."""
m = _CONSTRAINT.match(raw)
if not m:
return None
prefix, major, minor, patch, prerelease = m.groups()
major_i, minor_i, patch_i = int(major), int(minor), int(patch)
if max(major_i, minor_i, patch_i) > _MAX_SAFE_INT:
return None
return ParsedConstraint(
op=_OP_BY_PREFIX[prefix],
major=major_i,
minor=minor_i,
patch=patch_i,
prerelease=prerelease,
raw=raw,
)


def parse_ref(ref: str) -> ParsedRef:
"""Parse a strict ``registry://kind/namespace/name@constraint`` reference.

Raises ``RefError`` (with a ``reason``) on a malformed ref or a floating /
unsupported constraint — pins are mandatory.
"""
shape = _REF_SHAPE.match(ref)
if not shape:
raise RefError(
"INVALID_REGISTRY_REF",
f"not a valid registry ref (expected registry://kind/namespace/name@constraint): {ref}",
)
kind, namespace, name, raw_constraint = shape.groups()
constraint = parse_constraint(raw_constraint)
if constraint is None:
raise RefError(
"INVALID_CONSTRAINT",
f"unsupported version constraint '{raw_constraint}' (use exact, ^, or ~)",
)
return ParsedRef(kind=kind, namespace=namespace, name=name, constraint=constraint)
Loading
Loading