From e6f10ba8c9000d40688e99e7c365813b7b7922b4 Mon Sep 17 00:00:00 2001 From: Jonathan Boniface Date: Tue, 9 Jun 2026 15:54:18 +0100 Subject: [PATCH] feat: initial harness spec v1.0.0 + reference template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the normative contract every platform agent must conform to, plus a working reference template under template/ that ticks every box out of the box. Spec is derived from the production agents shipped in cjot-backstage-az/agents/ on branch sandbox/jonathan: - agent-registry, agent-factory, decomposer, discovery-agent, golden-path, modernization-factory, modernization-factory-v2, policy-transformer, scaffold-agent, support-intake-agent, the-watcher Contents: - SPEC.md normative contract (13 sections + conformance checklist) - CHANGELOG.md spec versioning (1.0.0) - docs/ registration.md self-registration with the agent-gateway observability.md OTel logs/metrics/traces wiring manifest.md /.well-known/agent.json schema + AgentSkill serialisation (pydantic vs protobuf) kubernetes.md k8s deployment shape, mandatory cross-refs, per-namespace agents, resource sizing deployment.md .image-version, ACR build, deploy scripts, rollback deviations.md tracked debts against the spec - template/ .env.local.example, .gitignore, .image-version (0.1.0), Dockerfile (python:3.12-slim, non-root UID 1001, HEALTHCHECK), requirements.txt (harness floor + optional LLM stack), app/agent.py (Starlette entry, lifespan + self-registration, defensive _skill_dict for pydantic vs protobuf), app/logging_setup.py (canonical OTel log bridge — copy verbatim), app/metrics.py (meter + example counter/histogram), app/skills.py (AGENT_CONFIG + AgentSkill list), k8s/configmap.yaml + deployment.yaml (Deployment + Service, OTel annotations + 6-var env block, agents-sa + agents-kv-spc bindings), scripts/deploy.sh (auto-bump + az acr build + apply + rollout), scripts/full-deploy.sh (preflight + deploy + post-deploy smoke), scripts/deploy-local.sh (docker/podman + .env.local) --- CHANGELOG.md | 34 ++++ README.md | 33 +++- SPEC.md | 325 +++++++++++++++++++++++++++++++ docs/deployment.md | 133 +++++++++++++ docs/deviations.md | 24 +++ docs/kubernetes.md | 198 +++++++++++++++++++ docs/manifest.md | 138 +++++++++++++ docs/observability.md | 209 ++++++++++++++++++++ docs/registration.md | 71 +++++++ template/.env.local.example | 14 ++ template/.gitignore | 17 ++ template/.image-version | 1 + template/Dockerfile | 23 +++ template/README.md | 77 ++++++++ template/app/__init__.py | 0 template/app/agent.py | 185 ++++++++++++++++++ template/app/logging_setup.py | 34 ++++ template/app/metrics.py | 33 ++++ template/app/skills.py | 40 ++++ template/k8s/configmap.yaml | 17 ++ template/k8s/deployment.yaml | 117 +++++++++++ template/requirements.txt | 24 +++ template/scripts/deploy-local.sh | 38 ++++ template/scripts/deploy.sh | 75 +++++++ template/scripts/full-deploy.sh | 74 +++++++ 25 files changed, 1933 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 SPEC.md create mode 100644 docs/deployment.md create mode 100644 docs/deviations.md create mode 100644 docs/kubernetes.md create mode 100644 docs/manifest.md create mode 100644 docs/observability.md create mode 100644 docs/registration.md create mode 100644 template/.env.local.example create mode 100644 template/.gitignore create mode 100644 template/.image-version create mode 100644 template/Dockerfile create mode 100644 template/README.md create mode 100644 template/app/__init__.py create mode 100644 template/app/agent.py create mode 100644 template/app/logging_setup.py create mode 100644 template/app/metrics.py create mode 100644 template/app/skills.py create mode 100644 template/k8s/configmap.yaml create mode 100644 template/k8s/deployment.yaml create mode 100644 template/requirements.txt create mode 100755 template/scripts/deploy-local.sh create mode 100755 template/scripts/deploy.sh create mode 100755 template/scripts/full-deploy.sh diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..201aacf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog — agent-harness-spec + +All notable changes to the spec are recorded here. The spec follows [semver](https://semver.org/). + +## [1.0.0] — 2026-06-09 + +Initial draft derived from the production agents shipped in `cjot-backstage-az/agents/` on branch `sandbox/jonathan`: + +- `agent-registry` (FastAPI, registry/gateway) +- `agent-factory` +- `decomposer` +- `discovery-agent` +- `golden-path` +- `modernization-factory` (v1) +- `modernization-factory-v2` +- `policy-transformer` +- `scaffold-agent` +- `support-intake-agent` (FastAPI, intake) +- `the-watcher` + +Codifies: + +- Mandatory `app.agent:app` Starlette entry point +- `GET /health` and `GET /.well-known/agent.json` contract +- Self-registration with the agent-gateway via `POST /agents/register-url` (3×5s retry budget) +- OTel operator-driven SDK injection + the six standard env vars (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_LOGS_EXPORTER`, `OTEL_METRICS_EXPORTER`, `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_SEMCONV_STABILITY_OPT_IN`) +- Canonical `app/logging_setup.py` bridge (root logger → `LoggerProvider`) +- `app/metrics.py` shape (`metrics.get_meter(name, version)` + module-scope instruments, `agent..*` naming) +- LangChain instrumentation via `LangChainInstrumentor().instrument()` +- Container shape (`python:3.12-slim`, non-root UID 1001, HEALTHCHECK) +- Kubernetes shape (namespace `agents`, ServiceAccount `agents-sa`, `agents-kv-spc` SPC at `/mnt/secrets-store`, named `http` port, OTel inject annotations) +- `.image-version` file + `scripts/deploy.sh` / `full-deploy.sh` / `deploy-local.sh` cadence + +Defensive serialisation for `a2a.types.AgentSkill` (pydantic vs protobuf) is mandated in §6 to avoid the `model_dump()` regression observed in `policy-transformer` 1.0.2 → 1.0.3. diff --git a/README.md b/README.md index ef08ee7..82da4c3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ # agent-harness-spec -Standard definition and specification for the agent harness used across the platform. \ No newline at end of file +Standard definition and reference template for **Python/Starlette agents** running on the platform. + +This repository defines: + +1. The **normative contract** every platform agent must conform to — see [SPEC.md](SPEC.md). +2. A working **reference template** under [`template/`](template/) you copy when starting a new agent. +3. **Deep-dive docs** under [`docs/`](docs/) covering registration, observability, the agent manifest, Kubernetes shape, and deployment. + +The spec is derived from the production agents shipped in `cjot-backstage-az/agents/` (branch `sandbox/jonathan`) — `agent-registry`, `golden-path`, `policy-transformer`, `modernization-factory-v2`, `scaffold-agent`, `decomposer`, `discovery-agent`, `the-watcher`, `support-intake-agent`, etc. If an agent in that repository disagrees with this spec, the spec wins on net-new work; existing deviations are tracked under [`docs/deviations.md`](docs/deviations.md) and brought into line at the next minor version bump. + +## When to use this repo + +- **Starting a new agent**: `cp -r template/ ../my-new-agent/` and follow [`template/README.md`](template/README.md). +- **Auditing an existing agent**: walk the [conformance checklist](SPEC.md#conformance-checklist) in `SPEC.md`. +- **Changing the contract**: open a PR against `SPEC.md` + bump the spec version in [CHANGELOG.md](CHANGELOG.md). + +## Spec version + +The current spec version is **`1.0.0`** (see [CHANGELOG.md](CHANGELOG.md)). + +## Quick links + +| Topic | File | +|---|---| +| Normative contract | [SPEC.md](SPEC.md) | +| Self-registration with the gateway | [docs/registration.md](docs/registration.md) | +| OpenTelemetry wiring (logs / metrics / traces) | [docs/observability.md](docs/observability.md) | +| `/.well-known/agent.json` manifest schema | [docs/manifest.md](docs/manifest.md) | +| Kubernetes deployment shape | [docs/kubernetes.md](docs/kubernetes.md) | +| Build & deploy flow (ACR, scripts, `.image-version`) | [docs/deployment.md](docs/deployment.md) | +| Known deviations from the spec | [docs/deviations.md](docs/deviations.md) | +| Reference template walkthrough | [template/README.md](template/README.md) | \ No newline at end of file diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..dbd031a --- /dev/null +++ b/SPEC.md @@ -0,0 +1,325 @@ +# Platform Agent Harness Specification + +**Version:** 1.0.0 +**Status:** Draft → ratification pending +**Applies to:** All Python agents deployed under `cjot-backstage-az/agents/` and any new agent on the platform. + +The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +--- + +## 1. Scope + +This spec defines the *harness* — the cross-cutting plumbing every agent shares — so that domain logic is the only thing an agent author has to write. It does **not** prescribe how an agent's business logic, prompts, or workflow internals are structured. + +In scope: + +- HTTP framework, entry point, and required routes +- Self-registration with the agent-gateway / agent-registry +- The `/.well-known/agent.json` manifest contract +- OpenTelemetry wiring (logs, metrics, traces, LangChain instrumentation) +- Container image shape and build provenance +- Kubernetes deployment shape (namespace, RBAC, secrets, probes, OTel injection) +- Deployment scripts and the `.image-version` cadence + +Out of scope: + +- LangGraph workflow shape (recommended but not mandated — see §11.2) +- A2A SDK integration (recommended for multi-agent coordination — see §11.3) +- Domain-specific concerns (templates, git ops, multi-cloud auth, etc.) + +--- + +## 2. Vocabulary + +| Term | Meaning | +|---|---| +| **Agent** | A Python HTTP service that exposes domain skills, registered with the agent-gateway. | +| **agent-registry** / **agent-gateway** | The single Service (`agent-gateway.agents.svc.cluster.local`) that holds the catalogue of running agents and proxies external traffic to them. | +| **Manifest** | The JSON document served at `/.well-known/agent.json` describing the agent's identity, version, skills, and capabilities. | +| **Harness** | The non-domain plumbing every agent shares (routes, registration, OTel, k8s shape). | +| **Operator** | The OpenTelemetry Operator that injects the Python auto-instrumentation init container. | + +--- + +## 3. Runtime requirements (MUST) + +1. The agent **MUST** be a Python ≥ 3.12 application. +2. The agent **MUST** expose its HTTP server via [Starlette](https://www.starlette.io/). FastAPI is permitted only for legacy services (e.g. `agent-registry`, `support-intake-agent`); new agents **MUST** use Starlette. +3. The ASGI application **MUST** be importable as `app.agent:app`. +4. The container **MUST** run `uvicorn app.agent:app --host 0.0.0.0 --port ` as PID 1. +5. The agent **MUST** listen on TCP port `8000` *or* `8080` inside the container. Port `8000` is preferred for new agents. + +--- + +## 4. Required HTTP routes (MUST) + +Every agent **MUST** register the following routes: + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/health` | Liveness/readiness probe. Returns `200 OK` with a JSON body when the process is up. | +| `GET` | `/.well-known/agent.json` | A2A discovery manifest — see §6 and [docs/manifest.md](docs/manifest.md). | + +Additional routes (the agent's domain endpoints) **MAY** be registered freely. + +`/health` **MUST** return JSON of at least: + +```json +{ + "status": "healthy", + "service": "" +} +``` + +A `version` field is **RECOMMENDED**. + +--- + +## 5. Self-registration with the gateway (MUST) + +Every agent **MUST** self-register with the agent-gateway on startup. The flow is described in detail in [docs/registration.md](docs/registration.md). In short: + +1. The agent **MUST** read these env vars: + - `REGISTRY_URL` — the gateway's base URL (default: `http://agent-gateway.agents.svc.cluster.local`). + - `AGENT_SELF_URL` — the URL the gateway should call back on (default: `http://.agents.svc.cluster.local`). +2. On startup (inside the Starlette `lifespan` context manager), the agent **MUST** spawn a background task that POSTs `{"endpoint": AGENT_SELF_URL}` to `{REGISTRY_URL}/agents/register-url`. +3. The registration call **MUST** retry at least 3 times with a 5-second backoff and a 2-second initial delay (to let uvicorn finish binding). +4. Registration failure **MUST NOT** prevent the agent from serving traffic — it is logged at `ERROR` and retried only via pod restart. +5. If `REGISTRY_URL` is unset or empty, the agent **MUST** skip registration and log at `INFO`. + +The gateway will then `GET {AGENT_SELF_URL}/.well-known/agent.json` to read the manifest. `AGENT_SELF_URL` **MUST** therefore be reachable from the gateway pod (i.e. resolve via cluster DNS) and serve a valid manifest. + +--- + +## 6. Agent manifest (MUST) + +`GET /.well-known/agent.json` **MUST** return a JSON document with at minimum: + +```json +{ + "name": "Policy Transformer", + "version": "1.0.0", + "description": "...", + "url": "/", + "skills": [ + { + "id": "policy_generation", + "name": "Policy Generation", + "description": "...", + "tags": ["policy", "generate"], + "examples": ["Generate a Kyverno policy that ..."] + } + ], + "capabilities": { + "streaming": false, + "conversational": false, + "direct_api": true + } +} +``` + +`skills` **MUST** be a JSON array. If the agent uses the `a2a-sdk` `AgentSkill` type, it **MUST** serialise each skill defensively because `AgentSkill` is exposed as a protobuf `Message` in current `a2a-sdk` releases (not pydantic). Use a helper that handles `model_dump`, protobuf `MessageToDict`, and attribute fallback — see the canonical implementation in [`template/app/agent.py`](template/app/agent.py) (`_skill_dict`). + +Full schema and examples: [docs/manifest.md](docs/manifest.md). + +--- + +## 7. OpenTelemetry wiring (MUST) + +The full design is in [docs/observability.md](docs/observability.md). Mandatory bullets: + +### 7.1 Operator-driven SDK injection + +The pod template **MUST** carry these annotations: + +```yaml +annotations: + instrumentation.opentelemetry.io/inject-python: "monitoring/otel-instrumentation" + instrumentation.opentelemetry.io/container-names: "" +``` + +The `container-names` value **MUST** match the agent's container `name`. + +### 7.2 Standard OTel env vars + +The pod **MUST** set all six env vars below, in the agent container: + +```yaml +env: + - name: OTEL_SERVICE_NAME + value: "" + - name: OTEL_RESOURCE_ATTRIBUTES + value: "service.namespace=agents,service.version=,deployment.environment=,agent.type=" + - name: OTEL_LOGS_EXPORTER + value: "otlp" + - name: OTEL_METRICS_EXPORTER + value: "otlp" + - name: OTEL_METRIC_EXPORT_INTERVAL + value: "15000" + - name: OTEL_SEMCONV_STABILITY_OPT_IN + value: "http" +``` + +Notes: + +- `service.namespace` **MUST** be the Kubernetes namespace the agent runs in (typically `agents`). +- `service.version` **MUST** match the contents of `.image-version`. +- `agent.type` **MUST** be a stable, lower-hyphen identifier (e.g. `policy-transformer`, `golden-path`, `registry`). It is the primary metric/log label used in dashboards. +- `OTEL_SEMCONV_STABILITY_OPT_IN=http` is required so the SDK emits stable HTTP semantic conventions (`http.server.request.duration`, `http.response.status_code`). + +### 7.3 Logging bridge + +The agent **MUST** ship `app/logging_setup.py` with a `configure_otlp_log_handler(level)` function that attaches a `LoggingHandler` to the root logger using the operator-provided `LoggerProvider`. The implementation **MUST** be a no-op when: + +- The OTel SDK packages are not importable, or +- `get_logger_provider()` returns `None` or a `NoOpLoggerProvider`, or +- A `LoggingHandler` is already attached. + +The canonical implementation is committed in [`template/app/logging_setup.py`](template/app/logging_setup.py); copy it verbatim. + +The agent **MUST** call `configure_otlp_log_handler()` once at module import time in `app/agent.py`. + +### 7.4 Custom metrics + +The agent **MUST** ship `app/metrics.py` that creates a meter via `metrics.get_meter("", "")` and declares all custom instruments at module scope. + +Naming **MUST** follow `agent..[.duration|.errors]`, with: + +- `unit="1"` for counters, +- `unit="s"` for histograms timing seconds. + +The agent **MUST NOT** call `MeterProvider` setters at runtime; the operator owns the global provider. + +### 7.5 LLM instrumentation + +If the agent calls a LangChain-style LLM, the agent **MUST** call `LangChainInstrumentor().instrument()` **before** the LLM client is constructed. This emits OpenInference spans with token counts and prompts/responses. + +### 7.6 Tracing + +Tracing of HTTP server, outbound HTTP, and FastAPI/Starlette routes is auto-instrumented by the operator. The agent **SHOULD NOT** install per-instrumentor wheels manually unless extending the default set. + +--- + +## 8. Container image (MUST) + +See [`template/Dockerfile`](template/Dockerfile) for the canonical shape. Required properties: + +1. Base image **MUST** be `python:3.12-slim` (or `3.13-slim` once promoted by platform). +2. Image **MUST** install `requirements.txt` at build time; runtime `pip install` is forbidden. +3. Image **MUST NOT** run as `root`. Create and switch to a non-root user (UID 1001). +4. Image **MUST** expose only the chosen container port (8000 or 8080). +5. Image **SHOULD** declare a `HEALTHCHECK` calling `GET /health`. +6. Image **MUST** be tagged with both `:latest` and `:` from `.image-version` and pushed to `bstagecjotdevacr.azurecr.io/`. + +--- + +## 9. Kubernetes manifests (MUST) + +See [docs/kubernetes.md](docs/kubernetes.md) for the full template. Required properties: + +1. The agent **MUST** ship `k8s/configmap.yaml` and `k8s/deployment.yaml` (and the `Service` may be inlined in `deployment.yaml`). +2. Namespace **MUST** be `agents` unless the agent has a strong domain reason for its own namespace (e.g. `modernization-factory-v2` runs in `modernization-factory`); in that case, `service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` **MUST** match. +3. ServiceAccount **MUST** be `agents-sa` (workload-identity bound). +4. The pod **MUST** mount the shared `agents-kv-spc` SecretProviderClass at `/mnt/secrets-store` and consume secrets via `secretKeyRef` from the synced `agents-kv-sync` secret. +5. The deployment **MUST** declare both `livenessProbe` and `readinessProbe` against `httpGet: { path: /health, port: http }`. Recommended timing: liveness `initialDelaySeconds: 20, periodSeconds: 15`; readiness `initialDelaySeconds: 10, periodSeconds: 10`. Init delays **MUST** account for OTel SDK warm-up + LangGraph compile time. +6. The deployment **MUST** carry the OTel inject annotations from §7.1. +7. The Service **MUST** expose port `80` mapped to the container port (named `http`), `type: ClusterIP`. +8. Resource requests/limits **MUST** be declared explicitly (no defaults). + +--- + +## 10. Build & deployment (MUST) + +See [docs/deployment.md](docs/deployment.md) for the full flow. Required properties: + +1. The agent **MUST** ship a `.image-version` file containing a single line of semver (`X.Y.Z`). +2. The agent **MUST** ship `scripts/deploy.sh` that: + - Auto-bumps the patch version of `.image-version` when no `--tag` is given, + - Runs `az acr build --registry bstagecjotdevacr --image : --image :latest .`, + - Applies `k8s/configmap.yaml` then `k8s/deployment.yaml`, + - Waits for `kubectl rollout status` (recommended ≥ 180s timeout). +3. The agent **SHOULD** ship `scripts/full-deploy.sh` (preflight + build + apply + post-deploy `/health` smoke) and `scripts/deploy-local.sh` (docker/podman + `.env.local`). +4. Bumping `.image-version` **MUST** be paired with a code change in the same commit; image-version-only commits are reserved for redeploy reruns. + +--- + +## 11. Recommended structure (SHOULD) + +### 11.1 `app/` layout + +``` +app/ +├── __init__.py +├── agent.py # Starlette entry point, routes, lifespan +├── logging_setup.py # OTel log bridge (copy verbatim from template) +├── metrics.py # Meter + custom instruments +├── skills.py # AGENT_SKILLS list + AGENT_CONFIG dict +└── ... # workflows/, nodes/, tools/, states/, prompts/ +``` + +### 11.2 LangGraph + +Agents that drive a multi-step LLM workflow **SHOULD** use LangGraph and place graph definitions under `app/workflows/`, state TypedDicts under `app/states.py` (or `app/states/`), and node handlers under `app/nodes/`. + +### 11.3 A2A SDK + +Agents that participate in multi-agent coordination **SHOULD** integrate the `a2a-sdk` and mount its routes alongside the agent's domain routes. New agents **MUST** still serve `/.well-known/agent.json` themselves (do not rely on the SDK's manifest because it differs from the registry's expected shape). + +### 11.4 Local dev + +The agent **SHOULD** ship `.env.local.example` enumerating every env var read at runtime, with safe defaults committed and secrets blank. + +--- + +## 12. Conformance checklist + +Use this list to audit any agent (existing or new): + +### Code + +- [ ] Python ≥ 3.12 (§3.1) +- [ ] Starlette app at `app.agent:app` (§3.2–3.3) +- [ ] `GET /health` returns `{"status": "healthy", ...}` (§4) +- [ ] `GET /.well-known/agent.json` returns the manifest defined in §6 +- [ ] Manifest serialisation handles pydantic + protobuf `AgentSkill` (§6) +- [ ] `_register_with_registry()` POSTs to `{REGISTRY_URL}/agents/register-url` with 3×5s retry budget (§5) +- [ ] Registration runs inside the Starlette `lifespan` and does not block startup (§5) +- [ ] `app/logging_setup.py` matches the canonical implementation and is invoked at import time (§7.3) +- [ ] `app/metrics.py` creates the meter via `metrics.get_meter(name, version)` (§7.4) +- [ ] `LangChainInstrumentor().instrument()` is called before any LLM client construction (§7.5) + +### Image + +- [ ] `Dockerfile` uses `python:3.12-slim`, runs as non-root UID 1001, declares `HEALTHCHECK` (§8) +- [ ] `requirements.txt` includes the harness dependency floor (see [`template/requirements.txt`](template/requirements.txt)) +- [ ] `.image-version` file present and matches `OTEL_RESOURCE_ATTRIBUTES` `service.version` (§10) + +### Kubernetes + +- [ ] Namespace is `agents` (or matches `service.namespace`) (§9.2) +- [ ] ServiceAccount is `agents-sa` (§9.3) +- [ ] OTel inject annotations present and `container-names` matches container name (§7.1) +- [ ] All six standard OTel env vars present (§7.2) +- [ ] `agents-kv-spc` SecretProviderClass mounted at `/mnt/secrets-store` (§9.4) +- [ ] Liveness + readiness probes hit `/health` (§9.5) +- [ ] Service exposes port 80 → container port via named `http` (§9.7) +- [ ] Resource requests/limits declared (§9.8) + +### Deployment + +- [ ] `scripts/deploy.sh` exists and auto-bumps `.image-version` (§10.2) +- [ ] `scripts/full-deploy.sh` and `scripts/deploy-local.sh` exist (§10.3 — SHOULD) +- [ ] `.env.local.example` present (§11.4 — SHOULD) + +--- + +## 13. Versioning of this spec + +The spec follows semver: + +- **MAJOR** — breaking change to a `MUST` clause (e.g. registration endpoint changes shape). +- **MINOR** — additive `MUST` or `SHOULD` clause. +- **PATCH** — clarifications, examples, typo fixes. + +See [CHANGELOG.md](CHANGELOG.md). Agents **SHOULD** record the spec version they target in their `README.md`. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..f204db0 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,133 @@ +# Build & deployment + +> Normative requirements: [SPEC.md §10](../SPEC.md#10-build--deployment-must) + +Every agent ships three deploy scripts under `scripts/` that wrap the same build → push → apply → roll-out pipeline at three layers of fidelity. + +## `.image-version` + +A single-line file at the agent root: + +``` +1.0.3 +``` + +Conventions: + +- Semver only. +- **MUST** match `OTEL_RESOURCE_ATTRIBUTES`' `service.version` value at all times. +- Bumped whenever a code change ships. The patch number is auto-incremented by `scripts/deploy.sh` when no `--tag` is given. +- Pure image-version-only commits are reserved for *redeploy reruns* (e.g. rebuilding without code changes to refresh dependencies). They **SHOULD** be rare. + +## ACR + +All images are pushed to: + +``` +bstagecjotdevacr.azurecr.io/: +bstagecjotdevacr.azurecr.io/:latest +``` + +Both tags are pushed every build so that pinning to `:latest` works for sandbox/dev clusters and pinning to `:` works for prod clusters. + +## `scripts/deploy.sh` + +The fast path. Used for routine redeploys. + +```bash +#!/bin/bash +set -euo pipefail +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +ACR_NAME="bstagecjotdevacr" +APP_NAME="" +NAMESPACE="agents" +VERSION_FILE="${SCRIPT_DIR}/../.image-version" +TAG="" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --tag) TAG="$2"; shift ;; + *) echo "unknown arg: $1"; exit 2 ;; + esac + shift +done + +if [ -z "$TAG" ]; then + CURRENT=$(cat "$VERSION_FILE" 2>/dev/null || echo "1.0.0") + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + PATCH=$((PATCH + 1)) + TAG="${MAJOR}.${MINOR}.${PATCH}" + echo "$TAG" > "$VERSION_FILE" +fi + +IMAGE="${ACR_NAME}.azurecr.io/${APP_NAME}:${TAG}" +echo "Building ${IMAGE} ..." +az acr build --registry "$ACR_NAME" \ + --image "${APP_NAME}:${TAG}" \ + --image "${APP_NAME}:latest" \ + "${SCRIPT_DIR}/.." + +echo "Applying manifests ..." +kubectl apply -f "${SCRIPT_DIR}/../k8s/configmap.yaml" +kubectl apply -f "${SCRIPT_DIR}/../k8s/deployment.yaml" + +kubectl -n "$NAMESPACE" rollout status "deployment/${APP_NAME}" --timeout=180s +``` + +Behaviour: + +- Auto-bumps the patch on `.image-version` if `--tag` is not given. +- Rebuilds and re-pushes both `:` and `:latest`. +- Applies `configmap.yaml` and `deployment.yaml` in order. +- Blocks until the rollout completes (or fails) within 180 s. + +## `scripts/full-deploy.sh` + +The "first time" path. Adds preflight + post-deploy smoke. + +Preflight (refuse to continue if any check fails): + +- `kubectl` available and pointed at a cluster. +- `az` available and logged in. +- Target namespace exists. +- ServiceAccount `agents-sa` exists in the namespace. +- SecretProviderClass `agents-kv-spc` exists in the namespace. + +After `rollout status` succeeds: + +- `kubectl exec deployment/ -- curl -fs http://localhost:/health` +- `kubectl exec deployment/ -- curl -fs http://localhost:/.well-known/agent.json | jq -e '.name'` + +Failures abort with a non-zero exit code so CI can block. + +## `scripts/deploy-local.sh` + +For dev loop on a workstation without cluster access. + +- Builds the image locally with `docker` or `podman`. +- Reads `.env.local` (sibling of `.env.local.example`). +- Runs the container with `-p :` and `--env-file`. + +Useful for iterating on prompt changes without touching the cluster. + +## CI integration + +The platform's GitHub Actions / Gitea Actions runners invoke `scripts/deploy.sh --tag $GITHUB_SHA` (or similar) when an agent's directory changes on the deploy branch. Pull requests trigger a build-only run that pushes a `:` tag without applying manifests. + +## Rolling back + +There is no automated rollback. To roll back: + +```bash +kubectl -n agents set image deployment/ \ + =bstagecjotdevacr.azurecr.io/: + +kubectl -n agents rollout status deployment/ --timeout=180s +``` + +If the rollback ships a different `service.version`, also revert `OTEL_RESOURCE_ATTRIBUTES` in the ConfigMap or the dashboards will misattribute. Prefer a forward-fix (new patch version) over a rollback whenever possible. + +## Re-registration after redeploy + +The agent re-registers with the gateway on every pod start. No manual step is required after a rollout. If the gateway pod is also restarting at the same time, expect 1–2 `WARNING`s in the agent log before registration succeeds — see [registration.md](registration.md#failure-modes--resolution). diff --git a/docs/deviations.md b/docs/deviations.md new file mode 100644 index 0000000..35f8acd --- /dev/null +++ b/docs/deviations.md @@ -0,0 +1,24 @@ +# Known deviations + +This file tracks where existing agents in `cjot-backstage-az/agents/` deviate from the [SPEC](../SPEC.md). Each row is a tracked debt; deviations should converge over time. + +| Agent | Deviation | Spec clause | Notes | +|---|---|---|---| +| `agent-registry` | Uses FastAPI, not Starlette | §3.2 | Grandfathered — registry pre-dates the spec. | +| `support-intake-agent` | Uses FastAPI, not Starlette | §3.2 | Grandfathered. | +| `agent-factory` | Uses `agent-factory-sa` ServiceAccount | §9.3 | Has its own Key Vault binding (`agent-factory-kv-spc`) for build-time credentials. | +| `sonarqube-mcp` | Uses `sonarqube-mcp-kv-spc` SecretProviderClass | §9 (Mandatory cross-references) | Domain-specific Key Vault for SonarQube tokens. | +| `golden-path` | Listens on port 8000 (not 8080) | §3.5 (allowed) | Port 8000 is permitted; flagged here only for awareness. | +| `modernization-factory-v2` | Runs in namespace `modernization-factory` | §9.2 | Namespace separation by domain; `service.namespace` and `AGENT_SELF_URL` are correctly aligned. | +| `documentor-agent` | Skeleton only (no `app/` shipped) | All | Placeholder; not yet a deployable agent. | + +## How to add a row + +When you knowingly merge an agent that violates a spec clause, add a row here with: + +- The agent's directory name. +- A one-line description of the deviation. +- The clause number(s) in `SPEC.md`. +- A short rationale or follow-up plan. + +When you fix a deviation, remove the row in the same PR that closes it. diff --git a/docs/kubernetes.md b/docs/kubernetes.md new file mode 100644 index 0000000..414aeeb --- /dev/null +++ b/docs/kubernetes.md @@ -0,0 +1,198 @@ +# Kubernetes deployment shape + +> Normative requirements: [SPEC.md §9](../SPEC.md#9-kubernetes-manifests-must) + +Every agent ships at minimum two YAML files under `k8s/`: + +- `configmap.yaml` — non-secret config (URLs, log level, port, etc.). +- `deployment.yaml` — `Deployment` + `Service` (concatenated with `---`). + +The reference template under [`../template/k8s/`](../template/k8s/) is a copy-and-fill skeleton. + +## ConfigMap + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: -config + namespace: agents +data: + # Required — see docs/registration.md + REGISTRY_URL: "http://agent-gateway.agents.svc.cluster.local" + AGENT_SELF_URL: "http://.agents.svc.cluster.local" + + # Standard knobs + LOG_LEVEL: "INFO" + PORT: "8000" + + # Agent-specific (LLM, cache, feature flags, etc.) + AZURE_OPENAI_DEPLOYMENT: "gpt-4o" + AZURE_OPENAI_API_VERSION: "2024-08-01-preview" +``` + +The agent's container **MUST** consume the ConfigMap via `envFrom: configMapRef`. Inline `env:` is reserved for OTel vars and `secretKeyRef` references. + +## Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: + namespace: agents + labels: + app: + component: agent +spec: + replicas: 1 + selector: + matchLabels: + app: + template: + metadata: + labels: + app: + azure.workload.identity/use: "true" + annotations: + instrumentation.opentelemetry.io/inject-python: "monitoring/otel-instrumentation" + instrumentation.opentelemetry.io/container-names: "" + spec: + serviceAccountName: agents-sa + containers: + - name: + image: bstagecjotdevacr.azurecr.io/:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + protocol: TCP + envFrom: + - configMapRef: + name: -config + env: + # ─── OTel (mandatory) ─────────────────────────────────────────── + - name: OTEL_SERVICE_NAME + value: "" + - name: OTEL_RESOURCE_ATTRIBUTES + value: "service.namespace=agents,service.version=1.0.0,deployment.environment=prod,agent.type=" + - name: OTEL_LOGS_EXPORTER + value: "otlp" + - name: OTEL_METRICS_EXPORTER + value: "otlp" + - name: OTEL_METRIC_EXPORT_INTERVAL + value: "15000" + - name: OTEL_SEMCONV_STABILITY_OPT_IN + value: "http" + + # ─── Secrets from agents-kv-sync (Key Vault sync) ─────────────── + - name: AZURE_OPENAI_ENDPOINT + valueFrom: + secretKeyRef: + name: agents-kv-sync + key: azure-openai-endpoint + - name: AZURE_OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: agents-kv-sync + key: azure-openai-api-key + + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + + volumeMounts: + - name: secrets-store + mountPath: "/mnt/secrets-store" + readOnly: true + + volumes: + - name: secrets-store + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: agents-kv-spc +--- +apiVersion: v1 +kind: Service +metadata: + name: + namespace: agents + labels: + app: +spec: + selector: + app: + ports: + - name: http + port: 80 + targetPort: 8000 + protocol: TCP + type: ClusterIP +``` + +## Mandatory cross-references + +These names are platform conventions and **MUST** be used as-written: + +| Name | Kind | Purpose | +|---|---|---| +| `agents` | Namespace | All agents live here unless they have a strong domain reason for their own namespace. | +| `agents-sa` | ServiceAccount | Workload-identity bound to the agents Key Vault. | +| `agents-kv-sync` | Secret | Synced from Key Vault by the SecretProviderClass. Source of `secretKeyRef` values. | +| `agents-kv-spc` | SecretProviderClass | Mounts Key Vault into `/mnt/secrets-store` and rotates the synced Secret. | +| `monitoring/otel-instrumentation` | Instrumentation CR | OTel SDK init container injection. | + +## Probes + +Tune the timing per agent — these are sane defaults: + +| Probe | Purpose | `initialDelaySeconds` | `periodSeconds` | `failureThreshold` | +|---|---|---|---|---| +| `liveness` | Restart hung pod. | 20 | 15 | 3 | +| `readiness` | Hold traffic until manifest is serving. | 10 | 10 | 3 | + +Agents with heavy startup work (large LangGraph compile, prompt caches, model warm-ups) **SHOULD** raise `initialDelaySeconds` rather than reduce `failureThreshold`. + +## Per-namespace agents + +Some agents (e.g. `modernization-factory-v2`) run in their own namespace. When they do: + +- The `serviceAccountName` **MUST** be created in that namespace and bound to the same workload identity (`agents-sa` is conventional, even when the namespace differs). +- `OTEL_RESOURCE_ATTRIBUTES`'s `service.namespace` **MUST** match the namespace. +- `AGENT_SELF_URL` **MUST** point to the cross-namespace FQDN (`http://..svc.cluster.local`); this is allow-listed by the gateway as long as the host ends in `.svc.cluster.local`. + +## Resource sizing reference + +Observed in production: + +| Agent class | CPU req / lim | Mem req / lim | +|---|---|---| +| Stateless small (e.g. `support-intake-agent`) | 100m / 250m | 256Mi / 512Mi | +| Standard LLM agent (`policy-transformer`, `decomposer`) | 250m / 500m | 512Mi / 1Gi | +| Heavy workflow (`golden-path`, `modernization-factory-v1`) | 500m–1 / 1–2 | 1Gi–2Gi / 2Gi–4Gi | + +When in doubt start with the *Standard* row and revisit after one week of production traffic. diff --git a/docs/manifest.md b/docs/manifest.md new file mode 100644 index 0000000..ba31b72 --- /dev/null +++ b/docs/manifest.md @@ -0,0 +1,138 @@ +# Agent manifest — `/.well-known/agent.json` + +> Normative requirements: [SPEC.md §6](../SPEC.md#6-agent-manifest-must) + +The agent-gateway discovers agents by fetching `GET {AGENT_SELF_URL}/.well-known/agent.json` after a successful `POST /agents/register-url` (see [registration.md](registration.md)). + +## Schema + +```jsonc +{ + "name": "Policy Transformer", // human-readable, used as dedup key + "version": "1.0.0", // semver, MUST match .image-version + "description": "...", // 1-3 sentences + "url": "/", // base path, almost always "/" + "skills": [ // see "Skills" below + { + "id": "policy_generation", // snake_case, stable identifier + "name": "Policy Generation", // human-readable + "description": "...", // 1-2 sentences + "tags": ["policy", "generate"], // free-form filters + "examples": [ // example natural-language prompts + "Generate a Kyverno policy that requires labels" + ] + } + ], + "capabilities": { + "streaming": false, // SSE/WebSocket support + "conversational": false, // multi-turn chat support + "direct_api": true // synchronous request/response + } +} +``` + +## Skills + +Skills declare what the agent can do. They are surfaced to: + +- The Backstage agent catalogue page, +- The agent-gateway's discovery API (so other agents can route by capability), +- LLM tool registries (so the platform-orchestrator can pick a skill at runtime). + +### Defining skills with `a2a-sdk` + +Most agents use the `a2a-sdk`'s `AgentSkill` type: + +```python +from a2a.types import AgentSkill + +POLICY_TRANSFORMER_SKILLS = [ + AgentSkill( + id="policy_generation", + name="Policy Generation", + description=( + "Generate production-ready Kyverno, Azure Policy, or " + "OPA/Gatekeeper policies from a natural-language description." + ), + tags=["policy", "generate", "kyverno", "azure", "gatekeeper", "opa"], + examples=[ + "Generate a Kyverno policy that requires all pods to have app and owner labels", + ], + ), + # ... +] +``` + +### Defensive serialisation + +`AgentSkill` is currently exposed as a **protobuf `Message`** in `a2a-sdk` runtime images, even though older type stubs suggest pydantic. Calling `s.model_dump()` blindly raises `AttributeError: model_dump` and the manifest endpoint returns 500 — which then causes the gateway's `register-url` call to fail with `HTTP 400 — Failed to fetch agent card ... HTTP 500`. + +The agent **MUST** serialise skills with a defensive helper: + +```python +def _skill_dict(s) -> dict: + if hasattr(s, "model_dump"): + return s.model_dump() + if hasattr(s, "DESCRIPTOR"): + try: + from google.protobuf.json_format import MessageToDict + return MessageToDict(s, preserving_proto_field_name=True) + except Exception: + pass + if hasattr(s, "dict") and callable(getattr(s, "dict")): + try: + return s.dict() + except Exception: + pass + return { + "id": getattr(s, "id", None), + "name": getattr(s, "name", None), + "description": getattr(s, "description", ""), + "tags": list(getattr(s, "tags", []) or []), + "examples": list(getattr(s, "examples", []) or []), + } +``` + +The canonical copy lives in [`../template/app/agent.py`](../template/app/agent.py). + +## The route handler + +```python +async def agent_manifest(request: Request) -> JSONResponse: + """GET /.well-known/agent.json — discovery manifest consumed by agent-gateway.""" + return JSONResponse({ + "name": AGENT_CONFIG["name"], + "version": AGENT_CONFIG["version"], + "description": AGENT_CONFIG["description"], + "url": "/", + "skills": [_skill_dict(s) for s in AGENT_SKILLS], + "capabilities": AGENT_CONFIG["capabilities"], + }) +``` + +## Capability flags + +| Flag | Meaning | Set to `true` when… | +|---|---|---| +| `streaming` | The agent supports server-sent events on at least one route. | You expose `text/event-stream` responses. | +| `conversational` | Multi-turn chat with session state. | You hold `session_id` → state in memory or Redis. | +| `direct_api` | Synchronous request → response. | The default for most agents. | + +These are advisory — the gateway uses them to pick UI affordances, not to gate routing. + +## Naming and dedup + +The gateway normalises `name` to lower-hyphen-case before storing the registration row. `Policy Transformer` and `policy-transformer` collide; pick one form and stick to it. The registration row's `name` becomes the path segment in `/proxy/agent-gateway/{agent_name}/{path}`. + +If two agents register with the same normalised name, the latest registration wins. Versioned agents (e.g. `Modernization Factory` vs `Modernization Factory v2`) **MUST** use distinct `name` strings. + +## Validation + +A simple smoke test you can run inside the pod: + +```bash +kubectl exec deployment/ -c -- \ + curl -fs http://localhost:/.well-known/agent.json | jq -e '.name and .version and (.skills|type=="array")' +``` + +Exit code 0 means the manifest is structurally valid. The full validation lives in the gateway and runs automatically on every `register-url` call. diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..2dcfec8 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,209 @@ +# Observability — OpenTelemetry wiring + +> Normative requirements: [SPEC.md §7](../SPEC.md#7-opentelemetry-wiring-must) + +The platform runs the OpenTelemetry Operator. Every agent gets the SDK injected as an init container; the agent code itself does **not** install or configure SDK exporters. This document explains what is provided for free, what the agent must wire by hand, and how the signals flow to the cluster collectors. + +## Pipeline + +```mermaid +flowchart LR + A[Agent pod] -->|OTLP gRPC| T1[Tier-1 collector] + T1 -->|metrics| P[Prometheus RemoteWrite] + T1 -->|logs| L[Loki] + T1 -->|traces| TP[Tempo] + T1 -->|spanmetrics| P +``` + +- **Tier-1 collector** runs as a DaemonSet, decorates spans/logs with `k8sattributes` (pod, namespace, node), and forwards to tier-2. +- **Tier-2 collector** generates spanmetrics from traces, exports OTLP traces to Tempo, OTLP logs to Loki, and Prometheus remote-writes metrics. +- The agent only needs OTLP gRPC reachability to `tier-1` — everything else is the operator's job. + +## What the operator gives you for free + +The `Instrumentation` CR `monitoring/otel-instrumentation` injects: + +- **Tracer/Meter/Logger providers** with OTLP exporters configured. +- **Auto-instrumented HTTP server** (`opentelemetry-instrumentation-asgi` or `-fastapi`). +- **Auto-instrumented outbound HTTP** (`urllib3`, `httpx`, `requests`). +- **Logger provider** with `BatchLogRecordProcessor` — but **does not** attach a `LoggingHandler` to Python's stdlib root logger. That is the agent's job. + +## What the agent must wire by hand + +### 1. The pod template annotations + +```yaml +metadata: + annotations: + instrumentation.opentelemetry.io/inject-python: "monitoring/otel-instrumentation" + instrumentation.opentelemetry.io/container-names: "" +``` + +`container-names` **MUST** match the container `name`. If you have multiple containers (sidecar, etc.), only list the agent container. + +### 2. The standard env block + +These six env vars are mandatory: + +```yaml +- name: OTEL_SERVICE_NAME + value: "" +- name: OTEL_RESOURCE_ATTRIBUTES + value: "service.namespace=agents,service.version=,deployment.environment=,agent.type=" +- name: OTEL_LOGS_EXPORTER + value: "otlp" +- name: OTEL_METRICS_EXPORTER + value: "otlp" +- name: OTEL_METRIC_EXPORT_INTERVAL + value: "15000" +- name: OTEL_SEMCONV_STABILITY_OPT_IN + value: "http" +``` + +#### `OTEL_SERVICE_NAME` + +The unique service identity. Used as the `service.name` resource attribute on every span/metric/log. **MUST** match the agent's lower-hyphen identifier. + +#### `OTEL_RESOURCE_ATTRIBUTES` + +Comma-separated `key=value` pairs merged into the resource. Required keys: + +- `service.namespace` — the Kubernetes namespace. +- `service.version` — semver string. **MUST** match the contents of the agent's `.image-version` file. +- `deployment.environment` — `prod` / `dev` / `local`. +- `agent.type` — stable lower-hyphen identifier (`policy-transformer`, `golden-path`, `registry`, etc.). This is the primary metric/log filter in dashboards. + +#### `OTEL_METRIC_EXPORT_INTERVAL` + +`15000` ms (15 s) is platform-wide. Faster than this generates noise; slower hides incidents. + +#### `OTEL_SEMCONV_STABILITY_OPT_IN` + +`http` opts the SDK into the **stable** HTTP semantic conventions: + +- `http.server.request.duration` (histogram) +- `http.response.status_code` (attribute) + +Without this flag the SDK emits the legacy `http.server.duration` + `http.status_code`, which the platform dashboards no longer query. + +### 3. The logging bridge + +The OTel Logger provider exists but isn't connected to stdlib `logging`. The agent **MUST** ship `app/logging_setup.py` and call `configure_otlp_log_handler()` once at module import time in `app/agent.py`. + +The canonical implementation is in [`../template/app/logging_setup.py`](../template/app/logging_setup.py). Copy it verbatim — it is intentionally short and idempotent: + +```python +def configure_otlp_log_handler(level: int = logging.INFO) -> None: + try: + from opentelemetry._logs import get_logger_provider + from opentelemetry.sdk._logs import LoggingHandler + except ImportError: + return + provider = get_logger_provider() + if provider is None or type(provider).__name__ == "NoOpLoggerProvider": + return + root = logging.getLogger() + if any(isinstance(h, LoggingHandler) for h in root.handlers): + return + root.addHandler(LoggingHandler(level=level, logger_provider=provider)) + if root.level == logging.NOTSET or root.level > level: + root.setLevel(level) +``` + +The function is a no-op outside the cluster (the SDK packages aren't installed), so the same call site works locally. + +### 4. Custom metrics + +Custom metrics live in `app/metrics.py`. The shape: + +```python +from opentelemetry import metrics + +_meter = metrics.get_meter("", "") + +work_done = _meter.create_counter( + "agent..runs", + unit="1", + description="Work units processed by outcome.", +) + +work_duration = _meter.create_histogram( + "agent..duration", + unit="s", + description="End-to-end work latency.", +) +``` + +Naming rules (mandatory): + +- Prefix all metric names with `agent..` where `` matches `agent.type` in `OTEL_RESOURCE_ATTRIBUTES`. +- Counters: noun (`runs`, `tokens`, `errors`). +- Histograms: noun + `.duration` (seconds). +- Use `unit="1"` for counts, `unit="s"` for seconds, `unit="By"` for bytes. + +Recording from a route handler: + +```python +from .metrics import work_done, work_duration + +async def handler(request): + started = time.perf_counter() + try: + outcome = await do_the_thing(...) + work_done.add(1, {"outcome": "success"}) + return JSONResponse(outcome) + except ValueError as exc: + work_done.add(1, {"outcome": "bad_request"}) + raise + except Exception: + work_done.add(1, {"outcome": "error"}) + raise + finally: + work_duration.record(time.perf_counter() - started) +``` + +Prefer **inline metric calls at every return path** over outer `try/finally` wrappers — it avoids indentation churn and reads cleaner. Use `try/finally` only for histograms that always need to record regardless of outcome. + +Do **not** create `MeterProvider` instances or call `set_meter_provider()` — the operator owns the global provider. + +### 5. LangChain / LLM instrumentation + +Agents that call an LLM via LangChain **MUST** wire OpenInference: + +```python +from openinference.instrumentation.langchain import LangChainInstrumentor + +LangChainInstrumentor().instrument() +``` + +Call this **once**, before any `AzureChatOpenAI(...)` (or other chat-model) construction. It emits OpenInference spans with token counts, prompt/response payloads, and tool calls — visible in Tempo and surfacable as LLM dashboards. + +Multiple `instrument()` calls are safe but log a warning. If you compose modules across `__main__` and `app.agent`, expect to see the warning once per import path; this is harmless. + +## Verifying signals end-to-end + +After deploying, check each signal flows: + +```bash +# Logs — should appear in Loki within ~30s +kubectl logs deployment/ -c | head + +# Metrics — query Prometheus for the meter name +curl -s "$PROM/api/v1/query?query=agent__runs_total" | jq + +# Traces — search Tempo for service.name +curl -s "$TEMPO/api/search?tags=service.name=" | jq + +# LLM token usage (if instrumented) +curl -s "$PROM/api/v1/query?query=gen_ai_client_token_usage_sum" | jq +``` + +## Common pitfalls + +| Pitfall | Symptom | Fix | +|---|---|---| +| Forgot the logging bridge | Logs appear in `kubectl logs` but not Loki. | Add `configure_otlp_log_handler()` in `app/agent.py`. | +| `OTEL_SEMCONV_STABILITY_OPT_IN` missing | Dashboards show no HTTP server latency. | Add the env var; redeploy. | +| `OTEL_RESOURCE_ATTRIBUTES` `service.version` drifts from `.image-version` | Dashboards group by version are wrong. | Bump both together; consider templating in `deployment.yaml`. | +| `LangChainInstrumentor()` called after LLM init | Spans missing token counts. | Move `.instrument()` to before `AzureChatOpenAI(...)`. | +| Custom metric names not prefixed `agent..` | Metric collides with another agent's. | Rename and bump minor version. | diff --git a/docs/registration.md b/docs/registration.md new file mode 100644 index 0000000..dc0e7ea --- /dev/null +++ b/docs/registration.md @@ -0,0 +1,71 @@ +# Self-registration with the agent-gateway + +> Normative requirements: [SPEC.md §5](../SPEC.md#5-self-registration-with-the-gateway-must) + +Every agent self-registers with the agent-gateway on startup. The gateway is the single Service in front of the registry catalogue and the proxy through which other agents (and Backstage) reach this agent's endpoints. + +## Topology + +```mermaid +sequenceDiagram + participant A as Agent pod + participant G as agent-gateway + Note over A: lifespan startup + A->>A: asyncio.sleep(2s)
(let uvicorn bind) + A->>G: POST /agents/register-url
{"endpoint": AGENT_SELF_URL} + G->>A: GET {AGENT_SELF_URL}/.well-known/agent.json + A-->>G: 200 OK + manifest + G-->>A: 201 Created (registration row) +``` + +## Configuration + +Two environment variables drive the flow. Both **MUST** be set in the agent's ConfigMap. + +| Var | Example | Purpose | +|---|---|---| +| `REGISTRY_URL` | `http://agent-gateway.agents.svc.cluster.local` | The gateway's base URL. | +| `AGENT_SELF_URL` | `http://policy-transformer.agents.svc.cluster.local` | The URL the gateway should call back on. | + +Notes: + +- The registry validates `AGENT_SELF_URL` against an SSRF allow-list. The host **MUST** end in `.svc.cluster.local` or `.kyndemo.live` and use `http` or `https`. Anything else is rejected with `400 Bad Request`. +- Cross-namespace `AGENT_SELF_URL` values are fine as long as the URL is resolvable in cluster DNS. +- The trailing path on `AGENT_SELF_URL` **MUST** be `/` (or empty); the gateway appends `/.well-known/agent.json` itself. + +## The registration call + +The canonical implementation lives in [`template/app/agent.py`](../template/app/agent.py). Key invariants: + +- Initial sleep of 2 seconds **before** the first attempt — uvicorn needs time to bind the listen socket and the manifest route. +- 3 attempts total, 5 seconds between attempts, 10-second timeout per request. +- HTTP 200 *or* 201 are both treated as success. +- Logging: + - `INFO` once on success. + - `WARNING` per failed attempt with `HTTP ` + body excerpt. + - `ERROR` once on final failure (after the third attempt). +- The task is created via `asyncio.create_task` inside the Starlette `lifespan`, so it does **not** block uvicorn from accepting traffic. A failed registration **MUST NOT** crash the agent. + +## What the gateway does next + +After the agent's `POST /agents/register-url` succeeds, the gateway performs: + +1. `GET {endpoint}/.well-known/agent.json` (hardcoded path). +2. Parses the manifest into an `AgentRegistration` row keyed by the manifest's `name`, normalised to lower-hyphen-case. +3. Registers the agent in the catalogue. +4. Routes inbound traffic from `/proxy/agent-gateway/{agent_name}/{path}` to `{endpoint}/{path}`. + +Therefore the manifest at `AGENT_SELF_URL` **MUST** be reachable and **MUST** match the schema in [docs/manifest.md](manifest.md). + +## Failure modes & resolution + +| Symptom | Cause | Fix | +|---|---|---| +| `HTTP 400 — Failed to fetch agent card from .../.well-known/agent.json: HTTP 404` | The agent has no manifest route. | Add `Route("/.well-known/agent.json", agent_manifest, methods=["GET"])`. | +| `HTTP 400 — ... HTTP 500` | The manifest route raises (e.g. `AgentSkill.model_dump` `AttributeError`). | Use the defensive `_skill_dict()` helper from the template. | +| `HTTP 400 — endpoint URL not allowed` | `AGENT_SELF_URL` host does not end in `.svc.cluster.local` or `.kyndemo.live`. | Fix the ConfigMap. | +| Three `WARNING`s + final `ERROR` but agent serves traffic fine | Gateway pod was restarting during agent startup. | Restart the agent pod once the gateway is `Ready`. Re-registration on next pod restart will recover. | + +## Local development + +When running outside the cluster, leave `REGISTRY_URL` unset (or set it to `""`) — the agent will log `REGISTRY_URL not set — skipping self-registration` and continue. diff --git a/template/.env.local.example b/template/.env.local.example new file mode 100644 index 0000000..abc1dbf --- /dev/null +++ b/template/.env.local.example @@ -0,0 +1,14 @@ +# ─── Self-registration (see docs/registration.md) ─────────────────────── +# Leave REGISTRY_URL blank to skip registration during local dev. +REGISTRY_URL= +AGENT_SELF_URL=http://localhost:8000 + +# ─── Server ───────────────────────────────────────────────────────────── +LOG_LEVEL=INFO +PORT=8000 + +# ─── Azure OpenAI (if your agent calls an LLM) ────────────────────────── +AZURE_OPENAI_ENDPOINT= +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_DEPLOYMENT=gpt-4o +AZURE_OPENAI_API_VERSION=2024-08-01-preview diff --git a/template/.gitignore b/template/.gitignore new file mode 100644 index 0000000..f3da050 --- /dev/null +++ b/template/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.venv/ +venv/ +.python-version + +# Local dev +.env.local +*.log + +# Editors +.vscode/ +.idea/ +.DS_Store diff --git a/template/.image-version b/template/.image-version new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/template/.image-version @@ -0,0 +1 @@ +0.1.0 diff --git a/template/Dockerfile b/template/Dockerfile new file mode 100644 index 0000000..3a7f480 --- /dev/null +++ b/template/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +RUN useradd --create-home --uid 1001 appuser \ + && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fs http://localhost:8000/health || exit 1 + +CMD ["uvicorn", "app.agent:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/template/README.md b/template/README.md new file mode 100644 index 0000000..2a905fb --- /dev/null +++ b/template/README.md @@ -0,0 +1,77 @@ +# Reference template — `` + +> **Spec version:** 1.0.0 — see [`../SPEC.md`](../SPEC.md) + +A working, copy-and-fill skeleton for a new Python/Starlette agent that conforms to the harness spec. + +## Quickstart + +```bash +# 1. Copy the template +cp -r template/ ../my-new-agent/ +cd ../my-new-agent + +# 2. Rename placeholders +git grep -l '\|\|\|' | xargs sed -i \ + -e 's//my-new-agent/g' \ + -e 's//my_new_agent/g' \ + -e 's//MyNewAgent/g' \ + -e 's//my-new-agent/g' + +# 3. Pin the initial version +echo "0.1.0" > .image-version + +# 4. Add your domain code +# - Replace the `/echo` route in app/agent.py with your endpoints +# - Add your skills to app/skills.py +# - Add custom metrics to app/metrics.py + +# 5. Configure local dev +cp .env.local.example .env.local +# edit .env.local with your secrets + +# 6. Run locally +./scripts/deploy-local.sh + +# 7. Build & deploy to the cluster +./scripts/full-deploy.sh +``` + +## What's in the box + +``` +template/ +├── .env.local.example # all env vars the agent reads, with safe defaults +├── .gitignore +├── .image-version # 0.1.0 +├── Dockerfile # python:3.12-slim, non-root, HEALTHCHECK +├── README.md # (this file) +├── requirements.txt # harness floor + LLM stack +├── app/ +│ ├── __init__.py +│ ├── agent.py # Starlette entry point, all required routes, +│ │ # lifespan + self-registration, _skill_dict helper +│ ├── logging_setup.py # OTel log bridge (copy verbatim — do not edit) +│ ├── metrics.py # Meter + example counter/histogram +│ └── skills.py # AGENT_SKILLS list + AGENT_CONFIG dict +├── k8s/ +│ ├── configmap.yaml # REGISTRY_URL, AGENT_SELF_URL, LOG_LEVEL, PORT +│ └── deployment.yaml # Deployment + Service, OTel annotations + env block +└── scripts/ + ├── deploy.sh # az acr build + kubectl apply + rollout + ├── deploy-local.sh # docker/podman + .env.local + └── full-deploy.sh # preflight + deploy.sh + post-deploy smoke +``` + +## Editing rules + +| Touch freely | Touch carefully | Do not edit | +|---|---|---| +| `app/skills.py` | `app/agent.py` (keep lifespan + registration + manifest intact) | `app/logging_setup.py` | +| `app/metrics.py` (rename instruments to your domain) | `k8s/deployment.yaml` env vars (only change `OTEL_*` values, not keys) | The `_skill_dict` helper in `app/agent.py` | +| `requirements.txt` (add your domain deps) | `Dockerfile` (only change `EXPOSE` and the uvicorn port) | `scripts/deploy.sh` flow | +| `k8s/configmap.yaml` data section | `k8s/deployment.yaml` probes (only `initialDelaySeconds` for slow startup) | OTel annotations on the pod template | + +## Conformance + +Before opening a PR for your new agent, walk the [conformance checklist](../SPEC.md#conformance-checklist) in `SPEC.md`. The reference template ticks every box out of the box; you should only need to verify your domain-specific additions don't regress anything. diff --git a/template/app/__init__.py b/template/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/app/agent.py b/template/app/agent.py new file mode 100644 index 0000000..8938393 --- /dev/null +++ b/template/app/agent.py @@ -0,0 +1,185 @@ +""" — Starlette entry point. + +This module is the canonical agent harness. It implements the cross-cutting +plumbing every agent on the platform must ship: + + - GET /health liveness/readiness probe + - GET /.well-known/agent.json A2A discovery manifest + - lifespan-driven self-registration with the agent-gateway + - OTel log bridge wired at import time + - LangChain instrumentation wired before any LLM client construction + +Replace the example `/echo` route with your domain endpoints. +""" +from __future__ import annotations + +import asyncio +import logging +import os +import time +from contextlib import asynccontextmanager + +import httpx +from dotenv import load_dotenv +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +try: + from openinference.instrumentation.langchain import LangChainInstrumentor +except ImportError: + LangChainInstrumentor = None # type: ignore[assignment] + +from .logging_setup import configure_otlp_log_handler +from .metrics import echo_duration, echoes +from .skills import AGENT_CONFIG, AGENT_SKILLS + +# ─── One-shot startup wiring ──────────────────────────────────────────── +load_dotenv(".env.local") + +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") +configure_otlp_log_handler(getattr(logging, LOG_LEVEL.upper(), logging.INFO)) +logger = logging.getLogger(__name__) + +if LangChainInstrumentor is not None: + try: + LangChainInstrumentor().instrument() + except Exception as exc: # pragma: no cover — instrumentation failure is non-fatal + logger.warning("LangChainInstrumentor failed to attach: %s", exc) + +# ─── Configuration ────────────────────────────────────────────────────── +REGISTRY_URL = os.getenv("REGISTRY_URL", "").strip() +AGENT_SELF_URL = os.getenv( + "AGENT_SELF_URL", + "http://.agents.svc.cluster.local", +).strip() +PORT = int(os.getenv("PORT", "8000")) + + +# ─── Skill serialisation (handles pydantic + protobuf AgentSkill) ─────── +def _skill_dict(s) -> dict: + """Serialise an A2A skill to a JSON-safe dict. + + a2a-sdk exposes AgentSkill as a protobuf Message in current releases, so + `s.model_dump()` raises AttributeError. Handle pydantic v2, pydantic v1, + protobuf, and fall back to attribute scraping. + """ + if hasattr(s, "model_dump"): + return s.model_dump() + if hasattr(s, "DESCRIPTOR"): + try: + from google.protobuf.json_format import MessageToDict + return MessageToDict(s, preserving_proto_field_name=True) + except Exception: # pragma: no cover + pass + if hasattr(s, "dict") and callable(getattr(s, "dict")): + try: + return s.dict() + except Exception: # pragma: no cover + pass + return { + "id": getattr(s, "id", None), + "name": getattr(s, "name", None), + "description": getattr(s, "description", ""), + "tags": list(getattr(s, "tags", []) or []), + "examples": list(getattr(s, "examples", []) or []), + } + + +# ─── Route handlers ───────────────────────────────────────────────────── +async def health_check(request: Request) -> JSONResponse: + return JSONResponse({ + "status": "healthy", + "service": AGENT_CONFIG["name"], + "version": AGENT_CONFIG["version"], + }) + + +async def agent_manifest(request: Request) -> JSONResponse: + """GET /.well-known/agent.json — discovery manifest consumed by agent-gateway.""" + return JSONResponse({ + "name": AGENT_CONFIG["name"], + "version": AGENT_CONFIG["version"], + "description": AGENT_CONFIG["description"], + "url": "/", + "skills": [_skill_dict(s) for s in AGENT_SKILLS], + "capabilities": AGENT_CONFIG["capabilities"], + }) + + +async def echo(request: Request) -> JSONResponse: + """POST /echo — example endpoint. Replace with your domain logic.""" + started = time.perf_counter() + try: + body = await request.json() + except Exception: + echoes.add(1, {"outcome": "bad_request"}) + return JSONResponse({"error": "invalid JSON body"}, status_code=400) + + try: + response = {"received": body} + echoes.add(1, {"outcome": "success"}) + return JSONResponse(response) + except Exception as exc: + echoes.add(1, {"outcome": "error"}) + logger.exception("echo failed: %s", exc) + return JSONResponse({"error": str(exc)}, status_code=500) + finally: + echo_duration.record(time.perf_counter() - started) + + +# ─── Self-registration with the agent-gateway ─────────────────────────── +async def _register_with_registry() -> None: + """POST our endpoint to the registry; it will GET /.well-known/agent.json back.""" + if not REGISTRY_URL: + logger.info("REGISTRY_URL not set — skipping self-registration") + return + + await asyncio.sleep(2) # let uvicorn finish binding before the gateway calls back + + url = f"{REGISTRY_URL.rstrip('/')}/agents/register-url" + payload = {"endpoint": AGENT_SELF_URL} + + for attempt in range(3): + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(url, json=payload) + if resp.status_code in (200, 201): + logger.info("Self-registered with agent-registry at %s", REGISTRY_URL) + return + logger.warning( + "Registry registration attempt %d: HTTP %d — %s", + attempt + 1, resp.status_code, resp.text[:200], + ) + except Exception as exc: + logger.warning("Registry registration attempt %d failed: %s", attempt + 1, exc) + await asyncio.sleep(5) + + logger.error("Failed to self-register with agent-registry after 3 attempts") + + +@asynccontextmanager +async def lifespan(app: Starlette): + register_task = asyncio.create_task(_register_with_registry()) + try: + yield + finally: + register_task.cancel() + + +# ─── Application ──────────────────────────────────────────────────────── +app = Starlette( + routes=[ + Route("/health", health_check, methods=["GET"]), + Route("/.well-known/agent.json", agent_manifest, methods=["GET"]), + Route("/echo", echo, methods=["POST"]), + ], + lifespan=lifespan, +) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("app.agent:app", host="0.0.0.0", port=PORT, log_level=LOG_LEVEL.lower()) diff --git a/template/app/logging_setup.py b/template/app/logging_setup.py new file mode 100644 index 0000000..21e5794 --- /dev/null +++ b/template/app/logging_setup.py @@ -0,0 +1,34 @@ +"""Bridge Python stdlib logging into the OTel LoggerProvider. + +The OTel Operator's Python auto-instrumentation creates a LoggerProvider with +an OTLP BatchLogRecordProcessor, but it does not attach a LoggingHandler bridge +to Python's root logger — so stdlib logger.info(...) calls never reach the +collector. Call configure_otlp_log_handler() once at startup to close that gap. + +This module is part of the platform agent harness — do not edit. The canonical +copy lives in the agent-harness-spec repo and is copied verbatim into each +agent. If you need to change the bridge, change it there and re-roll. +""" +from __future__ import annotations + +import logging + + +def configure_otlp_log_handler(level: int = logging.INFO) -> None: + try: + from opentelemetry._logs import get_logger_provider + from opentelemetry.sdk._logs import LoggingHandler + except ImportError: + return + + provider = get_logger_provider() + if provider is None or type(provider).__name__ == "NoOpLoggerProvider": + return + + root = logging.getLogger() + if any(isinstance(h, LoggingHandler) for h in root.handlers): + return + + root.addHandler(LoggingHandler(level=level, logger_provider=provider)) + if root.level == logging.NOTSET or root.level > level: + root.setLevel(level) diff --git a/template/app/metrics.py b/template/app/metrics.py new file mode 100644 index 0000000..1631e17 --- /dev/null +++ b/template/app/metrics.py @@ -0,0 +1,33 @@ +"""OTel metric instruments for . + +All metrics flow through the global MeterProvider installed by the OTel +Operator's init container. The provider exports OTLP to the cluster tier-1 +collector, which forwards metrics to Prometheus via tier-2's remote-write. + +Naming convention (see SPEC §7.4): + + agent.. — counters (unit="1") + agent...duration — histograms in seconds (unit="s") + +Replace `` and the example instruments with your own. +""" +from __future__ import annotations + +from opentelemetry import metrics + +# The (name, version) pair becomes `otel_scope_name` and `otel_scope_version` +# attributes on every metric — useful for filtering in PromQL. +_meter = metrics.get_meter("", "0.1.0") + +# ─── Example: an "echo" operation counter and latency histogram ───────── +echoes = _meter.create_counter( + "agent..echoes", + unit="1", + description="Echo requests by outcome.", +) + +echo_duration = _meter.create_histogram( + "agent..echo.duration", + unit="s", + description="End-to-end /echo latency.", +) diff --git a/template/app/skills.py b/template/app/skills.py new file mode 100644 index 0000000..32b5adf --- /dev/null +++ b/template/app/skills.py @@ -0,0 +1,40 @@ +"""A2A skill definitions + agent config for . + +See docs/manifest.md for the manifest schema this feeds into. +""" +from __future__ import annotations + +from a2a.types import AgentSkill + +# ─── Agent identity ───────────────────────────────────────────────────── +# The version here MUST match the contents of ../.image-version and the +# `service.version` token in OTEL_RESOURCE_ATTRIBUTES. +AGENT_CONFIG: dict = { + "name": "", + "version": "0.1.0", + "description": ( + "One- to three-sentence description of what this agent does, " + "phrased for a non-engineering audience." + ), + "capabilities": { + "streaming": False, + "conversational": False, + "direct_api": True, + }, +} + +# ─── Skills ───────────────────────────────────────────────────────────── +AGENT_SKILLS: list[AgentSkill] = [ + AgentSkill( + id="echo", + name="Echo", + description=( + "A trivial skill that returns the request payload verbatim. " + "Replace with your real skills." + ), + tags=["example", "echo"], + examples=[ + "Echo: hello world", + ], + ), +] diff --git a/template/k8s/configmap.yaml b/template/k8s/configmap.yaml new file mode 100644 index 0000000..d2c10b1 --- /dev/null +++ b/template/k8s/configmap.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: -config + namespace: agents +data: + # ─── Self-registration (docs/registration.md) ───────────────────────── + REGISTRY_URL: "http://agent-gateway.agents.svc.cluster.local" + AGENT_SELF_URL: "http://.agents.svc.cluster.local" + + # ─── Server ─────────────────────────────────────────────────────────── + LOG_LEVEL: "INFO" + PORT: "8000" + + # ─── LLM (delete if your agent does not call an LLM) ────────────────── + AZURE_OPENAI_DEPLOYMENT: "gpt-4o" + AZURE_OPENAI_API_VERSION: "2024-08-01-preview" diff --git a/template/k8s/deployment.yaml b/template/k8s/deployment.yaml new file mode 100644 index 0000000..d0feb44 --- /dev/null +++ b/template/k8s/deployment.yaml @@ -0,0 +1,117 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: + namespace: agents + labels: + app: + component: agent +spec: + replicas: 1 + selector: + matchLabels: + app: + template: + metadata: + labels: + app: + azure.workload.identity/use: "true" + annotations: + # ─── OTel SDK injection ───────────────────────────────────────── + instrumentation.opentelemetry.io/inject-python: "monitoring/otel-instrumentation" + instrumentation.opentelemetry.io/container-names: "" + spec: + serviceAccountName: agents-sa + containers: + - name: + image: bstagecjotdevacr.azurecr.io/:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + protocol: TCP + envFrom: + - configMapRef: + name: -config + env: + # ─── OTel (mandatory — keep these keys verbatim) ─────────── + - name: OTEL_SERVICE_NAME + value: "" + - name: OTEL_RESOURCE_ATTRIBUTES + value: "service.namespace=agents,service.version=0.1.0,deployment.environment=prod,agent.type=" + - name: OTEL_LOGS_EXPORTER + value: "otlp" + - name: OTEL_METRICS_EXPORTER + value: "otlp" + - name: OTEL_METRIC_EXPORT_INTERVAL + value: "15000" + - name: OTEL_SEMCONV_STABILITY_OPT_IN + value: "http" + + # ─── Secrets from agents-kv-sync (sync'd from Key Vault) ─── + - name: AZURE_OPENAI_ENDPOINT + valueFrom: + secretKeyRef: + name: agents-kv-sync + key: azure-openai-endpoint + - name: AZURE_OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: agents-kv-sync + key: azure-openai-api-key + + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + + volumeMounts: + - name: secrets-store + mountPath: "/mnt/secrets-store" + readOnly: true + + volumes: + - name: secrets-store + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: agents-kv-spc +--- +apiVersion: v1 +kind: Service +metadata: + name: + namespace: agents + labels: + app: +spec: + selector: + app: + ports: + - name: http + port: 80 + targetPort: 8000 + protocol: TCP + type: ClusterIP diff --git a/template/requirements.txt b/template/requirements.txt new file mode 100644 index 0000000..65ad2d0 --- /dev/null +++ b/template/requirements.txt @@ -0,0 +1,24 @@ +# ─── Web server ───────────────────────────────────────────────────────── +uvicorn[standard]>=0.32.1 +starlette>=0.40.0 +pydantic>=2.11.0 +python-dotenv>=1.0.0 +httpx>=0.27.0 + +# ─── A2A skill model (optional but recommended) ───────────────────────── +# Skill definitions are protobuf-backed in current releases; the +# _skill_dict helper in app/agent.py handles serialisation either way. +a2a-sdk[http-server]>=0.3.0 + +# ─── OpenTelemetry ────────────────────────────────────────────────────── +# The OTel Operator init container ships the SDK; the agent only needs +# the API surface plus the LangChain instrumentor for LLM spans. +opentelemetry-api>=1.27.0 +openinference-instrumentation-langchain>=0.1.29 + +# ─── LLM stack (delete if your agent does not call an LLM) ────────────── +langchain>=0.3.0 +langchain-openai>=0.2.0 +langchain-core>=0.3.0 +langgraph>=0.2.59 +openai>=1.50.0 diff --git a/template/scripts/deploy-local.sh b/template/scripts/deploy-local.sh new file mode 100755 index 0000000..1344936 --- /dev/null +++ b/template/scripts/deploy-local.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# deploy-local.sh — Build the image locally and run it against .env.local. +# +# Useful for iterating on prompts/skills without touching the cluster. + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +AGENT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd) + +APP_NAME="" +PORT=8000 +ENV_FILE="${AGENT_DIR}/.env.local" + +if [ ! -f "${ENV_FILE}" ]; then + echo "ERROR: ${ENV_FILE} not found. Copy .env.local.example first." >&2 + exit 1 +fi + +# Pick docker or podman +if command -v docker >/dev/null; then + RUNTIME=docker +elif command -v podman >/dev/null; then + RUNTIME=podman +else + echo "ERROR: neither docker nor podman found on PATH" >&2 + exit 1 +fi + +echo "Building ${APP_NAME}:local with ${RUNTIME}..." +"${RUNTIME}" build -t "${APP_NAME}:local" "${AGENT_DIR}" + +echo "Running ${APP_NAME}:local on http://localhost:${PORT} ..." +exec "${RUNTIME}" run --rm -it \ + --name "${APP_NAME}" \ + -p "${PORT}:${PORT}" \ + --env-file "${ENV_FILE}" \ + "${APP_NAME}:local" diff --git a/template/scripts/deploy.sh b/template/scripts/deploy.sh new file mode 100755 index 0000000..dbe1d09 --- /dev/null +++ b/template/scripts/deploy.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# deploy.sh — Build image in ACR and deploy to AKS. +# +# Usage: +# ./scripts/deploy.sh # auto-bump patch version +# ./scripts/deploy.sh --tag 1.2.3 # use an explicit tag (does not bump) +# +# Conforms to platform agent-harness-spec §10. + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +AGENT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd) + +ACR_NAME="bstagecjotdevacr" +APP_NAME="" +NAMESPACE="agents" +VERSION_FILE="${AGENT_DIR}/.image-version" +TAG="" + +# ─── Parse args ───────────────────────────────────────────────────────── +while [[ "$#" -gt 0 ]]; do + case $1 in + --tag) + TAG="$2" + shift + ;; + -h|--help) + sed -n '2,8p' "$0" + exit 0 + ;; + *) + echo "unknown arg: $1" >&2 + exit 2 + ;; + esac + shift +done + +# ─── Auto-bump patch version if --tag not given ───────────────────────── +if [ -z "$TAG" ]; then + if [ -f "$VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + else + CURRENT_VERSION="0.1.0" + fi + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + PATCH=$((PATCH + 1)) + TAG="${MAJOR}.${MINOR}.${PATCH}" + echo "$TAG" > "$VERSION_FILE" +fi + +IMAGE="${ACR_NAME}.azurecr.io/${APP_NAME}:${TAG}" +IMAGE_LATEST="${ACR_NAME}.azurecr.io/${APP_NAME}:latest" + +echo "======================================================================" +echo " ${APP_NAME} — build & deploy" +echo " Image: ${IMAGE}" +echo "======================================================================" + +# ─── Build & push in ACR ──────────────────────────────────────────────── +az acr build --registry "$ACR_NAME" \ + --image "${APP_NAME}:${TAG}" \ + --image "${APP_NAME}:latest" \ + "${AGENT_DIR}" + +# ─── Apply manifests ──────────────────────────────────────────────────── +kubectl apply -f "${AGENT_DIR}/k8s/configmap.yaml" +kubectl apply -f "${AGENT_DIR}/k8s/deployment.yaml" + +# ─── Wait for rollout ─────────────────────────────────────────────────── +kubectl -n "${NAMESPACE}" rollout status "deployment/${APP_NAME}" --timeout=180s + +echo "" +echo "Deployed ${IMAGE} to ${NAMESPACE}/${APP_NAME}" diff --git a/template/scripts/full-deploy.sh b/template/scripts/full-deploy.sh new file mode 100755 index 0000000..c06adb8 --- /dev/null +++ b/template/scripts/full-deploy.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# full-deploy.sh — Preflight checks + build + deploy + post-deploy smoke. +# +# Run this for first-time deploys or after an outage to validate cluster state. +# For routine redeploys, use scripts/deploy.sh. + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +AGENT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd) + +APP_NAME="" +NAMESPACE="agents" +SA_NAME="agents-sa" +SPC_NAME="agents-kv-spc" +PORT=8000 + +fail() { echo "preflight FAIL: $1" >&2; exit 1; } +ok() { echo "preflight OK : $1"; } + +# ─── Preflight ────────────────────────────────────────────────────────── +echo "─── Preflight ──────────────────────────────────────────" + +command -v kubectl >/dev/null || fail "kubectl not on PATH" +ok "kubectl on PATH" + +command -v az >/dev/null || fail "az CLI not on PATH" +ok "az CLI on PATH" + +kubectl cluster-info >/dev/null 2>&1 || fail "kubectl can't reach a cluster" +ok "cluster reachable: $(kubectl config current-context)" + +kubectl get namespace "${NAMESPACE}" >/dev/null 2>&1 \ + || fail "namespace ${NAMESPACE} missing" +ok "namespace ${NAMESPACE} exists" + +kubectl -n "${NAMESPACE}" get serviceaccount "${SA_NAME}" >/dev/null 2>&1 \ + || fail "ServiceAccount ${NAMESPACE}/${SA_NAME} missing" +ok "ServiceAccount ${SA_NAME} exists" + +kubectl -n "${NAMESPACE}" get secretproviderclass "${SPC_NAME}" >/dev/null 2>&1 \ + || fail "SecretProviderClass ${NAMESPACE}/${SPC_NAME} missing" +ok "SecretProviderClass ${SPC_NAME} exists" + +echo "" + +# ─── Build & deploy ───────────────────────────────────────────────────── +echo "─── Build & deploy ─────────────────────────────────────" +"${SCRIPT_DIR}/deploy.sh" "$@" + +echo "" + +# ─── Post-deploy smoke ────────────────────────────────────────────────── +echo "─── Post-deploy smoke ──────────────────────────────────" + +POD=$(kubectl -n "${NAMESPACE}" get pod -l "app=${APP_NAME}" \ + -o jsonpath='{.items[0].metadata.name}') + +echo "Probing /health on ${POD}..." +kubectl -n "${NAMESPACE}" exec "${POD}" -c "${APP_NAME}" -- \ + curl -fs "http://localhost:${PORT}/health" \ + | jq -e '.status == "healthy"' >/dev/null \ + && ok "/health returns healthy" \ + || fail "/health did not return healthy" + +echo "Probing /.well-known/agent.json on ${POD}..." +kubectl -n "${NAMESPACE}" exec "${POD}" -c "${APP_NAME}" -- \ + curl -fs "http://localhost:${PORT}/.well-known/agent.json" \ + | jq -e '.name and .version and (.skills | type == "array")' >/dev/null \ + && ok "/.well-known/agent.json valid" \ + || fail "/.well-known/agent.json invalid" + +echo "" +echo "Done. ${APP_NAME} is up and conforming to the harness spec."