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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ dist/
6
+ *.egg-info/
7
+ .coverage
@@ -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,5 @@
1
+ from .executor import DBOSExecutor
2
+ from .startup import init_dbos, recover
3
+ from .turn import run_durable_turn, set_turn_context
4
+
5
+ __all__ = ["DBOSExecutor", "init_dbos", "recover", "run_durable_turn", "set_turn_context"]
@@ -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")