Compare commits

..

29 Commits

Author SHA1 Message Date
daniel
4ca5fee2c0 refactor: move factcloud from hardcoded SSM to per-user DynamoDB oauth2_m2m connection
- Add oauth2_m2m auth type to mcp_loader.py (client_secret in record, not SSM)
- Remove _get_factcloud_token(), FACTCLOUD_* config, factcloud_clients from main.py
- Seed Daniel's factcloud connection into enrolled_services.mcp_connections
- factcloud now loaded dynamically via mcp_loader at session start
2026-05-16 09:49:28 -05:00
daniel
e77417b6cd feat: wire factcloud as direct MCP connection, drop knowledge_agent subagent
- Rename FACTBASE_CLOUD_* -> FACTCLOUD_* in config.py + SSM paths
- factcloud MCPClient added directly to main agent tool set
- knowledge_agent subagent removed (SSM + TOOL_PRESETS)
- System prompt updated: factcloud tools are direct, not via subagent
2026-05-16 09:25:55 -05:00
daniel
ef5734101e fix: add knowledge_agent to system prompt subagent list 2026-05-16 07:11:39 -05:00
daniel
8c28797bca feat: add /goal command for durable multi-turn objectives
- /goal set|status|checkpoint|pause|resume|clear intercept in main.py
- GOAL.md injected into system prompt when active (prompt_builder.py)
- Goal context added to heartbeat for autonomous progress
2026-05-16 07:07:46 -05:00
daniel
42dbdcde9e feat: factbase-cloud integration — knowledge_agent subagent with M2M auth 2026-05-15 23:32:23 -05:00
daniel
ed6577ccf9 feat: billing tags on CDK stack + inference profile creation script 2026-05-15 20:35:02 -05:00
daniel
4f17bbd2c3 fix: intercept [HEARTBEAT] prompt, suppress chatty non-urgent responses 2026-05-15 18:34:14 -05:00
daniel
e00702164d refactor: slim system prompt — SOUL.md+STATUS.md only, fix duplicate time injection 2026-05-15 16:42:27 -05:00
daniel
05fee423f2 feat: dynamic subagent loading from SSM 2026-05-15 15:19:08 -05:00
daniel
85efb082f7 fix: unconditional system prompt for call_aws availability 2026-05-15 11:49:03 -05:00
daniel
40f9712c54 fix: remove explicit MCPClient.start() - Strands calls it internally 2026-05-15 11:26:01 -05:00
daniel
ebd5a57ece fix: pass aws_service=aws-mcp to aws_iam_streamablehttp_client 2026-05-15 10:32:32 -05:00
daniel
9c09dce519 deps: add mcp-proxy-for-aws to runtime dependencies 2026-05-15 10:28:51 -05:00
daniel
0eff46126f Wire AWS MCP Server via mcp-proxy-for-aws 2026-05-15 10:19:44 -05:00
daniel
266231d070 Add native boto3 AWS tools, remove broken AWS MCP client 2026-05-15 10:03:56 -05:00
daniel
17b1536dae fix: move MCPClient imports inside try block, add TOOLS.md placeholder 2026-05-15 09:29:07 -05:00
daniel
add8c6c988 fix: add missing MCPClient/streamablehttp_client imports; fix EXECUTION_ROLE_ARN to actual AgentCore role 2026-05-15 09:14:33 -05:00
daniel
88ed337938 Add AWS MCP Server integration + IAM self-modify with approval gate
- CDK: add compute/build, broad read-only, IAM self-modify (scoped to own role),
  IAM policy management, and SSM read permissions to runtime1Role
- config.py: load /agent-claw/aws-mcp-url from SSM at cold start
- main.py: connect to AWS MCP Server with SigV4 auth (_AwsMcpSigV4Auth);
  add request_iam_permission and apply_iam_permission tools
- agentcore.json: add EXECUTION_ROLE_ARN env var
2026-05-15 08:56:06 -05:00
daniel
68aad4fb71 Read model-id from /agent-claw/model-id SSM param and pass to BedrockModel 2026-05-15 07:00:23 -05:00
daniel
f31d732cb9 Read model-id from SSM and pass to BedrockModel in main.py 2026-05-15 06:58:54 -05:00
daniel
62862f00f0 Make agent and compaction model IDs configurable via SSM 2026-05-14 18:27:35 -05:00
daniel
bdd334b6fb feat: add user-configurable MCP connections
- manage_mcp_connection tool: add/remove/enable/disable/list MCP servers
- mcp_loader: dynamic connection with OAuth/bearer/none auth, token caching
- Secrets stored in SSM, never in DynamoDB
- MCP clients loaded per-session and cleaned up in finally block
2026-05-13 21:55:01 -05:00
daniel
74f74ef877 refactor: migrate Secrets Manager secrets to SSM Parameter Store (free tier) 2026-05-13 12:55:16 -05:00
daniel
3a34e61479 feat: add windowed session history + LTM extraction/retrieval
- New memory_manager.py with:
  - check_and_compact: runs compaction on flagged sessions (extracts LTM via
    Claude Haiku, stores as AgentCore Memory event, deletes old events)
  - check_window_and_flag: sets DynamoDB flag when session > 100 events
  - load_ltm: retrieves LTM extractions and formats as system prompt block
- Wired into main.py:
  - Compaction runs before session_manager creation (trims old events)
  - LTM block injected into system prompt
  - Window check runs after session close
- SESSION_WINDOW_SIZE = 100 (named constant)
- Compaction is idempotent (uses event timestamps as cursor)
- LTM retrieval failure is non-fatal (logs and continues)
2026-05-13 11:57:50 -05:00
daniel
d217842917 refactor: remove MEMORY.md from prompt, add AgentCore memory instructions 2026-05-13 11:48:53 -05:00
daniel
3cc90550b5 feat: add Telegram file attachment support (inbound + outbound)
Inbound:
- tg-ingest detects document/photo/audio/video/voice attachments
- Downloads files via Telegram Bot API (getFile + download)
- Inlines small text files (<50KB) directly in the prompt
- Stores binary/large files to S3 (attachments/{chat_id}/{update_id}/{filename})
- agent-runner appends file context to the AgentCore prompt

Outbound:
- New send_file tool for the agent to send documents back to users
- TelegramAdapter.send_document uses multipart/form-data POST
- CDK grants tg-ingest S3 write access and passes bucket name env var
2026-05-13 05:34:33 -05:00
daniel
eba4f7db25 fix: align run_code with AWS docs pattern (invoke+executeCode, not execute_code wrapper) 2026-05-12 15:26:01 -05:00
daniel
9253d5046f feat: re-enable code interpreter tool (lazy code_session, no module-level init) 2026-05-12 15:05:26 -05:00
daniel
138f9224c3 fix: use sender from_id as actor_id in groups, not group chat_id 2026-05-12 14:17:02 -05:00
47 changed files with 3194 additions and 743 deletions

View File

@@ -20,9 +20,10 @@
"OAUTH_START_URL": "https://sptejrymri.execute-api.us-east-1.amazonaws.com/oauth/start",
"USERS_TABLE_NAME": "agent-claw-users",
"WORKSPACE_BUCKET_NAME": "agent-claw-workspace-495395224548",
"TELEGRAM_BOT_TOKEN_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3",
"BRAVE_API_KEY_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/brave-api-key-uUSgzi",
"SCHEDULER_LAMBDA_ARN": "arn:aws:lambda:us-east-1:495395224548:function:agent-claw-scheduler"
"TELEGRAM_BOT_TOKEN_SSM_PARAM": "/agent-claw/telegram-bot-token",
"BRAVE_API_KEY_SSM_PARAM": "/agent-claw/brave-api-key",
"SCHEDULER_LAMBDA_ARN": "arn:aws:lambda:us-east-1:495395224548:function:agent-claw-scheduler",
"EXECUTION_ROLE_ARN": "arn:aws:iam::495395224548:role/AgentCore-agentclaw-defau-ApplicationAgentAgentClaw-Ttg8kEtQ3cJj"
}
}
],
@@ -60,4 +61,4 @@
"configBundles": [],
"abTests": [],
"httpGateways": []
}
}

View File

@@ -19,14 +19,14 @@ class TelegramAdapter:
if self._token is None:
with self._lock:
if self._token is None:
secret_arn = self._secret_arn or os.environ.get(
'TELEGRAM_BOT_TOKEN_SECRET_ARN',
'arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3'
param_name = self._secret_arn or os.environ.get(
'TELEGRAM_BOT_TOKEN_SSM_PARAM',
'/agent-claw/telegram-bot-token'
)
sm = boto3.client('secretsmanager')
self._token = sm.get_secret_value(
SecretId=secret_arn
)['SecretString']
ssm = boto3.client('ssm')
self._token = ssm.get_parameter(
Name=param_name, WithDecryption=True
)['Parameter']['Value']
return self._token
def _api(self, method: str, data: dict) -> dict:
@@ -63,6 +63,43 @@ class TelegramAdapter:
import traceback
print(f'[telegram] send_typing failed: {e}\n{traceback.format_exc()}')
def send_document(self, file_bytes: bytes, filename: str, caption: str = '') -> str:
"""Send a file as a Telegram document using multipart/form-data. Returns message_id."""
import io
token = self._get_token()
url = f'https://api.telegram.org/bot{token}/sendDocument'
boundary = '----AgentClawBoundary'
body = io.BytesIO()
def add_field(name: str, value: str):
body.write(f'--{boundary}\r\n'.encode())
body.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
body.write(f'{value}\r\n'.encode())
def add_file(name: str, fname: str, data: bytes):
body.write(f'--{boundary}\r\n'.encode())
body.write(f'Content-Disposition: form-data; name="{name}"; filename="{fname}"\r\n'.encode())
body.write(b'Content-Type: application/octet-stream\r\n\r\n')
body.write(data)
body.write(b'\r\n')
add_field('chat_id', self.chat_id)
if self.thread_id is not None:
add_field('message_thread_id', str(self.thread_id))
if caption:
add_field('caption', caption)
add_file('document', filename, file_bytes)
body.write(f'--{boundary}--\r\n'.encode())
req = urllib.request.Request(
url, data=body.getvalue(),
headers={'Content-Type': f'multipart/form-data; boundary={boundary}'},
)
with urllib.request.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read())
return str(result.get('result', {}).get('message_id', ''))
def edit(self, message_id: str, text: str) -> None:
"""Edit an existing message in-place."""
try:

View File

@@ -0,0 +1,27 @@
"""Config loader — fetches model IDs and service URLs from SSM Parameter Store at cold start."""
import boto3
_DEFAULTS = {
'/agent-claw/model-id': 'us.anthropic.claude-sonnet-4-6',
'/agent-claw/config/compaction_model_id': 'us.anthropic.claude-3-5-haiku-20241022-v1:0',
'/agent-claw/aws-mcp-url': 'https://aws-mcp.us-east-1.api.aws/mcp',
}
def _load():
ssm = boto3.client('ssm', region_name='us-east-1')
names = list(_DEFAULTS.keys())
try:
resp = ssm.get_parameters(Names=names, WithDecryption=True)
found = {p['Name']: p['Value'] for p in resp['Parameters']}
except Exception:
found = {}
return {name: found.get(name, default) for name, default in _DEFAULTS.items()}
_params = _load()
AGENT_MODEL_ID: str = _params['/agent-claw/model-id']
COMPACTION_MODEL_ID: str = _params['/agent-claw/config/compaction_model_id']
AWS_MCP_URL: str = _params['/agent-claw/aws-mcp-url']

View File

@@ -4,12 +4,15 @@ agent-claw Runtime 1 — main assistant agent.
Entrypoint for AgentCore CodeZip deployment.
"""
import os
import time
from strands import Agent, tool
from strands.models import BedrockModel
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from channels.telegram import TelegramAdapter
from prompt_builder import build_system_prompt, invalidate_prompt
import memory_manager
import config
from tools import web as web_tools
from tools import workspace as ws_tools
from tools import messaging
@@ -17,7 +20,11 @@ from tools.scheduler import schedule_reminder, list_reminders, cancel_reminder
import tools.scheduler as _scheduler_module
from tools.home_assistant import home_assistant, set_ha_config
from tools.google_workspace import list_calendars, get_calendar_events, list_gmail_messages, get_gmail_message
from tools.send_file import send_file as _send_file_impl
from tools.mcp_tools import manage_mcp_connection
import tools.mcp_tools as _mcp_tools_module
import tools.google_workspace as _gws
import mcp_loader
import httpx
import botocore.auth
import botocore.awsrequest
@@ -29,6 +36,7 @@ OAUTH_START_URL = (
or 'https://sptejrymri.execute-api.us-east-1.amazonaws.com/oauth/start'
)
USERS_TABLE_NAME = os.environ.get('USERS_TABLE_NAME', 'agent-claw-users')
EXECUTION_ROLE_ARN = os.environ.get('EXECUTION_ROLE_ARN', '')
class _SigV4HttpxAuth(httpx.Auth):
@@ -56,12 +64,68 @@ class _SigV4HttpxAuth(httpx.Auth):
if self._actor_id:
request.headers['x-actor-id'] = self._actor_id
yield request
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager
# code_interpreter removed — causes [Errno 98] port 8080 conflict on warm container re-init
from tools.code_interpreter import run_code
from strands_tools import http_request, file_read
app = BedrockAgentCoreApp()
_aws_mcp_client = None
_aws_mcp_tools = []
try:
from strands.tools.mcp import MCPClient
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client
_aws_mcp_client = MCPClient(
lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp")
)
_aws_mcp_tools = [_aws_mcp_client]
print('[main] AWS MCP client created')
except Exception as _e:
import traceback
print(f'[main] AWS MCP client failed: {type(_e).__name__}: {_e}')
print(traceback.format_exc())
# ── Subagent loading ──────────────────────────────────────────────────────
TOOL_PRESETS = {
"aws": lambda: [MCPClient(lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp"))],
"coding": lambda: [MCPClient(lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp")), run_code],
"documents": lambda: [http_request, file_read],
}
def _load_subagents(ssm_client) -> list:
"""Load subagent definitions from SSM and return as tools."""
import json
try:
resp = ssm_client.get_parameter(Name='/agent-claw/subagents')
defs = json.loads(resp['Parameter']['Value'])
except Exception as e:
print(f'[main] Failed to load subagents from SSM: {type(e).__name__}: {e}')
return []
tools = []
for cfg in defs:
preset = cfg.get('tools', '')
if preset not in TOOL_PRESETS:
print(f'[main] Unknown tool preset "{preset}" for subagent "{cfg.get("name")}", skipping')
continue
try:
sub = Agent(
model=BedrockModel(model_id=cfg['model_id'], region_name='us-east-1'),
system_prompt=cfg['system_prompt'],
tools=TOOL_PRESETS[preset](),
)
tools.append(sub.as_tool(name=cfg['name'], description=cfg['description']))
except Exception as e:
print(f'[main] Failed to build subagent "{cfg.get("name")}": {type(e).__name__}: {e}')
print(f'[main] Loaded {len(tools)} subagent(s)')
return tools
# ── Tool definitions ──────────────────────────────────────────────────────
@@ -73,6 +137,19 @@ def web_search(query: str) -> str:
return web_tools.brave_search(query)
@tool
def send_file(file_content: str, filename: str, caption: str = '') -> str:
"""Send a file to the user as a Telegram document attachment.
Use this when you need to send code, data, or any text content as a downloadable file.
Args:
file_content: The text content of the file to send.
filename: The filename with extension (e.g. 'report.txt', 'data.csv', 'script.py').
caption: Optional caption to display with the file.
"""
return _send_file_impl(file_content, filename, caption)
@tool
def web_fetch(url: str) -> str:
"""Fetch and extract readable text content from a URL."""
@@ -244,6 +321,237 @@ def manage_service(action: str, service: str, config: dict | None = None) -> str
return f'Unknown action: {action}. Use "enroll", "remove", or "list".'
@tool
def request_iam_permission(action: str, resource: str, reason: str) -> str:
"""Request IAM permission from the user. ALWAYS call this before apply_iam_permission.
Sends the user a Telegram message explaining what permission is needed and why.
After calling this, wait for the user to explicitly say 'yes' before proceeding.
Args:
action: IAM action to request (e.g. 's3:PutObject')
resource: Resource ARN the action applies to (e.g. 'arn:aws:s3:::my-bucket/*')
reason: Why this permission is needed
"""
msg = (
f"⚠️ *IAM Permission Request*\n\n"
f"I need to add the following permission to my execution role:\n\n"
f"• Action: `{action}`\n"
f"• Resource: `{resource}`\n"
f"• Reason: {reason}\n\n"
f"Reply *yes* to approve or *no* to deny."
)
messaging.send(msg)
return "Permission request sent. Wait for the user to reply 'yes' before calling apply_iam_permission."
@tool
def apply_iam_permission(action: str, resource: str, policy_name: str) -> str:
"""Apply an IAM permission to the agent execution role.
Only call this after the user has explicitly approved via request_iam_permission.
Args:
action: IAM action to allow (e.g. 's3:PutObject')
resource: Resource ARN the action applies to
policy_name: Unique name for the inline policy (e.g. 'AllowS3PutObject')
"""
if not EXECUTION_ROLE_ARN:
return 'EXECUTION_ROLE_ARN not configured.'
import json as _json
role_name = EXECUTION_ROLE_ARN.split('/')[-1]
policy_doc = _json.dumps({
'Version': '2012-10-17',
'Statement': [{'Effect': 'Allow', 'Action': action, 'Resource': resource}],
})
boto3.client('iam', region_name='us-east-1').put_role_policy(
RoleName=role_name,
PolicyName=policy_name,
PolicyDocument=policy_doc,
)
return f"Applied policy '{policy_name}': Allow {action} on {resource}."
@tool
def aws_list_lambda_functions(region: str = "us-east-1") -> str:
"""List AWS Lambda functions in the specified region. Uses execution role credentials directly via boto3."""
import boto3
client = boto3.client("lambda", region_name=region)
paginator = client.get_paginator("list_functions")
functions = []
for page in paginator.paginate():
for fn in page["Functions"]:
functions.append(f"{fn['FunctionName']} ({fn['Runtime']})")
return f"{len(functions)} Lambda functions in {region}:\n" + "\n".join(functions)
@tool
def aws_get_cost_and_usage(start_date: str, end_date: str, granularity: str = "MONTHLY") -> str:
"""Get AWS Cost and Usage report. start_date and end_date in YYYY-MM-DD format. Uses execution role credentials."""
import boto3
client = boto3.client("ce", region_name="us-east-1")
response = client.get_cost_and_usage(
TimePeriod={"Start": start_date, "End": end_date},
Granularity=granularity,
Metrics=["UnblendedCost"]
)
lines = []
for result in response["ResultsByTime"]:
period = f"{result['TimePeriod']['Start']} to {result['TimePeriod']['End']}"
cost = result["Total"]["UnblendedCost"]["Amount"]
unit = result["Total"]["UnblendedCost"]["Unit"]
lines.append(f"{period}: {cost} {unit}")
return "\n".join(lines)
@tool
def aws_describe_service(service: str, region: str = "us-east-1") -> str:
"""Describe an AWS service. service can be: lambda, s3, cloudformation, dynamodb, sqs. Returns summary of key resources."""
import boto3
session = boto3.Session(region_name=region)
if service == "s3":
client = session.client("s3")
buckets = client.list_buckets()["Buckets"]
return f"{len(buckets)} S3 buckets: " + ", ".join(b["Name"] for b in buckets[:20])
elif service == "cloudformation":
client = session.client("cloudformation")
stacks = client.list_stacks(StackStatusFilter=["CREATE_COMPLETE", "UPDATE_COMPLETE", "ROLLBACK_COMPLETE"])["StackSummaries"]
return f"{len(stacks)} stacks: " + ", ".join(s["StackName"] for s in stacks[:20])
elif service == "dynamodb":
client = session.client("dynamodb")
tables = client.list_tables()["TableNames"]
return f"{len(tables)} DynamoDB tables: " + ", ".join(tables[:20])
elif service == "sqs":
client = session.client("sqs")
queues = client.list_queues().get("QueueUrls", [])
return f"{len(queues)} SQS queues: " + ", ".join(q.split("/")[-1] for q in queues[:20])
else:
return f"Service {service} not yet implemented. Try: lambda, s3, cloudformation, dynamodb, sqs"
# ── Goal helpers ──────────────────────────────────────────────────────────
from datetime import datetime as _dt
from zoneinfo import ZoneInfo as _ZoneInfo
def _now_iso() -> str:
return _dt.now(_ZoneInfo('America/Chicago')).strftime('%Y-%m-%dT%H:%M:%S%z')
def _read_goal() -> str | None:
"""Read GOAL.md from S3, return content or None."""
try:
return ws_tools.read_file('GOAL.md')
except Exception:
return None
def _write_goal(content: str):
ws_tools.write_file('GOAL.md', content)
invalidate_prompt()
def _delete_goal():
try:
_s3 = boto3.client('s3')
_s3.delete_object(Bucket=ws_tools.get_bucket(), Key='GOAL.md')
ws_tools._cache.pop('GOAL.md', None)
invalidate_prompt()
except Exception:
pass
def _parse_goal_status(content: str) -> str:
"""Extract Status field from GOAL.md content."""
for line in content.splitlines():
if line.startswith('**Status:**'):
return line.split('**Status:**')[1].strip()
return 'unknown'
def _get_active_goal_context() -> dict | None:
"""Return goal context dict if active, else None."""
content = _read_goal()
if not content or _parse_goal_status(content) != 'active':
return None
objective = stopping = last_cp = ''
for line in content.splitlines():
if line.startswith('**Objective:**'):
objective = line.split('**Objective:**')[1].strip()
elif line.startswith('**Stopping condition:**'):
stopping = line.split('**Stopping condition:**')[1].strip()
elif line.startswith('- ['):
last_cp = line # last checkpoint line wins
return {'objective': objective, 'stopping_condition': stopping, 'last_checkpoint': last_cp}
def _handle_goal_command(prompt: str) -> str | None:
"""Handle /goal commands. Returns reply string or None if not a goal command."""
parts = prompt.split(None, 2) # ['/goal', subcommand?, rest?]
cmd = parts[1] if len(parts) > 1 else 'status'
rest = parts[2] if len(parts) > 2 else ''
if cmd == 'set':
if not rest:
return '❌ Usage: `/goal set <objective>` or `/goal set <objective> | <stopping condition>`'
if '|' in rest:
objective, stopping = [s.strip() for s in rest.split('|', 1)]
else:
objective, stopping = rest.strip(), 'not specified'
content = (
f'# Goal\n\n'
f'**Objective:** {objective}\n'
f'**Stopping condition:** {stopping}\n'
f'**Status:** active\n'
f'**Set at:** {_now_iso()}\n\n'
f'## Checkpoint log\n'
)
_write_goal(content)
return f'✅ Goal set: {objective}\nStopping condition: {stopping}'
elif cmd in ('status', '/goal'):
content = _read_goal()
if not content:
return '📋 No active goal. Use `/goal set <objective>` to set one.'
return content
elif cmd == 'checkpoint':
if not rest:
return '❌ Usage: `/goal checkpoint <note>`'
content = _read_goal()
if not content:
return '❌ No active goal to checkpoint.'
entry = f'- [{_now_iso()}] {rest}\n'
content = content.rstrip() + '\n' + entry
_write_goal(content)
return f'✅ Checkpoint added: {rest}'
elif cmd == 'pause':
content = _read_goal()
if not content:
return '❌ No active goal to pause.'
content = content.replace('**Status:** active', '**Status:** paused')
_write_goal(content)
return '⏸️ Goal paused.'
elif cmd == 'resume':
content = _read_goal()
if not content:
return '❌ No goal to resume.'
content = content.replace('**Status:** paused', '**Status:** active')
_write_goal(content)
return '▶️ Goal resumed.'
elif cmd == 'clear':
_delete_goal()
return '🗑️ Goal cleared.'
else:
# Not a recognized subcommand — treat the whole thing as status check
content = _read_goal()
if not content:
return '📋 No active goal. Use `/goal set <objective>` to set one.'
return content
# ── Entrypoint ────────────────────────────────────────────────────────────
# Module-level actor_id for tool closures (set per-invocation)
@@ -297,6 +605,10 @@ async def main(payload: dict, context):
_current_chat_id = chat_id
_scheduler_module._current_actor_id = actor_id
_scheduler_module._current_chat_id = chat_id
_mcp_tools_module._current_actor_id = actor_id
# Run compaction if flagged from previous invocation (trims old events before load)
memory_manager.check_and_compact(actor_id, session_id)
memory_config = AgentCoreMemoryConfig(
memory_id=MEMORY_ID,
@@ -337,39 +649,83 @@ async def main(payload: dict, context):
user_context += f'\nEnrolled services: {", ".join(enrolled)}'
system_prompt = build_system_prompt(user_context=user_context, actor_id=actor_id)
# Inject current datetime so the model always has accurate time context
from datetime import datetime
from zoneinfo import ZoneInfo
_tz = ZoneInfo('America/Chicago')
_now = datetime.now(_tz)
_time_str = _now.strftime('%A, %B %d, %Y %I:%M %p %Z')
system_prompt = system_prompt + f'\n\nCurrent date/time: {_time_str}'
print(f'[main] System prompt time injection: {_time_str}')
# Inject long-term memory block before conversation history
ltm_block = memory_manager.load_ltm(actor_id)
if ltm_block:
system_prompt = system_prompt + '\n\n---\n\n' + ltm_block
system_prompt += '\nAWS tools available: call_aws (any AWS API via AWS MCP Server), aws_list_lambda_functions, aws_get_cost_and_usage, aws_describe_service. Use call_aws directly for AWS API calls — do NOT say you lack AWS access.'
system_prompt += '\n\nSubagents available — use them aggressively to save cost and improve quality:\n- aws_agent: all AWS infrastructure, cost, resource, IAM, CloudWatch queries\n- coding_agent: code writing, builds, deployments, CodeBuild/AppRunner/ECR\n- document_agent: summarize URLs, extract data from documents, process long text\nYou also have direct access to factcloud MCP tools (your personal knowledge graph) loaded from your MCP connections — use them directly for any factbase, factcloud, or knowledge base queries. Do NOT say you lack access to factcloud.\nDefault to delegating to subagents; only answer directly for simple conversational responses or tasks that don\'t fit a subagent.'
# Model: claude-sonnet-4-6 via cross-region inference
# NOTE: extended thinking disabled — causes retry/duplicate issues with streaming
from botocore.config import Config as BotoConfig
model = BedrockModel(
model_id="us.anthropic.claude-sonnet-4-6",
model_id=config.AGENT_MODEL_ID,
region_name="us-east-1",
boto_client_config=BotoConfig(read_timeout=600, connect_timeout=10),
)
base_tools = [web_search, web_fetch, read_workspace_file, write_workspace_file,
home_assistant, connect_google_account, list_google_accounts, remove_google_account,
manage_service, schedule_reminder, list_reminders, cancel_reminder,
list_calendars, get_calendar_events, list_gmail_messages, get_gmail_message]
manage_service, manage_mcp_connection, schedule_reminder, list_reminders, cancel_reminder,
list_calendars, get_calendar_events, list_gmail_messages, get_gmail_message,
run_code, send_file, request_iam_permission, apply_iam_permission,
aws_list_lambda_functions, aws_get_cost_and_usage, aws_describe_service]
# Load user's dynamic MCP connections
mcp_connections = services.get('mcp_connections', [])
mcp_clients, _mcp_to_close = mcp_loader.load_mcp_tools(mcp_connections, actor_id)
# Load subagents from SSM
ssm = boto3.client('ssm', region_name='us-east-1')
subagent_tools = _load_subagents(ssm)
all_tools = base_tools + _aws_mcp_tools + mcp_clients + subagent_tools
agent = Agent(
model=model,
system_prompt=system_prompt,
session_manager=session_manager,
tools=base_tools,
tools=all_tools,
)
# Intercept /goal commands — handle directly without LLM
prompt = payload.get('prompt', '')
if prompt.strip().startswith('/goal'):
goal_reply = _handle_goal_command(prompt.strip())
if goal_reply is not None:
yield {'data': goal_reply}
_typing_active = False
session_manager.close()
mcp_loader.close_mcp_clients(_mcp_to_close)
return
# Intercept heartbeat: replace bare [HEARTBEAT] with a strict-format instruction.
# Agent-runner suppresses replies that start with HEARTBEAT_OK, so only real alerts reach Telegram.
if prompt.strip() == '[HEARTBEAT]':
# Inject goal context into heartbeat if active
goal_ctx = _get_active_goal_context()
goal_heartbeat = ''
if goal_ctx:
goal_heartbeat = (
f' You have an active goal: "{goal_ctx["objective"]}". '
f'Stopping condition: "{goal_ctx["stopping_condition"]}". '
f'Last checkpoint: "{goal_ctx["last_checkpoint"]}". '
f'Make progress toward this goal or report blockers.'
)
prompt = (
'HEARTBEAT CHECK: Silently check for anything urgent Daniel should know about '
'(calendar events starting within 2 hours, unread urgent emails, overdue reminders). '
'Do NOT narrate your checking process. '
'If nothing is urgent: reply with the single word HEARTBEAT_OK and nothing else. '
'If something IS urgent: reply with 2-3 lines max summarising only the urgent items.'
+ goal_heartbeat
)
final_message = None
try:
async for event in agent.stream_async(payload.get('prompt', '')):
async for event in agent.stream_async(prompt):
if 'result' in event:
final_message = event['result'].message
yield event
@@ -382,6 +738,9 @@ async def main(payload: dict, context):
finally:
_typing_active = False
session_manager.close()
mcp_loader.close_mcp_clients(_mcp_to_close)
# Check if session exceeds window — flag for compaction on next invocation
memory_manager.check_window_and_flag(actor_id, session_id)
app.run()

View File

@@ -0,0 +1,122 @@
"""Dynamic MCP tool loader — connects to user-configured MCP servers and returns their tools."""
import time
import logging
import urllib.request
import urllib.parse
import json
import boto3
from mcp.client.streamable_http import streamablehttp_client
from strands.tools.mcp.mcp_client import MCPClient
logger = logging.getLogger(__name__)
# Token cache: {f"{actor_id}:{conn_name}": {"token": str, "expires_at": float}}
_token_cache: dict = {}
def _get_ssm_value(param_name: str) -> str:
ssm = boto3.client('ssm', region_name='us-east-1')
return ssm.get_parameter(Name=param_name, WithDecryption=True)['Parameter']['Value']
def _get_oauth_token(conn: dict, actor_id: str) -> str:
"""Fetch OAuth token via client_credentials grant, with caching."""
cache_key = f"{actor_id}:{conn['name']}"
cached = _token_cache.get(cache_key)
if cached and cached['expires_at'] > time.time():
return cached['token']
client_secret = _get_ssm_value(conn['client_secret_ssm'])
data = urllib.parse.urlencode({
'grant_type': 'client_credentials',
'client_id': conn['client_id'],
'client_secret': client_secret,
'scope': conn.get('scope', ''),
}).encode()
req = urllib.request.Request(conn['cognito_token_url'], data=data,
headers={'Content-Type': 'application/x-www-form-urlencoded'})
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read())
token = body['access_token']
expires_in = body.get('expires_in', 3600)
_token_cache[cache_key] = {'token': token, 'expires_at': time.time() + expires_in - 30}
return token
def _get_m2m_token(conn: dict, actor_id: str) -> str:
"""Fetch OAuth token for oauth2_m2m (secret stored directly in record)."""
cache_key = f"{actor_id}:{conn['name']}"
cached = _token_cache.get(cache_key)
if cached and cached['expires_at'] > time.time() + 60:
return cached['token']
data = urllib.parse.urlencode({
'grant_type': 'client_credentials',
'client_id': conn['client_id'],
'client_secret': conn['client_secret'],
'scope': conn.get('scopes', conn.get('scope', '')),
}).encode()
req = urllib.request.Request(conn['token_url'], data=data,
headers={'Content-Type': 'application/x-www-form-urlencoded'})
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read())
token = body['access_token']
expires_in = body.get('expires_in', 3600)
_token_cache[cache_key] = {'token': token, 'expires_at': time.time() + expires_in}
return token
def _resolve_auth_headers(conn: dict, actor_id: str) -> dict:
"""Resolve auth headers for a connection."""
auth_type = conn.get('auth_type', 'none')
if auth_type == 'oauth_client_credentials':
token = _get_oauth_token(conn, actor_id)
return {'Authorization': f'Bearer {token}'}
elif auth_type == 'oauth2_m2m':
token = _get_m2m_token(conn, actor_id)
return {'Authorization': f'Bearer {token}'}
elif auth_type == 'bearer':
token = _get_ssm_value(conn['token_ssm'])
return {'Authorization': f'Bearer {token}'}
return {}
def invalidate_token(conn_name: str, actor_id: str):
"""Invalidate cached token for a connection (call on auth failure)."""
_token_cache.pop(f"{actor_id}:{conn_name}", None)
def load_mcp_tools(mcp_connections: list, actor_id: str) -> tuple[list, list]:
"""Connect to each enabled MCP server and return (tools_list, clients_to_close).
Returns:
Tuple of (list of MCPClient instances to pass to Agent, list of same clients to close later)
"""
clients = []
for conn in mcp_connections:
if not conn.get('enabled', True):
continue
name = conn.get('name', 'unknown')
try:
headers = _resolve_auth_headers(conn, actor_id)
url = conn['url']
client = MCPClient(lambda u=url, h=headers: streamablehttp_client(u, headers=h))
client.start()
clients.append(client)
logger.info(f'[mcp_loader] Connected to MCP server: {name}')
except Exception as e:
logger.error(f'[mcp_loader] Failed to connect to MCP server "{name}": {e}')
return clients, clients
def close_mcp_clients(clients: list):
"""Close all MCP clients."""
for client in clients:
try:
client.stop()
except Exception as e:
logger.error(f'[mcp_loader] Error closing MCP client: {e}')

View File

@@ -0,0 +1,321 @@
"""Long-term memory manager: windowed loading, compaction, and LTM retrieval."""
import json
import logging
import os
from datetime import datetime, timezone
from typing import Any
import boto3
from bedrock_agentcore.memory.client import MemoryClient
import config
logger = logging.getLogger(__name__)
MEMORY_ID = 'agentclaw_AgentClawMemory-i7Csf776AH'
SESSION_WINDOW_SIZE = 100
USERS_TABLE_NAME = os.environ.get('USERS_TABLE_NAME', 'agent-claw-users')
LTM_SESSION_ID = 'ltm-extractions'
_memory_client: MemoryClient | None = None
def _get_memory_client() -> MemoryClient:
global _memory_client
if _memory_client is None:
_memory_client = MemoryClient(region_name='us-east-1')
return _memory_client
def _get_compaction_flag(actor_id: str) -> bool:
"""Check if compaction is needed for this actor."""
ddb = boto3.resource('dynamodb', region_name='us-east-1')
table = ddb.Table(USERS_TABLE_NAME)
resp = table.get_item(Key={'actor_id': actor_id})
return resp.get('Item', {}).get('needs_compaction', False)
def _set_compaction_flag(actor_id: str, value: bool) -> None:
"""Set or clear the compaction flag."""
ddb = boto3.resource('dynamodb', region_name='us-east-1')
table = ddb.Table(USERS_TABLE_NAME)
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET needs_compaction = :v',
ExpressionAttributeValues={':v': value},
)
def _count_session_events(actor_id: str, session_id: str) -> int:
"""Count total events in the session (excluding state/agent metadata events)."""
client = _get_memory_client()
events = client.list_events(
memory_id=MEMORY_ID,
actor_id=actor_id,
session_id=session_id,
max_results=10000,
include_payload=False,
)
# Filter out session/agent state events (they have stateType metadata)
return sum(1 for e in events if not e.get('metadata', {}).get('stateType'))
def _get_all_session_events(actor_id: str, session_id: str) -> list[dict]:
"""Get all conversation events (excluding state metadata events)."""
client = _get_memory_client()
events = client.list_events(
memory_id=MEMORY_ID,
actor_id=actor_id,
session_id=session_id,
max_results=10000,
include_payload=True,
)
return [e for e in events if not e.get('metadata', {}).get('stateType')]
def _extract_text_from_events(events: list[dict]) -> str:
"""Extract conversation text from events for summarization."""
lines = []
for event in events:
for item in event.get('payload', []):
if 'conversational' in item:
conv = item['conversational']
role = conv.get('role', 'UNKNOWN')
text = conv.get('content', {}).get('text', '')
lines.append(f'{role}: {text}')
elif 'blob' in item:
try:
blob = json.loads(item['blob']) if isinstance(item['blob'], str) else item['blob']
if isinstance(blob, list) and blob:
for msg in blob:
if isinstance(msg, (list, tuple)) and len(msg) == 2:
lines.append(f'{msg[1]}: {msg[0]}')
except (json.JSONDecodeError, TypeError):
pass
return '\n'.join(lines[-200:]) # Cap at last 200 lines to stay within context
def _call_claude_extraction(conversation_text: str) -> dict:
"""Call Claude Haiku to extract structured LTM from conversation text."""
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
prompt = (
'Extract structured long-term memory from this conversation. '
'Return ONLY valid JSON with these keys:\n'
'- "summary": 3-5 sentence narrative of what was discussed\n'
'- "facts": array of factual statements worth remembering\n'
'- "preferences": array of user preferences expressed\n'
'- "dates": array of events/deadlines with date/time mentioned\n'
'- "topics": array of topic keywords\n\n'
'Conversation:\n' + conversation_text
)
resp = bedrock.converse(
modelId=config.COMPACTION_MODEL_ID,
messages=[{'role': 'user', 'content': [{'text': prompt}]}],
inferenceConfig={'maxTokens': 1024},
)
text = resp['output']['message']['content'][0]['text']
# Parse JSON from response (handle markdown code blocks)
if '```' in text:
text = text.split('```')[1]
if text.startswith('json'):
text = text[4:]
return json.loads(text.strip())
def _get_last_compaction_timestamp(actor_id: str) -> str | None:
"""Get the timestamp of the most recent LTM extraction to avoid duplicates."""
client = _get_memory_client()
events = client.list_events(
memory_id=MEMORY_ID,
actor_id=actor_id,
session_id=LTM_SESSION_ID,
event_metadata=[{
'left': {'metadataKey': 'type'},
'operator': 'EQUALS_TO',
'right': {'metadataValue': {'stringValue': 'ltm_extraction'}},
}],
max_results=1,
include_payload=False,
)
if events:
# Events are returned chronologically; last one is most recent
return str(events[-1].get('eventTimestamp', ''))
return None
def check_and_compact(actor_id: str, session_id: str) -> None:
"""Run compaction if the flag is set. Call BEFORE creating session_manager."""
if not _get_compaction_flag(actor_id):
return
logger.info('[memory_manager] Compaction triggered for actor_id=%s', actor_id)
try:
events = _get_all_session_events(actor_id, session_id)
total = len(events)
if total <= SESSION_WINDOW_SIZE:
_set_compaction_flag(actor_id, False)
return
# Events to compact: everything before the window
compact_count = total - SESSION_WINDOW_SIZE
events_to_compact = events[:compact_count]
# Idempotency: check if we already compacted up to this timestamp
last_compacted = _get_last_compaction_timestamp(actor_id)
oldest_event_ts = str(events_to_compact[-1].get('eventTimestamp', ''))
if last_compacted and last_compacted >= oldest_event_ts:
logger.info('[memory_manager] Already compacted up to %s, skipping', last_compacted)
_set_compaction_flag(actor_id, False)
return
# Extract text and call Claude
text = _extract_text_from_events(events_to_compact)
if not text.strip():
logger.info('[memory_manager] No text to compact, clearing flag')
_set_compaction_flag(actor_id, False)
return
extraction = _call_claude_extraction(text)
# Store LTM extraction as an event
client = _get_memory_client()
client.create_event(
memory_id=MEMORY_ID,
actor_id=actor_id,
session_id=LTM_SESSION_ID,
messages=[(json.dumps(extraction), 'ASSISTANT')],
event_timestamp=datetime.now(timezone.utc),
metadata={
'type': {'stringValue': 'ltm_extraction'},
'actor_id': {'stringValue': actor_id},
'compacted_through': {'stringValue': oldest_event_ts},
},
)
# Delete compacted events from the session
for event in events_to_compact:
try:
client.gmdp_client.delete_event(
memoryId=MEMORY_ID,
actorId=actor_id,
sessionId=session_id,
eventId=event['eventId'],
)
except Exception as e:
logger.warning('[memory_manager] Failed to delete event %s: %s', event.get('eventId'), e)
_set_compaction_flag(actor_id, False)
logger.info('[memory_manager] Compacted %d events into LTM for actor_id=%s', compact_count, actor_id)
except Exception as e:
logger.error('[memory_manager] Compaction failed: %s', e)
# Don't clear flag — retry next invocation
def check_window_and_flag(actor_id: str, session_id: str) -> None:
"""After session loads, check if we exceed the window and set flag for next time."""
try:
count = _count_session_events(actor_id, session_id)
if count > SESSION_WINDOW_SIZE:
logger.info('[memory_manager] Session has %d events (> %d), setting compaction flag',
count, SESSION_WINDOW_SIZE)
_set_compaction_flag(actor_id, True)
except Exception as e:
logger.error('[memory_manager] Failed to check window: %s', e)
def load_ltm(actor_id: str) -> str:
"""Load all LTM extractions for an actor and format as a system prompt block.
Returns empty string on failure (non-fatal).
"""
try:
client = _get_memory_client()
events = client.list_events(
memory_id=MEMORY_ID,
actor_id=actor_id,
session_id=LTM_SESSION_ID,
event_metadata=[{
'left': {'metadataKey': 'type'},
'operator': 'EQUALS_TO',
'right': {'metadataValue': {'stringValue': 'ltm_extraction'}},
}],
max_results=50,
include_payload=True,
)
if not events:
return ''
# Parse extractions (events are chronological, reverse for most-recent-first)
extractions = []
for event in reversed(events):
for item in event.get('payload', []):
if 'conversational' in item:
text = item['conversational'].get('content', {}).get('text', '')
try:
extractions.append(json.loads(text))
except (json.JSONDecodeError, TypeError):
pass
if not extractions:
return ''
# Build the LTM block: most recent summary first, then deduplicated lists
parts = ['## Long-term memory\n']
# Most recent summary
if extractions[0].get('summary'):
parts.append(f'**Recent context:** {extractions[0]["summary"]}\n')
# Deduplicated facts
all_facts = []
seen_facts: set[str] = set()
for ext in extractions:
for f in ext.get('facts', []):
key = f.lower().strip()
if key not in seen_facts:
seen_facts.add(key)
all_facts.append(f)
if all_facts:
parts.append('**Facts:**')
for f in all_facts[:30]: # Cap at 30
parts.append(f'- {f}')
parts.append('')
# Deduplicated preferences
all_prefs = []
seen_prefs: set[str] = set()
for ext in extractions:
for p in ext.get('preferences', []):
key = p.lower().strip()
if key not in seen_prefs:
seen_prefs.add(key)
all_prefs.append(p)
if all_prefs:
parts.append('**Preferences:**')
for p in all_prefs[:15]:
parts.append(f'- {p}')
parts.append('')
# Dates (most recent extractions first, keep all)
all_dates = []
for ext in extractions:
all_dates.extend(ext.get('dates', []))
if all_dates:
parts.append('**Upcoming dates/events:**')
for d in all_dates[:10]:
parts.append(f'- {d}')
parts.append('')
result = '\n'.join(parts)
logger.info('[memory_manager] Loaded LTM block: %d chars from %d extractions', len(result), len(extractions))
return result
except Exception as e:
logger.error('[memory_manager] LTM retrieval failed (non-fatal): %s', e)
return ''

View File

@@ -46,35 +46,50 @@ def _get_base_prompt(actor_id: str = '') -> str:
s3 = boto3.client('s3')
parts = []
# Per-user MEMORY.md (falls back to global)
memory_key = f'users/{actor_id}/MEMORY.md' if actor_id else 'MEMORY.md'
# Inject active goal at the top of context
try:
obj = s3.get_object(Bucket=bucket, Key=memory_key)
content = obj['Body'].read().decode('utf-8')
parts.append(f'## MEMORY.md\n{content}')
print(f'[prompt_builder] Loaded {memory_key} ({len(content)} bytes)')
except ClientError as e:
if e.response['Error']['Code'] in ('NoSuchKey', 'AccessDenied') and actor_id:
# Fall back to global MEMORY.md
try:
obj = s3.get_object(Bucket=bucket, Key='MEMORY.md')
content = obj['Body'].read().decode('utf-8')
parts.append(f'## MEMORY.md\n{content}')
print(f'[prompt_builder] Loaded MEMORY.md (fallback, {len(content)} bytes)')
except Exception as e2:
print(f'[prompt_builder] Failed to load MEMORY.md: {e2}')
else:
print(f'[prompt_builder] Failed to load {memory_key}: {e}')
obj = s3.get_object(Bucket=bucket, Key='GOAL.md')
goal_content = obj['Body'].read().decode('utf-8')
if '**Status:** active' in goal_content:
parts.append(f'## Active Goal\n{goal_content}')
print(f'[prompt_builder] Injected GOAL.md ({len(goal_content)} bytes)')
except Exception:
pass
for fname in ['SOUL.md', 'AGENTS.md', 'IDENTITY.md', 'TOOLS.md', 'HEARTBEAT.md']:
for fname in ['SOUL.md', 'STATUS.md']:
try:
obj = s3.get_object(Bucket=bucket, Key=fname)
content = obj['Body'].read().decode('utf-8')
parts.append(f'## {fname}\n{content}')
if fname == 'STATUS.md':
parts.append(f'## Status — In Progress\n{content}')
else:
parts.append(content)
print(f'[prompt_builder] Loaded {fname} ({len(content)} bytes)')
except Exception as e:
print(f'[prompt_builder] Failed to load {fname}: {e}')
parts.append(
'## Memory\n'
'Your memory works through two layers:\n\n'
'**Conversation history (short-term):** AgentCore automatically loads your full '
'conversation history with this user at the start of each session. You have complete '
'context of everything discussed previously — no need to ask users to repeat themselves.\n\n'
'**Long-term facts (LTM):** Important facts extracted from past conversations are '
'retrieved and injected as context automatically. These are things like preferences, '
'setup details, names, and recurring topics the user has shared.\n\n'
'Guidelines:\n'
'- Never ask "what did we discuss last time?" — you already have the history.\n'
'- When a user shares something important (job interview, preference, key decision, '
'setup change), acknowledge it and trust it will be captured — do not ask if they '
'want you to remember it.\n'
'- If you notice a fact that seems important but may not be in LTM yet (e.g. a '
'deadline, a preference, a name), you may say "I\'ll keep that in mind" — but do '
'not ask permission or make a production of it.\n'
'- **In-progress tracking (STATUS.md):** When you start async work (CodeBuild job, '
'reminder, deployment, anything you need to check back on), update STATUS.md using '
"write_workspace_file('STATUS.md', content). Clear entries when complete. Check "
"STATUS.md at the start of sessions where Daniel asks 'what's happening' or 'any updates'."
)
parts.append('## Runtime\nRuntime: agent-claw | host=AgentCore | model=bedrock-claude-sonnet | channel=telegram | timezone=America/Chicago')
result = '\n\n---\n\n'.join(parts)

View File

@@ -15,6 +15,7 @@ dependencies = [
"strands-agents-tools >= 0.5.0",
"strands-agents >= 1.13.0",
"workspace-mcp >= 1.20.0",
"mcp-proxy-for-aws >= 1.0.0",
]
[tool.hatch.build.targets.wheel]

View File

@@ -1,68 +1,42 @@
"""Code interpreter tool — runs Python code in AgentCore managed sandbox."""
"""Code interpreter tool — runs Python code in AgentCore managed sandbox.
Follows the AWS-recommended pattern:
https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/code-interpreter-building-agents.html
"""
import json
import os
import base64
from strands import tool
def _parse_stream(result: dict) -> str:
"""Parse the streaming response from invoke_code_interpreter."""
parts = []
if "stream" not in result:
return str(result)
for event in result["stream"]:
if "result" not in event:
continue
for item in event["result"].get("content", []):
item_type = item.get("type", "")
if item_type == "text":
text = item.get("text", "")
if text:
parts.append(text)
elif item_type == "resource":
resource = item.get("resource", {})
if "text" in resource:
parts.append(resource["text"])
elif "blob" in resource:
try:
parts.append(base64.b64decode(resource["blob"]).decode("utf-8"))
except Exception:
parts.append(f"<binary resource: {resource.get('uri', '?')}>")
elif item_type == "image":
# Base64-encoded image
image_data = item.get("source", {}).get("data", "")
mime = item.get("source", {}).get("mediaType", "image/png")
parts.append(f"<image: {mime}, {len(image_data)} bytes base64>")
return "\n".join(parts) if parts else "(no output)"
from bedrock_agentcore.tools.code_interpreter_client import code_session
@tool
def run_code(code: str, packages: list[str] | None = None) -> str:
"""Execute Python code in a secure managed sandbox and return the output.
Optionally install pip packages before running (e.g. ['pandas', 'numpy']).
def run_code(code: str, description: str = '') -> str:
"""Execute Python code in a secure AgentCore managed sandbox and return the output.
State is maintained within a single session but not across separate calls.
Supports data analysis, calculations, file I/O, and most common Python libraries.
Args:
code: Python code to execute.
packages: Optional list of pip packages to install first.
description: Optional description prepended as a comment.
Returns:
Execution output (stdout, results, errors).
JSON result with stdout, stderr, exitCode, and executionTime.
"""
try:
from bedrock_agentcore.tools import CodeInterpreter, code_session
if description:
code = f'# {description}\n{code}'
region = os.environ.get('AWS_REGION', 'us-east-1')
print(f'[run_code] executing {len(code)}c')
with code_session(region) as client:
if packages:
install_raw = client.install_packages(packages)
install_out = _parse_stream(install_raw) if isinstance(install_raw, dict) else str(install_raw)
print(f'[code_interpreter] install: {install_out[:200]}')
region = os.environ.get('AWS_REGION', 'us-east-1')
raw = client.execute_code(code)
return _parse_stream(raw)
with code_session(region) as client:
response = client.invoke('executeCode', {
'code': code,
'language': 'python',
'clearContext': False,
})
except Exception as e:
import traceback
return f'Code interpreter error: {type(e).__name__}: {e}\n{traceback.format_exc()[-500:]}'
for event in response['stream']:
return json.dumps(event['result'])
return json.dumps({'isError': True, 'content': [{'type': 'text', 'text': 'No output from code interpreter'}]})

View File

@@ -0,0 +1,148 @@
"""MCP connection management tool — add/remove/enable/disable user MCP servers."""
import boto3
from strands import tool
USERS_TABLE_NAME = 'agent-claw-users'
# Set per-invocation by main.py
_current_actor_id: str = ''
@tool
def manage_mcp_connection(action: str, name: str = '', url: str = '',
auth_type: str = 'none', cognito_token_url: str = '',
client_id: str = '', client_secret: str = '',
scope: str = '', token: str = '') -> str:
"""Add, remove, enable, disable, or list MCP server connections.
Actions: add, remove, enable, disable, list
For add with auth_type=oauth_client_credentials, provide:
- cognito_token_url: Cognito token endpoint
- client_id: OAuth client ID
- client_secret: Secret value (stored securely in SSM, not in database)
- scope: Space-separated OAuth scopes
For add with auth_type=bearer, provide:
- token: Bearer token value (stored securely in SSM, not in database)
For add with auth_type=none, only name and url are required.
Args:
action: One of "add", "remove", "enable", "disable", "list".
name: Connection name (required for add/remove/enable/disable).
url: MCP server URL (required for add).
auth_type: One of "none", "bearer", "oauth_client_credentials".
cognito_token_url: Token endpoint for oauth_client_credentials.
client_id: OAuth client ID for oauth_client_credentials.
client_secret: OAuth client secret (will be stored in SSM).
scope: OAuth scopes for oauth_client_credentials.
token: Bearer token (will be stored in SSM).
"""
actor_id = _current_actor_id
if not actor_id:
return 'Cannot determine actor_id.'
ddb = boto3.resource('dynamodb', region_name='us-east-1')
table = ddb.Table(USERS_TABLE_NAME)
if action == 'list':
resp = table.get_item(Key={'actor_id': actor_id})
connections = resp.get('Item', {}).get('services', {}).get('mcp_connections', [])
if not connections:
return 'No MCP connections configured.'
lines = []
for c in connections:
status = '' if c.get('enabled', True) else ''
lines.append(f" {status} {c['name']}: {c['url']} (auth: {c.get('auth_type', 'none')})")
return 'MCP connections:\n' + '\n'.join(lines)
if not name:
return 'name is required for this action.'
if action == 'add':
if not url:
return 'url is required for add.'
if auth_type not in ('none', 'bearer', 'oauth_client_credentials'):
return f'Invalid auth_type: {auth_type}. Use none, bearer, or oauth_client_credentials.'
ssm = boto3.client('ssm', region_name='us-east-1')
safe_actor = actor_id.replace(':', '-')
ssm_prefix = f'/agent-claw/mcp/{safe_actor}/{name}'
conn = {'name': name, 'url': url, 'auth_type': auth_type, 'enabled': True}
if auth_type == 'oauth_client_credentials':
if not cognito_token_url or not client_id or not client_secret:
return 'oauth_client_credentials requires cognito_token_url, client_id, and client_secret.'
ssm.put_parameter(Name=f'{ssm_prefix}/client-secret', Value=client_secret,
Type='SecureString', Overwrite=True)
conn['cognito_token_url'] = cognito_token_url
conn['client_id'] = client_id
conn['client_secret_ssm'] = f'{ssm_prefix}/client-secret'
conn['scope'] = scope
elif auth_type == 'bearer':
if not token:
return 'bearer auth requires token.'
ssm.put_parameter(Name=f'{ssm_prefix}/token', Value=token,
Type='SecureString', Overwrite=True)
conn['token_ssm'] = f'{ssm_prefix}/token'
# Upsert into mcp_connections list
resp = table.get_item(Key={'actor_id': actor_id})
services = resp.get('Item', {}).get('services', {})
connections = services.get('mcp_connections', [])
connections = [c for c in connections if c['name'] != name]
connections.append(conn)
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET services = if_not_exists(services, :empty), services.mcp_connections = :conns',
ExpressionAttributeValues={':conns': connections, ':empty': {}},
)
return f'MCP connection "{name}" added ({auth_type} auth). It will be available on your next message.'
elif action == 'remove':
resp = table.get_item(Key={'actor_id': actor_id})
connections = resp.get('Item', {}).get('services', {}).get('mcp_connections', [])
found = [c for c in connections if c['name'] == name]
if not found:
return f'No connection named "{name}" found.'
# Clean up SSM secrets
ssm = boto3.client('ssm', region_name='us-east-1')
safe_actor = actor_id.replace(':', '-')
for key in ('client-secret', 'token'):
try:
ssm.delete_parameter(Name=f'/agent-claw/mcp/{safe_actor}/{name}/{key}')
except ssm.exceptions.ParameterNotFound:
pass
connections = [c for c in connections if c['name'] != name]
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET services.mcp_connections = :conns',
ExpressionAttributeValues={':conns': connections},
)
return f'MCP connection "{name}" removed.'
elif action in ('enable', 'disable'):
resp = table.get_item(Key={'actor_id': actor_id})
connections = resp.get('Item', {}).get('services', {}).get('mcp_connections', [])
updated = False
for c in connections:
if c['name'] == name:
c['enabled'] = (action == 'enable')
updated = True
break
if not updated:
return f'No connection named "{name}" found.'
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET services.mcp_connections = :conns',
ExpressionAttributeValues={':conns': connections},
)
return f'MCP connection "{name}" {action}d.'
else:
return f'Unknown action: {action}. Use add, remove, enable, disable, or list.'

View File

@@ -0,0 +1,20 @@
"""Send file tool — sends documents to the user via Telegram."""
from tools import messaging
def send_file(file_content: str, filename: str, caption: str = '') -> str:
"""Send a file to the user as a Telegram document attachment.
Args:
file_content: The text content of the file to send.
filename: The filename (e.g. 'report.txt', 'data.csv').
caption: Optional caption to display with the file.
"""
adapter = messaging._adapter
if adapter is None:
return 'No channel adapter configured.'
if not hasattr(adapter, 'send_document'):
return 'Channel adapter does not support file sending.'
file_bytes = file_content.encode('utf-8')
msg_id = adapter.send_document(file_bytes, filename, caption)
return f'File "{filename}" sent (id={msg_id})' if msg_id else f'File "{filename}" sent'

View File

@@ -15,12 +15,12 @@ def _get_brave_key() -> str:
if _brave_key is None:
with _brave_lock:
if _brave_key is None:
secret_arn = os.environ.get(
'BRAVE_API_KEY_SECRET_ARN',
'arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/brave-api-key-uUSgzi'
param_name = os.environ.get(
'BRAVE_API_KEY_SSM_PARAM',
'/agent-claw/brave-api-key'
)
sm = boto3.client('secretsmanager')
_brave_key = sm.get_secret_value(SecretId=secret_arn)['SecretString']
ssm = boto3.client('ssm')
_brave_key = ssm.get_parameter(Name=param_name, WithDecryption=True)['Parameter']['Value']
return _brave_key

View File

@@ -15,6 +15,7 @@ dependencies = [
{ name = "aws-opentelemetry-distro" },
{ name = "bedrock-agentcore" },
{ name = "botocore", extra = ["crt"] },
{ name = "mcp-proxy-for-aws" },
{ name = "strands-agents" },
{ name = "strands-agents-tools" },
{ name = "workspace-mcp" },
@@ -25,6 +26,7 @@ requires-dist = [
{ name = "aws-opentelemetry-distro" },
{ name = "bedrock-agentcore", specifier = ">=1.0.3" },
{ name = "botocore", extras = ["crt"], specifier = ">=1.35.0" },
{ name = "mcp-proxy-for-aws", specifier = ">=1.0.0" },
{ name = "strands-agents", specifier = ">=1.13.0" },
{ name = "strands-agents-tools", specifier = ">=0.5.0" },
{ name = "workspace-mcp", specifier = ">=1.20.0" },
@@ -1456,6 +1458,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[[package]]
name = "mcp-proxy-for-aws"
version = "1.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3" },
{ name = "botocore", extra = ["crt"] },
{ name = "fastmcp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/ba/7de2c1d132206ac5aae6ae4125ff97c62f19add9ea4593d26a4c5dec437e/mcp_proxy_for_aws-1.4.2.tar.gz", hash = "sha256:939c0cad338051776fab263b5f7d1e97c6507e31502e3c3ce895c3166b06b002", size = 417185, upload-time = "2026-04-30T11:19:09.072Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/47/382c2c603c89aa40feb798536564bb285635b4af6689a9d6d5018898057e/mcp_proxy_for_aws-1.4.2-py3-none-any.whl", hash = "sha256:b9cefc389bc618e28cad8b0558dd2eca9112e599738e17a8f695ad759d1b7de0", size = 34665, upload-time = "2026-04-30T11:19:10.18Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"

View File

@@ -5,6 +5,11 @@ import { AgentClawStack } from '../lib/agent-claw-stack';
const app = new cdk.App();
// Billing tags applied to all resources in the stack
cdk.Tags.of(app).add('project', 'agent-claw');
cdk.Tags.of(app).add('env', 'prod');
cdk.Tags.of(app).add('owner', 'daniel');
new AgentClawStack(app, 'AgentClawStack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,

View File

@@ -1,91 +1,46 @@
{
"version": "53.0.0",
"files": {
"e2659170a0721541efa761a8d5d04d5e36cbbf691c4b15a9053002b7c825055d": {
"displayName": "WorkspaceFiles/AwsCliLayer/Code",
"source": {
"path": "asset.e2659170a0721541efa761a8d5d04d5e36cbbf691c4b15a9053002b7c825055d.zip",
"packaging": "file"
},
"destinations": {
"495395224548-us-east-1-b19c5879": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "e2659170a0721541efa761a8d5d04d5e36cbbf691c4b15a9053002b7c825055d.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
}
},
"3423a042b818e31c1e34a19d6689ab2e5f9b70fcbe9e71df66f241b20a200bd9": {
"displayName": "Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Code",
"source": {
"path": "asset.3423a042b818e31c1e34a19d6689ab2e5f9b70fcbe9e71df66f241b20a200bd9",
"packaging": "zip"
},
"destinations": {
"495395224548-us-east-1-12f29a1a": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "3423a042b818e31c1e34a19d6689ab2e5f9b70fcbe9e71df66f241b20a200bd9.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
}
},
"0feea8d997b96e31a1bd7dd049faf8ee17babeb6d2f5b663ba7e3a70387302e0": {
"displayName": "WorkspaceFiles/Asset1",
"source": {
"path": "asset.0feea8d997b96e31a1bd7dd049faf8ee17babeb6d2f5b663ba7e3a70387302e0",
"packaging": "zip"
},
"destinations": {
"495395224548-us-east-1-2e3561b9": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "0feea8d997b96e31a1bd7dd049faf8ee17babeb6d2f5b663ba7e3a70387302e0.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
}
},
"f4461651bfa7d2822e3f36525ace7882e1610dcdaf85e052e1907241e25491d6": {
"e0a834c0c682fe0c528540082d0b587c43ed791927a064104e5eb6c507c0cdb2": {
"displayName": "TgIngest/Code",
"source": {
"path": "asset.f4461651bfa7d2822e3f36525ace7882e1610dcdaf85e052e1907241e25491d6",
"path": "asset.e0a834c0c682fe0c528540082d0b587c43ed791927a064104e5eb6c507c0cdb2",
"packaging": "zip"
},
"destinations": {
"495395224548-us-east-1-2abe2e26": {
"495395224548-us-east-1-351c433c": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "f4461651bfa7d2822e3f36525ace7882e1610dcdaf85e052e1907241e25491d6.zip",
"objectKey": "e0a834c0c682fe0c528540082d0b587c43ed791927a064104e5eb6c507c0cdb2.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
}
},
"e8d92532d2cb081ba122764c803acc80aaa41350d3497665468ca165dd5ff799": {
"59b4a808a6125020261c01ae23500d036cdde29fdb255aa6a82d61555fde4848": {
"displayName": "AgentRunner/Code",
"source": {
"path": "asset.e8d92532d2cb081ba122764c803acc80aaa41350d3497665468ca165dd5ff799",
"path": "asset.59b4a808a6125020261c01ae23500d036cdde29fdb255aa6a82d61555fde4848",
"packaging": "zip"
},
"destinations": {
"495395224548-us-east-1-7c682050": {
"495395224548-us-east-1-16e7a6a4": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "e8d92532d2cb081ba122764c803acc80aaa41350d3497665468ca165dd5ff799.zip",
"objectKey": "59b4a808a6125020261c01ae23500d036cdde29fdb255aa6a82d61555fde4848.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
}
},
"99aabce70089266e2352cb313d55ee18b849e39c418e8e9cd25dea8c4bf85fc4": {
"6a3663dc0651e59d7ac6f549864cf1c084e3fbfc98ebd0479fed6a84075a378e": {
"displayName": "OAuthHandler/Code",
"source": {
"path": "asset.99aabce70089266e2352cb313d55ee18b849e39c418e8e9cd25dea8c4bf85fc4",
"path": "asset.6a3663dc0651e59d7ac6f549864cf1c084e3fbfc98ebd0479fed6a84075a378e",
"packaging": "zip"
},
"destinations": {
"495395224548-us-east-1-793899ae": {
"495395224548-us-east-1-fffb41e6": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "99aabce70089266e2352cb313d55ee18b849e39c418e8e9cd25dea8c4bf85fc4.zip",
"objectKey": "6a3663dc0651e59d7ac6f549864cf1c084e3fbfc98ebd0479fed6a84075a378e.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
@@ -106,31 +61,31 @@
}
}
},
"8e7324457a5952eb51f04a34fbc5ba853252e7157d8d8958ac5fda92e72edb1f": {
"1aaec5c60ed6a294ff9b918a707477e0a0e299a850447e7456a8c8091604131b": {
"displayName": "Scheduler/Code",
"source": {
"path": "asset.8e7324457a5952eb51f04a34fbc5ba853252e7157d8d8958ac5fda92e72edb1f",
"path": "asset.1aaec5c60ed6a294ff9b918a707477e0a0e299a850447e7456a8c8091604131b",
"packaging": "zip"
},
"destinations": {
"495395224548-us-east-1-89bca2fb": {
"495395224548-us-east-1-e6bab83a": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "8e7324457a5952eb51f04a34fbc5ba853252e7157d8d8958ac5fda92e72edb1f.zip",
"objectKey": "1aaec5c60ed6a294ff9b918a707477e0a0e299a850447e7456a8c8091604131b.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
}
},
"78c322849179468ec994f5c3e550a0db4961592ea962b9cf484463e5f04b5a70": {
"9c45c012ea9c045aa771b1c3049eadcb15fe66ca16d02e617d50ee9745fa967a": {
"displayName": "AgentClawStack Template",
"source": {
"path": "AgentClawStack.template.json",
"packaging": "file"
},
"destinations": {
"495395224548-us-east-1-02b0125a": {
"495395224548-us-east-1-0ef056b9": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "78c322849179468ec994f5c3e550a0db4961592ea962b9cf484463e5f04b5a70.json",
"objectKey": "9c45c012ea9c045aa771b1c3049eadcb15fe66ca16d02e617d50ee9745fa967a.json",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}

View File

@@ -8,16 +8,6 @@
]
}
],
"/AgentClawStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C": [
{
"type": "aws:cdk:is-custom-resource-handler-singleton",
"data": true
},
{
"type": "aws:cdk:is-custom-resource-handler-runtime-family",
"data": 2
}
],
"/AgentClawStack/SessionStore": [
{
"type": "aws:cdk:hasPhysicalName",
@@ -42,7 +32,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:378:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:421:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -56,7 +46,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:382:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:425:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -70,7 +60,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:386:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:429:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -84,7 +74,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:391:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:434:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -98,7 +88,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:396:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:439:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -112,7 +102,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:401:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:444:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -126,7 +116,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:406:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:449:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -140,7 +130,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:411:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:454:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -154,7 +144,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:416:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:459:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -168,7 +158,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:421:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:464:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -200,36 +190,6 @@
]
}
],
"/AgentClawStack/WorkspaceBucket/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "WorkspaceBucket53E30B92"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new Bucket2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:46:9)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new BucketDeployment2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:55:7)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/SessionStore/Resource": [
{
"type": "aws:cdk:logicalId",
@@ -239,7 +199,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Table2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:62:26)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:71:26)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -254,7 +214,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Table2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:71:24)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:80:24)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -269,7 +229,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Queue2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:79:26)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:88:26)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -284,7 +244,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:88:24)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:97:24)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -299,7 +259,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:105:27)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:116:27)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -314,7 +274,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new HttpApi2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:143:21)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:153:21)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -329,7 +289,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Role2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:161:26)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:171:26)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -344,7 +304,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:243:28)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:248:28)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -359,7 +319,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:314:31)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:312:31)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -374,7 +334,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Rule2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:330:27)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:328:27)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -389,7 +349,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:334:19)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:332:19)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -404,7 +364,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:337:25)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:335:25)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -419,7 +379,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...WrappedClass.addPermission in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:350:17)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:348:17)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -438,51 +398,6 @@
]
}
],
"/AgentClawStack/WorkspaceFiles/AwsCliLayer/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "WorkspaceFilesAwsCliLayer50B6E9D8"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new BucketDeployment2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:55:7)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/WorkspaceFiles/CustomResource/Default": [
{
"type": "aws:cdk:logicalId",
"data": "WorkspaceFilesCustomResourceA7FC771F"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new BucketDeployment2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:55:7)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new BucketDeployment2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:55:7)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/TgIngest/ServiceRole/Resource": [
{
"type": "aws:cdk:logicalId",
@@ -492,7 +407,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:88:24)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:97:24)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -507,7 +422,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:105:27)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:116:27)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -522,7 +437,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...WrappedClass.addEventSource in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:137:19)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:147:19)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -537,7 +452,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new HttpApi2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:143:21)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:153:21)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -554,7 +469,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:147:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:157:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -571,7 +486,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:147:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:157:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -588,7 +503,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:279:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:277:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -605,7 +520,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:279:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:277:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -622,7 +537,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:286:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:284:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -639,7 +554,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:286:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:284:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -656,7 +571,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:295:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:293:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -673,7 +588,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:295:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:293:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -688,7 +603,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:165:18)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:175:18)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -702,8 +617,8 @@
{
"type": "aws:cdk:creationStack",
"data": [
"...environmentFromArn.grantRead in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:201:29)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:207:45)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -718,7 +633,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:243:28)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:248:28)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -733,7 +648,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:314:31)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:312:31)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -748,24 +663,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:337:25)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF"
},
{
"type": "aws:cdk:creationStack",
"data": [
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-s3-deployment/lib/bucket-deployment.js:1:71 in aws-cdk-lib...",
"Array.map (:)",
"...new BucketDeployment2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:55:7)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:335:25)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -780,7 +678,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...WrappedClass.grantSendMessages in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:101:18)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:111:18)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -795,7 +693,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...WrappedClass.grantReadWriteData in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:122:18)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:133:18)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -812,7 +710,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:147:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:157:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -829,7 +727,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:279:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:277:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -846,7 +744,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:286:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:284:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -863,7 +761,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:295:13)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:293:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -877,8 +775,8 @@
{
"type": "aws:cdk:creationStack",
"data": [
"...environmentFromArn.grantRead in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:258:29)",
"...WrappedClass.addToRolePolicy in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:263:20)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -893,7 +791,7 @@
"type": "aws:cdk:creationStack",
"data": [
"...WrappedClass.grantSendMessages in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:326:18)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:324:18)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -907,8 +805,8 @@
{
"type": "aws:cdk:creationStack",
"data": [
"...environmentFromArn.grantRead in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:348:20)",
"...WrappedClass.addToRolePolicy in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:346:17)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]

View File

@@ -1,238 +1,6 @@
{
"Description": "agent-claw: serverless personal assistant on AgentCore",
"Resources": {
"WorkspaceBucket53E30B92": {
"Type": "AWS::S3::Bucket",
"Properties": {
"BucketEncryption": {
"ServerSideEncryptionConfiguration": [
{
"ServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}
]
},
"BucketName": "agent-claw-workspace-495395224548",
"Tags": [
{
"Key": "aws-cdk:cr-owned:254e75d0",
"Value": "true"
}
]
},
"UpdateReplacePolicy": "Retain",
"DeletionPolicy": "Retain",
"Metadata": {
"aws:cdk:path": "AgentClawStack/WorkspaceBucket/Resource"
}
},
"WorkspaceFilesAwsCliLayer50B6E9D8": {
"Type": "AWS::Lambda::LayerVersion",
"Properties": {
"Content": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "e2659170a0721541efa761a8d5d04d5e36cbbf691c4b15a9053002b7c825055d.zip"
},
"Description": "/opt/awscli/aws"
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WorkspaceFiles/AwsCliLayer/Resource",
"aws:asset:path": "asset.e2659170a0721541efa761a8d5d04d5e36cbbf691c4b15a9053002b7c825055d.zip",
"aws:asset:is-bundled": false,
"aws:asset:property": "Content"
}
},
"WorkspaceFilesCustomResourceA7FC771F": {
"Type": "Custom::CDKBucketDeployment",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536",
"Arn"
]
},
"SourceBucketNames": [
"cdk-hnb659fds-assets-495395224548-us-east-1"
],
"SourceObjectKeys": [
"0feea8d997b96e31a1bd7dd049faf8ee17babeb6d2f5b663ba7e3a70387302e0.zip"
],
"DestinationBucketName": {
"Ref": "WorkspaceBucket53E30B92"
},
"WaitForDistributionInvalidation": true,
"Prune": true,
"OutputObjectKeys": true
},
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete",
"Metadata": {
"aws:cdk:path": "AgentClawStack/WorkspaceFiles/CustomResource/Default"
}
},
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
],
"Version": "2012-10-17"
},
"ManagedPolicyArns": [
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
]
]
}
]
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource"
}
},
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF": {
"Type": "AWS::IAM::Policy",
"Properties": {
"PolicyDocument": {
"Statement": [
{
"Action": [
"s3:GetObject*",
"s3:GetBucket*",
"s3:List*"
],
"Effect": "Allow",
"Resource": [
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":s3:::cdk-hnb659fds-assets-495395224548-us-east-1"
]
]
},
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":s3:::cdk-hnb659fds-assets-495395224548-us-east-1/*"
]
]
}
]
},
{
"Action": [
"s3:GetObject*",
"s3:GetBucket*",
"s3:List*",
"s3:DeleteObject*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
"s3:Abort*"
],
"Effect": "Allow",
"Resource": [
{
"Fn::GetAtt": [
"WorkspaceBucket53E30B92",
"Arn"
]
},
{
"Fn::Join": [
"",
[
{
"Fn::GetAtt": [
"WorkspaceBucket53E30B92",
"Arn"
]
},
"/*"
]
]
}
]
}
],
"Version": "2012-10-17"
},
"PolicyName": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF",
"Roles": [
{
"Ref": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265"
}
]
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource"
}
},
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "3423a042b818e31c1e34a19d6689ab2e5f9b70fcbe9e71df66f241b20a200bd9.zip"
},
"Environment": {
"Variables": {
"AWS_CA_BUNDLE": "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem"
}
},
"Handler": "index.handler",
"Layers": [
{
"Ref": "WorkspaceFilesAwsCliLayer50B6E9D8"
}
],
"Role": {
"Fn::GetAtt": [
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265",
"Arn"
]
},
"Runtime": "python3.13",
"Timeout": 900
},
"DependsOn": [
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF",
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265"
],
"Metadata": {
"aws:cdk:path": "AgentClawStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource",
"aws:asset:path": "asset.3423a042b818e31c1e34a19d6689ab2e5f9b70fcbe9e71df66f241b20a200bd9",
"aws:asset:is-bundled": false,
"aws:asset:property": "Code"
}
},
"SessionStore8C86EEFE": {
"Type": "AWS::DynamoDB::Table",
"Properties": {
@@ -353,13 +121,52 @@
]
}
},
{
"Action": "ssm:GetParameter",
"Effect": "Allow",
"Resource": [
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/telegram-bot-token",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/brave-api-key",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/google-oauth-client"
]
},
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
"s3:DeleteObject*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
"s3:Abort*"
],
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3"
"Resource": [
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":s3:::agent-claw-workspace-495395224548"
]
]
},
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":s3:::agent-claw-workspace-495395224548/*"
]
]
}
]
}
],
"Version": "2012-10-17"
@@ -380,15 +187,16 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "f4461651bfa7d2822e3f36525ace7882e1610dcdaf85e052e1907241e25491d6.zip"
"S3Key": "e0a834c0c682fe0c528540082d0b587c43ed791927a064104e5eb6c507c0cdb2.zip"
},
"Environment": {
"Variables": {
"MESSAGE_QUEUE_URL": {
"Ref": "MessageQueue7A3BF959"
},
"TELEGRAM_BOT_TOKEN_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3",
"TELEGRAM_WEBHOOK_SECRET": ""
"TELEGRAM_BOT_TOKEN_SSM_PARAM": "/agent-claw/telegram-bot-token",
"TELEGRAM_WEBHOOK_SECRET": "",
"ATTACHMENTS_BUCKET_NAME": "agent-claw-workspace-495395224548"
}
},
"FunctionName": "agent-claw-tg-ingest",
@@ -409,7 +217,7 @@
],
"Metadata": {
"aws:cdk:path": "AgentClawStack/TgIngest/Resource",
"aws:asset:path": "asset.f4461651bfa7d2822e3f36525ace7882e1610dcdaf85e052e1907241e25491d6",
"aws:asset:path": "asset.e0a834c0c682fe0c528540082d0b587c43ed791927a064104e5eb6c507c0cdb2",
"aws:asset:is-bundled": false,
"aws:asset:property": "Code"
}
@@ -538,42 +346,39 @@
"Effect": "Allow",
"Resource": [
{
"Fn::GetAtt": [
"WorkspaceBucket53E30B92",
"Arn"
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":s3:::agent-claw-workspace-495395224548"
]
]
},
{
"Fn::Join": [
"",
[
"arn:",
{
"Fn::GetAtt": [
"WorkspaceBucket53E30B92",
"Arn"
]
"Ref": "AWS::Partition"
},
"/*"
":s3:::agent-claw-workspace-495395224548/*"
]
]
}
]
},
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Action": "ssm:GetParameter",
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3"
},
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/brave-api-key-uUSgzi"
"Resource": [
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/telegram-bot-token",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/brave-api-key",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/google-oauth-client"
]
},
{
"Action": [
@@ -615,18 +420,16 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "e8d92532d2cb081ba122764c803acc80aaa41350d3497665468ca165dd5ff799.zip"
"S3Key": "59b4a808a6125020261c01ae23500d036cdde29fdb255aa6a82d61555fde4848.zip"
},
"Environment": {
"Variables": {
"SESSION_TABLE_NAME": {
"Ref": "SessionStore8C86EEFE"
},
"WORKSPACE_BUCKET_NAME": {
"Ref": "WorkspaceBucket53E30B92"
},
"TELEGRAM_BOT_TOKEN_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3",
"BRAVE_API_KEY_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/brave-api-key-uUSgzi",
"WORKSPACE_BUCKET_NAME": "agent-claw-workspace-495395224548",
"TELEGRAM_BOT_TOKEN_SSM_PARAM": "/agent-claw/telegram-bot-token",
"BRAVE_API_KEY_SSM_PARAM": "/agent-claw/brave-api-key",
"RUNTIME_1_ARN": "arn:aws:bedrock-agentcore:us-east-1:495395224548:runtime/agentclaw_agent_claw_main-vTRGIEG6ON",
"AWS_REGION_NAME": "us-east-1",
"USERS_TABLE_NAME": {
@@ -653,7 +456,7 @@
],
"Metadata": {
"aws:cdk:path": "AgentClawStack/AgentRunner/Resource",
"aws:asset:path": "asset.e8d92532d2cb081ba122764c803acc80aaa41350d3497665468ca165dd5ff799",
"aws:asset:path": "asset.59b4a808a6125020261c01ae23500d036cdde29fdb255aa6a82d61555fde4848",
"aws:asset:is-bundled": false,
"aws:asset:property": "Code"
}
@@ -1055,42 +858,39 @@
"Effect": "Allow",
"Resource": [
{
"Fn::GetAtt": [
"WorkspaceBucket53E30B92",
"Arn"
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":s3:::agent-claw-workspace-495395224548"
]
]
},
{
"Fn::Join": [
"",
[
"arn:",
{
"Fn::GetAtt": [
"WorkspaceBucket53E30B92",
"Arn"
]
"Ref": "AWS::Partition"
},
"/*"
":s3:::agent-claw-workspace-495395224548/*"
]
]
}
]
},
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Action": "ssm:GetParameter",
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3"
},
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/brave-api-key-uUSgzi"
"Resource": [
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/telegram-bot-token",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/brave-api-key",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/google-oauth-client"
]
},
{
"Action": [
@@ -1161,14 +961,6 @@
},
"Sid": "WorkspaceMcpInvoke"
},
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-subXHl"
},
{
"Action": "secretsmanager:GetSecretValue",
"Effect": "Allow",
@@ -1207,6 +999,72 @@
]
},
"Sid": "SchedulerLambdaPermission"
},
{
"Action": [
"codebuild:*",
"ecr:*",
"ecs:*",
"logs:*"
],
"Effect": "Allow",
"Resource": "*",
"Sid": "ComputeBuild"
},
{
"Action": [
"s3:List*",
"s3:GetObject",
"lambda:List*",
"lambda:Get*",
"cloudformation:Describe*",
"cloudformation:List*",
"sqs:List*",
"sqs:GetQueueAttributes",
"ec2:Describe*",
"ssm:Describe*",
"ssm:List*",
"ce:GetCostAndUsage",
"ce:GetCostForecast"
],
"Effect": "Allow",
"Resource": "*",
"Sid": "BroadReadOnly"
},
{
"Action": [
"iam:PutRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:DeleteRolePolicy"
],
"Effect": "Allow",
"Resource": {
"Fn::GetAtt": [
"Runtime1RoleA7A82078",
"Arn"
]
},
"Sid": "IamSelfModify"
},
{
"Action": [
"iam:CreatePolicy",
"iam:GetPolicy",
"iam:ListPolicies"
],
"Effect": "Allow",
"Resource": "*",
"Sid": "IamPolicyManagement"
},
{
"Action": [
"ssm:GetParameter",
"ssm:GetParameters"
],
"Effect": "Allow",
"Resource": "arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/aws-mcp-url",
"Sid": "AwsMcpUrlSsmRead"
}
],
"Version": "2012-10-17"
@@ -1228,12 +1086,13 @@
"PolicyDocument": {
"Statement": [
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Action": "ssm:GetParameter",
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-subXHl"
"Resource": [
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/telegram-bot-token",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/brave-api-key",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/google-oauth-client"
]
},
{
"Action": "secretsmanager:GetSecretValue",
@@ -1293,20 +1152,13 @@
"PolicyDocument": {
"Statement": [
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Action": "ssm:GetParameter",
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-subXHl"
},
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3"
"Resource": [
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/telegram-bot-token",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/brave-api-key",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/google-oauth-client"
]
},
{
"Action": [
@@ -1346,12 +1198,6 @@
}
]
},
{
"Action": "secretsmanager:GetSecretValue",
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-subXHl",
"Sid": "GoogleOAuthClientSecretExact"
},
{
"Action": [
"secretsmanager:CreateSecret",
@@ -1381,15 +1227,15 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "99aabce70089266e2352cb313d55ee18b849e39c418e8e9cd25dea8c4bf85fc4.zip"
"S3Key": "6a3663dc0651e59d7ac6f549864cf1c084e3fbfc98ebd0479fed6a84075a378e.zip"
},
"Environment": {
"Variables": {
"GOOGLE_OAUTH_CLIENT_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-subXHl",
"GOOGLE_OAUTH_CLIENT_SSM_PARAM": "/agent-claw/google-oauth-client",
"USERS_TABLE_NAME": {
"Ref": "UsersTable9725E9C8"
},
"TELEGRAM_BOT_TOKEN_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3",
"TELEGRAM_BOT_TOKEN_SSM_PARAM": "/agent-claw/telegram-bot-token",
"OAUTH_REDIRECT_URI": {
"Fn::Join": [
"",
@@ -1426,7 +1272,7 @@
],
"Metadata": {
"aws:cdk:path": "AgentClawStack/OAuthHandler/Resource",
"aws:asset:path": "asset.99aabce70089266e2352cb313d55ee18b849e39c418e8e9cd25dea8c4bf85fc4",
"aws:asset:path": "asset.6a3663dc0651e59d7ac6f549864cf1c084e3fbfc98ebd0479fed6a84075a378e",
"aws:asset:is-bundled": false,
"aws:asset:property": "Code"
}
@@ -1656,12 +1502,13 @@
"PolicyDocument": {
"Statement": [
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Action": "ssm:GetParameter",
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3"
"Resource": [
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/telegram-bot-token",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/brave-api-key",
"arn:aws:ssm:us-east-1:495395224548:parameter/agent-claw/google-oauth-client"
]
},
{
"Action": [
@@ -1690,11 +1537,11 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "8e7324457a5952eb51f04a34fbc5ba853252e7157d8d8958ac5fda92e72edb1f.zip"
"S3Key": "1aaec5c60ed6a294ff9b918a707477e0a0e299a850447e7456a8c8091604131b.zip"
},
"Environment": {
"Variables": {
"TELEGRAM_BOT_TOKEN_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/telegram-bot-token-Oq3in3"
"TELEGRAM_BOT_TOKEN_SSM_PARAM": "/agent-claw/telegram-bot-token"
}
},
"FunctionName": "agent-claw-scheduler",
@@ -1715,7 +1562,7 @@
],
"Metadata": {
"aws:cdk:path": "AgentClawStack/Scheduler/Resource",
"aws:asset:path": "asset.8e7324457a5952eb51f04a34fbc5ba853252e7157d8d8958ac5fda92e72edb1f",
"aws:asset:path": "asset.1aaec5c60ed6a294ff9b918a707477e0a0e299a850447e7456a8c8091604131b",
"aws:asset:is-bundled": false,
"aws:asset:property": "Code"
}
@@ -1740,7 +1587,7 @@
"CDKMetadata": {
"Type": "AWS::CDK::Metadata",
"Properties": {
"Analytics": "v2:deflate64:H4sIAAAAAAAA/21R207DMAz9Ft4zA934gG2AQAIxOsTr5LVelS1NSu2sqqL+O0rKxoR4OsfHl9gnGWR3GdxcYceTojxMjN5CWAsWB5UTO98WpLDjTeAphIUvDiRqubM/bIQFMg2Kp5tQUmNcX5MVGFP3Z0EhMwnDPMKgDNbbEiEsd/YFe2o/qWXtrFprWxkSZx+9LSQqZ7Lc/YoPR7KyTuu9YtNoW8X0/+qK2lozX84aN9ZYQ8idoViVcOWMLvrUlNigyt5i7cothA/cjpWJDIq/GMK7J5/ERAaFja5QqMP+mEF4EmnmjY75CDFcC1apYSRRyp2XkT1boarF07V/wlQ3KIpHMoTc/yzuDQ0nQzcmmrnBjgujYd7x0ujkr0q+x0eTK57F1ecPjlMu+JuXxsugrCsJ9nx9zGZwO4Obqz1rPWm9FV0T5CN+AxjAathBAgAA"
"Analytics": "v2:deflate64:H4sIAAAAAAAA/21P0U7DMAz8lr2nZnTjAzYEggfEaHmf3NZU2dqk1M6qKsq/o6QbD4inO5991l0O+UMO6xVOnNXNOet0Bb4UrM8KJz563sDe1WeSPTKpZjbY26YC/4lVR+rxyyQSFH8z+A9HLomJBNVhXzUI/tmZWrQ1cfXLny5kpLRurOkNh0GbNq7/Vw809po52m7+mCcojT34wi5REh5sp+s5mRILijdHZCZh2EVQOOgWhSacLzn4F5FhN+hoiBDHUrBNDxcSpcI6WdirEWpHvNX5M6a7oCi2YPCFuyZzHYWgCuLUTKUg8fu137uTwUlQxjYEJ7675Fu438J6dWKts9EZ0T1BseAPQK+curMBAAA="
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/CDKMetadata/Default"
@@ -1811,9 +1658,7 @@
},
"WorkspaceBucketName": {
"Description": "S3 bucket containing agent workspace files",
"Value": {
"Ref": "WorkspaceBucket53E30B92"
}
"Value": "agent-claw-workspace-495395224548"
},
"SessionTableName": {
"Description": "DynamoDB table for session mapping",

View File

@@ -0,0 +1,29 @@
"""EventBridge-triggered Lambda: sends a Telegram reminder then deletes the rule."""
import json
import os
import boto3
import urllib.request
def handler(event, context):
chat_id = event['chat_id']
message = event['message']
rule_name = event['rule_name']
# Fetch bot token
ssm = boto3.client('ssm', region_name='us-east-1')
token = ssm.get_parameter(Name=os.environ['TELEGRAM_BOT_TOKEN_SSM_PARAM'], WithDecryption=True)['Parameter']['Value']
# Send Telegram message
payload = json.dumps({'chat_id': chat_id, 'text': message}).encode()
req = urllib.request.Request(
f'https://api.telegram.org/bot{token}/sendMessage',
data=payload,
headers={'Content-Type': 'application/json'},
)
urllib.request.urlopen(req)
# Delete the one-time rule
eb = boto3.client('events', region_name='us-east-1')
eb.remove_targets(Rule=rule_name, Ids=['scheduler'])
eb.delete_rule(Name=rule_name)

View File

@@ -0,0 +1,292 @@
import json
import os
import time
import uuid
import boto3
import urllib.request
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:
from botocore.config import Config
_agentcore = boto3.client(
'bedrock-agentcore',
region_name='us-east-1',
config=Config(read_timeout=600, connect_timeout=10)
)
return _agentcore
def get_or_create_user(actor_id: str, from_info: dict) -> dict:
"""Look up user in registry, auto-registering on first contact."""
table_name = os.environ.get('USERS_TABLE_NAME', '')
if not table_name:
return {'actor_id': actor_id, 'display_name': from_info.get('from_name', actor_id)}
table = get_ddb().Table(table_name)
response = table.get_item(Key={'actor_id': actor_id})
item = response.get('Item')
if item:
return item
now = int(time.time())
item = {
'actor_id': actor_id,
'display_name': from_info.get('from_name') or actor_id,
'telegram_username': from_info.get('from_username', ''),
'created_at': str(now),
'status': 'pending',
'services': {},
}
table.put_item(Item=item)
print(f'[agent-runner] Registered new user (pending): {actor_id}')
return item
def update_user_status(actor_id: str, name: str, status: str) -> None:
table_name = os.environ.get('USERS_TABLE_NAME', '')
if not table_name:
return
table = get_ddb().Table(table_name)
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET display_name = :n, #s = :s',
ExpressionAttributeNames={'#s': 'status'},
ExpressionAttributeValues={':n': name, ':s': status},
)
# Per-invocation dedup: track sent message hashes to prevent AgentCore retry duplicates
_sent_hashes: set = set()
def send_telegram_direct(chat_id: str, token: str, text: str, thread_id: int | None = None) -> None:
import hashlib
h = hashlib.md5(f'{chat_id}:{text}'.encode()).hexdigest()[:12]
if h in _sent_hashes:
print(f'[agent-runner] dedup: skipping duplicate message (hash={h})')
return
_sent_hashes.add(h)
url = f'https://api.telegram.org/bot{token}/sendMessage'
payload: dict = {'chat_id': chat_id, 'text': text}
if thread_id is not None:
payload['message_thread_id'] = thread_id
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
try:
resp = urllib.request.urlopen(req, timeout=10)
resp_body = resp.read()
import re
msg_id = re.search(r'"message_id":(\d+)', resp_body.decode('utf-8', errors='replace'))
print(f'[agent-runner] Telegram sendMessage -> msg_id={msg_id.group(1) if msg_id else "?"} hash={h}')
except Exception as e:
print(f'[agent-runner] Telegram sendMessage FAILED: {type(e).__name__}: {e} hash={h}')
raise
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', '')
message_thread_id = first.get('message_thread_id') # int or None
# Use sender's user ID for identity (not chat_id, which is the group ID in group chats)
from_info_early = first.get('messages', [{}])[0]
sender_id = from_info_early.get('from_id') or chat_id
actor_id = f"{channel}:{sender_id}"
# ── User registry ─────────────────────────────────────────────────────
from_info = first.get('messages', [{}])[0]
user_profile = get_or_create_user(actor_id, from_info)
# ── Onboarding gate ─────────────────────────────────────────────────────
table_name = os.environ.get('USERS_TABLE_NAME', '')
if table_name and user_profile.get('status', 'active') == 'pending':
raw_prompt = records[0]['messages'][0]['text'] if records else ''
is_name_msg = bool(raw_prompt and len(raw_prompt.strip()) < 50 and '?' not in raw_prompt)
if is_name_msg:
name = raw_prompt.strip()
update_user_status(actor_id, name=name, status='active')
user_profile['display_name'] = name
user_profile['status'] = 'active'
prompt = f"[System: User just registered with name '{name}'. Welcome them warmly and ask how you can help.]"
else:
bot_token_secret_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SSM_PARAM', '')
bot_token = ''
if bot_token_secret_arn:
ssm = boto3.client('ssm', region_name='us-east-1')
bot_token = ssm.get_parameter(Name=bot_token_secret_arn, WithDecryption=True)['Parameter']['Value']
send_telegram_direct(chat_id, bot_token, "Hi! I don't recognize you yet. What's your name?", thread_id=message_thread_id)
return
# ── Get or create AgentCore session ──────────────────────────────────
session_id = get_or_create_session(actor_id)
print(f"[agent-runner] actor={actor_id} session={session_id} user={user_profile.get('display_name', '')}")
# ── 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)
# ── Attach file context if present ────────────────────────────────────
attachment = first.get('attachment')
if attachment:
file_name = attachment.get('file_name', 'unknown')
if 'inline_content' in attachment:
prompt += f"\n\n[Attached file: {file_name}]\n```\n{attachment['inline_content']}\n```"
elif 's3_key' in attachment:
s3_ref = f"s3://{attachment['s3_bucket']}/{attachment['s3_key']}"
prompt += f"\n\n[Attached file: {file_name} ({attachment.get('mime_type', '')}) — stored at {s3_ref}]"
elif 'error' in attachment:
prompt += f"\n\n[Attachment {file_name} could not be processed: {attachment['error']}]"
# ── Build payload for AgentCore Runtime 1 ────────────────────────────
payload: dict[str, Any] = {
'prompt': prompt,
'actor_id': actor_id,
'session_id': session_id,
'user_profile': {
'display_name': user_profile.get('display_name', actor_id),
'telegram_username': user_profile.get('telegram_username', ''),
'google_accounts': user_profile.get('google_accounts', {'primary': user_profile['google_email']} if user_profile.get('google_email') else {}),
'allowed': user_profile.get('allowed', True),
'services': user_profile.get('enrolled_services', user_profile.get('services', {})),
},
'channel_adapter': {
'type': channel,
'target_id': str(chat_id),
'message_thread_id': message_thread_id,
'bot_token_secret_arn': os.environ.get('TELEGRAM_BOT_TOKEN_SSM_PARAM', ''),
},
}
# ── 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(),
)
# Process streaming response: buffer text chunks and send to Telegram as paragraphs arrive
bot_token = ''
bot_token_param = os.environ.get('TELEGRAM_BOT_TOKEN_SSM_PARAM', '')
if bot_token_param:
ssm = boto3.client('ssm', region_name='us-east-1')
try:
bot_token = ssm.get_parameter(Name=bot_token_param, WithDecryption=True)['Parameter']['Value']
except Exception as e:
print(f'[agent-runner] Failed to get bot token: {e}')
body = response.get('response')
text_buffer = ''
leftover = ''
if body is not None:
for raw_chunk in body.iter_chunks():
if not raw_chunk:
continue
# AgentCore streams SSE format: "data: {...}\n\n"
text = leftover + raw_chunk.decode('utf-8', errors='replace')
parts = text.split('\n\n')
leftover = parts[-1]
for part in parts[:-1]:
for line in part.splitlines():
if not line.startswith('data: '):
continue
data = line[6:].strip()
if not data or data == '[DONE]':
continue
try:
event = json.loads(data)
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(event, dict):
continue
# Extract text delta from contentBlockDelta ONLY
# Do NOT use event.get('data') — that's the full formatted summary,
# causing duplicate delivery alongside the token stream.
delta = event.get('event', {}).get('contentBlockDelta', {}).get('delta', {})
if not isinstance(delta, dict):
continue
token = delta.get('text', '')
if token:
text_buffer += token
# Only flush if buffer is very large — prevents splitting multi-turn responses
if len(text_buffer) > 1200:
print(f'[agent-runner] send chunk {len(text_buffer)}c to {chat_id}')
send_telegram_direct(str(chat_id), bot_token, text_buffer.strip(), thread_id=message_thread_id)
text_buffer = ''
# Flush any remaining text
print(f'[agent-runner] stream done buffer={len(text_buffer)} bot_token_set={bool(bot_token)}')
if text_buffer.strip() and bot_token:
# Suppress heartbeat OK responses
if text_buffer.strip().upper().startswith('HEARTBEAT_OK'):
print(f'[agent-runner] heartbeat suppressed for {actor_id}')
return
print(f'[agent-runner] flushing {len(text_buffer)}c to {chat_id}')
send_telegram_direct(str(chat_id), bot_token, text_buffer.strip(), thread_id=message_thread_id)
print(f"[agent-runner] Completed session={session_id} actor={actor_id}")

View File

@@ -0,0 +1,246 @@
"""
Google OAuth handler Lambda.
Routes:
GET /oauth/start?actor_id=telegram:123&label=work → redirect to Google OAuth consent
GET /oauth/callback?code=...&state=... → exchange code, store tokens, update DynamoDB
"""
import base64
import json
import os
import time
import urllib.parse
import urllib.request
import boto3
_sm = None
_ddb = None
SCOPES = ' '.join([
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/documents',
'openid',
'email',
'profile',
])
def get_sm():
global _sm
if _sm is None:
_sm = boto3.client('secretsmanager', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
return _sm
def get_ddb():
global _ddb
if _ddb is None:
_ddb = boto3.resource('dynamodb')
return _ddb
def get_oauth_client() -> tuple[str, str]:
"""Return (client_id, client_secret) from SSM Parameter Store."""
param_name = os.environ['GOOGLE_OAUTH_CLIENT_SSM_PARAM']
ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
secret = json.loads(ssm.get_parameter(Name=param_name, WithDecryption=True)['Parameter']['Value'])
return secret['client_id'], secret['client_secret']
def actor_id_to_secret_name(actor_id: str, label: str = 'primary') -> str:
safe = actor_id.replace(':', '-').replace('/', '-')
return f'agent-claw/google-credentials/{safe}/{label}'
def _redirect(url: str) -> dict:
return {'statusCode': 302, 'headers': {'Location': url}, 'body': ''}
def _html(body: str, status: int = 200) -> dict:
return {'statusCode': status, 'headers': {'Content-Type': 'text/html'}, 'body': body}
def handler(event, context):
path = event.get('rawPath') or event.get('path', '')
params = event.get('queryStringParameters') or {}
if path.endswith('/oauth/start'):
return handle_start(params)
elif path.endswith('/oauth/callback'):
return handle_callback(params)
else:
return {'statusCode': 404, 'body': 'Not found'}
def handle_start(params: dict) -> dict:
actor_id = params.get('actor_id', '')
if not actor_id:
return _html('<h1>Missing actor_id</h1>', 400)
label = params.get('label', 'primary')
client_id, _ = get_oauth_client()
redirect_uri = os.environ['OAUTH_REDIRECT_URI']
# Encode actor_id + label in state (JSON → base64)
state_data = json.dumps({'a': actor_id, 'l': label})
state = base64.urlsafe_b64encode(state_data.encode()).decode().rstrip('=')
auth_url = (
'https://accounts.google.com/o/oauth2/v2/auth?'
+ urllib.parse.urlencode({
'client_id': client_id,
'redirect_uri': redirect_uri,
'response_type': 'code',
'scope': SCOPES,
'access_type': 'offline',
'prompt': 'consent',
'state': state,
})
)
return _redirect(auth_url)
def handle_callback(params: dict) -> dict:
code = params.get('code', '')
state = params.get('state', '')
error = params.get('error', '')
if error:
return _html(f'<h1>OAuth error: {error}</h1>', 400)
if not code or not state:
return _html('<h1>Missing code or state</h1>', 400)
# Decode actor_id + label from state
try:
padding = 4 - len(state) % 4
state_data = json.loads(base64.urlsafe_b64decode(state + '=' * padding).decode())
actor_id = state_data['a']
label = state_data.get('l', 'primary')
except Exception:
# Backward compat: old state was just base64(actor_id)
try:
padding = 4 - len(state) % 4
actor_id = base64.urlsafe_b64decode(state + '=' * padding).decode()
label = 'primary'
except Exception:
return _html('<h1>Invalid state</h1>', 400)
client_id, client_secret = get_oauth_client()
redirect_uri = os.environ['OAUTH_REDIRECT_URI']
# Exchange code for tokens
token_data = urllib.parse.urlencode({
'code': code,
'client_id': client_id,
'client_secret': client_secret,
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code',
}).encode()
req = urllib.request.Request(
'https://oauth2.googleapis.com/token',
data=token_data,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
tokens = json.loads(resp.read())
except Exception as e:
print(f'[oauth] Token exchange failed: {e}')
return _html(f'<h1>Token exchange failed: {e}</h1>', 500)
# Fetch user email from Google
user_email = ''
try:
id_token_payload = tokens.get('id_token', '').split('.')[1]
padding = 4 - len(id_token_payload) % 4
claims = json.loads(base64.urlsafe_b64decode(id_token_payload + '=' * padding))
user_email = claims.get('email', '')
except Exception:
pass
if not user_email:
try:
access_token = tokens.get('access_token', '')
req2 = urllib.request.Request(
'https://www.googleapis.com/oauth2/v3/userinfo',
headers={'Authorization': f'Bearer {access_token}'},
)
with urllib.request.urlopen(req2, timeout=10) as resp2:
user_email = json.loads(resp2.read()).get('email', '')
except Exception as e:
print(f'[oauth] userinfo fetch failed: {e}')
# Build credentials dict (google-auth format)
creds = {
'token': tokens.get('access_token'),
'refresh_token': tokens.get('refresh_token'),
'token_uri': 'https://oauth2.googleapis.com/token',
'client_id': client_id,
'client_secret': client_secret,
'scopes': [s for s in SCOPES.split() if s.startswith('https://')],
'email': user_email,
'user_email': user_email,
}
if tokens.get('expires_in'):
creds['expiry'] = time.strftime(
'%Y-%m-%dT%H:%M:%SZ',
time.gmtime(time.time() + int(tokens['expires_in']))
)
# Store in Secrets Manager at labeled path
secret_name = actor_id_to_secret_name(actor_id, label)
sm = get_sm()
try:
sm.create_secret(Name=secret_name, SecretString=json.dumps(creds))
except sm.exceptions.ResourceExistsException:
sm.put_secret_value(SecretId=secret_name, SecretString=json.dumps(creds))
print(f'[oauth] Stored credentials for actor={actor_id} label={label} email={user_email}')
# Update DynamoDB: merge into google_accounts map
table_name = os.environ.get('USERS_TABLE_NAME', '')
if table_name and actor_id:
try:
table = get_ddb().Table(table_name)
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET google_accounts = if_not_exists(google_accounts, :empty)',
ExpressionAttributeValues={':empty': {}},
)
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET google_accounts.#label = :email',
ExpressionAttributeNames={'#label': label},
ExpressionAttributeValues={':email': user_email},
)
except Exception as e:
print(f'[oauth] DynamoDB update failed: {e}')
# Best-effort Telegram confirmation
try:
bot_token_param = os.environ.get('TELEGRAM_BOT_TOKEN_SSM_PARAM', '')
if bot_token_param and actor_id.startswith('telegram:'):
chat_id = actor_id.split(':', 1)[1]
ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
bot_token = ssm.get_parameter(Name=bot_token_param, WithDecryption=True)['Parameter']['Value']
tg_text = f'✅ Connected {user_email} as "{label}"'
tg_payload = json.dumps({'chat_id': chat_id, 'text': tg_text}).encode()
tg_req = urllib.request.Request(
f'https://api.telegram.org/bot{bot_token}/sendMessage',
data=tg_payload,
headers={'Content-Type': 'application/json'},
)
urllib.request.urlopen(tg_req, timeout=5)
except Exception as e:
print(f'[oauth] Telegram notification failed: {e}')
return _html(
f'<h1>✅ Google account connected!</h1>'
f'<p>Connected <b>{user_email}</b> as "<b>{label}</b>".</p>'
f'<p>You can close this window and return to Telegram.</p>'
)

View File

@@ -0,0 +1,229 @@
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()
TEXT_EXTENSIONS = {'.txt', '.py', '.js', '.ts', '.json', '.md', '.csv', '.xml', '.html',
'.css', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.sh', '.bash',
'.sql', '.log', '.env', '.rs', '.go', '.java', '.c', '.h', '.cpp'}
MAX_INLINE_SIZE = 50 * 1024 # 50KB
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, thread_id: int | None = None) -> None:
"""Fire-and-forget typing action (does not raise on failure)."""
try:
token = get_bot_token()
payload: dict = {'chat_id': chat_id, 'action': 'typing'}
if thread_id is not None:
payload['message_thread_id'] = thread_id
data = json.dumps(payload).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 get_file_from_telegram(file_id: str) -> tuple[str, bytes]:
"""Call getFile then download. Returns (file_path, file_bytes)."""
token = get_bot_token()
# getFile
url = f'https://api.telegram.org/bot{token}/getFile'
data = json.dumps({'file_id': file_id}).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read()).get('result', {})
file_path = result.get('file_path', '')
# Download
download_url = f'https://api.telegram.org/file/bot{token}/{file_path}'
with urllib.request.urlopen(download_url, timeout=60) as resp:
file_bytes = resp.read()
return file_path, file_bytes
def extract_attachment(message: dict) -> dict | None:
"""Extract file attachment info from a Telegram message. Returns metadata dict or None."""
# Priority: document > photo > audio > video > voice > video_note
if 'document' in message:
doc = message['document']
return {'type': 'document', 'file_id': doc['file_id'],
'file_name': doc.get('file_name', 'document'), 'mime_type': doc.get('mime_type', ''),
'file_size': doc.get('file_size', 0)}
if 'photo' in message:
# Take largest photo (last in array)
photo = message['photo'][-1]
return {'type': 'photo', 'file_id': photo['file_id'],
'file_name': 'photo.jpg', 'mime_type': 'image/jpeg',
'file_size': photo.get('file_size', 0)}
if 'audio' in message:
audio = message['audio']
return {'type': 'audio', 'file_id': audio['file_id'],
'file_name': audio.get('file_name', 'audio.ogg'), 'mime_type': audio.get('mime_type', 'audio/ogg'),
'file_size': audio.get('file_size', 0)}
if 'video' in message:
video = message['video']
return {'type': 'video', 'file_id': video['file_id'],
'file_name': video.get('file_name', 'video.mp4'), 'mime_type': video.get('mime_type', 'video/mp4'),
'file_size': video.get('file_size', 0)}
if 'voice' in message:
voice = message['voice']
return {'type': 'voice', 'file_id': voice['file_id'],
'file_name': 'voice.ogg', 'mime_type': voice.get('mime_type', 'audio/ogg'),
'file_size': voice.get('file_size', 0)}
return None
def is_text_file(file_name: str, mime_type: str) -> bool:
"""Determine if a file should be inlined as text."""
ext = os.path.splitext(file_name)[1].lower()
if ext in TEXT_EXTENSIONS:
return True
if mime_type.startswith('text/'):
return True
return False
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:
print(f'[tg-ingest] Bad JSON body')
return {'statusCode': 400, 'body': 'Bad Request'}
print(f'[tg-ingest] Update keys: {list(body.keys())}')
update_id = body.get('update_id')
# Support regular messages and edited messages
message = body.get('message') or body.get('edited_message')
if not message:
print(f'[tg-ingest] No message field, update_type={list(body.keys())}')
return {'statusCode': 200, 'body': 'ok'}
chat_id = str(message.get('chat', {}).get('id', ''))
message_thread_id = message.get('message_thread_id') # present for supergroup topics
text = message.get('text', '') or message.get('caption', '')
from_user = message.get('from', {})
timestamp = message.get('date', 0)
# ── Detect file attachment ────────────────────────────────────────────
attachment = extract_attachment(message)
attachment_meta = None
if attachment:
print(f'[tg-ingest] Attachment detected: type={attachment["type"]} name={attachment["file_name"]} size={attachment["file_size"]}')
try:
file_path, file_bytes = get_file_from_telegram(attachment['file_id'])
file_name = attachment['file_name']
mime_type = attachment['mime_type']
if is_text_file(file_name, mime_type) and len(file_bytes) <= MAX_INLINE_SIZE:
# Inline small text files
try:
text_content = file_bytes.decode('utf-8')
except UnicodeDecodeError:
text_content = file_bytes.decode('latin-1')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
'inline_content': text_content,
}
else:
# Store to S3
bucket = os.environ.get('ATTACHMENTS_BUCKET_NAME', '')
if bucket:
s3 = boto3.client('s3')
s3_key = f'attachments/{chat_id}/{update_id}/{file_name}'
s3.put_object(Bucket=bucket, Key=s3_key, Body=file_bytes,
ContentType=mime_type or 'application/octet-stream')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
's3_bucket': bucket,
's3_key': s3_key,
}
print(f'[tg-ingest] Stored to s3://{bucket}/{s3_key}')
else:
print(f'[tg-ingest] No ATTACHMENTS_BUCKET_NAME configured, skipping S3 upload')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
'error': 'S3 bucket not configured',
}
except Exception as e:
print(f'[tg-ingest] Failed to process attachment: {e}')
attachment_meta = {
'type': attachment['type'],
'file_name': attachment['file_name'],
'error': str(e),
}
print(f'[tg-ingest] chat_id={chat_id} text_len={len(text)} attachment={bool(attachment_meta)} update_id={update_id}')
if not chat_id or (not text and not attachment_meta):
print(f'[tg-ingest] Dropping: chat_id={chat_id!r} text={text!r} attachment={attachment_meta}')
return {'statusCode': 200, 'body': 'ok'}
# ── Send typing action (non-blocking, background thread) ──────────────
t = threading.Thread(target=send_typing, args=(chat_id, message_thread_id))
t.daemon = True
t.start()
# ── Enqueue to SQS FIFO ───────────────────────────────────────────────
sqs = boto3.client('sqs')
msg_body: dict = {
'channel': 'telegram',
'chat_id': chat_id,
'message_thread_id': message_thread_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,
}
if attachment_meta:
msg_body['attachment'] = attachment_meta
sqs.send_message(
QueueUrl=os.environ['MESSAGE_QUEUE_URL'],
MessageGroupId=chat_id,
MessageDeduplicationId=str(update_id),
MessageBody=json.dumps(msg_body),
)
return {'statusCode': 200, 'body': 'ok'}

View File

@@ -0,0 +1,292 @@
import json
import os
import time
import uuid
import boto3
import urllib.request
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:
from botocore.config import Config
_agentcore = boto3.client(
'bedrock-agentcore',
region_name='us-east-1',
config=Config(read_timeout=600, connect_timeout=10)
)
return _agentcore
def get_or_create_user(actor_id: str, from_info: dict) -> dict:
"""Look up user in registry, auto-registering on first contact."""
table_name = os.environ.get('USERS_TABLE_NAME', '')
if not table_name:
return {'actor_id': actor_id, 'display_name': from_info.get('from_name', actor_id)}
table = get_ddb().Table(table_name)
response = table.get_item(Key={'actor_id': actor_id})
item = response.get('Item')
if item:
return item
now = int(time.time())
item = {
'actor_id': actor_id,
'display_name': from_info.get('from_name') or actor_id,
'telegram_username': from_info.get('from_username', ''),
'created_at': str(now),
'status': 'pending',
'services': {},
}
table.put_item(Item=item)
print(f'[agent-runner] Registered new user (pending): {actor_id}')
return item
def update_user_status(actor_id: str, name: str, status: str) -> None:
table_name = os.environ.get('USERS_TABLE_NAME', '')
if not table_name:
return
table = get_ddb().Table(table_name)
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET display_name = :n, #s = :s',
ExpressionAttributeNames={'#s': 'status'},
ExpressionAttributeValues={':n': name, ':s': status},
)
# Per-invocation dedup: track sent message hashes to prevent AgentCore retry duplicates
_sent_hashes: set = set()
def send_telegram_direct(chat_id: str, token: str, text: str, thread_id: int | None = None) -> None:
import hashlib
h = hashlib.md5(f'{chat_id}:{text}'.encode()).hexdigest()[:12]
if h in _sent_hashes:
print(f'[agent-runner] dedup: skipping duplicate message (hash={h})')
return
_sent_hashes.add(h)
url = f'https://api.telegram.org/bot{token}/sendMessage'
payload: dict = {'chat_id': chat_id, 'text': text}
if thread_id is not None:
payload['message_thread_id'] = thread_id
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
try:
resp = urllib.request.urlopen(req, timeout=10)
resp_body = resp.read()
import re
msg_id = re.search(r'"message_id":(\d+)', resp_body.decode('utf-8', errors='replace'))
print(f'[agent-runner] Telegram sendMessage -> msg_id={msg_id.group(1) if msg_id else "?"} hash={h}')
except Exception as e:
print(f'[agent-runner] Telegram sendMessage FAILED: {type(e).__name__}: {e} hash={h}')
raise
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', '')
message_thread_id = first.get('message_thread_id') # int or None
# Use sender's user ID for identity (not chat_id, which is the group ID in group chats)
from_info_early = first.get('messages', [{}])[0]
sender_id = from_info_early.get('from_id') or chat_id
actor_id = f"{channel}:{sender_id}"
# ── User registry ─────────────────────────────────────────────────────
from_info = first.get('messages', [{}])[0]
user_profile = get_or_create_user(actor_id, from_info)
# ── Onboarding gate ─────────────────────────────────────────────────────
table_name = os.environ.get('USERS_TABLE_NAME', '')
if table_name and user_profile.get('status', 'active') == 'pending':
raw_prompt = records[0]['messages'][0]['text'] if records else ''
is_name_msg = bool(raw_prompt and len(raw_prompt.strip()) < 50 and '?' not in raw_prompt)
if is_name_msg:
name = raw_prompt.strip()
update_user_status(actor_id, name=name, status='active')
user_profile['display_name'] = name
user_profile['status'] = 'active'
prompt = f"[System: User just registered with name '{name}'. Welcome them warmly and ask how you can help.]"
else:
bot_token_secret_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', '')
bot_token = ''
if bot_token_secret_arn:
sm = boto3.client('secretsmanager', region_name='us-east-1')
bot_token = sm.get_secret_value(SecretId=bot_token_secret_arn)['SecretString']
send_telegram_direct(chat_id, bot_token, "Hi! I don't recognize you yet. What's your name?", thread_id=message_thread_id)
return
# ── Get or create AgentCore session ──────────────────────────────────
session_id = get_or_create_session(actor_id)
print(f"[agent-runner] actor={actor_id} session={session_id} user={user_profile.get('display_name', '')}")
# ── 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)
# ── Attach file context if present ────────────────────────────────────
attachment = first.get('attachment')
if attachment:
file_name = attachment.get('file_name', 'unknown')
if 'inline_content' in attachment:
prompt += f"\n\n[Attached file: {file_name}]\n```\n{attachment['inline_content']}\n```"
elif 's3_key' in attachment:
s3_ref = f"s3://{attachment['s3_bucket']}/{attachment['s3_key']}"
prompt += f"\n\n[Attached file: {file_name} ({attachment.get('mime_type', '')}) — stored at {s3_ref}]"
elif 'error' in attachment:
prompt += f"\n\n[Attachment {file_name} could not be processed: {attachment['error']}]"
# ── Build payload for AgentCore Runtime 1 ────────────────────────────
payload: dict[str, Any] = {
'prompt': prompt,
'actor_id': actor_id,
'session_id': session_id,
'user_profile': {
'display_name': user_profile.get('display_name', actor_id),
'telegram_username': user_profile.get('telegram_username', ''),
'google_accounts': user_profile.get('google_accounts', {'primary': user_profile['google_email']} if user_profile.get('google_email') else {}),
'allowed': user_profile.get('allowed', True),
'services': user_profile.get('enrolled_services', user_profile.get('services', {})),
},
'channel_adapter': {
'type': channel,
'target_id': str(chat_id),
'message_thread_id': message_thread_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(),
)
# Process streaming response: buffer text chunks and send to Telegram as paragraphs arrive
bot_token = ''
bot_token_secret_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', '')
if bot_token_secret_arn:
sm = boto3.client('secretsmanager', region_name='us-east-1')
try:
bot_token = sm.get_secret_value(SecretId=bot_token_secret_arn)['SecretString']
except Exception as e:
print(f'[agent-runner] Failed to get bot token: {e}')
body = response.get('response')
text_buffer = ''
leftover = ''
if body is not None:
for raw_chunk in body.iter_chunks():
if not raw_chunk:
continue
# AgentCore streams SSE format: "data: {...}\n\n"
text = leftover + raw_chunk.decode('utf-8', errors='replace')
parts = text.split('\n\n')
leftover = parts[-1]
for part in parts[:-1]:
for line in part.splitlines():
if not line.startswith('data: '):
continue
data = line[6:].strip()
if not data or data == '[DONE]':
continue
try:
event = json.loads(data)
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(event, dict):
continue
# Extract text delta from contentBlockDelta ONLY
# Do NOT use event.get('data') — that's the full formatted summary,
# causing duplicate delivery alongside the token stream.
delta = event.get('event', {}).get('contentBlockDelta', {}).get('delta', {})
if not isinstance(delta, dict):
continue
token = delta.get('text', '')
if token:
text_buffer += token
# Only flush if buffer is very large — prevents splitting multi-turn responses
if len(text_buffer) > 1200:
print(f'[agent-runner] send chunk {len(text_buffer)}c to {chat_id}')
send_telegram_direct(str(chat_id), bot_token, text_buffer.strip(), thread_id=message_thread_id)
text_buffer = ''
# Flush any remaining text
print(f'[agent-runner] stream done buffer={len(text_buffer)} bot_token_set={bool(bot_token)}')
if text_buffer.strip() and bot_token:
# Suppress heartbeat OK responses
if text_buffer.strip().upper().startswith('HEARTBEAT_OK'):
print(f'[agent-runner] heartbeat suppressed for {actor_id}')
return
print(f'[agent-runner] flushing {len(text_buffer)}c to {chat_id}')
send_telegram_direct(str(chat_id), bot_token, text_buffer.strip(), thread_id=message_thread_id)
print(f"[agent-runner] Completed session={session_id} actor={actor_id}")

View File

@@ -0,0 +1,230 @@
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()
TEXT_EXTENSIONS = {'.txt', '.py', '.js', '.ts', '.json', '.md', '.csv', '.xml', '.html',
'.css', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.sh', '.bash',
'.sql', '.log', '.env', '.rs', '.go', '.java', '.c', '.h', '.cpp'}
MAX_INLINE_SIZE = 50 * 1024 # 50KB
def get_bot_token() -> str:
global _bot_token
if _bot_token is None:
with _token_lock:
if _bot_token is None:
ssm = boto3.client('ssm')
_bot_token = ssm.get_parameter(
Name=os.environ['TELEGRAM_BOT_TOKEN_SSM_PARAM'],
WithDecryption=True
)['Parameter']['Value']
return _bot_token
def send_typing(chat_id: str, thread_id: int | None = None) -> None:
"""Fire-and-forget typing action (does not raise on failure)."""
try:
token = get_bot_token()
payload: dict = {'chat_id': chat_id, 'action': 'typing'}
if thread_id is not None:
payload['message_thread_id'] = thread_id
data = json.dumps(payload).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 get_file_from_telegram(file_id: str) -> tuple[str, bytes]:
"""Call getFile then download. Returns (file_path, file_bytes)."""
token = get_bot_token()
# getFile
url = f'https://api.telegram.org/bot{token}/getFile'
data = json.dumps({'file_id': file_id}).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read()).get('result', {})
file_path = result.get('file_path', '')
# Download
download_url = f'https://api.telegram.org/file/bot{token}/{file_path}'
with urllib.request.urlopen(download_url, timeout=60) as resp:
file_bytes = resp.read()
return file_path, file_bytes
def extract_attachment(message: dict) -> dict | None:
"""Extract file attachment info from a Telegram message. Returns metadata dict or None."""
# Priority: document > photo > audio > video > voice > video_note
if 'document' in message:
doc = message['document']
return {'type': 'document', 'file_id': doc['file_id'],
'file_name': doc.get('file_name', 'document'), 'mime_type': doc.get('mime_type', ''),
'file_size': doc.get('file_size', 0)}
if 'photo' in message:
# Take largest photo (last in array)
photo = message['photo'][-1]
return {'type': 'photo', 'file_id': photo['file_id'],
'file_name': 'photo.jpg', 'mime_type': 'image/jpeg',
'file_size': photo.get('file_size', 0)}
if 'audio' in message:
audio = message['audio']
return {'type': 'audio', 'file_id': audio['file_id'],
'file_name': audio.get('file_name', 'audio.ogg'), 'mime_type': audio.get('mime_type', 'audio/ogg'),
'file_size': audio.get('file_size', 0)}
if 'video' in message:
video = message['video']
return {'type': 'video', 'file_id': video['file_id'],
'file_name': video.get('file_name', 'video.mp4'), 'mime_type': video.get('mime_type', 'video/mp4'),
'file_size': video.get('file_size', 0)}
if 'voice' in message:
voice = message['voice']
return {'type': 'voice', 'file_id': voice['file_id'],
'file_name': 'voice.ogg', 'mime_type': voice.get('mime_type', 'audio/ogg'),
'file_size': voice.get('file_size', 0)}
return None
def is_text_file(file_name: str, mime_type: str) -> bool:
"""Determine if a file should be inlined as text."""
ext = os.path.splitext(file_name)[1].lower()
if ext in TEXT_EXTENSIONS:
return True
if mime_type.startswith('text/'):
return True
return False
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:
print(f'[tg-ingest] Bad JSON body')
return {'statusCode': 400, 'body': 'Bad Request'}
print(f'[tg-ingest] Update keys: {list(body.keys())}')
update_id = body.get('update_id')
# Support regular messages and edited messages
message = body.get('message') or body.get('edited_message')
if not message:
print(f'[tg-ingest] No message field, update_type={list(body.keys())}')
return {'statusCode': 200, 'body': 'ok'}
chat_id = str(message.get('chat', {}).get('id', ''))
message_thread_id = message.get('message_thread_id') # present for supergroup topics
text = message.get('text', '') or message.get('caption', '')
from_user = message.get('from', {})
timestamp = message.get('date', 0)
# ── Detect file attachment ────────────────────────────────────────────
attachment = extract_attachment(message)
attachment_meta = None
if attachment:
print(f'[tg-ingest] Attachment detected: type={attachment["type"]} name={attachment["file_name"]} size={attachment["file_size"]}')
try:
file_path, file_bytes = get_file_from_telegram(attachment['file_id'])
file_name = attachment['file_name']
mime_type = attachment['mime_type']
if is_text_file(file_name, mime_type) and len(file_bytes) <= MAX_INLINE_SIZE:
# Inline small text files
try:
text_content = file_bytes.decode('utf-8')
except UnicodeDecodeError:
text_content = file_bytes.decode('latin-1')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
'inline_content': text_content,
}
else:
# Store to S3
bucket = os.environ.get('ATTACHMENTS_BUCKET_NAME', '')
if bucket:
s3 = boto3.client('s3')
s3_key = f'attachments/{chat_id}/{update_id}/{file_name}'
s3.put_object(Bucket=bucket, Key=s3_key, Body=file_bytes,
ContentType=mime_type or 'application/octet-stream')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
's3_bucket': bucket,
's3_key': s3_key,
}
print(f'[tg-ingest] Stored to s3://{bucket}/{s3_key}')
else:
print(f'[tg-ingest] No ATTACHMENTS_BUCKET_NAME configured, skipping S3 upload')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
'error': 'S3 bucket not configured',
}
except Exception as e:
print(f'[tg-ingest] Failed to process attachment: {e}')
attachment_meta = {
'type': attachment['type'],
'file_name': attachment['file_name'],
'error': str(e),
}
print(f'[tg-ingest] chat_id={chat_id} text_len={len(text)} attachment={bool(attachment_meta)} update_id={update_id}')
if not chat_id or (not text and not attachment_meta):
print(f'[tg-ingest] Dropping: chat_id={chat_id!r} text={text!r} attachment={attachment_meta}')
return {'statusCode': 200, 'body': 'ok'}
# ── Send typing action (non-blocking, background thread) ──────────────
t = threading.Thread(target=send_typing, args=(chat_id, message_thread_id))
t.daemon = True
t.start()
# ── Enqueue to SQS FIFO ───────────────────────────────────────────────
sqs = boto3.client('sqs')
msg_body: dict = {
'channel': 'telegram',
'chat_id': chat_id,
'message_thread_id': message_thread_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,
}
if attachment_meta:
msg_body['attachment'] = attachment_meta
sqs.send_message(
QueueUrl=os.environ['MESSAGE_QUEUE_URL'],
MessageGroupId=chat_id,
MessageDeduplicationId=str(update_id),
MessageBody=json.dumps(msg_body),
)
return {'statusCode': 200, 'body': 'ok'}

View File

@@ -18,7 +18,7 @@
"validateOnSynth": false,
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-deploy-role-495395224548-us-east-1",
"cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-cfn-exec-role-495395224548-us-east-1",
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-495395224548-us-east-1/78c322849179468ec994f5c3e550a0db4961592ea962b9cf484463e5f04b5a70.json",
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-495395224548-us-east-1/9c45c012ea9c045aa771b1c3049eadcb15fe66ca16d02e617d50ee9745fa967a.json",
"requiresBootstrapStackVersion": 6,
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version",
"additionalDependencies": [

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@ import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as apigatewayv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import * as events from 'aws-cdk-lib/aws-events';
import * as eventsTargets from 'aws-cdk-lib/aws-events-targets';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
@@ -20,25 +21,33 @@ export class AgentClawStack extends cdk.Stack {
super(scope, id, props);
// ── Context parameters ─────────────────────────────────────────────────
const telegramBotTokenSecretArn = this.node.tryGetContext('telegramBotTokenSecretArn') as string | undefined;
const braveApiKeySecretArn = this.node.tryGetContext('braveApiKeySecretArn') as string | undefined;
const telegramBotTokenParamName = this.node.tryGetContext('telegramBotTokenParamName') as string | undefined;
const braveApiKeyParamName = this.node.tryGetContext('braveApiKeyParamName') as string | undefined;
const googleOAuthClientParamName = this.node.tryGetContext('googleOAuthClientParamName') as string | undefined;
const existingWorkspaceBucketName = this.node.tryGetContext('workspaceBucketName') as string | undefined;
const runtime1Arn = this.node.tryGetContext('runtime1Arn') as string | undefined;
if (!telegramBotTokenSecretArn) {
throw new Error('Context param required: telegramBotTokenSecretArn');
if (!telegramBotTokenParamName) {
throw new Error('Context param required: telegramBotTokenParamName');
}
if (!braveApiKeySecretArn) {
throw new Error('Context param required: braveApiKeySecretArn');
if (!braveApiKeyParamName) {
throw new Error('Context param required: braveApiKeyParamName');
}
if (!googleOAuthClientParamName) {
throw new Error('Context param required: googleOAuthClientParamName');
}
// ── Secrets (reference existing) ───────────────────────────────────────
const botTokenSecret = secretsmanager.Secret.fromSecretCompleteArn(
this, 'TelegramBotToken', telegramBotTokenSecretArn
);
const braveApiKeySecret = secretsmanager.Secret.fromSecretCompleteArn(
this, 'BraveApiKey', braveApiKeySecretArn
);
// ── SSM Parameters (reference existing SecureString params) ────────────
const ssmParamArns = [
`arn:aws:ssm:${this.region}:${this.account}:parameter${telegramBotTokenParamName}`,
`arn:aws:ssm:${this.region}:${this.account}:parameter${braveApiKeyParamName}`,
`arn:aws:ssm:${this.region}:${this.account}:parameter${googleOAuthClientParamName}`,
];
const ssmReadPolicy = new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
resources: ssmParamArns,
});
// ── S3 workspace bucket ────────────────────────────────────────────────
const workspaceBucket = existingWorkspaceBucketName
@@ -94,12 +103,14 @@ export class AgentClawStack extends cdk.Stack {
memorySize: 128,
environment: {
MESSAGE_QUEUE_URL: messageQueue.queueUrl,
TELEGRAM_BOT_TOKEN_SECRET_ARN: telegramBotTokenSecretArn,
TELEGRAM_BOT_TOKEN_SSM_PARAM: telegramBotTokenParamName,
TELEGRAM_WEBHOOK_SECRET: '', // set via SSM or direct env after deploy
ATTACHMENTS_BUCKET_NAME: workspaceBucket.bucketName,
},
});
messageQueue.grantSendMessages(tgIngestFn);
botTokenSecret.grantRead(tgIngestFn);
tgIngestFn.addToRolePolicy(ssmReadPolicy);
workspaceBucket.grantWrite(tgIngestFn);
// ── Lambda: agent-runner ───────────────────────────────────────────────
const agentRunnerFn = new lambda.Function(this, 'AgentRunner', {
@@ -112,8 +123,8 @@ export class AgentClawStack extends cdk.Stack {
environment: {
SESSION_TABLE_NAME: sessionTable.tableName,
WORKSPACE_BUCKET_NAME: workspaceBucket.bucketName,
TELEGRAM_BOT_TOKEN_SECRET_ARN: telegramBotTokenSecretArn,
BRAVE_API_KEY_SECRET_ARN: braveApiKeySecretArn,
TELEGRAM_BOT_TOKEN_SSM_PARAM: telegramBotTokenParamName,
BRAVE_API_KEY_SSM_PARAM: braveApiKeyParamName,
RUNTIME_1_ARN: runtime1Arn ?? 'PLACEHOLDER_SET_AFTER_RUNTIME_DEPLOY',
AWS_REGION_NAME: 'us-east-1',
},
@@ -123,8 +134,7 @@ export class AgentClawStack extends cdk.Stack {
usersTable.grantReadWriteData(agentRunnerFn);
agentRunnerFn.addEnvironment('USERS_TABLE_NAME', usersTable.tableName);
workspaceBucket.grantRead(agentRunnerFn);
botTokenSecret.grantRead(agentRunnerFn);
braveApiKeySecret.grantRead(agentRunnerFn);
agentRunnerFn.addToRolePolicy(ssmReadPolicy);
messageQueue.grantConsumeMessages(agentRunnerFn);
// AgentCore invoke permission
@@ -170,8 +180,7 @@ export class AgentClawStack extends cdk.Stack {
resources: ['*'],
}));
workspaceBucket.grantRead(runtime1Role);
botTokenSecret.grantRead(runtime1Role);
braveApiKeySecret.grantRead(runtime1Role);
runtime1Role.addToPolicy(ssmReadPolicy);
usersTable.grantReadWriteData(runtime1Role);
// Google secret grants added after workspace_mcp section below
runtime1Role.addToPolicy(new iam.PolicyStatement({
@@ -190,15 +199,12 @@ export class AgentClawStack extends cdk.Stack {
// and fed back as context param runtime1Arn.
// ── Google Workspace MCP ──────────────────────────────────────────────
const googleOAuthClientSecret = secretsmanager.Secret.fromSecretCompleteArn(
this, 'GoogleOAuthClient', 'arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-subXHl'
);
// workspace-mcp Lambda execution role (import existing — created during initial setup)
const _workspaceMcpRole = iam.Role.fromRoleName(
this, 'WorkspaceMcpRole', 'agent-claw-workspace-mcp-role'
);
googleOAuthClientSecret.grantRead(_workspaceMcpRole);
_workspaceMcpRole.addToPrincipalPolicy?.(ssmReadPolicy);
// Grant workspace-mcp role read access to all per-user Google credential secrets
(_workspaceMcpRole as iam.Role).addToPrincipalPolicy?.(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsRead',
@@ -223,8 +229,7 @@ export class AgentClawStack extends cdk.Stack {
conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM' } },
}));
// Grant AgentCore execution role read access to Google OAuth client + per-user credentials
googleOAuthClientSecret.grantRead(runtime1Role);
// Grant AgentCore execution role read access to per-user Google credentials (stays in Secrets Manager)
runtime1Role.addToPolicy(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsReadRuntime',
actions: ['secretsmanager:GetSecretValue'],
@@ -248,22 +253,15 @@ export class AgentClawStack extends cdk.Stack {
timeout: cdk.Duration.seconds(30),
memorySize: 128,
environment: {
GOOGLE_OAUTH_CLIENT_SECRET_ARN: googleOAuthClientSecret.secretArn,
GOOGLE_OAUTH_CLIENT_SSM_PARAM: googleOAuthClientParamName,
USERS_TABLE_NAME: usersTable.tableName,
TELEGRAM_BOT_TOKEN_SECRET_ARN: telegramBotTokenSecretArn,
TELEGRAM_BOT_TOKEN_SSM_PARAM: telegramBotTokenParamName,
// OAUTH_REDIRECT_URI set after API GW URL is known — injected via addEnvironment below
OAUTH_REDIRECT_URI: 'PLACEHOLDER',
},
});
googleOAuthClientSecret.grantRead(oauthHandlerFn);
botTokenSecret.grantRead(oauthHandlerFn);
oauthHandlerFn.addToRolePolicy(ssmReadPolicy);
usersTable.grantReadWriteData(oauthHandlerFn);
// Explicit access to the OAuth client secret (fromSecretNameV2 wildcard may not resolve)
oauthHandlerFn.addToRolePolicy(new iam.PolicyStatement({
sid: 'GoogleOAuthClientSecretExact',
actions: ['secretsmanager:GetSecretValue'],
resources: ['arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-subXHl'],
}));
// Grant OAuth handler write access to per-user credential secrets
oauthHandlerFn.addToRolePolicy(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsWrite',
@@ -342,10 +340,10 @@ export class AgentClawStack extends cdk.Stack {
timeout: cdk.Duration.seconds(30),
memorySize: 128,
environment: {
TELEGRAM_BOT_TOKEN_SECRET_ARN: telegramBotTokenSecretArn,
TELEGRAM_BOT_TOKEN_SSM_PARAM: telegramBotTokenParamName,
},
});
botTokenSecret.grantRead(schedulerFn);
schedulerFn.addToRolePolicy(ssmReadPolicy);
// Allow EventBridge to invoke the scheduler Lambda
schedulerFn.addPermission('EventBridgeInvoke', {
principal: new iam.ServicePrincipal('events.amazonaws.com'),
@@ -373,6 +371,51 @@ export class AgentClawStack extends cdk.Stack {
resources: [schedulerFn.functionArn],
}));
// ── AgentCore Runtime 1 — extended permissions ───────────────────────
// Compute/build
runtime1Role.addToPolicy(new iam.PolicyStatement({
sid: 'ComputeBuild',
actions: ['codebuild:*', 'ecr:*', 'ecs:*', 'logs:*'],
resources: ['*'],
}));
// Broad read-only across account
runtime1Role.addToPolicy(new iam.PolicyStatement({
sid: 'BroadReadOnly',
actions: [
's3:List*', 's3:GetObject',
'lambda:List*', 'lambda:Get*',
'cloudformation:Describe*', 'cloudformation:List*',
'sqs:List*', 'sqs:GetQueueAttributes',
'ec2:Describe*',
'ssm:Describe*', 'ssm:List*',
'ce:GetCostAndUsage', 'ce:GetCostForecast',
],
resources: ['*'],
}));
// IAM self-modify — scoped to own role only
runtime1Role.addToPolicy(new iam.PolicyStatement({
sid: 'IamSelfModify',
actions: ['iam:PutRolePolicy', 'iam:AttachRolePolicy', 'iam:DetachRolePolicy', 'iam:DeleteRolePolicy'],
resources: [runtime1Role.roleArn],
}));
// IAM policy management
runtime1Role.addToPolicy(new iam.PolicyStatement({
sid: 'IamPolicyManagement',
actions: ['iam:CreatePolicy', 'iam:GetPolicy', 'iam:ListPolicies'],
resources: ['*'],
}));
// SSM read for AWS MCP URL
runtime1Role.addToPolicy(new iam.PolicyStatement({
sid: 'AwsMcpUrlSsmRead',
actions: ['ssm:GetParameter', 'ssm:GetParameters'],
resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/agent-claw/aws-mcp-url`],
}));
// ── Outputs ────────────────────────────────────────────────────────────
new cdk.CfnOutput(this, 'WorkspaceMcpFunctionUrl', {

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Create Bedrock Application Inference Profiles for agent-claw and update SSM.
Run after: aws sso login --profile ai1
Usage:
python3 scripts/create-inference-profiles.py [--dry-run]
Creates:
agent-claw-opus — main agent
agent-claw-sonnet — aws_agent + coding_agent
agent-claw-haiku — document_agent
Then updates SSM:
/agent-claw/model-id → agent-claw-opus ARN
/agent-claw/subagents → inline model_id fields replaced with profile ARNs
"""
import argparse
import json
import sys
import boto3
from botocore.exceptions import ClientError
PROFILE = 'ai1'
REGION = 'us-east-1'
BILLING_TAGS = [
{'key': 'project', 'value': 'agent-claw'},
{'key': 'env', 'value': 'prod'},
{'key': 'owner', 'value': 'daniel'},
]
# Map: profile name → cross-region model ID to copy from
PROFILES_TO_CREATE = {
'agent-claw-opus': 'us.anthropic.claude-opus-4-6-v1:0',
'agent-claw-sonnet': 'us.anthropic.claude-sonnet-4-6-20251001-v1:0',
'agent-claw-haiku': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
}
# SSM subagent model_id values → which profile ARN to swap in
SUBAGENT_MODEL_MAP = {
'us.anthropic.claude-sonnet-4-6': 'agent-claw-sonnet',
'us.anthropic.claude-sonnet-4-6-20251001-v1:0':'agent-claw-sonnet',
'us.anthropic.claude-haiku-4-5-20251001-v1:0': 'agent-claw-haiku',
}
def get_system_inference_profile_arn(bedrock, model_id: str) -> str:
"""Find the system inference profile ARN for a given cross-region model ID."""
paginator = bedrock.get_paginator('list_inference_profiles')
for page in paginator.paginate(typeEquals='SYSTEM_DEFINED'):
for p in page.get('inferenceProfileSummaries', []):
if p.get('inferenceProfileId', '') == model_id or \
any(m.get('modelArn', '').endswith(model_id) for m in p.get('models', [])):
return p['inferenceProfileArn']
# Fallback: construct ARN directly (works for cross-region profiles)
return f'arn:aws:bedrock:{REGION}::foundation-model/{model_id}'
def get_existing_profile(bedrock, name: str) -> dict | None:
"""Return existing application profile by name, or None."""
paginator = bedrock.get_paginator('list_inference_profiles')
for page in paginator.paginate(typeEquals='APPLICATION'):
for p in page.get('inferenceProfileSummaries', []):
if p.get('inferenceProfileName') == name:
return p
return None
def create_or_get_profile(bedrock, name: str, model_id: str, dry_run: bool) -> str:
"""Create application inference profile (idempotent). Returns ARN."""
existing = get_existing_profile(bedrock, name)
if existing:
arn = existing['inferenceProfileArn']
print(f' [exists] {name}{arn}')
return arn
source_arn = get_system_inference_profile_arn(bedrock, model_id)
print(f' [create] {name}')
print(f' source: {source_arn}')
if dry_run:
print(f' [dry-run] skipping create')
return f'arn:aws:bedrock:{REGION}:{{}account}}:application-inference-profile/{name}-DRY-RUN'
resp = bedrock.create_inference_profile(
inferenceProfileName=name,
description=f'agent-claw {name.split("-")[-1]} model with billing tags',
modelSource={'copyFrom': source_arn},
tags=BILLING_TAGS,
)
arn = resp['inferenceProfileArn']
print(f'{arn}')
return arn
def update_ssm(ssm, param: str, value: str, dry_run: bool):
print(f' [ssm] {param} = {value[:80]}...' if len(value) > 80 else f' [ssm] {param} = {value}')
if not dry_run:
ssm.put_parameter(Name=param, Value=value, Type='String', Overwrite=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
session = boto3.Session(profile_name=PROFILE, region_name=REGION)
bedrock = session.client('bedrock')
ssm = session.client('ssm')
print('=== Creating inference profiles ===')
arns = {}
for name, model_id in PROFILES_TO_CREATE.items():
arns[name] = create_or_get_profile(bedrock, name, model_id, args.dry_run)
print('\n=== Updating SSM ===')
# Main agent model
update_ssm(ssm, '/agent-claw/model-id', arns['agent-claw-opus'], args.dry_run)
# Subagents JSON — swap model_id fields
try:
resp = ssm.get_parameter(Name='/agent-claw/subagents')
defs = json.loads(resp['Parameter']['Value'])
except ClientError as e:
print(f' [error] Could not read /agent-claw/subagents: {e}')
sys.exit(1)
changed = False
for agent in defs:
mid = agent.get('model_id', '')
profile_name = SUBAGENT_MODEL_MAP.get(mid)
if profile_name:
new_arn = arns[profile_name]
print(f' [subagent] {agent["name"]}: {mid}{profile_name}')
agent['model_id'] = new_arn
changed = True
else:
print(f' [subagent] {agent["name"]}: {mid} (no mapping, left as-is)')
if changed:
update_ssm(ssm, '/agent-claw/subagents', json.dumps(defs, indent=2), args.dry_run)
print('\n=== Done ===')
print('Profiles created and SSM updated.')
print('Redeploy not required — agent reads model IDs from SSM at startup.')
if args.dry_run:
print('\n[dry-run mode — no AWS changes were made]')
if __name__ == '__main__':
main()

35
scripts/seed_factcloud.py Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Seed Daniel's factcloud MCP connection into DynamoDB."""
import boto3
ACTOR_ID = 'telegram:8537376738'
TABLE_NAME = 'agent-claw-users'
conn = {
'name': 'factcloud',
'url': 'https://factbase-cloud-gateway-2czetaoh3u.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp',
'auth_type': 'oauth2_m2m',
'client_id': '5fo2q4fb452j3aekd55g3190i4',
'client_secret': '1e0bqs8r4jk90sbeivh96mn893mgmv96h2olvcq7m3o5gjpjc56p',
'token_url': 'https://factbase-cloud.auth.us-east-1.amazoncognito.com/oauth2/token',
'scopes': 'factbase-cloud/read factbase-cloud/write',
'enabled': True,
}
session = boto3.Session(profile_name='ai1', region_name='us-east-1')
ddb = session.resource('dynamodb')
table = ddb.Table(TABLE_NAME)
# Get existing connections, upsert factcloud
resp = table.get_item(Key={'actor_id': ACTOR_ID})
services = resp.get('Item', {}).get('enrolled_services', {})
connections = services.get('mcp_connections', [])
connections = [c for c in connections if c['name'] != 'factcloud']
connections.append(conn)
table.update_item(
Key={'actor_id': ACTOR_ID},
UpdateExpression='SET enrolled_services.mcp_connections = :conns',
ExpressionAttributeValues={':conns': connections},
)
print(f'Seeded factcloud connection for {ACTOR_ID}')

View File

@@ -142,7 +142,10 @@ def handler(event, context):
channel = first.get('channel', 'telegram')
chat_id = first.get('chat_id', '')
message_thread_id = first.get('message_thread_id') # int or None
actor_id = f"{channel}:{chat_id}"
# Use sender's user ID for identity (not chat_id, which is the group ID in group chats)
from_info_early = first.get('messages', [{}])[0]
sender_id = from_info_early.get('from_id') or chat_id
actor_id = f"{channel}:{sender_id}"
# ── User registry ─────────────────────────────────────────────────────
from_info = first.get('messages', [{}])[0]
@@ -160,11 +163,11 @@ def handler(event, context):
user_profile['status'] = 'active'
prompt = f"[System: User just registered with name '{name}'. Welcome them warmly and ask how you can help.]"
else:
bot_token_secret_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', '')
bot_token_secret_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SSM_PARAM', '')
bot_token = ''
if bot_token_secret_arn:
sm = boto3.client('secretsmanager', region_name='us-east-1')
bot_token = sm.get_secret_value(SecretId=bot_token_secret_arn)['SecretString']
ssm = boto3.client('ssm', region_name='us-east-1')
bot_token = ssm.get_parameter(Name=bot_token_secret_arn, WithDecryption=True)['Parameter']['Value']
send_telegram_direct(chat_id, bot_token, "Hi! I don't recognize you yet. What's your name?", thread_id=message_thread_id)
return
# ── Get or create AgentCore session ──────────────────────────────────
@@ -181,6 +184,18 @@ def handler(event, context):
]
prompt = f"You have {len(records)} queued messages:\n" + "\n".join(lines)
# ── Attach file context if present ────────────────────────────────────
attachment = first.get('attachment')
if attachment:
file_name = attachment.get('file_name', 'unknown')
if 'inline_content' in attachment:
prompt += f"\n\n[Attached file: {file_name}]\n```\n{attachment['inline_content']}\n```"
elif 's3_key' in attachment:
s3_ref = f"s3://{attachment['s3_bucket']}/{attachment['s3_key']}"
prompt += f"\n\n[Attached file: {file_name} ({attachment.get('mime_type', '')}) — stored at {s3_ref}]"
elif 'error' in attachment:
prompt += f"\n\n[Attachment {file_name} could not be processed: {attachment['error']}]"
# ── Build payload for AgentCore Runtime 1 ────────────────────────────
payload: dict[str, Any] = {
'prompt': prompt,
@@ -197,7 +212,7 @@ def handler(event, context):
'type': channel,
'target_id': str(chat_id),
'message_thread_id': message_thread_id,
'bot_token_secret_arn': os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', ''),
'bot_token_secret_arn': os.environ.get('TELEGRAM_BOT_TOKEN_SSM_PARAM', ''),
},
}
@@ -217,11 +232,11 @@ def handler(event, context):
# Process streaming response: buffer text chunks and send to Telegram as paragraphs arrive
bot_token = ''
bot_token_secret_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', '')
if bot_token_secret_arn:
sm = boto3.client('secretsmanager', region_name='us-east-1')
bot_token_param = os.environ.get('TELEGRAM_BOT_TOKEN_SSM_PARAM', '')
if bot_token_param:
ssm = boto3.client('ssm', region_name='us-east-1')
try:
bot_token = sm.get_secret_value(SecretId=bot_token_secret_arn)['SecretString']
bot_token = ssm.get_parameter(Name=bot_token_param, WithDecryption=True)['Parameter']['Value']
except Exception as e:
print(f'[agent-runner] Failed to get bot token: {e}')

View File

@@ -44,9 +44,10 @@ def get_ddb():
def get_oauth_client() -> tuple[str, str]:
"""Return (client_id, client_secret) from Secrets Manager."""
arn = os.environ['GOOGLE_OAUTH_CLIENT_SECRET_ARN']
secret = json.loads(get_sm().get_secret_value(SecretId=arn)['SecretString'])
"""Return (client_id, client_secret) from SSM Parameter Store."""
param_name = os.environ['GOOGLE_OAUTH_CLIENT_SSM_PARAM']
ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
secret = json.loads(ssm.get_parameter(Name=param_name, WithDecryption=True)['Parameter']['Value'])
return secret['client_id'], secret['client_secret']
@@ -222,10 +223,11 @@ def handle_callback(params: dict) -> dict:
# Best-effort Telegram confirmation
try:
bot_token_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', '')
if bot_token_arn and actor_id.startswith('telegram:'):
bot_token_param = os.environ.get('TELEGRAM_BOT_TOKEN_SSM_PARAM', '')
if bot_token_param and actor_id.startswith('telegram:'):
chat_id = actor_id.split(':', 1)[1]
bot_token = get_sm().get_secret_value(SecretId=bot_token_arn)['SecretString']
ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
bot_token = ssm.get_parameter(Name=bot_token_param, WithDecryption=True)['Parameter']['Value']
tg_text = f'✅ Connected {user_email} as "{label}"'
tg_payload = json.dumps({'chat_id': chat_id, 'text': tg_text}).encode()
tg_req = urllib.request.Request(

View File

@@ -11,8 +11,8 @@ def handler(event, context):
rule_name = event['rule_name']
# Fetch bot token
sm = boto3.client('secretsmanager', region_name='us-east-1')
token = sm.get_secret_value(SecretId=os.environ['TELEGRAM_BOT_TOKEN_SECRET_ARN'])['SecretString']
ssm = boto3.client('ssm', region_name='us-east-1')
token = ssm.get_parameter(Name=os.environ['TELEGRAM_BOT_TOKEN_SSM_PARAM'], WithDecryption=True)['Parameter']['Value']
# Send Telegram message
payload = json.dumps({'chat_id': chat_id, 'text': message}).encode()

View File

@@ -9,16 +9,22 @@ import boto3
_bot_token: str | None = None
_token_lock = threading.Lock()
TEXT_EXTENSIONS = {'.txt', '.py', '.js', '.ts', '.json', '.md', '.csv', '.xml', '.html',
'.css', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.sh', '.bash',
'.sql', '.log', '.env', '.rs', '.go', '.java', '.c', '.h', '.cpp'}
MAX_INLINE_SIZE = 50 * 1024 # 50KB
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']
ssm = boto3.client('ssm')
_bot_token = ssm.get_parameter(
Name=os.environ['TELEGRAM_BOT_TOKEN_SSM_PARAM'],
WithDecryption=True
)['Parameter']['Value']
return _bot_token
@@ -40,6 +46,64 @@ def send_typing(chat_id: str, thread_id: int | None = None) -> None:
pass # typing is best-effort
def get_file_from_telegram(file_id: str) -> tuple[str, bytes]:
"""Call getFile then download. Returns (file_path, file_bytes)."""
token = get_bot_token()
# getFile
url = f'https://api.telegram.org/bot{token}/getFile'
data = json.dumps({'file_id': file_id}).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read()).get('result', {})
file_path = result.get('file_path', '')
# Download
download_url = f'https://api.telegram.org/file/bot{token}/{file_path}'
with urllib.request.urlopen(download_url, timeout=60) as resp:
file_bytes = resp.read()
return file_path, file_bytes
def extract_attachment(message: dict) -> dict | None:
"""Extract file attachment info from a Telegram message. Returns metadata dict or None."""
# Priority: document > photo > audio > video > voice > video_note
if 'document' in message:
doc = message['document']
return {'type': 'document', 'file_id': doc['file_id'],
'file_name': doc.get('file_name', 'document'), 'mime_type': doc.get('mime_type', ''),
'file_size': doc.get('file_size', 0)}
if 'photo' in message:
# Take largest photo (last in array)
photo = message['photo'][-1]
return {'type': 'photo', 'file_id': photo['file_id'],
'file_name': 'photo.jpg', 'mime_type': 'image/jpeg',
'file_size': photo.get('file_size', 0)}
if 'audio' in message:
audio = message['audio']
return {'type': 'audio', 'file_id': audio['file_id'],
'file_name': audio.get('file_name', 'audio.ogg'), 'mime_type': audio.get('mime_type', 'audio/ogg'),
'file_size': audio.get('file_size', 0)}
if 'video' in message:
video = message['video']
return {'type': 'video', 'file_id': video['file_id'],
'file_name': video.get('file_name', 'video.mp4'), 'mime_type': video.get('mime_type', 'video/mp4'),
'file_size': video.get('file_size', 0)}
if 'voice' in message:
voice = message['voice']
return {'type': 'voice', 'file_id': voice['file_id'],
'file_name': 'voice.ogg', 'mime_type': voice.get('mime_type', 'audio/ogg'),
'file_size': voice.get('file_size', 0)}
return None
def is_text_file(file_name: str, mime_type: str) -> bool:
"""Determine if a file should be inlined as text."""
ext = os.path.splitext(file_name)[1].lower()
if ext in TEXT_EXTENSIONS:
return True
if mime_type.startswith('text/'):
return True
return False
def handler(event, context):
# ── Validate Telegram webhook secret ──────────────────────────────────
@@ -68,14 +132,69 @@ def handler(event, context):
chat_id = str(message.get('chat', {}).get('id', ''))
message_thread_id = message.get('message_thread_id') # present for supergroup topics
text = message.get('text', '')
text = message.get('text', '') or message.get('caption', '')
from_user = message.get('from', {})
timestamp = message.get('date', 0)
print(f'[tg-ingest] chat_id={chat_id} text_len={len(text)} update_id={update_id}')
# ── Detect file attachment ────────────────────────────────────────────
attachment = extract_attachment(message)
attachment_meta = None
if not chat_id or not text:
print(f'[tg-ingest] Dropping: chat_id={chat_id!r} text={text!r}')
if attachment:
print(f'[tg-ingest] Attachment detected: type={attachment["type"]} name={attachment["file_name"]} size={attachment["file_size"]}')
try:
file_path, file_bytes = get_file_from_telegram(attachment['file_id'])
file_name = attachment['file_name']
mime_type = attachment['mime_type']
if is_text_file(file_name, mime_type) and len(file_bytes) <= MAX_INLINE_SIZE:
# Inline small text files
try:
text_content = file_bytes.decode('utf-8')
except UnicodeDecodeError:
text_content = file_bytes.decode('latin-1')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
'inline_content': text_content,
}
else:
# Store to S3
bucket = os.environ.get('ATTACHMENTS_BUCKET_NAME', '')
if bucket:
s3 = boto3.client('s3')
s3_key = f'attachments/{chat_id}/{update_id}/{file_name}'
s3.put_object(Bucket=bucket, Key=s3_key, Body=file_bytes,
ContentType=mime_type or 'application/octet-stream')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
's3_bucket': bucket,
's3_key': s3_key,
}
print(f'[tg-ingest] Stored to s3://{bucket}/{s3_key}')
else:
print(f'[tg-ingest] No ATTACHMENTS_BUCKET_NAME configured, skipping S3 upload')
attachment_meta = {
'type': attachment['type'],
'file_name': file_name,
'mime_type': mime_type,
'error': 'S3 bucket not configured',
}
except Exception as e:
print(f'[tg-ingest] Failed to process attachment: {e}')
attachment_meta = {
'type': attachment['type'],
'file_name': attachment['file_name'],
'error': str(e),
}
print(f'[tg-ingest] chat_id={chat_id} text_len={len(text)} attachment={bool(attachment_meta)} update_id={update_id}')
if not chat_id or (not text and not attachment_meta):
print(f'[tg-ingest] Dropping: chat_id={chat_id!r} text={text!r} attachment={attachment_meta}')
return {'statusCode': 200, 'body': 'ok'}
# ── Send typing action (non-blocking, background thread) ──────────────
@@ -85,23 +204,27 @@ def handler(event, context):
# ── Enqueue to SQS FIFO ───────────────────────────────────────────────
sqs = boto3.client('sqs')
msg_body: dict = {
'channel': 'telegram',
'chat_id': chat_id,
'message_thread_id': message_thread_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,
}
if attachment_meta:
msg_body['attachment'] = attachment_meta
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,
'message_thread_id': message_thread_id, # None for regular chats, int for topics
'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,
}),
MessageBody=json.dumps(msg_body),
)
return {'statusCode': 200, 'body': 'ok'}

View File

@@ -1,5 +1,5 @@
"""
Fetch Google OAuth credentials and client secrets from Secrets Manager.
Fetch Google OAuth credentials from SSM (client secret) and Secrets Manager (per-user tokens).
Called by bootstrap before starting workspace-mcp.
"""
import json
@@ -12,10 +12,11 @@ def main():
sm = boto3.client('secretsmanager', region_name=region)
# Fetch OAuth client credentials (client_id + client_secret)
client_secret_arn = os.environ.get('GOOGLE_OAUTH_CLIENT_SECRET_ARN')
if client_secret_arn:
client_secret_param = os.environ.get('GOOGLE_OAUTH_CLIENT_SSM_PARAM')
if client_secret_param:
try:
client_creds = json.loads(sm.get_secret_value(SecretId=client_secret_arn)['SecretString'])
ssm = boto3.client('ssm', region_name=region)
client_creds = json.loads(ssm.get_parameter(Name=client_secret_param, WithDecryption=True)['Parameter']['Value'])
os.environ['GOOGLE_OAUTH_CLIENT_ID'] = client_creds['client_id']
os.environ['GOOGLE_OAUTH_CLIENT_SECRET'] = client_creds['client_secret']
print('[fetch_credentials] OAuth client credentials loaded', file=sys.stderr)

View File

@@ -21,11 +21,11 @@ def _setup_shared_environment():
os.environ.setdefault('HOME', '/tmp')
os.environ.setdefault('GOOGLE_WORKSPACE_MCP_CREDENTIALS_DIR', '/tmp/workspace_mcp_credentials')
client_arn = os.environ.get('GOOGLE_OAUTH_CLIENT_SECRET_ARN', '')
if client_arn:
client_param = os.environ.get('GOOGLE_OAUTH_CLIENT_SSM_PARAM', '')
if client_param:
try:
sm = boto3.client('secretsmanager', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
client_creds = json.loads(sm.get_secret_value(SecretId=client_arn)['SecretString'])
ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
client_creds = json.loads(ssm.get_parameter(Name=client_param, WithDecryption=True)['Parameter']['Value'])
os.environ['GOOGLE_OAUTH_CLIENT_ID'] = client_creds['client_id']
os.environ['GOOGLE_OAUTH_CLIENT_SECRET'] = client_creds['client_secret']
except Exception as e:

View File

@@ -4,7 +4,9 @@ agent-claw Runtime 1 — main assistant agent.
Entrypoint for AgentCore CodeZip deployment.
"""
import os
import boto3
from strands import Agent, tool
from strands.models import BedrockModel
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from channels.telegram import TelegramAdapter
@@ -15,6 +17,10 @@ from tools import messaging
app = BedrockAgentCoreApp()
# Read model ID from SSM once at module load (cached for warm invocations)
_ssm = boto3.client("ssm", region_name=os.environ.get("AWS_REGION_NAME", "us-east-1"))
_model_id = _ssm.get_parameter(Name="/agent-claw/model-id")["Parameter"]["Value"]
# ── Tool definitions ──────────────────────────────────────────────────────
@@ -77,6 +83,7 @@ def main(payload: dict, context) -> dict:
# Create and run Strands agent
agent = Agent(
model=BedrockModel(model_id=_model_id),
system_prompt=system_prompt,
tools=[send_message, web_search, web_fetch, read_workspace_file, write_workspace_file],
)

3
workspace/TOOLS.md Normal file
View File

@@ -0,0 +1,3 @@
# Tools
This file documents available tools for the agent-claw runtime.