If you’re building multi-user AI agents with AWS Strands SDK, you’ll quickly hit a common problem: how do you bind tools to a specific user without passing user_id in every query?
The obvious solution - functools.partial - silently breaks everything. Here’s why, and the pattern that actually works.
The Problem
Let’s say you have a tool that fetches user data:
from strands import tool
@tool
def get_user_data(user_id: str, days: int = 30) -> dict:
"""Get user's tracked health data."""
return {"user_id": user_id, "entries": [...]}
You want to pre-bind user_id so the AI doesn’t need to know it:
from functools import partial
# This seems reasonable...
user_id = "550e8400-e29b-41d4-a716-446655440000"
scoped_tool = partial(get_user_data, user_id=user_id)
# Pass to agent
agent = Agent(model="...", tools=[scoped_tool])
This won’t work. The agent will have zero tools.
Why partial() Breaks Strands
The @tool decorator attaches metadata to your function that Strands uses for tool discovery, parameter extraction, and docstring parsing.
When you wrap with functools.partial(), you get a new partial object that doesn’t carry this metadata. Strands sees it as a random callable, not a tool.
from strands import tool
from functools import partial
@tool
def my_tool(user_id: str) -> dict:
return {}
print(hasattr(my_tool, '__tool_metadata__')) # True
scoped = partial(my_tool, user_id="123")
print(hasattr(scoped, '__tool_metadata__')) # False - GONE!
The Solution: Closure-Based Scoping
Create new functions that capture user_id in a closure, then apply @tool:
from strands import tool
from typing import Dict, List, Callable
def create_user_scoped_tools(user_id: str) -> List[Callable]:
"""Create user-scoped tools with proper @tool decorators."""
@tool
def get_tracking_data(days: int = 30) -> Dict:
"""Get user's tracked health data for the past N days."""
# user_id is captured in the closure - not a parameter!
return _fetch_user_data(user_id, days)
@tool
def log_symptom(symptom: str, severity: int = 5) -> Dict:
"""Log a symptom for the user. Severity is 1-10."""
return _save_symptom(user_id, symptom, severity)
return [get_tracking_data, log_symptom]
Each call creates fresh functions with:
- Proper
@tooldecorator applied user_idcaptured in the closure- Clean parameter signatures for the AI
Usage
from strands import Agent
def handle_user_request(user_id: str, message: str):
tools = create_user_scoped_tools(user_id)
agent = Agent(
model="anthropic.claude-3-5-sonnet-20241022-v2:0",
tools=tools
)
return agent(message)
Production Pattern with Database Transactions
Here’s a complete pattern with Django:
import logging
from typing import Dict, List, Callable
from datetime import timedelta
from django.db import connection, transaction
from django.utils import timezone
from strands import tool
logger = logging.getLogger(__name__)
def create_user_scoped_tools(user_id: str) -> List[Callable]:
@tool
def get_tracking_data(days: int = 30) -> Dict:
"""Get user's health tracking data."""
try:
cutoff = timezone.now() - timedelta(days=days)
with connection.cursor() as cursor:
cursor.execute('''
SELECT name, tracked_at, severity
FROM health_measurements
WHERE user_id = %s::uuid AND tracked_at >= %s
ORDER BY tracked_at DESC
''', [user_id, cutoff])
entries = [
{"name": r[0], "date": r[1].isoformat(), "severity": r[2]}
for r in cursor.fetchall()
]
# Prevent AI hallucination with explicit no-data response
if not entries:
return {
"status": "no_data",
"has_data": False,
"instruction": "Tell user: 'No data logged yet. Want to track something?'"
}
return {"status": "success", "has_data": True, "entries": entries}
except Exception as e:
logger.error(f"get_tracking_data failed: {e}")
return {"status": "error", "message": str(e)}
@tool
def log_symptom(symptom_name: str, severity: int = 5) -> Dict:
"""Log a symptom. Severity is 1-10 scale."""
import uuid
try:
# CRITICAL: transaction.atomic() ensures data persists
with transaction.atomic():
with connection.cursor() as cursor:
cursor.execute(
"SELECT id, name FROM symptoms WHERE name ILIKE %s LIMIT 1",
[f'%{symptom_name}%']
)
item = cursor.fetchone()
if not item:
return {"status": "error", "message": f"Unknown: {symptom_name}"}
cursor.execute('''
INSERT INTO health_measurements
(id, symptom_id, user_id, severity, tracked_at)
VALUES (%s::uuid, %s::uuid, %s::uuid, %s, %s)
''', [str(uuid.uuid4()), item[0], user_id, severity, timezone.now()])
return {"status": "success", "logged": item[1], "severity": severity}
except Exception as e:
return {"status": "error", "message": str(e)}
return [get_tracking_data, log_symptom]
Key Patterns
Anti-Hallucination Responses
When tools return empty data, AI models sometimes invent data. Prevent this:
if not data:
return {
"status": "no_data",
"has_data": False,
"instruction": "Tell the user no data exists."
}
Transaction Safety
Always wrap database writes:
from django.db import transaction
with transaction.atomic():
cursor.execute("INSERT ...")
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Using functools.partial |
Agent has 0 tools | Use closure pattern |
Missing transaction.atomic() |
Data doesn’t persist | Wrap writes in transaction |
| Generic “no data” response | AI hallucinates data | Return explicit has_data: False |
| Not casting UUIDs in SQL | invalid input syntax |
Use %s::uuid in Postgres |
Conclusion
The functools.partial trap is easy to fall into - it’s the obvious Python solution. But Strands requires the @tool metadata that partial() strips away.
The closure pattern gives you:
- Proper tool recognition by Strands
- Clean parameter signatures for the AI
- User isolation without query-time user_id
AWS Strands is new enough that patterns are still emerging. Hope this saves you some debugging time.
Code available as a gist. MIT licensed.