codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,14 @@
1
1
  """Codex autorunner package."""
2
2
 
3
- __all__ = ["cli", "core", "integrations", "routes", "server", "voice", "web"]
3
+ __all__ = [
4
+ "cli",
5
+ "core",
6
+ "integrations",
7
+ "routes",
8
+ "server",
9
+ "surfaces",
10
+ "surfaces.web.routes",
11
+ "surfaces.web",
12
+ "voice",
13
+ "web",
14
+ ]
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any, AsyncIterator, Optional
5
5
 
6
- from ...core.app_server_events import AppServerEventBuffer
6
+ from ...integrations.app_server.event_buffer import AppServerEventBuffer
7
7
  from ...integrations.app_server.supervisor import WorkspaceAppServerSupervisor
8
8
  from ..base import AgentHarness
9
9
  from ..types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
@@ -21,6 +21,7 @@ class OpenCodeApiProfile:
21
21
 
22
22
  supports_prompt_async: bool = True
23
23
  supports_global_endpoints: bool = True
24
+ max_text_chars: Optional[int] = None
24
25
  spec_fetched: bool = False
25
26
 
26
27
 
@@ -83,6 +84,7 @@ class OpenCodeClient:
83
84
  *,
84
85
  auth: Optional[tuple[str, str]] = None,
85
86
  timeout: Optional[float] = None,
87
+ max_text_chars: Optional[int] = None,
86
88
  logger: Optional[logging.Logger] = None,
87
89
  ) -> None:
88
90
  self._client = httpx.AsyncClient(
@@ -93,6 +95,10 @@ class OpenCodeClient:
93
95
  self._logger = logger or logging.getLogger(__name__)
94
96
  self._api_profile: Optional[OpenCodeApiProfile] = None
95
97
  self._api_profile_lock = asyncio.Lock()
98
+ self._max_text_chars_override = (
99
+ int(max_text_chars) if isinstance(max_text_chars, int) else None
100
+ )
101
+ self._max_text_chars_cache: Optional[int] = None
96
102
 
97
103
  async def close(self) -> None:
98
104
  await self._client.aclose()
@@ -121,6 +127,7 @@ class OpenCodeClient:
121
127
  profile.supports_global_endpoints = self.has_endpoint(
122
128
  spec, "get", "/global/health"
123
129
  ) or self.has_endpoint(spec, "get", "/global/event")
130
+ profile.max_text_chars = self._extract_max_text_chars(spec)
124
131
 
125
132
  log_event(
126
133
  self._logger,
@@ -147,6 +154,103 @@ class OpenCodeClient:
147
154
  return OpenCodeApiProfile()
148
155
  return self._api_profile
149
156
 
157
+ @staticmethod
158
+ def _extract_max_text_chars(spec: dict[str, Any]) -> Optional[int]:
159
+ if not isinstance(spec, dict):
160
+ return None
161
+ components = spec.get("components")
162
+ if not isinstance(components, dict):
163
+ return None
164
+ schemas = components.get("schemas")
165
+ if not isinstance(schemas, dict):
166
+ return None
167
+ candidates: list[int] = []
168
+ for schema in schemas.values():
169
+ max_len = OpenCodeClient._find_text_max_length(schema)
170
+ if isinstance(max_len, int) and max_len > 0:
171
+ candidates.append(max_len)
172
+ return min(candidates) if candidates else None
173
+
174
+ @staticmethod
175
+ def _find_text_max_length(schema: Any) -> Optional[int]:
176
+ if not isinstance(schema, dict):
177
+ return None
178
+ candidates: list[int] = []
179
+ properties = schema.get("properties")
180
+ if isinstance(properties, dict) and "text" in properties:
181
+ text_schema = properties.get("text")
182
+ if isinstance(text_schema, dict):
183
+ max_len = text_schema.get("maxLength")
184
+ if isinstance(max_len, int) and max_len > 0:
185
+ candidates.append(max_len)
186
+ for key in ("allOf", "anyOf", "oneOf"):
187
+ seq = schema.get(key)
188
+ if isinstance(seq, list):
189
+ for item in seq:
190
+ item_len = OpenCodeClient._find_text_max_length(item)
191
+ if isinstance(item_len, int) and item_len > 0:
192
+ candidates.append(item_len)
193
+ return min(candidates) if candidates else None
194
+
195
+ def set_max_text_chars(self, value: Optional[int]) -> None:
196
+ self._max_text_chars_override = int(value) if isinstance(value, int) else None
197
+ self._max_text_chars_cache = None
198
+
199
+ async def _resolve_max_text_chars(
200
+ self, profile: Optional[OpenCodeApiProfile] = None
201
+ ) -> Optional[int]:
202
+ if self._max_text_chars_cache is not None:
203
+ return self._max_text_chars_cache
204
+ if profile is None:
205
+ profile = await self.detect_api_shape()
206
+ detected = (
207
+ profile.max_text_chars
208
+ if isinstance(profile.max_text_chars, int) and profile.max_text_chars > 0
209
+ else None
210
+ )
211
+ override = (
212
+ self._max_text_chars_override
213
+ if isinstance(self._max_text_chars_override, int)
214
+ and self._max_text_chars_override > 0
215
+ else None
216
+ )
217
+ if override is None:
218
+ resolved = detected
219
+ elif detected is None:
220
+ resolved = override
221
+ else:
222
+ resolved = min(override, detected)
223
+ self._max_text_chars_cache = resolved
224
+ return resolved
225
+
226
+ @staticmethod
227
+ def _split_text(text: str, max_chars: int) -> list[str]:
228
+ if max_chars <= 0 or len(text) <= max_chars:
229
+ return [text]
230
+ parts: list[str] = []
231
+ start = 0
232
+ length = len(text)
233
+ while start < length:
234
+ end = min(start + max_chars, length)
235
+ if end < length:
236
+ split = text.rfind("\n", start, end)
237
+ if split <= start:
238
+ split = text.rfind(" ", start, end)
239
+ if split > start:
240
+ end = split + 1
241
+ parts.append(text[start:end])
242
+ start = end
243
+ return [part for part in parts if part]
244
+
245
+ async def _build_text_parts(
246
+ self, message: str, profile: Optional[OpenCodeApiProfile] = None
247
+ ) -> list[dict[str, str]]:
248
+ limit = await self._resolve_max_text_chars(profile)
249
+ if limit is None:
250
+ return [{"type": "text", "text": message}]
251
+ chunks = self._split_text(message, limit)
252
+ return [{"type": "text", "text": chunk} for chunk in chunks]
253
+
150
254
  def _dir_params(self, directory: Optional[str]) -> dict[str, str]:
151
255
  return {"directory": directory} if directory else {}
152
256
 
@@ -275,8 +379,10 @@ class OpenCodeClient:
275
379
  model: Optional[dict[str, str]] = None,
276
380
  variant: Optional[str] = None,
277
381
  ) -> Any:
382
+ profile = await self.detect_api_shape()
383
+ parts = await self._build_text_parts(message, profile)
278
384
  payload: dict[str, Any] = {
279
- "parts": [{"type": "text", "text": message}],
385
+ "parts": parts,
280
386
  }
281
387
  if agent:
282
388
  payload["agent"] = agent
@@ -300,8 +406,10 @@ class OpenCodeClient:
300
406
  model: Optional[dict[str, str]] = None,
301
407
  variant: Optional[str] = None,
302
408
  ) -> Any:
409
+ profile = await self.detect_api_shape()
410
+ parts = await self._build_text_parts(message, profile)
303
411
  payload: dict[str, Any] = {
304
- "parts": [{"type": "text", "text": message}],
412
+ "parts": parts,
305
413
  }
306
414
  if agent:
307
415
  payload["agent"] = agent
@@ -310,7 +418,6 @@ class OpenCodeClient:
310
418
  if variant:
311
419
  payload["variant"] = variant
312
420
 
313
- profile = await self.detect_api_shape()
314
421
  if profile.supports_prompt_async:
315
422
  return await self._request(
316
423
  "POST",
@@ -335,8 +442,10 @@ class OpenCodeClient:
335
442
  model: Optional[dict[str, str]] = None,
336
443
  variant: Optional[str] = None,
337
444
  ) -> Any:
445
+ profile = await self.detect_api_shape()
446
+ parts = await self._build_text_parts(message, profile)
338
447
  payload: dict[str, Any] = {
339
- "parts": [{"type": "text", "text": message}],
448
+ "parts": parts,
340
449
  }
341
450
  if agent:
342
451
  payload["agent"] = agent
@@ -0,0 +1,3 @@
1
+ DEFAULT_TICKET_MODEL = "zai-coding-plan/glm-4.7"
2
+
3
+ __all__ = ["DEFAULT_TICKET_MODEL"]
@@ -6,9 +6,10 @@ import logging
6
6
  from pathlib import Path
7
7
  from typing import Any, AsyncIterator, Optional
8
8
 
9
- from ...core.app_server_events import format_sse
9
+ from ...integrations.app_server.event_buffer import format_sse
10
10
  from ..base import AgentHarness
11
11
  from ..types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
12
+ from .constants import DEFAULT_TICKET_MODEL
12
13
  from .runtime import (
13
14
  build_turn_id,
14
15
  extract_session_id,
@@ -168,6 +169,8 @@ class OpenCodeHarness(AgentHarness):
168
169
  sandbox_policy: Optional[Any],
169
170
  ) -> TurnRef:
170
171
  client = await self._supervisor.get_client(workspace_root)
172
+ if model is None:
173
+ model = DEFAULT_TICKET_MODEL
171
174
  model_payload = split_model_id(model)
172
175
  await client.prompt_async(
173
176
  conversation_id,
@@ -192,6 +195,8 @@ class OpenCodeHarness(AgentHarness):
192
195
  sandbox_policy: Optional[Any],
193
196
  ) -> TurnRef:
194
197
  client = await self._supervisor.get_client(workspace_root)
198
+ if model is None:
199
+ model = DEFAULT_TICKET_MODEL
195
200
  arguments = prompt if prompt else ""
196
201
 
197
202
  async def _send_review() -> None:
@@ -122,6 +122,12 @@ def extract_session_id(
122
122
  value = payload.get(key)
123
123
  if isinstance(value, str) and value:
124
124
  return value
125
+ info = payload.get("info")
126
+ if isinstance(info, dict):
127
+ for key in ("sessionID", "sessionId", "session_id"):
128
+ value = info.get(key)
129
+ if isinstance(value, str) and value:
130
+ return value
125
131
  if allow_fallback_id:
126
132
  value = payload.get("id")
127
133
  if isinstance(value, str) and value:
@@ -132,6 +138,12 @@ def extract_session_id(
132
138
  value = properties.get(key)
133
139
  if isinstance(value, str) and value:
134
140
  return value
141
+ info = properties.get("info")
142
+ if isinstance(info, dict):
143
+ for key in ("sessionID", "sessionId", "session_id"):
144
+ value = info.get(key)
145
+ if isinstance(value, str) and value:
146
+ return value
135
147
  part = properties.get("part")
136
148
  if isinstance(part, dict):
137
149
  for key in ("sessionID", "sessionId", "session_id"):
@@ -757,8 +769,9 @@ async def collect_opencode_output_from_events(
757
769
  error: Optional[str] = None
758
770
  message_roles: dict[str, str] = {}
759
771
  message_roles_seen = False
760
- last_role_seen: Optional[str] = None
761
772
  pending_text: dict[str, list[str]] = {}
773
+ pending_no_id: list[str] = []
774
+ no_id_role: Optional[str] = None
762
775
  fallback_message: Optional[tuple[Optional[str], Optional[str], str]] = None
763
776
  last_usage_total: Optional[int] = None
764
777
  last_context_window: Optional[int] = None
@@ -793,7 +806,7 @@ async def collect_opencode_output_from_events(
793
806
  return None
794
807
 
795
808
  def _register_message_role(payload: Any) -> tuple[Optional[str], Optional[str]]:
796
- nonlocal last_role_seen, message_roles_seen
809
+ nonlocal message_roles_seen
797
810
  if not isinstance(payload, dict):
798
811
  return None, None
799
812
  info = payload.get("info")
@@ -806,18 +819,27 @@ async def collect_opencode_output_from_events(
806
819
  if isinstance(role, str) and msg_id:
807
820
  message_roles[msg_id] = role
808
821
  message_roles_seen = True
809
- last_role_seen = role
810
822
  return msg_id, role if isinstance(role, str) else None
811
823
 
824
+ def _flush_pending_no_id_as_assistant() -> None:
825
+ nonlocal no_id_role
826
+ if pending_no_id:
827
+ text_parts.extend(pending_no_id)
828
+ pending_no_id.clear()
829
+ no_id_role = "assistant"
830
+
831
+ def _discard_pending_no_id() -> None:
832
+ if pending_no_id:
833
+ pending_no_id.clear()
834
+
812
835
  def _append_text_for_message(message_id: Optional[str], text: str) -> None:
813
836
  if not text:
814
837
  return
815
838
  if message_id is None:
816
- if not message_roles_seen:
817
- text_parts.append(text)
818
- return
819
- if last_role_seen != "user":
839
+ if no_id_role == "assistant":
820
840
  text_parts.append(text)
841
+ else:
842
+ pending_no_id.append(text)
821
843
  return
822
844
  role = message_roles.get(message_id)
823
845
  if role == "user":
@@ -839,12 +861,32 @@ async def collect_opencode_output_from_events(
839
861
  text_parts.extend(pending)
840
862
 
841
863
  def _flush_all_pending_text() -> None:
842
- if not pending_text:
864
+ if pending_text:
865
+ for pending in list(pending_text.values()):
866
+ if pending:
867
+ text_parts.extend(pending)
868
+ pending_text.clear()
869
+ if pending_no_id:
870
+ # If we have not seen a role yet, assume assistant for backwards
871
+ # compatibility with providers that omit roles entirely. Otherwise,
872
+ # only flush when we have already classified no-id text as assistant
873
+ # or when we have no other text (to avoid echoing user prompts).
874
+ if not message_roles_seen or no_id_role == "assistant" or not text_parts:
875
+ text_parts.extend(pending_no_id)
876
+ pending_no_id.clear()
877
+
878
+ def _handle_role_update(message_id: Optional[str], role: Optional[str]) -> None:
879
+ nonlocal no_id_role
880
+ if not role:
881
+ return
882
+ if role == "assistant":
883
+ _flush_pending_text(message_id)
884
+ _flush_pending_no_id_as_assistant()
843
885
  return
844
- for pending in list(pending_text.values()):
845
- if pending:
846
- text_parts.extend(pending)
847
- pending_text.clear()
886
+ if role == "user":
887
+ _flush_pending_text(message_id)
888
+ _discard_pending_no_id()
889
+ no_id_role = None
848
890
 
849
891
  async def _resolve_session_model_ids() -> tuple[Optional[str], Optional[str]]:
850
892
  nonlocal session_model_ids
@@ -1002,7 +1044,7 @@ async def collect_opencode_output_from_events(
1002
1044
  status_type=status_type,
1003
1045
  idle_seconds=idle_seconds,
1004
1046
  )
1005
- if not text_parts and pending_text:
1047
+ if not text_parts and (pending_text or pending_no_id):
1006
1048
  _flush_all_pending_text()
1007
1049
  break
1008
1050
  if last_primary_completion_at is not None:
@@ -1079,7 +1121,7 @@ async def collect_opencode_output_from_events(
1079
1121
  status_type=status_type,
1080
1122
  idle_seconds=idle_seconds,
1081
1123
  )
1082
- if not text_parts and pending_text:
1124
+ if not text_parts and (pending_text or pending_no_id):
1083
1125
  _flush_all_pending_text()
1084
1126
  break
1085
1127
  if last_primary_completion_at is not None:
@@ -1296,8 +1338,7 @@ async def collect_opencode_output_from_events(
1296
1338
  if event.event in ("message.updated", "message.completed"):
1297
1339
  if is_primary_session:
1298
1340
  msg_id, role = _register_message_role(payload)
1299
- if role == "assistant":
1300
- _flush_pending_text(msg_id)
1341
+ _handle_role_update(msg_id, role)
1301
1342
  if event.event == "message.part.updated":
1302
1343
  properties = (
1303
1344
  payload.get("properties") if isinstance(payload, dict) else None
@@ -1470,7 +1511,7 @@ async def collect_opencode_output_from_events(
1470
1511
  ):
1471
1512
  if not is_primary_session:
1472
1513
  continue
1473
- if not text_parts and pending_text:
1514
+ if not text_parts and (pending_text or pending_no_id):
1474
1515
  _flush_all_pending_text()
1475
1516
  break
1476
1517
  if event.event == "message.completed" and is_primary_session:
@@ -1485,7 +1526,7 @@ async def collect_opencode_output_from_events(
1485
1526
  resolved_role = message_roles.get(msg_id)
1486
1527
  if resolved_role == "assistant":
1487
1528
  _append_text_for_message(msg_id, text)
1488
- if pending_text:
1529
+ if pending_text or pending_no_id:
1489
1530
  _flush_all_pending_text()
1490
1531
 
1491
1532
  return OpenCodeTurnOutput(text="".join(text_parts).strip(), error=error)
@@ -55,6 +55,7 @@ class OpenCodeSupervisor:
55
55
  base_url: Optional[str] = None,
56
56
  subagent_models: Optional[Mapping[str, str]] = None,
57
57
  session_stall_timeout_seconds: Optional[float] = None,
58
+ max_text_chars: Optional[int] = None,
58
59
  ) -> None:
59
60
  self._command = [str(arg) for arg in command]
60
61
  self._logger = logger or logging.getLogger(__name__)
@@ -70,6 +71,7 @@ class OpenCodeSupervisor:
70
71
  self._base_env = base_env
71
72
  self._base_url = base_url
72
73
  self._subagent_models = subagent_models or {}
74
+ self._max_text_chars = max_text_chars
73
75
  self._handles: dict[str, OpenCodeHandle] = {}
74
76
  self._lock: Optional[asyncio.Lock] = None
75
77
 
@@ -275,6 +277,7 @@ class OpenCodeSupervisor:
275
277
  base_url,
276
278
  auth=self._auth,
277
279
  timeout=self._request_timeout,
280
+ max_text_chars=self._max_text_chars,
278
281
  logger=self._logger,
279
282
  )
280
283
  try:
@@ -344,6 +347,7 @@ class OpenCodeSupervisor:
344
347
  base_url,
345
348
  auth=self._auth,
346
349
  timeout=self._request_timeout,
350
+ max_text_chars=self._max_text_chars,
347
351
  logger=self._logger,
348
352
  )
349
353
  try:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import importlib.metadata
4
4
  import logging
5
+ import threading
5
6
  from dataclasses import dataclass
6
7
  from typing import Any, Callable, Iterable, Literal, Optional
7
8
 
@@ -102,6 +103,9 @@ _BUILTIN_AGENTS: dict[str, AgentDescriptor] = {
102
103
  # Lazy-loaded cache of built-in + plugin agents.
103
104
  _AGENT_CACHE: Optional[dict[str, AgentDescriptor]] = None
104
105
 
106
+ # Lock to protect cache initialization and reload from concurrent access.
107
+ _AGENT_CACHE_LOCK = threading.Lock()
108
+
105
109
 
106
110
  def _select_entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
107
111
  """Compatibility wrapper for `importlib.metadata.entry_points()` across py versions."""
@@ -163,14 +167,36 @@ def _load_agent_plugins() -> dict[str, AgentDescriptor]:
163
167
  )
164
168
  continue
165
169
 
166
- if descriptor.plugin_api_version != CAR_PLUGIN_API_VERSION:
170
+ api_version_raw = getattr(descriptor, "plugin_api_version", None)
171
+ if api_version_raw is None:
172
+ api_version = None
173
+ else:
174
+ try:
175
+ api_version = int(api_version_raw)
176
+ except Exception:
177
+ api_version = None
178
+ if api_version is None:
167
179
  _logger.warning(
168
- "Ignoring agent plugin %s (api_version=%s): expected %s",
180
+ "Ignoring agent plugin %s: invalid api_version %s",
169
181
  agent_id,
170
- descriptor.plugin_api_version,
182
+ api_version_raw,
183
+ )
184
+ continue
185
+ if api_version > CAR_PLUGIN_API_VERSION:
186
+ _logger.warning(
187
+ "Ignoring agent plugin %s (api_version=%s) requires newer core (%s)",
188
+ agent_id,
189
+ api_version,
171
190
  CAR_PLUGIN_API_VERSION,
172
191
  )
173
192
  continue
193
+ if api_version < CAR_PLUGIN_API_VERSION:
194
+ _logger.info(
195
+ "Loaded agent plugin %s with older api_version=%s (current=%s)",
196
+ agent_id,
197
+ api_version,
198
+ CAR_PLUGIN_API_VERSION,
199
+ )
174
200
 
175
201
  if agent_id in _BUILTIN_AGENTS:
176
202
  _logger.warning(
@@ -196,9 +222,11 @@ def _load_agent_plugins() -> dict[str, AgentDescriptor]:
196
222
  def _all_agents() -> dict[str, AgentDescriptor]:
197
223
  global _AGENT_CACHE
198
224
  if _AGENT_CACHE is None:
199
- agents = _BUILTIN_AGENTS.copy()
200
- agents.update(_load_agent_plugins())
201
- _AGENT_CACHE = agents
225
+ with _AGENT_CACHE_LOCK:
226
+ if _AGENT_CACHE is None:
227
+ agents = _BUILTIN_AGENTS.copy()
228
+ agents.update(_load_agent_plugins())
229
+ _AGENT_CACHE = agents
202
230
  return _AGENT_CACHE
203
231
 
204
232
 
@@ -209,7 +237,8 @@ def reload_agents() -> dict[str, AgentDescriptor]:
209
237
  """
210
238
 
211
239
  global _AGENT_CACHE
212
- _AGENT_CACHE = None
240
+ with _AGENT_CACHE_LOCK:
241
+ _AGENT_CACHE = None
213
242
  return get_registered_agents()
214
243
 
215
244