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:
@@ -17,7 +17,8 @@
|
||||
"networkMode": "PUBLIC",
|
||||
"protocol": "HTTP",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -13,7 +13,7 @@ from prompt_builder import build_system_prompt, invalidate_prompt
|
||||
from tools import web as web_tools
|
||||
from tools import workspace as ws_tools
|
||||
from tools import messaging
|
||||
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 strands.tools.mcp.mcp_client import MCPClient
|
||||
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'
|
||||
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):
|
||||
@@ -102,7 +103,6 @@ def connect_google_account() -> str:
|
||||
or when Google tools fail due to missing credentials."""
|
||||
if not OAUTH_START_URL:
|
||||
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
|
||||
if not actor_id:
|
||||
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.'
|
||||
|
||||
|
||||
@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 ────────────────────────────────────────────────────────────
|
||||
|
||||
# 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', ''),
|
||||
)
|
||||
else:
|
||||
# Future channels: instantiate appropriate adapter
|
||||
raise ValueError(f"Unsupported channel type: {channel_type}")
|
||||
|
||||
messaging.set_adapter(adapter)
|
||||
@@ -165,8 +232,14 @@ def main(payload: dict, context) -> dict:
|
||||
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', {})
|
||||
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 = ''
|
||||
if user_profile:
|
||||
name = user_profile.get('display_name', '')
|
||||
@@ -179,6 +252,9 @@ def main(payload: dict, context) -> dict:
|
||||
user_context += f'\nGoogle account: {google_email}'
|
||||
else:
|
||||
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)
|
||||
|
||||
# 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,
|
||||
_code_interpreter.code_interpreter, home_assistant, connect_google_account]
|
||||
_code_interpreter.code_interpreter, home_assistant, connect_google_account,
|
||||
manage_service]
|
||||
|
||||
def _run_agent(tools):
|
||||
agent = Agent(
|
||||
@@ -205,7 +282,6 @@ def main(payload: dict, context) -> dict:
|
||||
workspace_tools = []
|
||||
google_email = user_profile.get('google_email', '')
|
||||
if google_email:
|
||||
# Only attempt workspace-mcp if user has connected Google
|
||||
try:
|
||||
with workspace_mcp_client:
|
||||
workspace_tools = workspace_mcp_client.list_tools_sync()
|
||||
@@ -222,11 +298,8 @@ def main(payload: dict, context) -> dict:
|
||||
# Flush buffered memory events
|
||||
session_manager.close()
|
||||
|
||||
# Deliver final response — only send if agent didn't already call send_message tool.
|
||||
# If the tool was used, the response is already delivered. The fallback handles
|
||||
# cases where the agent responds directly without calling the tool.
|
||||
# Deliver final response
|
||||
if not messaging.was_sent() and result.message:
|
||||
# Extract plain text from Strands result (avoid sending raw dict/JSON)
|
||||
msg = result.message
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get('content', {})
|
||||
|
||||
@@ -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 os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from strands import tool
|
||||
|
||||
HA_URL = "https://homeassistant.home.everyonce.com"
|
||||
# Token stored in workspace or env; fallback to hardcoded for AgentCore runtime
|
||||
HA_TOKEN = os.environ.get(
|
||||
"HA_TOKEN",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJlMDExN2YwNzhlM2Q0NjViODJhNjJiZWFiMzI1ZWU4MiIsImlhdCI6MTc3MTM1MjU0MiwiZXhwIjoyMDg2NzEyNTQyfQ.UySLD6JV4e_bdd1nQjdbZcimdCD6B3kBGDftcRz1H6Q"
|
||||
)
|
||||
# Per-invocation config — set by main.py before agent runs
|
||||
_ha_url: str = ''
|
||||
_ha_token: str = ''
|
||||
|
||||
|
||||
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:
|
||||
url = f"{HA_URL}{path}"
|
||||
url = f"{_ha_url}{path}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {HA_TOKEN}",
|
||||
"Authorization": f"Bearer {_ha_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
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).
|
||||
- "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).
|
||||
- "get_history": Not yet implemented.
|
||||
|
||||
Common service examples:
|
||||
- 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:
|
||||
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 not entity_id:
|
||||
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":
|
||||
result = _ha_request("GET", "/api/states")
|
||||
if isinstance(result, list):
|
||||
# Filter by domain prefix if entity_id used as filter
|
||||
prefix = entity_id or domain
|
||||
if 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]]
|
||||
return "\n".join(lines) + (f"\n... ({len(result)} total)" if len(result) > 50 else "")
|
||||
return json.dumps(result)
|
||||
|
||||
Reference in New Issue
Block a user