Saltar a contenido

ciel.observability — Auditoría y trazas

Trazabilidad multi-tenant para Ciel: eventos de auditoría (AuditEvent), sumideros (AuditSink) y un trazador consciente de herramientas (ToolAwareTracer) que emite spans a través de un sumidero asíncrono.

ciel.observability

__all__ = ['AuditEvent', 'AuditSink', 'InMemoryAuditSink', 'JsonlAuditSink', 'NullAuditSink', 'TraceSpan', 'ToolAwareTracer', 'assert_tenant_event', 'propagate'] module-attribute

AuditEvent dataclass

Source code in src/ciel/observability/__init__.py
@dataclass
class AuditEvent:
    event: str
    session_id: Optional[str] = None
    agent: Optional[str] = None
    tool_call_id: Optional[str] = None
    data: Dict[str, Any] = None
    tenant_id: Optional[str] = None

    def __post_init__(self) -> None:
        if self.data is None:
            self.data = {}

AuditSink

Source code in src/ciel/observability/__init__.py
class AuditSink:
    async def write(self, event: AuditEvent) -> None:
        raise NotImplementedError

InMemoryAuditSink

Bases: AuditSink

Source code in src/ciel/observability/__init__.py
class InMemoryAuditSink(AuditSink):
    def __init__(self) -> None:
        self.events: List[AuditEvent] = []

    async def write(self, event: AuditEvent) -> None:
        self.events.append(event)

JsonlAuditSink

Bases: AuditSink

JSONL audit sink partitioned by tenant and session.

Each flushed event is written to base_path / tenant_id / session_id / {tenant_id}-{session_id}.jsonl. Missing directories are created automatically and writes are protected by an internal lock to keep async consumers safe.

Source code in src/ciel/observability/__init__.py
class JsonlAuditSink(AuditSink):
    """JSONL audit sink partitioned by tenant and session.

    Each flushed event is written to ``base_path / tenant_id / session_id
    / {tenant_id}-{session_id}.jsonl``. Missing directories are created
    automatically and writes are protected by an internal lock to keep
    async consumers safe.
    """

    def __init__(self, base_path: Path | str = "audit") -> None:
        self.base_path = Path(base_path)
        self._lock = asyncio.Lock()

    def _jsonl_path(self, event: AuditEvent) -> Path:
        tenant = event.tenant_id or "_global"
        session = event.session_id or "_nosession"
        return self.base_path / tenant / session / f"{tenant}-{session}.jsonl"

    async def write(self, event: AuditEvent) -> None:
        path = self._jsonl_path(event)
        path.parent.mkdir(parents=True, exist_ok=True)
        async with self._lock:
            f = await asyncio.to_thread(path.open, mode="a", encoding="utf-8")
            try:
                payload = {
                    "ts": time.time(),
                    "event": event.event,
                    "tenant_id": event.tenant_id,
                    "session_id": event.session_id,
                    "agent": event.agent,
                    "tool_call_id": event.tool_call_id,
                    "data": event.data or {},
                }
                f.write(json.dumps(payload, ensure_ascii=True) + "\n")
            finally:
                await asyncio.to_thread(f.close)

NullAuditSink

Bases: AuditSink

Source code in src/ciel/observability/__init__.py
class NullAuditSink(AuditSink):
    async def write(self, event: AuditEvent) -> None:
        return

ToolAwareTracer

Tool-aware async tracer.

Keeps lightweight session/tenant root spans and emits tool call spans through an async context manager. Every lifecycle event is written to the provided sink so sinks can aggregate cross-tool traces.

Source code in src/ciel/observability/__init__.py
class ToolAwareTracer:
    """Tool-aware async tracer.

    Keeps lightweight session/tenant root spans and emits tool call spans
    through an async context manager. Every lifecycle event is written
    to the provided sink so sinks can aggregate cross-tool traces.
    """

    def __init__(self, sink: AuditSink) -> None:
        self.sink = sink
        self._spans: Dict[str, TraceSpan] = {}

    def span(self, trace_id: str, *, tenant_id: Optional[str] = None) -> TraceSpan:
        key = f"{tenant_id or '_'}:{trace_id}"
        if key not in self._spans:
            self._spans[key] = TraceSpan(
                trace_id=trace_id,
                span_id="root",
                tenant_id=tenant_id,
            )
        return self._spans[key]

    @asynccontextmanager
    async def tool_span(
        self,
        tool_name: str,
        *,
        trace_id: Optional[str] = None,
        tenant_id: Optional[str] = None,
        tool_call_id: Optional[str] = None,
        arguments: Optional[Dict[str, Any]] = None,
    ) -> AsyncIterator[TraceSpan]:
        resolved_trace_id = trace_id or "trace"
        parent = self.span(trace_id=resolved_trace_id, tenant_id=tenant_id)
        async with parent.start_child(tool_name, tenant_id=tenant_id) as span:
            await self.sink.write(
                AuditEvent(
                    event="tool.call.start",
                    tenant_id=tenant_id,
                    tool_call_id=tool_call_id,
                    data={"arguments": arguments or {}, "trace_id": resolved_trace_id, "span_id": span.span_id},
                )
            )
            try:
                yield span
                await self.sink.write(
                    AuditEvent(
                        event="tool.call.end",
                        tenant_id=tenant_id,
                        tool_call_id=tool_call_id,
                        data={"trace_id": resolved_trace_id, "span_id": span.span_id},
                    )
                )
            except Exception as exc:
                await self.sink.write(
                    AuditEvent(
                        event="tool.call.error",
                        tenant_id=tenant_id,
                        tool_call_id=tool_call_id,
                        data={"error": str(exc), "trace_id": resolved_trace_id, "span_id": span.span_id},
                    )
                )
                raise

TraceSpan dataclass

Source code in src/ciel/observability/__init__.py
@dataclass(frozen=True)
class TraceSpan:
    trace_id: str
    span_id: str
    parent_span_id: Optional[str] = None
    tenant_id: Optional[str] = None
    name: Optional[str] = None
    data: Dict[str, Any] = field(default_factory=dict)
    events: List[AuditEvent] = field(default_factory=list)

    def add_event(self, event: AuditEvent, *, tenant_id: Optional[str] = None) -> AuditEvent:
        normalized = propagate(event, tenant_id=tenant_id)
        normalized.data.setdefault("trace_id", self.trace_id)
        normalized.data.setdefault("span_id", self.span_id)
        self.events.append(normalized)
        return normalized

    @asynccontextmanager
    async def start_child(
        self, name: str, *, tenant_id: Optional[str] = None
    ) -> AsyncIterator["TraceSpan"]:
        child = TraceSpan(
            trace_id=self.trace_id,
            span_id=f"{self.span_id}.{name}",
            parent_span_id=self.span_id,
            tenant_id=tenant_id or self.tenant_id,
            name=name,
        )
        yield child

assert_tenant_event(event: AuditEvent) -> None

Source code in src/ciel/observability/__init__.py
def assert_tenant_event(event: AuditEvent) -> None:
    if event.tenant_id is None:
        raise ValueError("AuditEvent requires tenant_id for multi-tenancy tracing")

propagate(event: AuditEvent, *, tenant_id: Optional[str] = None) -> AuditEvent

Source code in src/ciel/observability/__init__.py
def propagate(event: AuditEvent, *, tenant_id: Optional[str] = None) -> AuditEvent:
    normalized_tenant_id = tenant_id or event.tenant_id
    if normalized_tenant_id is None:
        raise ValueError(
            "propagate() requires tenant_id to be passed explicitly or present on event"
        )
    if tenant_id is not None:
        event.tenant_id = normalized_tenant_id
    return event

OpenTelemetry audit exporter and tracing bootstrap (lenient).

This module is optional at runtime: if the opentelemetry-api / opentelemetry-sdk packages are not installed (they live behind the observability extra) every symbol here still imports cleanly. The :class:OtlpAuditExporter degrades to a no-op sink and :func:init_tracing returns None while logging a warning, so callers never need to guard imports themselves.

When the packages are present, :class:OtlpAuditExporter implements the :class:~ciel.observability.AuditSink interface and emits an OpenTelemetry span (plus a span event) for every :class:~ciel.observability.AuditEvent it receives, preserving multi-tenancy via tenant_id span attributes.

Fase 8 añade helpers de observabilidad centralizada: :func:init_tracing acepta un otlp_endpoint (exportador OTLP a un colector) o, por defecto, un InMemorySpanExporter para tests offline; :func:span_count cuenta los spans exportados (usado por los tests); :func:current_tracer devuelve el tracer global.

OTEL_AVAILABLE = _OTEL_AVAILABLE module-attribute

_OTEL_AVAILABLE = True module-attribute

__all__ = ['OtlpAuditExporter', 'init_tracing', 'OTEL_AVAILABLE', 'current_tracer', 'span_count'] module-attribute

_last_provider = None module-attribute

logger = logging.getLogger(__name__) module-attribute

AuditEvent dataclass

Source code in src/ciel/observability/__init__.py
@dataclass
class AuditEvent:
    event: str
    session_id: Optional[str] = None
    agent: Optional[str] = None
    tool_call_id: Optional[str] = None
    data: Dict[str, Any] = None
    tenant_id: Optional[str] = None

    def __post_init__(self) -> None:
        if self.data is None:
            self.data = {}

AuditSink

Source code in src/ciel/observability/__init__.py
class AuditSink:
    async def write(self, event: AuditEvent) -> None:
        raise NotImplementedError

OtlpAuditExporter

Bases: AuditSink

Audit sink that forwards events to an OpenTelemetry tracer.

Every :meth:write call starts a span named after event.event and attaches a span event carrying the event payload. Multi-tenancy is preserved by recording tenant_id (and session_id / agent / tool_call_id when present) as span attributes.

The sink is safe: a missing opentelemetry install makes :meth:write a no-op, and any tracing error is swallowed after logging so audit emission never crashes the caller.

Source code in src/ciel/observability/otel.py
class OtlpAuditExporter(AuditSink):
    """Audit sink that forwards events to an OpenTelemetry tracer.

    Every :meth:`write` call starts a span named after ``event.event`` and
    attaches a span event carrying the event payload. Multi-tenancy is
    preserved by recording ``tenant_id`` (and ``session_id`` / ``agent`` /
    ``tool_call_id`` when present) as span attributes.

    The sink is *safe*: a missing opentelemetry install makes :meth:`write` a
    no-op, and any tracing error is swallowed after logging so audit emission
    never crashes the caller.
    """

    def __init__(
        self,
        *,
        tracer=None,
        service_name: str = "ciel",
    ) -> None:
        if _OTEL_AVAILABLE and tracer is None:
            tracer = trace.get_tracer(service_name)
        self._tracer = tracer
        self.service_name = service_name

    async def write(self, event: AuditEvent) -> None:
        if not _OTEL_AVAILABLE or self._tracer is None:
            return
        try:
            with self._tracer.start_as_current_span(event.event) as span:
                if event.tenant_id is not None:
                    span.set_attribute("tenant.id", event.tenant_id)
                if event.session_id is not None:
                    span.set_attribute("session.id", event.session_id)
                if event.agent is not None:
                    span.set_attribute("agent.name", event.agent)
                if event.tool_call_id is not None:
                    span.set_attribute("tool.call.id", event.tool_call_id)
                attributes = {
                    "event": event.event,
                    "tenant.id": event.tenant_id or "",
                }
                if event.data:
                    for key, value in event.data.items():
                        safe_key = key.replace(".", "_")[:64]
                        try:
                            span.set_attribute(f"event.data.{safe_key}", str(value))
                            attributes[f"data.{safe_key}"] = str(value)
                        except Exception:  # pragma: no cover - defensive
                            pass
                span.add_event(event.event, attributes=attributes)
        except Exception as exc:  # pragma: no cover - defensive
            logger.warning("OtlpAuditExporter.write failed: %s", exc)

_find_in_memory_exporter(provider) -> Optional['InMemorySpanExporter']

Navega la estructura REAL del TracerProvider del SDK instalado para hallar el InMemorySpanExporter.

La forma de acceder al span processor varía entre versiones/builds del SDK:

  • Algunas versiones exponen el metodo publico provider.get_active_span_processor() (devuelve un SynchronousMultiSpanProcessor / ConcurrentMultiSpanProcessor), y cada processor hijo tiene el atributo span_exporter.
  • En opentelemetry-sdk 1.x el provider NO expone ese metodo publico; el atributo real es provider._active_span_processor (un multiprocesador) que contiene _span_processors (lista) y cada processor tiene span_exporter.

El helper soporta AMBAS formas de forma defensiva y se detiene ante el primer InMemorySpanExporter encontrado. Si el exporter no es in-memory (o no se puede navegar la estructura), devuelve None.

Source code in src/ciel/observability/otel.py
def _find_in_memory_exporter(provider) -> Optional["InMemorySpanExporter"]:
    """Navega la estructura REAL del TracerProvider del SDK instalado para
    hallar el ``InMemorySpanExporter``.

    La forma de acceder al span processor varía entre versiones/builds del
    SDK:

    * Algunas versiones exponen el metodo publico
      ``provider.get_active_span_processor()`` (devuelve un
      ``SynchronousMultiSpanProcessor`` / ``ConcurrentMultiSpanProcessor``),
      y cada processor hijo tiene el atributo ``span_exporter``.
    * En opentelemetry-sdk 1.x el provider NO expone ese metodo publico;
      el atributo real es ``provider._active_span_processor`` (un multiprocesador)
      que contiene ``_span_processors`` (lista) y cada processor tiene
      ``span_exporter``.

    El helper soporta AMBAS formas de forma defensiva y se detiene ante el
    primer ``InMemorySpanExporter`` encontrado. Si el exporter no es
    in-memory (o no se puede navegar la estructura), devuelve ``None``.
    """
    if provider is None:
        return None
    # Evita inspeccionar el ProxyTracerProvider del API global (no tiene spans).
    proxy_types = tuple(
        t
        for t in (getattr(trace, "_ProxyTracerProvider", None),)
        if t is not None
    )
    if proxy_types and isinstance(provider, proxy_types):
        return None

    # (1) API publica si existe.
    asp = None
    if hasattr(provider, "get_active_span_processor") and callable(
        getattr(provider, "get_active_span_processor", None)
    ):
        try:
            asp = provider.get_active_span_processor()
        except Exception:  # pragma: no cover - defensive
            asp = None
    # (2) Fallback a la estructura interna del SDK 1.x.
    if asp is None:
        asp = getattr(provider, "_active_span_processor", None)
    if asp is None:
        return None

    # Un multiprocesador contiene _span_processors; un processor unico
    # expone directamente `span_exporter`.
    processors = getattr(asp, "_span_processors", None)
    if not processors:
        single = getattr(asp, "span_exporter", None)
        return single if isinstance(single, InMemorySpanExporter) else None
    for proc in processors:
        exporter = getattr(proc, "span_exporter", None)
        if isinstance(exporter, InMemorySpanExporter):
            return exporter
    return None

_import_otlp_exporter()

Best-effort import of an OTLP span exporter (lenient).

Tries the gRPC exporter first, then the HTTP/protobuf one. Returns the exporter class or None if neither is installed.

Source code in src/ciel/observability/otel.py
def _import_otlp_exporter():
    """Best-effort import of an OTLP span exporter (lenient).

    Tries the gRPC exporter first, then the HTTP/protobuf one. Returns the
    exporter class or ``None`` if neither is installed.
    """
    try:  # pragma: no cover - depends on optional extras
        from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
            OTLPSpanExporter,
        )

        return OTLPSpanExporter
    except Exception:
        pass
    try:  # pragma: no cover - depends on optional extras
        from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
            OTLPSpanExporter,
        )

        return OTLPSpanExporter
    except Exception:
        return None

current_tracer()

Devuelve el tracer de OTel, o None si OTel no está disponible.

Usa el provider real instalado por :func:init_tracing (_last_provider) en lugar del proxy global, para que las trazas caigan en el exporter que :func:span_count inspecciona.

Source code in src/ciel/observability/otel.py
def current_tracer():
    """Devuelve el tracer de OTel, o ``None`` si OTel no está disponible.

    Usa el provider real instalado por :func:`init_tracing` (``_last_provider``)
    en lugar del proxy global, para que las trazas caigan en el exporter que
    :func:`span_count` inspecciona.
    """
    if not _OTEL_AVAILABLE:
        return None
    provider = _last_provider or trace.get_tracer_provider()
    return provider.get_tracer("ciel")

init_tracing(*, service_name: str = 'ciel', otlp_endpoint: Optional[str] = None) -> Optional['TracerProvider']

Configure and install a global OpenTelemetry :class:TracerProvider.

Parameters

service_name: Value for the service.name resource attribute. otlp_endpoint: If given, spans are exported to this OTLP collector endpoint. If the OTLP exporter packages are not installed the call degrades to an in-memory exporter and logs a warning. If None, an in-memory exporter is used so the gateway is still bootable offline.

Returns

TracerProvider | None The installed provider, or None when opentelemetry is unavailable.

Source code in src/ciel/observability/otel.py
def init_tracing(
    *,
    service_name: str = "ciel",
    otlp_endpoint: Optional[str] = None,
) -> Optional["TracerProvider"]:
    """Configure and install a global OpenTelemetry :class:`TracerProvider`.

    Parameters
    ----------
    service_name:
        Value for the ``service.name`` resource attribute.
    otlp_endpoint:
        If given, spans are exported to this OTLP collector endpoint. If the
        OTLP exporter packages are not installed the call degrades to an
        in-memory exporter and logs a warning. If ``None``, an in-memory
        exporter is used so the gateway is still bootable offline.

    Returns
    -------
    TracerProvider | None
        The installed provider, or ``None`` when opentelemetry is unavailable.
    """
    if not _OTEL_AVAILABLE:  # pragma: no cover - depends on optional extras
        logger.warning("init_tracing: opentelemetry unavailable; tracing disabled")
        return None

    # OTel forbids re-setting the global provider via the public API
    # ("Overriding of current TracerProvider is not allowed"); the proxy
    # also can't be overwritten through set_tracer_provider. We always build
    # our own provider and force it onto the SDK's global slot so spans land on
    # the exporter that span_count() inspects (and current_tracer() binds to it).
    resource = Resource.create({SERVICE_NAME: service_name})
    provider: "TracerProvider" = TracerProvider(resource=resource)

    if otlp_endpoint:
        exporter_cls = _import_otlp_exporter()
        if exporter_cls is not None:  # pragma: no cover - needs otlp exporter
            provider.add_span_processor(
                SimpleSpanProcessor(exporter_cls(endpoint=otlp_endpoint))
            )
            logger.info("tracing: OTLP exporter configured for %s", otlp_endpoint)
        else:  # pragma: no cover - needs missing exporter
            logger.warning(
                "tracing: OTLP endpoint set but no OTLP exporter installed; "
                "falling back to in-memory exporter"
            )
            provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
    else:
        provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))

    # OTel forbids re-setting the global provider via the public API
    # ("Overriding of current TracerProvider is not allowed"). We force our
    # provider onto the SDK's global slot so spans always land on the exporter
    # that span_count() inspects (and current_tracer() is bound to it).
    trace._TRACER_PROVIDER = provider  # type: ignore[attr-defined]
    global _last_provider
    _last_provider = provider
    return provider

span_count() -> int

Número de spans emitidos por el exporter in-memory (solo tests/diagnóstico).

Si el provider global fue configurado con InMemorySpanExporter (el caso por defecto de init_tracing sin endpoint), devuelve cuántos spans se han exportado. Si OTel no está disponible o el exporter no es in-memory, devuelve -1 (no medible de forma determinista).

Source code in src/ciel/observability/otel.py
def span_count() -> int:
    """Número de spans emitidos por el exporter in-memory (solo tests/diagnóstico).

    Si el provider global fue configurado con ``InMemorySpanExporter`` (el
    caso por defecto de ``init_tracing`` sin endpoint), devuelve cuántos
    spans se han exportado. Si OTel no está disponible o el exporter no es
    in-memory, devuelve ``-1`` (no medible de forma determinista).
    """
    if not _OTEL_AVAILABLE:
        return -1
    provider = _last_provider or trace.get_tracer_provider()
    exporter = _find_in_memory_exporter(provider)
    if exporter is not None:
        return len(exporter.get_finished_spans())
    return -1

Prometheus metrics for Ciel (lenient).

This module is optional at runtime: if prometheus-client is not installed (it lives behind the observability extra) every symbol here still imports cleanly and the helpers provided (:func:record_request, :func:record_tool_call, :func:record_agent_loop) become no-ops that never raise. A single :func:metrics_handler is exposed so the gateway can mount a /metrics endpoint that returns valid Prometheus text when the client is available and a minimal 200 response otherwise.

Multi-tenancy is preserved by recording tenant as a label on every metric that carries it.

PROM_AVAILABLE = _PROM_AVAILABLE module-attribute

_PROM_AVAILABLE = True module-attribute

__all__ = ['PROM_AVAILABLE', 'record_request', 'record_tool_call', 'record_agent_loop', 'metrics_handler', 'generate_latest'] module-attribute

_agent_loops_total = Counter('ciel_agent_loops_total', 'Total Ciel agent loop iterations (optionally by tenant).', ['tenant']) module-attribute

_requests_total = Counter('ciel_requests_total', 'Total Ciel requests by surface, tenant and status.', ['surface', 'tenant', 'status']) module-attribute

_tool_calls_total = Counter('ciel_tool_calls_total', 'Total Ciel tool invocations by tenant, tool and status.', ['tenant', 'tool', 'status']) module-attribute

logger = logging.getLogger(__name__) module-attribute

metrics_handler(request=None) -> Response async

Starlette/FastAPI handler exposing /metrics.

Returns Prometheus text exposition when the client is available. When it is not, returns a 200 response with a short notice so the endpoint still resolves without crashing the gateway (offline-safe).

Source code in src/ciel/observability/metrics.py
async def metrics_handler(request=None) -> Response:
    """Starlette/FastAPI handler exposing ``/metrics``.

    Returns Prometheus text exposition when the client is available. When it
    is not, returns a 200 response with a short notice so the endpoint still
    resolves without crashing the gateway (offline-safe).
    """
    if _PROM_AVAILABLE and generate_latest is not None:  # pragma: no cover
        body = generate_latest(REGISTRY)
        return Response(content=body, media_type=CONTENT_TYPE_LATEST)
    return Response(
        content="# ciel metrics: prometheus-client not installed\n",
        media_type=CONTENT_TYPE_LATEST,
    )

record_agent_loop(tenant: Optional[str] = None, *, increment: int = 1) -> None

Increment ciel_agent_loops_total.

Safe no-op when prometheus-client is unavailable or an error occurs.

Source code in src/ciel/observability/metrics.py
def record_agent_loop(tenant: Optional[str] = None, *, increment: int = 1) -> None:
    """Increment ``ciel_agent_loops_total``.

    Safe no-op when prometheus-client is unavailable or an error occurs.
    """
    if not _PROM_AVAILABLE or _agent_loops_total is None:
        return
    try:
        _agent_loops_total.labels(tenant=tenant or "_global").inc(increment)
    except Exception as exc:  # pragma: no cover - defensive
        logger.warning("record_agent_loop failed: %s", exc)

record_request(surface: str, tenant: Optional[str], status: str, *, increment: int = 1) -> None

Increment ciel_requests_total.

Safe no-op when prometheus-client is unavailable or an error occurs.

Source code in src/ciel/observability/metrics.py
def record_request(
    surface: str,
    tenant: Optional[str],
    status: str,
    *,
    increment: int = 1,
) -> None:
    """Increment ``ciel_requests_total``.

    Safe no-op when prometheus-client is unavailable or an error occurs.
    """
    if not _PROM_AVAILABLE or _requests_total is None:
        return
    try:
        _requests_total.labels(
            surface=surface, tenant=tenant or "_global", status=status
        ).inc(increment)
    except Exception as exc:  # pragma: no cover - defensive
        logger.warning("record_request failed: %s", exc)

record_tool_call(tool: str, tenant: Optional[str], status: str, *, increment: int = 1) -> None

Increment ciel_tool_calls_total.

Safe no-op when prometheus-client is unavailable or an error occurs.

Source code in src/ciel/observability/metrics.py
def record_tool_call(
    tool: str,
    tenant: Optional[str],
    status: str,
    *,
    increment: int = 1,
) -> None:
    """Increment ``ciel_tool_calls_total``.

    Safe no-op when prometheus-client is unavailable or an error occurs.
    """
    if not _PROM_AVAILABLE or _tool_calls_total is None:
        return
    try:
        _tool_calls_total.labels(
            tool=tool, tenant=tenant or "_global", status=status
        ).inc(increment)
    except Exception as exc:  # pragma: no cover - defensive
        logger.warning("record_tool_call failed: %s", exc)