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:
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