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.
Files changed (104) hide show
  1. agentdeck/README.md +50 -0
  2. agentdeck/__init__.py +51 -0
  3. agentdeck/adapters/__init__.py +5 -0
  4. agentdeck/adapters/control/__init__.py +1 -0
  5. agentdeck/adapters/control/memory/__init__.py +5 -0
  6. agentdeck/adapters/control/memory/port.py +25 -0
  7. agentdeck/adapters/control/sqlite/__init__.py +5 -0
  8. agentdeck/adapters/control/sqlite/port.py +111 -0
  9. agentdeck/adapters/engines/__init__.py +1 -0
  10. agentdeck/adapters/engines/langgraph/__init__.py +8 -0
  11. agentdeck/adapters/engines/langgraph/checkpointer.py +205 -0
  12. agentdeck/adapters/engines/langgraph/engine.py +410 -0
  13. agentdeck/adapters/engines/openai_agents/__init__.py +9 -0
  14. agentdeck/adapters/engines/openai_agents/engine.py +321 -0
  15. agentdeck/adapters/engines/openai_agents/reconcile.py +178 -0
  16. agentdeck/adapters/engines/openai_agents/runconfig.py +118 -0
  17. agentdeck/adapters/engines/openai_agents/sessions.py +100 -0
  18. agentdeck/adapters/engines/openai_agents/translate.py +124 -0
  19. agentdeck/adapters/engines/stub/__init__.py +5 -0
  20. agentdeck/adapters/engines/stub/engine.py +100 -0
  21. agentdeck/adapters/stores/__init__.py +1 -0
  22. agentdeck/adapters/stores/memory/__init__.py +5 -0
  23. agentdeck/adapters/stores/memory/store.py +148 -0
  24. agentdeck/adapters/stores/postgres/__init__.py +5 -0
  25. agentdeck/adapters/stores/postgres/store.py +374 -0
  26. agentdeck/adapters/stores/redis/__init__.py +5 -0
  27. agentdeck/adapters/stores/redis/store.py +359 -0
  28. agentdeck/adapters/stores/sqlite/__init__.py +5 -0
  29. agentdeck/adapters/stores/sqlite/store.py +338 -0
  30. agentdeck/adapters/telemetry/__init__.py +1 -0
  31. agentdeck/adapters/telemetry/langfuse/__init__.py +18 -0
  32. agentdeck/adapters/telemetry/langfuse/client.py +180 -0
  33. agentdeck/adapters/telemetry/langfuse/sink.py +366 -0
  34. agentdeck/adapters/telemetry/langfuse/trace.py +88 -0
  35. agentdeck/adapters/tools/__init__.py +1 -0
  36. agentdeck/adapters/tools/mcp/__init__.py +18 -0
  37. agentdeck/adapters/tools/mcp/lifecycle.py +178 -0
  38. agentdeck/adapters/tools/mcp/source.py +47 -0
  39. agentdeck/adapters/tools/mcp/transport.py +234 -0
  40. agentdeck/adapters/tools/mcp/wiring.py +63 -0
  41. agentdeck/authoring/__init__.py +22 -0
  42. agentdeck/authoring/agent.py +169 -0
  43. agentdeck/authoring/compile.py +250 -0
  44. agentdeck/authoring/graphs.py +145 -0
  45. agentdeck/authoring/hooks.py +117 -0
  46. agentdeck/authoring/injection.py +233 -0
  47. agentdeck/authoring/instructions.py +80 -0
  48. agentdeck/authoring/interrupts.py +37 -0
  49. agentdeck/authoring/nodes.py +140 -0
  50. agentdeck/authoring/runners/__init__.py +6 -0
  51. agentdeck/authoring/runners/agent.py +171 -0
  52. agentdeck/authoring/runners/workflow.py +99 -0
  53. agentdeck/authoring/skills.py +56 -0
  54. agentdeck/authoring/state.py +44 -0
  55. agentdeck/authoring/timers.py +44 -0
  56. agentdeck/authoring/tools.py +147 -0
  57. agentdeck/authoring/web_search.py +43 -0
  58. agentdeck/authoring/workflow.py +266 -0
  59. agentdeck/cli.py +56 -0
  60. agentdeck/composition.py +213 -0
  61. agentdeck/core/__init__.py +110 -0
  62. agentdeck/core/base.py +47 -0
  63. agentdeck/core/content.py +166 -0
  64. agentdeck/core/context.py +136 -0
  65. agentdeck/core/control.py +150 -0
  66. agentdeck/core/events.py +468 -0
  67. agentdeck/core/invocable.py +38 -0
  68. agentdeck/core/ports/__init__.py +26 -0
  69. agentdeck/core/ports/control.py +35 -0
  70. agentdeck/core/ports/engine.py +66 -0
  71. agentdeck/core/ports/sink.py +53 -0
  72. agentdeck/core/ports/store.py +170 -0
  73. agentdeck/core/ports/tools.py +57 -0
  74. agentdeck/core/reporting.py +75 -0
  75. agentdeck/core/status.py +75 -0
  76. agentdeck/deck.py +894 -0
  77. agentdeck/errors.py +67 -0
  78. agentdeck/mcp.py +82 -0
  79. agentdeck/observers.py +111 -0
  80. agentdeck/py.typed +0 -0
  81. agentdeck/runtime/__init__.py +1 -0
  82. agentdeck/runtime/capture.py +32 -0
  83. agentdeck/runtime/config.default.yaml +38 -0
  84. agentdeck/runtime/discovery.py +175 -0
  85. agentdeck/runtime/dispatch.py +440 -0
  86. agentdeck/runtime/registry.py +173 -0
  87. agentdeck/runtime/service.py +671 -0
  88. agentdeck/runtime/settings.py +568 -0
  89. agentdeck/serve.py +330 -0
  90. agentdeck/skills/__init__.py +114 -0
  91. agentdeck/skills/bundle.py +65 -0
  92. agentdeck/surfaces/__init__.py +4 -0
  93. agentdeck/surfaces/cli/__init__.py +7 -0
  94. agentdeck/surfaces/cli/chat.py +88 -0
  95. agentdeck/surfaces/serve/__init__.py +7 -0
  96. agentdeck/surfaces/serve/app.py +71 -0
  97. agentdeck/surfaces/serve/compat.py +212 -0
  98. agentdeck/surfaces/serve/workflows.py +69 -0
  99. agentdeck/testing.py +364 -0
  100. agentdeck_sdk-3.1.0.dist-info/METADATA +222 -0
  101. agentdeck_sdk-3.1.0.dist-info/RECORD +104 -0
  102. agentdeck_sdk-3.1.0.dist-info/WHEEL +4 -0
  103. agentdeck_sdk-3.1.0.dist-info/entry_points.txt +3 -0
  104. 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"]