agentdeck-sdk 3.1.0__py3-none-any.whl
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.
- agentdeck/README.md +50 -0
- agentdeck/__init__.py +51 -0
- agentdeck/adapters/__init__.py +5 -0
- agentdeck/adapters/control/__init__.py +1 -0
- agentdeck/adapters/control/memory/__init__.py +5 -0
- agentdeck/adapters/control/memory/port.py +25 -0
- agentdeck/adapters/control/sqlite/__init__.py +5 -0
- agentdeck/adapters/control/sqlite/port.py +111 -0
- agentdeck/adapters/engines/__init__.py +1 -0
- agentdeck/adapters/engines/langgraph/__init__.py +8 -0
- agentdeck/adapters/engines/langgraph/checkpointer.py +205 -0
- agentdeck/adapters/engines/langgraph/engine.py +410 -0
- agentdeck/adapters/engines/openai_agents/__init__.py +9 -0
- agentdeck/adapters/engines/openai_agents/engine.py +321 -0
- agentdeck/adapters/engines/openai_agents/reconcile.py +178 -0
- agentdeck/adapters/engines/openai_agents/runconfig.py +118 -0
- agentdeck/adapters/engines/openai_agents/sessions.py +100 -0
- agentdeck/adapters/engines/openai_agents/translate.py +124 -0
- agentdeck/adapters/engines/stub/__init__.py +5 -0
- agentdeck/adapters/engines/stub/engine.py +100 -0
- agentdeck/adapters/stores/__init__.py +1 -0
- agentdeck/adapters/stores/memory/__init__.py +5 -0
- agentdeck/adapters/stores/memory/store.py +148 -0
- agentdeck/adapters/stores/postgres/__init__.py +5 -0
- agentdeck/adapters/stores/postgres/store.py +374 -0
- agentdeck/adapters/stores/redis/__init__.py +5 -0
- agentdeck/adapters/stores/redis/store.py +359 -0
- agentdeck/adapters/stores/sqlite/__init__.py +5 -0
- agentdeck/adapters/stores/sqlite/store.py +338 -0
- agentdeck/adapters/telemetry/__init__.py +1 -0
- agentdeck/adapters/telemetry/langfuse/__init__.py +18 -0
- agentdeck/adapters/telemetry/langfuse/client.py +180 -0
- agentdeck/adapters/telemetry/langfuse/sink.py +366 -0
- agentdeck/adapters/telemetry/langfuse/trace.py +88 -0
- agentdeck/adapters/tools/__init__.py +1 -0
- agentdeck/adapters/tools/mcp/__init__.py +18 -0
- agentdeck/adapters/tools/mcp/lifecycle.py +178 -0
- agentdeck/adapters/tools/mcp/source.py +47 -0
- agentdeck/adapters/tools/mcp/transport.py +234 -0
- agentdeck/adapters/tools/mcp/wiring.py +63 -0
- agentdeck/authoring/__init__.py +22 -0
- agentdeck/authoring/agent.py +169 -0
- agentdeck/authoring/compile.py +250 -0
- agentdeck/authoring/graphs.py +145 -0
- agentdeck/authoring/hooks.py +117 -0
- agentdeck/authoring/injection.py +233 -0
- agentdeck/authoring/instructions.py +80 -0
- agentdeck/authoring/interrupts.py +37 -0
- agentdeck/authoring/nodes.py +140 -0
- agentdeck/authoring/runners/__init__.py +6 -0
- agentdeck/authoring/runners/agent.py +171 -0
- agentdeck/authoring/runners/workflow.py +99 -0
- agentdeck/authoring/skills.py +56 -0
- agentdeck/authoring/state.py +44 -0
- agentdeck/authoring/timers.py +44 -0
- agentdeck/authoring/tools.py +147 -0
- agentdeck/authoring/web_search.py +43 -0
- agentdeck/authoring/workflow.py +266 -0
- agentdeck/cli.py +56 -0
- agentdeck/composition.py +213 -0
- agentdeck/core/__init__.py +110 -0
- agentdeck/core/base.py +47 -0
- agentdeck/core/content.py +166 -0
- agentdeck/core/context.py +136 -0
- agentdeck/core/control.py +150 -0
- agentdeck/core/events.py +468 -0
- agentdeck/core/invocable.py +38 -0
- agentdeck/core/ports/__init__.py +26 -0
- agentdeck/core/ports/control.py +35 -0
- agentdeck/core/ports/engine.py +66 -0
- agentdeck/core/ports/sink.py +53 -0
- agentdeck/core/ports/store.py +170 -0
- agentdeck/core/ports/tools.py +57 -0
- agentdeck/core/reporting.py +75 -0
- agentdeck/core/status.py +75 -0
- agentdeck/deck.py +894 -0
- agentdeck/errors.py +67 -0
- agentdeck/mcp.py +82 -0
- agentdeck/observers.py +111 -0
- agentdeck/py.typed +0 -0
- agentdeck/runtime/__init__.py +1 -0
- agentdeck/runtime/capture.py +32 -0
- agentdeck/runtime/config.default.yaml +38 -0
- agentdeck/runtime/discovery.py +175 -0
- agentdeck/runtime/dispatch.py +440 -0
- agentdeck/runtime/registry.py +173 -0
- agentdeck/runtime/service.py +671 -0
- agentdeck/runtime/settings.py +568 -0
- agentdeck/serve.py +330 -0
- agentdeck/skills/__init__.py +114 -0
- agentdeck/skills/bundle.py +65 -0
- agentdeck/surfaces/__init__.py +4 -0
- agentdeck/surfaces/cli/__init__.py +7 -0
- agentdeck/surfaces/cli/chat.py +88 -0
- agentdeck/surfaces/serve/__init__.py +7 -0
- agentdeck/surfaces/serve/app.py +71 -0
- agentdeck/surfaces/serve/compat.py +212 -0
- agentdeck/surfaces/serve/workflows.py +69 -0
- agentdeck/testing.py +364 -0
- agentdeck_sdk-3.1.0.dist-info/METADATA +222 -0
- agentdeck_sdk-3.1.0.dist-info/RECORD +104 -0
- agentdeck_sdk-3.1.0.dist-info/WHEEL +4 -0
- agentdeck_sdk-3.1.0.dist-info/entry_points.txt +3 -0
- agentdeck_sdk-3.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""The openai-agents engine: ``EnginePort`` over ``agents.Runner``.
|
|
2
|
+
|
|
3
|
+
``spec.native`` is the pre-built ``agents.Agent`` (handoffs and tools included) — this
|
|
4
|
+
adapter only runs it and translates its stream, per ``core/ports/engine.py``. Execution
|
|
5
|
+
state (the SDK session) is engine-private (ADR-D5): the session, not the log, is what
|
|
6
|
+
feeds the model. The log passed in as ``history`` is read for exactly one purpose — the
|
|
7
|
+
turn-start reconciliation in ``reconcile.py``, which repairs a session left behind by a
|
|
8
|
+
crash between the log write and the session write.
|
|
9
|
+
|
|
10
|
+
Input is multimodal (``_to_sdk_input`` maps ``TextBlock``/``ImageBlock``/``AudioBlock`` onto the
|
|
11
|
+
SDK's own canonical parts); output is not. Nothing in this run loop produces an image or audio
|
|
12
|
+
block — ``_run_completed`` only ever builds ``TextBlock``/``DataBlock`` — so an agent can *see*
|
|
13
|
+
a photo or a voice note and never *return* one. Audio is chat-completions only: at the pinned
|
|
14
|
+
``openai-agents==0.17.0``/``openai==2.32.0``, the Responses API's content list has no audio
|
|
15
|
+
member, so an ``AudioBlock`` under ``use_responses=True`` raises rather than reaching the
|
|
16
|
+
endpoint and coming back as an opaque 400.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import dataclasses
|
|
22
|
+
import json
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from contextlib import AbstractAsyncContextManager, aclosing, asynccontextmanager, nullcontext
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
27
|
+
|
|
28
|
+
from agents import Agent, Runner
|
|
29
|
+
from pydantic import BaseModel
|
|
30
|
+
|
|
31
|
+
from agentdeck.adapters.engines.openai_agents.reconcile import reconcile
|
|
32
|
+
from agentdeck.adapters.engines.openai_agents.runconfig import RunSettings, build_run_config
|
|
33
|
+
from agentdeck.adapters.engines.openai_agents.sessions import ExecutionStore
|
|
34
|
+
from agentdeck.adapters.engines.openai_agents.translate import translate
|
|
35
|
+
from agentdeck.core.content import AudioBlock, DataBlock, ImageBlock, TextBlock, coerce_input
|
|
36
|
+
from agentdeck.core.control import ControlSignalled
|
|
37
|
+
from agentdeck.core.events import RunCompleted, Usage, UsageReported
|
|
38
|
+
from agentdeck.core.ports import EnginePort
|
|
39
|
+
from agentdeck.errors import ConfigError
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Sequence
|
|
43
|
+
|
|
44
|
+
from agents.items import TResponseInputItem
|
|
45
|
+
from agents.memory.session import Session
|
|
46
|
+
from agents.result import RunResultStreaming
|
|
47
|
+
from agents.usage import Usage as SDKUsage
|
|
48
|
+
|
|
49
|
+
from agentdeck.core.content import ContentBlock, Input
|
|
50
|
+
from agentdeck.core.context import RunContext
|
|
51
|
+
from agentdeck.core.events import Event, KnownPayload
|
|
52
|
+
from agentdeck.core.invocable import InvocableSpec
|
|
53
|
+
|
|
54
|
+
SandboxScope = Callable[[Agent[Any]], AbstractAsyncContextManager[Any]]
|
|
55
|
+
"""How this engine opens whatever sandbox an agent needs: given the agent, a scope yielding
|
|
56
|
+
the SDK ``sandbox`` handle for its run (or ``None``).
|
|
57
|
+
|
|
58
|
+
Injected rather than built here because a sandbox is a capability, not an engine concern —
|
|
59
|
+
it becomes a port of its own in the next slice. Unset means no agent in this project needs
|
|
60
|
+
one, which is every code-first caller until it says otherwise."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class Launch:
|
|
65
|
+
"""One run's SDK handle, plus whether this engine reached its terminal payload.
|
|
66
|
+
|
|
67
|
+
``finished`` exists because nothing on the SDK result can answer that question at the
|
|
68
|
+
moment it is asked: a run abandoned mid-stream and a run that ended normally both arrive
|
|
69
|
+
at ``_launch``'s exit already cancelled, so ``is_complete`` is true either way, and
|
|
70
|
+
``final_output`` is only *usually* absent from the abandoned one (the SDK's run loop is
|
|
71
|
+
detached, so it may well have finished while nobody was reading). The engine's own control
|
|
72
|
+
flow is the authority, and this is how it says so.
|
|
73
|
+
|
|
74
|
+
It is the *engine's* view, not the log's, and cannot be made the log's: it is set before the
|
|
75
|
+
terminal payload is yielded, because the Runtime breaks on that payload and never returns
|
|
76
|
+
here. So a store that rejects the terminal append leaves this ``True`` while the log ends in
|
|
77
|
+
``run.failed`` — an observability span reporting success for a run the log calls failed. The
|
|
78
|
+
log is the record; a reader reconciling the two believes the log.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
result: RunResultStreaming
|
|
82
|
+
finished: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class OpenAIAgentsEngine(EnginePort):
|
|
86
|
+
"""Plays ``spec.native`` (an ``agents.Agent``) through ``Runner.run_streamed``.
|
|
87
|
+
|
|
88
|
+
Everything a run is configured with arrives here already resolved — ``sessions`` is the
|
|
89
|
+
conversation memory (Redis-backed or local), ``settings`` the endpoint and limits, and
|
|
90
|
+
``sandbox`` the scope an agent that needs one runs inside. All three default to the
|
|
91
|
+
SDK's own behavior, so ``OpenAIAgentsEngine()`` still runs an agent that configured
|
|
92
|
+
itself.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
engine: ClassVar[str] = "openai-agents"
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
sessions: ExecutionStore | None = None,
|
|
100
|
+
*,
|
|
101
|
+
settings: RunSettings | None = None,
|
|
102
|
+
sandbox: SandboxScope | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self._sessions = sessions or ExecutionStore()
|
|
105
|
+
self._settings = settings or RunSettings()
|
|
106
|
+
self._sandbox = sandbox
|
|
107
|
+
|
|
108
|
+
async def start(
|
|
109
|
+
self,
|
|
110
|
+
spec: InvocableSpec,
|
|
111
|
+
input: Input,
|
|
112
|
+
history: Sequence[Event],
|
|
113
|
+
ctx: RunContext,
|
|
114
|
+
) -> AsyncGenerator[KnownPayload, None]:
|
|
115
|
+
agent = _agent_of(spec)
|
|
116
|
+
session = self._session(ctx)
|
|
117
|
+
if session is not None:
|
|
118
|
+
diverged = await reconcile(session, history)
|
|
119
|
+
if diverged is not None:
|
|
120
|
+
# Two stores disagreeing is worth a place in the record, not just a log line;
|
|
121
|
+
# the run itself still has the session it needs and plays on.
|
|
122
|
+
yield diverged
|
|
123
|
+
message = _to_sdk_input(input, use_responses=self._settings.use_responses)
|
|
124
|
+
async with self._launch(agent, message, ctx, session) as launch:
|
|
125
|
+
result = launch.result
|
|
126
|
+
tool_names: dict[str, str] = {}
|
|
127
|
+
# The SDK's run loop is a detached task; an abandoned generator must cancel it
|
|
128
|
+
# explicitly (mirrors agents/runners/headless.py's run_streamed, same reason).
|
|
129
|
+
stream = cast("AsyncGenerator[Any, None]", result.stream_events())
|
|
130
|
+
try:
|
|
131
|
+
async with aclosing(stream) as events:
|
|
132
|
+
async for event in events:
|
|
133
|
+
payload = self._translate(event, tool_names)
|
|
134
|
+
if payload is not None:
|
|
135
|
+
yield payload
|
|
136
|
+
try:
|
|
137
|
+
await ctx.gate.checkpoint()
|
|
138
|
+
except ControlSignalled as signalled:
|
|
139
|
+
# A complete chunk was just yielded (or none was, at the very
|
|
140
|
+
# first safe point) — never a partial one — so this is the next
|
|
141
|
+
# safe point the contract promises, not "right now, mid-token".
|
|
142
|
+
# The SDK run is dropped either way: a paused turn has no
|
|
143
|
+
# checkpoint to sit in, so resuming replays it from the log.
|
|
144
|
+
result.cancel()
|
|
145
|
+
for payload in signalled.payloads:
|
|
146
|
+
yield payload
|
|
147
|
+
return
|
|
148
|
+
except BaseException:
|
|
149
|
+
result.cancel()
|
|
150
|
+
raise
|
|
151
|
+
result.cancel()
|
|
152
|
+
terminal = self._terminal(result)
|
|
153
|
+
# Set before the yields, not after them: the Runtime breaks on the terminal event,
|
|
154
|
+
# so the line after this loop never runs.
|
|
155
|
+
launch.finished = True
|
|
156
|
+
for payload in terminal:
|
|
157
|
+
yield payload
|
|
158
|
+
|
|
159
|
+
def _session(self, ctx: RunContext) -> Session | None:
|
|
160
|
+
"""The execution state this run reads and writes — the adapter's own store by default."""
|
|
161
|
+
return self._sessions.session_for(ctx)
|
|
162
|
+
|
|
163
|
+
@asynccontextmanager
|
|
164
|
+
async def _launch(
|
|
165
|
+
self, agent: Agent[Any], message: str | list[TResponseInputItem], ctx: RunContext, session: Session | None
|
|
166
|
+
) -> AsyncIterator[Launch]:
|
|
167
|
+
"""Start the run and hold whatever scope it needs open until the stream is drained.
|
|
168
|
+
|
|
169
|
+
Lifecycle rule: **code after the ``yield`` may never run.** A successful run ends
|
|
170
|
+
with the Runtime breaking on the terminal event, which closes this generator — the
|
|
171
|
+
``yield`` raises ``GeneratorExit`` and the lines below it are skipped. Anything that
|
|
172
|
+
must happen once per finished run therefore belongs in the ``GeneratorExit`` path,
|
|
173
|
+
keyed on ``Launch.finished``, never only after the ``yield``.
|
|
174
|
+
"""
|
|
175
|
+
scope = self._sandbox(agent) if self._sandbox is not None else nullcontext(None)
|
|
176
|
+
async with scope as sandbox:
|
|
177
|
+
yield Launch(
|
|
178
|
+
Runner.run_streamed(
|
|
179
|
+
agent,
|
|
180
|
+
message,
|
|
181
|
+
# The run context travels as the SDK's own context object, which is the one thing
|
|
182
|
+
# the SDK hands a function tool: a tool declaring ``RunContextWrapper[RunContext]``
|
|
183
|
+
# reaches ``wrapper.context.reporter`` (and the gate) without importing a Runtime.
|
|
184
|
+
# Nothing in the SDK reads it — it is opaque to the run loop by design.
|
|
185
|
+
context=ctx,
|
|
186
|
+
session=session,
|
|
187
|
+
run_config=build_run_config(self._settings, sandbox=sandbox),
|
|
188
|
+
max_turns=self._settings.max_turns,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _translate(self, event: Any, tool_names: dict[str, str]) -> KnownPayload | None:
|
|
193
|
+
payload = translate(event, tool_names)
|
|
194
|
+
return payload if payload is not None else _usage_reported(event)
|
|
195
|
+
|
|
196
|
+
def _terminal(self, result: RunResultStreaming) -> Sequence[KnownPayload]:
|
|
197
|
+
return (_run_completed(result),)
|
|
198
|
+
|
|
199
|
+
async def resume(
|
|
200
|
+
self,
|
|
201
|
+
spec: InvocableSpec,
|
|
202
|
+
thread_id: str,
|
|
203
|
+
value: Any,
|
|
204
|
+
ctx: RunContext,
|
|
205
|
+
) -> AsyncGenerator[KnownPayload, None]:
|
|
206
|
+
# M0 scope is UC1's plain chat, which never suspends — there is no interrupted run
|
|
207
|
+
# for this engine to continue. Raising (not a silent no-op) matches the Runtime's
|
|
208
|
+
# own rule that this method is only ever called on a WAITING_HUMAN run.
|
|
209
|
+
raise ConfigError(f"openai-agents engine (M0) has no interrupts to resume: {spec.name!r} never suspends")
|
|
210
|
+
yield # pragma: no cover — makes this an async generator; never reached
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _usage_reported(event: Any) -> KnownPayload | None:
|
|
214
|
+
"""One finished model call → one ``usage.reported``.
|
|
215
|
+
|
|
216
|
+
The terminal event's ``usage`` is the SDK's cumulative total for the turn, so without
|
|
217
|
+
this a consumer cannot tell one model call from four — which is exactly what v1's
|
|
218
|
+
``usage.requests`` counted.
|
|
219
|
+
"""
|
|
220
|
+
if event.type != "raw_response_event" or getattr(event.data, "type", None) != "response.completed":
|
|
221
|
+
return None
|
|
222
|
+
response = event.data.response
|
|
223
|
+
usage = getattr(response, "usage", None)
|
|
224
|
+
if usage is None:
|
|
225
|
+
return None
|
|
226
|
+
return UsageReported(
|
|
227
|
+
model=str(getattr(response, "model", "") or ""),
|
|
228
|
+
usage=Usage(input_tokens=usage.input_tokens, output_tokens=usage.output_tokens),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _agent_of(spec: InvocableSpec) -> Agent[Any]:
|
|
233
|
+
if not isinstance(spec.native, Agent):
|
|
234
|
+
raise ConfigError(f"{spec.name!r} has no openai-agents Agent: expected native=Agent, got {type(spec.native)}")
|
|
235
|
+
return spec.native
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _to_sdk_input(input: Input, *, use_responses: bool) -> str | list[TResponseInputItem]:
|
|
239
|
+
"""All-text input still returns the joined ``str`` it always has, byte for byte — every
|
|
240
|
+
existing session item, reconcile transcript and stored event stays unchanged, and only a
|
|
241
|
+
turn that actually carries media takes the branch below.
|
|
242
|
+
|
|
243
|
+
That branch emits the SDK's own canonical (Responses) item shape — ``input_text`` /
|
|
244
|
+
``input_image`` / ``input_audio`` parts — rather than a converter agentdeck writes itself:
|
|
245
|
+
``agents.models.chatcmpl_converter.Converter`` already accepts these parts and maps them
|
|
246
|
+
down to Chat-Completions parts, so one emitted shape works on both API paths.
|
|
247
|
+
"""
|
|
248
|
+
texts = [block.text for block in input if isinstance(block, TextBlock)]
|
|
249
|
+
if len(texts) == len(input):
|
|
250
|
+
return "\n".join(texts)
|
|
251
|
+
item = {"role": "user", "content": [_part_of(block, use_responses=use_responses) for block in input]}
|
|
252
|
+
return cast("list[TResponseInputItem]", [item])
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _part_of(block: ContentBlock, *, use_responses: bool) -> dict[str, Any]:
|
|
256
|
+
if isinstance(block, TextBlock):
|
|
257
|
+
return {"type": "input_text", "text": block.text}
|
|
258
|
+
if isinstance(block, ImageBlock):
|
|
259
|
+
return {"type": "input_image", "image_url": f"data:{block.media_type};base64,{block.data_b64}"}
|
|
260
|
+
if isinstance(block, AudioBlock):
|
|
261
|
+
if use_responses:
|
|
262
|
+
raise ConfigError(
|
|
263
|
+
"openai-agents engine cannot send an 'audio' block over the Responses API: "
|
|
264
|
+
"ResponseInputMessageContentListParam carries no audio member at this pin "
|
|
265
|
+
"(openai-agents==0.17.0) — set use_responses=False (chat-completions) to send audio"
|
|
266
|
+
)
|
|
267
|
+
return {
|
|
268
|
+
"type": "input_audio",
|
|
269
|
+
"input_audio": {"data": block.data_b64, "format": _audio_format(block.media_type)},
|
|
270
|
+
}
|
|
271
|
+
raise ConfigError(
|
|
272
|
+
f"openai-agents engine cannot send a {block.type!r} block to the model; "
|
|
273
|
+
"it accepts text, image, and audio (chat-completions only) input blocks"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _audio_format(media_type: str) -> str:
|
|
278
|
+
"""``audio/ogg; codecs=opus`` (a WhatsApp voice note's own media type) becomes ``ogg``: the
|
|
279
|
+
subtype with parameters stripped, unvalidated against openai's own ``Literal["mp3", "wav"]``
|
|
280
|
+
— the chat-completions converter passes the string through unchanged, and a provider such
|
|
281
|
+
as Gemini's OpenAI-compatible endpoint accepts ``ogg``."""
|
|
282
|
+
return media_type.split(";", 1)[0].strip().rsplit("/", 1)[-1]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _run_completed(result: RunResultStreaming) -> RunCompleted:
|
|
286
|
+
output = result.final_output
|
|
287
|
+
if isinstance(output, str):
|
|
288
|
+
return RunCompleted(output=coerce_input(output), usage=_usage_of(result))
|
|
289
|
+
return RunCompleted(output=[DataBlock(data=_structured(output))], usage=_usage_of(result))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _structured(output: Any) -> Any:
|
|
293
|
+
"""An ``output_type`` agent's validated result as JSON data.
|
|
294
|
+
|
|
295
|
+
It travels as a ``DataBlock``, which is why this no longer raises: refusing a non-``str``
|
|
296
|
+
final output turned a documented feature into a failed run. The ceiling, and it applies to
|
|
297
|
+
every branch below: a leaf JSON cannot carry becomes its ``str()`` — a non-finite float
|
|
298
|
+
included, since ``null`` would claim it was absent — rather than failing the run at its
|
|
299
|
+
last event.
|
|
300
|
+
"""
|
|
301
|
+
if isinstance(output, BaseModel):
|
|
302
|
+
try:
|
|
303
|
+
output = output.model_dump(mode="json")
|
|
304
|
+
except ValueError:
|
|
305
|
+
# PydanticSerializationError, which is a ValueError: one leaf pydantic cannot
|
|
306
|
+
# render as JSON. The python dump keeps the rest and the net below takes that
|
|
307
|
+
# leaf, so only its fidelity is lost — not the whole run's terminal event.
|
|
308
|
+
output = output.model_dump()
|
|
309
|
+
elif dataclasses.is_dataclass(output) and not isinstance(output, type):
|
|
310
|
+
output = dataclasses.asdict(output)
|
|
311
|
+
return json.loads(json.dumps(output, default=str), parse_constant=str)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _usage_of(result: RunResultStreaming) -> Usage:
|
|
315
|
+
usage: SDKUsage | None = getattr(result.context_wrapper, "usage", None)
|
|
316
|
+
if usage is None:
|
|
317
|
+
return Usage(input_tokens=0, output_tokens=0)
|
|
318
|
+
return Usage(input_tokens=usage.input_tokens, output_tokens=usage.output_tokens)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
__all__ = ["Launch", "OpenAIAgentsEngine", "SandboxScope"]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Bringing the SDK session back in line with the event log after a crash between the
|
|
2
|
+
two writes (ADR-D5: the log records the intent, the session is the engine's working memory).
|
|
3
|
+
|
|
4
|
+
A turn writes the log first and the session second, so a process that dies in between
|
|
5
|
+
leaves the log holding a message the session never got — a question the model would
|
|
6
|
+
otherwise never see, or an answer it would think it never gave. On the next turn this
|
|
7
|
+
compares the two message-level transcripts and appends whatever the session is missing.
|
|
8
|
+
|
|
9
|
+
Message level means content and order, nothing more, and that has a lasting cost: the log
|
|
10
|
+
stores tool results truncated and never stores reasoning items, so a repaired session holds
|
|
11
|
+
plain text where an intact one held paired tool-call/tool-result items and reasoning, and it
|
|
12
|
+
keeps holding it for the rest of that conversation. The model can then see an answer with no
|
|
13
|
+
evidence of the tool call behind it. Accepted deliberately — the alternative is a turn the
|
|
14
|
+
model cannot see at all.
|
|
15
|
+
|
|
16
|
+
Two things are never replayed. The input of a run cancelled before it produced anything:
|
|
17
|
+
``run.started`` says a turn was asked for, not that the engine took it, so replaying it would
|
|
18
|
+
land in front of the question the user is about to retry. And anything at all into a session
|
|
19
|
+
that has gone somewhere the log's prefix does not cover: that session is the authority on
|
|
20
|
+
execution, a wrong guess about its tail is worse than a gap, and the disagreement is reported
|
|
21
|
+
instead.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
29
|
+
from weakref import WeakKeyDictionary
|
|
30
|
+
|
|
31
|
+
from agentdeck.core.content import TextBlock
|
|
32
|
+
from agentdeck.core.events import Custom, InputAppended, MessageCompleted, RunCancelled, RunStarted
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Iterable, Sequence
|
|
36
|
+
|
|
37
|
+
from agents.items import TResponseInputItem
|
|
38
|
+
from agents.memory.session import Session
|
|
39
|
+
|
|
40
|
+
from agentdeck.core.content import Input
|
|
41
|
+
from agentdeck.core.events import Event
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
Role = Literal["user", "assistant"]
|
|
46
|
+
Message = tuple[Role, str]
|
|
47
|
+
"""One transcript entry: the role that spoke and the text it said."""
|
|
48
|
+
|
|
49
|
+
DIVERGED = "openai_agents.session_diverged"
|
|
50
|
+
"""This engine's own event for "the two stores disagree about more than a missing tail"."""
|
|
51
|
+
|
|
52
|
+
_LOCKS: WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]] = WeakKeyDictionary()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def reconcile(session: Session, history: Sequence[Event]) -> Custom | None:
|
|
56
|
+
"""Append the messages ``history`` records and ``session`` is missing, before a turn runs.
|
|
57
|
+
|
|
58
|
+
Returns a ``custom`` payload when the two disagree about more than a missing tail, for
|
|
59
|
+
the caller to yield: a log line alone cannot be noticed, and the run is still perfectly
|
|
60
|
+
runnable on the session it has. ``None`` means nothing to do, or a repair that went in.
|
|
61
|
+
"""
|
|
62
|
+
# ponytail: whole log against whole session, every turn — the same ceiling the Runtime's
|
|
63
|
+
# own per-turn log read already has, and the SDK reads the whole session anyway. Both want
|
|
64
|
+
# windowing together, once a session's history outgrows one read.
|
|
65
|
+
logged = _log_transcript(history)
|
|
66
|
+
if not logged:
|
|
67
|
+
return None
|
|
68
|
+
# Read-then-append is atomic only under this lock: two turns racing on one session would
|
|
69
|
+
# otherwise both apply the same repair and double the conversation. One process is as far as
|
|
70
|
+
# it reaches — two servers on one session are stopped at the door by #83's session claim.
|
|
71
|
+
async with _lock_for(session.session_id):
|
|
72
|
+
stored = _session_transcript(await session.get_items())
|
|
73
|
+
shared = min(len(stored), len(logged))
|
|
74
|
+
if stored[:shared] != logged[:shared]:
|
|
75
|
+
at = next(index for index in range(shared) if stored[index] != logged[index])
|
|
76
|
+
logger.warning(
|
|
77
|
+
"session %s disagrees with its log from message %d (%d session messages, %d logged): replaying nothing",
|
|
78
|
+
session.session_id,
|
|
79
|
+
at,
|
|
80
|
+
len(stored),
|
|
81
|
+
len(logged),
|
|
82
|
+
)
|
|
83
|
+
return Custom(
|
|
84
|
+
name=DIVERGED,
|
|
85
|
+
data={"agreed_through": at, "session_messages": len(stored), "logged_messages": len(logged)},
|
|
86
|
+
)
|
|
87
|
+
missing = logged[len(stored) :]
|
|
88
|
+
if not missing:
|
|
89
|
+
# Equal, or the session is ahead: an abandoned turn the engine had in fact started
|
|
90
|
+
# leaves an input in the session the log skips, and there is nothing to add for it.
|
|
91
|
+
return None
|
|
92
|
+
logger.info("replaying %d logged message(s) the session is missing into %s", len(missing), session.session_id)
|
|
93
|
+
await session.add_items([_as_item(role, text) for role, text in missing])
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _lock_for(session_id: str) -> asyncio.Lock:
|
|
98
|
+
"""One lock per session, per event loop: a lock outlives neither, so one left behind by a
|
|
99
|
+
finished loop can never be acquired again."""
|
|
100
|
+
per_loop = _LOCKS.setdefault(asyncio.get_running_loop(), {})
|
|
101
|
+
lock = per_loop.get(session_id)
|
|
102
|
+
if lock is None:
|
|
103
|
+
lock = per_loop[session_id] = asyncio.Lock()
|
|
104
|
+
return lock
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _log_transcript(history: Sequence[Event]) -> list[Message]:
|
|
108
|
+
"""Every message the log says entered or left the loop, in order.
|
|
109
|
+
|
|
110
|
+
A turn's input lands on ``run.started``, mid-turn steering on ``input.appended``, the
|
|
111
|
+
assistant's side on ``message.completed``. Deltas are streaming UX and tool traffic is not
|
|
112
|
+
message level, so neither belongs here.
|
|
113
|
+
|
|
114
|
+
A run that was cancelled *before it got anywhere* contributes no input: the consumer walked
|
|
115
|
+
away before the engine read anything, so the session never saw that question and the user's
|
|
116
|
+
retry would arrive behind a copy of itself. Cancelled after an answer is the opposite case —
|
|
117
|
+
the SDK persists a turn's input and its output together, so both are in the session and
|
|
118
|
+
dropping the input would misalign the two transcripts on every later turn. A *failed* run
|
|
119
|
+
also keeps its input, because a session write that died is what a failure looks like here.
|
|
120
|
+
"""
|
|
121
|
+
cancelled = {event.run_id for event in history if isinstance(event.payload, RunCancelled)}
|
|
122
|
+
answered = {event.run_id for event in history if isinstance(event.payload, MessageCompleted)}
|
|
123
|
+
abandoned = cancelled - answered
|
|
124
|
+
transcript: list[Message] = []
|
|
125
|
+
for event in history:
|
|
126
|
+
payload = event.payload
|
|
127
|
+
if isinstance(payload, RunStarted) and event.run_id in abandoned:
|
|
128
|
+
continue
|
|
129
|
+
if isinstance(payload, RunStarted | InputAppended):
|
|
130
|
+
transcript.append(("user", _text_of(payload.input)))
|
|
131
|
+
elif isinstance(payload, MessageCompleted):
|
|
132
|
+
transcript.append(("assistant", payload.text))
|
|
133
|
+
return transcript
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _session_transcript(items: Sequence[TResponseInputItem]) -> list[Message]:
|
|
137
|
+
"""The same view of the session: user and assistant messages only, tool calls,
|
|
138
|
+
tool results and reasoning items skipped."""
|
|
139
|
+
transcript: list[Message] = []
|
|
140
|
+
for item in items:
|
|
141
|
+
if not isinstance(item, dict):
|
|
142
|
+
continue
|
|
143
|
+
role = item.get("role")
|
|
144
|
+
if role == "user" or role == "assistant": # noqa: PLR1714 — two comparisons narrow role, `in` does not
|
|
145
|
+
transcript.append((role, _item_text(item.get("content"))))
|
|
146
|
+
return transcript
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _join_texts(texts: Iterable[str]) -> str:
|
|
150
|
+
"""The one join both reconciliation sides call, so a multi-``TextBlock`` turn reads the same
|
|
151
|
+
string from the log and from the session instead of drifting by whose join ran. Before
|
|
152
|
+
``_to_sdk_input`` could emit a parts list (#161), the two agreed only because the session's
|
|
153
|
+
content was the bare string this function's caller had already joined."""
|
|
154
|
+
return "\n".join(texts)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _item_text(content: Any) -> str:
|
|
158
|
+
if isinstance(content, str):
|
|
159
|
+
return content
|
|
160
|
+
if isinstance(content, list):
|
|
161
|
+
# A text part is ``input_text`` on the user side, ``output_text`` on the assistant
|
|
162
|
+
# side — both have a ``text`` key. An image or audio part has neither type nor key,
|
|
163
|
+
# so filtering on the key itself covers both roles without naming either type.
|
|
164
|
+
return _join_texts(
|
|
165
|
+
part["text"] for part in content if isinstance(part, dict) and isinstance(part.get("text"), str)
|
|
166
|
+
)
|
|
167
|
+
return ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _text_of(input: Input) -> str:
|
|
171
|
+
return _join_texts(block.text for block in input if isinstance(block, TextBlock))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _as_item(role: Role, text: str) -> TResponseInputItem:
|
|
175
|
+
return {"role": role, "content": text}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
__all__ = ["DIVERGED", "reconcile"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""How one run is configured: plain resolved values in, an SDK ``RunConfig`` out.
|
|
2
|
+
|
|
3
|
+
The values arrive from the composition root (``agentdeck/composition.py``'s
|
|
4
|
+
``resolve_run_settings``) rather than being read here, for the reason the store and the
|
|
5
|
+
control port are already resolved there: an adapter that reaches for ``get_settings()``
|
|
6
|
+
cannot be handed a different endpoint by a caller, and a second front door would have to
|
|
7
|
+
mutate process state to get one.
|
|
8
|
+
|
|
9
|
+
A bare :class:`RunSettings` therefore configures nothing at all — no provider, the SDK's
|
|
10
|
+
own defaults — which is what a code-first caller wiring ``OpenAIAgentsEngine()`` by hand
|
|
11
|
+
gets. Naming a model is what turns on the provider (see ``_provider``), but it never reaches
|
|
12
|
+
``RunConfig.model``: the SDK overrides *every* agent's own model with that field once it is
|
|
13
|
+
set, string or ``Model`` alike, so the settings-resolved default is handed to each agent
|
|
14
|
+
instead, at compile time (``authoring.compile.compile_agent``) — the one place an agent's own
|
|
15
|
+
declared model and the run's default both resolve to a single value before either ever
|
|
16
|
+
reaches an SDK ``RunConfig``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from agents import ModelSettings, MultiProvider, OpenAIProvider, RunConfig
|
|
27
|
+
from openai import AsyncOpenAI
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class RunSettings:
|
|
32
|
+
"""Everything a run's ``RunConfig`` is resolved from, as values an adapter can hold.
|
|
33
|
+
|
|
34
|
+
Defaults are the SDK's, not the project's: ``RunSettings()`` must leave a run exactly as
|
|
35
|
+
``RunConfig()`` would, so the contract suite and a code-first caller keep configuring
|
|
36
|
+
their agents themselves.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
model: str | None = None
|
|
40
|
+
api_key: str = ""
|
|
41
|
+
base_url: str = ""
|
|
42
|
+
ca_bundle: str = ""
|
|
43
|
+
use_responses: bool = True
|
|
44
|
+
workflow_name: str = "Agent workflow"
|
|
45
|
+
nest_handoff_history: bool = False
|
|
46
|
+
temperature: float | None = None
|
|
47
|
+
max_tokens: int | None = None
|
|
48
|
+
max_turns: int = 10
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_run_config(settings: RunSettings, *, sandbox: Any = None) -> RunConfig:
|
|
52
|
+
"""One run's ``RunConfig``.
|
|
53
|
+
|
|
54
|
+
Built per run, never once and mutated: ``sandbox`` is this run's workspace handle, and a
|
|
55
|
+
shared config carrying somebody else's would hand two concurrent turns the same session.
|
|
56
|
+
"""
|
|
57
|
+
return RunConfig(
|
|
58
|
+
workflow_name=settings.workflow_name,
|
|
59
|
+
# No `model=` here: the SDK's own `RunConfig.model` overrides every agent's model
|
|
60
|
+
# once set, so a per-agent default lives on the compiled SDK agent instead
|
|
61
|
+
# (`authoring.compile.compile_agent`), where an agent's own declaration still wins.
|
|
62
|
+
nest_handoff_history=settings.nest_handoff_history,
|
|
63
|
+
tracing_disabled=not tracing_enabled(),
|
|
64
|
+
model_provider=_provider(settings) or MultiProvider(),
|
|
65
|
+
# ``include_usage`` asks the Chat-Completions API to emit the streaming usage chunk
|
|
66
|
+
# (prompt/completion tokens) — without it, streamed turns carry no token counts at
|
|
67
|
+
# all, so ``usage.reported`` and ``run.completed`` would both report zero. No-op on
|
|
68
|
+
# the Responses API, where usage is always included.
|
|
69
|
+
model_settings=ModelSettings(
|
|
70
|
+
temperature=settings.temperature, max_tokens=settings.max_tokens, include_usage=True
|
|
71
|
+
),
|
|
72
|
+
sandbox=sandbox,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def tracing_enabled() -> bool:
|
|
77
|
+
"""Opt-in switch for the SDK's default trace exporter (issue #61).
|
|
78
|
+
|
|
79
|
+
Off by default: a keyless/fake-model run (tests, CI, the M0 demo) has no OpenAI
|
|
80
|
+
account to export traces to, and the SDK's exporter otherwise attempts a real HTTPS
|
|
81
|
+
call on every run, logging a non-fatal ``Tracing client error 401``. Set
|
|
82
|
+
``AGENTDECK_OPENAI_AGENTS_TRACING_ENABLED=true`` to restore it for a deployment that
|
|
83
|
+
wants the SDK's own trace export.
|
|
84
|
+
|
|
85
|
+
Not the Langfuse switch it used to be: traces are built from the event stream by
|
|
86
|
+
``adapters/telemetry/langfuse``, so the SDK's own exporter is a separate question now
|
|
87
|
+
and answered separately.
|
|
88
|
+
"""
|
|
89
|
+
raw = os.environ.get("AGENTDECK_OPENAI_AGENTS_TRACING_ENABLED")
|
|
90
|
+
return raw is not None and raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _provider(settings: RunSettings) -> OpenAIProvider | None:
|
|
94
|
+
"""The provider for the endpoint these settings name, or ``None`` for "no endpoint".
|
|
95
|
+
|
|
96
|
+
A custom CA bundle needs its own httpx client (``verify=<path>``), so ``base_url`` and
|
|
97
|
+
``api_key`` ride on that client rather than on the provider — the provider ignores both
|
|
98
|
+
once it is handed a client of its own.
|
|
99
|
+
"""
|
|
100
|
+
if not settings.model:
|
|
101
|
+
return None
|
|
102
|
+
if settings.ca_bundle:
|
|
103
|
+
return OpenAIProvider(
|
|
104
|
+
openai_client=AsyncOpenAI(
|
|
105
|
+
base_url=settings.base_url or None,
|
|
106
|
+
api_key=settings.api_key,
|
|
107
|
+
http_client=httpx.AsyncClient(verify=settings.ca_bundle),
|
|
108
|
+
),
|
|
109
|
+
use_responses=settings.use_responses,
|
|
110
|
+
)
|
|
111
|
+
return OpenAIProvider(
|
|
112
|
+
base_url=settings.base_url or None,
|
|
113
|
+
api_key=settings.api_key or None,
|
|
114
|
+
use_responses=settings.use_responses,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = ["RunSettings", "build_run_config", "tracing_enabled"]
|