multi-tenant phase 3: per-user Home Assistant + enrolled services

- tools/home_assistant.py: remove hardcoded URL/token; read from per-user
  config injected via set_ha_config() at invocation time; return helpful
  enrollment prompt when HA not configured
- main.py: inject HA config from user_profile.services at startup; add
  manage_service tool (enroll/remove/list) that persists to DynamoDB;
  show enrolled services in user context; add USERS_TABLE_NAME env var
- agent-runner/handler.py: pass services dict from DDB user record in
  user_profile payload; initialize services={} for new users
- cdk/lib/agent-claw-stack.ts: grant usersTable read/write to runtime1Role
  so manage_service tool can update user records
- agentclaw/agentcore/agentcore.json: add USERS_TABLE_NAME env var
This commit is contained in:
daniel
2026-05-07 09:10:39 -05:00
parent 4f551ce069
commit 92c87222e8
13 changed files with 369 additions and 54 deletions

View File

@@ -17,7 +17,8 @@
"networkMode": "PUBLIC", "networkMode": "PUBLIC",
"protocol": "HTTP", "protocol": "HTTP",
"environmentVariables": { "environmentVariables": {
"OAUTH_START_URL": "https://sptejrymri.execute-api.us-east-1.amazonaws.com/oauth/start" "OAUTH_START_URL": "https://sptejrymri.execute-api.us-east-1.amazonaws.com/oauth/start",
"USERS_TABLE_NAME": "agent-claw-users"
} }
} }
], ],

View File

@@ -13,7 +13,7 @@ from prompt_builder import build_system_prompt, invalidate_prompt
from tools import web as web_tools from tools import web as web_tools
from tools import workspace as ws_tools from tools import workspace as ws_tools
from tools import messaging from tools import messaging
from tools.home_assistant import home_assistant from tools.home_assistant import home_assistant, set_ha_config
from mcp.client.streamable_http import streamablehttp_client from mcp.client.streamable_http import streamablehttp_client
from strands.tools.mcp.mcp_client import MCPClient from strands.tools.mcp.mcp_client import MCPClient
import httpx import httpx
@@ -24,6 +24,7 @@ from urllib.parse import urlparse as _urlparse
WORKSPACE_MCP_URL = 'https://25hugrzw4uwtueeg77jsmft6lq0wunmd.lambda-url.us-east-1.on.aws/mcp' WORKSPACE_MCP_URL = 'https://25hugrzw4uwtueeg77jsmft6lq0wunmd.lambda-url.us-east-1.on.aws/mcp'
OAUTH_START_URL = os.environ.get('OAUTH_START_URL', '') OAUTH_START_URL = os.environ.get('OAUTH_START_URL', '')
USERS_TABLE_NAME = os.environ.get('USERS_TABLE_NAME', 'agent-claw-users')
class _SigV4HttpxAuth(httpx.Auth): class _SigV4HttpxAuth(httpx.Auth):
@@ -102,7 +103,6 @@ def connect_google_account() -> str:
or when Google tools fail due to missing credentials.""" or when Google tools fail due to missing credentials."""
if not OAUTH_START_URL: if not OAUTH_START_URL:
return 'Google OAuth is not configured. Set OAUTH_START_URL environment variable.' return 'Google OAuth is not configured. Set OAUTH_START_URL environment variable.'
# actor_id is injected into the tool's closure via _current_actor_id module-level var
actor_id = _current_actor_id actor_id = _current_actor_id
if not actor_id: if not actor_id:
return 'Cannot determine actor_id for OAuth flow.' return 'Cannot determine actor_id for OAuth flow.'
@@ -110,6 +110,74 @@ def connect_google_account() -> str:
return f'Please open this URL to connect your Google account:\n{url}\n\nAfter authorizing, Google Workspace tools (Gmail, Calendar, Drive) will be available.' return f'Please open this URL to connect your Google account:\n{url}\n\nAfter authorizing, Google Workspace tools (Gmail, Calendar, Drive) will be available.'
@tool
def manage_service(action: str, service: str, config: dict | None = None) -> str:
"""Enroll, update, remove, or list external services for your account.
Actions:
- "enroll": Add or update a service (requires service name and config dict).
- "remove": Remove a service by name.
- "list": List all enrolled services (shows service names, not secrets).
Supported services:
- "home_assistant": config = {"url": "https://your-ha-url", "token": "long-lived-access-token"}
Examples:
- Enroll HA: manage_service(action="enroll", service="home_assistant",
config={"url": "https://ha.example.com", "token": "eyJ..."})
- Remove HA: manage_service(action="remove", service="home_assistant")
- List all: manage_service(action="list")
"""
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})
services = resp.get('Item', {}).get('services', {})
if not services:
return 'No services enrolled.'
lines = [f"- {svc}: configured" for svc in services]
return 'Enrolled services:\n' + '\n'.join(lines)
elif action == 'enroll':
if not service:
return 'service name is required.'
if not config:
return 'config dict is required for enroll.'
# Validate known services
if service == 'home_assistant':
if 'url' not in config or 'token' not in config:
return 'home_assistant config requires "url" and "token" keys.'
# Update in-memory config immediately for this session
set_ha_config(config['url'], config['token'])
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET services = if_not_exists(services, :empty), services.#svc = :cfg',
ExpressionAttributeNames={'#svc': service},
ExpressionAttributeValues={':cfg': config, ':empty': {}},
)
return f'Service "{service}" enrolled successfully.'
elif action == 'remove':
if not service:
return 'service name is required.'
if service == 'home_assistant':
set_ha_config('', '')
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='REMOVE services.#svc',
ExpressionAttributeNames={'#svc': service},
)
return f'Service "{service}" removed.'
else:
return f'Unknown action: {action}. Use "enroll", "remove", or "list".'
# ── Entrypoint ──────────────────────────────────────────────────────────── # ── Entrypoint ────────────────────────────────────────────────────────────
# Module-level actor_id for tool closures (set per-invocation) # Module-level actor_id for tool closures (set per-invocation)
@@ -131,7 +199,6 @@ def main(payload: dict, context) -> dict:
bot_token_secret_arn=adapter_config.get('bot_token_secret_arn', ''), bot_token_secret_arn=adapter_config.get('bot_token_secret_arn', ''),
) )
else: else:
# Future channels: instantiate appropriate adapter
raise ValueError(f"Unsupported channel type: {channel_type}") raise ValueError(f"Unsupported channel type: {channel_type}")
messaging.set_adapter(adapter) messaging.set_adapter(adapter)
@@ -165,8 +232,14 @@ def main(payload: dict, context) -> dict:
region_name='us-east-1', region_name='us-east-1',
) )
# Build system prompt — base cached, user context injected per-invocation # Inject per-user service configs
user_profile = payload.get('user_profile', {}) user_profile = payload.get('user_profile', {})
services = user_profile.get('services', {})
ha_cfg = services.get('home_assistant', {})
set_ha_config(ha_cfg.get('url', ''), ha_cfg.get('token', ''))
# Build system prompt — base cached, user context injected per-invocation
user_context = '' user_context = ''
if user_profile: if user_profile:
name = user_profile.get('display_name', '') name = user_profile.get('display_name', '')
@@ -179,6 +252,9 @@ def main(payload: dict, context) -> dict:
user_context += f'\nGoogle account: {google_email}' user_context += f'\nGoogle account: {google_email}'
else: else:
user_context += '\nGoogle account: not connected (use connect_google_account tool to connect)' user_context += '\nGoogle account: not connected (use connect_google_account tool to connect)'
enrolled = list(services.keys())
if enrolled:
user_context += f'\nEnrolled services: {", ".join(enrolled)}'
system_prompt = build_system_prompt(user_context=user_context, actor_id=actor_id) system_prompt = build_system_prompt(user_context=user_context, actor_id=actor_id)
# Model: claude-sonnet-4-6 via cross-region inference # Model: claude-sonnet-4-6 via cross-region inference
@@ -188,7 +264,8 @@ def main(payload: dict, context) -> dict:
) )
base_tools = [send_message, web_search, web_fetch, read_workspace_file, write_workspace_file, base_tools = [send_message, web_search, web_fetch, read_workspace_file, write_workspace_file,
_code_interpreter.code_interpreter, home_assistant, connect_google_account] _code_interpreter.code_interpreter, home_assistant, connect_google_account,
manage_service]
def _run_agent(tools): def _run_agent(tools):
agent = Agent( agent = Agent(
@@ -205,7 +282,6 @@ def main(payload: dict, context) -> dict:
workspace_tools = [] workspace_tools = []
google_email = user_profile.get('google_email', '') google_email = user_profile.get('google_email', '')
if google_email: if google_email:
# Only attempt workspace-mcp if user has connected Google
try: try:
with workspace_mcp_client: with workspace_mcp_client:
workspace_tools = workspace_mcp_client.list_tools_sync() workspace_tools = workspace_mcp_client.list_tools_sync()
@@ -222,11 +298,8 @@ def main(payload: dict, context) -> dict:
# Flush buffered memory events # Flush buffered memory events
session_manager.close() session_manager.close()
# Deliver final response — only send if agent didn't already call send_message tool. # Deliver final response
# If the tool was used, the response is already delivered. The fallback handles
# cases where the agent responds directly without calling the tool.
if not messaging.was_sent() and result.message: if not messaging.was_sent() and result.message:
# Extract plain text from Strands result (avoid sending raw dict/JSON)
msg = result.message msg = result.message
if isinstance(msg, dict): if isinstance(msg, dict):
content = msg.get('content', {}) content = msg.get('content', {})

View File

@@ -1,22 +1,24 @@
"""Home Assistant tool — control and query HA entities via REST API.""" """Home Assistant tool — control and query HA entities via REST API (per-user config)."""
import json import json
import os
import urllib.request import urllib.request
import urllib.error import urllib.error
from strands import tool from strands import tool
HA_URL = "https://homeassistant.home.everyonce.com" # Per-invocation config — set by main.py before agent runs
# Token stored in workspace or env; fallback to hardcoded for AgentCore runtime _ha_url: str = ''
HA_TOKEN = os.environ.get( _ha_token: str = ''
"HA_TOKEN",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJlMDExN2YwNzhlM2Q0NjViODJhNjJiZWFiMzI1ZWU4MiIsImlhdCI6MTc3MTM1MjU0MiwiZXhwIjoyMDg2NzEyNTQyfQ.UySLD6JV4e_bdd1nQjdbZcimdCD6B3kBGDftcRz1H6Q"
) def set_ha_config(url: str, token: str) -> None:
global _ha_url, _ha_token
_ha_url = url
_ha_token = token
def _ha_request(method: str, path: str, body: dict | None = None) -> dict | list: def _ha_request(method: str, path: str, body: dict | None = None) -> dict | list:
url = f"{HA_URL}{path}" url = f"{_ha_url}{path}"
headers = { headers = {
"Authorization": f"Bearer {HA_TOKEN}", "Authorization": f"Bearer {_ha_token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
data = json.dumps(body).encode() if body else None data = json.dumps(body).encode() if body else None
@@ -39,7 +41,6 @@ def home_assistant(action: str, entity_id: str = "", domain: str = "", service:
- "get_state": Get the current state of a specific entity (requires entity_id). - "get_state": Get the current state of a specific entity (requires entity_id).
- "list_states": List all entity states (optionally filter by domain prefix like 'light', 'switch', 'climate', 'sensor'). - "list_states": List all entity states (optionally filter by domain prefix like 'light', 'switch', 'climate', 'sensor').
- "call_service": Call a HA service (requires domain, service, and optional service_data with entity_id). - "call_service": Call a HA service (requires domain, service, and optional service_data with entity_id).
- "get_history": Not yet implemented.
Common service examples: Common service examples:
- Turn light on: domain="light", service="turn_on", service_data={"entity_id": "light.living_room"} - Turn light on: domain="light", service="turn_on", service_data={"entity_id": "light.living_room"}
@@ -58,6 +59,12 @@ def home_assistant(action: str, entity_id: str = "", domain: str = "", service:
Returns: Returns:
JSON string with the result. JSON string with the result.
""" """
if not _ha_url or not _ha_token:
return ("Home Assistant is not configured for your account. "
"Use the manage_service tool to enroll it: "
"manage_service(action='enroll', service='home_assistant', "
"config={'url': 'https://your-ha-url', 'token': 'your-long-lived-token'})")
if action == "get_state": if action == "get_state":
if not entity_id: if not entity_id:
return "entity_id is required for get_state" return "entity_id is required for get_state"
@@ -69,11 +76,9 @@ def home_assistant(action: str, entity_id: str = "", domain: str = "", service:
elif action == "list_states": elif action == "list_states":
result = _ha_request("GET", "/api/states") result = _ha_request("GET", "/api/states")
if isinstance(result, list): if isinstance(result, list):
# Filter by domain prefix if entity_id used as filter
prefix = entity_id or domain prefix = entity_id or domain
if prefix: if prefix:
result = [s for s in result if s.get("entity_id", "").startswith(prefix)] result = [s for s in result if s.get("entity_id", "").startswith(prefix)]
# Return concise summary
lines = [f"{s['entity_id']}: {s['state']}" for s in result[:50]] lines = [f"{s['entity_id']}: {s['state']}" for s in result[:50]]
return "\n".join(lines) + (f"\n... ({len(result)} total)" if len(result) > 50 else "") return "\n".join(lines) + (f"\n... ({len(result)} total)" if len(result) > 50 else "")
return json.dumps(result) return json.dumps(result)

View File

@@ -16,16 +16,16 @@
} }
} }
}, },
"6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370": { "c0db2a060be885d61722dfe6fbd3967e1c956826682078f6338123bf0c797e5b": {
"displayName": "AgentRunner/Code", "displayName": "AgentRunner/Code",
"source": { "source": {
"path": "asset.6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370", "path": "asset.c0db2a060be885d61722dfe6fbd3967e1c956826682078f6338123bf0c797e5b",
"packaging": "zip" "packaging": "zip"
}, },
"destinations": { "destinations": {
"495395224548-us-east-1-ab491e35": { "495395224548-us-east-1-82b9a17b": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1", "bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370.zip", "objectKey": "c0db2a060be885d61722dfe6fbd3967e1c956826682078f6338123bf0c797e5b.zip",
"region": "us-east-1", "region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1" "assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
} }
@@ -46,16 +46,16 @@
} }
} }
}, },
"fdf1ff81e9e0ded898f1c1d03a2bb8bbe0bbf63689426c24072f179b49b527c6": { "a31aaa0bc9eab4fd6f17f10795fba05983dba0c88e83a263fe9fffe930da06b9": {
"displayName": "AgentClawStack Template", "displayName": "AgentClawStack Template",
"source": { "source": {
"path": "AgentClawStack.template.json", "path": "AgentClawStack.template.json",
"packaging": "file" "packaging": "file"
}, },
"destinations": { "destinations": {
"495395224548-us-east-1-9bba4277": { "495395224548-us-east-1-477d6bc7": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1", "bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "fdf1ff81e9e0ded898f1c1d03a2bb8bbe0bbf63689426c24072f179b49b527c6.json", "objectKey": "a31aaa0bc9eab4fd6f17f10795fba05983dba0c88e83a263fe9fffe930da06b9.json",
"region": "us-east-1", "region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1" "assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
} }

View File

@@ -32,7 +32,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:289:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:290:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -46,7 +46,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:293:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:294:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -60,7 +60,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:297:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:298:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -74,7 +74,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:302:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:303:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -88,7 +88,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:307:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:308:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -102,7 +102,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:312:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:313:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -116,7 +116,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:317:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:318:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -130,7 +130,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:322:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:323:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -144,7 +144,7 @@
{ {
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:327:5)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:328:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -290,7 +290,7 @@
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"...new Function2 in aws-cdk-lib...", "...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:235:28)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:236:28)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -414,7 +414,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...", ".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)", "Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...", "...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:263:13)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:264:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -431,7 +431,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...", ".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)", "Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...", "...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:263:13)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:264:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -448,7 +448,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...", ".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)", "Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...", "...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:270:13)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:271:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -465,7 +465,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...", ".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)", "Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...", "...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:270:13)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:271:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -495,7 +495,7 @@
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"...SecretBase.grantRead in aws-cdk-lib...", "...SecretBase.grantRead in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:198:29)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:199:29)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -510,7 +510,7 @@
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"...new Function2 in aws-cdk-lib...", "...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:235:28)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:236:28)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -574,7 +574,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...", ".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)", "Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...", "...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:263:13)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:264:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -591,7 +591,7 @@
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...", ".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)", "Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...", "...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:270:13)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:271:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]
@@ -606,7 +606,7 @@
"type": "aws:cdk:creationStack", "type": "aws:cdk:creationStack",
"data": [ "data": [
"...SecretBase.grantRead in aws-cdk-lib...", "...SecretBase.grantRead in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:249:29)", "new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:250:29)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)", "<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..." "...node internals, ts-node, ts-node, ts-node..."
] ]

View File

@@ -387,7 +387,7 @@
"Properties": { "Properties": {
"Code": { "Code": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1", "S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370.zip" "S3Key": "c0db2a060be885d61722dfe6fbd3967e1c956826682078f6338123bf0c797e5b.zip"
}, },
"Environment": { "Environment": {
"Variables": { "Variables": {
@@ -423,7 +423,7 @@
], ],
"Metadata": { "Metadata": {
"aws:cdk:path": "AgentClawStack/AgentRunner/Resource", "aws:cdk:path": "AgentClawStack/AgentRunner/Resource",
"aws:asset:path": "asset.6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370", "aws:asset:path": "asset.c0db2a060be885d61722dfe6fbd3967e1c956826682078f6338123bf0c797e5b",
"aws:asset:is-bundled": false, "aws:asset:is-bundled": false,
"aws:asset:property": "Code" "aws:asset:property": "Code"
} }
@@ -779,6 +779,44 @@
"Effect": "Allow", "Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/brave-api-key-uUSgzi" "Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/brave-api-key-uUSgzi"
}, },
{
"Action": [
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:GetItem",
"dynamodb:Scan",
"dynamodb:ConditionCheckItem",
"dynamodb:BatchWriteItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
],
"Effect": "Allow",
"Resource": [
{
"Fn::GetAtt": [
"UsersTable9725E9C8",
"Arn"
]
}
]
},
{
"Action": [
"dynamodb:GetRecords",
"dynamodb:GetShardIterator"
],
"Effect": "Allow",
"Resource": [
{
"Fn::GetAtt": [
"UsersTable9725E9C8",
"Arn"
]
}
]
},
{ {
"Action": [ "Action": [
"bedrock-agentcore:CreateEvent", "bedrock-agentcore:CreateEvent",

View File

@@ -0,0 +1,194 @@
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:
_agentcore = boto3.client('bedrock-agentcore', region_name='us-east-1')
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},
)
def send_telegram_direct(chat_id: str, token: str, text: str) -> None:
url = f'https://api.telegram.org/bot{token}/sendMessage'
data = json.dumps({'chat_id': chat_id, 'text': text}).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req, timeout=10)
def get_or_create_session(actor_id: str) -> str:
"""Look up active session for actor, or create a new one."""
table = get_ddb().Table(os.environ['SESSION_TABLE_NAME'])
response = table.get_item(Key={'actor_id': actor_id})
item = response.get('Item')
now = int(time.time())
ttl_8hr = now + (8 * 3600)
if item and item.get('ttl', 0) > now:
# Active session exists — extend TTL
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET #ttl = :ttl',
ExpressionAttributeNames={'#ttl': 'ttl'},
ExpressionAttributeValues={':ttl': ttl_8hr},
)
return item['session_id']
# Create new session
session_id = str(uuid.uuid4())
table.put_item(Item={
'actor_id': actor_id,
'session_id': session_id,
'created_at': str(now),
'ttl': ttl_8hr,
})
return session_id
def handler(event, context):
# ── Parse SQS records (FIFO — all from same actor) ───────────────────
records = []
for record in event.get('Records', []):
try:
records.append(json.loads(record['body']))
except (json.JSONDecodeError, KeyError):
continue
if not records:
return
first = records[0]
channel = first.get('channel', 'telegram')
chat_id = first.get('chat_id', '')
actor_id = f"{channel}:{chat_id}"
# ── 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?")
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)
# ── 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_email': user_profile.get('google_email', ''),
'allowed': user_profile.get('allowed', True),
'services': user_profile.get('services', {}),
},
'channel_adapter': {
'type': channel,
'target_id': str(chat_id),
'bot_token_secret_arn': os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', ''),
},
}
# ── Invoke AgentCore Runtime 1 ────────────────────────────────────────
runtime_arn = os.environ.get('RUNTIME_1_ARN', '')
if not runtime_arn or runtime_arn == 'PLACEHOLDER_SET_AFTER_RUNTIME_DEPLOY':
print(f"[agent-runner] RUNTIME_1_ARN not set — skipping AgentCore invoke")
print(f"[agent-runner] Would have sent: {json.dumps(payload)[:200]}")
return
client = get_agentcore()
response = client.invoke_agent_runtime(
agentRuntimeArn=runtime_arn,
runtimeSessionId=session_id,
payload=json.dumps(payload).encode(),
)
# Consume streaming response (agent delivers to Telegram via send_message tool)
for chunk in response.get('response', []):
pass # intentional no-op — agent handles delivery internally
print(f"[agent-runner] Completed session={session_id} actor={actor_id}")

View File

@@ -18,7 +18,7 @@
"validateOnSynth": false, "validateOnSynth": false,
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-deploy-role-495395224548-us-east-1", "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", "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/fdf1ff81e9e0ded898f1c1d03a2bb8bbe0bbf63689426c24072f179b49b527c6.json", "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-495395224548-us-east-1/a31aaa0bc9eab4fd6f17f10795fba05983dba0c88e83a263fe9fffe930da06b9.json",
"requiresBootstrapStackVersion": 6, "requiresBootstrapStackVersion": 6,
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version",
"additionalDependencies": [ "additionalDependencies": [

File diff suppressed because one or more lines are too long

View File

@@ -170,6 +170,7 @@ export class AgentClawStack extends cdk.Stack {
workspaceBucket.grantRead(runtime1Role); workspaceBucket.grantRead(runtime1Role);
botTokenSecret.grantRead(runtime1Role); botTokenSecret.grantRead(runtime1Role);
braveApiKeySecret.grantRead(runtime1Role); braveApiKeySecret.grantRead(runtime1Role);
usersTable.grantReadWriteData(runtime1Role);
// Google secret grants added after workspace_mcp section below // Google secret grants added after workspace_mcp section below
runtime1Role.addToPolicy(new iam.PolicyStatement({ runtime1Role.addToPolicy(new iam.PolicyStatement({
actions: [ actions: [

View File

@@ -42,6 +42,7 @@ def get_or_create_user(actor_id: str, from_info: dict) -> dict:
'telegram_username': from_info.get('from_username', ''), 'telegram_username': from_info.get('from_username', ''),
'created_at': str(now), 'created_at': str(now),
'status': 'pending', 'status': 'pending',
'services': {},
} }
table.put_item(Item=item) table.put_item(Item=item)
print(f'[agent-runner] Registered new user (pending): {actor_id}') print(f'[agent-runner] Registered new user (pending): {actor_id}')
@@ -163,6 +164,7 @@ def handler(event, context):
'telegram_username': user_profile.get('telegram_username', ''), 'telegram_username': user_profile.get('telegram_username', ''),
'google_email': user_profile.get('google_email', ''), 'google_email': user_profile.get('google_email', ''),
'allowed': user_profile.get('allowed', True), 'allowed': user_profile.get('allowed', True),
'services': user_profile.get('services', {}),
}, },
'channel_adapter': { 'channel_adapter': {
'type': channel, 'type': channel,