# Agents, Context Engineering, Guardrails & Human-in-the-Loop ## Overview Agents combine language models with tools to create reasoning systems that iteratively work toward solutions. They follow a ReAct (Reasoning + Acting) loop: input, model decision, tool execution, observation, repeat until completion. The `create_agent` function provides a production-ready implementation using LangGraph's graph-based runtime. Context engineering -- supplying appropriate information and tools in suitable formats -- is the primary factor in agent reliability. Guardrails add safety checks at key execution points, while human-in-the-loop patterns enable human oversight of sensitive operations. ## Installation ```bash pip install langchain langgraph ``` ## Creating Agents ### Basic Agent ```python from langchain.agents import create_agent from langchain.tools import tool @tool def search(query: str) -> str: """Search for information.""" return f"Results for: {query}" agent = create_agent("openai:gpt-5", tools=[search]) ``` ### With Model Instance ```python from langchain.chat_models import ChatOpenAI model = ChatOpenAI(model="gpt-5", temperature=0.1, max_tokens=1000) agent = create_agent(model, tools=[search]) ``` ### System Prompts ```python agent = create_agent(model, tools, system_prompt="You are a helpful assistant. Be concise and accurate.", name="research_assistant" # use snake_case ) ``` ### Dynamic System Prompts ```python @dynamic_prompt def state_aware_prompt(request: ModelRequest) -> str: message_count = len(request.messages) base = "You are a helpful assistant." if message_count > 10: base += "\nThis is a long conversation - be extra concise." return base ``` ## Invocation ### Basic ```python result = agent.invoke({ "messages": [{"role": "user", "content": "What's the weather in San Francisco?"}] }) ``` ### Streaming ```python for chunk in agent.stream({"messages": [...]}, stream_mode="values"): latest_message = chunk["messages"][-1] ``` ## Tools ### Static Tools Defined upfront and always available: ```python @tool(parse_docstring=True) def search_orders(user_id: str, status: str, limit: int = 10) -> str: """Search orders by status. Use when checking order history or status. Args: user_id: User identifier status: 'pending', 'shipped', or 'delivered' limit: Maximum results (default 10) """ ``` ### Dynamic Tools Filtered based on state, permissions, or feature flags at runtime: ```python @wrap_model_call def state_based_tools(request: ModelRequest, handler): if not request.state.get("authenticated"): tools = [t for t in request.tools if t.name.startswith("public_")] request = request.override(tools=tools) return handler(request) ``` ## Structured Output ### ToolStrategy (any tool-calling model) ```python response_format = ToolStrategy(ContactInfo) ``` ### ProviderStrategy (native model support) ```python response_format = ProviderStrategy(ContactInfo) ``` ### Schema Definition ```python from pydantic import BaseModel, Field class CustomerSupportTicket(BaseModel): category: str = Field(description="'billing', 'technical', 'account', or 'product'") priority: str = Field(description="'low', 'medium', 'high', or 'critical'") summary: str = Field(description="One-sentence issue summary") customer_sentiment: str = Field(description="'frustrated', 'neutral', or 'satisfied'") ``` ## Custom State ```python from langchain.agents import AgentState class CustomState(AgentState): user_preferences: dict agent = create_agent(model, tools, state_schema=CustomState) ``` ## Dynamic Model Selection ```python @wrap_model_call def dynamic_model_selection(request, handler): if len(request.state["messages"]) > 10: model = advanced_model else: model = basic_model return handler(request.override(model=model)) ``` ## Middleware & Error Handling ### Error Handling ```python @wrap_tool_call def handle_tool_errors(request, handler): try: return handler(request) except Exception as e: return ToolMessage(content=f"Tool error: {str(e)}", tool_call_id=request.tool_call["id"]) ``` ### Message Injection (Transient) ```python @wrap_model_call def inject_context(request: ModelRequest, handler): messages = [*request.messages, {"role": "user", "content": context}] request = request.override(messages=messages) return handler(request) ``` --- ## Context Engineering Context engineering is "the number one job of AI Engineers." Agents fail primarily because the LLM lacks proper context, not due to model capability limitations. ### Context Control Framework | Category | Control Scope | Persistence | |----------|---------------|-------------| | Model Context | Instructions, messages, tools, model selection, response formatting | Transient (per-call) | | Tool Context | Tool access, data reads/writes, runtime state modifications | Persistent (state-saved) | | Life-cycle Context | Inter-step operations, summarization, guardrails, logging | Persistent (state-saved) | ### Three Memory Layers | Layer | Scope | Examples | |-------|-------|---------| | Runtime Context | Static configuration | User IDs, API keys, permissions, environment settings | | State | Conversation-scoped | Messages, files, authentication status, tool results | | Store | Cross-conversation | Preferences, insights, historical data | ### Tool Context -- Reading **State Access:** ```python @tool def check_auth(runtime: ToolRuntime) -> str: is_authenticated = runtime.state.get("authenticated", False) return "User authenticated" if is_authenticated else "Not authenticated" ``` **Store Access:** ```python @tool def get_preference(key: str, runtime: ToolRuntime[Context]) -> str: prefs = runtime.store.get(("preferences",), runtime.context.user_id) return prefs.value.get(key) if prefs else "Not found" ``` **Runtime Context Access:** ```python @tool def fetch_data(query: str, runtime: ToolRuntime[Context]) -> str: results = query_db( runtime.context.db_connection, query, runtime.context.api_key ) return f"Found {len(results)} results" ``` ### Tool Context -- Writing **State Updates:** ```python @tool def authenticate_user(password: str, runtime: ToolRuntime) -> Command: is_valid = password == "correct" return Command(update={"authenticated": is_valid}) ``` **Store Persistence:** ```python @tool def save_preference(key: str, value: str, runtime: ToolRuntime[Context]) -> str: prefs = runtime.store.get(("preferences",), runtime.context.user_id) data = prefs.value if prefs else {} data[key] = value runtime.store.put(("preferences",), runtime.context.user_id, data) return f"Saved {key} = {value}" ``` ### Summarization Middleware ```python agent = create_agent( model="gpt-4.1", tools=[...], middleware=[ SummarizationMiddleware( model="gpt-4.1-mini", trigger={"tokens": 4000}, keep={"messages": 20}, ), ], ) ``` --- ## Guardrails ### Built-in: PII Detection ```python from langchain.agents.middleware import PIIMiddleware agent = create_agent( model="gpt-4.1", tools=[customer_service_tool, email_tool], middleware=[ PIIMiddleware("email", strategy="redact", apply_to_input=True), PIIMiddleware("credit_card", strategy="mask", apply_to_input=True), PIIMiddleware("api_key", detector=r"sk-[a-zA-Z0-9]{32}", strategy="block", apply_to_input=True), ], ) ``` **PII Handling Strategies:** | Strategy | Behavior | |----------|----------| | `redact` | Replace with `[REDACTED_{PII_TYPE}]` | | `mask` | Partially obscure values | | `hash` | Replace with deterministic hash | | `block` | Raise exception when detected | **Configuration Parameters:** | Parameter | Description | Default | |-----------|-------------|---------| | `pii_type` | Type of PII to detect | Required | | `strategy` | How to handle detected PII | `"redact"` | | `detector` | Custom detector function or regex | Built-in | | `apply_to_input` | Check user messages | `True` | | `apply_to_output` | Check AI messages | `False` | | `apply_to_tool_results` | Check tool results | `False` | ### Custom: Before Agent Guardrail ```python from langchain.agents.middleware import AgentMiddleware, AgentState, hook_config from langgraph.runtime import Runtime class ContentFilterMiddleware(AgentMiddleware): def __init__(self, banned_keywords: list[str]): super().__init__() self.banned_keywords = [kw.lower() for kw in banned_keywords] @hook_config(can_jump_to=["end"]) def before_agent(self, state: AgentState, runtime: Runtime): if not state["messages"]: return None first_message = state["messages"][0] if first_message.type != "human": return None content = first_message.content.lower() for keyword in self.banned_keywords: if keyword in content: return { "messages": [{ "role": "assistant", "content": "Cannot process requests with inappropriate content." }], "jump_to": "end" } return None ``` ### Custom: After Agent Guardrail ```python from langchain.agents.middleware import AgentMiddleware, AgentState, hook_config from langchain.messages import AIMessage from langchain.chat_models import init_chat_model class SafetyGuardrailMiddleware(AgentMiddleware): def __init__(self): super().__init__() self.safety_model = init_chat_model("gpt-4.1-mini") @hook_config(can_jump_to=["end"]) def after_agent(self, state: AgentState, runtime: Runtime): if not state["messages"]: return None last_message = state["messages"][-1] if not isinstance(last_message, AIMessage): return None safety_prompt = f"Evaluate if safe: {last_message.content}" result = self.safety_model.invoke([{"role": "user", "content": safety_prompt}]) if "UNSAFE" in result.content: last_message.content = "Cannot provide that response." return None ``` ### Combining Multiple Guardrails ```python agent = create_agent( model="gpt-4.1", tools=[search_tool, send_email_tool], middleware=[ ContentFilterMiddleware(banned_keywords=["hack", "exploit"]), PIIMiddleware("email", strategy="redact", apply_to_input=True), PIIMiddleware("email", strategy="redact", apply_to_output=True), HumanInTheLoopMiddleware(interrupt_on={"send_email": True}), SafetyGuardrailMiddleware(), ], ) ``` --- ## Human-in-the-Loop (HITL) HITL middleware pauses execution when agent actions require human review, using LangGraph's persistence layer to save state and resume later. ### Configuration ```python from langchain.agents.middleware import HumanInTheLoopMiddleware from langgraph.checkpoint.memory import InMemorySaver agent = create_agent( model="gpt-4.1", tools=[search_tool, send_email_tool, delete_database_tool], middleware=[ HumanInTheLoopMiddleware( interrupt_on={ "send_email": True, "delete_database": True, "search": False, } ), ], checkpointer=InMemorySaver(), # Use AsyncPostgresSaver in production ) ``` The `interrupt_on` parameter accepts: - `True` -- interrupt with default settings - `False` -- auto-approve - `InterruptOnConfig` object with `allowed_decisions` and custom `description` ### Human Decision Types | Decision | Description | |----------|-------------| | `approve` | Execute the action without modifications | | `edit` | Modify tool arguments before execution | | `reject` | Decline with explanatory feedback | ### Responding to Interrupts ```python from langgraph.types import Command result = agent.invoke( Command(resume={"decisions": [ {"type": "approve"}, # or {"type": "edit", "edited_action": {"name": "send_email", "args": {...}}}, # or {"type": "reject", "message": "Do not send this email"}, ]}), config={"configurable": {"thread_id": "thread-1"}} ) ``` ### Execution Flow 1. Agent invokes the model 2. Middleware inspects tool calls against policy 3. Matching calls trigger an interrupt with `HITLRequest` 4. Graph pauses, awaiting human decisions 5. Decisions execute approved/edited calls or synthesize rejection feedback --- ## Best Practices - Start with static components; introduce dynamics only when justified - Add one context engineering feature per iteration - Track model calls, token consumption, and latency metrics - Use built-in middleware (`SummarizationMiddleware`, `PIIMiddleware`, `HumanInTheLoopMiddleware`) before building custom - Document which context gets passed and why - Remember transient (per-call) vs. persistent (state-saved) behavior distinctions - Use well-defined tool names, descriptions, and parameter specifications - Keep agent names in snake_case for cross-provider compatibility