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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +469 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,18 +1,41 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
1
3
|
import json
|
|
2
4
|
import logging
|
|
5
|
+
from pathlib import Path
|
|
3
6
|
from typing import Any, AsyncGenerator, Dict, Optional
|
|
4
7
|
|
|
5
8
|
from ...agents.opencode.client import OpenCodeClient
|
|
6
9
|
from ...agents.opencode.events import SSEEvent
|
|
7
|
-
from .
|
|
8
|
-
from .
|
|
10
|
+
from ...agents.opencode.logging import OpenCodeEventFormatter
|
|
11
|
+
from ...agents.opencode.runtime import (
|
|
12
|
+
OpenCodeTurnOutput,
|
|
13
|
+
build_turn_id,
|
|
14
|
+
collect_opencode_output,
|
|
15
|
+
extract_session_id,
|
|
16
|
+
map_approval_policy_to_permission,
|
|
17
|
+
opencode_missing_env,
|
|
18
|
+
parse_message_response,
|
|
19
|
+
split_model_id,
|
|
20
|
+
)
|
|
21
|
+
from ...agents.opencode.supervisor import OpenCodeSupervisor
|
|
22
|
+
from ...core.ports.agent_backend import (
|
|
23
|
+
AgentBackend,
|
|
24
|
+
AgentEvent,
|
|
25
|
+
AgentEventType,
|
|
26
|
+
now_iso,
|
|
27
|
+
)
|
|
28
|
+
from ...core.ports.run_event import (
|
|
9
29
|
Completed,
|
|
10
30
|
Failed,
|
|
11
31
|
OutputDelta,
|
|
12
32
|
RunEvent,
|
|
33
|
+
RunNotice,
|
|
13
34
|
Started,
|
|
35
|
+
TokenUsage,
|
|
14
36
|
ToolCall,
|
|
15
37
|
)
|
|
38
|
+
from ...core.text_delta_coalescer import StreamingTextCoalescer
|
|
16
39
|
|
|
17
40
|
_logger = logging.getLogger(__name__)
|
|
18
41
|
|
|
@@ -20,32 +43,74 @@ _logger = logging.getLogger(__name__)
|
|
|
20
43
|
class OpenCodeBackend(AgentBackend):
|
|
21
44
|
def __init__(
|
|
22
45
|
self,
|
|
23
|
-
base_url: str,
|
|
24
46
|
*,
|
|
47
|
+
base_url: Optional[str] = None,
|
|
48
|
+
supervisor: Optional[OpenCodeSupervisor] = None,
|
|
49
|
+
workspace_root: Optional[Path] = None,
|
|
25
50
|
auth: Optional[tuple[str, str]] = None,
|
|
26
51
|
timeout: Optional[float] = None,
|
|
27
52
|
agent: Optional[str] = None,
|
|
28
|
-
model: Optional[
|
|
53
|
+
model: Optional[str] = None,
|
|
54
|
+
reasoning: Optional[str] = None,
|
|
55
|
+
approval_policy: Optional[str] = None,
|
|
56
|
+
session_stall_timeout_seconds: Optional[float] = None,
|
|
57
|
+
logger: Optional[logging.Logger] = None,
|
|
29
58
|
):
|
|
30
|
-
self.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
self._supervisor = supervisor
|
|
60
|
+
self._workspace_root = Path(workspace_root) if workspace_root else None
|
|
61
|
+
self._client: Optional[OpenCodeClient]
|
|
62
|
+
if base_url:
|
|
63
|
+
self._client = OpenCodeClient(
|
|
64
|
+
base_url=base_url,
|
|
65
|
+
auth=auth,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
logger=logger,
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
self._client = None
|
|
35
71
|
self._agent = agent
|
|
36
72
|
self._model = model
|
|
73
|
+
self._reasoning = reasoning
|
|
74
|
+
self._approval_policy = approval_policy
|
|
75
|
+
self._session_stall_timeout_seconds = session_stall_timeout_seconds
|
|
76
|
+
self._logger = logger or _logger
|
|
37
77
|
|
|
38
78
|
self._session_id: Optional[str] = None
|
|
39
79
|
self._message_count: int = 0
|
|
40
80
|
self._final_messages: list[str] = []
|
|
81
|
+
self._last_turn_id: Optional[str] = None
|
|
82
|
+
self._last_token_total: Optional[dict[str, Any]] = None
|
|
83
|
+
self._event_formatter = OpenCodeEventFormatter()
|
|
84
|
+
|
|
85
|
+
def configure(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
model: Optional[str],
|
|
89
|
+
reasoning: Optional[str],
|
|
90
|
+
approval_policy: Optional[str],
|
|
91
|
+
) -> None:
|
|
92
|
+
self._model = model
|
|
93
|
+
self._reasoning = reasoning
|
|
94
|
+
self._approval_policy = approval_policy
|
|
41
95
|
|
|
42
96
|
async def start_session(self, target: dict, context: dict) -> str:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
97
|
+
client = await self._ensure_client()
|
|
98
|
+
workspace_root = self._workspace_root or Path(context.get("workspace") or ".")
|
|
99
|
+
resume_session = context.get("session_id") or context.get("thread_id")
|
|
100
|
+
if isinstance(resume_session, str) and resume_session:
|
|
101
|
+
try:
|
|
102
|
+
await client.get_session(resume_session)
|
|
103
|
+
self._session_id = resume_session
|
|
104
|
+
except Exception:
|
|
105
|
+
self._session_id = None
|
|
106
|
+
|
|
107
|
+
if not self._session_id:
|
|
108
|
+
result = await client.create_session(
|
|
109
|
+
title=f"Flow session {self._message_count}",
|
|
110
|
+
directory=str(workspace_root),
|
|
111
|
+
)
|
|
112
|
+
self._session_id = extract_session_id(result, allow_fallback_id=True)
|
|
47
113
|
|
|
48
|
-
self._session_id = result.get("id")
|
|
49
114
|
if not self._session_id:
|
|
50
115
|
raise RuntimeError("Failed to create OpenCode session: missing session ID")
|
|
51
116
|
|
|
@@ -56,6 +121,7 @@ class OpenCodeBackend(AgentBackend):
|
|
|
56
121
|
async def run_turn(
|
|
57
122
|
self, session_id: str, message: str
|
|
58
123
|
) -> AsyncGenerator[AgentEvent, None]:
|
|
124
|
+
client = await self._ensure_client()
|
|
59
125
|
if session_id:
|
|
60
126
|
self._session_id = session_id
|
|
61
127
|
if not self._session_id:
|
|
@@ -65,11 +131,11 @@ class OpenCodeBackend(AgentBackend):
|
|
|
65
131
|
|
|
66
132
|
yield AgentEvent.stream_delta(content=message, delta_type="user_message")
|
|
67
133
|
|
|
68
|
-
await
|
|
134
|
+
await client.send_message(
|
|
69
135
|
self._session_id,
|
|
70
136
|
message=message,
|
|
71
137
|
agent=self._agent,
|
|
72
|
-
model=self._model,
|
|
138
|
+
model=split_model_id(self._model) if self._model else None,
|
|
73
139
|
)
|
|
74
140
|
|
|
75
141
|
self._message_count += 1
|
|
@@ -79,34 +145,192 @@ class OpenCodeBackend(AgentBackend):
|
|
|
79
145
|
async def run_turn_events(
|
|
80
146
|
self, session_id: str, message: str
|
|
81
147
|
) -> AsyncGenerator[RunEvent, None]:
|
|
148
|
+
client = await self._ensure_client()
|
|
149
|
+
workspace_root = self._workspace_root or Path(".")
|
|
150
|
+
|
|
82
151
|
if session_id:
|
|
83
152
|
self._session_id = session_id
|
|
84
153
|
if not self._session_id:
|
|
85
|
-
self._session_id = await self.start_session(
|
|
154
|
+
self._session_id = await self.start_session(
|
|
155
|
+
target={},
|
|
156
|
+
context={"workspace": str(workspace_root)},
|
|
157
|
+
)
|
|
86
158
|
|
|
87
159
|
_logger.info("Running turn events on session %s", self._session_id)
|
|
88
160
|
|
|
161
|
+
self._last_turn_id = build_turn_id(self._session_id)
|
|
162
|
+
|
|
89
163
|
yield Started(timestamp=now_iso(), session_id=self._session_id)
|
|
90
164
|
|
|
91
165
|
yield OutputDelta(
|
|
92
166
|
timestamp=now_iso(), content=message, delta_type="user_message"
|
|
93
167
|
)
|
|
94
168
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
agent=self._agent,
|
|
99
|
-
model=self._model,
|
|
169
|
+
model_payload = split_model_id(self._model) if self._model else None
|
|
170
|
+
missing_env = await opencode_missing_env(
|
|
171
|
+
client, str(workspace_root), model_payload
|
|
100
172
|
)
|
|
173
|
+
if missing_env:
|
|
174
|
+
provider_id = model_payload.get("providerID") if model_payload else None
|
|
175
|
+
missing_label = ", ".join(missing_env)
|
|
176
|
+
yield Failed(
|
|
177
|
+
timestamp=now_iso(),
|
|
178
|
+
error_message=(
|
|
179
|
+
"OpenCode provider "
|
|
180
|
+
f"{provider_id or 'selected'} requires env vars: {missing_label}"
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
return
|
|
101
184
|
|
|
102
|
-
|
|
185
|
+
permission_policy = map_approval_policy_to_permission(
|
|
186
|
+
self._approval_policy, default="allow"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
event_queue: asyncio.Queue[RunEvent] = asyncio.Queue()
|
|
190
|
+
self._event_formatter.reset()
|
|
191
|
+
assistant_stream_coalescer = StreamingTextCoalescer()
|
|
192
|
+
|
|
193
|
+
async def _enqueue_lines(lines: list[str]) -> None:
|
|
194
|
+
for line in lines:
|
|
195
|
+
await event_queue.put(
|
|
196
|
+
OutputDelta(
|
|
197
|
+
timestamp=now_iso(), content=line, delta_type="log_line"
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def _part_handler(
|
|
202
|
+
part_type: str, part: dict[str, Any], delta_text: Optional[str]
|
|
203
|
+
) -> None:
|
|
204
|
+
if part_type == "usage" and isinstance(part, dict):
|
|
205
|
+
self._last_token_total = _usage_to_token_total(part)
|
|
206
|
+
await event_queue.put(TokenUsage(timestamp=now_iso(), usage=dict(part)))
|
|
207
|
+
await _enqueue_lines(self._event_formatter.format_usage(part))
|
|
208
|
+
else:
|
|
209
|
+
await _enqueue_lines(
|
|
210
|
+
self._event_formatter.format_part(part_type, part, delta_text)
|
|
211
|
+
)
|
|
212
|
+
if part_type == "text" and isinstance(delta_text, str) and delta_text:
|
|
213
|
+
for chunk in assistant_stream_coalescer.add(delta_text):
|
|
214
|
+
await event_queue.put(
|
|
215
|
+
OutputDelta(
|
|
216
|
+
timestamp=now_iso(),
|
|
217
|
+
content=chunk,
|
|
218
|
+
delta_type="assistant_stream",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
ready_event = asyncio.Event()
|
|
223
|
+
output_task = asyncio.create_task(
|
|
224
|
+
collect_opencode_output(
|
|
225
|
+
client,
|
|
226
|
+
session_id=self._session_id,
|
|
227
|
+
workspace_path=str(workspace_root),
|
|
228
|
+
model_payload=model_payload,
|
|
229
|
+
permission_policy=permission_policy,
|
|
230
|
+
part_handler=_part_handler,
|
|
231
|
+
ready_event=ready_event,
|
|
232
|
+
stall_timeout_seconds=self._session_stall_timeout_seconds,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
await asyncio.wait_for(ready_event.wait(), timeout=2.0)
|
|
237
|
+
except asyncio.TimeoutError:
|
|
238
|
+
await event_queue.put(
|
|
239
|
+
RunNotice(
|
|
240
|
+
timestamp=now_iso(),
|
|
241
|
+
kind="ready_timeout",
|
|
242
|
+
message="OpenCode stream readiness wait timed out",
|
|
243
|
+
data={"timeout_seconds": 2.0},
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
prompt_response: Any = None
|
|
248
|
+
prompt_task: Optional[asyncio.Task[Any]] = asyncio.create_task(
|
|
249
|
+
client.prompt_async(
|
|
250
|
+
self._session_id,
|
|
251
|
+
message=message,
|
|
252
|
+
agent=self._agent,
|
|
253
|
+
model=model_payload,
|
|
254
|
+
variant=self._reasoning,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
103
257
|
|
|
258
|
+
output_result = None
|
|
104
259
|
try:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
260
|
+
while True:
|
|
261
|
+
queue_task = asyncio.create_task(event_queue.get())
|
|
262
|
+
tasks = {output_task, queue_task}
|
|
263
|
+
if prompt_task is not None:
|
|
264
|
+
tasks.add(prompt_task)
|
|
265
|
+
done, pending = await asyncio.wait(
|
|
266
|
+
tasks, return_when=asyncio.FIRST_COMPLETED
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if queue_task in done:
|
|
270
|
+
yield queue_task.result()
|
|
271
|
+
else:
|
|
272
|
+
queue_task.cancel()
|
|
273
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
274
|
+
await queue_task
|
|
275
|
+
|
|
276
|
+
if prompt_task is not None and prompt_task in done:
|
|
277
|
+
try:
|
|
278
|
+
prompt_response = prompt_task.result()
|
|
279
|
+
except Exception as exc:
|
|
280
|
+
output_task.cancel()
|
|
281
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
282
|
+
await output_task
|
|
283
|
+
yield Failed(timestamp=now_iso(), error_message=str(exc))
|
|
284
|
+
return
|
|
285
|
+
prompt_task = None
|
|
286
|
+
|
|
287
|
+
if output_task in done:
|
|
288
|
+
output_result = await output_task
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
finally:
|
|
292
|
+
if prompt_task is not None:
|
|
293
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
294
|
+
await prompt_task
|
|
295
|
+
for line in self._event_formatter.flush_all_reasoning():
|
|
296
|
+
await event_queue.put(
|
|
297
|
+
OutputDelta(
|
|
298
|
+
timestamp=now_iso(), content=line, delta_type="log_line"
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
for chunk in assistant_stream_coalescer.flush():
|
|
302
|
+
await event_queue.put(
|
|
303
|
+
OutputDelta(
|
|
304
|
+
timestamp=now_iso(),
|
|
305
|
+
content=chunk,
|
|
306
|
+
delta_type="assistant_stream",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
while not event_queue.empty():
|
|
311
|
+
yield event_queue.get_nowait()
|
|
312
|
+
|
|
313
|
+
if output_result is None:
|
|
314
|
+
yield Failed(timestamp=now_iso(), error_message="OpenCode output failed")
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
if prompt_response is not None and not output_result.text:
|
|
318
|
+
fallback = parse_message_response(prompt_response)
|
|
319
|
+
if fallback.text:
|
|
320
|
+
output_result = OpenCodeTurnOutput(
|
|
321
|
+
text=fallback.text, error=output_result.error
|
|
322
|
+
)
|
|
323
|
+
if fallback.error and not output_result.error:
|
|
324
|
+
output_result = OpenCodeTurnOutput(
|
|
325
|
+
text=output_result.text, error=fallback.error
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if output_result.text:
|
|
329
|
+
yield Completed(timestamp=now_iso(), final_message=output_result.text)
|
|
330
|
+
elif output_result.error:
|
|
331
|
+
yield Failed(timestamp=now_iso(), error_message=output_result.error)
|
|
332
|
+
else:
|
|
333
|
+
yield Completed(timestamp=now_iso(), final_message="")
|
|
110
334
|
|
|
111
335
|
async def stream_events(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
|
|
112
336
|
if session_id:
|
|
@@ -114,15 +338,17 @@ class OpenCodeBackend(AgentBackend):
|
|
|
114
338
|
if not self._session_id:
|
|
115
339
|
raise RuntimeError("Session not started. Call start_session() first.")
|
|
116
340
|
|
|
117
|
-
|
|
341
|
+
client = await self._ensure_client()
|
|
342
|
+
async for sse in client.stream_events(directory=None):
|
|
118
343
|
for agent_event in self._convert_sse_to_agent_event(sse):
|
|
119
344
|
yield agent_event
|
|
120
345
|
|
|
121
346
|
async def interrupt(self, session_id: str) -> None:
|
|
122
347
|
target_session = session_id or self._session_id
|
|
123
348
|
if target_session:
|
|
349
|
+
client = await self._ensure_client()
|
|
124
350
|
try:
|
|
125
|
-
await
|
|
351
|
+
await client.abort(target_session)
|
|
126
352
|
_logger.info("Interrupted OpenCode session %s", target_session)
|
|
127
353
|
except Exception as e:
|
|
128
354
|
_logger.warning("Failed to interrupt session: %s", e)
|
|
@@ -140,7 +366,8 @@ class OpenCodeBackend(AgentBackend):
|
|
|
140
366
|
if self._session_id:
|
|
141
367
|
paths.insert(0, f"/session/{self._session_id}/event")
|
|
142
368
|
try:
|
|
143
|
-
|
|
369
|
+
client = await self._ensure_client()
|
|
370
|
+
async for sse in client.stream_events(
|
|
144
371
|
directory=None,
|
|
145
372
|
paths=paths,
|
|
146
373
|
):
|
|
@@ -168,7 +395,8 @@ class OpenCodeBackend(AgentBackend):
|
|
|
168
395
|
if self._session_id:
|
|
169
396
|
paths.insert(0, f"/session/{self._session_id}/event")
|
|
170
397
|
try:
|
|
171
|
-
|
|
398
|
+
client = await self._ensure_client()
|
|
399
|
+
async for sse in client.stream_events(
|
|
172
400
|
directory=None,
|
|
173
401
|
paths=paths,
|
|
174
402
|
):
|
|
@@ -323,3 +551,48 @@ class OpenCodeBackend(AgentBackend):
|
|
|
323
551
|
# If server does not tag events, do not drop them.
|
|
324
552
|
return True
|
|
325
553
|
return session_id == self._session_id
|
|
554
|
+
|
|
555
|
+
async def _ensure_client(self) -> OpenCodeClient:
|
|
556
|
+
if self._client is not None:
|
|
557
|
+
return self._client
|
|
558
|
+
if self._supervisor is None or self._workspace_root is None:
|
|
559
|
+
raise RuntimeError("OpenCode client unavailable: supervisor not configured")
|
|
560
|
+
client = await self._supervisor.get_client(self._workspace_root)
|
|
561
|
+
self._client = client
|
|
562
|
+
return client
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def last_turn_id(self) -> Optional[str]:
|
|
566
|
+
return self._last_turn_id
|
|
567
|
+
|
|
568
|
+
@property
|
|
569
|
+
def last_token_total(self) -> Optional[dict[str, Any]]:
|
|
570
|
+
return self._last_token_total
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _usage_to_token_total(usage: dict[str, Any]) -> Optional[dict[str, int]]:
|
|
574
|
+
if not isinstance(usage, dict):
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
def _int(key: str) -> int:
|
|
578
|
+
value = usage.get(key)
|
|
579
|
+
return int(value) if isinstance(value, (int, float)) else 0
|
|
580
|
+
|
|
581
|
+
total = usage.get("totalTokens")
|
|
582
|
+
total_tokens = int(total) if isinstance(total, (int, float)) else None
|
|
583
|
+
input_tokens = _int("inputTokens")
|
|
584
|
+
cached_tokens = _int("cachedInputTokens")
|
|
585
|
+
output_tokens = _int("outputTokens")
|
|
586
|
+
reasoning_tokens = _int("reasoningTokens")
|
|
587
|
+
if total_tokens is None:
|
|
588
|
+
total_tokens = input_tokens + cached_tokens + output_tokens + reasoning_tokens
|
|
589
|
+
return {
|
|
590
|
+
"total": total_tokens,
|
|
591
|
+
"input_tokens": input_tokens,
|
|
592
|
+
"prompt_tokens": input_tokens,
|
|
593
|
+
"cached_input_tokens": cached_tokens,
|
|
594
|
+
"output_tokens": output_tokens,
|
|
595
|
+
"completion_tokens": output_tokens,
|
|
596
|
+
"reasoning_tokens": reasoning_tokens,
|
|
597
|
+
"reasoning_output_tokens": reasoning_tokens,
|
|
598
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import AsyncGenerator, Callable, Optional
|
|
5
|
+
|
|
6
|
+
from ...core.ports.agent_backend import AgentBackend, AgentEvent
|
|
7
|
+
from ...core.ports.run_event import (
|
|
8
|
+
Failed,
|
|
9
|
+
OutputDelta,
|
|
10
|
+
RunEvent,
|
|
11
|
+
Started,
|
|
12
|
+
now_iso,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
LogHandler = Callable[[str], None]
|
|
18
|
+
EventCallback = Callable[[RunEvent], None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def run_turn_with_backend(
|
|
22
|
+
backend: AgentBackend,
|
|
23
|
+
message: str,
|
|
24
|
+
session_id: Optional[str],
|
|
25
|
+
*,
|
|
26
|
+
log_handler: Optional[LogHandler] = None,
|
|
27
|
+
event_callback: Optional[EventCallback] = None,
|
|
28
|
+
) -> int:
|
|
29
|
+
"""
|
|
30
|
+
Execute a turn using the AgentBackend protocol.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Exit code (0 for success, non-zero for failure)
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
if not session_id:
|
|
37
|
+
session_id = await backend.start_session(target={}, context={})
|
|
38
|
+
|
|
39
|
+
if event_callback:
|
|
40
|
+
event_callback(Started(timestamp=now_iso(), session_id=session_id))
|
|
41
|
+
|
|
42
|
+
if log_handler:
|
|
43
|
+
log_handler(message)
|
|
44
|
+
|
|
45
|
+
events_consumed = False
|
|
46
|
+
if hasattr(backend, "run_turn_events"):
|
|
47
|
+
async for run_event in backend.run_turn_events(session_id, message):
|
|
48
|
+
events_consumed = True
|
|
49
|
+
if event_callback:
|
|
50
|
+
event_callback(run_event)
|
|
51
|
+
if log_handler and isinstance(run_event, OutputDelta):
|
|
52
|
+
log_handler(run_event.content)
|
|
53
|
+
|
|
54
|
+
if not events_consumed:
|
|
55
|
+
async for agent_event in backend.run_turn(session_id, message):
|
|
56
|
+
if isinstance(agent_event, AgentEvent):
|
|
57
|
+
if log_handler:
|
|
58
|
+
if agent_event.data.get("content"):
|
|
59
|
+
log_handler(agent_event.data["content"])
|
|
60
|
+
elif isinstance(agent_event, str):
|
|
61
|
+
if log_handler:
|
|
62
|
+
log_handler(agent_event)
|
|
63
|
+
|
|
64
|
+
return 0
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
_logger.error("Turn execution failed: %s", exc)
|
|
67
|
+
if event_callback:
|
|
68
|
+
event_callback(Failed(timestamp=now_iso(), error_message=str(exc)))
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def stream_turn_events(
|
|
73
|
+
backend: AgentBackend,
|
|
74
|
+
session_id: str,
|
|
75
|
+
) -> AsyncGenerator[AgentEvent, None]:
|
|
76
|
+
"""
|
|
77
|
+
Stream events from a backend for an existing session.
|
|
78
|
+
|
|
79
|
+
This is used for external streaming (e.g., WebSocket UI) where the turn
|
|
80
|
+
has already been initiated and we want to stream events as they arrive.
|
|
81
|
+
"""
|
|
82
|
+
if hasattr(backend, "stream_events"):
|
|
83
|
+
async for event in backend.stream_events(session_id):
|
|
84
|
+
yield event
|
|
85
|
+
else:
|
|
86
|
+
yield AgentEvent.stream_delta(content="", delta_type="noop")
|