This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
exec-aide/src/conversation/app.py

165 lines
6.2 KiB
Python
Raw Normal View History

import json
import logging
from decimal import Decimal
import boto3
class _DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
return int(o) if o == int(o) else float(o)
return super().default(o)
from shared.dynamo import get_conversation, save_conversation
from shared.secrets import get_slack_token
from shared.slack import update_message, post_message
from conversation.tools import TOOLS, dispatch_tool, set_tool_context
logger = logging.getLogger()
logger.setLevel(logging.INFO)
_bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL_ID = "us.anthropic.claude-sonnet-4-6"
MAX_TOOL_ITERATIONS = 10
SYSTEM_PROMPT = """\
You are Lauren, a personal executive assistant for Adam Moussa, President \
of Sea Haven Industries (a facilities maintenance company).
You have access to Adam's email inbox. Use the available tools to answer \
questions about emails, find information, and provide inbox summaries.
Be concise and direct. Adam is busy - give him the key information without \
unnecessary preamble. Use bullet points for lists of emails.
When referencing emails, always include the sender name and subject line. \
For time references, use relative terms (e.g., "2 hours ago", "yesterday").
Add DM fixes, digest stats, tasks/reminders, calendar, and CI/CD pipeline (#15) * Fix flat replies in DMs DM replies were threaded under Adam's message instead of appearing flat. Root cause: listener fell back to event["ts"] as thread_ts for new DMs, causing say() to post as a threaded reply. Removed the fallback so DMs always use flat messages with placeholder update. Closes #4 * Add synchronous digest trigger for inline stats Changed trigger_daily_digest from async (fire-and-forget) to synchronous invocation so Lauren can report summary stats inline while the full Block Kit digest arrives as a separate DM. Closes #1 * Add tasks and reminders - DynamoDB task CRUD with TASK#{ulid} prefix - 5 new tools: create_task, list_tasks, complete_task, delete_task, create_reminder - New exec-aide-reminder Lambda triggered by EventBridge Scheduler one-shots - CDK: reminder Lambda, scheduler IAM role, conversation Lambda permissions - Updated system prompt with task/reminder capabilities Closes #3 * Add Google Calendar integration - New src/shared/calendar.py: OAuth service builder, list_events, create_event, check_availability (reuses gmail-oauth secret) - 3 new tools: get_calendar_events, create_calendar_event, check_availability - Extended OAuth scopes to include calendar in gmail.py and token script - Updated system prompt with calendar capabilities Requires re-running scripts/get_gmail_token.py to grant the calendar scope and updating the exec-aide/gmail-oauth secret with the new refresh token. Closes #2 * Add CodeBuild CI/CD pipeline CodePipeline triggers on pushes to main, CodeBuild runs cdk deploy via buildspec. * Update README for tasks, reminders, calendar, and CI/CD pipeline
2026-05-05 10:45:14 -04:00
You can manage Adam's task list — create tasks, list them, mark them \
complete, or delete them. You can also set reminders that will send a Slack \
DM at a specified time. When Adam asks to be reminded of something, use \
create_reminder with the appropriate date/time (default timezone: \
America/New_York).
You have access to Adam's Google Calendar. You can view upcoming events, \
check availability, and create new events. Always confirm event details \
(time, attendees, title) with Adam before creating. Default timezone: \
America/New_York. Use ISO 8601 format for all times sent to calendar tools.
Speak naturally and casually, like a capable coworker - not a bot or AI. \
Keep responses conversational.
FORMATTING: You are posting in Slack, which uses mrkdwn (NOT standard Markdown). \
Use *single asterisks* for bold, _underscores_ for italic. \
Do NOT use **double asterisks** or __double underscores__ - they will not render. \
Bullet points with - or • are fine. Slack emoji codes like :warning: work.\
"""
CHANNEL_ADDENDUM = """\
IMPORTANT: You are currently in a Slack channel visible to others - NOT a \
private DM. NEVER share Adam's private information (emails, inbox contents, \
calendar, schedule, or any personal data) in this context. You may only \
discuss information that Adam has explicitly shared in this conversation. \
If someone asks about Adam's private information, politely decline: \
"I can only discuss that with Adam directly."
You CAN help with coordination tasks visible to the channel - scheduling \
meetings, answering general questions, and interacting with other people \
when Adam asks you to.\
"""
def lambda_handler(event, context):
user_message = event["user_message"]
thread_ts = event.get("thread_ts")
channel = event["channel"]
placeholder_ts = event.get("placeholder_ts")
is_dm = event.get("is_dm", True)
coordination = event.get("coordination")
try:
system = SYSTEM_PROMPT
if not is_dm:
system += CHANNEL_ADDENDUM
conv_key = thread_ts or channel
messages = get_conversation(conv_key)
messages.append({"role": "user", "content": [{"text": user_message}]})
set_tool_context(channel, thread_ts)
assistant_message = _run_conversation(messages, system)
final_text = _extract_text(assistant_message)
save_conversation(conv_key, messages)
token = get_slack_token()
if placeholder_ts:
update_message(channel, placeholder_ts, final_text, token)
elif coordination:
post_message(channel, final_text, token,
thread_ts=coordination["coord_thread_ts"])
elif thread_ts:
post_message(channel, final_text, token, thread_ts=thread_ts)
else:
post_message(channel, final_text, token)
logger.info("Conversation complete: channel=%s, thread=%s, turns=%d, is_dm=%s",
channel, thread_ts, len(messages), is_dm)
except Exception:
logger.exception("Conversation failed for thread %s", thread_ts)
try:
token = get_slack_token()
error_msg = "Sorry, I hit an error processing that. Try again in a moment."
if placeholder_ts:
update_message(channel, placeholder_ts, error_msg, token)
elif thread_ts:
post_message(channel, error_msg, token, thread_ts=thread_ts)
else:
post_message(channel, error_msg, token)
except Exception:
logger.exception("Failed to post error message")
def _run_conversation(messages, system):
assistant_message = None
for _ in range(MAX_TOOL_ITERATIONS):
response = _bedrock.converse(
modelId=MODEL_ID,
system=[{"text": system}],
messages=messages,
toolConfig={"tools": TOOLS},
inferenceConfig={"maxTokens": 4096},
)
assistant_message = response["output"]["message"]
messages.append(assistant_message)
if response["stopReason"] != "tool_use":
break
tool_results = []
for block in assistant_message["content"]:
tool_use = block.get("toolUse")
if tool_use:
logger.info("Tool call: %s(%s)", tool_use["name"], json.dumps(tool_use["input"]))
result = dispatch_tool(tool_use["name"], tool_use["input"])
tool_results.append({
"toolResult": {
"toolUseId": tool_use["toolUseId"],
"content": [{"text": json.dumps(result, cls=_DecimalEncoder)}],
}
})
messages.append({"role": "user", "content": tool_results})
return assistant_message
def _extract_text(message):
parts = []
for block in message.get("content", []):
if "text" in block:
parts.append(block["text"])
return "\n".join(parts) if parts else "I processed your request but have nothing to report."