codex-autorunner 1.1.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/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +17 -7
- codex_autorunner/bootstrap.py +219 -1
- codex_autorunner/core/__init__.py +17 -1
- codex_autorunner/core/about_car.py +114 -1
- codex_autorunner/core/app_server_threads.py +6 -0
- codex_autorunner/core/config.py +236 -1
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +71 -1
- codex_autorunner/core/flows/reconciler.py +4 -1
- codex_autorunner/core/flows/runtime.py +22 -0
- codex_autorunner/core/flows/store.py +61 -9
- codex_autorunner/core/flows/transition.py +23 -16
- codex_autorunner/core/flows/ux_helpers.py +18 -3
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/hub.py +198 -41
- codex_autorunner/core/lifecycle_events.py +253 -0
- 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/agent_backend.py +2 -5
- codex_autorunner/core/ports/run_event.py +1 -4
- 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 +5 -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/ticket_linter_cli.py +17 -0
- codex_autorunner/core/ticket_manager_cli.py +154 -92
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/utils.py +34 -6
- codex_autorunner/flows/review/service.py +23 -25
- codex_autorunner/flows/ticket_flow/definition.py +43 -1
- codex_autorunner/integrations/agents/__init__.py +2 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
- codex_autorunner/integrations/agents/codex_backend.py +19 -8
- codex_autorunner/integrations/agents/runner.py +3 -8
- codex_autorunner/integrations/agents/wiring.py +8 -0
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- 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 +346 -58
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
- codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
- codex_autorunner/integrations/telegram/helpers.py +1 -3
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +30 -0
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
- codex_autorunner/integrations/telegram/transport.py +10 -3
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/server.py +2 -2
- codex_autorunner/static/agentControls.js +21 -5
- codex_autorunner/static/app.js +115 -11
- codex_autorunner/static/chatUploads.js +137 -0
- 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 +46 -81
- codex_autorunner/static/index.html +303 -24
- codex_autorunner/static/messages.js +82 -4
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/settings.js +3 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9125 -6742
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +41 -13
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +69 -19
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +28 -0
- codex_autorunner/static/workspace.js +258 -44
- codex_autorunner/static/workspaceFileBrowser.js +6 -4
- codex_autorunner/surfaces/cli/cli.py +1465 -155
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/web/app.py +253 -49
- codex_autorunner/surfaces/web/routes/__init__.py +4 -0
- codex_autorunner/surfaces/web/routes/analytics.py +29 -22
- codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +219 -29
- codex_autorunner/surfaces/web/routes/messages.py +70 -39
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +1 -1
- codex_autorunner/surfaces/web/routes/shared.py +0 -3
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/runner_manager.py +2 -2
- codex_autorunner/surfaces/web/schemas.py +70 -18
- codex_autorunner/tickets/agent_pool.py +27 -0
- codex_autorunner/tickets/files.py +33 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +3 -0
- codex_autorunner/tickets/outbox.py +41 -5
- codex_autorunner/tickets/runner.py +350 -69
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -3302
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -14,7 +14,7 @@ import difflib
|
|
|
14
14
|
import logging
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Any, AsyncIterator, Dict, Optional
|
|
17
|
+
from typing import Any, AsyncIterator, Callable, Dict, Optional
|
|
18
18
|
|
|
19
19
|
from fastapi import APIRouter, HTTPException, Request
|
|
20
20
|
from fastapi.responses import StreamingResponse
|
|
@@ -140,7 +140,70 @@ def _parse_target(repo_root: Path, raw: str) -> _Target:
|
|
|
140
140
|
state_key=f"workspace_{rel_suffix.replace('/', '_')}",
|
|
141
141
|
)
|
|
142
142
|
|
|
143
|
-
raise HTTPException(status_code=400, detail="invalid target")
|
|
143
|
+
raise HTTPException(status_code=400, detail=f"invalid target: {target}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _build_file_chat_prompt(*, target: _Target, message: str, before: str) -> str:
|
|
147
|
+
if target.kind == "ticket":
|
|
148
|
+
file_role_context = (
|
|
149
|
+
"This file is a CAR ticket. Ticket flow processes "
|
|
150
|
+
"`.codex-autorunner/tickets/TICKET-###*.md` in numeric order.\n"
|
|
151
|
+
"Edits here change what the ticket flow agent will do; keep YAML "
|
|
152
|
+
"frontmatter valid."
|
|
153
|
+
)
|
|
154
|
+
elif target.kind == "workspace":
|
|
155
|
+
file_role_context = (
|
|
156
|
+
"This file is a CAR workspace doc under `.codex-autorunner/workspace/`."
|
|
157
|
+
" These docs act as shared memory across ticket turns."
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
file_role_context = (
|
|
161
|
+
"This file is a normal repo file (not a CAR ticket/workspace doc)."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
"<injected context>\n"
|
|
166
|
+
"You are operating inside a Codex Autorunner (CAR) managed repo.\n\n"
|
|
167
|
+
"CAR’s durable control-plane lives under `.codex-autorunner/`:\n"
|
|
168
|
+
"- `.codex-autorunner/ABOUT_CAR.md` — short repo-local briefing "
|
|
169
|
+
"(ticket/workspace conventions + helper scripts).\n"
|
|
170
|
+
"- `.codex-autorunner/tickets/` — ordered ticket queue "
|
|
171
|
+
"(`TICKET-###*.md`) used by the ticket flow runner.\n"
|
|
172
|
+
"- `.codex-autorunner/workspace/` — shared context docs:\n"
|
|
173
|
+
" - `active_context.md` — current “north star” context; kept fresh "
|
|
174
|
+
"for ongoing work.\n"
|
|
175
|
+
" - `spec.md` — longer spec / acceptance criteria when needed.\n"
|
|
176
|
+
" - `decisions.md` — prior decisions / tradeoffs when relevant.\n\n"
|
|
177
|
+
"Intent signals: if the user mentions tickets, “dispatch”, “resume”, "
|
|
178
|
+
"workspace docs, or `.codex-autorunner/`, they are likely referring "
|
|
179
|
+
"to CAR artifacts/workflow rather than generic repo files.\n\n"
|
|
180
|
+
"Use the above as orientation. If you need the operational details "
|
|
181
|
+
"(exact helper commands, what CAR auto-generates), read "
|
|
182
|
+
"`.codex-autorunner/ABOUT_CAR.md`.\n"
|
|
183
|
+
"</injected context>\n\n"
|
|
184
|
+
"<file_role_context>\n"
|
|
185
|
+
f"{file_role_context}\n"
|
|
186
|
+
"</file_role_context>\n\n"
|
|
187
|
+
"You are editing a single file in Codex Autorunner.\n\n"
|
|
188
|
+
"<target>\n"
|
|
189
|
+
f"{target.target}\n"
|
|
190
|
+
"</target>\n\n"
|
|
191
|
+
"<path>\n"
|
|
192
|
+
f"{target.rel_path}\n"
|
|
193
|
+
"</path>\n\n"
|
|
194
|
+
"<instructions>\n"
|
|
195
|
+
"- This is a single-turn edit request. Don’t ask the user questions.\n"
|
|
196
|
+
"- You may read other files for context, but only modify the target file.\n"
|
|
197
|
+
"- If no changes are needed, explain why without editing the file.\n"
|
|
198
|
+
"- Respond with a short summary of what you did.\n"
|
|
199
|
+
"</instructions>\n\n"
|
|
200
|
+
"<user_request>\n"
|
|
201
|
+
f"{message}\n"
|
|
202
|
+
"</user_request>\n\n"
|
|
203
|
+
"<FILE_CONTENT>\n"
|
|
204
|
+
f"{before[:12000]}\n"
|
|
205
|
+
"</FILE_CONTENT>\n"
|
|
206
|
+
)
|
|
144
207
|
|
|
145
208
|
|
|
146
209
|
def _read_file(path: Path) -> str:
|
|
@@ -164,6 +227,10 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
164
227
|
router = APIRouter(prefix="/api", tags=["file-chat"])
|
|
165
228
|
_active_chats: Dict[str, asyncio.Event] = {}
|
|
166
229
|
_chat_lock = asyncio.Lock()
|
|
230
|
+
_turn_lock = asyncio.Lock()
|
|
231
|
+
_current_by_target: Dict[str, Dict[str, Any]] = {}
|
|
232
|
+
_current_by_client: Dict[str, Dict[str, Any]] = {}
|
|
233
|
+
_last_by_client: Dict[str, Dict[str, Any]] = {}
|
|
167
234
|
|
|
168
235
|
async def _get_or_create_interrupt_event(key: str) -> asyncio.Event:
|
|
169
236
|
async with _chat_lock:
|
|
@@ -175,6 +242,61 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
175
242
|
async with _chat_lock:
|
|
176
243
|
_active_chats.pop(key, None)
|
|
177
244
|
|
|
245
|
+
async def _begin_turn_state(target: _Target, client_turn_id: Optional[str]) -> None:
|
|
246
|
+
async with _turn_lock:
|
|
247
|
+
state: Dict[str, Any] = {
|
|
248
|
+
"client_turn_id": client_turn_id or "",
|
|
249
|
+
"target": target.target,
|
|
250
|
+
"status": "starting",
|
|
251
|
+
"agent": None,
|
|
252
|
+
"thread_id": None,
|
|
253
|
+
"turn_id": None,
|
|
254
|
+
}
|
|
255
|
+
_current_by_target[target.state_key] = state
|
|
256
|
+
if client_turn_id:
|
|
257
|
+
_current_by_client[client_turn_id] = state
|
|
258
|
+
|
|
259
|
+
async def _update_turn_state(target: _Target, **updates: Any) -> None:
|
|
260
|
+
async with _turn_lock:
|
|
261
|
+
state = _current_by_target.get(target.state_key)
|
|
262
|
+
if not state:
|
|
263
|
+
return
|
|
264
|
+
for key, value in updates.items():
|
|
265
|
+
if value is None:
|
|
266
|
+
continue
|
|
267
|
+
state[key] = value
|
|
268
|
+
cid = state.get("client_turn_id") or ""
|
|
269
|
+
if cid:
|
|
270
|
+
_current_by_client[cid] = state
|
|
271
|
+
|
|
272
|
+
async def _finalize_turn_state(target: _Target, result: Dict[str, Any]) -> None:
|
|
273
|
+
async with _turn_lock:
|
|
274
|
+
state = _current_by_target.pop(target.state_key, None)
|
|
275
|
+
cid = ""
|
|
276
|
+
if state:
|
|
277
|
+
cid = state.get("client_turn_id", "") or ""
|
|
278
|
+
if cid:
|
|
279
|
+
_current_by_client.pop(cid, None)
|
|
280
|
+
_last_by_client[cid] = dict(result or {})
|
|
281
|
+
|
|
282
|
+
async def _active_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
|
|
283
|
+
if not client_turn_id:
|
|
284
|
+
return {}
|
|
285
|
+
async with _turn_lock:
|
|
286
|
+
return dict(_current_by_client.get(client_turn_id, {}))
|
|
287
|
+
|
|
288
|
+
async def _last_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
|
|
289
|
+
if not client_turn_id:
|
|
290
|
+
return {}
|
|
291
|
+
async with _turn_lock:
|
|
292
|
+
return dict(_last_by_client.get(client_turn_id, {}))
|
|
293
|
+
|
|
294
|
+
@router.get("/file-chat/active")
|
|
295
|
+
async def file_chat_active(client_turn_id: Optional[str] = None) -> Dict[str, Any]:
|
|
296
|
+
current = await _active_for_client(client_turn_id)
|
|
297
|
+
last = await _last_for_client(client_turn_id)
|
|
298
|
+
return {"active": bool(current), "current": current, "last_result": last}
|
|
299
|
+
|
|
178
300
|
@router.post("/file-chat")
|
|
179
301
|
async def chat_file(request: Request):
|
|
180
302
|
"""Chat with a file target - optionally streams SSE events."""
|
|
@@ -185,6 +307,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
185
307
|
agent = body.get("agent", "codex")
|
|
186
308
|
model = body.get("model")
|
|
187
309
|
reasoning = body.get("reasoning")
|
|
310
|
+
client_turn_id = (body.get("client_turn_id") or "").strip() or None
|
|
188
311
|
|
|
189
312
|
if not message:
|
|
190
313
|
raise HTTPException(status_code=400, detail="message is required")
|
|
@@ -203,6 +326,8 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
203
326
|
raise HTTPException(status_code=409, detail="File chat already running")
|
|
204
327
|
_active_chats[target.state_key] = asyncio.Event()
|
|
205
328
|
|
|
329
|
+
await _begin_turn_state(target, client_turn_id)
|
|
330
|
+
|
|
206
331
|
if stream:
|
|
207
332
|
return StreamingResponse(
|
|
208
333
|
_stream_file_chat(
|
|
@@ -213,21 +338,47 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
213
338
|
agent=agent,
|
|
214
339
|
model=model,
|
|
215
340
|
reasoning=reasoning,
|
|
341
|
+
client_turn_id=client_turn_id,
|
|
216
342
|
),
|
|
217
343
|
media_type="text/event-stream",
|
|
218
344
|
headers=SSE_HEADERS,
|
|
219
345
|
)
|
|
220
346
|
|
|
221
347
|
try:
|
|
222
|
-
result
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
348
|
+
result: Dict[str, Any]
|
|
349
|
+
|
|
350
|
+
async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
|
|
351
|
+
await _update_turn_state(
|
|
352
|
+
target,
|
|
353
|
+
agent=agent_id,
|
|
354
|
+
thread_id=thread_id,
|
|
355
|
+
turn_id=turn_id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
result = await _execute_file_chat(
|
|
360
|
+
request,
|
|
361
|
+
repo_root,
|
|
362
|
+
target,
|
|
363
|
+
message,
|
|
364
|
+
agent=agent,
|
|
365
|
+
model=model,
|
|
366
|
+
reasoning=reasoning,
|
|
367
|
+
on_meta=_on_meta,
|
|
368
|
+
)
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
await _finalize_turn_state(
|
|
371
|
+
target,
|
|
372
|
+
{
|
|
373
|
+
"status": "error",
|
|
374
|
+
"detail": str(exc),
|
|
375
|
+
"client_turn_id": client_turn_id or "",
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
raise
|
|
379
|
+
result = dict(result or {})
|
|
380
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
381
|
+
await _finalize_turn_state(target, result)
|
|
231
382
|
return result
|
|
232
383
|
finally:
|
|
233
384
|
await _clear_interrupt_event(target.state_key)
|
|
@@ -241,22 +392,62 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
241
392
|
agent: str = "codex",
|
|
242
393
|
model: Optional[str] = None,
|
|
243
394
|
reasoning: Optional[str] = None,
|
|
395
|
+
client_turn_id: Optional[str] = None,
|
|
244
396
|
) -> AsyncIterator[str]:
|
|
245
397
|
yield format_sse("status", {"status": "queued"})
|
|
246
398
|
try:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
399
|
+
|
|
400
|
+
async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
|
|
401
|
+
await _update_turn_state(
|
|
402
|
+
target,
|
|
403
|
+
agent=agent_id,
|
|
404
|
+
thread_id=thread_id,
|
|
405
|
+
turn_id=turn_id,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
run_task = asyncio.create_task(
|
|
409
|
+
_execute_file_chat(
|
|
410
|
+
request,
|
|
411
|
+
repo_root,
|
|
412
|
+
target,
|
|
413
|
+
message,
|
|
414
|
+
agent=agent,
|
|
415
|
+
model=model,
|
|
416
|
+
reasoning=reasoning,
|
|
417
|
+
on_meta=_on_meta,
|
|
418
|
+
)
|
|
255
419
|
)
|
|
420
|
+
|
|
421
|
+
async def _finalize() -> None:
|
|
422
|
+
result = {"status": "error", "detail": "File chat failed"}
|
|
423
|
+
try:
|
|
424
|
+
result = await run_task
|
|
425
|
+
except Exception as exc:
|
|
426
|
+
logger.exception("file chat task failed")
|
|
427
|
+
result = {
|
|
428
|
+
"status": "error",
|
|
429
|
+
"detail": str(exc) or "File chat failed",
|
|
430
|
+
}
|
|
431
|
+
result = dict(result or {})
|
|
432
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
433
|
+
await _finalize_turn_state(target, result)
|
|
434
|
+
|
|
435
|
+
asyncio.create_task(_finalize())
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
result = await asyncio.shield(run_task)
|
|
439
|
+
except asyncio.CancelledError:
|
|
440
|
+
# client disconnected; turn continues in background
|
|
441
|
+
return
|
|
442
|
+
|
|
256
443
|
if result.get("status") == "ok":
|
|
257
444
|
raw_events = result.pop("raw_events", []) or []
|
|
258
445
|
for event in raw_events:
|
|
259
446
|
yield format_sse("app-server", event)
|
|
447
|
+
usage_parts = result.pop("usage_parts", []) or []
|
|
448
|
+
for usage in usage_parts:
|
|
449
|
+
yield format_sse("token_usage", usage)
|
|
450
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
260
451
|
yield format_sse("update", result)
|
|
261
452
|
yield format_sse("done", {"status": "ok"})
|
|
262
453
|
elif result.get("status") == "interrupted":
|
|
@@ -283,11 +474,14 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
283
474
|
agent: str = "codex",
|
|
284
475
|
model: Optional[str] = None,
|
|
285
476
|
reasoning: Optional[str] = None,
|
|
477
|
+
on_meta: Optional[Callable[[str, str, str], Any]] = None,
|
|
478
|
+
on_usage: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
|
286
479
|
) -> Dict[str, Any]:
|
|
287
480
|
supervisor = getattr(request.app.state, "app_server_supervisor", None)
|
|
288
481
|
threads = getattr(request.app.state, "app_server_threads", None)
|
|
289
482
|
opencode = getattr(request.app.state, "opencode_supervisor", None)
|
|
290
483
|
engine = getattr(request.app.state, "engine", None)
|
|
484
|
+
events = getattr(request.app.state, "app_server_events", None)
|
|
291
485
|
stall_timeout_seconds = None
|
|
292
486
|
try:
|
|
293
487
|
stall_timeout_seconds = (
|
|
@@ -303,21 +497,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
303
497
|
before = _read_file(target.path)
|
|
304
498
|
base_hash = _hash_content(before)
|
|
305
499
|
|
|
306
|
-
prompt = (
|
|
307
|
-
"You are editing a single file in Codex AutoRunner.\n\n"
|
|
308
|
-
f"Target: {target.target}\n"
|
|
309
|
-
f"Path: {target.rel_path}\n\n"
|
|
310
|
-
"Instructions:\n"
|
|
311
|
-
"- This run is non-interactive. Do not ask the user questions.\n"
|
|
312
|
-
"- Edit ONLY the target file.\n"
|
|
313
|
-
"- If no changes are needed, explain why without editing the file.\n"
|
|
314
|
-
"- Respond with a short summary of what you did.\n\n"
|
|
315
|
-
"User request:\n"
|
|
316
|
-
f"{message}\n\n"
|
|
317
|
-
"<FILE_CONTENT>\n"
|
|
318
|
-
f"{before[:12000]}\n"
|
|
319
|
-
"</FILE_CONTENT>\n"
|
|
320
|
-
)
|
|
500
|
+
prompt = _build_file_chat_prompt(target=target, message=message, before=before)
|
|
321
501
|
|
|
322
502
|
interrupt_event = await _get_or_create_interrupt_event(target.state_key)
|
|
323
503
|
if interrupt_event.is_set():
|
|
@@ -329,6 +509,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
329
509
|
agent_id = "codex"
|
|
330
510
|
|
|
331
511
|
thread_key = f"file_chat.{target.state_key}"
|
|
512
|
+
await _update_turn_state(target, status="running", agent=agent_id)
|
|
332
513
|
|
|
333
514
|
if agent_id == "opencode":
|
|
334
515
|
if opencode is None:
|
|
@@ -343,6 +524,8 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
343
524
|
thread_registry=threads,
|
|
344
525
|
thread_key=thread_key,
|
|
345
526
|
stall_timeout_seconds=stall_timeout_seconds,
|
|
527
|
+
on_meta=on_meta,
|
|
528
|
+
on_usage=on_usage,
|
|
346
529
|
)
|
|
347
530
|
else:
|
|
348
531
|
if supervisor is None:
|
|
@@ -355,10 +538,13 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
355
538
|
repo_root,
|
|
356
539
|
prompt,
|
|
357
540
|
interrupt_event,
|
|
541
|
+
agent_id=agent_id,
|
|
358
542
|
model=model,
|
|
359
543
|
reasoning=reasoning,
|
|
360
544
|
thread_registry=threads,
|
|
361
545
|
thread_key=thread_key,
|
|
546
|
+
on_meta=on_meta,
|
|
547
|
+
events=events,
|
|
362
548
|
)
|
|
363
549
|
|
|
364
550
|
if result.get("status") != "ok":
|
|
@@ -393,6 +579,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
393
579
|
return {
|
|
394
580
|
"status": "ok",
|
|
395
581
|
"target": target.target,
|
|
582
|
+
"agent": agent_id,
|
|
396
583
|
"agent_message": agent_message,
|
|
397
584
|
"message": response_text,
|
|
398
585
|
"has_draft": True,
|
|
@@ -400,6 +587,8 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
400
587
|
"content": after,
|
|
401
588
|
"base_hash": base_hash,
|
|
402
589
|
"created_at": drafts[target.state_key]["created_at"],
|
|
590
|
+
"thread_id": result.get("thread_id"),
|
|
591
|
+
"turn_id": result.get("turn_id"),
|
|
403
592
|
**(
|
|
404
593
|
{"raw_events": result.get("raw_events")}
|
|
405
594
|
if result.get("raw_events")
|
|
@@ -410,9 +599,12 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
410
599
|
return {
|
|
411
600
|
"status": "ok",
|
|
412
601
|
"target": target.target,
|
|
602
|
+
"agent": agent_id,
|
|
413
603
|
"agent_message": agent_message,
|
|
414
604
|
"message": response_text,
|
|
415
605
|
"has_draft": False,
|
|
606
|
+
"thread_id": result.get("thread_id"),
|
|
607
|
+
"turn_id": result.get("turn_id"),
|
|
416
608
|
**(
|
|
417
609
|
{"raw_events": result.get("raw_events")}
|
|
418
610
|
if result.get("raw_events")
|
|
@@ -428,8 +620,11 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
428
620
|
*,
|
|
429
621
|
model: Optional[str] = None,
|
|
430
622
|
reasoning: Optional[str] = None,
|
|
623
|
+
agent_id: str = "codex",
|
|
431
624
|
thread_registry: Optional[Any] = None,
|
|
432
625
|
thread_key: Optional[str] = None,
|
|
626
|
+
on_meta: Optional[Callable[[str, str, str], Any]] = None,
|
|
627
|
+
events: Optional[Any] = None,
|
|
433
628
|
) -> Dict[str, Any]:
|
|
434
629
|
client = await supervisor.get_client(repo_root)
|
|
435
630
|
|
|
@@ -463,6 +658,18 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
463
658
|
sandbox_policy="dangerFullAccess",
|
|
464
659
|
**turn_kwargs,
|
|
465
660
|
)
|
|
661
|
+
if events is not None:
|
|
662
|
+
try:
|
|
663
|
+
await events.register_turn(thread_id, handle.turn_id)
|
|
664
|
+
except Exception:
|
|
665
|
+
logger.debug("file chat register_turn failed", exc_info=True)
|
|
666
|
+
if on_meta is not None:
|
|
667
|
+
try:
|
|
668
|
+
maybe = on_meta(agent_id, thread_id, handle.turn_id)
|
|
669
|
+
if asyncio.iscoroutine(maybe):
|
|
670
|
+
await maybe
|
|
671
|
+
except Exception:
|
|
672
|
+
logger.debug("file chat meta callback failed", exc_info=True)
|
|
466
673
|
|
|
467
674
|
turn_task = asyncio.create_task(handle.wait(timeout=None))
|
|
468
675
|
timeout_task = asyncio.create_task(asyncio.sleep(FILE_CHAT_TIMEOUT_SECONDS))
|
|
@@ -495,6 +702,9 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
495
702
|
"agent_message": agent_message,
|
|
496
703
|
"message": output,
|
|
497
704
|
"raw_events": raw_events,
|
|
705
|
+
"thread_id": thread_id,
|
|
706
|
+
"turn_id": getattr(handle, "turn_id", None),
|
|
707
|
+
"agent": agent_id,
|
|
498
708
|
}
|
|
499
709
|
|
|
500
710
|
async def _execute_opencode(
|
|
@@ -508,9 +718,12 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
508
718
|
thread_registry: Optional[Any] = None,
|
|
509
719
|
thread_key: Optional[str] = None,
|
|
510
720
|
stall_timeout_seconds: Optional[float] = None,
|
|
721
|
+
on_meta: Optional[Callable[[str, str, str], Any]] = None,
|
|
722
|
+
on_usage: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
|
511
723
|
) -> Dict[str, Any]:
|
|
512
724
|
from ....agents.opencode.runtime import (
|
|
513
725
|
PERMISSION_ALLOW,
|
|
726
|
+
build_turn_id,
|
|
514
727
|
collect_opencode_output,
|
|
515
728
|
extract_session_id,
|
|
516
729
|
parse_message_response,
|
|
@@ -529,9 +742,32 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
529
742
|
if thread_registry is not None and thread_key:
|
|
530
743
|
thread_registry.set_thread_id(thread_key, session_id)
|
|
531
744
|
|
|
745
|
+
turn_id = build_turn_id(session_id)
|
|
746
|
+
if on_meta is not None:
|
|
747
|
+
try:
|
|
748
|
+
maybe = on_meta("opencode", session_id, turn_id)
|
|
749
|
+
if asyncio.iscoroutine(maybe):
|
|
750
|
+
await maybe
|
|
751
|
+
except Exception:
|
|
752
|
+
logger.debug("file chat opencode meta failed", exc_info=True)
|
|
753
|
+
|
|
532
754
|
model_payload = split_model_id(model)
|
|
533
755
|
await supervisor.mark_turn_started(repo_root)
|
|
534
756
|
|
|
757
|
+
usage_parts: list[Dict[str, Any]] = []
|
|
758
|
+
|
|
759
|
+
async def _part_handler(
|
|
760
|
+
part_type: str, part: Any, turn_id_arg: Optional[str] | None
|
|
761
|
+
) -> None:
|
|
762
|
+
if part_type == "usage" and on_usage is not None:
|
|
763
|
+
usage_parts.append(part)
|
|
764
|
+
try:
|
|
765
|
+
maybe = on_usage(part)
|
|
766
|
+
if asyncio.iscoroutine(maybe):
|
|
767
|
+
await maybe
|
|
768
|
+
except Exception:
|
|
769
|
+
logger.debug("file chat usage handler failed", exc_info=True)
|
|
770
|
+
|
|
535
771
|
ready_event = asyncio.Event()
|
|
536
772
|
output_task = asyncio.create_task(
|
|
537
773
|
collect_opencode_output(
|
|
@@ -543,6 +779,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
543
779
|
question_policy="auto_first_option",
|
|
544
780
|
should_stop=interrupt_event.is_set,
|
|
545
781
|
ready_event=ready_event,
|
|
782
|
+
part_handler=_part_handler,
|
|
546
783
|
stall_timeout_seconds=stall_timeout_seconds,
|
|
547
784
|
)
|
|
548
785
|
)
|
|
@@ -593,11 +830,17 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
593
830
|
if output_result.error:
|
|
594
831
|
raise FileChatError(output_result.error)
|
|
595
832
|
agent_message = _parse_agent_message(output_result.text)
|
|
596
|
-
|
|
833
|
+
result = {
|
|
597
834
|
"status": "ok",
|
|
598
835
|
"agent_message": agent_message,
|
|
599
836
|
"message": output_result.text,
|
|
837
|
+
"thread_id": session_id,
|
|
838
|
+
"turn_id": turn_id,
|
|
839
|
+
"agent": "opencode",
|
|
600
840
|
}
|
|
841
|
+
if usage_parts:
|
|
842
|
+
result["usage_parts"] = usage_parts
|
|
843
|
+
return result
|
|
601
844
|
|
|
602
845
|
def _parse_agent_message(output: str) -> str:
|
|
603
846
|
text = (output or "").strip()
|
|
@@ -693,6 +936,37 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
693
936
|
"content": _read_file(resolved.path),
|
|
694
937
|
}
|
|
695
938
|
|
|
939
|
+
@router.get("/file-chat/turns/{turn_id}/events")
|
|
940
|
+
async def stream_file_chat_turn_events(
|
|
941
|
+
turn_id: str, request: Request, thread_id: str, agent: str = "codex"
|
|
942
|
+
):
|
|
943
|
+
agent_id = (agent or "").strip().lower()
|
|
944
|
+
if agent_id == "codex":
|
|
945
|
+
events = getattr(request.app.state, "app_server_events", None)
|
|
946
|
+
if events is None:
|
|
947
|
+
raise HTTPException(status_code=404, detail="Events unavailable")
|
|
948
|
+
if not thread_id:
|
|
949
|
+
raise HTTPException(status_code=400, detail="thread_id is required")
|
|
950
|
+
return StreamingResponse(
|
|
951
|
+
events.stream(thread_id, turn_id),
|
|
952
|
+
media_type="text/event-stream",
|
|
953
|
+
headers=SSE_HEADERS,
|
|
954
|
+
)
|
|
955
|
+
if agent_id == "opencode":
|
|
956
|
+
supervisor = getattr(request.app.state, "opencode_supervisor", None)
|
|
957
|
+
if supervisor is None:
|
|
958
|
+
raise HTTPException(status_code=404, detail="OpenCode unavailable")
|
|
959
|
+
from ....agents.opencode.harness import OpenCodeHarness
|
|
960
|
+
|
|
961
|
+
harness = OpenCodeHarness(supervisor)
|
|
962
|
+
repo_root = _resolve_repo_root(request)
|
|
963
|
+
return StreamingResponse(
|
|
964
|
+
harness.stream_events(repo_root, thread_id, turn_id),
|
|
965
|
+
media_type="text/event-stream",
|
|
966
|
+
headers=SSE_HEADERS,
|
|
967
|
+
)
|
|
968
|
+
raise HTTPException(status_code=404, detail="Unknown agent")
|
|
969
|
+
|
|
696
970
|
@router.post("/file-chat/interrupt")
|
|
697
971
|
async def interrupt_file_chat(request: Request):
|
|
698
972
|
body = await request.json()
|
|
@@ -715,6 +989,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
715
989
|
agent = body.get("agent", "codex")
|
|
716
990
|
model = body.get("model")
|
|
717
991
|
reasoning = body.get("reasoning")
|
|
992
|
+
client_turn_id = (body.get("client_turn_id") or "").strip() or None
|
|
718
993
|
|
|
719
994
|
if not message:
|
|
720
995
|
raise HTTPException(status_code=400, detail="message is required")
|
|
@@ -729,6 +1004,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
729
1004
|
status_code=409, detail="Ticket chat already running"
|
|
730
1005
|
)
|
|
731
1006
|
_active_chats[target.state_key] = asyncio.Event()
|
|
1007
|
+
await _begin_turn_state(target, client_turn_id)
|
|
732
1008
|
|
|
733
1009
|
if stream:
|
|
734
1010
|
return StreamingResponse(
|
|
@@ -740,13 +1016,14 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
740
1016
|
agent=agent,
|
|
741
1017
|
model=model,
|
|
742
1018
|
reasoning=reasoning,
|
|
1019
|
+
client_turn_id=client_turn_id,
|
|
743
1020
|
),
|
|
744
1021
|
media_type="text/event-stream",
|
|
745
1022
|
headers=SSE_HEADERS,
|
|
746
1023
|
)
|
|
747
1024
|
|
|
748
1025
|
try:
|
|
749
|
-
|
|
1026
|
+
result = await _execute_file_chat(
|
|
750
1027
|
request,
|
|
751
1028
|
repo_root,
|
|
752
1029
|
target,
|
|
@@ -755,6 +1032,10 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
755
1032
|
model=model,
|
|
756
1033
|
reasoning=reasoning,
|
|
757
1034
|
)
|
|
1035
|
+
result = dict(result or {})
|
|
1036
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
1037
|
+
await _finalize_turn_state(target, result)
|
|
1038
|
+
return result
|
|
758
1039
|
finally:
|
|
759
1040
|
await _clear_interrupt_event(target.state_key)
|
|
760
1041
|
|