""" P08 · SRE Agent — LangGraph implementation ReAct pattern: Reason → Act → Observe → repeat until done. Human-in-the-loop: tools marked requires_approval=True need explicit user confirmation. """ import json import re from typing import Any from .tools import TOOLS, ToolResult # ── Agent state ─────────────────────────────────────────────────────────────── class AgentState: def __init__(self, query: str): self.query = query self.steps: list[dict] = [] # full trace self.pending_approval: dict | None = None # tool waiting for human self.final_answer: str = "" self.done: bool = False self.error: str = "" def add_step(self, step_type: str, content: Any): self.steps.append({"type": step_type, "content": content}) def to_trace(self) -> str: lines = [] for s in self.steps: if s["type"] == "thought": lines.append(f"🤔 **Thought:** {s['content']}") elif s["type"] == "tool_call": lines.append(f"🔧 **Tool:** `{s['content']['tool']}` → `{json.dumps(s['content']['args'])}`") elif s["type"] == "tool_result": r: ToolResult = s["content"] if r.success: lines.append(f"✅ **Result ({r.latency_ms}ms):** ```json\n{json.dumps(r.data, indent=2)}\n```") else: lines.append(f"❌ **Error:** {r.error}") elif s["type"] == "approval_needed": lines.append(f"⚠️ **Approval needed for:** `{s['content']}`") elif s["type"] == "approved": lines.append(f"✅ **Approved:** `{s['content']}`") elif s["type"] == "rejected": lines.append(f"🚫 **Rejected:** `{s['content']}`") return "\n\n".join(lines) # ── Prompt builder ──────────────────────────────────────────────────────────── def build_system_prompt() -> str: tool_descriptions = "\n".join( f"- {name}: {info['description']}" for name, info in TOOLS.items() ) return f"""You are an SRE on-call assistant. Answer questions about service health, SLOs, alerts, and runbooks using the available tools. Available tools: {tool_descriptions} To use a tool, respond with EXACTLY this format (nothing else on that line): TOOL: tool_name ARGS: {{"arg1": "value1", "arg2": "value2"}} To give a final answer, respond with: ANSWER: your complete answer here Rules: - Always use tools to get real data before answering - Use at most 5 tool calls per query - If a tool fails, try an alternative approach - Be concise and actionable in your final answer - Always include the service name and severity in your answer""" def build_user_prompt(state: AgentState) -> str: if not state.steps: return f"User query: {state.query}" history = [] for step in state.steps: if step["type"] == "thought": history.append(f"Thought: {step['content']}") elif step["type"] == "tool_call": tc = step["content"] history.append(f"Tool called: {tc['tool']} with {tc['args']}") elif step["type"] == "tool_result": r: ToolResult = step["content"] if r.success: history.append(f"Tool result: {json.dumps(r.data)}") else: history.append(f"Tool error: {r.error}") history_str = "\n".join(history) return f"User query: {state.query}\n\nHistory:\n{history_str}\n\nContinue:" # ── Tool call parser ────────────────────────────────────────────────────────── def parse_tool_call(text: str) -> dict | None: """Parse TOOL/ARGS block from model output.""" tool_match = re.search(r"TOOL:\s*(\w+)", text) args_match = re.search(r"ARGS:\s*(\{.*?\})", text, re.DOTALL) answer_match = re.search(r"ANSWER:\s*(.+)", text, re.DOTALL) if answer_match: return {"type": "answer", "content": answer_match.group(1).strip()} if tool_match: tool_name = tool_match.group(1).strip() args = {} if args_match: try: args = json.loads(args_match.group(1)) except json.JSONDecodeError: pass return {"type": "tool_call", "tool": tool_name, "args": args} return None # ── Execute tool ────────────────────────────────────────────────────────────── def execute_tool(tool_name: str, args: dict) -> ToolResult: if tool_name not in TOOLS: return ToolResult( tool=tool_name, success=False, data=None, error=f"Unknown tool '{tool_name}'. Available: {list(TOOLS.keys())}", ) fn = TOOLS[tool_name]["fn"] try: return fn(**args) except TypeError as e: return ToolResult( tool=tool_name, success=False, data=None, error=f"Invalid arguments for {tool_name}: {e}", ) except Exception as e: return ToolResult( tool=tool_name, success=False, data=None, error=f"Tool execution failed: {e}", ) # ── Main agent class ────────────────────────────────────────────────────────── class SREAgent: """ LangGraph-style ReAct agent with human-in-the-loop approval. Uses local transformers model — no external API calls. """ def __init__(self, pipe): """pipe: a transformers text-generation pipeline.""" self.pipe = pipe self.max_steps = 5 def _call_llm(self, state: AgentState) -> str: system = build_system_prompt() user = build_user_prompt(state) prompt = ( f"<|im_start|>system\n{system}<|im_end|>\n" f"<|im_start|>user\n{user}<|im_end|>\n" f"<|im_start|>assistant\n" ) output = self.pipe(prompt, return_full_text=False)[0]["generated_text"] # Stop at next turn marker return output.split("<|im_end|>")[0].strip() def run(self, query: str) -> AgentState: """ Run the agent synchronously. Tools requiring approval will pause and set state.pending_approval. Call run_with_approval() to continue after user approves. """ state = AgentState(query) step_count = 0 while not state.done and step_count < self.max_steps: step_count += 1 # Get LLM decision llm_output = self._call_llm(state) parsed = parse_tool_call(llm_output) if parsed is None: # No structured output — treat as final answer state.final_answer = llm_output state.done = True break if parsed["type"] == "answer": state.final_answer = parsed["content"] state.done = True break if parsed["type"] == "tool_call": tool_name = parsed["tool"] args = parsed["args"] state.add_step("tool_call", {"tool": tool_name, "args": args}) # Check if tool requires human approval tool_info = TOOLS.get(tool_name, {}) if tool_info.get("requires_approval", False): state.add_step("approval_needed", f"{tool_name}({args})") state.pending_approval = {"tool": tool_name, "args": args} break # Pause for human input # Execute tool result = execute_tool(tool_name, args) state.add_step("tool_result", result) if step_count >= self.max_steps and not state.done: state.final_answer = "Reached maximum steps. Based on gathered data, please review the tool results above." state.done = True return state def approve_and_continue(self, state: AgentState) -> AgentState: """Continue after human approves a pending tool call.""" if not state.pending_approval: return state tool_name = state.pending_approval["tool"] args = state.pending_approval["args"] state.add_step("approved", f"{tool_name}({args})") result = execute_tool(tool_name, args) state.add_step("tool_result", result) state.pending_approval = None # Continue the agent loop return self.run_continue(state) def reject_and_continue(self, state: AgentState) -> AgentState: """Continue after human rejects a pending tool call.""" if not state.pending_approval: return state tool_name = state.pending_approval["tool"] state.add_step("rejected", tool_name) state.add_step( "tool_result", ToolResult( tool=tool_name, success=False, data=None, error="Tool call rejected by user", ), ) state.pending_approval = None return self.run_continue(state) def run_continue(self, state: AgentState) -> AgentState: """Continue running from an existing state.""" step_count = len(state.steps) while not state.done and step_count < self.max_steps: step_count += 1 llm_output = self._call_llm(state) parsed = parse_tool_call(llm_output) if parsed is None or parsed["type"] == "answer": state.final_answer = ( parsed["content"] if parsed and parsed["type"] == "answer" else llm_output ) state.done = True break if parsed["type"] == "tool_call": tool_name = parsed["tool"] args = parsed["args"] state.add_step("tool_call", {"tool": tool_name, "args": args}) tool_info = TOOLS.get(tool_name, {}) if tool_info.get("requires_approval", False): state.add_step("approval_needed", f"{tool_name}({args})") state.pending_approval = {"tool": tool_name, "args": args} break result = execute_tool(tool_name, args) state.add_step("tool_result", result) if step_count >= self.max_steps and not state.done: state.final_answer = "Reached maximum steps. Review tool results above." state.done = True return state