feat(agent-chat): complete context guardrail event coverage
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user