feat: initial harness spec v1.0.0 + reference template
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)
This commit is contained in:
34
CHANGELOG.md
Normal file
34
CHANGELOG.md
Normal file
@@ -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.<type>.*` 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.
|
||||||
33
README.md
33
README.md
@@ -1,3 +1,34 @@
|
|||||||
# agent-harness-spec
|
# agent-harness-spec
|
||||||
|
|
||||||
Standard definition and specification for the agent harness used across the platform.
|
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) |
|
||||||
325
SPEC.md
Normal file
325
SPEC.md
Normal file
@@ -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 <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": "<human-readable agent name>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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://<agent-name>.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: "<container-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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: "<agent-name>"
|
||||||
|
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
value: "service.namespace=agents,service.version=<X.Y.Z>,deployment.environment=<env>,agent.type=<role>"
|
||||||
|
- 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("<agent-name>", "<X.Y.Z>")` and declares all custom instruments at module scope.
|
||||||
|
|
||||||
|
Naming **MUST** follow `agent.<agent_type>.<verb_or_noun>[.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 `:<version>` from `.image-version` and pushed to `bstagecjotdevacr.azurecr.io/<agent-name>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 <agent>:<tag> --image <agent>: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`.
|
||||||
133
docs/deployment.md
Normal file
133
docs/deployment.md
Normal file
@@ -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/<agent-name>:<X.Y.Z>
|
||||||
|
bstagecjotdevacr.azurecr.io/<agent-name>:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Both tags are pushed every build so that pinning to `:latest` works for sandbox/dev clusters and pinning to `:<version>` 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="<agent-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 `:<tag>` 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/<agent> -- curl -fs http://localhost:<port>/health`
|
||||||
|
- `kubectl exec deployment/<agent> -- curl -fs http://localhost:<port>/.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 <port>:<port>` 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 `:<sha>` tag without applying manifests.
|
||||||
|
|
||||||
|
## Rolling back
|
||||||
|
|
||||||
|
There is no automated rollback. To roll back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n agents set image deployment/<agent-name> \
|
||||||
|
<agent-name>=bstagecjotdevacr.azurecr.io/<agent-name>:<previous-version>
|
||||||
|
|
||||||
|
kubectl -n agents rollout status deployment/<agent-name> --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).
|
||||||
24
docs/deviations.md
Normal file
24
docs/deviations.md
Normal file
@@ -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.
|
||||||
198
docs/kubernetes.md
Normal file
198
docs/kubernetes.md
Normal file
@@ -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: <agent-name>-config
|
||||||
|
namespace: agents
|
||||||
|
data:
|
||||||
|
# Required — see docs/registration.md
|
||||||
|
REGISTRY_URL: "http://agent-gateway.agents.svc.cluster.local"
|
||||||
|
AGENT_SELF_URL: "http://<agent-name>.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: <agent-name>
|
||||||
|
namespace: agents
|
||||||
|
labels:
|
||||||
|
app: <agent-name>
|
||||||
|
component: agent
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: <agent-name>
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: <agent-name>
|
||||||
|
azure.workload.identity/use: "true"
|
||||||
|
annotations:
|
||||||
|
instrumentation.opentelemetry.io/inject-python: "monitoring/otel-instrumentation"
|
||||||
|
instrumentation.opentelemetry.io/container-names: "<agent-name>"
|
||||||
|
spec:
|
||||||
|
serviceAccountName: agents-sa
|
||||||
|
containers:
|
||||||
|
- name: <agent-name>
|
||||||
|
image: bstagecjotdevacr.azurecr.io/<agent-name>:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8000
|
||||||
|
protocol: TCP
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: <agent-name>-config
|
||||||
|
env:
|
||||||
|
# ─── OTel (mandatory) ───────────────────────────────────────────
|
||||||
|
- name: OTEL_SERVICE_NAME
|
||||||
|
value: "<agent-name>"
|
||||||
|
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
value: "service.namespace=agents,service.version=1.0.0,deployment.environment=prod,agent.type=<role>"
|
||||||
|
- 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: <agent-name>
|
||||||
|
namespace: agents
|
||||||
|
labels:
|
||||||
|
app: <agent-name>
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: <agent-name>
|
||||||
|
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://<agent>.<namespace>.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.
|
||||||
138
docs/manifest.md
Normal file
138
docs/manifest.md
Normal file
@@ -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/<agent> -c <container> -- \
|
||||||
|
curl -fs http://localhost:<port>/.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.
|
||||||
209
docs/observability.md
Normal file
209
docs/observability.md
Normal file
@@ -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-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: "<agent-name>"
|
||||||
|
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
value: "service.namespace=agents,service.version=<X.Y.Z>,deployment.environment=<env>,agent.type=<role>"
|
||||||
|
- 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("<agent-name>", "<X.Y.Z>")
|
||||||
|
|
||||||
|
work_done = _meter.create_counter(
|
||||||
|
"agent.<type>.runs",
|
||||||
|
unit="1",
|
||||||
|
description="Work units processed by outcome.",
|
||||||
|
)
|
||||||
|
|
||||||
|
work_duration = _meter.create_histogram(
|
||||||
|
"agent.<type>.duration",
|
||||||
|
unit="s",
|
||||||
|
description="End-to-end work latency.",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Naming rules (mandatory):
|
||||||
|
|
||||||
|
- Prefix all metric names with `agent.<type>.` where `<type>` 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/<agent> -c <agent-name> | head
|
||||||
|
|
||||||
|
# Metrics — query Prometheus for the meter name
|
||||||
|
curl -s "$PROM/api/v1/query?query=agent_<type>_runs_total" | jq
|
||||||
|
|
||||||
|
# Traces — search Tempo for service.name
|
||||||
|
curl -s "$TEMPO/api/search?tags=service.name=<agent-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.<type>.` | Metric collides with another agent's. | Rename and bump minor version. |
|
||||||
71
docs/registration.md
Normal file
71
docs/registration.md
Normal file
@@ -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)<br/>(let uvicorn bind)
|
||||||
|
A->>G: POST /agents/register-url<br/>{"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 <code>` + 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.
|
||||||
14
template/.env.local.example
Normal file
14
template/.env.local.example
Normal file
@@ -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
|
||||||
17
template/.gitignore
vendored
Normal file
17
template/.gitignore
vendored
Normal file
@@ -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
|
||||||
1
template/.image-version
Normal file
1
template/.image-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0.1.0
|
||||||
23
template/Dockerfile
Normal file
23
template/Dockerfile
Normal file
@@ -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"]
|
||||||
77
template/README.md
Normal file
77
template/README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Reference template — `<your-agent-name>`
|
||||||
|
|
||||||
|
> **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 '<agent-name>\|<agent_name>\|<AgentName>\|<role>' | xargs sed -i \
|
||||||
|
-e 's/<agent-name>/my-new-agent/g' \
|
||||||
|
-e 's/<agent_name>/my_new_agent/g' \
|
||||||
|
-e 's/<AgentName>/MyNewAgent/g' \
|
||||||
|
-e 's/<role>/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.
|
||||||
0
template/app/__init__.py
Normal file
0
template/app/__init__.py
Normal file
185
template/app/agent.py
Normal file
185
template/app/agent.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""<AgentName> — 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://<agent-name>.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())
|
||||||
34
template/app/logging_setup.py
Normal file
34
template/app/logging_setup.py
Normal file
@@ -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)
|
||||||
33
template/app/metrics.py
Normal file
33
template/app/metrics.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""OTel metric instruments for <agent-name>.
|
||||||
|
|
||||||
|
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.<agent_type>.<noun> — counters (unit="1")
|
||||||
|
agent.<agent_type>.<noun>.duration — histograms in seconds (unit="s")
|
||||||
|
|
||||||
|
Replace `<agent_name>` 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("<agent-name>", "0.1.0")
|
||||||
|
|
||||||
|
# ─── Example: an "echo" operation counter and latency histogram ─────────
|
||||||
|
echoes = _meter.create_counter(
|
||||||
|
"agent.<agent_name>.echoes",
|
||||||
|
unit="1",
|
||||||
|
description="Echo requests by outcome.",
|
||||||
|
)
|
||||||
|
|
||||||
|
echo_duration = _meter.create_histogram(
|
||||||
|
"agent.<agent_name>.echo.duration",
|
||||||
|
unit="s",
|
||||||
|
description="End-to-end /echo latency.",
|
||||||
|
)
|
||||||
40
template/app/skills.py
Normal file
40
template/app/skills.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""A2A skill definitions + agent config for <agent-name>.
|
||||||
|
|
||||||
|
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": "<AgentName>",
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
17
template/k8s/configmap.yaml
Normal file
17
template/k8s/configmap.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: <agent-name>-config
|
||||||
|
namespace: agents
|
||||||
|
data:
|
||||||
|
# ─── Self-registration (docs/registration.md) ─────────────────────────
|
||||||
|
REGISTRY_URL: "http://agent-gateway.agents.svc.cluster.local"
|
||||||
|
AGENT_SELF_URL: "http://<agent-name>.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"
|
||||||
117
template/k8s/deployment.yaml
Normal file
117
template/k8s/deployment.yaml
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: <agent-name>
|
||||||
|
namespace: agents
|
||||||
|
labels:
|
||||||
|
app: <agent-name>
|
||||||
|
component: agent
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: <agent-name>
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: <agent-name>
|
||||||
|
azure.workload.identity/use: "true"
|
||||||
|
annotations:
|
||||||
|
# ─── OTel SDK injection ─────────────────────────────────────────
|
||||||
|
instrumentation.opentelemetry.io/inject-python: "monitoring/otel-instrumentation"
|
||||||
|
instrumentation.opentelemetry.io/container-names: "<agent-name>"
|
||||||
|
spec:
|
||||||
|
serviceAccountName: agents-sa
|
||||||
|
containers:
|
||||||
|
- name: <agent-name>
|
||||||
|
image: bstagecjotdevacr.azurecr.io/<agent-name>:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8000
|
||||||
|
protocol: TCP
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: <agent-name>-config
|
||||||
|
env:
|
||||||
|
# ─── OTel (mandatory — keep these keys verbatim) ───────────
|
||||||
|
- name: OTEL_SERVICE_NAME
|
||||||
|
value: "<agent-name>"
|
||||||
|
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
value: "service.namespace=agents,service.version=0.1.0,deployment.environment=prod,agent.type=<role>"
|
||||||
|
- 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: <agent-name>
|
||||||
|
namespace: agents
|
||||||
|
labels:
|
||||||
|
app: <agent-name>
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: <agent-name>
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 8000
|
||||||
|
protocol: TCP
|
||||||
|
type: ClusterIP
|
||||||
24
template/requirements.txt
Normal file
24
template/requirements.txt
Normal file
@@ -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
|
||||||
38
template/scripts/deploy-local.sh
Executable file
38
template/scripts/deploy-local.sh
Executable file
@@ -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="<agent-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"
|
||||||
75
template/scripts/deploy.sh
Executable file
75
template/scripts/deploy.sh
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy.sh — Build <agent-name> 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="<agent-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}"
|
||||||
74
template/scripts/full-deploy.sh
Executable file
74
template/scripts/full-deploy.sh
Executable file
@@ -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="<agent-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."
|
||||||
Reference in New Issue
Block a user