Skip to content

Commit 84a5dc5

Browse files
authored
Merge pull request #12 from co-cddo/ap/feature/agentcore_runtime_base
feat: AgentCore CDK construct with built-in agent template
2 parents 3cba0b7 + 8706719 commit 84a5dc5

20 files changed

Lines changed: 4158 additions & 516 deletions

File tree

README.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,138 @@ This simplifies the deployment of containerised applications in the gds-idea tea
88
It is not designed to be used directly but it is a dependency in the [app templates repo](https://github.com/co-cddo/gds-idea-app-templates)
99
For instructions on usage please see the docs for gds-idea-app-templates.
1010

11+
## AgentCore
12+
13+
Deploys an [Amazon Bedrock AgentCore](https://docs.aws.amazon.com/bedrock/latest/userguide/agentcore.html) runtime with memory, permissions, and observability pre-configured. The built-in agent uses Strands Agent Framework.
14+
15+
### Quick start (zero-config)
16+
17+
Uses the built-in agent template with sensible defaults — no code to copy:
18+
19+
```python
20+
from gds_idea_cdk_constructs.agent_core import AgentCore, AgentCoreProperties
21+
22+
AgentCore(
23+
app,
24+
"MyAgent",
25+
props=AgentCoreProperties(runtime_name="my-agent"),
26+
)
27+
```
28+
29+
### Built-in agent with custom settings
30+
31+
Configure the model, system prompt, and memory without writing agent code:
32+
33+
```python
34+
from gds_idea_cdk_constructs.agent_core import (
35+
AgentCore,
36+
AgentCoreProperties,
37+
BuiltInAgent,
38+
ModelConfig,
39+
MemoryConfig,
40+
)
41+
42+
AgentCore(
43+
app,
44+
"MyAgent",
45+
props=AgentCoreProperties(
46+
runtime_name="my-data-agent",
47+
agent=BuiltInAgent(
48+
model=ModelConfig(
49+
model_id="eu.anthropic.claude-sonnet-4-6",
50+
max_tokens=8000,
51+
budget_tokens=4000,
52+
),
53+
system_prompt="You are a helpful data analyst.",
54+
log_level="DEBUG",
55+
),
56+
memory=MemoryConfig(name="my-memory"),
57+
),
58+
)
59+
```
60+
61+
To disable memory, pass `memory=None`.
62+
63+
### Custom agent code
64+
65+
For full control (adding tools, custom logic), use `CustomAgent`:
66+
67+
```python
68+
from gds_idea_cdk_constructs.agent_core import (
69+
AgentCore,
70+
AgentCoreProperties,
71+
CustomAgent,
72+
)
73+
74+
AgentCore(
75+
app,
76+
"MyAgent",
77+
props=AgentCoreProperties(
78+
runtime_name="my-agent",
79+
agent=CustomAgent(
80+
agent_code_directory="my_agent_code/",
81+
model_id="eu.anthropic.claude-sonnet-4-6",
82+
environment_variables={"MY_API_KEY": "secret"},
83+
),
84+
memory=None,
85+
),
86+
)
87+
```
88+
89+
Your directory must contain a `Dockerfile` and an `agent.py` entrypoint. The built-in `agent_template/` can be copied as a starting point.
90+
91+
The construct automatically injects these env vars into your container:
92+
93+
| Variable | When |
94+
|---|---|
95+
| `MODEL_ID` | Always |
96+
| `REGION` | Always |
97+
| `MEMORY_ID` | When `memory` is set |
98+
99+
### Configuration reference
100+
101+
#### `AgentCoreProperties`
102+
103+
| Property | Type | Default | Description |
104+
|---|---|---|---|
105+
| `runtime_name` | `str` | *(required)* | Unique name per account/region |
106+
| `agent` | `BuiltInAgent \| CustomAgent` | `BuiltInAgent()` | Agent mode |
107+
| `memory` | `MemoryConfig \| None` | `MemoryConfig()` | Memory config, or `None` to skip |
108+
| `description` | `str` | `"An AgentCore Runtime..."` | Runtime description |
109+
| `platform` | `Platform` | `LINUX_ARM64` | Docker build target |
110+
| `removal_policy` | `RemovalPolicy` | `DESTROY` | Removal policy for stateful resources |
111+
112+
#### `BuiltInAgent`
113+
114+
| Property | Type | Default | Description |
115+
|---|---|---|---|
116+
| `model` | `ModelConfig` | `ModelConfig()` | Model configuration |
117+
| `system_prompt` | `str` | `""` | System prompt (overrides default file) |
118+
| `log_level` | `str` | `"INFO"` | Log level |
119+
120+
#### `ModelConfig`
121+
122+
| Property | Type | Default | Description |
123+
|---|---|---|---|
124+
| `model_id` | `str` | `"eu.anthropic.claude-sonnet-4-6"` | Bedrock model ID |
125+
| `max_tokens` | `int` | `8000` | Max output tokens (thinking + reply) |
126+
| `budget_tokens` | `int` | `4000` | Thinking budget (must be < max_tokens) |
127+
| `thinking_enabled` | `bool` | `True` | Enable extended thinking |
128+
| `max_history` | `int` | `20` | Conversation turns to retain |
129+
130+
#### `CustomAgent`
131+
132+
| Property | Type | Default | Description |
133+
|---|---|---|---|
134+
| `agent_code_directory` | `str` | *(required)* | Path to agent code + Dockerfile |
135+
| `model_id` | `str` | `"eu.anthropic.claude-sonnet-4-6"` | Bedrock model ID |
136+
| `environment_variables` | `dict` | `{}` | Extra env vars for your container |
137+
138+
#### `MemoryConfig`
139+
140+
| Property | Type | Default | Description |
141+
|---|---|---|---|
142+
| `name` | `str` | `"chat_session_store"` | Memory store name |
143+
| `description` | `str` | `"Stores short-term..."` | Memory store description |
144+
11145
Docs https://co-cddo.github.io/gds-idea-cdk-constructs/

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "gds-idea-cdk-constructs"
3-
version = "0.4.1"
3+
version = "0.4.2"
44
description = "A repo for commonly used constructs in the team."
55
readme = "README.md"
66
authors = [
@@ -9,6 +9,7 @@ authors = [
99
]
1010
requires-python = ">=3.11"
1111
dependencies = [
12+
"aws-cdk-aws-bedrock-agentcore-alpha>=2.251.0a0",
1213
"aws-cdk-lib>=2.243.0",
1314
"boto3>=1.35.0",
1415
]
@@ -77,6 +78,7 @@ exclude = [
7778
".pytest_cache",
7879
".venv",
7980
"venv",
81+
"src/gds_idea_cdk_constructs/agent_core/agent_template",
8082
]
8183

8284
[tool.ruff.lint]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from .props import (
2+
AgentCoreProperties,
3+
BuiltInAgent,
4+
CustomAgent,
5+
MemoryConfig,
6+
ModelConfig,
7+
)
8+
from .stack import AgentCore
9+
10+
__all__ = [
11+
"AgentCore",
12+
"AgentCoreProperties",
13+
"BuiltInAgent",
14+
"CustomAgent",
15+
"MemoryConfig",
16+
"ModelConfig",
17+
]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.venv/
2+
__pycache__/
3+
*.pyc
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
FROM ghcr.io/astral-sh/uv:0.9 AS uv
2+
3+
FROM python:3.13-slim
4+
5+
WORKDIR /app
6+
7+
COPY --from=uv /uv /usr/local/bin/uv
8+
9+
RUN apt-get update \
10+
&& apt-get install -y --no-install-recommends ca-certificates \
11+
&& rm -rf /var/lib/apt/lists/* \
12+
&& useradd -m -u 1000 bedrock_agentcore \
13+
&& chown -R bedrock_agentcore:bedrock_agentcore /app
14+
15+
COPY --chown=bedrock_agentcore:bedrock_agentcore pyproject.toml uv.lock* ./
16+
17+
USER bedrock_agentcore
18+
19+
RUN uv sync --frozen 2>/dev/null || uv sync
20+
21+
COPY --chown=bedrock_agentcore:bedrock_agentcore . .
22+
23+
EXPOSE 8080
24+
25+
CMD ["uv", "run", "python", "agent.py"]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import os
2+
from dataclasses import dataclass
3+
from pathlib import Path
4+
5+
6+
@dataclass(frozen=True)
7+
class Config:
8+
"""Runtime configuration loaded from environment variables."""
9+
10+
region: str
11+
model_id: str
12+
memory_id: str | None
13+
max_history: int
14+
max_tokens: int
15+
budget_tokens: int
16+
thinking_enabled: bool
17+
system_prompt: str
18+
actor_id: str = "agent"
19+
20+
@classmethod
21+
def from_env(cls) -> "Config":
22+
prompt_file = Path(__file__).parent / "default_system_prompt.md"
23+
system_prompt = os.getenv("SYSTEM_PROMPT") or (
24+
prompt_file.read_text(encoding="utf-8") if prompt_file.exists() else ""
25+
)
26+
return cls(
27+
region=os.environ["REGION"],
28+
model_id=os.environ["MODEL_ID"],
29+
memory_id=os.getenv("MEMORY_ID"),
30+
max_history=int(os.getenv("MAX_HISTORY", "20")),
31+
max_tokens=int(os.getenv("MAX_TOKENS", "8000")),
32+
budget_tokens=int(os.getenv("BUDGET_TOKENS", "4000")),
33+
thinking_enabled=os.getenv("THINKING_ENABLED", "true").lower() == "true",
34+
system_prompt=system_prompt,
35+
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Structured JSON logging for CloudWatch.
2+
3+
Configures stdout-based JSON logging so Docker/CloudWatch captures
4+
structured output. Must be imported before any other application modules
5+
to ensure handlers are set before third-party loggers initialise.
6+
"""
7+
8+
import json
9+
import logging
10+
import os
11+
import sys
12+
13+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
14+
15+
16+
class _JsonFormatter(logging.Formatter):
17+
"""Formats log records as single-line JSON for CloudWatch Logs Insights."""
18+
19+
def format(self, record):
20+
entry = {
21+
"timestamp": self.formatTime(record, self.datefmt),
22+
"level": record.levelname,
23+
"logger": record.name,
24+
"message": record.getMessage(),
25+
}
26+
if record.exc_info:
27+
entry["exception"] = self.formatException(record.exc_info)
28+
return json.dumps(entry)
29+
30+
31+
def setup_logging() -> logging.Logger:
32+
"""Configure root logger and return a module-level logger.
33+
34+
- Clears pre-existing handlers (avoids duplicates from SDK imports).
35+
- Writes JSON to stdout (required for Docker log drivers).
36+
- Suppresses noisy boto/urllib3 loggers.
37+
"""
38+
level = getattr(logging, LOG_LEVEL, logging.INFO)
39+
40+
root = logging.getLogger()
41+
root.handlers.clear()
42+
43+
handler = logging.StreamHandler(sys.stdout)
44+
handler.setLevel(level)
45+
handler.setFormatter(_JsonFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
46+
47+
root.addHandler(handler)
48+
root.setLevel(level)
49+
50+
# Suppress noisy SDK loggers
51+
for name in ("boto3", "botocore", "urllib3"):
52+
logging.getLogger(name).setLevel(logging.WARNING)
53+
54+
return logging.getLogger("agent")
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Token usage extraction and OpenTelemetry reporting."""
2+
3+
import logging
4+
5+
from opentelemetry import trace
6+
7+
logger = logging.getLogger("agent")
8+
9+
10+
def _get_attr(obj, key, default=None):
11+
"""Get a value from a dict or object attribute."""
12+
if isinstance(obj, dict):
13+
return obj.get(key, default)
14+
return getattr(obj, key, default)
15+
16+
17+
def extract_and_record_usage(
18+
result_obj,
19+
session_id: str,
20+
model_id: str,
21+
) -> dict:
22+
"""Extract token metrics from a Strands result and record to OTel.
23+
24+
Returns:
25+
Dict with keys ``input``, ``output``, ``total`` (all default 0).
26+
"""
27+
usage = {"input": 0, "output": 0, "total": 0}
28+
29+
try:
30+
metrics = _get_attr(result_obj, "metrics")
31+
accumulated = _get_attr(metrics, "accumulated_usage") if metrics else None
32+
33+
if not accumulated:
34+
logger.warning("No accumulated_usage in result metrics")
35+
return usage
36+
37+
usage["input"] = accumulated.get("inputTokens", 0)
38+
usage["output"] = accumulated.get("outputTokens", 0)
39+
usage["total"] = accumulated.get(
40+
"totalTokens", usage["input"] + usage["output"]
41+
)
42+
43+
logger.info(
44+
"TOKEN_USAGE | Session=%s | In=%d | Out=%d | Total=%d",
45+
session_id,
46+
usage["input"],
47+
usage["output"],
48+
usage["total"],
49+
)
50+
51+
_record_otel_span(usage, model_id)
52+
53+
except Exception:
54+
logger.warning("Error extracting Strands metrics", exc_info=True)
55+
56+
return usage
57+
58+
59+
def _record_otel_span(usage: dict, model_id: str) -> None:
60+
"""Attach token metrics to the current OTel span."""
61+
span = trace.get_current_span()
62+
if not span.is_recording():
63+
logger.warning("OTel span not recording — tokens won't reach dashboard")
64+
return
65+
66+
span.set_attribute("gen_ai.system", "bedrock")
67+
span.set_attribute("gen_ai.request.model", model_id)
68+
span.set_attribute("gen_ai.usage.input_tokens", usage["input"])
69+
span.set_attribute("gen_ai.usage.output_tokens", usage["output"])
70+
span.set_attribute("gen_ai.usage.total_tokens", usage["total"])

0 commit comments

Comments
 (0)