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