Trace agent runs and tool calls

@log_activity opens the invoke_agent span for every agent run and names
the trace's activity; gen_continuation opens its own. ToolExecutor.execute
records each executed call with redacted argument and result previews, and
paused, denied, skipped and client-executed calls are recorded where they
are decided.
This commit is contained in:
arc53-machine committed 2026-09-23 17:25:41 +01:00
1 parent e8166e6c29
commit a15940c281
6 files changed
+557 -35

No files matched your search

+81 -1
View File
@@ -13,6 +13,7 @@ from docsgpt.agents.default_tools import (
resolve_tool_by_id,
synthesized_default_tools,
)
from docsgpt import tracing
from docsgpt.agents.tools.tool_action_parser import ToolActionParser
from docsgpt.agents.tools.tool_manager import ToolManager
from docsgpt.guardrails.types import Stage as GuardrailStage, resolve_tool_result
@@ -29,6 +30,65 @@ from docsgpt.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
def record_tool_span_start(call: Any, **attributes: Any):
"""Open an ``execute_tool`` span for ``call`` (no-op without an active trace)."""
llm_name = getattr(call, "name", None) or "unknown"
base = {
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": llm_name,
"gen_ai.tool.call.id": getattr(call, "id", None),
"gen_ai.tool.type": "function",
}
base.update(attributes)
return tracing.start_span(
tracing.KIND_TOOL,
f"execute_tool {llm_name}",
attributes={k: v for k, v in base.items() if v is not None},
)
#: ``tool_calls`` statuses mapped to trace span statuses.
_TOOL_SPAN_STATUS = {
"error": tracing.STATUS_ERROR,
"denied": tracing.STATUS_DENIED,
"skipped": tracing.STATUS_SKIPPED,
"awaiting_approval": tracing.STATUS_PENDING,
"requires_client_execution": tracing.STATUS_PENDING,
}
def finish_tool_span(span: Any, data: Dict[str, Any]) -> None:
"""Close a tool span from the ``tool_calls`` entry the call produced.
Args:
span: The span from :func:`record_tool_span_start`.
data: The tool-call record (``tool_name``, ``arguments``, ``result``,
``status``, ...); results in it are already guardrail-scanned.
"""
if not span:
return
status = str(data.get("status") or "completed")
span.set(
**{
"docsgpt.tool": data.get("tool_name"),
"docsgpt.action": data.get("action_name"),
"docsgpt.tool_status": status,
"docsgpt.artifact_id": data.get("artifact_id"),
}
)
if data.get("arguments") is not None:
span.preview("arguments", data.get("arguments"))
if data.get("result") is not None:
span.preview("result", data.get("result"))
span_status = _TOOL_SPAN_STATUS.get(status, tracing.STATUS_OK)
if span_status == tracing.STATUS_ERROR:
span.error = str(data.get("error") or data.get("result") or "tool error")[:500]
span.set(**{"error.type": str(data.get("error_type") or "ToolError")})
elif data.get("error"):
span.error = str(data["error"])[:500]
span.end(span_status)
def _is_foreign_key_violation(exc: BaseException) -> bool:
"""Whether ``exc`` is a Postgres FK violation (SQLSTATE 23503)."""
if not isinstance(exc, IntegrityError):
@@ -950,7 +1010,27 @@ class ToolExecutor:
MAX_ADVERTISED_TOOL_NAMES = 30
def execute(self, tools_dict: Dict, call, llm_class_name: str):
"""Execute a tool call. Yields status events, returns (result, call_id)."""
"""Execute a tool call. Yields status events, returns (result, call_id).
Every call is recorded as an ``execute_tool`` trace span; its outcome
is read from the ``tool_calls`` entry each branch of :meth:`_execute`
appends, so the span matches what the conversation stores.
"""
span = record_tool_span_start(call)
recorded = len(self.tool_calls)
try:
outcome = yield from self._execute(tools_dict, call, llm_class_name)
except Exception as exc:
span.end(error=exc)
raise
except GeneratorExit:
span.end(tracing.STATUS_CANCELLED)
raise
data = self.tool_calls[-1] if len(self.tool_calls) > recorded else {}
finish_tool_span(span, data)
return outcome
def _execute(self, tools_dict: Dict, call, llm_class_name: str):
parser = ToolActionParser(llm_class_name, name_mapping=self._name_to_tool)
tool_id, action_name, call_args = parser.parse_args(call)
llm_name = getattr(call, "name", "unknown")