"""Context builder — assembles the message list for an AI agent run. Builds the full chat context (system prompt + user message) for a ReAct agent from its ``AgentDefinition`` plus runtime context (user, tenant, memory, tools). Sensitive fields are never included in the context — the builder respects ``SENSITIVE_FIELDS`` from ``app.core.sensitive_data``. Usage:: from app.ai.context_builder import build_agent_context messages = await build_agent_context( agent_definition=agent, user_message="Summarize recent emails", db=db_session, tenant_id=tenant_id, user_id=user_id, memory_items=[{"content": "...", "metadata": {...}}], ) """ from __future__ import annotations import logging import uuid from typing import TYPE_CHECKING, Any from app.core.sensitive_data import sanitize_dict if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) # Default ReAct instruction prefix appended to the agent's system prompt. _REACT_PREFIX = ( "You operate in a ReAct (Reasoning + Acting) loop. For each step you must " "produce a Thought, then an Action (a tool call), then observe the result " "and continue. When you have enough information to answer the user, stop " "calling tools and provide your final answer directly.\n\n" "Format:\n" "Thought: \n" "Action: \n" "Action Input: \n" "Observation: \n" "... (repeat as needed) ...\n" "Final Answer: \n" ) class ReActSystemPromptBuilder: """Builds the ReAct system prompt for an agent definition. Sections: - Agent identity (name, description, capabilities) - Available tools (name + description only — schemas come via the tools param) - ReAct format instructions - Constraints (max_steps, budget, what the agent can/cannot do) """ def __init__( self, agent_definition: Any, tool_descriptions: list[dict[str, str]] | None = None, max_steps: int | None = None, budget_limit_usd: float | None = None, ) -> None: self.agent_definition = agent_definition self.tool_descriptions = tool_descriptions or [] self.max_steps = max_steps self.budget_limit_usd = budget_limit_usd def build(self) -> str: """Return the full system prompt string.""" sections: list[str] = [] # 1. Agent identity sections.append(self._identity_section()) # 2. Available tools sections.append(self._tools_section()) # 3. ReAct format instructions sections.append(_REACT_PREFIX) # 4. Constraints sections.append(self._constraints_section()) # 5. Base system prompt from the agent definition base_prompt = getattr(self.agent_definition, "system_prompt", "") or "" if base_prompt: sections.append(base_prompt) return "\n\n".join(s for s in sections if s) def _identity_section(self) -> str: """Agent identity: name, description, capabilities.""" name = getattr(self.agent_definition, "name", "") or "AI Agent" description = getattr(self.agent_definition, "description", "") or "" capabilities = getattr(self.agent_definition, "capabilities", None) or [] lines = [f"You are {name}."] if description: lines.append(f"Description: {description}") if capabilities: caps = ", ".join(str(c) for c in capabilities) lines.append(f"Capabilities: {caps}") return "\n".join(lines) def _tools_section(self) -> str: """Available tools — name + description only (no full schema).""" if not self.tool_descriptions: return "You have no tools available. Answer from your own knowledge." lines = ["Available tools:"] for tool in self.tool_descriptions: name = tool.get("name", "") description = tool.get("description", "") if name: lines.append(f"- {name}: {description}") return "\n".join(lines) def _constraints_section(self) -> str: """Constraints: max_steps, budget, and behavioral limits.""" constraints: list[str] = [] max_steps = self.max_steps or getattr( self.agent_definition, "max_steps", None ) or 20 constraints.append(f"- Maximum {max_steps} reasoning steps per run.") budget = self.budget_limit_usd if budget is None: budget = getattr(self.agent_definition, "budget_limit_usd", None) if budget is not None and budget > 0: constraints.append(f"- Budget limit: ${float(budget):.2f} per run.") constraints.append( "- Only call tools that are listed as available. Do not invent tools." ) constraints.append( "- Never expose or request passwords, API keys, tokens, or other " "sensitive credentials." ) constraints.append( "- Respect tenant data boundaries. Do not access data outside the " "current tenant." ) return "Constraints:\n" + "\n".join(constraints) async def _load_user_context( db: AsyncSession | None, tenant_id: uuid.UUID, user_id: uuid.UUID, run_as_user_id: uuid.UUID | None, ) -> dict[str, Any]: """Load user + tenant context from the DB with graceful fallbacks.""" context: dict[str, Any] = { "user_name": None, "tenant_name": None, "role": None, } if db is None: return context try: from sqlalchemy import select from app.models.tenant import Tenant from app.models.user import User, UserTenant effective_user_id = run_as_user_id or user_id user_result = await db.execute( select(User).where(User.id == effective_user_id).limit(1) ) user = user_result.scalar_one_or_none() if user is not None: context["user_name"] = user.name or user.email tenant_result = await db.execute( select(Tenant).where(Tenant.id == tenant_id).limit(1) ) tenant = tenant_result.scalar_one_or_none() if tenant is not None: context["tenant_name"] = tenant.name role_result = await db.execute( select(UserTenant.role) .where(UserTenant.user_id == effective_user_id) .where(UserTenant.tenant_id == tenant_id) .limit(1) ) role = role_result.scalar_one_or_none() if role: context["role"] = role except Exception: logger.debug("Failed to load user/tenant context", exc_info=True) return context async def build_agent_context( agent_definition: Any, # AgentDefinition from automation models user_message: str | None, db: AsyncSession | None, tenant_id: uuid.UUID, user_id: uuid.UUID, run_as_user_id: uuid.UUID | None = None, memory_items: list[dict] | None = None, trace_id: str | None = None, ) -> list[dict[str, Any]]: """Build the full message list for an agent run. Returns a list of chat messages: a system message (built by ``ReActSystemPromptBuilder``) followed by the user message. Sensitive fields are redacted from any injected context. """ # 1. Resolve the tools the agent has access to (filtered by tool_ids). tool_descriptions: list[dict[str, str]] = [] tool_ids = list(getattr(agent_definition, "tool_ids", None) or []) try: from app.plugins.builtins.ai_assistant.contracts import get_tool_registry registry = get_tool_registry() if tool_ids: tools = registry.get_by_names(tool_ids) else: tools = registry.get_all() tool_descriptions = [ {"name": t.name, "description": t.description} for t in tools ] except Exception: logger.debug("Failed to load tool descriptions", exc_info=True) # 2. Build the system prompt. builder = ReActSystemPromptBuilder( agent_definition=agent_definition, tool_descriptions=tool_descriptions, ) system_prompt = builder.build() # 3. Load user/tenant context. user_ctx = await _load_user_context( db, tenant_id, user_id, run_as_user_id ) # 4. Assemble the context block (redacting sensitive fields). context_lines: list[str] = [] if user_ctx.get("user_name"): context_lines.append(f"Current user: {user_ctx['user_name']}") if user_ctx.get("tenant_name"): context_lines.append(f"Current tenant: {user_ctx['tenant_name']}") if user_ctx.get("role"): context_lines.append(f"Current user role: {user_ctx['role']}") if memory_items: context_lines.append("Relevant memory items:") for item in memory_items: content = item.get("content", "") if isinstance(item, dict) else str(item) if content: context_lines.append(f"- {content}") # 5. Build the final message list. messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt} ] if context_lines: context_block = "\n".join(context_lines) messages.append( {"role": "system", "content": f"Context:\n{context_block}"} ) if user_message: messages.append({"role": "user", "content": user_message}) return messages __all__ = ["ReActSystemPromptBuilder", "build_agent_context"]