|
3 | 3 | Implements the multi-round LLM iteration with tool calling, |
4 | 4 | skill-context stripping, message truncation, and result building. |
5 | 5 | Extracted from grounding_agent.py (Epic 5.8). |
| 6 | +Instrumented with observability (Epic 6.1). |
6 | 7 | """ |
7 | 8 |
|
8 | 9 | from __future__ import annotations |
9 | 10 |
|
10 | 11 | import copy |
| 12 | +import hashlib |
| 13 | +import time |
11 | 14 | from typing import Any, Dict, List, Optional |
12 | 15 |
|
| 16 | +try: |
| 17 | + from openspace.observability.metrics import metrics as _metrics |
| 18 | + from openspace.observability.tracing import tracer as _tracer |
| 19 | +except ImportError: |
| 20 | + _metrics = None # type: ignore[assignment] |
| 21 | + _tracer = None # type: ignore[assignment] |
13 | 22 | from openspace.prompts import GroundingAgentPrompts |
14 | 23 | from openspace.utils.logging import Logger |
15 | 24 |
|
@@ -47,6 +56,10 @@ async def process(agent, context: Dict[str, Any]) -> Dict[str, Any]: |
47 | 56 |
|
48 | 57 | logger.info(f"Grounding Agent: Processing instruction at step {agent.step}") |
49 | 58 |
|
| 59 | + # ── Observability: agent name sanitized for Prometheus labels ───── |
| 60 | + _agent_label = _sanitize_label(agent._name) |
| 61 | + _exec_start = time.monotonic() |
| 62 | + |
50 | 63 | # Existing workspace files check |
51 | 64 | workspace_info = await agent._check_workspace_artifacts(context) |
52 | 65 | if workspace_info["has_files"]: |
@@ -96,6 +109,20 @@ async def process(agent, context: Dict[str, Any]) -> Dict[str, Any]: |
96 | 109 | tools=tools, |
97 | 110 | ) |
98 | 111 |
|
| 112 | + # ── Observability setup (isolated — must not kill execution) ────── |
| 113 | + _gauge_incremented = False |
| 114 | + try: |
| 115 | + _instr_hash = hashlib.sha256(instruction.encode()).hexdigest()[:12] |
| 116 | + _tracer.start_trace( |
| 117 | + "grounding.process", |
| 118 | + instruction_hash=_instr_hash, |
| 119 | + instruction_length=len(instruction), |
| 120 | + ) |
| 121 | + _metrics.execution_in_flight.labels(agent=_agent_label).inc() |
| 122 | + _gauge_incremented = True |
| 123 | + except Exception: |
| 124 | + logger.debug("Observability setup failed", exc_info=True) |
| 125 | + |
99 | 126 | try: |
100 | 127 | while current_iteration < max_iterations: |
101 | 128 | current_iteration += 1 |
@@ -274,14 +301,47 @@ async def process(agent, context: Dict[str, Any]) -> Dict[str, Any]: |
274 | 301 | f"Grounding Agent: Execution completed with status: " |
275 | 302 | f"{result.get('status')}" |
276 | 303 | ) |
| 304 | + |
| 305 | + # ── Observability: record success (isolated — must not discard result) |
| 306 | + try: |
| 307 | + _elapsed = time.monotonic() - _exec_start |
| 308 | + _metrics.execution_latency.labels(agent=_agent_label, status="success").observe(_elapsed) |
| 309 | + _metrics.execution_iterations.labels(agent=_agent_label).observe(current_iteration) |
| 310 | + _metrics.execution_total.labels(agent=_agent_label, status="success").inc() |
| 311 | + if _gauge_incremented: |
| 312 | + _metrics.execution_in_flight.labels(agent=_agent_label).dec() |
| 313 | + _gauge_incremented = False |
| 314 | + root_span = _tracer.current_span() |
| 315 | + if root_span: |
| 316 | + root_span.attributes["iterations"] = current_iteration |
| 317 | + root_span.attributes["status"] = result.get("status", "unknown") |
| 318 | + _tracer.finish_trace() |
| 319 | + except Exception: |
| 320 | + logger.debug("Observability recording failed", exc_info=True) |
| 321 | + |
277 | 322 | return result |
278 | 323 |
|
279 | 324 | except Exception as e: |
280 | 325 | logger.error(f"Grounding Agent: Execution failed: {e}") |
| 326 | + |
| 327 | + # ── Observability: record error (isolated — must not mask result) |
| 328 | + try: |
| 329 | + _elapsed = time.monotonic() - _exec_start |
| 330 | + _metrics.execution_latency.labels(agent=_agent_label, status="error").observe(_elapsed) |
| 331 | + _metrics.execution_total.labels(agent=_agent_label, status="error").inc() |
| 332 | + if _gauge_incremented: |
| 333 | + _metrics.execution_in_flight.labels(agent=_agent_label).dec() |
| 334 | + root_span = _tracer.current_span() |
| 335 | + if root_span: |
| 336 | + root_span.add_event("error", error_type=type(e).__name__) |
| 337 | + root_span.status = "error" |
| 338 | + _tracer.finish_trace() |
| 339 | + except Exception: |
| 340 | + logger.debug("Observability recording failed", exc_info=True) |
| 341 | + |
281 | 342 | result = { |
282 | | - "error": str(e), |
| 343 | + "error": type(e).__name__, |
283 | 344 | "status": "error", |
284 | | - "instruction": instruction, |
285 | 345 | "iteration": current_iteration, |
286 | 346 | } |
287 | 347 | agent.increment_step() |
@@ -323,3 +383,21 @@ def _build_retrieved_tools_list( |
323 | 383 |
|
324 | 384 | retrieved_tools_list.append(tool_info) |
325 | 385 | return retrieved_tools_list |
| 386 | + |
| 387 | + |
| 388 | +import re |
| 389 | + |
| 390 | +# ── Label sanitization ─────────────────────────────────────────────── |
| 391 | + |
| 392 | +# Prometheus label values must be bounded and safe. Strict allowlist |
| 393 | +# prevents cardinality bombs and scraper injection. |
| 394 | +_LABEL_MAX_LEN = 32 |
| 395 | +_LABEL_SAFE_RE = re.compile(r"[^a-zA-Z0-9_\-]") |
| 396 | + |
| 397 | + |
| 398 | +def _sanitize_label(value: str) -> str: |
| 399 | + """Sanitize a string for use as a Prometheus label value.""" |
| 400 | + if not value: |
| 401 | + return "unknown" |
| 402 | + clean = _LABEL_SAFE_RE.sub("_", value[:_LABEL_MAX_LEN]) |
| 403 | + return clean or "unknown" |
0 commit comments