feat(agent-chat): complete context guardrail event coverage

This commit is contained in:
2026-07-05 14:14:42 +03:00
parent 45e781fb74
commit 147d711657
13 changed files with 587 additions and 24 deletions

View File

@@ -52,7 +52,12 @@ from src.agent.context import set_user_jwt, set_user_role
from src.agent.document_parser import parse_upload
from src.agent.langgraph_setup import create_agent
from src.agent.middleware import log_tool_event
from src.agent.tools import _redact_sensitive_fields, get_all_tools
from src.agent.tools import (
_redact_sensitive_fields,
drain_tool_retry_events,
get_all_tools,
start_tool_retry_event_buffer,
)
from src.core.auth.jwt import decode_token
from src.core.cot_logger import seed_trace_id
from src.core.logger import logger
@@ -517,6 +522,15 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
user_role,
uicontext.get("objectType") if uicontext else None,
)
yield json.dumps({
"content": "",
"metadata": {
"type": "pipeline_result",
"tools": [tool.name for tool in agent_tools],
"object_type": uicontext.get("objectType") if uicontext else None,
"user_role": user_role,
},
})
# Auto-inject environment_id into tool calls when LLM omits it
agent_tools = _inject_env_id_into_tools(agent_tools, env_id)
agent = await create_agent(agent_tools, env_id)
@@ -524,6 +538,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
assistant_parts: list[str] = []
max_attempts = 2
start_tool_retry_event_buffer()
try:
for attempt in range(max_attempts):
@@ -534,6 +549,9 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
config=config,
version="v2",
):
for retry_event in drain_tool_retry_events():
emitted_any = True
yield json.dumps(retry_event)
kind = event.get("event")
if kind in ("on_tool_start", "on_tool_end", "on_tool_error"):
await log_tool_event(event, conv_id)
@@ -589,12 +607,31 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
user_role=denied_role,
)
continue
if "timed out" in err.lower() or "timeout" in err.lower():
is_write_tool = "operation status unknown" in err.lower() or tool_name in {
"deploy_dashboard", "commit_changes", "create_branch", "run_backup",
"execute_migration", "start_maintenance", "end_maintenance",
}
yield json.dumps({
"content": f"⏱️ {tool_name} — timeout",
"metadata": {
"type": "tool_timeout",
"tool": tool_name,
"timeout_seconds": 30,
"is_write_tool": is_write_tool,
"retryable": not is_write_tool,
},
})
continue
yield json.dumps({
"content": f"{tool_name}{err}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": err},
})
state = await agent.aget_state(config)
for retry_event in drain_tool_retry_events():
emitted_any = True
yield json.dumps(retry_event)
if getattr(state, "next", None):
emitted_any = True
yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id)

View File

@@ -6,6 +6,7 @@
# @INVARIANT Every @tool function MUST have a Python docstring (triple-quoted string immediately after the signature). LangChain raises ValueError("Function must have a docstring if description not provided.") when args_schema is provided without a description param AND the function lacks a docstring. This is a runtime blocker — the entire Gradio agent fails to import when ANY single tool violates this invariant. The GRACE @BRIEF comment is NOT a substitute — it's structural metadata invisible to the Python runtime. Rule: BEFORE decorating with @tool(args_schema=...), ensure `"""One-line description."""` is present on the next line.
import asyncio
from contextvars import ContextVar
import json
import os
from typing import Any
@@ -20,9 +21,47 @@ from src.core.logger import logger
TOOL_RESPONSE_LIMIT = 4000
TOOL_TIMEOUT_SECONDS = 30
_TOOL_RETRY_EVENTS: ContextVar[list[dict[str, Any]] | None] = ContextVar(
"agent_tool_retry_events",
default=None,
)
# ── Internal helpers ─────────────────────────────────────────────
# #region AgentChat.Tools.RetryEvents [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,retry,sse]
# @ingroup AgentChat
# @BRIEF Context-local retry event buffer used by Gradio stream to emit tool_retry metadata.
def start_tool_retry_event_buffer() -> None:
"""Initialise an empty retry-event buffer for the current agent request."""
_TOOL_RETRY_EVENTS.set([])
def drain_tool_retry_events() -> list[dict[str, Any]]:
"""Return and clear pending tool_retry events for the current request."""
events = _TOOL_RETRY_EVENTS.get()
if not events:
return []
drained = list(events)
events.clear()
return drained
def _queue_tool_retry_event(tool_name: str, attempt: int, max_attempts: int) -> None:
"""Queue a tool_retry event if a request-local buffer is active."""
events = _TOOL_RETRY_EVENTS.get()
if events is None:
return
events.append({
"content": f"🔁 {tool_name} retry {attempt}/{max_attempts}",
"metadata": {
"type": "tool_retry",
"tool": tool_name,
"attempt": attempt,
"max_attempts": max_attempts,
},
})
# #endregion AgentChat.Tools.RetryEvents
# #region AgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,auth,helper]
# @ingroup AgentChat
# @BRIEF Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019.
@@ -132,6 +171,7 @@ async def _retry_read_tool(
except (httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout) as e:
last_error = e
if attempt < max_attempts:
_queue_tool_retry_event(tool_name, attempt, max_attempts)
logger.reason("Tool retry", payload={"tool": tool_name, "attempt": attempt, "error": str(e)[:100]}, extra={"src": "AgentChat.Tools.Retry"})
await asyncio.sleep(1)
continue