exoclaw-executor-dbos 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exoclaw_executor_dbos-0.1.0/.gitignore +7 -0
- exoclaw_executor_dbos-0.1.0/PKG-INFO +15 -0
- exoclaw_executor_dbos-0.1.0/README.md +5 -0
- exoclaw_executor_dbos-0.1.0/exoclaw_executor_dbos/__init__.py +5 -0
- exoclaw_executor_dbos-0.1.0/exoclaw_executor_dbos/executor.py +196 -0
- exoclaw_executor_dbos-0.1.0/exoclaw_executor_dbos/startup.py +43 -0
- exoclaw_executor_dbos-0.1.0/exoclaw_executor_dbos/turn.py +157 -0
- exoclaw_executor_dbos-0.1.0/pyproject.toml +15 -0
- exoclaw_executor_dbos-0.1.0/tests/__init__.py +0 -0
- exoclaw_executor_dbos-0.1.0/tests/test_executor.py +68 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exoclaw-executor-dbos
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DBOS-backed durable executor for exoclaw — turns and tool calls survive restarts
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: dbos>=1.0.0
|
|
7
|
+
Requires-Dist: exoclaw>=0.9.0
|
|
8
|
+
Requires-Dist: structlog>=24.0.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# exoclaw-executor-dbos
|
|
12
|
+
|
|
13
|
+
DBOS-backed durable executor for exoclaw. Makes agent turns survive process restarts.
|
|
14
|
+
|
|
15
|
+
Every LLM call and tool execution is a DBOS step, checkpointed to SQLite. If the process restarts mid-turn, DBOS replays completed steps and continues from the next one.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# exoclaw-executor-dbos
|
|
2
|
+
|
|
3
|
+
DBOS-backed durable executor for exoclaw. Makes agent turns survive process restarts.
|
|
4
|
+
|
|
5
|
+
Every LLM call and tool execution is a DBOS step, checkpointed to SQLite. If the process restarts mid-turn, DBOS replays completed steps and continues from the next one.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""DBOS-backed durable executor for exoclaw.
|
|
2
|
+
|
|
3
|
+
Drop-in replacement for DirectExecutor. Every LLM call and tool execution
|
|
4
|
+
is a DBOS step, automatically checkpointed to SQLite. If the process
|
|
5
|
+
restarts mid-turn, DBOS replays completed steps from the journal.
|
|
6
|
+
|
|
7
|
+
Architecture follows the same pattern as standd_agent's TemporalExecutor:
|
|
8
|
+
the agent loop runs inside a @DBOS.workflow(), and each chat/tool call
|
|
9
|
+
is a @DBOS.step().
|
|
10
|
+
|
|
11
|
+
Usage in nanobot wiring:
|
|
12
|
+
from exoclaw_executor_dbos import run_durable_turn, DBOSExecutor
|
|
13
|
+
|
|
14
|
+
# In message processing, instead of calling AgentLoop._process_message:
|
|
15
|
+
await run_durable_turn(session_id, message, ...)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextvars
|
|
21
|
+
import dataclasses
|
|
22
|
+
import json
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from dbos import DBOS
|
|
27
|
+
from exoclaw.agent.conversation import Conversation
|
|
28
|
+
from exoclaw.agent.tools.protocol import ToolContext
|
|
29
|
+
from exoclaw.agent.tools.registry import ToolRegistry
|
|
30
|
+
from exoclaw.providers.protocol import LLMProvider
|
|
31
|
+
from exoclaw.providers.types import LLMResponse, ToolCallRequest
|
|
32
|
+
|
|
33
|
+
# ── Serialization helpers ────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _response_to_dict(resp: LLMResponse) -> dict[str, Any]:
|
|
37
|
+
return dataclasses.asdict(resp)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _dict_to_response(d: dict[str, Any]) -> LLMResponse:
|
|
41
|
+
d = dict(d) # don't mutate caller's dict
|
|
42
|
+
tool_calls = [ToolCallRequest(**tc) for tc in d.pop("tool_calls", [])]
|
|
43
|
+
return LLMResponse(tool_calls=tool_calls, **d)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Per-task context for non-serializable refs ───────────────────────────────
|
|
47
|
+
# ContextVars are safe for concurrent workflows — each asyncio Task gets
|
|
48
|
+
# its own copy, so parallel turns don't stomp on each other.
|
|
49
|
+
|
|
50
|
+
_provider_var: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar(
|
|
51
|
+
"_provider_var", default=None
|
|
52
|
+
)
|
|
53
|
+
_registry_var: contextvars.ContextVar[ToolRegistry | None] = contextvars.ContextVar(
|
|
54
|
+
"_registry_var", default=None
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── DBOS step functions ──────────────────────────────────────────────────────
|
|
59
|
+
# Module-level so DBOS can register and replay them.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@DBOS.step(retries_allowed=True, max_attempts=3, interval_seconds=2)
|
|
63
|
+
async def _chat_step(
|
|
64
|
+
messages: list[dict[str, Any]],
|
|
65
|
+
tools_json: str | None,
|
|
66
|
+
model: str | None,
|
|
67
|
+
temperature: float,
|
|
68
|
+
max_tokens: int,
|
|
69
|
+
reasoning_effort: str | None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Durable LLM call. Result is cached by DBOS on completion."""
|
|
72
|
+
provider = _provider_var.get()
|
|
73
|
+
if provider is None:
|
|
74
|
+
raise RuntimeError("provider not set — call set_turn_context() before running turns")
|
|
75
|
+
tools = json.loads(tools_json) if tools_json else None
|
|
76
|
+
resp = await provider.chat(
|
|
77
|
+
messages=messages,
|
|
78
|
+
tools=tools,
|
|
79
|
+
model=model,
|
|
80
|
+
temperature=temperature,
|
|
81
|
+
max_tokens=max_tokens,
|
|
82
|
+
reasoning_effort=reasoning_effort,
|
|
83
|
+
)
|
|
84
|
+
return _response_to_dict(resp)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@DBOS.step(retries_allowed=True, max_attempts=2, interval_seconds=1)
|
|
88
|
+
async def _tool_step(
|
|
89
|
+
name: str,
|
|
90
|
+
params: dict[str, Any],
|
|
91
|
+
ctx_data: dict[str, Any] | None,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Durable tool execution. Result is cached by DBOS on completion."""
|
|
94
|
+
registry = _registry_var.get()
|
|
95
|
+
if registry is None:
|
|
96
|
+
raise RuntimeError("registry not set — call set_turn_context() before running turns")
|
|
97
|
+
ctx = ToolContext(**ctx_data) if ctx_data else None
|
|
98
|
+
return await registry.execute(name, params, ctx)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── DBOSExecutor ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DBOSExecutor:
|
|
105
|
+
"""Executor that routes AgentLoop operations through DBOS steps.
|
|
106
|
+
|
|
107
|
+
Must be used inside a @DBOS.workflow() — see run_durable_turn().
|
|
108
|
+
Sets ContextVar refs so steps can access provider/registry safely
|
|
109
|
+
across concurrent workflows.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
async def chat(
|
|
113
|
+
self,
|
|
114
|
+
provider: LLMProvider,
|
|
115
|
+
*,
|
|
116
|
+
messages: list[dict[str, object]],
|
|
117
|
+
tools: list[dict[str, object]] | None = None,
|
|
118
|
+
model: str | None = None,
|
|
119
|
+
temperature: float = 0.7,
|
|
120
|
+
max_tokens: int = 4096,
|
|
121
|
+
reasoning_effort: str | None = None,
|
|
122
|
+
) -> LLMResponse:
|
|
123
|
+
_provider_var.set(provider)
|
|
124
|
+
tools_json = json.dumps(tools) if tools else None
|
|
125
|
+
result = await _chat_step(
|
|
126
|
+
messages=list(messages),
|
|
127
|
+
tools_json=tools_json,
|
|
128
|
+
model=model,
|
|
129
|
+
temperature=temperature,
|
|
130
|
+
max_tokens=max_tokens,
|
|
131
|
+
reasoning_effort=reasoning_effort,
|
|
132
|
+
)
|
|
133
|
+
return _dict_to_response(result)
|
|
134
|
+
|
|
135
|
+
async def execute_tool(
|
|
136
|
+
self,
|
|
137
|
+
registry: ToolRegistry,
|
|
138
|
+
name: str,
|
|
139
|
+
params: dict[str, object],
|
|
140
|
+
ctx: ToolContext | None = None,
|
|
141
|
+
*,
|
|
142
|
+
tool_call_id: str | None = None,
|
|
143
|
+
) -> str:
|
|
144
|
+
_registry_var.set(registry)
|
|
145
|
+
ctx_data = dataclasses.asdict(ctx) if ctx else None
|
|
146
|
+
return await _tool_step(
|
|
147
|
+
name=name,
|
|
148
|
+
params=dict(params),
|
|
149
|
+
ctx_data=ctx_data,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def build_prompt(
|
|
153
|
+
self,
|
|
154
|
+
conversation: Conversation,
|
|
155
|
+
session_id: str,
|
|
156
|
+
message: str,
|
|
157
|
+
*,
|
|
158
|
+
channel: str | None = None,
|
|
159
|
+
chat_id: str | None = None,
|
|
160
|
+
media: list[str] | None = None,
|
|
161
|
+
plugin_context: list[str] | None = None,
|
|
162
|
+
**kwargs: list[str] | None,
|
|
163
|
+
) -> list[dict[str, object]]:
|
|
164
|
+
return await conversation.build_prompt(
|
|
165
|
+
session_id,
|
|
166
|
+
message,
|
|
167
|
+
channel=channel,
|
|
168
|
+
chat_id=chat_id,
|
|
169
|
+
media=media,
|
|
170
|
+
plugin_context=plugin_context,
|
|
171
|
+
**kwargs,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def record(
|
|
175
|
+
self,
|
|
176
|
+
conversation: Conversation,
|
|
177
|
+
session_id: str,
|
|
178
|
+
new_messages: list[dict[str, object]],
|
|
179
|
+
) -> None:
|
|
180
|
+
await conversation.record(session_id, new_messages)
|
|
181
|
+
|
|
182
|
+
async def clear(
|
|
183
|
+
self,
|
|
184
|
+
conversation: Conversation,
|
|
185
|
+
session_id: str,
|
|
186
|
+
) -> bool:
|
|
187
|
+
return await conversation.clear(session_id)
|
|
188
|
+
|
|
189
|
+
async def run_hook(
|
|
190
|
+
self,
|
|
191
|
+
fn: Callable[..., Awaitable[object]],
|
|
192
|
+
/,
|
|
193
|
+
*args: object,
|
|
194
|
+
**kwargs: object,
|
|
195
|
+
) -> object:
|
|
196
|
+
return await fn(*args, **kwargs)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""DBOS initialization and recovery for exoclaw.
|
|
2
|
+
|
|
3
|
+
Call init_dbos() once at app startup to initialize DBOS with SQLite.
|
|
4
|
+
Call recover() after set_turn_context() to resume incomplete workflows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
from dbos import DBOS
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def init_dbos(db_path: str | Path = "exoclaw.sqlite") -> None:
|
|
18
|
+
"""Initialize DBOS with SQLite.
|
|
19
|
+
|
|
20
|
+
Call this once at startup, before any turns run.
|
|
21
|
+
Does NOT trigger recovery — call recover() separately after
|
|
22
|
+
set_turn_context() has been called.
|
|
23
|
+
"""
|
|
24
|
+
DBOS(
|
|
25
|
+
config={
|
|
26
|
+
"name": "exoclaw",
|
|
27
|
+
"system_database_url": f"sqlite:///{db_path}",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
DBOS.launch()
|
|
31
|
+
logger.info("dbos_initialized", db_path=str(db_path))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def recover() -> None:
|
|
35
|
+
"""Recover incomplete workflows from a previous run.
|
|
36
|
+
|
|
37
|
+
Must be called AFTER set_turn_context() so that recovered
|
|
38
|
+
workflows have access to provider/conversation/tools.
|
|
39
|
+
"""
|
|
40
|
+
recover_fn = getattr(DBOS, "recover_pending_workflows", None)
|
|
41
|
+
if recover_fn is not None:
|
|
42
|
+
recover_fn()
|
|
43
|
+
logger.info("dbos_recovery_complete")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Durable turn — runs an agent turn as a DBOS workflow.
|
|
2
|
+
|
|
3
|
+
Equivalent to standd_agent's _run_turn() but using DBOS instead of Temporal.
|
|
4
|
+
The agent loop runs inside the workflow, with each chat/tool call as a step.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextvars
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dbos import DBOS
|
|
13
|
+
from exoclaw.agent.conversation import Conversation
|
|
14
|
+
from exoclaw.agent.loop import AgentLoop
|
|
15
|
+
from exoclaw.agent.tools.protocol import Tool
|
|
16
|
+
from exoclaw.providers.protocol import LLMProvider
|
|
17
|
+
|
|
18
|
+
from .executor import DBOSExecutor
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _NullBus:
|
|
22
|
+
"""No-op bus — AgentLoop requires one but DBOSExecutor bypasses it."""
|
|
23
|
+
|
|
24
|
+
async def publish_inbound(self, msg: Any) -> None:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
async def publish_outbound(self, msg: Any) -> None:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
async def get_inbound(self) -> Any:
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
async def get_outbound(self) -> Any:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Per-task context for non-serializable deps ───────────────────────────────
|
|
38
|
+
# ContextVars are safe for concurrent workflows — each asyncio Task gets
|
|
39
|
+
# its own copy.
|
|
40
|
+
|
|
41
|
+
_ctx_provider: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar(
|
|
42
|
+
"_ctx_provider", default=None
|
|
43
|
+
)
|
|
44
|
+
_ctx_conversation: contextvars.ContextVar[Conversation | None] = contextvars.ContextVar(
|
|
45
|
+
"_ctx_conversation", default=None
|
|
46
|
+
)
|
|
47
|
+
_ctx_tools: contextvars.ContextVar[list[Tool] | None] = contextvars.ContextVar(
|
|
48
|
+
"_ctx_tools", default=None
|
|
49
|
+
)
|
|
50
|
+
_ctx_on_tool_calls: contextvars.ContextVar[Any] = contextvars.ContextVar(
|
|
51
|
+
"_ctx_on_tool_calls", default=None
|
|
52
|
+
)
|
|
53
|
+
_ctx_on_tool_result: contextvars.ContextVar[Any] = contextvars.ContextVar(
|
|
54
|
+
"_ctx_on_tool_result", default=None
|
|
55
|
+
)
|
|
56
|
+
_ctx_on_pre_tool: contextvars.ContextVar[Any] = contextvars.ContextVar(
|
|
57
|
+
"_ctx_on_pre_tool", default=None
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_turn_context(
|
|
62
|
+
*,
|
|
63
|
+
provider: LLMProvider,
|
|
64
|
+
conversation: Conversation,
|
|
65
|
+
tools: list[Tool] | None = None,
|
|
66
|
+
on_tool_calls: Any = None,
|
|
67
|
+
on_tool_result: Any = None,
|
|
68
|
+
on_pre_tool: Any = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Set the non-serializable context for durable turns.
|
|
71
|
+
|
|
72
|
+
Call once at startup. The same provider/conversation/tools are used
|
|
73
|
+
for all turns and workflow recovery. Safe for concurrent turns via
|
|
74
|
+
ContextVar inheritance.
|
|
75
|
+
"""
|
|
76
|
+
_ctx_provider.set(provider)
|
|
77
|
+
_ctx_conversation.set(conversation)
|
|
78
|
+
_ctx_tools.set(tools)
|
|
79
|
+
_ctx_on_tool_calls.set(on_tool_calls)
|
|
80
|
+
_ctx_on_tool_result.set(on_tool_result)
|
|
81
|
+
_ctx_on_pre_tool.set(on_pre_tool)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@DBOS.workflow()
|
|
85
|
+
async def run_durable_turn(
|
|
86
|
+
session_id: str,
|
|
87
|
+
message: str,
|
|
88
|
+
*,
|
|
89
|
+
channel: str = "cli",
|
|
90
|
+
chat_id: str = "direct",
|
|
91
|
+
media: list[str] | None = None,
|
|
92
|
+
plugin_context: list[str] | None = None,
|
|
93
|
+
max_iterations: int = 40,
|
|
94
|
+
temperature: float = 0.7,
|
|
95
|
+
max_tokens: int = 4096,
|
|
96
|
+
model: str | None = None,
|
|
97
|
+
reasoning_effort: str | None = None,
|
|
98
|
+
turn_context: list[str] | None = None,
|
|
99
|
+
skills: list[str] | None = None,
|
|
100
|
+
) -> str | None:
|
|
101
|
+
"""Run one full agent turn as a durable DBOS workflow.
|
|
102
|
+
|
|
103
|
+
Every LLM call and tool execution within the turn is a DBOS step.
|
|
104
|
+
If the process restarts, DBOS replays completed steps and continues.
|
|
105
|
+
|
|
106
|
+
Returns the final assistant content, or None if max iterations reached.
|
|
107
|
+
"""
|
|
108
|
+
provider = _ctx_provider.get()
|
|
109
|
+
conversation = _ctx_conversation.get()
|
|
110
|
+
tools = _ctx_tools.get()
|
|
111
|
+
|
|
112
|
+
if provider is None:
|
|
113
|
+
raise RuntimeError("provider must be set via set_turn_context()")
|
|
114
|
+
if conversation is None:
|
|
115
|
+
raise RuntimeError("conversation must be set via set_turn_context()")
|
|
116
|
+
|
|
117
|
+
executor = DBOSExecutor()
|
|
118
|
+
|
|
119
|
+
loop = AgentLoop(
|
|
120
|
+
bus=_NullBus(), # type: ignore[arg-type]
|
|
121
|
+
provider=provider,
|
|
122
|
+
conversation=conversation,
|
|
123
|
+
executor=executor,
|
|
124
|
+
tools=tools,
|
|
125
|
+
model=model,
|
|
126
|
+
max_iterations=max_iterations,
|
|
127
|
+
temperature=temperature,
|
|
128
|
+
max_tokens=max_tokens,
|
|
129
|
+
reasoning_effort=reasoning_effort,
|
|
130
|
+
on_tool_calls=_ctx_on_tool_calls.get(),
|
|
131
|
+
on_tool_result=_ctx_on_tool_result.get(),
|
|
132
|
+
on_pre_tool=_ctx_on_pre_tool.get(),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
kwargs: dict[str, Any] = {}
|
|
136
|
+
if skills:
|
|
137
|
+
kwargs["skills"] = skills
|
|
138
|
+
|
|
139
|
+
initial = await executor.build_prompt(
|
|
140
|
+
conversation,
|
|
141
|
+
session_id,
|
|
142
|
+
message,
|
|
143
|
+
channel=channel,
|
|
144
|
+
chat_id=chat_id,
|
|
145
|
+
media=media,
|
|
146
|
+
plugin_context=plugin_context,
|
|
147
|
+
turn_context=turn_context,
|
|
148
|
+
**kwargs,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
final_content, _, all_msgs = await loop._run_agent_loop(initial)
|
|
152
|
+
|
|
153
|
+
# Persist the turn
|
|
154
|
+
new_msgs = all_msgs[len(initial) - 1 :]
|
|
155
|
+
await executor.record(conversation, session_id, new_msgs)
|
|
156
|
+
|
|
157
|
+
return final_content
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "exoclaw-executor-dbos"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "DBOS-backed durable executor for exoclaw — turns and tool calls survive restarts"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"exoclaw>=0.9.0",
|
|
9
|
+
"dbos>=1.0.0",
|
|
10
|
+
"structlog>=24.0.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Basic tests for exoclaw-executor-dbos."""
|
|
2
|
+
|
|
3
|
+
from exoclaw.providers.types import LLMResponse, ToolCallRequest
|
|
4
|
+
from exoclaw_executor_dbos.executor import (
|
|
5
|
+
DBOSExecutor,
|
|
6
|
+
_dict_to_response,
|
|
7
|
+
_response_to_dict,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestSerialization:
|
|
12
|
+
def test_response_roundtrip(self) -> None:
|
|
13
|
+
resp = LLMResponse(
|
|
14
|
+
content="hello",
|
|
15
|
+
tool_calls=[ToolCallRequest(id="1", name="exec", arguments={"cmd": "ls"})],
|
|
16
|
+
finish_reason="tool_calls",
|
|
17
|
+
usage={"prompt_tokens": 10, "completion_tokens": 5},
|
|
18
|
+
)
|
|
19
|
+
d = _response_to_dict(resp)
|
|
20
|
+
restored = _dict_to_response(d)
|
|
21
|
+
assert restored.content == "hello"
|
|
22
|
+
assert len(restored.tool_calls) == 1
|
|
23
|
+
assert restored.tool_calls[0].name == "exec"
|
|
24
|
+
assert restored.tool_calls[0].arguments == {"cmd": "ls"}
|
|
25
|
+
assert restored.finish_reason == "tool_calls"
|
|
26
|
+
|
|
27
|
+
def test_response_roundtrip_no_tools(self) -> None:
|
|
28
|
+
resp = LLMResponse(content="done", finish_reason="stop")
|
|
29
|
+
d = _response_to_dict(resp)
|
|
30
|
+
restored = _dict_to_response(d)
|
|
31
|
+
assert restored.content == "done"
|
|
32
|
+
assert restored.tool_calls == []
|
|
33
|
+
assert restored.finish_reason == "stop"
|
|
34
|
+
|
|
35
|
+
def test_response_roundtrip_with_reasoning(self) -> None:
|
|
36
|
+
resp = LLMResponse(
|
|
37
|
+
content="answer",
|
|
38
|
+
reasoning_content="I thought about it",
|
|
39
|
+
thinking_blocks=[{"type": "thinking", "text": "hmm"}],
|
|
40
|
+
)
|
|
41
|
+
d = _response_to_dict(resp)
|
|
42
|
+
restored = _dict_to_response(d)
|
|
43
|
+
assert restored.reasoning_content == "I thought about it"
|
|
44
|
+
assert restored.thinking_blocks == [{"type": "thinking", "text": "hmm"}]
|
|
45
|
+
|
|
46
|
+
def test_dict_to_response_does_not_mutate_input(self) -> None:
|
|
47
|
+
d = {
|
|
48
|
+
"content": "hi",
|
|
49
|
+
"tool_calls": [{"id": "1", "name": "exec", "arguments": {}}],
|
|
50
|
+
"finish_reason": "stop",
|
|
51
|
+
"usage": {},
|
|
52
|
+
"reasoning_content": None,
|
|
53
|
+
"thinking_blocks": None,
|
|
54
|
+
}
|
|
55
|
+
original_keys = set(d.keys())
|
|
56
|
+
_dict_to_response(d)
|
|
57
|
+
assert set(d.keys()) == original_keys # not mutated
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestDBOSExecutorProtocol:
|
|
61
|
+
def test_has_required_methods(self) -> None:
|
|
62
|
+
executor = DBOSExecutor()
|
|
63
|
+
assert hasattr(executor, "chat")
|
|
64
|
+
assert hasattr(executor, "execute_tool")
|
|
65
|
+
assert hasattr(executor, "build_prompt")
|
|
66
|
+
assert hasattr(executor, "record")
|
|
67
|
+
assert hasattr(executor, "clear")
|
|
68
|
+
assert hasattr(executor, "run_hook")
|