Repository navigation
Expand file tree
/
Copy pathrunner.py
More file actions
285 lines (239 loc) · 9.97 KB
/
Copy pathrunner.py
File metadata and controls
285 lines (239 loc) · 9.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
"""AgentRunner implementation backed by the OpenAI Agents SDK.
Each registered MCP server is connected as a stdio MCP server so the OpenAI
agent can call IoT / FMSR / TSFM / utilities tools directly via MCP.
Usage::
import anyio
from agent.openai_agent import OpenAIAgentRunner
runner = OpenAIAgentRunner(model="litellm_proxy/azure/gpt-5.4")
result = anyio.run(runner.run, "What sensors are on Chiller 6?")
print(result.answer)
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import time
from contextlib import AsyncExitStack
from pathlib import Path
from openai import AsyncOpenAI
from agents import (
Agent,
ModelProvider,
OpenAIChatCompletionsModel,
RunConfig,
Runner,
set_tracing_disabled,
)
from agents.mcp import MCPServerStdio
from observability import agent_run_span, persist_trajectory
from llm.routers import resolve_model, resolve_router_creds
from .._prompts import AGENT_SYSTEM_PROMPT
from ..models import AgentResult, ToolCall, Trajectory, TurnRecord
from ..runner import AgentRunner
_log = logging.getLogger(__name__)
_DEFAULT_MODEL = "litellm_proxy/azure/gpt-5.4"
def _build_run_config(model_id: str) -> RunConfig | None:
"""Build a RunConfig with a LiteLLM model provider when needed.
When *model_id* starts with a proxy-router prefix (``litellm_proxy/`` or
``tokenrouter/``), creates an :class:`AsyncOpenAI` client pointing at that
router's OpenAI-compatible endpoint (credentials from the router's env
vars) and wraps it in :class:`OpenAIChatCompletionsModel`.
Returns ``None`` for direct OpenAI API usage.
"""
creds = resolve_router_creds(model_id)
if creds is None:
return None
resolved = resolve_model(model_id)
client = AsyncOpenAI(base_url=creds.base_url, api_key=creds.api_key)
set_tracing_disabled(disabled=True)
class _LiteLLMModelProvider(ModelProvider):
def get_model(self, model_name: str | None):
return OpenAIChatCompletionsModel(
model=model_name or resolved,
openai_client=client,
)
return RunConfig(model_provider=_LiteLLMModelProvider())
def _build_mcp_servers(
server_paths: dict[str, Path | str],
) -> list[MCPServerStdio]:
"""Convert server_paths entries into MCPServerStdio instances.
Entry-point names (str without path separators) become
``MCPServerStdio(command="uv", args=["run", name])``.
Path objects become ``MCPServerStdio(command="uv", args=["run", str(path)])``.
"""
servers: list[MCPServerStdio] = []
for name, spec in server_paths.items():
cmd_arg = str(spec) if isinstance(spec, Path) else spec
servers.append(
MCPServerStdio(
name=name,
params={
"command": "uv",
"args": ["run", cmd_arg],
},
cache_tools_list=True,
)
)
return servers
def _build_trajectory(result) -> Trajectory:
"""Extract a Trajectory from a Runner.run result.
Walks ``result.new_items`` to collect text messages, tool calls, and
tool outputs. Token usage is pulled from ``result.raw_responses``.
"""
trajectory = Trajectory()
turn_index = 0
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
def _flush() -> None:
nonlocal text_parts, tool_calls, turn_index
if not text_parts and not tool_calls:
return
trajectory.turns.append(
TurnRecord(
index=turn_index,
text="".join(text_parts),
tool_calls=list(tool_calls),
)
)
turn_index += 1
text_parts = []
tool_calls = []
for item in result.new_items:
item_type = getattr(item, "type", "")
if item_type == "message_output_item":
# Flush any pending tool calls from previous turn
_flush()
raw = getattr(item, "raw_item", None)
if raw:
content = getattr(raw, "content", None) or []
for part in content:
if hasattr(part, "text"):
text_parts.append(part.text)
elif item_type == "tool_call_item":
raw = getattr(item, "raw_item", None)
if raw:
tc_name = getattr(raw, "name", "") or ""
tc_id = getattr(raw, "call_id", "") or getattr(raw, "id", "") or ""
tc_args = getattr(raw, "arguments", "{}") or "{}"
try:
tc_input = (
json.loads(tc_args) if isinstance(tc_args, str) else tc_args
)
except (json.JSONDecodeError, TypeError):
tc_input = {"raw": tc_args}
tool_calls.append(ToolCall(name=tc_name, input=tc_input, id=tc_id))
elif item_type == "tool_call_output_item":
output = getattr(item, "output", None)
# Attach output to the last matching tool call
if tool_calls:
tool_calls[-1].output = output
# Flush remaining
_flush()
# Distribute token usage from raw_responses across turns
raw_responses = getattr(result, "raw_responses", []) or []
for i, resp in enumerate(raw_responses):
usage = getattr(resp, "usage", None)
if usage and i < len(trajectory.turns):
trajectory.turns[i].input_tokens = getattr(usage, "input_tokens", 0) or 0
trajectory.turns[i].output_tokens = getattr(usage, "output_tokens", 0) or 0
return trajectory
class OpenAIAgentRunner(AgentRunner):
"""Agent runner that delegates to the OpenAI Agents SDK agentic loop.
The SDK handles tool discovery, invocation, and multi-turn conversation
against the registered MCP servers.
Routes all requests through a LiteLLM proxy via the ``litellm_proxy/``
proxy-router prefix ``litellm_proxy/`` or ``tokenrouter/`` (requires the
matching ``*_BASE_URL`` / ``*_API_KEY`` env vars).
Args:
llm: Unused — OpenAIAgentRunner uses the OpenAI Agents SDK directly.
Accepted for interface compatibility with ``AgentRunner``.
server_paths: MCP server specs identical to ``PlanExecuteRunner``.
Defaults to all registered servers.
model: LiteLLM model string with ``litellm_proxy/`` prefix
(default: ``litellm_proxy/azure/gpt-5.4``).
max_turns: Maximum agentic loop turns (default: 30).
"""
def __init__(
self,
llm=None,
server_paths: dict[str, Path | str] | None = None,
model: str = _DEFAULT_MODEL,
max_turns: int = 30,
) -> None:
super().__init__(llm, server_paths)
self._model_id = model
self._model = resolve_model(model)
self._run_config = _build_run_config(model)
self._max_turns = max_turns
async def run(self, question: str) -> AgentResult:
"""Run the OpenAI Agents SDK loop for *question*.
Args:
question: Natural-language question to answer.
Returns:
AgentResult with the final answer and full execution trajectory.
"""
with agent_run_span(
"openai-agent", model=self._model_id, question=question
) as span:
run_started = time.perf_counter()
started_at = _dt.datetime.now(_dt.UTC).isoformat()
mcp_servers = _build_mcp_servers(self._server_paths)
# AsyncExitStack enters every server and closes them in LIFO order
# on exit (success or exception).
async with AsyncExitStack() as stack:
active_servers = [
await stack.enter_async_context(s) for s in mcp_servers
]
agent = Agent(
name="AssetOps Assistant",
instructions=AGENT_SYSTEM_PROMPT,
mcp_servers=active_servers,
model=self._model,
)
_log.info(
"OpenAIAgentRunner: starting query (model=%s, servers=%d)",
self._model,
len(active_servers),
)
run_kwargs: dict = dict(max_turns=self._max_turns)
if self._run_config is not None:
run_kwargs["run_config"] = self._run_config
result = await Runner.run(
agent,
question,
**run_kwargs,
)
answer = result.final_output or ""
trajectory = _build_trajectory(result)
trajectory.started_at = started_at
_log.info(
"OpenAIAgentRunner: done (turns=%d, input_tokens=%d, "
"output_tokens=%d)",
len(trajectory.turns),
trajectory.total_input_tokens,
trajectory.total_output_tokens,
)
span.set_attribute("agent.answer.length", len(answer))
span.set_attribute(
"gen_ai.usage.input_tokens", trajectory.total_input_tokens
)
span.set_attribute(
"gen_ai.usage.output_tokens", trajectory.total_output_tokens
)
span.set_attribute("agent.turns", len(trajectory.turns))
span.set_attribute("agent.tool_calls", len(trajectory.all_tool_calls))
span.set_attribute(
"agent.duration_ms", (time.perf_counter() - run_started) * 1000
)
persist_trajectory(
runner_name="openai-agent",
model=self._model_id,
question=question,
answer=answer,
trajectory=trajectory,
)
return AgentResult(
question=question,
answer=answer,
trajectory=trajectory,
)