codex-autorunner 0.1.2__py3-none-any.whl → 1.0.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 (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,307 @@
1
+ import asyncio
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Any, AsyncGenerator, Dict, Optional, Union
5
+
6
+ from ...core.circuit_breaker import CircuitBreaker
7
+ from ...integrations.app_server.client import CodexAppServerClient
8
+ from .agent_backend import AgentBackend, AgentEvent, now_iso
9
+ from .run_event import (
10
+ ApprovalRequested,
11
+ Completed,
12
+ Failed,
13
+ OutputDelta,
14
+ RunEvent,
15
+ Started,
16
+ ToolCall,
17
+ )
18
+
19
+ _logger = logging.getLogger(__name__)
20
+
21
+ ApprovalDecision = Union[str, Dict[str, Any]]
22
+
23
+
24
+ class CodexAppServerBackend(AgentBackend):
25
+ def __init__(
26
+ self,
27
+ command: list[str],
28
+ *,
29
+ cwd: Optional[Path] = None,
30
+ env: Optional[Dict[str, str]] = None,
31
+ approval_policy: Optional[str] = None,
32
+ sandbox_policy: Optional[str] = None,
33
+ ):
34
+ self._command = command
35
+ self._cwd = cwd
36
+ self._env = env
37
+ self._approval_policy = approval_policy
38
+ self._sandbox_policy = sandbox_policy
39
+
40
+ self._client: Optional[CodexAppServerClient] = None
41
+ self._session_id: Optional[str] = None
42
+ self._thread_id: Optional[str] = None
43
+
44
+ self._circuit_breaker = CircuitBreaker("CodexAppServer", logger=_logger)
45
+ self._event_queue: asyncio.Queue[RunEvent] = asyncio.Queue()
46
+
47
+ async def _ensure_client(self) -> CodexAppServerClient:
48
+ if self._client is None:
49
+ self._client = CodexAppServerClient(
50
+ self._command,
51
+ cwd=self._cwd,
52
+ env=self._env,
53
+ approval_handler=self._handle_approval_request,
54
+ notification_handler=self._handle_notification,
55
+ )
56
+ await self._client.start()
57
+ return self._client
58
+
59
+ async def start_session(self, target: dict, context: dict) -> str:
60
+ client = await self._ensure_client()
61
+
62
+ repo_root = Path(context.get("workspace") or self._cwd or Path.cwd())
63
+
64
+ result = await client.thread_start(str(repo_root))
65
+ self._thread_id = result.get("id")
66
+
67
+ if not self._thread_id:
68
+ raise RuntimeError("Failed to start thread: missing thread ID")
69
+
70
+ self._session_id = self._thread_id
71
+ _logger.info("Started Codex app-server session: %s", self._session_id)
72
+
73
+ return self._session_id
74
+
75
+ async def run_turn(
76
+ self, session_id: str, message: str
77
+ ) -> AsyncGenerator[AgentEvent, None]:
78
+ client = await self._ensure_client()
79
+
80
+ if session_id:
81
+ self._thread_id = session_id
82
+
83
+ if not self._thread_id:
84
+ await self.start_session(target={}, context={})
85
+
86
+ _logger.info(
87
+ "Running turn on thread %s with message: %s",
88
+ self._thread_id or "unknown",
89
+ message[:100],
90
+ )
91
+
92
+ handle = await client.turn_start(
93
+ self._thread_id if self._thread_id else "default",
94
+ text=message,
95
+ approval_policy=self._approval_policy,
96
+ sandbox_policy=self._sandbox_policy,
97
+ )
98
+
99
+ yield AgentEvent.stream_delta(content=message, delta_type="user_message")
100
+
101
+ result = await handle.wait(timeout=600.0)
102
+
103
+ for msg in result.agent_messages:
104
+ yield AgentEvent.stream_delta(content=msg, delta_type="assistant_message")
105
+
106
+ for event_data in result.raw_events:
107
+ yield self._parse_raw_event(event_data)
108
+
109
+ yield AgentEvent.message_complete(
110
+ final_message="\n".join(result.agent_messages)
111
+ )
112
+
113
+ async def run_turn_events(
114
+ self, session_id: str, message: str
115
+ ) -> AsyncGenerator[RunEvent, None]:
116
+ client = await self._ensure_client()
117
+
118
+ if session_id:
119
+ self._thread_id = session_id
120
+
121
+ if not self._thread_id:
122
+ actual_session_id = await self.start_session(target={}, context={})
123
+ else:
124
+ actual_session_id = self._thread_id
125
+
126
+ _logger.info(
127
+ "Running turn events on thread %s with message: %s",
128
+ actual_session_id or "unknown",
129
+ message[:100],
130
+ )
131
+
132
+ yield Started(timestamp=now_iso(), session_id=actual_session_id)
133
+
134
+ yield OutputDelta(
135
+ timestamp=now_iso(), content=message, delta_type="user_message"
136
+ )
137
+
138
+ self._event_queue = asyncio.Queue()
139
+
140
+ handle = await client.turn_start(
141
+ actual_session_id if actual_session_id else "default",
142
+ text=message,
143
+ approval_policy=self._approval_policy,
144
+ sandbox_policy=self._sandbox_policy,
145
+ )
146
+
147
+ wait_task = asyncio.create_task(handle.wait(timeout=600.0))
148
+
149
+ try:
150
+ while True:
151
+ if not self._event_queue.empty():
152
+ run_event = self._event_queue.get_nowait()
153
+ if run_event:
154
+ yield run_event
155
+ continue
156
+
157
+ get_task = asyncio.create_task(self._event_queue.get())
158
+ done_set, pending_set = await asyncio.wait(
159
+ {wait_task, get_task}, return_when=asyncio.FIRST_COMPLETED
160
+ )
161
+
162
+ if wait_task in done_set:
163
+ if get_task in pending_set:
164
+ get_task.cancel()
165
+ result = wait_task.result()
166
+ for msg in result.agent_messages:
167
+ yield OutputDelta(
168
+ timestamp=now_iso(),
169
+ content=msg,
170
+ delta_type="assistant_message",
171
+ )
172
+ # raw_events already contain the same notifications we streamed
173
+ # through _event_queue; skipping here avoids double-emitting.
174
+ while not self._event_queue.empty():
175
+ extra = self._event_queue.get_nowait()
176
+ if extra:
177
+ yield extra
178
+ yield Completed(
179
+ timestamp=now_iso(),
180
+ final_message="\n".join(result.agent_messages),
181
+ )
182
+ break
183
+
184
+ for task in done_set:
185
+ if task is not wait_task:
186
+ run_event = task.result()
187
+ if run_event:
188
+ yield run_event
189
+ for task in pending_set:
190
+ task.cancel()
191
+ except Exception as e:
192
+ _logger.error("Error during turn execution: %s", e)
193
+ if not wait_task.done():
194
+ wait_task.cancel()
195
+ yield Failed(timestamp=now_iso(), error_message=str(e))
196
+
197
+ async def stream_events(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
198
+ if False:
199
+ yield AgentEvent.stream_delta(content="", delta_type="noop")
200
+
201
+ async def interrupt(self, session_id: str) -> None:
202
+ target_thread = session_id or self._thread_id
203
+ if self._client and target_thread:
204
+ try:
205
+ await self._client.turn_interrupt(target_thread)
206
+ _logger.info("Interrupted turn on thread %s", target_thread)
207
+ except Exception as e:
208
+ _logger.warning("Failed to interrupt turn: %s", e)
209
+
210
+ async def final_messages(self, session_id: str) -> list[str]:
211
+ return []
212
+
213
+ async def request_approval(
214
+ self, description: str, context: Optional[Dict[str, Any]] = None
215
+ ) -> bool:
216
+ raise NotImplementedError(
217
+ "Approvals are handled via approval_handler in CodexAppServerBackend"
218
+ )
219
+
220
+ async def _handle_approval_request(
221
+ self, request: Dict[str, Any]
222
+ ) -> ApprovalDecision:
223
+ method = request.get("method", "")
224
+ item_type = request.get("params", {}).get("type", "")
225
+
226
+ _logger.info("Received approval request: %s (type=%s)", method, item_type)
227
+ request_id = str(request.get("id") or "")
228
+ # Surface the approval request to consumers (e.g., Telegram) while defaulting to approve
229
+ await self._event_queue.put(
230
+ ApprovalRequested(
231
+ timestamp=now_iso(),
232
+ request_id=request_id,
233
+ description=method or "approval requested",
234
+ context=request.get("params", {}),
235
+ )
236
+ )
237
+
238
+ return {"approve": True}
239
+
240
+ async def _handle_notification(self, notification: Dict[str, Any]) -> None:
241
+ method = notification.get("method", "")
242
+ params = notification.get("params", {}) or {}
243
+ thread_id = params.get("threadId") or params.get("thread_id")
244
+ if self._thread_id and thread_id and thread_id != self._thread_id:
245
+ return
246
+ _logger.debug("Received notification: %s", method)
247
+ run_event = self._map_to_run_event(notification)
248
+ if run_event:
249
+ await self._event_queue.put(run_event)
250
+
251
+ def _map_to_run_event(self, event_data: Dict[str, Any]) -> Optional[RunEvent]:
252
+ method = event_data.get("method", "")
253
+
254
+ if method == "turn/streamDelta":
255
+ content = event_data.get("params", {}).get("delta", "")
256
+ return OutputDelta(
257
+ timestamp=now_iso(), content=content, delta_type="assistant_stream"
258
+ )
259
+
260
+ if method == "item/toolCall/start":
261
+ params = event_data.get("params", {})
262
+ return ToolCall(
263
+ timestamp=now_iso(),
264
+ tool_name=params.get("name", ""),
265
+ tool_input=params.get("input", {}),
266
+ )
267
+
268
+ if method == "item/toolCall/end":
269
+ return None
270
+
271
+ if method == "turn/error":
272
+ params = event_data.get("params", {})
273
+ error_message = params.get("message", "Unknown error")
274
+ return Failed(timestamp=now_iso(), error_message=error_message)
275
+
276
+ return None
277
+
278
+ def _parse_raw_event(self, event_data: Dict[str, Any]) -> AgentEvent:
279
+ method = event_data.get("method", "")
280
+
281
+ if method == "turn/streamDelta":
282
+ content = event_data.get("params", {}).get("delta", "")
283
+ return AgentEvent.stream_delta(
284
+ content=content, delta_type="assistant_stream"
285
+ )
286
+
287
+ if method == "item/toolCall/start":
288
+ params = event_data.get("params", {})
289
+ return AgentEvent.tool_call(
290
+ tool_name=params.get("name", ""),
291
+ tool_input=params.get("input", {}),
292
+ )
293
+
294
+ if method == "item/toolCall/end":
295
+ params = event_data.get("params", {})
296
+ return AgentEvent.tool_result(
297
+ tool_name=params.get("name", ""),
298
+ result=params.get("result"),
299
+ error=params.get("error"),
300
+ )
301
+
302
+ if method == "turn/error":
303
+ params = event_data.get("params", {})
304
+ error_message = params.get("message", "Unknown error")
305
+ return AgentEvent.error(error_message=error_message)
306
+
307
+ return AgentEvent.stream_delta(content="", delta_type="unknown_event")
@@ -0,0 +1,325 @@
1
+ import json
2
+ import logging
3
+ from typing import Any, AsyncGenerator, Dict, Optional
4
+
5
+ from ...agents.opencode.client import OpenCodeClient
6
+ from ...agents.opencode.events import SSEEvent
7
+ from .agent_backend import AgentBackend, AgentEvent, AgentEventType, now_iso
8
+ from .run_event import (
9
+ Completed,
10
+ Failed,
11
+ OutputDelta,
12
+ RunEvent,
13
+ Started,
14
+ ToolCall,
15
+ )
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+
20
+ class OpenCodeBackend(AgentBackend):
21
+ def __init__(
22
+ self,
23
+ base_url: str,
24
+ *,
25
+ auth: Optional[tuple[str, str]] = None,
26
+ timeout: Optional[float] = None,
27
+ agent: Optional[str] = None,
28
+ model: Optional[dict[str, str]] = None,
29
+ ):
30
+ self._client = OpenCodeClient(
31
+ base_url=base_url,
32
+ auth=auth,
33
+ timeout=timeout,
34
+ )
35
+ self._agent = agent
36
+ self._model = model
37
+
38
+ self._session_id: Optional[str] = None
39
+ self._message_count: int = 0
40
+ self._final_messages: list[str] = []
41
+
42
+ async def start_session(self, target: dict, context: dict) -> str:
43
+ result = await self._client.create_session(
44
+ title=f"Flow session {self._message_count}",
45
+ directory=None,
46
+ )
47
+
48
+ self._session_id = result.get("id")
49
+ if not self._session_id:
50
+ raise RuntimeError("Failed to create OpenCode session: missing session ID")
51
+
52
+ _logger.info("Started OpenCode session: %s", self._session_id)
53
+
54
+ return self._session_id
55
+
56
+ async def run_turn(
57
+ self, session_id: str, message: str
58
+ ) -> AsyncGenerator[AgentEvent, None]:
59
+ if session_id:
60
+ self._session_id = session_id
61
+ if not self._session_id:
62
+ self._session_id = await self.start_session(target={}, context={})
63
+
64
+ _logger.info("Sending message to session %s", self._session_id)
65
+
66
+ yield AgentEvent.stream_delta(content=message, delta_type="user_message")
67
+
68
+ await self._client.send_message(
69
+ self._session_id,
70
+ message=message,
71
+ agent=self._agent,
72
+ model=self._model,
73
+ )
74
+
75
+ self._message_count += 1
76
+ async for event in self._yield_events_until_completion():
77
+ yield event
78
+
79
+ async def run_turn_events(
80
+ self, session_id: str, message: str
81
+ ) -> AsyncGenerator[RunEvent, None]:
82
+ if session_id:
83
+ self._session_id = session_id
84
+ if not self._session_id:
85
+ self._session_id = await self.start_session(target={}, context={})
86
+
87
+ _logger.info("Running turn events on session %s", self._session_id)
88
+
89
+ yield Started(timestamp=now_iso(), session_id=self._session_id)
90
+
91
+ yield OutputDelta(
92
+ timestamp=now_iso(), content=message, delta_type="user_message"
93
+ )
94
+
95
+ await self._client.send_message(
96
+ self._session_id,
97
+ message=message,
98
+ agent=self._agent,
99
+ model=self._model,
100
+ )
101
+
102
+ self._message_count += 1
103
+
104
+ try:
105
+ async for run_event in self._yield_run_events_until_completion():
106
+ yield run_event
107
+ except Exception as e:
108
+ _logger.error("Error during turn execution: %s", e)
109
+ yield Failed(timestamp=now_iso(), error_message=str(e))
110
+
111
+ async def stream_events(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
112
+ if session_id:
113
+ self._session_id = session_id
114
+ if not self._session_id:
115
+ raise RuntimeError("Session not started. Call start_session() first.")
116
+
117
+ async for sse in self._client.stream_events(directory=None):
118
+ for agent_event in self._convert_sse_to_agent_event(sse):
119
+ yield agent_event
120
+
121
+ async def interrupt(self, session_id: str) -> None:
122
+ target_session = session_id or self._session_id
123
+ if target_session:
124
+ try:
125
+ await self._client.abort(target_session)
126
+ _logger.info("Interrupted OpenCode session %s", target_session)
127
+ except Exception as e:
128
+ _logger.warning("Failed to interrupt session: %s", e)
129
+
130
+ async def final_messages(self, session_id: str) -> list[str]:
131
+ return self._final_messages
132
+
133
+ async def request_approval(
134
+ self, description: str, context: Optional[Dict[str, Any]] = None
135
+ ) -> bool:
136
+ raise NotImplementedError("Approvals not implemented for OpenCodeBackend")
137
+
138
+ async def _yield_events_until_completion(self) -> AsyncGenerator[AgentEvent, None]:
139
+ paths = ["/event", "/global/event"]
140
+ if self._session_id:
141
+ paths.insert(0, f"/session/{self._session_id}/event")
142
+ try:
143
+ async for sse in self._client.stream_events(
144
+ directory=None,
145
+ paths=paths,
146
+ ):
147
+ if not self._sse_matches_session(sse):
148
+ continue
149
+ for agent_event in self._convert_sse_to_agent_event(sse):
150
+ yield agent_event
151
+ if agent_event.event_type in {
152
+ AgentEventType.MESSAGE_COMPLETE,
153
+ AgentEventType.SESSION_ENDED,
154
+ }:
155
+ if agent_event.event_type == AgentEventType.MESSAGE_COMPLETE:
156
+ self._final_messages.append(
157
+ agent_event.data.get("final_message", "")
158
+ )
159
+ return
160
+ except Exception as e:
161
+ _logger.warning("Error in event collection: %s", e)
162
+ yield AgentEvent.error(error_message=str(e))
163
+
164
+ async def _yield_run_events_until_completion(
165
+ self,
166
+ ) -> AsyncGenerator[RunEvent, None]:
167
+ paths = ["/event", "/global/event"]
168
+ if self._session_id:
169
+ paths.insert(0, f"/session/{self._session_id}/event")
170
+ try:
171
+ async for sse in self._client.stream_events(
172
+ directory=None,
173
+ paths=paths,
174
+ ):
175
+ if not self._sse_matches_session(sse):
176
+ continue
177
+ for run_event in self._convert_sse_to_run_event(sse):
178
+ yield run_event
179
+ if isinstance(run_event, (Completed, Failed)):
180
+ if isinstance(run_event, Completed):
181
+ self._final_messages.append(run_event.final_message)
182
+ return
183
+ except Exception as e:
184
+ _logger.warning("Error in run event collection: %s", e)
185
+ yield Failed(timestamp=now_iso(), error_message=str(e))
186
+
187
+ def _convert_sse_to_run_event(self, sse: SSEEvent) -> list[RunEvent]:
188
+ events: list[RunEvent] = []
189
+
190
+ try:
191
+ payload = json.loads(sse.data) if sse.data else {}
192
+ except json.JSONDecodeError:
193
+ return events
194
+
195
+ payload_type = payload.get("type", "")
196
+
197
+ if payload_type == "textDelta":
198
+ text = payload.get("text", "")
199
+ events.append(
200
+ OutputDelta(
201
+ timestamp=now_iso(), content=text, delta_type="assistant_stream"
202
+ )
203
+ )
204
+
205
+ elif payload_type == "toolCall":
206
+ tool_name = payload.get("toolName", "")
207
+ tool_input = payload.get("toolInput", {})
208
+ events.append(
209
+ ToolCall(
210
+ timestamp=now_iso(), tool_name=tool_name, tool_input=tool_input
211
+ )
212
+ )
213
+
214
+ elif payload_type == "toolCallEnd":
215
+ pass
216
+
217
+ elif payload_type == "messageEnd":
218
+ final_message = payload.get("message", "")
219
+ events.append(Completed(timestamp=now_iso(), final_message=final_message))
220
+
221
+ elif payload_type == "error":
222
+ error_message = payload.get("message", "Unknown error")
223
+ events.append(Failed(timestamp=now_iso(), error_message=error_message))
224
+
225
+ elif payload_type == "sessionEnd":
226
+ # Prefer messageEnd content if we already saw it; otherwise treat as failure.
227
+ final_message = payload.get("message") or ""
228
+ if final_message:
229
+ events.append(
230
+ Completed(timestamp=now_iso(), final_message=final_message)
231
+ )
232
+ else:
233
+ events.append(
234
+ Failed(
235
+ timestamp=now_iso(),
236
+ error_message=payload.get("reason", "Session ended early"),
237
+ )
238
+ )
239
+
240
+ return events
241
+
242
+ def _convert_sse_to_agent_event(self, sse: SSEEvent) -> list[AgentEvent]:
243
+ events: list[AgentEvent] = []
244
+
245
+ try:
246
+ payload = json.loads(sse.data) if sse.data else {}
247
+ except json.JSONDecodeError:
248
+ return events
249
+
250
+ payload_type = payload.get("type", "")
251
+ session_id = self._extract_session_id(payload)
252
+
253
+ if payload_type == "textDelta":
254
+ text = payload.get("text", "")
255
+ event = AgentEvent.stream_delta(content=text, delta_type="assistant_stream")
256
+ if session_id:
257
+ event.data["session_id"] = session_id
258
+ events.append(event)
259
+
260
+ elif payload_type == "toolCall":
261
+ tool_name = payload.get("toolName", "")
262
+ tool_input = payload.get("toolInput", {})
263
+ event = AgentEvent.tool_call(tool_name=tool_name, tool_input=tool_input)
264
+ if session_id:
265
+ event.data["session_id"] = session_id
266
+ events.append(event)
267
+
268
+ elif payload_type == "toolCallEnd":
269
+ tool_name = payload.get("toolName", "")
270
+ result = payload.get("result")
271
+ error = payload.get("error")
272
+ event = AgentEvent.tool_result(
273
+ tool_name=tool_name, result=result, error=error
274
+ )
275
+ if session_id:
276
+ event.data["session_id"] = session_id
277
+ events.append(event)
278
+
279
+ elif payload_type == "messageEnd":
280
+ final_message = payload.get("message", "")
281
+ event = AgentEvent.message_complete(final_message=final_message)
282
+ if session_id:
283
+ event.data["session_id"] = session_id
284
+ events.append(event)
285
+
286
+ elif payload_type == "error":
287
+ error_message = payload.get("message", "Unknown error")
288
+ event = AgentEvent.error(error_message=error_message)
289
+ if session_id:
290
+ event.data["session_id"] = session_id
291
+ events.append(event)
292
+
293
+ elif payload_type == "sessionEnd":
294
+ events.append(
295
+ AgentEvent(
296
+ type=AgentEventType.SESSION_ENDED.value,
297
+ timestamp=now_iso(),
298
+ data={
299
+ "reason": payload.get("reason", "unknown"),
300
+ "session_id": session_id,
301
+ },
302
+ )
303
+ )
304
+
305
+ return events
306
+
307
+ def _extract_session_id(self, payload: dict[str, Any]) -> Optional[str]:
308
+ for key in ("session", "sessionId", "sessionID", "session_id"):
309
+ value = payload.get(key)
310
+ if isinstance(value, str):
311
+ return value
312
+ return None
313
+
314
+ def _sse_matches_session(self, sse: SSEEvent) -> bool:
315
+ if not self._session_id:
316
+ return True
317
+ try:
318
+ payload = json.loads(sse.data) if sse.data else {}
319
+ except json.JSONDecodeError:
320
+ return True
321
+ session_id = self._extract_session_id(payload)
322
+ if session_id is None:
323
+ # If server does not tag events, do not drop them.
324
+ return True
325
+ return session_id == self._session_id
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Union
6
+
7
+
8
+ def now_iso() -> str:
9
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Started:
14
+ timestamp: str
15
+ session_id: str
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class OutputDelta:
20
+ timestamp: str
21
+ content: str
22
+ delta_type: str = "text"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ToolCall:
27
+ timestamp: str
28
+ tool_name: str
29
+ tool_input: dict[str, Any]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ApprovalRequested:
34
+ timestamp: str
35
+ request_id: str
36
+ description: str
37
+ context: dict[str, Any]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Completed:
42
+ timestamp: str
43
+ final_message: str = ""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Failed:
48
+ timestamp: str
49
+ error_message: str
50
+
51
+
52
+ RunEvent = Union[
53
+ Started,
54
+ OutputDelta,
55
+ ToolCall,
56
+ ApprovalRequested,
57
+ Completed,
58
+ Failed,
59
+ ]
60
+
61
+
62
+ __all__ = [
63
+ "RunEvent",
64
+ "Started",
65
+ "OutputDelta",
66
+ "ToolCall",
67
+ "ApprovalRequested",
68
+ "Completed",
69
+ "Failed",
70
+ "now_iso",
71
+ ]