Agentic_SRE / agents /reasoning_loop.py
LordofMonarchs's picture
Fix auto-registration of tasks, add offline DB fallback for local evaluation, and improve rate-limit out-of-credit failover
915c925
Raw
History Blame Contribute Delete
37.4 kB
"""
agents/reasoning_loop.py β€” ToRA-style agent reasoning loop (per episode).
Pattern: Reason β†’ Act β†’ Observe β†’ Revise
Provider-agnostic: uses OpenAI-compatible API via config.model_base_url /
config.model_api_key / config.model_name. Switching providers = env var change only.
Includes:
- Retry-with-exponential-backoff (Β§5B requirement β€” free/trial tiers are rate-limited)
- GLM-5.2 thinking mode via extra_body (on by default, configurable)
- Single-tool probe (probe_tool_calling) to verify format before full wiring
- Claude backend fallback when model_backend=claude
Per brief Β§5:
1. State goal (golden-signal targets) before first action
2. Query memory (lessons table) for similar past states β†’ inject into context
3. State rationale for chosen action BEFORE calling tool
4. On failed/rejected action: reflect, retry with revision
"""
from __future__ import annotations
import json
import os
import time
from typing import Any, Dict, List, Optional, Tuple
import structlog
from config import settings
from mcp.tools import MCPTools, ToolResult
from memory.retrieve import embed_state, format_lessons_for_context, retrieve_lessons
from memory.write import write_causal_edge, write_decision
log = structlog.get_logger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Tool definitions β€” OpenAI function-calling format
# (also used as source-of-truth for Claude's input_schema)
# ─────────────────────────────────────────────────────────────────────────────
OPENAI_TOOLS = [
{
"type": "function",
"function": {
"name": "diagnostic_query",
"description": "Query current golden signals (p99 latency, error rate, saturation) for a service.",
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"enum": ["auth", "api-gateway", "user-service", "payment-service"],
"description": "Target service name",
},
"metric": {
"type": "string",
"enum": ["p99_latency_ms", "error_rate_pct", "saturation_pct", "all"],
"description": "Metric to query, or 'all' for full golden signals",
},
},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "log_inspection",
"description": "Inspect recent logs and distributed traces for a service.",
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"enum": ["auth", "api-gateway", "user-service", "payment-service"],
},
"time_window_minutes": {
"type": "integer",
"minimum": 1,
"maximum": 60,
"description": "How many minutes of logs to inspect",
},
},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "remediation",
"description": (
"Apply a remediation action to a service. "
"Will be validated by the Quarantine gate before execution. "
"If rejected, you will receive the exact rejection reason β€” reflect on it and revise."
),
"parameters": {
"type": "object",
"properties": {
"action_type": {
"type": "string",
"enum": [
"restart_service", "scale_up", "rollback",
"increase_db_pool", "kill_slow_queries", "vacuum_analyze",
"circuit_breaker",
],
},
"target": {
"type": "string",
"enum": ["auth", "api-gateway", "user-service", "payment-service", "database"],
},
"params": {
"type": "object",
"description": "Action parameters. E.g. {'factor': 2.0} for scale_up.",
},
},
"required": ["action_type", "target"],
},
},
},
{
"type": "function",
"function": {
"name": "submit_resolution",
"description": "Submit your final resolution summary when the incident is resolved.",
"parameters": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "Detailed summary of what was wrong, what you did, and why.",
},
},
"required": ["summary"],
},
},
},
]
# Claude format derived from OpenAI definitions
CLAUDE_TOOLS = [
{
"name": t["function"]["name"],
"description": t["function"]["description"],
"input_schema": t["function"]["parameters"],
}
for t in OPENAI_TOOLS
]
# ─────────────────────────────────────────────────────────────────────────────
# Provider client helpers
# ─────────────────────────────────────────────────────────────────────────────
class ProviderPool:
"""Manages rotation across configured API keys/tiers on rate-limits (429) and quota exhaustion (402)."""
def __init__(self):
self.providers = []
self.current_idx = 0
self.provider_cooldowns: Dict[int, Dict[str, Any]] = {}
self._init_providers()
def _init_providers(self):
# 1. Primary from settings/env
if settings.model_api_key and settings.model_api_key.strip():
self.providers.append({
"base_url": settings.model_base_url.strip(),
"api_key": settings.model_api_key.strip(),
"model_name": settings.model_name.strip(),
"name": "Primary Configured"
})
# 2. Add all known backup tiers if present or configured in environment variables, ordered strictly by Tier priority
known_tiers = [
{"name": "ZenMux (Tier 1)", "base_url": "https://zenmux.ai/api/v1", "api_key": (settings.zenmux_api_key or "").strip(), "model_name": "z-ai/glm-5.2"},
{"name": "Z.ai Direct (Tier 2)", "base_url": "https://api.z.ai/v1", "api_key": (settings.zai_api_key or "").strip(), "model_name": "glm-5.2"},
{"name": "Zhipu Direct (Tier 3)", "base_url": "https://open.bigmodel.cn/api/paas/v4/", "api_key": (settings.zhipu_api_key or "").strip(), "model_name": "glm-5.2"},
{"name": "OpenRouter (Tier 4)", "base_url": "https://openrouter.ai/api/v1", "api_key": (settings.openrouter_api_key or "").strip(), "model_name": "z-ai/glm-5.2"},
{"name": "HuggingFace Router (Tier 0)", "base_url": "https://router.huggingface.co/v1", "api_key": (settings.hf_api_key or "").strip(), "model_name": "zai-org/GLM-5.2"},
]
seen_keys = {p["api_key"] for p in self.providers}
for t in known_tiers:
if t["api_key"] and t["api_key"] not in seen_keys:
self.providers.append(t)
seen_keys.add(t["api_key"])
def _get_cooldown_duration(self, reason: Optional[Union[int, str]]) -> float:
if reason == 402 or str(reason) == "402":
return 6 * 3600.0 # 6 hours for quota exhaustion
elif reason in [401, 403, 404] or str(reason) in ["401", "403", "404"]:
return 6 * 3600.0 # 6 hours for auth / forbidden / endpoint errors
elif reason == 429 or str(reason) == "429" or (isinstance(reason, int) and reason >= 500):
return 5 * 60.0 # 5 minutes for rate limit or 5xx server errors
else:
return 5 * 60.0 # 5 minutes default
def _clean_expired_cooldowns(self) -> None:
now = time.time()
expired = []
for idx, info in self.provider_cooldowns.items():
duration = self._get_cooldown_duration(info.get("reason"))
if now - info["failover_at"] >= duration:
expired.append(idx)
for idx in expired:
log.info(
"api.provider_pool_cooldown_expired",
provider_idx=idx,
provider_name=self.providers[idx]["name"],
reason=self.provider_cooldowns[idx].get("reason"),
)
del self.provider_cooldowns[idx]
def next_provider(self, reason: Optional[Union[int, str]] = None) -> bool:
if len(self.providers) <= 1:
log.warning("api.provider_failover_aborted", current_idx=self.current_idx, total_providers=len(self.providers), reason="No remaining providers to rotate to")
return False
# Record cooldown for the failing provider
self.provider_cooldowns[self.current_idx] = {
"failover_at": time.time(),
"reason": reason,
}
self._clean_expired_cooldowns()
# Find next candidate provider NOT inside its cooldown window
candidate = (self.current_idx + 1) % len(self.providers)
attempts = 0
while candidate in self.provider_cooldowns and attempts < len(self.providers) - 1:
candidate = (candidate + 1) % len(self.providers)
attempts += 1
if candidate in self.provider_cooldowns:
log.warning("api.provider_failover_aborted", current_idx=self.current_idx, total_providers=len(self.providers), reason="All backup providers are currently inside their cooldown windows")
return False
self.current_idx = candidate
p = self.providers[self.current_idx]
log.warning("api.provider_failover", current_idx=self.current_idx, total_providers=len(self.providers), switched_to=p["name"], base_url=p["base_url"], model=p["model_name"], failover_reason=reason)
return True
def get_active(self) -> Dict[str, Any]:
self._clean_expired_cooldowns()
if not self.providers:
return {"base_url": settings.model_base_url, "api_key": settings.model_api_key or "none", "model_name": settings.model_name, "name": "Default"}
# Periodic retry-from-top: check if an earlier tier than current_idx has expired/recovered from cooldown
for i in range(self.current_idx):
if i not in self.provider_cooldowns:
log.info("api.provider_pool_retry_earlier_tier", previous_idx=self.current_idx, switched_to_idx=i, provider_name=self.providers[i]["name"])
self.current_idx = i
break
# If current_idx itself is inside its cooldown window, advance to the first available non-cooldown tier
if self.current_idx in self.provider_cooldowns:
for i in range(len(self.providers)):
if i not in self.provider_cooldowns:
self.current_idx = i
break
return self.providers[self.current_idx]
def get_client(self):
from openai import OpenAI
active = self.get_active()
return OpenAI(api_key=active["api_key"], base_url=active["base_url"])
provider_pool = ProviderPool()
def _get_openai_client():
return provider_pool.get_client()
def _get_claude_client():
import anthropic
return anthropic.Anthropic(api_key=settings.anthropic_api_key)
# ─────────────────────────────────────────────────────────────────────────────
# Retry-with-exponential-backoff + Automatic Provider Failover (Β§5B)
# ─────────────────────────────────────────────────────────────────────────────
def _call_with_retry(fn, *args, **kwargs):
"""
Call fn(*args, **kwargs) with exponential backoff on rate-limit / transient errors.
Uses config.model_max_retries, model_retry_base_delay, model_retry_max_delay.
"""
import openai
max_retries = settings.model_max_retries
base_delay = settings.model_retry_base_delay
max_delay = settings.model_retry_max_delay
last_exc = None
for attempt in range(max_retries + 1):
try:
return fn(*args, **kwargs)
except openai.RateLimitError as exc:
last_exc = exc
if attempt == max_retries:
break
delay = min(base_delay * (2 ** attempt), max_delay)
log.warning(
"model.rate_limited",
attempt=attempt + 1,
max_retries=max_retries,
retry_in=delay,
model=settings.model_name,
)
time.sleep(delay)
except openai.APIStatusError as exc:
# Retry on 5xx server errors only
if exc.status_code and exc.status_code >= 500:
last_exc = exc
if attempt == max_retries:
break
delay = min(base_delay * (2 ** attempt), max_delay)
log.warning("model.server_error", status=exc.status_code, retry_in=delay)
time.sleep(delay)
else:
raise # 4xx (bad request, auth, etc.) β€” don't retry
except Exception:
raise # Non-API errors β€” don't retry
raise last_exc
def _sanitize_key_str(text: str, *keys: str) -> str:
"""Redact API keys from strings before logging or raising exceptions."""
if not text:
return text
res = str(text)
for k in keys:
if k and isinstance(k, str) and len(k.strip()) > 4:
res = res.replace(k.strip(), "[REDACTED_API_KEY]")
if settings.model_api_key and len(settings.model_api_key.strip()) > 4:
res = res.replace(settings.model_api_key.strip(), "[REDACTED_API_KEY]")
return res
def _execute_completion_with_failover(messages: List[Dict[str, Any]], tools: List[Dict[str, Any]] = OPENAI_TOOLS, tool_choice: str = "auto", max_tokens: int = 4096):
"""
Execute chat completion across providers in ProviderPool.
Automatically rotates to the next API key when hitting 402 (Depleted Credits), 401 (Auth Error), or exhausted 429 (Rate Limit).
"""
import openai
max_retries = settings.model_max_retries
base_delay = settings.model_retry_base_delay
max_delay = settings.model_retry_max_delay
last_exc = None
total_attempts = 0
max_total = max_retries * max(len(provider_pool.providers), 1) + 5
while total_attempts < max_total:
total_attempts += 1
active = provider_pool.get_active()
client = provider_pool.get_client()
model_name = active["model_name"]
base_url = active["base_url"]
active_key = active.get("api_key", "")
extra_body = {}
if settings.model_thinking_mode == "on":
if "openrouter.ai" in base_url:
extra_body = {"reasoning": {"enabled": True}}
else:
extra_body = {"thinking": {"mode": "on"}}
try:
return client.chat.completions.create(
model=model_name,
messages=messages,
tools=tools,
tool_choice=tool_choice,
max_tokens=max_tokens,
**({"extra_body": extra_body} if extra_body else {}),
)
except openai.RateLimitError as exc:
last_exc = exc
err_msg = str(exc).lower()
if any(k in err_msg for k in ["1113", "余钝不袳", "insufficient balance", "depleted", "no credit", "quota"]):
log.warning("model.provider_error", status=402, provider=active["name"], error=_sanitize_key_str(str(exc)[:150], active_key))
if not provider_pool.next_provider(reason=402):
raise RuntimeError(_sanitize_key_str(str(exc), active_key))
continue
is_zhipu = "zhipu" in active.get("name", "").lower()
rotate_threshold = 6 if is_zhipu else 2
delay = min(base_delay * (2 ** ((total_attempts - 1) % (6 if is_zhipu else max_retries))), 30.0 if is_zhipu else max_delay)
log.warning("model.rate_limited", provider=active["name"], attempt=total_attempts, retry_in=delay, rotate_threshold=rotate_threshold)
time.sleep(delay)
if len(provider_pool.providers) > 1 and (total_attempts % rotate_threshold == 0):
provider_pool.next_provider(reason=429)
except (openai.APIStatusError, openai.APIConnectionError, openai.APITimeoutError) as exc:
last_exc = exc
err_msg = str(exc).lower()
status_code = getattr(exc, "status_code", None)
safe_err = _sanitize_key_str(str(exc)[:150], active_key)
if status_code in [401, 402, 403, 404] or any(k in err_msg for k in ["1113", "余钝不袳", "insufficient balance", "depleted", "no credit", "quota"]) or (status_code == 400 and ("model" in err_msg or "not a valid" in err_msg or "endpoint" in err_msg)):
# Out of credits / auth failure / invalid model ID on this provider β€” rotate immediately!
log.warning("model.provider_error", status=status_code or 402, provider=active["name"], error=safe_err)
if not provider_pool.next_provider(reason=status_code or 402):
raise RuntimeError(_sanitize_key_str(str(exc), active_key))
continue
elif status_code == 429:
is_zhipu = "zhipu" in active.get("name", "").lower()
rotate_threshold = 6 if is_zhipu else 2
delay = min(base_delay * (2 ** ((total_attempts - 1) % (6 if is_zhipu else max_retries))), 30.0 if is_zhipu else max_delay)
log.warning("model.status_429", provider=active["name"], attempt=total_attempts, retry_in=delay, rotate_threshold=rotate_threshold)
time.sleep(delay)
if len(provider_pool.providers) > 1 and (total_attempts % rotate_threshold == 0):
provider_pool.next_provider(reason=429)
elif (status_code and status_code >= 500) or isinstance(exc, (openai.APIConnectionError, openai.APITimeoutError)):
delay = min(base_delay * (2 ** ((total_attempts - 1) % max_retries)), max_delay)
log.warning("model.server_or_connection_error", status=status_code or type(exc).__name__, provider=active["name"], retry_in=delay, error=safe_err)
time.sleep(delay)
if len(provider_pool.providers) > 1 and (total_attempts % 3 == 0):
provider_pool.next_provider(reason=status_code or type(exc).__name__)
else:
raise RuntimeError(_sanitize_key_str(str(exc), active_key))
except Exception as exc:
last_exc = exc
raise RuntimeError(_sanitize_key_str(str(exc), active_key))
raise RuntimeError(_sanitize_key_str(str(last_exc), active.get("api_key", "")))
# ─────────────────────────────────────────────────────────────────────────────
# Single-tool probe (Β§5B: verify format before wiring full tool set)
# ─────────────────────────────────────────────────────────────────────────────
def probe_tool_calling() -> dict:
"""
Send one test call with a single MCP tool exposed and inspect the raw response.
Run this BEFORE running full episodes to confirm GLM-5.2's function-calling
response format matches what the reasoning loop expects.
Returns a dict with:
- raw_response: the raw API response object (inspect manually)
- tool_calls_found: list of (name, args) extracted
- format_ok: True if the format matches expected OpenAI function-calling spec
- notes: any discrepancies found
"""
try:
response = _execute_completion_with_failover(
messages=[
{
"role": "user",
"content": (
"I need to check the latency of the auth service. "
"Please call the diagnostic_query tool with service='auth' and metric='all'."
),
}
],
tools=single_tool,
tool_choice="auto",
max_tokens=512,
)
except Exception as exc:
return {
"raw_response": None,
"tool_calls_found": [],
"format_ok": False,
"notes": f"API call failed: {exc}",
}
msg = response.choices[0].message
tool_calls_found = []
notes = []
format_ok = True
if msg.tool_calls:
for tc in msg.tool_calls:
try:
args = json.loads(tc.function.arguments)
tool_calls_found.append({"name": tc.function.name, "args": args, "id": tc.id})
except json.JSONDecodeError as e:
notes.append(f"JSON parse error on arguments: {e}")
format_ok = False
else:
notes.append("No tool_calls in response β€” model may have responded with text only.")
format_ok = False
if msg.content:
notes.append(f"Text response: {msg.content[:200]}")
# Verify expected fields exist
if tool_calls_found:
tc = tool_calls_found[0]
if tc["name"] != "diagnostic_query":
notes.append(f"Wrong tool called: {tc['name']}")
format_ok = False
if "service" not in tc["args"]:
notes.append("Expected 'service' arg not found in tool call args")
format_ok = False
result = {
"raw_response": response.model_dump() if hasattr(response, "model_dump") else str(response),
"model": settings.model_name,
"base_url": settings.model_base_url,
"tool_calls_found": tool_calls_found,
"format_ok": format_ok,
"finish_reason": response.choices[0].finish_reason,
"notes": notes,
}
return result
# ─────────────────────────────────────────────────────────────────────────────
# Reasoning Loop
# ─────────────────────────────────────────────────────────────────────────────
class ReasoningLoop:
"""
ToRA-style agent reasoning loop for a single episode.
Provider-agnostic: backend selected via config.model_backend.
"""
def __init__(
self,
tools: MCPTools,
telemetry,
fsm,
episode_id: str,
golden_targets: Dict[str, Any],
) -> None:
self._tools = tools
self._telemetry = telemetry
self._fsm = fsm
self._episode_id = episode_id
self._golden_targets = golden_targets
self._messages: List[Dict] = []
self._step_index = 0
self._prev_decision_id: Optional[str] = None
self._backend = settings.model_backend
async def run(self) -> None:
initial_obs = self._telemetry.full_observation()
system_prompt = self._build_system_prompt(initial_obs)
self._messages = [
{
"role": "user",
"content": (
"A new incident has been detected. "
"Please diagnose and resolve it. "
"Begin by stating your goal and the current golden-signal targets you need to restore, "
"then proceed to diagnose before taking any remediation action."
),
}
]
log.info(
"reasoning_loop.start",
episode_id=self._episode_id,
backend=self._backend,
model=settings.model_name,
)
while self._fsm.is_active and not self._tools.resolution_submitted:
if not self._fsm.step():
break
# ── Query memory before deciding ──────────────────────────────────
state_sig = self._build_state_signature()
lessons, no_match = await retrieve_lessons(
state_signature=state_sig,
task_id=self._fsm.ctx.task_id,
episode_id=self._episode_id,
step_index=self._step_index,
)
memory_context = format_lessons_for_context(lessons)
if self._step_index > 0:
current_state_str = json.dumps(self._telemetry.collect_metrics(), indent=2)
memory_note = (
f"\n\n{memory_context}\n\n" if memory_context
else "\nNo relevant past experience found for this situation.\n\n"
)
self._messages.append({
"role": "user",
"content": (
f"Current system state:\n{current_state_str}"
f"{memory_note}"
f"Please state your rationale for the next action before calling any tool."
),
})
# ── Call model (with retry) ────────────────────────────────────────
if self._backend == "claude":
tool_calls, assistant_content = self._call_claude(system_prompt)
else:
tool_calls, assistant_content = self._call_openai_compatible(system_prompt)
if assistant_content:
log.debug("reasoning_loop.model_text", text=assistant_content[:200])
if not tool_calls:
break # model responded with text only β€” end turn
# ── Process tool calls ────────────────────────────────────────────
tool_results = []
for tool_name, tool_input, call_id in tool_calls:
log.info(
"reasoning_loop.tool_call",
episode_id=self._episode_id,
step=self._step_index,
tool=tool_name,
input=tool_input,
)
result = self._dispatch_tool(tool_name, tool_input)
# Write decision to memory
embedding = embed_state(state_sig)
decision_id = await write_decision(
episode_id=self._episode_id,
step_index=self._step_index,
state_signature=state_sig,
state_embedding=embedding,
action_type=tool_name,
action_payload=tool_input,
result_stdout=json.dumps(result.data) if result.data else None,
result_stderr=result.error,
exit_code=0 if result.success else 1,
quarantine_flag=result.quarantine_blocked,
no_match_flag=no_match,
quarantine_reason=result.quarantine_reason,
)
if self._prev_decision_id is not None:
await write_causal_edge(
from_decision=self._prev_decision_id,
to_decision=decision_id,
relation_type="preceded",
)
self._prev_decision_id = decision_id
if result.quarantine_blocked:
result_content = (
f"ACTION BLOCKED by Quarantine Gate.\n"
f"Reason: {result.quarantine_reason}\n\n"
f"Reflect on why this was rejected and try a different approach."
)
elif result.success:
result_content = json.dumps(result.data or {"status": "success"})
else:
result_content = f"Error: {result.error}"
tool_results.append((call_id, tool_name, result_content))
self._step_index += 1
# Feed results back
if self._backend == "claude":
self._messages.append({
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": cid, "content": content}
for cid, _, content in tool_results
],
})
else:
for cid, tname, content in tool_results:
self._messages.append({"role": "tool", "tool_call_id": cid, "content": content})
if self._tools.resolution_submitted:
break
log.info("reasoning_loop.complete", episode_id=self._episode_id, steps=self._step_index)
# ── Backend calls ─────────────────────────────────────────────────────────
def _call_openai_compatible(self, system_prompt: str) -> Tuple[List, str]:
"""
Call OpenAI-compatible API (GLM, Groq, Z.ai, ZenMux, OpenRouter).
Passes MODEL_THINKING_MODE via extra_body for GLM-5.2.
Returns (tool_calls, text_content).
"""
messages = [{"role": "system", "content": system_prompt}] + self._messages
response = _execute_completion_with_failover(
messages=messages,
tools=OPENAI_TOOLS,
tool_choice="auto",
max_tokens=4096,
)
msg = response.choices[0].message
text_content = msg.content or ""
# Extract OpenRouter / GLM reasoning details if returned by provider
reasoning_details = None
if hasattr(msg, "reasoning_details") and msg.reasoning_details is not None:
reasoning_details = msg.reasoning_details
elif hasattr(msg, "model_extra") and isinstance(msg.model_extra, dict):
reasoning_details = msg.model_extra.get("reasoning_details") or msg.model_extra.get("reasoning")
elif hasattr(msg, "reasoning") and msg.reasoning is not None:
reasoning_details = msg.reasoning
tool_calls = []
if msg.tool_calls:
for tc in msg.tool_calls:
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
args = {}
tool_calls.append((tc.function.name, args, tc.id))
# Add to message history with tool_calls and reasoning_details (preserved for multi-turn)
assistant_msg: Dict = {"role": "assistant", "content": text_content}
if reasoning_details is not None:
assistant_msg["reasoning_details"] = reasoning_details
if msg.tool_calls:
assistant_msg["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in msg.tool_calls
]
self._messages.append(assistant_msg)
return tool_calls, text_content
def _call_claude(self, system_prompt: str) -> Tuple[List, str]:
"""Call Anthropic Claude. Returns (tool_calls, text_content)."""
import anthropic
client = _get_claude_client()
response = _call_with_retry(
client.messages.create,
model=settings.claude_model,
max_tokens=4096,
system=system_prompt,
tools=CLAUDE_TOOLS,
messages=self._messages,
)
self._messages.append({"role": "assistant", "content": response.content})
tool_calls = []
text_parts = []
for block in response.content:
if block.type == "tool_use":
tool_calls.append((block.name, block.input, block.id))
elif hasattr(block, "text"):
text_parts.append(block.text)
return tool_calls, " ".join(text_parts)
# ── Helpers ───────────────────────────────────────────────────────────────
def _build_system_prompt(self, initial_obs: dict) -> str:
targets_str = json.dumps(self._golden_targets, indent=2)
obs_str = json.dumps(initial_obs.get("metrics", {}), indent=2)
return f"""You are an expert SRE (Site Reliability Engineer) agent tasked with diagnosing and resolving an active incident.
## Your Goal
Restore all services to their golden-signal targets:
```json
{targets_str}
```
## Current State (at episode start)
```json
{obs_str}
```
## Instructions
1. **State your goal** explicitly at the start.
2. **Diagnose before remediating** β€” use diagnostic_query and log_inspection to understand root cause.
3. **State your rationale** before EVERY tool call. Format: "Rationale: [why]"
4. **If an action is blocked** by the Quarantine gate, read the rejection reason and try a different approach.
5. **Think causally** β€” for multi-service incidents, find root cause before fixing downstream symptoms.
6. **Submit resolution** only when signals are restored or options exhausted.
## Available Services: auth, api-gateway, user-service, payment-service
## Rules
- Do NOT attempt the same rejected action twice.
- Do NOT use shell commands or anything outside the provided tools.
"""
def _build_state_signature(self) -> str:
try:
metrics = self._telemetry.collect_metrics()
degraded = []
for svc, m in metrics.items():
issues = []
if m.get("p99_latency_ms", 0) > 500:
issues.append(f"latency={m['p99_latency_ms']:.0f}ms")
if m.get("error_rate_pct", 0) > 5.0:
issues.append(f"errors={m['error_rate_pct']:.1f}%")
if m.get("saturation_pct", 0) > 80.0:
issues.append(f"sat={m['saturation_pct']:.0f}%")
if issues:
degraded.append(f"{svc}:[{','.join(issues)}]")
return f"step={self._step_index} degraded={';'.join(degraded) or 'none'}"
except Exception:
return f"step={self._step_index}"
def _dispatch_tool(self, tool_name: str, tool_input: Dict[str, Any]) -> ToolResult:
try:
if tool_name == "diagnostic_query":
return self._tools.diagnostic_query(
service=tool_input["service"],
metric=tool_input.get("metric", "all"),
)
elif tool_name == "log_inspection":
return self._tools.log_inspection(
service=tool_input["service"],
time_window_minutes=tool_input.get("time_window_minutes", 5),
)
elif tool_name == "remediation":
return self._tools.remediation(
action_type=tool_input["action_type"],
target=tool_input["target"],
params=tool_input.get("params", {}),
)
elif tool_name == "submit_resolution":
return self._tools.submit_resolution(summary=tool_input["summary"])
else:
return ToolResult(tool=tool_name, success=False, error=f"Unknown tool: {tool_name}")
except Exception as exc:
log.exception("reasoning_loop.dispatch_error", tool=tool_name, error=str(exc))
return ToolResult(tool=tool_name, success=False, error=str(exc))