codex-autorunner 0.1.1__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.
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/__init__.py +20 -0
- codex_autorunner/agents/base.py +2 -2
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/__init__.py +4 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +305 -28
- codex_autorunner/agents/opencode/harness.py +71 -20
- codex_autorunner/agents/opencode/logging.py +225 -0
- codex_autorunner/agents/opencode/run_prompt.py +261 -0
- codex_autorunner/agents/opencode/runtime.py +1202 -132
- codex_autorunner/agents/opencode/supervisor.py +194 -68
- codex_autorunner/agents/registry.py +258 -0
- codex_autorunner/agents/types.py +2 -2
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +19 -40
- codex_autorunner/cli.py +234 -151
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_events.py +15 -6
- codex_autorunner/core/app_server_logging.py +55 -15
- codex_autorunner/core/app_server_prompts.py +28 -259
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +555 -133
- codex_autorunner/core/docs.py +54 -9
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +828 -274
- codex_autorunner/core/exceptions.py +60 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +21 -13
- codex_autorunner/core/locks.py +118 -1
- codex_autorunner/core/logging_utils.py +9 -6
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +161 -0
- codex_autorunner/core/run_index.py +223 -0
- codex_autorunner/core/runner_controller.py +44 -1
- codex_autorunner/core/runner_process.py +30 -1
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +273 -44
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +107 -75
- codex_autorunner/core/utils.py +167 -3
- codex_autorunner/discovery.py +3 -3
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +708 -153
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +474 -185
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +239 -1
- codex_autorunner/integrations/telegram/constants.py +19 -1
- codex_autorunner/integrations/telegram/dispatch.py +44 -8
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +90 -18
- codex_autorunner/integrations/telegram/notifications.py +126 -35
- codex_autorunner/integrations/telegram/outbox.py +214 -43
- codex_autorunner/integrations/telegram/progress_stream.py +42 -19
- codex_autorunner/integrations/telegram/runtime.py +24 -13
- codex_autorunner/integrations/telegram/service.py +500 -129
- codex_autorunner/integrations/telegram/state.py +1278 -330
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +37 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +25 -14
- codex_autorunner/routes/agents.py +18 -78
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +142 -113
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/repos.py +17 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/sessions.py +16 -8
- codex_autorunner/routes/settings.py +22 -0
- codex_autorunner/routes/shared.py +33 -3
- codex_autorunner/routes/system.py +22 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +9 -1
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +27 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +162 -150
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +67 -126
- codex_autorunner/static/index.html +788 -807
- codex_autorunner/static/liveUpdates.js +59 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -205
- codex_autorunner/static/styles.css +7577 -3758
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +53 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +21 -7
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +419 -199
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +47 -13
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +114 -109
- codex_autorunner/web/static_assets.py +55 -42
- codex_autorunner/web/static_refresh.py +86 -0
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/core/doc_chat.py +0 -1415
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -118
- codex_autorunner/spec_ingest.py +0 -788
- codex_autorunner/static/docChatActions.js +0 -279
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -274
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -442
- codex_autorunner/static/logs.js +0 -640
- codex_autorunner/static/runs.js +0 -409
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -86
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.1.dist-info/RECORD +0 -191
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
from pathlib import Path
|
|
@@ -8,7 +9,12 @@ from typing import Any, AsyncIterator, Optional
|
|
|
8
9
|
from ...core.app_server_events import format_sse
|
|
9
10
|
from ..base import AgentHarness
|
|
10
11
|
from ..types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
|
|
11
|
-
from .runtime import
|
|
12
|
+
from .runtime import (
|
|
13
|
+
build_turn_id,
|
|
14
|
+
extract_session_id,
|
|
15
|
+
extract_turn_id,
|
|
16
|
+
split_model_id,
|
|
17
|
+
)
|
|
12
18
|
from .supervisor import OpenCodeSupervisor
|
|
13
19
|
|
|
14
20
|
_logger = logging.getLogger(__name__)
|
|
@@ -24,8 +30,29 @@ def _coerce_providers(payload: Any) -> list[dict[str, Any]]:
|
|
|
24
30
|
return []
|
|
25
31
|
|
|
26
32
|
|
|
33
|
+
def _iter_provider_models(models_raw: Any) -> list[tuple[str, dict[str, Any]]]:
|
|
34
|
+
models: list[tuple[str, dict[str, Any]]] = []
|
|
35
|
+
if isinstance(models_raw, dict):
|
|
36
|
+
for model_id, model in models_raw.items():
|
|
37
|
+
if isinstance(model_id, str) and model_id:
|
|
38
|
+
if isinstance(model, dict):
|
|
39
|
+
models.append((model_id, model))
|
|
40
|
+
else:
|
|
41
|
+
models.append((model_id, {"id": model_id}))
|
|
42
|
+
return models
|
|
43
|
+
if isinstance(models_raw, list):
|
|
44
|
+
for entry in models_raw:
|
|
45
|
+
if isinstance(entry, dict):
|
|
46
|
+
model_id = entry.get("id") or entry.get("modelID")
|
|
47
|
+
if isinstance(model_id, str) and model_id:
|
|
48
|
+
models.append((model_id, entry))
|
|
49
|
+
elif isinstance(entry, str) and entry:
|
|
50
|
+
models.append((entry, {"id": entry}))
|
|
51
|
+
return models
|
|
52
|
+
|
|
53
|
+
|
|
27
54
|
class OpenCodeHarness(AgentHarness):
|
|
28
|
-
agent_id: AgentId = "opencode"
|
|
55
|
+
agent_id: AgentId = AgentId("opencode")
|
|
29
56
|
display_name = "OpenCode"
|
|
30
57
|
|
|
31
58
|
def __init__(self, supervisor: OpenCodeSupervisor) -> None:
|
|
@@ -58,12 +85,8 @@ class OpenCodeHarness(AgentHarness):
|
|
|
58
85
|
provider_id = provider.get("id") or provider.get("providerID")
|
|
59
86
|
if not isinstance(provider_id, str) or not provider_id:
|
|
60
87
|
continue
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
continue
|
|
64
|
-
for model_id, model in models_map.items():
|
|
65
|
-
if not isinstance(model_id, str) or not isinstance(model, dict):
|
|
66
|
-
continue
|
|
88
|
+
models_raw = provider.get("models")
|
|
89
|
+
for model_id, model in _iter_provider_models(models_raw):
|
|
67
90
|
name = model.get("name") or model.get("id") or model_id
|
|
68
91
|
display_name = name if isinstance(name, str) and name else model_id
|
|
69
92
|
capabilities = model.get("capabilities")
|
|
@@ -101,7 +124,7 @@ class OpenCodeHarness(AgentHarness):
|
|
|
101
124
|
session_id = extract_session_id(result) or result.get("id")
|
|
102
125
|
if not isinstance(session_id, str) or not session_id:
|
|
103
126
|
raise ValueError("OpenCode did not return a session id")
|
|
104
|
-
return ConversationRef(agent="opencode", id=session_id)
|
|
127
|
+
return ConversationRef(agent=AgentId("opencode"), id=session_id)
|
|
105
128
|
|
|
106
129
|
async def list_conversations(self, workspace_root: Path) -> list[ConversationRef]:
|
|
107
130
|
client = await self._supervisor.get_client(workspace_root)
|
|
@@ -117,7 +140,9 @@ class OpenCodeHarness(AgentHarness):
|
|
|
117
140
|
for entry in sessions:
|
|
118
141
|
session_id = extract_session_id(entry) or entry.get("id")
|
|
119
142
|
if isinstance(session_id, str) and session_id:
|
|
120
|
-
conversations.append(
|
|
143
|
+
conversations.append(
|
|
144
|
+
ConversationRef(agent=AgentId("opencode"), id=session_id)
|
|
145
|
+
)
|
|
121
146
|
return conversations
|
|
122
147
|
|
|
123
148
|
async def resume_conversation(
|
|
@@ -129,7 +154,7 @@ class OpenCodeHarness(AgentHarness):
|
|
|
129
154
|
except Exception:
|
|
130
155
|
result = {}
|
|
131
156
|
session_id = extract_session_id(result) or conversation_id
|
|
132
|
-
return ConversationRef(agent="opencode", id=session_id)
|
|
157
|
+
return ConversationRef(agent=AgentId("opencode"), id=session_id)
|
|
133
158
|
|
|
134
159
|
async def start_turn(
|
|
135
160
|
self,
|
|
@@ -144,7 +169,7 @@ class OpenCodeHarness(AgentHarness):
|
|
|
144
169
|
) -> TurnRef:
|
|
145
170
|
client = await self._supervisor.get_client(workspace_root)
|
|
146
171
|
model_payload = split_model_id(model)
|
|
147
|
-
await client.
|
|
172
|
+
await client.prompt_async(
|
|
148
173
|
conversation_id,
|
|
149
174
|
message=prompt,
|
|
150
175
|
model=model_payload,
|
|
@@ -168,13 +193,23 @@ class OpenCodeHarness(AgentHarness):
|
|
|
168
193
|
) -> TurnRef:
|
|
169
194
|
client = await self._supervisor.get_client(workspace_root)
|
|
170
195
|
arguments = prompt if prompt else ""
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
|
|
197
|
+
async def _send_review() -> None:
|
|
198
|
+
try:
|
|
199
|
+
result = await client.send_command(
|
|
200
|
+
conversation_id,
|
|
201
|
+
command="review",
|
|
202
|
+
arguments=arguments,
|
|
203
|
+
model=model,
|
|
204
|
+
)
|
|
205
|
+
turn_id = extract_turn_id(conversation_id, result)
|
|
206
|
+
if turn_id:
|
|
207
|
+
_logger.debug("OpenCode review started: %s", turn_id)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
_logger.warning("OpenCode review command failed: %s", exc)
|
|
210
|
+
|
|
211
|
+
asyncio.create_task(_send_review())
|
|
212
|
+
turn_id = build_turn_id(conversation_id)
|
|
178
213
|
return TurnRef(conversation_id=conversation_id, turn_id=turn_id)
|
|
179
214
|
|
|
180
215
|
async def interrupt(
|
|
@@ -199,7 +234,23 @@ class OpenCodeHarness(AgentHarness):
|
|
|
199
234
|
except json.JSONDecodeError:
|
|
200
235
|
parsed = {"raw": payload}
|
|
201
236
|
session_id = extract_session_id(parsed)
|
|
202
|
-
|
|
237
|
+
status_type = None
|
|
238
|
+
if event.event == "session.status" and isinstance(parsed, dict):
|
|
239
|
+
properties = parsed.get("properties")
|
|
240
|
+
if isinstance(properties, dict):
|
|
241
|
+
status = properties.get("status") or {}
|
|
242
|
+
else:
|
|
243
|
+
status = parsed.get("status") or {}
|
|
244
|
+
if isinstance(status, dict):
|
|
245
|
+
status_type = status.get("type") or status.get("status")
|
|
246
|
+
if (
|
|
247
|
+
event.event == "session.idle"
|
|
248
|
+
or (
|
|
249
|
+
event.event == "session.status"
|
|
250
|
+
and isinstance(status_type, str)
|
|
251
|
+
and status_type.lower() == "idle"
|
|
252
|
+
)
|
|
253
|
+
) and session_id == conversation_id:
|
|
203
254
|
break
|
|
204
255
|
if session_id and session_id != conversation_id:
|
|
205
256
|
continue
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from ...core.text_delta_coalescer import TextDeltaCoalescer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenCodeEventFormatter:
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self._seen_reasoning_parts: set[str] = set()
|
|
12
|
+
self._reasoning_coalescers: dict[str, TextDeltaCoalescer] = {}
|
|
13
|
+
self._tool_last_status: dict[str, str] = {}
|
|
14
|
+
self._seen_patch_hashes: set[str] = set()
|
|
15
|
+
self._logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
def flush_all_reasoning(self) -> list[str]:
|
|
18
|
+
lines: list[str] = []
|
|
19
|
+
for coalescer in self._reasoning_coalescers.values():
|
|
20
|
+
remaining = coalescer.flush_all()
|
|
21
|
+
for line in remaining:
|
|
22
|
+
if line.strip():
|
|
23
|
+
lines.append(f"**{line.strip()}**")
|
|
24
|
+
self._reasoning_coalescers.clear()
|
|
25
|
+
return lines
|
|
26
|
+
|
|
27
|
+
def reset(self) -> None:
|
|
28
|
+
self._seen_reasoning_parts.clear()
|
|
29
|
+
self._reasoning_coalescers.clear()
|
|
30
|
+
self._tool_last_status.clear()
|
|
31
|
+
self._seen_patch_hashes.clear()
|
|
32
|
+
|
|
33
|
+
def format_part(
|
|
34
|
+
self, part_type: str, part: dict[str, Any], delta_text: Optional[str]
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
lines: list[str] = []
|
|
37
|
+
part_id = part.get("id") or part.get("partId")
|
|
38
|
+
|
|
39
|
+
if part_type == "reasoning":
|
|
40
|
+
lines.extend(self._format_reasoning_part(part_id, delta_text))
|
|
41
|
+
|
|
42
|
+
elif part_type == "tool":
|
|
43
|
+
lines.extend(self._format_tool_part(part))
|
|
44
|
+
|
|
45
|
+
elif part_type == "patch":
|
|
46
|
+
lines.extend(self._format_patch_part(part))
|
|
47
|
+
|
|
48
|
+
return lines
|
|
49
|
+
|
|
50
|
+
def _format_reasoning_part(
|
|
51
|
+
self, part_id: Optional[str], delta_text: Optional[str]
|
|
52
|
+
) -> list[str]:
|
|
53
|
+
lines: list[str] = []
|
|
54
|
+
key = part_id or "reasoning"
|
|
55
|
+
|
|
56
|
+
if delta_text:
|
|
57
|
+
if key not in self._seen_reasoning_parts:
|
|
58
|
+
lines.append("thinking")
|
|
59
|
+
self._seen_reasoning_parts.add(key)
|
|
60
|
+
|
|
61
|
+
if key not in self._reasoning_coalescers:
|
|
62
|
+
self._reasoning_coalescers[key] = TextDeltaCoalescer()
|
|
63
|
+
coalescer = self._reasoning_coalescers[key]
|
|
64
|
+
coalescer.add(delta_text)
|
|
65
|
+
complete_lines = coalescer.flush_lines()
|
|
66
|
+
for line in complete_lines:
|
|
67
|
+
if line.strip():
|
|
68
|
+
lines.append(f"**{line.strip()}**")
|
|
69
|
+
return lines
|
|
70
|
+
|
|
71
|
+
def _format_tool_part(self, part: dict[str, Any]) -> list[str]:
|
|
72
|
+
lines: list[str] = []
|
|
73
|
+
tool_id = part.get("callID") or part.get("id")
|
|
74
|
+
tool_name = part.get("tool") or part.get("name") or ""
|
|
75
|
+
|
|
76
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
77
|
+
return lines
|
|
78
|
+
|
|
79
|
+
state = part.get("state", {})
|
|
80
|
+
if not isinstance(state, dict):
|
|
81
|
+
state = {}
|
|
82
|
+
|
|
83
|
+
status = state.get("status")
|
|
84
|
+
if isinstance(status, str) and status:
|
|
85
|
+
key = f"{tool_id}:{tool_name}" if tool_id else tool_name
|
|
86
|
+
last_status = self._tool_last_status.get(key)
|
|
87
|
+
|
|
88
|
+
if last_status != status:
|
|
89
|
+
self._tool_last_status[key] = status
|
|
90
|
+
|
|
91
|
+
if status in ("running", "pending"):
|
|
92
|
+
lines.append("exec")
|
|
93
|
+
lines.append(f"tool: {tool_name}")
|
|
94
|
+
|
|
95
|
+
elif status == "completed":
|
|
96
|
+
if last_status not in ("running", "pending"):
|
|
97
|
+
lines.append("exec")
|
|
98
|
+
lines.append(f"tool: {tool_name}")
|
|
99
|
+
exit_code = state.get("exitCode")
|
|
100
|
+
if exit_code is not None:
|
|
101
|
+
lines.append(f"exit {exit_code}")
|
|
102
|
+
|
|
103
|
+
elif status in ("error", "failed"):
|
|
104
|
+
if last_status not in ("running", "pending"):
|
|
105
|
+
lines.append("exec")
|
|
106
|
+
lines.append(f"tool: {tool_name}")
|
|
107
|
+
error = state.get("error")
|
|
108
|
+
if isinstance(error, (str, dict)):
|
|
109
|
+
if isinstance(error, dict):
|
|
110
|
+
error = error.get("message") or error.get("error")
|
|
111
|
+
if isinstance(error, str) and error:
|
|
112
|
+
lines.append(f"error: {error}")
|
|
113
|
+
|
|
114
|
+
elif tool_id is None:
|
|
115
|
+
lines.append("exec")
|
|
116
|
+
lines.append(f"tool: {tool_name}")
|
|
117
|
+
|
|
118
|
+
input_preview: Optional[str] = None
|
|
119
|
+
for key in ("input", "command", "cmd", "script"):
|
|
120
|
+
value = part.get(key)
|
|
121
|
+
if isinstance(value, str) and value.strip():
|
|
122
|
+
input_preview = value.strip()
|
|
123
|
+
break
|
|
124
|
+
if input_preview is None:
|
|
125
|
+
args = part.get("args") or part.get("arguments") or part.get("params")
|
|
126
|
+
if isinstance(args, dict):
|
|
127
|
+
for key in ("command", "cmd", "script", "input"):
|
|
128
|
+
value = args.get(key)
|
|
129
|
+
if isinstance(value, str) and value.strip():
|
|
130
|
+
input_preview = value.strip()
|
|
131
|
+
break
|
|
132
|
+
elif isinstance(args, str) and args.strip():
|
|
133
|
+
input_preview = args.strip()
|
|
134
|
+
if input_preview:
|
|
135
|
+
if len(input_preview) > 240:
|
|
136
|
+
input_preview = input_preview[:240] + "…"
|
|
137
|
+
lines.append(f"input: {input_preview}")
|
|
138
|
+
|
|
139
|
+
return lines
|
|
140
|
+
|
|
141
|
+
def _format_patch_part(self, part: dict[str, Any]) -> list[str]:
|
|
142
|
+
lines: list[str] = []
|
|
143
|
+
patch_hash = part.get("hash")
|
|
144
|
+
|
|
145
|
+
if isinstance(patch_hash, str) and patch_hash:
|
|
146
|
+
if patch_hash in self._seen_patch_hashes:
|
|
147
|
+
return lines
|
|
148
|
+
self._seen_patch_hashes.add(patch_hash)
|
|
149
|
+
|
|
150
|
+
files = part.get("files")
|
|
151
|
+
if isinstance(files, list):
|
|
152
|
+
if files:
|
|
153
|
+
lines.append("file update")
|
|
154
|
+
for file_entry in files:
|
|
155
|
+
if isinstance(file_entry, dict):
|
|
156
|
+
path = file_entry.get("path") or file_entry.get("file")
|
|
157
|
+
action = file_entry.get("status") or "M"
|
|
158
|
+
if isinstance(path, str) and path:
|
|
159
|
+
lines.append(f"{action} {path}")
|
|
160
|
+
elif isinstance(file_entry, str):
|
|
161
|
+
lines.append(f"M {file_entry}")
|
|
162
|
+
|
|
163
|
+
elif isinstance(files, str):
|
|
164
|
+
lines.append("file update")
|
|
165
|
+
lines.append(f"M {files}")
|
|
166
|
+
|
|
167
|
+
return lines
|
|
168
|
+
|
|
169
|
+
def format_usage(self, usage: dict[str, Any]) -> list[str]:
|
|
170
|
+
lines: list[str] = []
|
|
171
|
+
|
|
172
|
+
total = usage.get("totalTokens")
|
|
173
|
+
if isinstance(total, int):
|
|
174
|
+
input_tokens = usage.get("inputTokens")
|
|
175
|
+
cached_tokens = usage.get("cachedInputTokens")
|
|
176
|
+
output_tokens = usage.get("outputTokens")
|
|
177
|
+
reasoning_tokens = usage.get("reasoningTokens")
|
|
178
|
+
|
|
179
|
+
parts: list[str] = []
|
|
180
|
+
if isinstance(input_tokens, int):
|
|
181
|
+
parts.append(f"input: {input_tokens}")
|
|
182
|
+
if isinstance(cached_tokens, int) and cached_tokens > 0:
|
|
183
|
+
parts.append(f"cached: {cached_tokens}")
|
|
184
|
+
if isinstance(output_tokens, int):
|
|
185
|
+
parts.append(f"output: {output_tokens}")
|
|
186
|
+
if isinstance(reasoning_tokens, int):
|
|
187
|
+
parts.append(f"reasoning: {reasoning_tokens}")
|
|
188
|
+
|
|
189
|
+
if parts:
|
|
190
|
+
lines.append(f"tokens used - {', '.join(parts)}")
|
|
191
|
+
else:
|
|
192
|
+
lines.append(f"tokens used: {total}")
|
|
193
|
+
|
|
194
|
+
context_window = usage.get("modelContextWindow")
|
|
195
|
+
if isinstance(context_window, int) and context_window > 0:
|
|
196
|
+
lines.append(f"context window: {context_window}")
|
|
197
|
+
|
|
198
|
+
return lines
|
|
199
|
+
|
|
200
|
+
def format_permission(self, properties: dict[str, Any]) -> list[str]:
|
|
201
|
+
lines: list[str] = []
|
|
202
|
+
|
|
203
|
+
reason = properties.get("reason") or properties.get("message")
|
|
204
|
+
if isinstance(reason, str) and reason:
|
|
205
|
+
lines.append(f"permission: {reason}")
|
|
206
|
+
else:
|
|
207
|
+
lines.append("permission requested")
|
|
208
|
+
|
|
209
|
+
return lines
|
|
210
|
+
|
|
211
|
+
def format_error(self, error: Any) -> list[str]:
|
|
212
|
+
lines: list[str] = []
|
|
213
|
+
|
|
214
|
+
message = None
|
|
215
|
+
if isinstance(error, dict):
|
|
216
|
+
message = error.get("message") or error.get("error") or error.get("detail")
|
|
217
|
+
elif isinstance(error, str):
|
|
218
|
+
message = error
|
|
219
|
+
|
|
220
|
+
if isinstance(message, str) and message:
|
|
221
|
+
lines.append(f"error: {message}")
|
|
222
|
+
else:
|
|
223
|
+
lines.append("error: session error")
|
|
224
|
+
|
|
225
|
+
return lines
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Awaitable, Callable, Optional
|
|
9
|
+
|
|
10
|
+
from .runtime import (
|
|
11
|
+
PERMISSION_ALLOW,
|
|
12
|
+
OpenCodeTurnOutput,
|
|
13
|
+
build_turn_id,
|
|
14
|
+
collect_opencode_output,
|
|
15
|
+
extract_session_id,
|
|
16
|
+
opencode_missing_env,
|
|
17
|
+
parse_message_response,
|
|
18
|
+
split_model_id,
|
|
19
|
+
)
|
|
20
|
+
from .supervisor import OpenCodeSupervisor
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class OpenCodeRunResult:
|
|
25
|
+
session_id: str
|
|
26
|
+
turn_id: str
|
|
27
|
+
output_text: str
|
|
28
|
+
output_error: Optional[str]
|
|
29
|
+
stopped: bool
|
|
30
|
+
timed_out: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class OpenCodeRunConfig:
|
|
35
|
+
agent: str
|
|
36
|
+
model: Optional[str]
|
|
37
|
+
reasoning: Optional[str]
|
|
38
|
+
prompt: str
|
|
39
|
+
workspace_root: str
|
|
40
|
+
timeout_seconds: int = 3600
|
|
41
|
+
interrupt_grace_seconds: int = 10
|
|
42
|
+
on_turn_start: Optional[Callable[[str, str], Awaitable[None]]] = None
|
|
43
|
+
permission_policy: str = PERMISSION_ALLOW
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def run_opencode_prompt(
|
|
47
|
+
supervisor: OpenCodeSupervisor,
|
|
48
|
+
config: OpenCodeRunConfig,
|
|
49
|
+
*,
|
|
50
|
+
should_stop: Optional[Callable[[], bool]] = None,
|
|
51
|
+
logger: Optional[logging.Logger] = None,
|
|
52
|
+
) -> OpenCodeRunResult:
|
|
53
|
+
client = await supervisor.get_client(Path(config.workspace_root))
|
|
54
|
+
|
|
55
|
+
session_id: Optional[str] = None
|
|
56
|
+
try:
|
|
57
|
+
session = await client.create_session(directory=config.workspace_root)
|
|
58
|
+
session_id = extract_session_id(session, allow_fallback_id=True)
|
|
59
|
+
if not isinstance(session_id, str) or not session_id:
|
|
60
|
+
raise ValueError("OpenCode did not return a session id")
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise RuntimeError(f"Failed to create OpenCode session: {exc}") from exc
|
|
63
|
+
|
|
64
|
+
model_payload = split_model_id(config.model)
|
|
65
|
+
missing_env = await opencode_missing_env(
|
|
66
|
+
client, config.workspace_root, model_payload
|
|
67
|
+
)
|
|
68
|
+
if missing_env:
|
|
69
|
+
provider_id = model_payload.get("providerID") if model_payload else None
|
|
70
|
+
missing_label = ", ".join(missing_env)
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
f"OpenCode provider {provider_id or 'selected'} requires env vars: {missing_label}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
opencode_turn_started = False
|
|
76
|
+
await supervisor.mark_turn_started(Path(config.workspace_root))
|
|
77
|
+
opencode_turn_started = True
|
|
78
|
+
turn_id = build_turn_id(session_id)
|
|
79
|
+
|
|
80
|
+
if config.on_turn_start is not None:
|
|
81
|
+
try:
|
|
82
|
+
await config.on_turn_start(session_id, turn_id)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
stopped = False
|
|
87
|
+
timed_out = False
|
|
88
|
+
output_result: Optional[OpenCodeTurnOutput] = None
|
|
89
|
+
|
|
90
|
+
stop_task = None
|
|
91
|
+
if should_stop is not None:
|
|
92
|
+
|
|
93
|
+
async def _wait_for_stop() -> bool:
|
|
94
|
+
while True:
|
|
95
|
+
if should_stop():
|
|
96
|
+
return True
|
|
97
|
+
await asyncio.sleep(0.2)
|
|
98
|
+
|
|
99
|
+
stop_task = asyncio.create_task(_wait_for_stop())
|
|
100
|
+
|
|
101
|
+
async def _abort_session(reason: str) -> None:
|
|
102
|
+
try:
|
|
103
|
+
await client.abort(session_id)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
if logger is not None:
|
|
106
|
+
logger.warning(f"OpenCode abort failed ({reason}): {exc}")
|
|
107
|
+
|
|
108
|
+
permission_policy = config.permission_policy or PERMISSION_ALLOW
|
|
109
|
+
ready_event = asyncio.Event()
|
|
110
|
+
output_task = asyncio.create_task(
|
|
111
|
+
collect_opencode_output(
|
|
112
|
+
client,
|
|
113
|
+
session_id=session_id,
|
|
114
|
+
workspace_path=config.workspace_root,
|
|
115
|
+
model_payload=model_payload,
|
|
116
|
+
permission_policy=permission_policy,
|
|
117
|
+
should_stop=should_stop,
|
|
118
|
+
ready_event=ready_event,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
122
|
+
await asyncio.wait_for(ready_event.wait(), timeout=2.0)
|
|
123
|
+
prompt_task = asyncio.create_task(
|
|
124
|
+
client.prompt_async(
|
|
125
|
+
session_id,
|
|
126
|
+
message=config.prompt,
|
|
127
|
+
model=model_payload,
|
|
128
|
+
variant=config.reasoning,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
timeout_task = asyncio.create_task(asyncio.sleep(config.timeout_seconds))
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
|
|
135
|
+
async def _finish_output(
|
|
136
|
+
ignore_errors: bool,
|
|
137
|
+
) -> Optional[OpenCodeTurnOutput]:
|
|
138
|
+
if output_task.done():
|
|
139
|
+
try:
|
|
140
|
+
return await output_task
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
if not ignore_errors:
|
|
143
|
+
raise
|
|
144
|
+
if logger is not None:
|
|
145
|
+
logger.warning(f"OpenCode output failed after interrupt: {exc}")
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
grace_seconds = max(0, config.interrupt_grace_seconds or 0)
|
|
149
|
+
if grace_seconds <= 0:
|
|
150
|
+
output_task.cancel()
|
|
151
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
152
|
+
await output_task
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
return await asyncio.wait_for(output_task, timeout=grace_seconds)
|
|
157
|
+
except asyncio.TimeoutError:
|
|
158
|
+
output_task.cancel()
|
|
159
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
160
|
+
await output_task
|
|
161
|
+
if logger is not None:
|
|
162
|
+
logger.warning("OpenCode output did not stop within grace period")
|
|
163
|
+
return None
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
if not ignore_errors:
|
|
166
|
+
raise
|
|
167
|
+
if logger is not None:
|
|
168
|
+
logger.warning(f"OpenCode output failed after interrupt: {exc}")
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
tasks = {output_task, prompt_task, timeout_task}
|
|
172
|
+
if stop_task is not None:
|
|
173
|
+
tasks.add(stop_task)
|
|
174
|
+
|
|
175
|
+
while True:
|
|
176
|
+
done, pending = await asyncio.wait(
|
|
177
|
+
tasks, return_when=asyncio.FIRST_COMPLETED
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if output_task in done:
|
|
181
|
+
output_result = await output_task
|
|
182
|
+
if should_stop is not None and should_stop():
|
|
183
|
+
stopped = True
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
if stop_task is not None and stop_task in done:
|
|
187
|
+
stopped = True
|
|
188
|
+
if logger is not None:
|
|
189
|
+
logger.info("OpenCode prompt stopped")
|
|
190
|
+
await _abort_session("stop")
|
|
191
|
+
output_result = await _finish_output(ignore_errors=True)
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
if timeout_task in done:
|
|
195
|
+
timed_out = True
|
|
196
|
+
if logger is not None:
|
|
197
|
+
logger.warning("OpenCode prompt timed out")
|
|
198
|
+
await _abort_session("timeout")
|
|
199
|
+
output_result = await _finish_output(ignore_errors=True)
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
if prompt_task in done:
|
|
203
|
+
try:
|
|
204
|
+
await prompt_task
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
if logger is not None:
|
|
207
|
+
logger.error(f"OpenCode prompt failed: {exc}")
|
|
208
|
+
output_task.cancel()
|
|
209
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
210
|
+
await output_task
|
|
211
|
+
raise RuntimeError(f"OpenCode prompt failed: {exc}") from exc
|
|
212
|
+
tasks.discard(prompt_task)
|
|
213
|
+
tasks = pending
|
|
214
|
+
|
|
215
|
+
finally:
|
|
216
|
+
timeout_task.cancel()
|
|
217
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
218
|
+
await timeout_task
|
|
219
|
+
if stop_task is not None:
|
|
220
|
+
stop_task.cancel()
|
|
221
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
222
|
+
await stop_task
|
|
223
|
+
if not prompt_task.done():
|
|
224
|
+
prompt_task.cancel()
|
|
225
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
226
|
+
await prompt_task
|
|
227
|
+
if opencode_turn_started:
|
|
228
|
+
try:
|
|
229
|
+
await supervisor.mark_turn_finished(Path(config.workspace_root))
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
output_text = output_result.text if output_result else ""
|
|
234
|
+
output_error = output_result.error if output_result else None
|
|
235
|
+
if prompt_task.done() and not output_text:
|
|
236
|
+
try:
|
|
237
|
+
prompt_response = prompt_task.result()
|
|
238
|
+
except Exception:
|
|
239
|
+
prompt_response = None
|
|
240
|
+
if prompt_response is not None:
|
|
241
|
+
fallback = parse_message_response(prompt_response)
|
|
242
|
+
if fallback.text:
|
|
243
|
+
output_text = fallback.text
|
|
244
|
+
if fallback.error and not output_error:
|
|
245
|
+
output_error = fallback.error
|
|
246
|
+
|
|
247
|
+
return OpenCodeRunResult(
|
|
248
|
+
session_id=session_id,
|
|
249
|
+
turn_id=turn_id,
|
|
250
|
+
output_text=output_text,
|
|
251
|
+
output_error=output_error,
|
|
252
|
+
stopped=stopped,
|
|
253
|
+
timed_out=timed_out,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
__all__ = [
|
|
258
|
+
"OpenCodeRunResult",
|
|
259
|
+
"OpenCodeRunConfig",
|
|
260
|
+
"run_opencode_prompt",
|
|
261
|
+
]
|