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:
5
src/runtime-1/tools/__init__.py
Normal file
5
src/runtime-1/tools/__init__.py
Normal 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']
|
||||
21
src/runtime-1/tools/messaging.py
Normal file
21
src/runtime-1/tools/messaging.py
Normal 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'
|
||||
66
src/runtime-1/tools/web.py
Normal file
66
src/runtime-1/tools/web.py
Normal 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()
|
||||
48
src/runtime-1/tools/workspace.py
Normal file
48
src/runtime-1/tools/workspace.py
Normal 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
|
||||
Reference in New Issue
Block a user