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.
- sovara_openai_agents-0.1.0/LICENSE +21 -0
- sovara_openai_agents-0.1.0/PKG-INFO +38 -0
- sovara_openai_agents-0.1.0/README.md +24 -0
- sovara_openai_agents-0.1.0/pyproject.toml +58 -0
- sovara_openai_agents-0.1.0/setup.cfg +4 -0
- sovara_openai_agents-0.1.0/src/sovara_openai_agents/__init__.py +17 -0
- sovara_openai_agents-0.1.0/src/sovara_openai_agents/_patch.py +231 -0
- sovara_openai_agents-0.1.0/src/sovara_openai_agents.egg-info/PKG-INFO +38 -0
- sovara_openai_agents-0.1.0/src/sovara_openai_agents.egg-info/SOURCES.txt +13 -0
- sovara_openai_agents-0.1.0/src/sovara_openai_agents.egg-info/dependency_links.txt +1 -0
- sovara_openai_agents-0.1.0/src/sovara_openai_agents.egg-info/requires.txt +2 -0
- sovara_openai_agents-0.1.0/src/sovara_openai_agents.egg-info/top_level.txt +1 -0
- sovara_openai_agents-0.1.0/tests/test_contract.py +222 -0
- sovara_openai_agents-0.1.0/tests/test_recording.py +268 -0
- sovara_openai_agents-0.1.0/tests/test_sovara_openai_agents.py +197 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sovara_openai_agents
|
|
@@ -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"}
|