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:
118
src/lambdas/agent-runner/handler.py
Normal file
118
src/lambdas/agent-runner/handler.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import boto3
|
||||
from typing import Any
|
||||
|
||||
# AWS clients
|
||||
_ddb = None
|
||||
_agentcore = None
|
||||
|
||||
|
||||
def get_ddb():
|
||||
global _ddb
|
||||
if _ddb is None:
|
||||
_ddb = boto3.resource('dynamodb')
|
||||
return _ddb
|
||||
|
||||
|
||||
def get_agentcore():
|
||||
global _agentcore
|
||||
if _agentcore is None:
|
||||
_agentcore = boto3.client('bedrock-agentcore', region_name='us-east-1')
|
||||
return _agentcore
|
||||
|
||||
|
||||
def get_or_create_session(actor_id: str) -> str:
|
||||
"""Look up active session for actor, or create a new one."""
|
||||
table = get_ddb().Table(os.environ['SESSION_TABLE_NAME'])
|
||||
|
||||
response = table.get_item(Key={'actor_id': actor_id})
|
||||
item = response.get('Item')
|
||||
|
||||
now = int(time.time())
|
||||
ttl_8hr = now + (8 * 3600)
|
||||
|
||||
if item and item.get('ttl', 0) > now:
|
||||
# Active session exists — extend TTL
|
||||
table.update_item(
|
||||
Key={'actor_id': actor_id},
|
||||
UpdateExpression='SET #ttl = :ttl',
|
||||
ExpressionAttributeNames={'#ttl': 'ttl'},
|
||||
ExpressionAttributeValues={':ttl': ttl_8hr},
|
||||
)
|
||||
return item['session_id']
|
||||
|
||||
# Create new session
|
||||
session_id = str(uuid.uuid4())
|
||||
table.put_item(Item={
|
||||
'actor_id': actor_id,
|
||||
'session_id': session_id,
|
||||
'created_at': str(now),
|
||||
'ttl': ttl_8hr,
|
||||
})
|
||||
return session_id
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
# ── Parse SQS records (FIFO — all from same actor) ───────────────────
|
||||
records = []
|
||||
for record in event.get('Records', []):
|
||||
try:
|
||||
records.append(json.loads(record['body']))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
if not records:
|
||||
return
|
||||
|
||||
first = records[0]
|
||||
channel = first.get('channel', 'telegram')
|
||||
chat_id = first.get('chat_id', '')
|
||||
actor_id = f"{channel}:{chat_id}"
|
||||
|
||||
# ── Get or create AgentCore session ──────────────────────────────────
|
||||
session_id = get_or_create_session(actor_id)
|
||||
|
||||
# ── Bundle messages ───────────────────────────────────────────────────
|
||||
if len(records) == 1:
|
||||
prompt = records[0]['messages'][0]['text']
|
||||
else:
|
||||
lines = [
|
||||
f"[{i+1}] {r['messages'][0]['text']}"
|
||||
for i, r in enumerate(records)
|
||||
]
|
||||
prompt = f"You have {len(records)} queued messages:\n" + "\n".join(lines)
|
||||
|
||||
# ── Build payload for AgentCore Runtime 1 ────────────────────────────
|
||||
payload: dict[str, Any] = {
|
||||
'prompt': prompt,
|
||||
'actor_id': actor_id,
|
||||
'session_id': session_id,
|
||||
'channel_adapter': {
|
||||
'type': channel,
|
||||
'target_id': str(chat_id),
|
||||
'bot_token_secret_arn': os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', ''),
|
||||
},
|
||||
}
|
||||
|
||||
# ── Invoke AgentCore Runtime 1 ────────────────────────────────────────
|
||||
runtime_arn = os.environ.get('RUNTIME_1_ARN', '')
|
||||
if not runtime_arn or runtime_arn == 'PLACEHOLDER_SET_AFTER_RUNTIME_DEPLOY':
|
||||
print(f"[agent-runner] RUNTIME_1_ARN not set — skipping AgentCore invoke")
|
||||
print(f"[agent-runner] Would have sent: {json.dumps(payload)[:200]}")
|
||||
return
|
||||
|
||||
client = get_agentcore()
|
||||
response = client.invoke_agent_runtime(
|
||||
agentRuntimeArn=runtime_arn,
|
||||
runtimeSessionId=session_id,
|
||||
payload=json.dumps(payload).encode(),
|
||||
)
|
||||
|
||||
# Consume streaming response (agent delivers to Telegram via send_message tool)
|
||||
for chunk in response.get('response', []):
|
||||
pass # intentional no-op — agent handles delivery internally
|
||||
|
||||
print(f"[agent-runner] Completed session={session_id} actor={actor_id}")
|
||||
1
src/lambdas/agent-runner/requirements.txt
Normal file
1
src/lambdas/agent-runner/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
boto3>=1.34.0
|
||||
96
src/lambdas/tg-ingest/handler.py
Normal file
96
src/lambdas/tg-ingest/handler.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import boto3
|
||||
|
||||
# Cache bot token (fetched once at Lambda init)
|
||||
_bot_token: str | None = None
|
||||
_token_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_bot_token() -> str:
|
||||
global _bot_token
|
||||
if _bot_token is None:
|
||||
with _token_lock:
|
||||
if _bot_token is None:
|
||||
sm = boto3.client('secretsmanager')
|
||||
_bot_token = sm.get_secret_value(
|
||||
SecretId=os.environ['TELEGRAM_BOT_TOKEN_SECRET_ARN']
|
||||
)['SecretString']
|
||||
return _bot_token
|
||||
|
||||
|
||||
def send_typing(chat_id: str) -> None:
|
||||
"""Fire-and-forget typing action (does not raise on failure)."""
|
||||
try:
|
||||
token = get_bot_token()
|
||||
data = json.dumps({'chat_id': chat_id, 'action': 'typing'}).encode()
|
||||
req = urllib.request.Request(
|
||||
f'https://api.telegram.org/bot{token}/sendChatAction',
|
||||
data=data,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=3)
|
||||
except Exception:
|
||||
pass # typing is best-effort
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
# ── Validate Telegram webhook secret ──────────────────────────────────
|
||||
expected_secret = os.environ.get('TELEGRAM_WEBHOOK_SECRET', '')
|
||||
if expected_secret:
|
||||
headers = event.get('headers') or {}
|
||||
received = headers.get('x-telegram-bot-api-secret-token', '')
|
||||
if received != expected_secret:
|
||||
return {'statusCode': 403, 'body': 'Forbidden'}
|
||||
|
||||
# ── Parse Telegram Update ─────────────────────────────────────────────
|
||||
try:
|
||||
body = json.loads(event.get('body', '{}'))
|
||||
except json.JSONDecodeError:
|
||||
return {'statusCode': 400, 'body': 'Bad Request'}
|
||||
|
||||
update_id = body.get('update_id')
|
||||
|
||||
# Support regular messages and edited messages
|
||||
message = body.get('message') or body.get('edited_message')
|
||||
if not message:
|
||||
# Not a message update (could be channel_post, callback_query, etc.)
|
||||
return {'statusCode': 200, 'body': 'ok'}
|
||||
|
||||
chat_id = str(message.get('chat', {}).get('id', ''))
|
||||
text = message.get('text', '')
|
||||
from_user = message.get('from', {})
|
||||
timestamp = message.get('date', 0)
|
||||
|
||||
if not chat_id or not text:
|
||||
return {'statusCode': 200, 'body': 'ok'}
|
||||
|
||||
# ── Send typing action (non-blocking, background thread) ──────────────
|
||||
t = threading.Thread(target=send_typing, args=(chat_id,))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
# ── Enqueue to SQS FIFO ───────────────────────────────────────────────
|
||||
sqs = boto3.client('sqs')
|
||||
sqs.send_message(
|
||||
QueueUrl=os.environ['MESSAGE_QUEUE_URL'],
|
||||
MessageGroupId=chat_id,
|
||||
MessageDeduplicationId=str(update_id),
|
||||
MessageBody=json.dumps({
|
||||
'channel': 'telegram',
|
||||
'chat_id': chat_id,
|
||||
'messages': [{
|
||||
'text': text,
|
||||
'from_id': str(from_user.get('id', '')),
|
||||
'from_username': from_user.get('username', ''),
|
||||
'from_name': f"{from_user.get('first_name', '')} {from_user.get('last_name', '')}".strip(),
|
||||
}],
|
||||
'update_id': update_id,
|
||||
'timestamp': timestamp,
|
||||
}),
|
||||
)
|
||||
|
||||
return {'statusCode': 200, 'body': 'ok'}
|
||||
1
src/lambdas/tg-ingest/requirements.txt
Normal file
1
src/lambdas/tg-ingest/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
boto3>=1.34.0
|
||||
4
src/runtime-1/channels/__init__.py
Normal file
4
src/runtime-1/channels/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .adapter import ChannelAdapter
|
||||
from .telegram import TelegramAdapter
|
||||
|
||||
__all__ = ['ChannelAdapter', 'TelegramAdapter']
|
||||
18
src/runtime-1/channels/adapter.py
Normal file
18
src/runtime-1/channels/adapter.py
Normal 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."""
|
||||
...
|
||||
66
src/runtime-1/channels/telegram.py
Normal file
66
src/runtime-1/channels/telegram.py
Normal 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
89
src/runtime-1/main.py
Normal 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()
|
||||
35
src/runtime-1/prompt_builder.py
Normal file
35
src/runtime-1/prompt_builder.py
Normal 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
|
||||
16
src/runtime-1/pyproject.toml
Normal file
16
src/runtime-1/pyproject.toml
Normal 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"
|
||||
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