Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions datapizza-ai-core/datapizza/agents/runner.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import inspect
import os
from collections.abc import AsyncGenerator, Generator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
Expand All @@ -21,6 +22,16 @@
from .agent import Agent, Plan, StepResult


def _trace_tool_io_enabled() -> bool:
"""Whether tool arguments/results may be exported to trace spans.

Mirrors the client I/O gate so tool spans do not leak arguments and
results (which can carry file contents, DB rows or PII) to trace
exporters unless the operator explicitly opts in.
"""
return os.getenv("DATAPIZZA_TRACE_CLIENT_IO", "false").lower() == "true"


@dataclass
class HandoffRequest:
target: str
Expand Down Expand Up @@ -1090,16 +1101,19 @@ def _execute_tool(
self, function_call: FunctionCallBlock, agent: "Agent"
) -> FunctionCallResultBlock:
with tool_span(f"Tool {function_call.tool.name}") as current_tool_span:
current_tool_span.set_attribute(
"tool_arguments", str(function_call.arguments)
)
trace_io = _trace_tool_io_enabled()
if trace_io:
current_tool_span.set_attribute(
"tool_arguments", str(function_call.arguments)
)
result = function_call.tool(**function_call.arguments)

if inspect.iscoroutine(result):
result = AsyncExecutor.get_instance().run(result)

if result:
current_tool_span.set_attribute("tool_result", result)
if trace_io:
current_tool_span.set_attribute("tool_result", result)
agent._logger.log_panel(
result,
title=f"TOOL {function_call.tool.name.upper()} RESULT",
Expand All @@ -1115,16 +1129,19 @@ async def _a_execute_tool(
self, function_call: FunctionCallBlock, agent: "Agent"
) -> FunctionCallResultBlock:
with tool_span(f"Tool {function_call.tool.name}") as current_tool_span:
current_tool_span.set_attribute(
"tool_arguments", str(function_call.arguments)
)
trace_io = _trace_tool_io_enabled()
if trace_io:
current_tool_span.set_attribute(
"tool_arguments", str(function_call.arguments)
)
result = function_call.tool(**function_call.arguments)

if inspect.iscoroutine(result):
result = await result

if result:
current_tool_span.set_attribute("tool_result", result)
if trace_io:
current_tool_span.set_attribute("tool_result", result)
agent._logger.log_panel(
result,
title=f"TOOL {function_call.tool.name.upper()} RESULT",
Expand Down
170 changes: 170 additions & 0 deletions datapizza-ai-core/datapizza/agents/tests/test_tool_span_privacy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import asyncio
from contextlib import contextmanager
from unittest.mock import MagicMock, patch

from datapizza.agents.agent import Agent
from datapizza.agents.runner import AgentRunner
from datapizza.clients import MockClient
from datapizza.tools import tool
from datapizza.type import FunctionCallBlock

TRACE_ENV = "DATAPIZZA_TRACE_CLIENT_IO"


@tool
def reveal(secret: str) -> str:
return f"SENSITIVE:{secret}"


@tool
def empty_tool() -> str:
return ""


def _make_call():
return FunctionCallBlock(
id="call-1",
arguments={"secret": "hunter2"},
name=reveal.name,
tool=reveal,
)


def _capture_span():
span = MagicMock()

@contextmanager
def fake_tool_span(name=None):
yield span

return span, fake_tool_span


def _attribute_keys(span):
return [call.args[0] for call in span.set_attribute.call_args_list]


def test_tool_span_omits_io_by_default(monkeypatch):
monkeypatch.delenv(TRACE_ENV, raising=False)
span, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[reveal])

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
AgentRunner()._execute_tool(_make_call(), agent)

keys = _attribute_keys(span)
assert "tool_arguments" not in keys
assert "tool_result" not in keys


def test_tool_span_includes_io_when_enabled(monkeypatch):
monkeypatch.setenv(TRACE_ENV, "true")
span, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[reveal])

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
AgentRunner()._execute_tool(_make_call(), agent)

keys = _attribute_keys(span)
assert "tool_arguments" in keys
assert "tool_result" in keys


def test_a_tool_span_omits_io_by_default(monkeypatch):
monkeypatch.delenv(TRACE_ENV, raising=False)
span, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[reveal])

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
asyncio.run(AgentRunner()._a_execute_tool(_make_call(), agent))

keys = _attribute_keys(span)
assert "tool_arguments" not in keys
assert "tool_result" not in keys


def test_a_tool_span_includes_io_when_enabled(monkeypatch):
monkeypatch.setenv(TRACE_ENV, "true")
span, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[reveal])

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
asyncio.run(AgentRunner()._a_execute_tool(_make_call(), agent))

keys = _attribute_keys(span)
assert "tool_arguments" in keys
assert "tool_result" in keys


def test_tool_span_includes_io_with_uppercase_env(monkeypatch):
# The docs instruct DATAPIZZA_TRACE_CLIENT_IO=TRUE (uppercase); the gate must honor it.
monkeypatch.setenv(TRACE_ENV, "TRUE")
span, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[reveal])

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
AgentRunner()._execute_tool(_make_call(), agent)

keys = _attribute_keys(span)
assert "tool_arguments" in keys
assert "tool_result" in keys


def test_tool_span_omits_io_for_non_true_value(monkeypatch):
# Only the literal true/TRUE enables (parity with the client I/O gate); "1" must not.
monkeypatch.setenv(TRACE_ENV, "1")
span, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[reveal])

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
AgentRunner()._execute_tool(_make_call(), agent)

keys = _attribute_keys(span)
assert "tool_arguments" not in keys
assert "tool_result" not in keys


def test_falsy_tool_result_never_sets_result_attribute(monkeypatch):
# Even with tracing enabled, a falsy result must not produce a tool_result attribute.
monkeypatch.setenv(TRACE_ENV, "true")
span, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[empty_tool])
call = FunctionCallBlock(
id="call-empty", arguments={}, name=empty_tool.name, tool=empty_tool
)

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
AgentRunner()._execute_tool(call, agent)

keys = _attribute_keys(span)
assert "tool_arguments" in keys
assert "tool_result" not in keys


def test_return_value_unchanged_regardless_of_flag(monkeypatch):
_, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[reveal])

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
monkeypatch.delenv(TRACE_ENV, raising=False)
off = AgentRunner()._execute_tool(_make_call(), agent)
monkeypatch.setenv(TRACE_ENV, "true")
on = AgentRunner()._execute_tool(_make_call(), agent)

assert off.result == "SENSITIVE:hunter2"
assert on.result == off.result


def test_console_log_panel_called_even_when_tracing_disabled(monkeypatch):
# The console channel is intentionally independent of the trace gate: it must
# still fire while the span omits the sensitive attributes.
monkeypatch.delenv(TRACE_ENV, raising=False)
span, fake_tool_span = _capture_span()
agent = Agent(name="t", client=MockClient(), tools=[reveal])
agent._logger = MagicMock()

with patch("datapizza.agents.runner.tool_span", fake_tool_span):
AgentRunner()._execute_tool(_make_call(), agent)

assert agent._logger.log_panel.called
assert "tool_result" not in _attribute_keys(span)
4 changes: 4 additions & 0 deletions docs/Guides/Monitoring/tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ If you want to log the input/output and the memory passed to client invoke you s

default is `FALSE`

The same flag also gates tool spans: tool arguments and results (which may contain
file contents, database rows or other sensitive data) are attached to trace spans
only when `DATAPIZZA_TRACE_CLIENT_IO=TRUE`. With the default they are omitted.



## Manual Span Creation
Expand Down