dex-agents 0.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.
- dex_agents/__init__.py +131 -0
- dex_agents/client.py +224 -0
- dex_agents/events.py +222 -0
- dex_agents/persistence.py +87 -0
- dex_agents/py.typed +0 -0
- dex_agents/runner.py +499 -0
- dex_agents/session_flow.py +1157 -0
- dex_agents/testing/__init__.py +89 -0
- dex_agents/testing/coordination.py +160 -0
- dex_agents/testing/history.py +101 -0
- dex_agents/testing/polling.py +110 -0
- dex_agents/testing/processes.py +457 -0
- dex_agents/testing/stack.py +192 -0
- dex_agents/testing/worker.py +115 -0
- dex_agents/values.py +458 -0
- dex_agents-0.1.0.dist-info/METADATA +403 -0
- dex_agents-0.1.0.dist-info/RECORD +19 -0
- dex_agents-0.1.0.dist-info/WHEEL +4 -0
- dex_agents-0.1.0.dist-info/licenses/LICENSE +105 -0
dex_agents/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""Durable agent sessions for Dex.
|
|
10
|
+
|
|
11
|
+
``AgentSessionFlow`` runs one agent session per Flow execution. A framework
|
|
12
|
+
adapter implements ``AgentFrameworkRunner`` and passes it to the Flow; the
|
|
13
|
+
Flow runs every model call and every tool call as its own Step, and owns the
|
|
14
|
+
conversation, tool approvals, and live events. See the package README for the full contract.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from dex_agents.client import AgentSessionClient
|
|
18
|
+
from dex_agents.events import AgentEventType, AgentEventWriter
|
|
19
|
+
from dex_agents.persistence import (
|
|
20
|
+
AGENT_MESSAGES,
|
|
21
|
+
AGENT_STATE,
|
|
22
|
+
AGENT_TOOL_CALLS,
|
|
23
|
+
AGENT_USAGE,
|
|
24
|
+
DEFAULT_AGENT_EVENTS_CAPACITY_BYTES,
|
|
25
|
+
FRAMEWORK_RUN_STATE,
|
|
26
|
+
INTERRUPT_RESOLUTIONS,
|
|
27
|
+
PENDING_INTERRUPTS,
|
|
28
|
+
QUEUED_USER_MESSAGES,
|
|
29
|
+
TOOL_CALL_RESULTS,
|
|
30
|
+
message_sequence_key,
|
|
31
|
+
)
|
|
32
|
+
from dex_agents.runner import (
|
|
33
|
+
APPROVAL_DEADLINE_PASSED,
|
|
34
|
+
DEFAULT_REJECTION_TOOL_RESULT,
|
|
35
|
+
OUTCOME_UNKNOWN_TOOL_RESULT,
|
|
36
|
+
TURN_FAILURE_NOTE_FOOTER,
|
|
37
|
+
TURN_FAILURE_NOTE_HEADER,
|
|
38
|
+
AgentFrameworkRunner,
|
|
39
|
+
AgentTurnContext,
|
|
40
|
+
AgentTurnOutcome,
|
|
41
|
+
DeferredToolCall,
|
|
42
|
+
DeferredToolExecution,
|
|
43
|
+
FrameworkMessage,
|
|
44
|
+
ToolCallsRequested,
|
|
45
|
+
ToolExecutionError,
|
|
46
|
+
ToolExecutionResult,
|
|
47
|
+
ToolPolicy,
|
|
48
|
+
TurnCompleted,
|
|
49
|
+
build_turn_failure_note,
|
|
50
|
+
describe_tool_call_outcome,
|
|
51
|
+
resolve_tool_policy,
|
|
52
|
+
)
|
|
53
|
+
from dex_agents.session_flow import AgentSessionFlow, AgentSessionOptions
|
|
54
|
+
from dex_agents.values import (
|
|
55
|
+
AgentEvent,
|
|
56
|
+
AgentInterrupt,
|
|
57
|
+
AgentInterruptResolution,
|
|
58
|
+
AgentMessage,
|
|
59
|
+
AgentMessagesPage,
|
|
60
|
+
AgentSessionStartInput,
|
|
61
|
+
AgentSessionStatus,
|
|
62
|
+
AgentSnapshot,
|
|
63
|
+
AgentState,
|
|
64
|
+
AgentToolCall,
|
|
65
|
+
AgentUsage,
|
|
66
|
+
AgentUserMessage,
|
|
67
|
+
InterruptKind,
|
|
68
|
+
ListAgentMessagesRequest,
|
|
69
|
+
MessageRole,
|
|
70
|
+
ResolveInterruptRequest,
|
|
71
|
+
ToolCallCompleted,
|
|
72
|
+
ToolCallStatus,
|
|
73
|
+
ToolEffect,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"AGENT_MESSAGES",
|
|
78
|
+
"AGENT_STATE",
|
|
79
|
+
"AGENT_TOOL_CALLS",
|
|
80
|
+
"AGENT_USAGE",
|
|
81
|
+
"APPROVAL_DEADLINE_PASSED",
|
|
82
|
+
"DEFAULT_AGENT_EVENTS_CAPACITY_BYTES",
|
|
83
|
+
"DEFAULT_REJECTION_TOOL_RESULT",
|
|
84
|
+
"FRAMEWORK_RUN_STATE",
|
|
85
|
+
"INTERRUPT_RESOLUTIONS",
|
|
86
|
+
"OUTCOME_UNKNOWN_TOOL_RESULT",
|
|
87
|
+
"PENDING_INTERRUPTS",
|
|
88
|
+
"QUEUED_USER_MESSAGES",
|
|
89
|
+
"TOOL_CALL_RESULTS",
|
|
90
|
+
"TURN_FAILURE_NOTE_FOOTER",
|
|
91
|
+
"TURN_FAILURE_NOTE_HEADER",
|
|
92
|
+
"AgentEvent",
|
|
93
|
+
"AgentEventType",
|
|
94
|
+
"AgentEventWriter",
|
|
95
|
+
"AgentFrameworkRunner",
|
|
96
|
+
"AgentInterrupt",
|
|
97
|
+
"AgentInterruptResolution",
|
|
98
|
+
"AgentMessage",
|
|
99
|
+
"AgentMessagesPage",
|
|
100
|
+
"AgentSessionClient",
|
|
101
|
+
"AgentSessionFlow",
|
|
102
|
+
"AgentSessionOptions",
|
|
103
|
+
"AgentSessionStartInput",
|
|
104
|
+
"AgentSessionStatus",
|
|
105
|
+
"AgentSnapshot",
|
|
106
|
+
"AgentState",
|
|
107
|
+
"AgentToolCall",
|
|
108
|
+
"AgentTurnContext",
|
|
109
|
+
"AgentTurnOutcome",
|
|
110
|
+
"AgentUsage",
|
|
111
|
+
"AgentUserMessage",
|
|
112
|
+
"DeferredToolCall",
|
|
113
|
+
"DeferredToolExecution",
|
|
114
|
+
"FrameworkMessage",
|
|
115
|
+
"InterruptKind",
|
|
116
|
+
"ListAgentMessagesRequest",
|
|
117
|
+
"MessageRole",
|
|
118
|
+
"ResolveInterruptRequest",
|
|
119
|
+
"ToolCallCompleted",
|
|
120
|
+
"ToolCallStatus",
|
|
121
|
+
"ToolCallsRequested",
|
|
122
|
+
"ToolEffect",
|
|
123
|
+
"ToolExecutionError",
|
|
124
|
+
"ToolExecutionResult",
|
|
125
|
+
"ToolPolicy",
|
|
126
|
+
"TurnCompleted",
|
|
127
|
+
"build_turn_failure_note",
|
|
128
|
+
"describe_tool_call_outcome",
|
|
129
|
+
"message_sequence_key",
|
|
130
|
+
"resolve_tool_policy",
|
|
131
|
+
]
|
dex_agents/client.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""Typed Client calls for agent sessions."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
|
|
16
|
+
from dex import AsyncClient, RpcLockConflictError, StartFlowOptions, StreamMessage
|
|
17
|
+
|
|
18
|
+
from dex_agents.session_flow import AgentSessionFlow
|
|
19
|
+
from dex_agents.values import (
|
|
20
|
+
AgentEvent,
|
|
21
|
+
AgentMessagesPage,
|
|
22
|
+
AgentSessionStartInput,
|
|
23
|
+
AgentSnapshot,
|
|
24
|
+
AgentUserMessage,
|
|
25
|
+
ListAgentMessagesRequest,
|
|
26
|
+
ResolveInterruptRequest,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_LOCK_CONFLICT_RETRY_INTERVAL_SECONDS = 0.05
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AgentSessionClient:
|
|
33
|
+
"""Start agent sessions and call their RPCs with typed arguments.
|
|
34
|
+
|
|
35
|
+
Wrap the application's ``AsyncClient`` and the registered
|
|
36
|
+
``AgentSessionFlow``. Every method is a thin call through the Client; the
|
|
37
|
+
session ID is the Flow ID.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> sessions = AgentSessionClient(client, flow)
|
|
41
|
+
>>> await sessions.start_session("session-42")
|
|
42
|
+
>>> await sessions.send_user_message("session-42", "Refund order 7")
|
|
43
|
+
>>> snapshot = await sessions.get_agent_snapshot("session-42")
|
|
44
|
+
>>> for interrupt in snapshot.pending_interrupts:
|
|
45
|
+
... await sessions.resolve_interrupt("session-42", interrupt.interrupt_id,
|
|
46
|
+
... approved=True)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
client: AsyncClient,
|
|
52
|
+
flow: AgentSessionFlow,
|
|
53
|
+
*,
|
|
54
|
+
lock_conflict_retry_timeout: timedelta = timedelta(seconds=5),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Create a typed view over one agent's sessions.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
client: An open ``AsyncClient`` whose Registry contains ``flow``.
|
|
60
|
+
The caller keeps ownership and closes it.
|
|
61
|
+
flow: The registered ``AgentSessionFlow`` instance.
|
|
62
|
+
lock_conflict_retry_timeout: How long ``resolve_interrupt`` retries
|
|
63
|
+
an ``RpcLockConflictError``. Defaults to five seconds.
|
|
64
|
+
"""
|
|
65
|
+
self._client = client
|
|
66
|
+
self._flow = flow
|
|
67
|
+
self._lock_conflict_retry_timeout = lock_conflict_retry_timeout
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def flow(self) -> AgentSessionFlow:
|
|
71
|
+
"""Return the agent session Flow these calls target.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The Flow passed to the constructor.
|
|
75
|
+
"""
|
|
76
|
+
return self._flow
|
|
77
|
+
|
|
78
|
+
async def start_session(
|
|
79
|
+
self,
|
|
80
|
+
session_id: str,
|
|
81
|
+
initial_message: str | None = None,
|
|
82
|
+
options: StartFlowOptions | None = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Start one agent session.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
session_id: Flow ID of the new session.
|
|
88
|
+
initial_message: Optional first user message.
|
|
89
|
+
options: Start options, such as a Request ID for an idempotent start.
|
|
90
|
+
``None`` uses ``StartFlowOptions()``.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The server-assigned run ID.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
FlowAlreadyStartedError: If the Flow ID is already in use.
|
|
97
|
+
"""
|
|
98
|
+
return await self._client.start_flow(
|
|
99
|
+
self._flow,
|
|
100
|
+
session_id,
|
|
101
|
+
AgentSessionStartInput(initial_message=initial_message),
|
|
102
|
+
options or StartFlowOptions(),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def send_user_message(self, session_id: str, text: str) -> bool:
|
|
106
|
+
"""Queue one user message.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
session_id: Running session.
|
|
110
|
+
text: Message text.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
``True`` when queued; ``False`` for whitespace-only text.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
FlowNotActiveOrNotFoundError: If the session is not running.
|
|
117
|
+
"""
|
|
118
|
+
return await self._client.invoke_rpc(
|
|
119
|
+
self._flow.send_user_message, session_id, AgentUserMessage(text=text)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def resolve_interrupt(
|
|
123
|
+
self,
|
|
124
|
+
session_id: str,
|
|
125
|
+
interrupt_id: str,
|
|
126
|
+
*,
|
|
127
|
+
approved: bool,
|
|
128
|
+
rejection_message: str | None = None,
|
|
129
|
+
) -> bool:
|
|
130
|
+
"""Approve or reject one pending interrupt.
|
|
131
|
+
|
|
132
|
+
The call retries ``RpcLockConflictError`` until
|
|
133
|
+
``lock_conflict_retry_timeout`` passes. A retry is safe because a
|
|
134
|
+
conflicting call makes no change.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
session_id: Running session.
|
|
138
|
+
interrupt_id: ``AgentInterrupt.interrupt_id``.
|
|
139
|
+
approved: ``True`` to run the tool call.
|
|
140
|
+
rejection_message: Message the model sees after a rejection.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
``True`` when resolved; ``False`` when it is unknown, already resolved,
|
|
144
|
+
or past its deadline.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
RpcLockConflictError: If the lock stays busy past the retry timeout.
|
|
148
|
+
FlowNotActiveOrNotFoundError: If the session is not running.
|
|
149
|
+
"""
|
|
150
|
+
request = ResolveInterruptRequest(
|
|
151
|
+
interrupt_id=interrupt_id, approved=approved, rejection_message=rejection_message
|
|
152
|
+
)
|
|
153
|
+
loop = asyncio.get_running_loop()
|
|
154
|
+
deadline = loop.time() + self._lock_conflict_retry_timeout.total_seconds()
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
return await self._client.invoke_rpc(
|
|
158
|
+
self._flow.resolve_interrupt, session_id, request
|
|
159
|
+
)
|
|
160
|
+
except RpcLockConflictError:
|
|
161
|
+
if loop.time() >= deadline:
|
|
162
|
+
raise
|
|
163
|
+
await asyncio.sleep(_LOCK_CONFLICT_RETRY_INTERVAL_SECONDS)
|
|
164
|
+
|
|
165
|
+
async def get_agent_snapshot(self, session_id: str) -> AgentSnapshot:
|
|
166
|
+
"""Return the session snapshot a UI renders.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
session_id: Running or retained closed session.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The snapshot from ``get_agent_snapshot``.
|
|
173
|
+
"""
|
|
174
|
+
return await self._client.invoke_rpc(self._flow.get_agent_snapshot, session_id)
|
|
175
|
+
|
|
176
|
+
async def list_agent_messages(
|
|
177
|
+
self,
|
|
178
|
+
session_id: str,
|
|
179
|
+
*,
|
|
180
|
+
before_sequence: int | None = None,
|
|
181
|
+
limit: int = 50,
|
|
182
|
+
) -> AgentMessagesPage:
|
|
183
|
+
"""Return one page of stored messages, oldest first.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
session_id: Running or retained closed session.
|
|
187
|
+
before_sequence: Anchor from the previous page, or ``None`` for the newest page.
|
|
188
|
+
limit: Page size, clamped to 1 through 200.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The page from ``list_agent_messages``.
|
|
192
|
+
"""
|
|
193
|
+
return await self._client.invoke_rpc(
|
|
194
|
+
self._flow.list_agent_messages,
|
|
195
|
+
session_id,
|
|
196
|
+
ListAgentMessagesRequest(before_sequence=before_sequence, limit=limit),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def read_agent_event(
|
|
200
|
+
self,
|
|
201
|
+
session_id: str,
|
|
202
|
+
resume_token: str = "",
|
|
203
|
+
timeout: timedelta | None = None,
|
|
204
|
+
) -> StreamMessage[AgentEvent]:
|
|
205
|
+
"""Wait for the next ``AgentEvents`` message after a resume token.
|
|
206
|
+
|
|
207
|
+
Pass each returned ``resume_token`` to the next call. Events are best
|
|
208
|
+
effort: after a gap, rebuild from ``get_agent_snapshot``.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
session_id: Session whose events to read.
|
|
212
|
+
resume_token: Token from the previous message, or ``""`` for the
|
|
213
|
+
oldest retained event.
|
|
214
|
+
timeout: Server-side long-poll duration, or ``None`` for the default.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The next event and its resume token.
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
LongPollTimeoutError: If no event arrives before the long poll ends.
|
|
221
|
+
"""
|
|
222
|
+
return await self._client.read_stream(
|
|
223
|
+
session_id, self._flow.agent_events, resume_token, timeout
|
|
224
|
+
)
|
dex_agents/events.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""Live event writing for the ``AgentEvents`` Stream."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
from dex_agents.values import AgentEvent, ToolCallStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AgentEventType(StrEnum):
|
|
23
|
+
"""Name every event the session writes to ``AgentEvents``.
|
|
24
|
+
|
|
25
|
+
``AgentEvent.type`` holds one of these values as a string. Payloads are
|
|
26
|
+
JSON objects with snake_case keys.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
TURN_STARTED: A user message started a turn. Payload ``{"sequence"}``,
|
|
30
|
+
the user message sequence.
|
|
31
|
+
TURN_COMPLETED: The turn produced its final answer. Payload
|
|
32
|
+
``{"message_count"}``, the number of messages the turn appended.
|
|
33
|
+
TURN_FAILED: ``RecordTurnFailure`` ended the turn. Payload ``{"error"}``.
|
|
34
|
+
TEXT_DELTA: Visible model text. Payload ``{"text"}``.
|
|
35
|
+
REASONING_DELTA: A provider-authored reasoning summary. Payload ``{"text"}``.
|
|
36
|
+
TOOL_CALL_STARTED: A tool started. Payload ``{"tool_call_id", "tool_name"}``.
|
|
37
|
+
TOOL_CALL_FINISHED: A tool call's record became final, including a
|
|
38
|
+
rejection. Payload ``{"tool_call_id", "tool_name", "status"}``.
|
|
39
|
+
TOOL_CALL_DEFERRED: The model asked for a tool call, which runs in its own Step.
|
|
40
|
+
Payload ``{"tool_call_id", "tool_name", "effect", "requires_approval"}``.
|
|
41
|
+
INTERRUPT_RAISED: A tool call waits for approval. Payload
|
|
42
|
+
``{"interrupt_id", "tool_call_id", "tool_name", "deadline"}``.
|
|
43
|
+
INTERRUPT_RESOLVED: An interrupt was answered or expired. Payload
|
|
44
|
+
``{"interrupt_id", "approved", "rejection_message"}``.
|
|
45
|
+
USAGE_RECORDED: The session added one model call's usage. Payload
|
|
46
|
+
``{"recorded", "session"}``, each an ``AgentUsage`` object.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
TURN_STARTED = "turn_started"
|
|
50
|
+
TURN_COMPLETED = "turn_completed"
|
|
51
|
+
TURN_FAILED = "turn_failed"
|
|
52
|
+
TEXT_DELTA = "text_delta"
|
|
53
|
+
REASONING_DELTA = "reasoning_delta"
|
|
54
|
+
TOOL_CALL_STARTED = "tool_call_started"
|
|
55
|
+
TOOL_CALL_FINISHED = "tool_call_finished"
|
|
56
|
+
TOOL_CALL_DEFERRED = "tool_call_deferred"
|
|
57
|
+
INTERRUPT_RAISED = "interrupt_raised"
|
|
58
|
+
INTERRUPT_RESOLVED = "interrupt_resolved"
|
|
59
|
+
USAGE_RECORDED = "usage_recorded"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AgentEventWriter:
|
|
63
|
+
"""Write live events for one Step attempt to ``AgentEvents``.
|
|
64
|
+
|
|
65
|
+
The session gives every runner call a writer bound to the current turn ID,
|
|
66
|
+
Step execution ID, and Step attempt. Writes enqueue immediately and never
|
|
67
|
+
block. They are best effort: a retry can repeat them and a consumer can miss
|
|
68
|
+
them, so never put durable results only in events.
|
|
69
|
+
|
|
70
|
+
Every method is safe to call from any thread, such as a synchronous tool
|
|
71
|
+
running in an executor thread. A call from another thread hands the event
|
|
72
|
+
to the event loop that created the writer, and the Stream receives it in
|
|
73
|
+
call order. Events written after the runner call returns are dropped.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
>>> async def run_model_step(self, turn: AgentTurnContext) -> AgentTurnOutcome:
|
|
77
|
+
... async for chunk in model.stream(...):
|
|
78
|
+
... turn.events.write_text_delta(chunk.text)
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
write_agent_event: Callable[[AgentEvent], None],
|
|
84
|
+
turn_id: str | None,
|
|
85
|
+
attempt: int,
|
|
86
|
+
step_execution_id: str = "",
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Create a writer that stamps every event with its turn and Step attempt.
|
|
89
|
+
|
|
90
|
+
The session constructs writers. Construct one yourself only to test a
|
|
91
|
+
runner without a Dex Server. A writer created while an event loop runs
|
|
92
|
+
calls ``write_agent_event`` only on that loop's thread.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
write_agent_event: Callable that appends one event to the Stream.
|
|
96
|
+
turn_id: Turn stamped on every event, or ``None``.
|
|
97
|
+
attempt: One-based Step attempt stamped on every event.
|
|
98
|
+
step_execution_id: Dex Step execution ID stamped on every event.
|
|
99
|
+
"""
|
|
100
|
+
self._write_agent_event = write_agent_event
|
|
101
|
+
self._turn_id = turn_id
|
|
102
|
+
self._attempt = attempt
|
|
103
|
+
self._step_execution_id = step_execution_id
|
|
104
|
+
try:
|
|
105
|
+
self._loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
|
|
106
|
+
except RuntimeError:
|
|
107
|
+
self._loop = None
|
|
108
|
+
self._loop_thread_id = threading.get_ident()
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def turn_id(self) -> str | None:
|
|
112
|
+
"""Return the turn ID stamped on every event.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
``"turn-<n>"``, or ``None`` outside a turn.
|
|
116
|
+
"""
|
|
117
|
+
return self._turn_id
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def attempt(self) -> int:
|
|
121
|
+
"""Return the Step attempt stamped on every event.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The one-based attempt of the Step that owns this writer.
|
|
125
|
+
"""
|
|
126
|
+
return self._attempt
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def step_execution_id(self) -> str:
|
|
130
|
+
"""Return the Dex Step execution ID stamped on every event.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The ID shared by every attempt of the Step that owns this writer.
|
|
134
|
+
"""
|
|
135
|
+
return self._step_execution_id
|
|
136
|
+
|
|
137
|
+
def write_event(self, event_type: AgentEventType | str, payload: Mapping[str, object]) -> None:
|
|
138
|
+
"""Write one event with a JSON object payload.
|
|
139
|
+
|
|
140
|
+
Prefer the typed methods below for the standard event types. Use this
|
|
141
|
+
method for an adapter-specific type; give it an adapter prefix, such as
|
|
142
|
+
``"openai_agents.handoff"``, so it never collides with a standard type.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
event_type: Event type stored in ``AgentEvent.type``.
|
|
146
|
+
payload: JSON-compatible object stored as ``AgentEvent.payload`` text.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
TypeError: If ``payload`` is not JSON-serializable.
|
|
150
|
+
RuntimeError: If called from another thread after the writer's
|
|
151
|
+
event loop closed.
|
|
152
|
+
"""
|
|
153
|
+
event = AgentEvent(
|
|
154
|
+
type=str(event_type),
|
|
155
|
+
turn_id=self._turn_id,
|
|
156
|
+
attempt=self._attempt,
|
|
157
|
+
payload=json.dumps(dict(payload), ensure_ascii=False, sort_keys=True),
|
|
158
|
+
step_execution_id=self._step_execution_id,
|
|
159
|
+
)
|
|
160
|
+
if self._loop is None or threading.get_ident() == self._loop_thread_id:
|
|
161
|
+
self._write_agent_event(event)
|
|
162
|
+
return
|
|
163
|
+
# The Dex Step output queue belongs to the event loop and is not thread-safe.
|
|
164
|
+
self._loop.call_soon_threadsafe(self._write_agent_event, event)
|
|
165
|
+
|
|
166
|
+
def write_text_delta(self, text: str) -> None:
|
|
167
|
+
"""Write a chunk of visible model text.
|
|
168
|
+
|
|
169
|
+
Every call writes one Stream message. Batch very small chunks when the
|
|
170
|
+
framework emits one per token.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
text: Text chunk, written as ``{"text": text}``.
|
|
174
|
+
"""
|
|
175
|
+
if text:
|
|
176
|
+
self.write_event(AgentEventType.TEXT_DELTA, {"text": text})
|
|
177
|
+
|
|
178
|
+
def write_reasoning_delta(self, text: str) -> None:
|
|
179
|
+
"""Write a chunk of a provider-authored reasoning summary.
|
|
180
|
+
|
|
181
|
+
Call it only for an explicit provider reasoning-summary event. Never
|
|
182
|
+
label ordinary model text as reasoning.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
text: Reasoning summary chunk, written as ``{"text": text}``.
|
|
186
|
+
"""
|
|
187
|
+
if text:
|
|
188
|
+
self.write_event(AgentEventType.REASONING_DELTA, {"text": text})
|
|
189
|
+
|
|
190
|
+
def write_tool_call_started(self, tool_call_id: str, tool_name: str) -> None:
|
|
191
|
+
"""Report that a tool started running.
|
|
192
|
+
|
|
193
|
+
The session writes this event when a tool call Step starts. A runner
|
|
194
|
+
writes it only for a tool its framework cannot defer.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
tool_call_id: Framework tool call ID.
|
|
198
|
+
tool_name: Tool name.
|
|
199
|
+
"""
|
|
200
|
+
self.write_event(
|
|
201
|
+
AgentEventType.TOOL_CALL_STARTED,
|
|
202
|
+
{"tool_call_id": tool_call_id, "tool_name": tool_name},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def write_tool_call_finished(
|
|
206
|
+
self,
|
|
207
|
+
tool_call_id: str,
|
|
208
|
+
tool_name: str,
|
|
209
|
+
status: ToolCallStatus,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Report that a tool finished running.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
tool_call_id: Framework tool call ID.
|
|
215
|
+
tool_name: Tool name.
|
|
216
|
+
status: ``SUCCEEDED`` or ``FAILED`` for a call that ran, or another
|
|
217
|
+
final status the session recorded.
|
|
218
|
+
"""
|
|
219
|
+
self.write_event(
|
|
220
|
+
AgentEventType.TOOL_CALL_FINISHED,
|
|
221
|
+
{"tool_call_id": tool_call_id, "tool_name": tool_name, "status": str(status)},
|
|
222
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""Persistence definitions shared by every agent session Flow type.
|
|
10
|
+
|
|
11
|
+
Every ``AgentSessionFlow`` registers these exact definition objects, so a
|
|
12
|
+
Client can read any agent session with ``AsyncClient.get_attributes``. The
|
|
13
|
+
``AgentEvents`` Stream is not here: each Flow type owns its own Stream object,
|
|
14
|
+
available as ``AgentSessionFlow.agent_events``.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> values = await client.get_attributes("session-42", AGENT_STATE, AGENT_USAGE)
|
|
18
|
+
>>> state = values.get(AGENT_STATE)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dex import Attribute, AttributeMap, Channel, ChannelMap
|
|
24
|
+
|
|
25
|
+
from dex_agents.values import (
|
|
26
|
+
AgentInterrupt,
|
|
27
|
+
AgentInterruptResolution,
|
|
28
|
+
AgentMessage,
|
|
29
|
+
AgentState,
|
|
30
|
+
AgentToolCall,
|
|
31
|
+
AgentUsage,
|
|
32
|
+
AgentUserMessage,
|
|
33
|
+
ToolCallCompleted,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
#: Current session position. Unset until ``StartAgentSession`` commits.
|
|
37
|
+
AGENT_STATE: Attribute[AgentState] = Attribute("AgentState", AgentState)
|
|
38
|
+
|
|
39
|
+
#: Conversation messages keyed by ``message_sequence_key(sequence)``.
|
|
40
|
+
AGENT_MESSAGES: AttributeMap[AgentMessage] = AttributeMap("AgentMessages", AgentMessage)
|
|
41
|
+
|
|
42
|
+
#: Opaque framework run state JSON text, set only while a turn is suspended.
|
|
43
|
+
FRAMEWORK_RUN_STATE: Attribute[str] = Attribute("FrameworkRunState", str)
|
|
44
|
+
|
|
45
|
+
#: Deferred tool call records of the active turn, keyed by tool call ID.
|
|
46
|
+
AGENT_TOOL_CALLS: AttributeMap[AgentToolCall] = AttributeMap("AgentToolCalls", AgentToolCall)
|
|
47
|
+
|
|
48
|
+
#: Interrupts that ``resolve_interrupt`` can still answer.
|
|
49
|
+
PENDING_INTERRUPTS: Attribute[list[AgentInterrupt]] = Attribute(
|
|
50
|
+
"PendingInterrupts", list[AgentInterrupt]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Model usage of the whole session.
|
|
54
|
+
AGENT_USAGE: Attribute[AgentUsage] = Attribute("AgentUsage", AgentUsage)
|
|
55
|
+
|
|
56
|
+
#: User messages waiting for the session to start their turns.
|
|
57
|
+
QUEUED_USER_MESSAGES: Channel[AgentUserMessage] = Channel("QueuedUserMessages", AgentUserMessage)
|
|
58
|
+
|
|
59
|
+
#: Interrupt answers keyed by interrupt ID.
|
|
60
|
+
INTERRUPT_RESOLUTIONS: ChannelMap[AgentInterruptResolution] = ChannelMap(
|
|
61
|
+
"InterruptResolutions", AgentInterruptResolution
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
#: Internal results of tool call Steps; ``ResumeTurn`` waits for one per tool call.
|
|
65
|
+
TOOL_CALL_RESULTS: Channel[ToolCallCompleted] = Channel("ToolCallResults", ToolCallCompleted)
|
|
66
|
+
|
|
67
|
+
#: Default ``AgentEvents`` Stream capacity: 10 MiB.
|
|
68
|
+
DEFAULT_AGENT_EVENTS_CAPACITY_BYTES = 10 * 1024 * 1024
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def message_sequence_key(sequence: int) -> str:
|
|
72
|
+
"""Return the ``AgentMessages`` map instance for a message sequence.
|
|
73
|
+
|
|
74
|
+
Use it to read one stored message with ``AGENT_MESSAGES.instance(...)``.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
sequence: Positive message sequence.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The sequence as a 12-digit zero-padded decimal, such as ``"000000000007"``.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If ``sequence`` is not positive.
|
|
84
|
+
"""
|
|
85
|
+
if sequence <= 0:
|
|
86
|
+
raise ValueError("message sequence must be positive")
|
|
87
|
+
return f"{sequence:012d}"
|
dex_agents/py.typed
ADDED
|
File without changes
|