sovara-openai-agents 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sovara Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: sovara-openai-agents
3
+ Version: 0.1.0
4
+ Summary: Sovara recording for the OpenAI Agents SDK
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://docs.sovara-labs.com/integrations/openai-agents
7
+ Project-URL: Repository, https://github.com/SovaraLabs/sovara-openai-agents/tree/main/python
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: sovara<0.11,>=0.10
12
+ Requires-Dist: openai-agents<0.14,>=0.13.6
13
+ Dynamic: license-file
14
+
15
+ # Sovara for the OpenAI Agents SDK (Python)
16
+
17
+ Record OpenAI Agents tool calls in Sovara traces.
18
+
19
+ ```bash
20
+ uv add sovara-openai-agents
21
+ # or: pip install sovara-openai-agents
22
+ ```
23
+
24
+ ```python
25
+ import sovara
26
+ import sovara_openai_agents
27
+
28
+ sovara.auto_instrument() # model calls, through the HTTP client
29
+ sovara_openai_agents.instrument() # function tools as tool steps, agent tools as child traces
30
+ ```
31
+
32
+ Call both once at startup, before creating agents. Inside a Sovara trace, function tools are
33
+ recorded as tool steps and agent tools (`agent.as_tool()`) open a child trace with their input
34
+ and output. Outside a trace, tools run unrecorded. Requires `openai-agents` 0.13.6 (the supported 0.13 series).
35
+
36
+ ## License
37
+
38
+ MIT.
@@ -0,0 +1,24 @@
1
+ # Sovara for the OpenAI Agents SDK (Python)
2
+
3
+ Record OpenAI Agents tool calls in Sovara traces.
4
+
5
+ ```bash
6
+ uv add sovara-openai-agents
7
+ # or: pip install sovara-openai-agents
8
+ ```
9
+
10
+ ```python
11
+ import sovara
12
+ import sovara_openai_agents
13
+
14
+ sovara.auto_instrument() # model calls, through the HTTP client
15
+ sovara_openai_agents.instrument() # function tools as tool steps, agent tools as child traces
16
+ ```
17
+
18
+ Call both once at startup, before creating agents. Inside a Sovara trace, function tools are
19
+ recorded as tool steps and agent tools (`agent.as_tool()`) open a child trace with their input
20
+ and output. Outside a trace, tools run unrecorded. Requires `openai-agents` 0.13.6 (the supported 0.13 series).
21
+
22
+ ## License
23
+
24
+ MIT.
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sovara-openai-agents"
7
+ version = "0.1.0"
8
+ description = "Sovara recording for the OpenAI Agents SDK"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "sovara>=0.10,<0.11",
14
+ # Function-tool capture needs 0.13+. Tested with 0.13.6; see the dev group.
15
+ "openai-agents>=0.13.6,<0.14",
16
+ ]
17
+
18
+ [project.urls]
19
+ Documentation = "https://docs.sovara-labs.com/integrations/openai-agents"
20
+ Repository = "https://github.com/SovaraLabs/sovara-openai-agents/tree/main/python"
21
+
22
+ [tool.setuptools]
23
+ package-dir = {"" = "src"}
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+ include = ["sovara_openai_agents*"]
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest",
32
+ "ruff==0.17.2",
33
+ # The versions the contract tests target; openai-agents 0.24 changed the tool executor.
34
+ "openai==2.30.0",
35
+ "openai-agents==0.13.6",
36
+ ]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py310"
41
+
42
+ [tool.ruff.lint]
43
+ select = [
44
+ "E4", "E7", "E9", "F", "I", "UP", "B",
45
+ "D100", "D101", "D102", "D103", "D104", "D106", "D107",
46
+ "D200", "D205", "D400", "D401", "D419",
47
+ "ERA001", "PGH004", "RUF100",
48
+ ]
49
+
50
+ [tool.ruff.lint.per-file-ignores]
51
+ "tests/**/*.py" = ["D1"]
52
+
53
+ [tool.ruff.lint.isort]
54
+ # Sovara's own packages sort together, as in the sovara repository.
55
+ known-first-party = ["sovara", "sovara_common", "sovara_openai_agents", "tests"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ """Record OpenAI Agents SDK tool calls in Sovara traces.
2
+
3
+ Call ``instrument()`` once at startup, after importing ``agents``. Function tools are recorded as
4
+ tool steps; agent tools open a child trace. Model calls are recorded by ``sovara.auto_instrument()``
5
+ through the HTTP client.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = ["instrument"]
11
+
12
+
13
+ def instrument() -> None:
14
+ """Record OpenAI Agents function and agent tools inside Sovara traces; idempotent."""
15
+ from sovara_openai_agents._patch import openai_agents_patch
16
+
17
+ openai_agents_patch()
@@ -0,0 +1,231 @@
1
+ """Record OpenAI Agents function tools and nested agent-tool traces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from functools import wraps
8
+ from typing import Any, Literal
9
+
10
+ from sovara import Step, ToolCall
11
+ from sovara import integration as instrumentation
12
+ from sovara.integration import json_value, subtrace
13
+
14
+ logger = logging.getLogger("sovara_openai_agents")
15
+
16
+ _STEP_ATTR = "_sovara_openai_agents_step"
17
+
18
+
19
+ def _is_agent_tool(func_tool: Any) -> bool:
20
+ return bool(getattr(func_tool, "_is_agent_tool", False))
21
+
22
+
23
+ def _agent_tool_name(func_tool: Any) -> str:
24
+ agent_name = getattr(getattr(func_tool, "_agent_instance", None), "name", None)
25
+ return str(agent_name or func_tool.name)
26
+
27
+
28
+ def _agent_tool_input(arguments: str) -> str:
29
+ parsed, parse_error = _parse_tool_arguments(arguments)
30
+ if parse_error is None and parsed is not None:
31
+ input_value = parsed.get("input")
32
+ if set(parsed) == {"input"} and isinstance(input_value, str):
33
+ return input_value
34
+ return arguments
35
+
36
+
37
+ def _trace_io_text(value: Any) -> str:
38
+ if isinstance(value, str):
39
+ return value
40
+ return json.dumps(json_value(value), ensure_ascii=False, sort_keys=True)
41
+
42
+
43
+ def _tool_namespace(tool_call: Any, func_tool: Any) -> str | None:
44
+ namespace = getattr(tool_call, "namespace", None) or getattr(func_tool, "_tool_namespace", None)
45
+ return namespace if isinstance(namespace, str) and namespace else None
46
+
47
+
48
+ def _qualified_tool_name(tool_name: str, namespace: str | None) -> str:
49
+ return f"{namespace}.{tool_name}" if namespace else tool_name
50
+
51
+
52
+ def _parse_tool_arguments(arguments: str) -> tuple[dict[str, Any] | None, str | None]:
53
+ if not arguments:
54
+ return {}, None
55
+
56
+ try:
57
+ parsed = json.loads(arguments)
58
+ except Exception as exc:
59
+ return None, f"Invalid JSON arguments: {exc}"
60
+
61
+ if not isinstance(parsed, dict):
62
+ return None, "Tool arguments must decode to a JSON object."
63
+
64
+ return parsed, None
65
+
66
+
67
+ def _tool_call(func_tool: Any, tool_call: Any) -> ToolCall:
68
+ tool_name = str(func_tool.name)
69
+ namespace = _tool_namespace(tool_call, func_tool)
70
+ arguments = str(getattr(tool_call, "arguments", "") or "")
71
+ parsed_input, parse_error = _parse_tool_arguments(arguments)
72
+ metadata: dict[str, Any] = {
73
+ "tool_class": func_tool.__class__.__name__,
74
+ "strict_json_schema": bool(getattr(func_tool, "strict_json_schema", False)),
75
+ "is_agent_tool": bool(getattr(func_tool, "_is_agent_tool", False)),
76
+ "defer_loading": bool(getattr(func_tool, "defer_loading", False)),
77
+ }
78
+ if namespace:
79
+ metadata["tool_namespace"] = namespace
80
+ if parse_error is not None:
81
+ metadata["tool_arguments_raw"] = arguments
82
+ metadata["arguments_error"] = parse_error
83
+ return ToolCall(
84
+ name=_qualified_tool_name(tool_name, namespace),
85
+ arguments=json_value(parsed_input),
86
+ call_id=str(getattr(tool_call, "call_id", "") or "").strip() or None,
87
+ metadata=metadata,
88
+ )
89
+
90
+
91
+ def _step_of(task_state: Any) -> Step | None:
92
+ return getattr(task_state, _STEP_ATTR, None)
93
+
94
+
95
+ async def _log_trace_io(kind: Literal["input", "output"], text: str) -> None:
96
+ await instrumentation.alog_trace_io(kind, text)
97
+
98
+
99
+ def openai_agents_patch() -> None:
100
+ """Install OpenAI Agents function-tool and agent-tool instrumentation once.
101
+
102
+ Missing agents support is a no-op. Wrap the batch executor and supported invoker
103
+ variant; capture is gated by the recording and tracing flag. Agent tools use child
104
+ traces; ordinary function tools are recorded as tool steps. Tool arguments and
105
+ results pass through unchanged.
106
+ """
107
+ try:
108
+ from agents.run_internal import tool_execution
109
+ except ImportError:
110
+ logger.info("openai-agents not installed, skipping openai-agents patches")
111
+ return
112
+
113
+ batch_executor_cls = tool_execution._FunctionToolBatchExecutor
114
+ if getattr(batch_executor_cls, "__sovara_openai_agents_patched__", False):
115
+ return
116
+
117
+ original_execute_single_tool_body = batch_executor_cls._execute_single_tool_body
118
+ original_invoke_tool_and_run_post_invoke = batch_executor_cls._invoke_tool_and_run_post_invoke
119
+ invoker_name = (
120
+ "_invoke_function_tool_with_metadata"
121
+ if hasattr(tool_execution, "_invoke_function_tool_with_metadata")
122
+ else "invoke_function_tool"
123
+ )
124
+ original_invoke_function_tool = getattr(tool_execution, invoker_name)
125
+ invoker_returns_metadata = invoker_name == "_invoke_function_tool_with_metadata"
126
+
127
+ @wraps(original_invoke_function_tool)
128
+ async def patched_invoke_function_tool(*, function_tool, context, arguments):
129
+ if not instrumentation.capture_active() or not _is_agent_tool(function_tool):
130
+ return await original_invoke_function_tool(
131
+ function_tool=function_tool,
132
+ context=context,
133
+ arguments=arguments,
134
+ )
135
+
136
+ async with subtrace(_agent_tool_name(function_tool)) as child_trace_id:
137
+ if child_trace_id:
138
+ await _log_trace_io("input", _agent_tool_input(arguments))
139
+ result = await original_invoke_function_tool(
140
+ function_tool=function_tool,
141
+ context=context,
142
+ arguments=arguments,
143
+ )
144
+ output = result.output if invoker_returns_metadata else result
145
+ if child_trace_id and output is not None:
146
+ await _log_trace_io("output", _trace_io_text(output))
147
+ return result
148
+
149
+ @wraps(original_execute_single_tool_body)
150
+ async def patched_execute_single_tool_body(
151
+ self,
152
+ *,
153
+ outer_task,
154
+ task_state,
155
+ func_tool,
156
+ tool_call,
157
+ tool_context,
158
+ agent_hooks,
159
+ ):
160
+ if not instrumentation.capture_active() or _is_agent_tool(func_tool):
161
+ return await original_execute_single_tool_body(
162
+ self,
163
+ outer_task=outer_task,
164
+ task_state=task_state,
165
+ func_tool=func_tool,
166
+ tool_call=tool_call,
167
+ tool_context=tool_context,
168
+ agent_hooks=agent_hooks,
169
+ )
170
+
171
+ step = await instrumentation.abegin(_tool_call(func_tool, tool_call))
172
+ if step is not None:
173
+ setattr(task_state, _STEP_ATTR, step)
174
+
175
+ return await original_execute_single_tool_body(
176
+ self,
177
+ outer_task=outer_task,
178
+ task_state=task_state,
179
+ func_tool=func_tool,
180
+ tool_call=tool_call,
181
+ tool_context=tool_context,
182
+ agent_hooks=agent_hooks,
183
+ )
184
+
185
+ @wraps(original_invoke_tool_and_run_post_invoke)
186
+ async def patched_invoke_tool_and_run_post_invoke(
187
+ self,
188
+ *,
189
+ outer_task,
190
+ task_state,
191
+ func_tool,
192
+ tool_call,
193
+ tool_context,
194
+ agent_hooks,
195
+ ):
196
+ step = _step_of(task_state)
197
+ if step is None:
198
+ return await original_invoke_tool_and_run_post_invoke(
199
+ self,
200
+ outer_task=outer_task,
201
+ task_state=task_state,
202
+ func_tool=func_tool,
203
+ tool_call=tool_call,
204
+ tool_context=tool_context,
205
+ agent_hooks=agent_hooks,
206
+ )
207
+
208
+ try:
209
+ final_result = await original_invoke_tool_and_run_post_invoke(
210
+ self,
211
+ outer_task=outer_task,
212
+ task_state=task_state,
213
+ func_tool=func_tool,
214
+ tool_call=tool_call,
215
+ tool_context=tool_context,
216
+ agent_hooks=agent_hooks,
217
+ )
218
+ except Exception as exc:
219
+ await instrumentation.areport(step, "fail", exc, propagating=True)
220
+ raise
221
+ except BaseException as exc:
222
+ instrumentation.report_in_background(step, "cancel", exc.__class__.__name__)
223
+ raise
224
+ await instrumentation.areport(step, "finish", json_value(final_result))
225
+ return final_result
226
+
227
+ batch_executor_cls._execute_single_tool_body = patched_execute_single_tool_body
228
+ batch_executor_cls._invoke_tool_and_run_post_invoke = patched_invoke_tool_and_run_post_invoke
229
+ setattr(tool_execution, invoker_name, patched_invoke_function_tool)
230
+ batch_executor_cls.__sovara_openai_agents_patched__ = True
231
+ logger.info("openai-agents patches applied")
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: sovara-openai-agents
3
+ Version: 0.1.0
4
+ Summary: Sovara recording for the OpenAI Agents SDK
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://docs.sovara-labs.com/integrations/openai-agents
7
+ Project-URL: Repository, https://github.com/SovaraLabs/sovara-openai-agents/tree/main/python
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: sovara<0.11,>=0.10
12
+ Requires-Dist: openai-agents<0.14,>=0.13.6
13
+ Dynamic: license-file
14
+
15
+ # Sovara for the OpenAI Agents SDK (Python)
16
+
17
+ Record OpenAI Agents tool calls in Sovara traces.
18
+
19
+ ```bash
20
+ uv add sovara-openai-agents
21
+ # or: pip install sovara-openai-agents
22
+ ```
23
+
24
+ ```python
25
+ import sovara
26
+ import sovara_openai_agents
27
+
28
+ sovara.auto_instrument() # model calls, through the HTTP client
29
+ sovara_openai_agents.instrument() # function tools as tool steps, agent tools as child traces
30
+ ```
31
+
32
+ Call both once at startup, before creating agents. Inside a Sovara trace, function tools are
33
+ recorded as tool steps and agent tools (`agent.as_tool()`) open a child trace with their input
34
+ and output. Outside a trace, tools run unrecorded. Requires `openai-agents` 0.13.6 (the supported 0.13 series).
35
+
36
+ ## License
37
+
38
+ MIT.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/sovara_openai_agents/__init__.py
5
+ src/sovara_openai_agents/_patch.py
6
+ src/sovara_openai_agents.egg-info/PKG-INFO
7
+ src/sovara_openai_agents.egg-info/SOURCES.txt
8
+ src/sovara_openai_agents.egg-info/dependency_links.txt
9
+ src/sovara_openai_agents.egg-info/requires.txt
10
+ src/sovara_openai_agents.egg-info/top_level.txt
11
+ tests/test_contract.py
12
+ tests/test_recording.py
13
+ tests/test_sovara_openai_agents.py
@@ -0,0 +1,2 @@
1
+ sovara<0.11,>=0.10
2
+ openai-agents<0.14,>=0.13.6
@@ -0,0 +1,222 @@
1
+ import asyncio
2
+ import json
3
+ from contextlib import asynccontextmanager, contextmanager
4
+
5
+ from sovara import _recording, _steps
6
+ from sovara._recording import TraceWriteCredentials
7
+ from sovara.runner import context_manager
8
+ from sovara_openai_agents import _patch as openai_agents_patch_module
9
+
10
+
11
+ def _sdk():
12
+ from agents import Agent, RunConfig, RunContextWrapper, function_tool
13
+ from agents.lifecycle import RunHooks
14
+ from agents.run_internal import tool_execution
15
+ from agents.run_internal.run_steps import ToolRunFunction
16
+ from openai.types.responses import ResponseFunctionToolCall
17
+
18
+ return {
19
+ "Agent": Agent,
20
+ "RunConfig": RunConfig,
21
+ "RunContextWrapper": RunContextWrapper,
22
+ "RunHooks": RunHooks,
23
+ "ToolRunFunction": ToolRunFunction,
24
+ "ResponseFunctionToolCall": ResponseFunctionToolCall,
25
+ "FunctionToolBatchExecutor": tool_execution._FunctionToolBatchExecutor,
26
+ "AgentBindings": getattr(tool_execution, "AgentBindings", None),
27
+ "function_tool": function_tool,
28
+ }
29
+
30
+
31
+ def _build_executor(tool, tool_call):
32
+ sdk = _sdk()
33
+ agent = sdk["Agent"](
34
+ name="Tool runner",
35
+ instructions="Use the tool.",
36
+ model="gpt-4o-mini",
37
+ tools=[tool],
38
+ )
39
+ executor_args = dict(
40
+ tool_runs=[sdk["ToolRunFunction"](tool_call=tool_call, function_tool=tool)],
41
+ hooks=sdk["RunHooks"](),
42
+ context_wrapper=sdk["RunContextWrapper"](context=None),
43
+ config=sdk["RunConfig"](),
44
+ isolate_parallel_failures=True,
45
+ )
46
+ if sdk["AgentBindings"] is None:
47
+ executor_args["agent"] = agent
48
+ else:
49
+ executor_args["bindings"] = sdk["AgentBindings"](
50
+ public_agent=agent,
51
+ execution_agent=agent,
52
+ )
53
+ return sdk["FunctionToolBatchExecutor"](**executor_args)
54
+
55
+
56
+ class _FakeStep:
57
+ recorded = True
58
+
59
+ def __init__(self, call):
60
+ self.call = call
61
+ self.reports = []
62
+
63
+ async def afinish(self, output=None, **kwargs):
64
+ self.reports.append(("finish", output, kwargs))
65
+
66
+ async def afail(self, error=None, **kwargs):
67
+ self.reports.append(("fail", error, kwargs))
68
+
69
+
70
+ def _fake_steps(monkeypatch) -> list[_FakeStep]:
71
+ steps = []
72
+
73
+ async def astart_step(call, *, lessons=False, step_uuid=None):
74
+ del lessons, step_uuid
75
+ steps.append(_FakeStep(call))
76
+ return steps[-1]
77
+
78
+ monkeypatch.setattr(_steps, "astart_step", astart_step)
79
+ return steps
80
+
81
+
82
+ @contextmanager
83
+ def _recording_context(trace_id: str):
84
+ recording = context_manager.RecordingContext(
85
+ owner=object(),
86
+ credentials=TraceWriteCredentials(
87
+ trace_id=trace_id,
88
+ recording_id=trace_id,
89
+ _trace_write_token=f"token-{trace_id}",
90
+ ),
91
+ lesson_scope_paths=None,
92
+ )
93
+ with context_manager.bind_recording_context(recording):
94
+ yield
95
+
96
+
97
+ def test_openai_agents_contract_sdk_surface_available():
98
+ sdk = _sdk()
99
+ agent_tool = sdk["Agent"](name="Researcher", instructions="Research.").as_tool(
100
+ tool_name="researcher",
101
+ tool_description="Research a topic.",
102
+ )
103
+
104
+ assert callable(sdk["function_tool"])
105
+ assert hasattr(sdk["FunctionToolBatchExecutor"], "_execute_single_tool_body")
106
+ assert hasattr(sdk["FunctionToolBatchExecutor"], "_invoke_tool_and_run_post_invoke")
107
+ assert agent_tool._is_agent_tool is True
108
+ assert agent_tool._agent_instance.name == "Researcher"
109
+
110
+
111
+ def test_openai_agents_patch_bypasses_tracing_without_recording(monkeypatch):
112
+ sdk = _sdk()
113
+ captured = {}
114
+ steps = _fake_steps(monkeypatch)
115
+
116
+ @sdk["function_tool"]
117
+ async def get_weather(city: str) -> dict[str, str]:
118
+ captured["called_with"] = city
119
+ return {"city": city}
120
+
121
+ tool_call = sdk["ResponseFunctionToolCall"](
122
+ arguments='{"city":"Zurich"}',
123
+ call_id="call_bypass",
124
+ name="get_weather",
125
+ type="function_call",
126
+ )
127
+
128
+ openai_agents_patch_module.openai_agents_patch()
129
+
130
+ executor = _build_executor(get_weather, tool_call)
131
+ results, _, _ = asyncio.run(executor.execute())
132
+
133
+ assert captured["called_with"] == "Zurich"
134
+ assert results[0].output == {"city": "Zurich"}
135
+ assert json.loads(tool_call.arguments) == {"city": "Zurich"}
136
+ assert steps == []
137
+
138
+
139
+ def test_openai_agents_patch_records_function_tool_execution(monkeypatch):
140
+ sdk = _sdk()
141
+ captured = {}
142
+
143
+ @sdk["function_tool"]
144
+ async def get_weather(city: str) -> dict[str, str]:
145
+ captured["called_with"] = city
146
+ return {"forecast": f"sunny in {city}"}
147
+
148
+ tool_call = sdk["ResponseFunctionToolCall"](
149
+ arguments='{"city":"Zurich"}',
150
+ call_id="call_1",
151
+ name="get_weather",
152
+ type="function_call",
153
+ )
154
+
155
+ steps = _fake_steps(monkeypatch)
156
+ openai_agents_patch_module.openai_agents_patch()
157
+
158
+ executor = _build_executor(get_weather, tool_call)
159
+ with _recording_context("trace-1"):
160
+ results, _, _ = asyncio.run(executor.execute())
161
+
162
+ assert captured["called_with"] == "Zurich"
163
+ assert steps[0].call.name == "get_weather" and steps[0].call.call_id == "call_1"
164
+ assert steps[0].call.arguments == {"city": "Zurich"}
165
+ assert steps[0].reports == [("finish", {"forecast": "sunny in Zurich"}, {})]
166
+ assert results[0].output == {"forecast": "sunny in Zurich"}
167
+
168
+
169
+ def test_openai_agents_patch_records_agent_tool_as_subtrace(monkeypatch):
170
+ sdk = _sdk()
171
+ captured = {"subtraces": [], "io": []}
172
+ steps = _fake_steps(monkeypatch)
173
+ agent_tool = sdk["Agent"](name="Researcher", instructions="Research.").as_tool(
174
+ tool_name="researcher",
175
+ tool_description="Research a topic.",
176
+ )
177
+
178
+ async def run_researcher(_context, input_json):
179
+ captured["trace_id"] = context_manager.get_trace_id()
180
+ return f"researched {json.loads(input_json)['input']}"
181
+
182
+ agent_tool.on_invoke_tool = run_researcher
183
+ tool_call = sdk["ResponseFunctionToolCall"](
184
+ arguments='{"input":"Zurich"}',
185
+ call_id="call_agent",
186
+ name="researcher",
187
+ type="function_call",
188
+ )
189
+
190
+ @asynccontextmanager
191
+ async def fake_subtrace(name):
192
+ captured["subtraces"].append(name)
193
+ with _recording_context("child-trace"):
194
+ yield "child-trace"
195
+
196
+ async def fake_log_trace_io(recording, kind, value):
197
+ captured["io"].append((recording.trace_id, kind, value))
198
+
199
+ monkeypatch.setattr(openai_agents_patch_module, "subtrace", fake_subtrace)
200
+ monkeypatch.setattr(_recording, "alog_trace_io", fake_log_trace_io)
201
+
202
+ openai_agents_patch_module.openai_agents_patch()
203
+ executor = _build_executor(agent_tool, tool_call)
204
+
205
+ async def execute():
206
+ with _recording_context("parent-trace"):
207
+ results, _, _ = await executor.execute()
208
+ assert context_manager.get_trace_id() == "parent-trace"
209
+ return results
210
+
211
+ results = asyncio.run(execute())
212
+
213
+ assert captured == {
214
+ "subtraces": ["Researcher"],
215
+ "io": [
216
+ ("child-trace", "input", "Zurich"),
217
+ ("child-trace", "output", "researched Zurich"),
218
+ ],
219
+ "trace_id": "child-trace",
220
+ }
221
+ assert results[0].output == "researched Zurich"
222
+ assert steps == []
@@ -0,0 +1,268 @@
1
+ import asyncio
2
+ import json
3
+ import sys
4
+ import types
5
+ from contextlib import asynccontextmanager
6
+ from types import SimpleNamespace
7
+
8
+ from sovara import _recording
9
+ from sovara.runner import context_manager
10
+ from sovara_openai_agents import _patch as openai_agents_patch_module
11
+ from tests.fake_steps import fake_steps, recording_context
12
+
13
+
14
+ class _FakeFunctionTool:
15
+ def __init__(
16
+ self,
17
+ func,
18
+ *,
19
+ name: str = "get_weather",
20
+ namespace: str | None = None,
21
+ strict_json_schema: bool = False,
22
+ is_agent_tool: bool = False,
23
+ agent_name: str = "Research Agent",
24
+ defer_loading: bool = False,
25
+ ):
26
+ self._func = func
27
+ self.name = name
28
+ self._tool_namespace = namespace
29
+ self.strict_json_schema = strict_json_schema
30
+ self._is_agent_tool = is_agent_tool
31
+ self._agent_instance = SimpleNamespace(name=agent_name) if is_agent_tool else None
32
+ self.defer_loading = defer_loading
33
+
34
+ async def invoke(self, **kwargs):
35
+ return await self._func(**kwargs)
36
+
37
+
38
+ def _make_tool_call(
39
+ *,
40
+ arguments: str = '{"city":"Zurich"}',
41
+ call_id: str = "call_1",
42
+ namespace: str | None = None,
43
+ ):
44
+ return SimpleNamespace(
45
+ arguments=arguments,
46
+ call_id=call_id,
47
+ name="get_weather",
48
+ namespace=namespace,
49
+ type="function_call",
50
+ )
51
+
52
+
53
+ def _decode_tool_input(arguments: str) -> dict[str, object] | None:
54
+ if not arguments:
55
+ return {}
56
+ try:
57
+ parsed = json.loads(arguments)
58
+ except Exception:
59
+ return None
60
+ return parsed if isinstance(parsed, dict) else None
61
+
62
+
63
+ def _install_fake_agents_sdk(monkeypatch):
64
+ tool_execution_module = types.ModuleType("agents.run_internal.tool_execution")
65
+
66
+ async def invoke_function_tool(*, function_tool, context, arguments):
67
+ tool_input = context.tool_input
68
+ if tool_input is None:
69
+ tool_input = _decode_tool_input(arguments) or {}
70
+ return await function_tool.invoke(**tool_input)
71
+
72
+ tool_execution_module.invoke_function_tool = invoke_function_tool
73
+
74
+ class FakeBatchExecutor:
75
+ def __init__(self, *, func_tool, tool_call):
76
+ self.agent = SimpleNamespace(name="Tool runner")
77
+ self.func_tool = func_tool
78
+ self.tool_call = tool_call
79
+
80
+ async def _execute_single_tool_body(
81
+ self,
82
+ *,
83
+ outer_task,
84
+ task_state,
85
+ func_tool,
86
+ tool_call,
87
+ tool_context,
88
+ agent_hooks,
89
+ ):
90
+ del outer_task, task_state, func_tool, tool_call, tool_context, agent_hooks
91
+ return None
92
+
93
+ async def _invoke_tool_and_run_post_invoke(
94
+ self,
95
+ *,
96
+ outer_task,
97
+ task_state,
98
+ func_tool,
99
+ tool_call,
100
+ tool_context,
101
+ agent_hooks,
102
+ ):
103
+ del outer_task, task_state, tool_call, agent_hooks
104
+ return await tool_execution_module.invoke_function_tool(
105
+ function_tool=func_tool,
106
+ context=tool_context,
107
+ arguments=tool_context.tool_arguments,
108
+ )
109
+
110
+ async def execute(self):
111
+ task_state = SimpleNamespace()
112
+ tool_context = SimpleNamespace(
113
+ tool_arguments=str(getattr(self.tool_call, "arguments", "") or ""),
114
+ tool_call=self.tool_call,
115
+ tool_input=_decode_tool_input(str(getattr(self.tool_call, "arguments", "") or "")),
116
+ )
117
+ await self._execute_single_tool_body(
118
+ outer_task=None,
119
+ task_state=task_state,
120
+ func_tool=self.func_tool,
121
+ tool_call=self.tool_call,
122
+ tool_context=tool_context,
123
+ agent_hooks=None,
124
+ )
125
+ final_result = await self._invoke_tool_and_run_post_invoke(
126
+ outer_task=None,
127
+ task_state=task_state,
128
+ func_tool=self.func_tool,
129
+ tool_call=self.tool_call,
130
+ tool_context=tool_context,
131
+ agent_hooks=None,
132
+ )
133
+ return [SimpleNamespace(output=final_result)], None, None
134
+
135
+ tool_execution_module._FunctionToolBatchExecutor = FakeBatchExecutor
136
+
137
+ agents_module = types.ModuleType("agents")
138
+ agents_module.__path__ = []
139
+ trace_internal_module = types.ModuleType("agents.run_internal")
140
+ trace_internal_module.__path__ = []
141
+ trace_internal_module.tool_execution = tool_execution_module
142
+ agents_module.run_internal = trace_internal_module
143
+
144
+ monkeypatch.setitem(sys.modules, "agents", agents_module)
145
+ monkeypatch.setitem(sys.modules, "agents.run_internal", trace_internal_module)
146
+ monkeypatch.setitem(sys.modules, "agents.run_internal.tool_execution", tool_execution_module)
147
+
148
+ if hasattr(FakeBatchExecutor, "__sovara_openai_agents_patched__"):
149
+ delattr(FakeBatchExecutor, "__sovara_openai_agents_patched__")
150
+
151
+ return FakeBatchExecutor
152
+
153
+
154
+ def test_openai_agents_patch_bypasses_tracing_without_recording(monkeypatch):
155
+ BatchExecutor = _install_fake_agents_sdk(monkeypatch)
156
+ steps = fake_steps(monkeypatch)
157
+ captured = {}
158
+
159
+ async def get_weather(city: str):
160
+ captured["called_with"] = city
161
+ return {"city": city}
162
+
163
+ tool = _FakeFunctionTool(get_weather)
164
+ tool_call = _make_tool_call(call_id="call_bypass")
165
+ openai_agents_patch_module.openai_agents_patch()
166
+
167
+ executor = BatchExecutor(func_tool=tool, tool_call=tool_call)
168
+ results, _, _ = asyncio.run(executor.execute())
169
+
170
+ assert captured["called_with"] == "Zurich"
171
+ assert results[0].output == {"city": "Zurich"}
172
+ assert json.loads(tool_call.arguments) == {"city": "Zurich"}
173
+ assert steps == []
174
+
175
+
176
+ def test_openai_agents_patch_records_function_tool_execution(monkeypatch):
177
+ BatchExecutor = _install_fake_agents_sdk(monkeypatch)
178
+ steps = fake_steps(monkeypatch)
179
+
180
+ async def get_weather(city: str):
181
+ return {"forecast": f"sunny in {city}"}
182
+
183
+ tool = _FakeFunctionTool(get_weather)
184
+ tool_call = _make_tool_call(call_id="call_1")
185
+ openai_agents_patch_module.openai_agents_patch()
186
+
187
+ executor = BatchExecutor(func_tool=tool, tool_call=tool_call)
188
+ with recording_context():
189
+ results, _, _ = asyncio.run(executor.execute())
190
+
191
+ assert results[0].output == {"forecast": "sunny in Zurich"}
192
+ assert steps[0].call.name == "get_weather" and steps[0].call.call_id == "call_1"
193
+ assert steps[0].call.arguments == {"city": "Zurich"}
194
+ assert (steps[0].ending, steps[0].output) == ("finish", {"forecast": "sunny in Zurich"})
195
+
196
+
197
+ def test_openai_agents_patch_fails_the_step_when_the_tool_raises(monkeypatch):
198
+ BatchExecutor = _install_fake_agents_sdk(monkeypatch)
199
+ steps = fake_steps(monkeypatch)
200
+
201
+ async def get_weather(city: str):
202
+ raise ValueError(city)
203
+
204
+ openai_agents_patch_module.openai_agents_patch()
205
+ executor = BatchExecutor(func_tool=_FakeFunctionTool(get_weather), tool_call=_make_tool_call())
206
+ with recording_context():
207
+ try:
208
+ asyncio.run(executor.execute())
209
+ except ValueError:
210
+ pass
211
+
212
+ assert steps[0].ending == "fail"
213
+ assert isinstance(steps[0].reports[0][1], ValueError)
214
+
215
+
216
+ def test_openai_agents_patch_records_agent_tool_as_subtrace(monkeypatch):
217
+ BatchExecutor = _install_fake_agents_sdk(monkeypatch)
218
+ steps = fake_steps(monkeypatch)
219
+ captured = {"subtraces": [], "io": []}
220
+
221
+ async def run_researcher(input: str):
222
+ captured["trace_id"] = context_manager.get_trace_id()
223
+ return f"researched {input}"
224
+
225
+ tool = _FakeFunctionTool(
226
+ run_researcher,
227
+ name="researcher",
228
+ is_agent_tool=True,
229
+ agent_name="Researcher",
230
+ )
231
+ tool_call = _make_tool_call(
232
+ arguments='{"input":"Zurich"}',
233
+ call_id="call_agent",
234
+ )
235
+
236
+ @asynccontextmanager
237
+ async def fake_subtrace(name):
238
+ captured["subtraces"].append(name)
239
+ with recording_context("child-trace"):
240
+ yield "child-trace"
241
+
242
+ async def fake_log_trace_io(recording, kind, value):
243
+ captured["io"].append((recording.trace_id, kind, value))
244
+
245
+ monkeypatch.setattr(openai_agents_patch_module, "subtrace", fake_subtrace)
246
+ monkeypatch.setattr(_recording, "alog_trace_io", fake_log_trace_io)
247
+
248
+ openai_agents_patch_module.openai_agents_patch()
249
+ executor = BatchExecutor(func_tool=tool, tool_call=tool_call)
250
+
251
+ async def execute():
252
+ with recording_context("parent-trace"):
253
+ results, _, _ = await executor.execute()
254
+ assert context_manager.get_trace_id() == "parent-trace"
255
+ return results
256
+
257
+ results = asyncio.run(execute())
258
+
259
+ assert captured == {
260
+ "subtraces": ["Researcher"],
261
+ "io": [
262
+ ("child-trace", "input", "Zurich"),
263
+ ("child-trace", "output", "researched Zurich"),
264
+ ],
265
+ "trace_id": "child-trace",
266
+ }
267
+ assert results[0].output == "researched Zurich"
268
+ assert steps == []
@@ -0,0 +1,197 @@
1
+ """Package tests that need no Sovara server; recording tests live in the Sovara repository."""
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ import types
7
+ from types import SimpleNamespace
8
+
9
+ from sovara_openai_agents import _patch as openai_agents_patch_module
10
+
11
+
12
+ class _FakeFunctionTool:
13
+ def __init__(
14
+ self,
15
+ func,
16
+ *,
17
+ name: str = "get_weather",
18
+ namespace: str | None = None,
19
+ strict_json_schema: bool = False,
20
+ is_agent_tool: bool = False,
21
+ agent_name: str = "Research Agent",
22
+ defer_loading: bool = False,
23
+ ):
24
+ self._func = func
25
+ self.name = name
26
+ self._tool_namespace = namespace
27
+ self.strict_json_schema = strict_json_schema
28
+ self._is_agent_tool = is_agent_tool
29
+ self._agent_instance = SimpleNamespace(name=agent_name) if is_agent_tool else None
30
+ self.defer_loading = defer_loading
31
+
32
+ async def invoke(self, **kwargs):
33
+ return await self._func(**kwargs)
34
+
35
+
36
+ def _make_tool_call(
37
+ *,
38
+ arguments: str = '{"city":"Zurich"}',
39
+ call_id: str = "call_1",
40
+ namespace: str | None = None,
41
+ ):
42
+ return SimpleNamespace(
43
+ arguments=arguments,
44
+ call_id=call_id,
45
+ name="get_weather",
46
+ namespace=namespace,
47
+ type="function_call",
48
+ )
49
+
50
+
51
+ def _decode_tool_input(arguments: str) -> dict[str, object] | None:
52
+ if not arguments:
53
+ return {}
54
+ try:
55
+ parsed = json.loads(arguments)
56
+ except Exception:
57
+ return None
58
+ return parsed if isinstance(parsed, dict) else None
59
+
60
+
61
+ def _install_fake_agents_sdk(monkeypatch):
62
+ tool_execution_module = types.ModuleType("agents.run_internal.tool_execution")
63
+
64
+ async def invoke_function_tool(*, function_tool, context, arguments):
65
+ tool_input = context.tool_input
66
+ if tool_input is None:
67
+ tool_input = _decode_tool_input(arguments) or {}
68
+ return await function_tool.invoke(**tool_input)
69
+
70
+ tool_execution_module.invoke_function_tool = invoke_function_tool # pyright: ignore[reportAttributeAccessIssue]
71
+
72
+ class FakeBatchExecutor:
73
+ def __init__(self, *, func_tool, tool_call):
74
+ self.agent = SimpleNamespace(name="Tool runner")
75
+ self.func_tool = func_tool
76
+ self.tool_call = tool_call
77
+
78
+ async def _execute_single_tool_body(
79
+ self,
80
+ *,
81
+ outer_task,
82
+ task_state,
83
+ func_tool,
84
+ tool_call,
85
+ tool_context,
86
+ agent_hooks,
87
+ ):
88
+ del outer_task, task_state, func_tool, tool_call, tool_context, agent_hooks
89
+ return None
90
+
91
+ async def _invoke_tool_and_run_post_invoke(
92
+ self,
93
+ *,
94
+ outer_task,
95
+ task_state,
96
+ func_tool,
97
+ tool_call,
98
+ tool_context,
99
+ agent_hooks,
100
+ ):
101
+ del outer_task, task_state, tool_call, agent_hooks
102
+ return await tool_execution_module.invoke_function_tool(
103
+ function_tool=func_tool,
104
+ context=tool_context,
105
+ arguments=tool_context.tool_arguments,
106
+ )
107
+
108
+ async def execute(self):
109
+ task_state = SimpleNamespace()
110
+ tool_context = SimpleNamespace(
111
+ tool_arguments=str(getattr(self.tool_call, "arguments", "") or ""),
112
+ tool_call=self.tool_call,
113
+ tool_input=_decode_tool_input(str(getattr(self.tool_call, "arguments", "") or "")),
114
+ )
115
+ await self._execute_single_tool_body(
116
+ outer_task=None,
117
+ task_state=task_state,
118
+ func_tool=self.func_tool,
119
+ tool_call=self.tool_call,
120
+ tool_context=tool_context,
121
+ agent_hooks=None,
122
+ )
123
+ final_result = await self._invoke_tool_and_run_post_invoke(
124
+ outer_task=None,
125
+ task_state=task_state,
126
+ func_tool=self.func_tool,
127
+ tool_call=self.tool_call,
128
+ tool_context=tool_context,
129
+ agent_hooks=None,
130
+ )
131
+ return [SimpleNamespace(output=final_result)], None, None
132
+
133
+ tool_execution_module._FunctionToolBatchExecutor = FakeBatchExecutor # pyright: ignore[reportAttributeAccessIssue]
134
+
135
+ agents_module = types.ModuleType("agents")
136
+ agents_module.__path__ = []
137
+ trace_internal_module = types.ModuleType("agents.run_internal")
138
+ trace_internal_module.__path__ = []
139
+ trace_internal_module.tool_execution = tool_execution_module # pyright: ignore[reportAttributeAccessIssue]
140
+ agents_module.run_internal = trace_internal_module # pyright: ignore[reportAttributeAccessIssue]
141
+
142
+ monkeypatch.setitem(sys.modules, "agents", agents_module)
143
+ monkeypatch.setitem(sys.modules, "agents.run_internal", trace_internal_module)
144
+ monkeypatch.setitem(sys.modules, "agents.run_internal.tool_execution", tool_execution_module)
145
+
146
+ if hasattr(FakeBatchExecutor, "__sovara_openai_agents_patched__"):
147
+ delattr(FakeBatchExecutor, "__sovara_openai_agents_patched__")
148
+
149
+ return FakeBatchExecutor
150
+
151
+
152
+ def test_tool_call_keeps_raw_arguments_when_not_json_object():
153
+ async def get_weather(**kwargs):
154
+ return kwargs
155
+
156
+ tool = _FakeFunctionTool(get_weather)
157
+ tool_call = _make_tool_call(arguments='["Zurich"]', call_id="call_invalid")
158
+
159
+ call = openai_agents_patch_module._tool_call(tool, tool_call)
160
+
161
+ assert (call.name, call.call_id, call.arguments) == ("get_weather", "call_invalid", None)
162
+ assert call.metadata["tool_arguments_raw"] == '["Zurich"]'
163
+ assert call.metadata["arguments_error"] == "Tool arguments must decode to a JSON object."
164
+
165
+
166
+ def test_tool_call_uses_tool_namespace_for_qualified_tool_name():
167
+ async def get_weather(**kwargs):
168
+ return kwargs
169
+
170
+ tool = _FakeFunctionTool(get_weather, namespace="weather")
171
+ tool_call = _make_tool_call(call_id="call_namespaced")
172
+
173
+ call = openai_agents_patch_module._tool_call(tool, tool_call)
174
+
175
+ assert call.name == "weather.get_weather"
176
+ assert call.metadata["tool_namespace"] == "weather"
177
+ assert call.arguments == {"city": "Zurich"}
178
+
179
+
180
+ def test_openai_agents_patch_bypasses_tracing_without_recording(monkeypatch):
181
+ BatchExecutor = _install_fake_agents_sdk(monkeypatch)
182
+ captured = {}
183
+
184
+ async def get_weather(city: str):
185
+ captured["called_with"] = city
186
+ return {"city": city}
187
+
188
+ tool = _FakeFunctionTool(get_weather)
189
+ tool_call = _make_tool_call(call_id="call_bypass")
190
+ openai_agents_patch_module.openai_agents_patch()
191
+
192
+ executor = BatchExecutor(func_tool=tool, tool_call=tool_call)
193
+ results, _, _ = asyncio.run(executor.execute())
194
+
195
+ assert captured["called_with"] == "Zurich"
196
+ assert results[0].output == {"city": "Zurich"}
197
+ assert json.loads(tool_call.arguments) == {"city": "Zurich"}