Phase 0: CDK stack + Lambdas + AgentCore Runtime 1 scaffold

- CDK TypeScript stack (AgentClawStack):
  - S3 workspace bucket with BucketDeployment seed
  - DynamoDB session-store (actor_id → session_id, TTL)
  - SQS FIFO message queue (serialized per actor)
  - Lambda: tg-ingest (webhook validation, typing action, SQS enqueue)
  - Lambda: agent-runner (SQS → InvokeAgentRuntime, session management)
  - API Gateway HTTP: POST /telegram → tg-ingest
  - AgentCore Runtime 1 IAM execution role
  - CDK outputs: WebhookUrl, WorkspaceBucketName, Runtime1RoleArn

- Runtime 1 (Python + Strands + BedrockAgentCoreApp):
  - main.py: entrypoint, Strands agent, tool wiring
  - channels/: ChannelAdapter Protocol + TelegramAdapter (decoupled)
  - tools/: web_search (Brave), web_fetch, read/write_workspace_file, send_message
  - prompt_builder.py: loads SOUL.md/AGENTS.md/USER.md from S3 (cached)

- Lambdas:
  - tg-ingest: validate X-Telegram-Bot-Api-Secret-Token, send typing, enqueue FIFO
  - agent-runner: session lookup/create in DDB, bundle batched messages, InvokeAgentRuntime

- workspace/: seed files (SOUL.md, AGENTS.md, USER.md, IDENTITY.md, HEARTBEAT.md)

NOTE: AgentCore Runtime 1 creation via CfnResource deferred — deploy CDK first,
create runtime manually with the output Role ARN, then redeploy with runtime1Arn context param.
This commit is contained in:
daniel
2026-05-04 09:00:23 -05:00
parent 6ee2890831
commit 38905bb1e9
24 changed files with 1429 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from .adapter import ChannelAdapter
from .telegram import TelegramAdapter
__all__ = ['ChannelAdapter', 'TelegramAdapter']

View File

@@ -0,0 +1,18 @@
from typing import Protocol, runtime_checkable
@runtime_checkable
class ChannelAdapter(Protocol):
"""Protocol for channel-specific message delivery."""
def send(self, text: str) -> str:
"""Send a message. Returns message_id if available."""
...
def send_typing(self) -> None:
"""Send a typing indicator (best-effort)."""
...
def edit(self, message_id: str, text: str) -> None:
"""Edit an existing message in-place."""
...

View File

@@ -0,0 +1,66 @@
import threading
import urllib.request
import json
import boto3
class TelegramAdapter:
"""Channel adapter for Telegram Bot API."""
def __init__(self, chat_id: str, bot_token_secret_arn: str = ''):
self.chat_id = str(chat_id)
self._secret_arn = bot_token_secret_arn
self._token: str | None = None
self._lock = threading.Lock()
def _get_token(self) -> str:
if self._token is None:
with self._lock:
if self._token is None:
sm = boto3.client('secretsmanager')
self._token = sm.get_secret_value(
SecretId=self._secret_arn
)['SecretString']
return self._token
def _api(self, method: str, data: dict) -> dict:
token = self._get_token()
body = json.dumps(data).encode()
req = urllib.request.Request(
f'https://api.telegram.org/bot{token}/{method}',
data=body,
headers={'Content-Type': 'application/json'},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def send(self, text: str) -> str:
"""Send message, return message_id."""
resp = self._api('sendMessage', {
'chat_id': self.chat_id,
'text': text,
'parse_mode': 'Markdown',
})
return str(resp.get('result', {}).get('message_id', ''))
def send_typing(self) -> None:
"""Send typing action (best-effort)."""
try:
self._api('sendChatAction', {
'chat_id': self.chat_id,
'action': 'typing',
})
except Exception:
pass
def edit(self, message_id: str, text: str) -> None:
"""Edit an existing message in-place."""
try:
self._api('editMessageText', {
'chat_id': self.chat_id,
'message_id': int(message_id),
'text': text,
'parse_mode': 'Markdown',
})
except Exception:
pass

89
src/runtime-1/main.py Normal file
View File

@@ -0,0 +1,89 @@
"""
agent-claw Runtime 1 — main assistant agent.
Entrypoint for AgentCore CodeZip deployment.
"""
import os
from strands import Agent, tool
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from channels.telegram import TelegramAdapter
from prompt_builder import build_system_prompt, invalidate_prompt
from tools import web as web_tools
from tools import workspace as ws_tools
from tools import messaging
app = BedrockAgentCoreApp()
# ── Tool definitions ──────────────────────────────────────────────────────
@tool
def send_message(text: str) -> str:
"""Send a message to the user through their channel (Telegram, Slack, etc.)"""
return messaging.send(text)
@tool
def web_search(query: str) -> str:
"""Search the web using Brave Search. Returns titles, URLs, and snippets."""
return web_tools.brave_search(query)
@tool
def web_fetch(url: str) -> str:
"""Fetch and extract readable text content from a URL."""
return web_tools.web_fetch(url)
@tool
def read_workspace_file(path: str) -> str:
"""Read a file from the agent workspace (SOUL.md, HEARTBEAT.md, etc.)"""
return ws_tools.read_file(path)
@tool
def write_workspace_file(path: str, content: str) -> str:
"""Write or update a file in the agent workspace."""
result = ws_tools.write_file(path, content)
invalidate_prompt() # force system prompt rebuild if persona files changed
return result
# ── Entrypoint ────────────────────────────────────────────────────────────
@app.entrypoint
def main(payload: dict, context) -> dict:
"""Handle an invocation from agent-runner Lambda."""
# Set up channel adapter
adapter_config = payload.get('channel_adapter', {})
channel_type = adapter_config.get('type', 'telegram')
if channel_type == 'telegram':
adapter = TelegramAdapter(
chat_id=adapter_config.get('target_id', ''),
bot_token_secret_arn=adapter_config.get('bot_token_secret_arn', ''),
)
else:
# Future channels: instantiate appropriate adapter
raise ValueError(f"Unsupported channel type: {channel_type}")
messaging.set_adapter(adapter)
# Build system prompt (cached across warm invocations)
system_prompt = build_system_prompt()
# Create and run Strands agent
agent = Agent(
system_prompt=system_prompt,
tools=[send_message, web_search, web_fetch, read_workspace_file, write_workspace_file],
)
prompt = payload.get('prompt', '')
result = agent(prompt)
return {'result': result.message}
app.run()

View File

@@ -0,0 +1,35 @@
import os
from tools.workspace import load_persona_files
# Cache: built once per warm session
_system_prompt: str | None = None
def build_system_prompt() -> str:
"""Build system prompt from S3 workspace files (cached for warm session)."""
global _system_prompt
if _system_prompt is not None:
return _system_prompt
files = load_persona_files()
parts = []
# Inject persona files in order
for fname in ['SOUL.md', 'AGENTS.md', 'IDENTITY.md', 'USER.md']:
content = files.get(fname, '')
if content:
parts.append(f"## {fname}\n{content}")
# Runtime metadata block
parts.append(f"""## Runtime
Runtime: agent-claw | host=AgentCore | model=bedrock-claude-sonnet | channel=telegram
Current date/time is provided by the system. Timezone: America/Chicago.""")
_system_prompt = '\n\n---\n\n'.join(parts)
return _system_prompt
def invalidate_prompt() -> None:
"""Force rebuild of system prompt on next invocation (call after workspace write)."""
global _system_prompt
_system_prompt = None

View File

@@ -0,0 +1,16 @@
[project]
name = "agent-claw-runtime-1"
version = "0.1.0"
requires-python = ">=3.11"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.backends.legacy:build"
[tool.setuptools.packages.find]
where = ["."]
[project.dependencies]
strands-agents = ">=0.1.0"
bedrock-agentcore = ">=0.1.0"
boto3 = ">=1.34.0"

View File

@@ -0,0 +1,5 @@
from .web import brave_search, web_fetch
from .workspace import read_file, write_file
from .messaging import send, set_adapter
__all__ = ['brave_search', 'web_fetch', 'read_file', 'write_file', 'send', 'set_adapter']

View File

@@ -0,0 +1,21 @@
"""Messaging tool — channel-adapter-backed send_message for the agent."""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from channels.adapter import ChannelAdapter
# Injected by main.py before each invocation
_adapter: 'ChannelAdapter | None' = None
def set_adapter(adapter: 'ChannelAdapter') -> None:
global _adapter
_adapter = adapter
def send(text: str) -> str:
"""Send a message to the user via the active channel adapter."""
if _adapter is None:
return 'No channel adapter configured.'
msg_id = _adapter.send(text)
return f"Sent (id={msg_id})" if msg_id else 'Sent'

View File

@@ -0,0 +1,66 @@
import os
import threading
import urllib.request
import urllib.parse
import json
import boto3
# Brave Search API
_brave_key: str | None = None
_brave_lock = threading.Lock()
def _get_brave_key() -> str:
global _brave_key
if _brave_key is None:
with _brave_lock:
if _brave_key is None:
sm = boto3.client('secretsmanager')
_brave_key = sm.get_secret_value(
SecretId=os.environ['BRAVE_API_KEY_SECRET_ARN']
)['SecretString']
return _brave_key
def brave_search(query: str, count: int = 5) -> str:
"""Search the web using Brave Search API."""
api_key = _get_brave_key()
params = urllib.parse.urlencode({'q': query, 'count': count})
req = urllib.request.Request(
f'https://api.search.brave.com/res/v1/web/search?{params}',
headers={
'Accept': 'application/json',
'X-Subscription-Token': api_key,
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
results = data.get('web', {}).get('results', [])
if not results:
return 'No results found.'
parts = []
for r in results:
parts.append(f"**{r.get('title', '')}**\n{r.get('url', '')}\n{r.get('description', '')}")
return '\n\n'.join(parts)
def web_fetch(url: str) -> str:
"""Fetch and return text content from a URL."""
req = urllib.request.Request(
url,
headers={'User-Agent': 'Mozilla/5.0 (compatible; agent-claw/1.0)'},
)
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read(1024 * 1024) # cap at 1MB
# Basic text extraction (strip HTML tags)
import re
text = raw.decode('utf-8', errors='ignore')
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'[ \t]+', ' ', text)
text = re.sub(r'\n{3,}', '\n\n', text)
return text[:8000].strip()

View File

@@ -0,0 +1,48 @@
import os
import boto3
# In-memory cache for workspace files (lives for the duration of the warm session)
_cache: dict[str, str] = {}
_s3 = None
def _get_s3():
global _s3
if _s3 is None:
_s3 = boto3.client('s3')
return _s3
def get_bucket() -> str:
return os.environ['WORKSPACE_BUCKET_NAME']
def read_file(path: str) -> str:
"""Read a workspace file from S3 (cached)."""
if path not in _cache:
resp = _get_s3().get_object(Bucket=get_bucket(), Key=path)
_cache[path] = resp['Body'].read().decode('utf-8')
return _cache[path]
def write_file(path: str, content: str) -> str:
"""Write a workspace file to S3 and update cache."""
_get_s3().put_object(
Bucket=get_bucket(),
Key=path,
Body=content.encode('utf-8'),
ContentType='text/markdown',
)
_cache[path] = content
return f"Written {len(content)} bytes to {path}"
def load_persona_files() -> dict[str, str]:
"""Load all persona files at session start (SOUL.md etc.)"""
files = {}
for fname in ['SOUL.md', 'AGENTS.md', 'IDENTITY.md', 'USER.md']:
try:
files[fname] = read_file(fname)
except Exception:
pass
return files