yee88 0.3.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.
- yee88/__init__.py +1 -0
- yee88/api.py +116 -0
- yee88/backends.py +25 -0
- yee88/backends_helpers.py +14 -0
- yee88/cli/__init__.py +228 -0
- yee88/cli/config.py +320 -0
- yee88/cli/doctor.py +173 -0
- yee88/cli/init.py +113 -0
- yee88/cli/onboarding_cmd.py +126 -0
- yee88/cli/plugins.py +196 -0
- yee88/cli/run.py +419 -0
- yee88/cli/topic.py +355 -0
- yee88/commands.py +134 -0
- yee88/config.py +142 -0
- yee88/config_migrations.py +124 -0
- yee88/config_watch.py +146 -0
- yee88/context.py +9 -0
- yee88/directives.py +146 -0
- yee88/engines.py +53 -0
- yee88/events.py +170 -0
- yee88/ids.py +17 -0
- yee88/lockfile.py +158 -0
- yee88/logging.py +283 -0
- yee88/markdown.py +298 -0
- yee88/model.py +77 -0
- yee88/plugins.py +312 -0
- yee88/presenter.py +25 -0
- yee88/progress.py +99 -0
- yee88/router.py +113 -0
- yee88/runner.py +712 -0
- yee88/runner_bridge.py +619 -0
- yee88/runners/__init__.py +1 -0
- yee88/runners/claude.py +483 -0
- yee88/runners/codex.py +656 -0
- yee88/runners/mock.py +221 -0
- yee88/runners/opencode.py +505 -0
- yee88/runners/pi.py +523 -0
- yee88/runners/run_options.py +39 -0
- yee88/runners/tool_actions.py +90 -0
- yee88/runtime_loader.py +207 -0
- yee88/scheduler.py +159 -0
- yee88/schemas/__init__.py +1 -0
- yee88/schemas/claude.py +238 -0
- yee88/schemas/codex.py +169 -0
- yee88/schemas/opencode.py +51 -0
- yee88/schemas/pi.py +117 -0
- yee88/settings.py +360 -0
- yee88/telegram/__init__.py +20 -0
- yee88/telegram/api_models.py +37 -0
- yee88/telegram/api_schemas.py +152 -0
- yee88/telegram/backend.py +163 -0
- yee88/telegram/bridge.py +425 -0
- yee88/telegram/chat_prefs.py +242 -0
- yee88/telegram/chat_sessions.py +112 -0
- yee88/telegram/client.py +409 -0
- yee88/telegram/client_api.py +539 -0
- yee88/telegram/commands/__init__.py +12 -0
- yee88/telegram/commands/agent.py +196 -0
- yee88/telegram/commands/cancel.py +116 -0
- yee88/telegram/commands/dispatch.py +111 -0
- yee88/telegram/commands/executor.py +449 -0
- yee88/telegram/commands/file_transfer.py +586 -0
- yee88/telegram/commands/handlers.py +45 -0
- yee88/telegram/commands/media.py +143 -0
- yee88/telegram/commands/menu.py +139 -0
- yee88/telegram/commands/model.py +215 -0
- yee88/telegram/commands/overrides.py +159 -0
- yee88/telegram/commands/parse.py +30 -0
- yee88/telegram/commands/plan.py +16 -0
- yee88/telegram/commands/reasoning.py +234 -0
- yee88/telegram/commands/reply.py +23 -0
- yee88/telegram/commands/topics.py +332 -0
- yee88/telegram/commands/trigger.py +143 -0
- yee88/telegram/context.py +140 -0
- yee88/telegram/engine_defaults.py +86 -0
- yee88/telegram/engine_overrides.py +105 -0
- yee88/telegram/files.py +178 -0
- yee88/telegram/loop.py +1822 -0
- yee88/telegram/onboarding.py +1088 -0
- yee88/telegram/outbox.py +177 -0
- yee88/telegram/parsing.py +239 -0
- yee88/telegram/render.py +198 -0
- yee88/telegram/state_store.py +88 -0
- yee88/telegram/topic_state.py +334 -0
- yee88/telegram/topics.py +256 -0
- yee88/telegram/trigger_mode.py +68 -0
- yee88/telegram/types.py +63 -0
- yee88/telegram/voice.py +110 -0
- yee88/transport.py +53 -0
- yee88/transport_runtime.py +323 -0
- yee88/transports.py +76 -0
- yee88/utils/__init__.py +1 -0
- yee88/utils/git.py +87 -0
- yee88/utils/json_state.py +21 -0
- yee88/utils/paths.py +47 -0
- yee88/utils/streams.py +44 -0
- yee88/utils/subprocess.py +86 -0
- yee88/worktrees.py +135 -0
- yee88-0.3.0.dist-info/METADATA +116 -0
- yee88-0.3.0.dist-info/RECORD +103 -0
- yee88-0.3.0.dist-info/WHEEL +4 -0
- yee88-0.3.0.dist-info/entry_points.txt +11 -0
- yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
yee88/runners/codex.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import msgspec
|
|
9
|
+
|
|
10
|
+
from ..backends import EngineBackend, EngineConfig
|
|
11
|
+
from ..config import ConfigError
|
|
12
|
+
from ..events import EventFactory
|
|
13
|
+
from ..logging import get_logger
|
|
14
|
+
from ..model import ActionPhase, EngineId, ResumeToken, TakopiEvent
|
|
15
|
+
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
|
16
|
+
from .run_options import get_run_options
|
|
17
|
+
from ..schemas import codex as codex_schema
|
|
18
|
+
from ..utils.paths import relativize_command
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
ENGINE: EngineId = "codex"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ENGINE",
|
|
26
|
+
"CodexRunner",
|
|
27
|
+
"find_exec_only_flag",
|
|
28
|
+
"translate_codex_event",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_RESUME_RE = re.compile(r"(?im)^\s*`?codex\s+resume\s+(?P<token>[^`\s]+)`?\s*$")
|
|
32
|
+
_RECONNECTING_RE = re.compile(
|
|
33
|
+
r"^Reconnecting\.{3}\s*(?P<attempt>\d+)/(?P<max>\d+)\s*$",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
_EXEC_ONLY_FLAGS = {
|
|
37
|
+
"--skip-git-repo-check",
|
|
38
|
+
"--json",
|
|
39
|
+
"--output-schema",
|
|
40
|
+
"--output-last-message",
|
|
41
|
+
"--color",
|
|
42
|
+
"-o",
|
|
43
|
+
}
|
|
44
|
+
_EXEC_ONLY_PREFIXES = (
|
|
45
|
+
"--output-schema=",
|
|
46
|
+
"--output-last-message=",
|
|
47
|
+
"--color=",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_exec_only_flag(extra_args: list[str]) -> str | None:
|
|
52
|
+
for arg in extra_args:
|
|
53
|
+
if arg in _EXEC_ONLY_FLAGS:
|
|
54
|
+
return arg
|
|
55
|
+
for prefix in _EXEC_ONLY_PREFIXES:
|
|
56
|
+
if arg.startswith(prefix):
|
|
57
|
+
return arg
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_reconnect_message(message: str) -> tuple[int, int] | None:
|
|
62
|
+
match = _RECONNECTING_RE.match(message)
|
|
63
|
+
if not match:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
attempt = int(match.group("attempt"))
|
|
67
|
+
max_attempts = int(match.group("max"))
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return None
|
|
70
|
+
return (attempt, max_attempts)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _short_tool_name(server: str | None, tool: str | None) -> str:
|
|
74
|
+
name = ".".join(part for part in (server, tool) if part)
|
|
75
|
+
return name or "tool"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _summarize_tool_result(result: Any) -> dict[str, Any] | None:
|
|
79
|
+
if isinstance(result, codex_schema.McpToolCallItemResult):
|
|
80
|
+
summary: dict[str, Any] = {}
|
|
81
|
+
content = result.content
|
|
82
|
+
if isinstance(content, list):
|
|
83
|
+
summary["content_blocks"] = len(content)
|
|
84
|
+
elif content is not None:
|
|
85
|
+
summary["content_blocks"] = 1
|
|
86
|
+
summary["has_structured"] = result.structured_content is not None
|
|
87
|
+
return summary or None
|
|
88
|
+
|
|
89
|
+
if isinstance(result, dict):
|
|
90
|
+
summary = {}
|
|
91
|
+
content = result.get("content")
|
|
92
|
+
if isinstance(content, list):
|
|
93
|
+
summary["content_blocks"] = len(content)
|
|
94
|
+
elif content is not None:
|
|
95
|
+
summary["content_blocks"] = 1
|
|
96
|
+
|
|
97
|
+
structured_key: str | None = None
|
|
98
|
+
if "structured_content" in result:
|
|
99
|
+
structured_key = "structured_content"
|
|
100
|
+
elif "structured" in result:
|
|
101
|
+
structured_key = "structured"
|
|
102
|
+
|
|
103
|
+
if structured_key is not None:
|
|
104
|
+
summary["has_structured"] = result.get(structured_key) is not None
|
|
105
|
+
return summary or None
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _normalize_change_list(changes: list[Any]) -> list[dict[str, str]]:
|
|
111
|
+
normalized: list[dict[str, str]] = []
|
|
112
|
+
for change in changes:
|
|
113
|
+
path: str | None = None
|
|
114
|
+
kind: str | None = None
|
|
115
|
+
if isinstance(change, codex_schema.FileUpdateChange):
|
|
116
|
+
path = change.path
|
|
117
|
+
kind = change.kind
|
|
118
|
+
elif isinstance(change, dict):
|
|
119
|
+
path = change.get("path")
|
|
120
|
+
kind = change.get("kind")
|
|
121
|
+
if not isinstance(path, str) or not path:
|
|
122
|
+
continue
|
|
123
|
+
entry = {"path": path}
|
|
124
|
+
if isinstance(kind, str) and kind:
|
|
125
|
+
entry["kind"] = kind
|
|
126
|
+
normalized.append(entry)
|
|
127
|
+
return normalized
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _format_change_summary(changes: list[Any]) -> str:
|
|
131
|
+
paths: list[str] = []
|
|
132
|
+
for change in changes:
|
|
133
|
+
if isinstance(change, codex_schema.FileUpdateChange):
|
|
134
|
+
if change.path:
|
|
135
|
+
paths.append(change.path)
|
|
136
|
+
continue
|
|
137
|
+
if isinstance(change, dict):
|
|
138
|
+
path = change.get("path")
|
|
139
|
+
if isinstance(path, str) and path:
|
|
140
|
+
paths.append(path)
|
|
141
|
+
if not paths:
|
|
142
|
+
total = len(changes)
|
|
143
|
+
if total <= 0:
|
|
144
|
+
return "files"
|
|
145
|
+
return f"{total} files"
|
|
146
|
+
return ", ".join(str(path) for path in paths)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class _TodoSummary:
|
|
151
|
+
done: int
|
|
152
|
+
total: int
|
|
153
|
+
next_text: str | None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _summarize_todo_list(items: Any) -> _TodoSummary:
|
|
157
|
+
if not isinstance(items, list):
|
|
158
|
+
return _TodoSummary(done=0, total=0, next_text=None)
|
|
159
|
+
|
|
160
|
+
done = 0
|
|
161
|
+
total = 0
|
|
162
|
+
next_text: str | None = None
|
|
163
|
+
|
|
164
|
+
for raw_item in items:
|
|
165
|
+
if isinstance(raw_item, codex_schema.TodoItem):
|
|
166
|
+
total += 1
|
|
167
|
+
if raw_item.completed:
|
|
168
|
+
done += 1
|
|
169
|
+
continue
|
|
170
|
+
if next_text is None:
|
|
171
|
+
next_text = raw_item.text
|
|
172
|
+
continue
|
|
173
|
+
if not isinstance(raw_item, dict):
|
|
174
|
+
continue
|
|
175
|
+
total += 1
|
|
176
|
+
completed = raw_item.get("completed") is True
|
|
177
|
+
if completed:
|
|
178
|
+
done += 1
|
|
179
|
+
continue
|
|
180
|
+
if next_text is None:
|
|
181
|
+
text = raw_item.get("text")
|
|
182
|
+
next_text = str(text) if text is not None else None
|
|
183
|
+
|
|
184
|
+
return _TodoSummary(done=done, total=total, next_text=next_text)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _todo_title(summary: _TodoSummary) -> str:
|
|
188
|
+
if summary.total <= 0:
|
|
189
|
+
return "todo"
|
|
190
|
+
if summary.next_text:
|
|
191
|
+
return f"todo {summary.done}/{summary.total}: {summary.next_text}"
|
|
192
|
+
return f"todo {summary.done}/{summary.total}: done"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _translate_item_event(
|
|
196
|
+
phase: ActionPhase, item: codex_schema.ThreadItem, *, factory: EventFactory
|
|
197
|
+
) -> list[TakopiEvent]:
|
|
198
|
+
match item:
|
|
199
|
+
case codex_schema.AgentMessageItem():
|
|
200
|
+
return []
|
|
201
|
+
case codex_schema.ErrorItem(id=action_id, message=message):
|
|
202
|
+
if phase != "completed":
|
|
203
|
+
return []
|
|
204
|
+
return [
|
|
205
|
+
factory.action_completed(
|
|
206
|
+
action_id=action_id,
|
|
207
|
+
kind="warning",
|
|
208
|
+
title=message,
|
|
209
|
+
detail={"message": message},
|
|
210
|
+
ok=False,
|
|
211
|
+
message=message,
|
|
212
|
+
level="warning",
|
|
213
|
+
),
|
|
214
|
+
]
|
|
215
|
+
case codex_schema.CommandExecutionItem(
|
|
216
|
+
id=action_id,
|
|
217
|
+
command=command,
|
|
218
|
+
exit_code=exit_code,
|
|
219
|
+
status=status,
|
|
220
|
+
):
|
|
221
|
+
title = relativize_command(command)
|
|
222
|
+
if phase in {"started", "updated"}:
|
|
223
|
+
return [
|
|
224
|
+
factory.action(
|
|
225
|
+
phase=phase,
|
|
226
|
+
action_id=action_id,
|
|
227
|
+
kind="command",
|
|
228
|
+
title=title,
|
|
229
|
+
)
|
|
230
|
+
]
|
|
231
|
+
if phase == "completed":
|
|
232
|
+
ok = status == "completed"
|
|
233
|
+
if isinstance(exit_code, int):
|
|
234
|
+
ok = ok and exit_code == 0
|
|
235
|
+
detail = {"exit_code": exit_code, "status": status}
|
|
236
|
+
return [
|
|
237
|
+
factory.action_completed(
|
|
238
|
+
action_id=action_id,
|
|
239
|
+
kind="command",
|
|
240
|
+
title=title,
|
|
241
|
+
detail=detail,
|
|
242
|
+
ok=ok,
|
|
243
|
+
),
|
|
244
|
+
]
|
|
245
|
+
case codex_schema.McpToolCallItem(
|
|
246
|
+
id=action_id,
|
|
247
|
+
server=server,
|
|
248
|
+
tool=tool,
|
|
249
|
+
arguments=arguments,
|
|
250
|
+
status=status,
|
|
251
|
+
result=result,
|
|
252
|
+
error=error,
|
|
253
|
+
):
|
|
254
|
+
title = _short_tool_name(server, tool)
|
|
255
|
+
detail: dict[str, Any] = {
|
|
256
|
+
"server": server,
|
|
257
|
+
"tool": tool,
|
|
258
|
+
"status": status,
|
|
259
|
+
"arguments": arguments,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if phase in {"started", "updated"}:
|
|
263
|
+
return [
|
|
264
|
+
factory.action(
|
|
265
|
+
phase=phase,
|
|
266
|
+
action_id=action_id,
|
|
267
|
+
kind="tool",
|
|
268
|
+
title=title,
|
|
269
|
+
detail=detail,
|
|
270
|
+
)
|
|
271
|
+
]
|
|
272
|
+
if phase == "completed":
|
|
273
|
+
ok = status == "completed" and error is None
|
|
274
|
+
if error is not None:
|
|
275
|
+
detail["error_message"] = str(error.message)
|
|
276
|
+
result_summary = _summarize_tool_result(result)
|
|
277
|
+
if result_summary is not None:
|
|
278
|
+
detail["result_summary"] = result_summary
|
|
279
|
+
return [
|
|
280
|
+
factory.action_completed(
|
|
281
|
+
action_id=action_id,
|
|
282
|
+
kind="tool",
|
|
283
|
+
title=title,
|
|
284
|
+
detail=detail,
|
|
285
|
+
ok=ok,
|
|
286
|
+
),
|
|
287
|
+
]
|
|
288
|
+
case codex_schema.WebSearchItem(id=action_id, query=query):
|
|
289
|
+
detail = {"query": query}
|
|
290
|
+
if phase in {"started", "updated"}:
|
|
291
|
+
return [
|
|
292
|
+
factory.action(
|
|
293
|
+
phase=phase,
|
|
294
|
+
action_id=action_id,
|
|
295
|
+
kind="web_search",
|
|
296
|
+
title=query,
|
|
297
|
+
detail=detail,
|
|
298
|
+
)
|
|
299
|
+
]
|
|
300
|
+
if phase == "completed":
|
|
301
|
+
return [
|
|
302
|
+
factory.action_completed(
|
|
303
|
+
action_id=action_id,
|
|
304
|
+
kind="web_search",
|
|
305
|
+
title=query,
|
|
306
|
+
detail=detail,
|
|
307
|
+
ok=True,
|
|
308
|
+
)
|
|
309
|
+
]
|
|
310
|
+
case codex_schema.FileChangeItem(id=action_id, changes=changes, status=status):
|
|
311
|
+
if phase != "completed":
|
|
312
|
+
return []
|
|
313
|
+
title = _format_change_summary(changes)
|
|
314
|
+
normalized_changes = _normalize_change_list(changes)
|
|
315
|
+
detail = {
|
|
316
|
+
"changes": normalized_changes,
|
|
317
|
+
"status": status,
|
|
318
|
+
"error": None,
|
|
319
|
+
}
|
|
320
|
+
ok = status == "completed"
|
|
321
|
+
return [
|
|
322
|
+
factory.action_completed(
|
|
323
|
+
action_id=action_id,
|
|
324
|
+
kind="file_change",
|
|
325
|
+
title=title,
|
|
326
|
+
detail=detail,
|
|
327
|
+
ok=ok,
|
|
328
|
+
)
|
|
329
|
+
]
|
|
330
|
+
case codex_schema.TodoListItem(id=action_id, items=items):
|
|
331
|
+
summary = _summarize_todo_list(items)
|
|
332
|
+
title = _todo_title(summary)
|
|
333
|
+
detail = {"done": summary.done, "total": summary.total}
|
|
334
|
+
if phase in {"started", "updated"}:
|
|
335
|
+
return [
|
|
336
|
+
factory.action(
|
|
337
|
+
phase=phase,
|
|
338
|
+
action_id=action_id,
|
|
339
|
+
kind="note",
|
|
340
|
+
title=title,
|
|
341
|
+
detail=detail,
|
|
342
|
+
)
|
|
343
|
+
]
|
|
344
|
+
if phase == "completed":
|
|
345
|
+
return [
|
|
346
|
+
factory.action_completed(
|
|
347
|
+
action_id=action_id,
|
|
348
|
+
kind="note",
|
|
349
|
+
title=title,
|
|
350
|
+
detail=detail,
|
|
351
|
+
ok=True,
|
|
352
|
+
)
|
|
353
|
+
]
|
|
354
|
+
case codex_schema.ReasoningItem(id=action_id, text=text):
|
|
355
|
+
if phase in {"started", "updated"}:
|
|
356
|
+
return [
|
|
357
|
+
factory.action(
|
|
358
|
+
phase=phase,
|
|
359
|
+
action_id=action_id,
|
|
360
|
+
kind="note",
|
|
361
|
+
title=text,
|
|
362
|
+
)
|
|
363
|
+
]
|
|
364
|
+
if phase == "completed":
|
|
365
|
+
return [
|
|
366
|
+
factory.action_completed(
|
|
367
|
+
action_id=action_id,
|
|
368
|
+
kind="note",
|
|
369
|
+
title=text,
|
|
370
|
+
ok=True,
|
|
371
|
+
)
|
|
372
|
+
]
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def translate_codex_event(
|
|
377
|
+
event: codex_schema.ThreadEvent,
|
|
378
|
+
*,
|
|
379
|
+
title: str,
|
|
380
|
+
factory: EventFactory,
|
|
381
|
+
) -> list[TakopiEvent]:
|
|
382
|
+
match event:
|
|
383
|
+
case codex_schema.ThreadStarted(thread_id=thread_id):
|
|
384
|
+
token = ResumeToken(engine=ENGINE, value=thread_id)
|
|
385
|
+
return [factory.started(token, title=title)]
|
|
386
|
+
case codex_schema.ItemStarted(item=item):
|
|
387
|
+
return _translate_item_event("started", item, factory=factory)
|
|
388
|
+
case codex_schema.ItemUpdated(item=item):
|
|
389
|
+
return _translate_item_event("updated", item, factory=factory)
|
|
390
|
+
case codex_schema.ItemCompleted(item=item):
|
|
391
|
+
return _translate_item_event("completed", item, factory=factory)
|
|
392
|
+
case _:
|
|
393
|
+
return []
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@dataclass(slots=True)
|
|
397
|
+
class CodexRunState:
|
|
398
|
+
factory: EventFactory
|
|
399
|
+
note_seq: int = 0
|
|
400
|
+
final_answer: str | None = None
|
|
401
|
+
turn_index: int = 0
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
|
405
|
+
engine: EngineId = ENGINE
|
|
406
|
+
resume_re = _RESUME_RE
|
|
407
|
+
logger = logger
|
|
408
|
+
|
|
409
|
+
def __init__(
|
|
410
|
+
self,
|
|
411
|
+
*,
|
|
412
|
+
codex_cmd: str,
|
|
413
|
+
extra_args: list[str],
|
|
414
|
+
title: str = "Codex",
|
|
415
|
+
) -> None:
|
|
416
|
+
self.codex_cmd = codex_cmd
|
|
417
|
+
self.extra_args = extra_args
|
|
418
|
+
self.session_title = title
|
|
419
|
+
|
|
420
|
+
def command(self) -> str:
|
|
421
|
+
return self.codex_cmd
|
|
422
|
+
|
|
423
|
+
def build_args(
|
|
424
|
+
self,
|
|
425
|
+
prompt: str,
|
|
426
|
+
resume: ResumeToken | None,
|
|
427
|
+
*,
|
|
428
|
+
state: Any,
|
|
429
|
+
) -> list[str]:
|
|
430
|
+
run_options = get_run_options()
|
|
431
|
+
args = [*self.extra_args]
|
|
432
|
+
if run_options is not None:
|
|
433
|
+
if run_options.model:
|
|
434
|
+
args.extend(["--model", str(run_options.model)])
|
|
435
|
+
if run_options.reasoning:
|
|
436
|
+
args.extend(
|
|
437
|
+
[
|
|
438
|
+
"-c",
|
|
439
|
+
f"model_reasoning_effort={run_options.reasoning}",
|
|
440
|
+
]
|
|
441
|
+
)
|
|
442
|
+
args.extend(
|
|
443
|
+
[
|
|
444
|
+
"exec",
|
|
445
|
+
"--json",
|
|
446
|
+
"--skip-git-repo-check",
|
|
447
|
+
"--color=never",
|
|
448
|
+
]
|
|
449
|
+
)
|
|
450
|
+
if resume:
|
|
451
|
+
args.extend(["resume", resume.value, "-"])
|
|
452
|
+
else:
|
|
453
|
+
args.append("-")
|
|
454
|
+
return args
|
|
455
|
+
|
|
456
|
+
def new_state(self, prompt: str, resume: ResumeToken | None) -> CodexRunState:
|
|
457
|
+
return CodexRunState(factory=EventFactory(ENGINE))
|
|
458
|
+
|
|
459
|
+
def start_run(
|
|
460
|
+
self,
|
|
461
|
+
prompt: str,
|
|
462
|
+
resume: ResumeToken | None,
|
|
463
|
+
*,
|
|
464
|
+
state: CodexRunState,
|
|
465
|
+
) -> None:
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
def decode_jsonl(self, *, line: bytes) -> codex_schema.ThreadEvent:
|
|
469
|
+
return codex_schema.decode_event(line)
|
|
470
|
+
|
|
471
|
+
def decode_error_events(
|
|
472
|
+
self,
|
|
473
|
+
*,
|
|
474
|
+
raw: str,
|
|
475
|
+
line: str,
|
|
476
|
+
error: Exception,
|
|
477
|
+
state: CodexRunState,
|
|
478
|
+
) -> list[TakopiEvent]:
|
|
479
|
+
if isinstance(error, msgspec.DecodeError):
|
|
480
|
+
self.get_logger().warning(
|
|
481
|
+
"jsonl.msgspec.invalid",
|
|
482
|
+
tag=self.tag(),
|
|
483
|
+
error=str(error),
|
|
484
|
+
error_type=error.__class__.__name__,
|
|
485
|
+
)
|
|
486
|
+
return []
|
|
487
|
+
return super().decode_error_events(
|
|
488
|
+
raw=raw,
|
|
489
|
+
line=line,
|
|
490
|
+
error=error,
|
|
491
|
+
state=state,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def pipes_error_message(self) -> str:
|
|
495
|
+
return "codex exec failed to open subprocess pipes"
|
|
496
|
+
|
|
497
|
+
def translate(
|
|
498
|
+
self,
|
|
499
|
+
data: codex_schema.ThreadEvent,
|
|
500
|
+
*,
|
|
501
|
+
state: CodexRunState,
|
|
502
|
+
resume: ResumeToken | None,
|
|
503
|
+
found_session: ResumeToken | None,
|
|
504
|
+
) -> list[TakopiEvent]:
|
|
505
|
+
factory = state.factory
|
|
506
|
+
match data:
|
|
507
|
+
case codex_schema.StreamError(message=message):
|
|
508
|
+
reconnect = _parse_reconnect_message(message)
|
|
509
|
+
if reconnect is not None:
|
|
510
|
+
attempt, max_attempts = reconnect
|
|
511
|
+
phase: ActionPhase = "started" if attempt <= 1 else "updated"
|
|
512
|
+
return [
|
|
513
|
+
factory.action(
|
|
514
|
+
phase=phase,
|
|
515
|
+
action_id="codex.reconnect",
|
|
516
|
+
kind="note",
|
|
517
|
+
title=message,
|
|
518
|
+
detail={"attempt": attempt, "max": max_attempts},
|
|
519
|
+
level="info",
|
|
520
|
+
)
|
|
521
|
+
]
|
|
522
|
+
return [self.note_event(message, state=state, ok=False)]
|
|
523
|
+
case codex_schema.TurnFailed(error=error):
|
|
524
|
+
resume_for_completed = found_session or resume
|
|
525
|
+
return [
|
|
526
|
+
factory.completed_error(
|
|
527
|
+
error=error.message,
|
|
528
|
+
answer=state.final_answer or "",
|
|
529
|
+
resume=resume_for_completed,
|
|
530
|
+
)
|
|
531
|
+
]
|
|
532
|
+
case codex_schema.TurnStarted():
|
|
533
|
+
action_id = f"turn_{state.turn_index}"
|
|
534
|
+
state.turn_index += 1
|
|
535
|
+
return [
|
|
536
|
+
factory.action_started(
|
|
537
|
+
action_id=action_id,
|
|
538
|
+
kind="turn",
|
|
539
|
+
title="turn started",
|
|
540
|
+
)
|
|
541
|
+
]
|
|
542
|
+
case codex_schema.TurnCompleted(usage=usage):
|
|
543
|
+
resume_for_completed = found_session or resume
|
|
544
|
+
return [
|
|
545
|
+
factory.completed_ok(
|
|
546
|
+
answer=state.final_answer or "",
|
|
547
|
+
resume=resume_for_completed,
|
|
548
|
+
usage=msgspec.to_builtins(usage),
|
|
549
|
+
)
|
|
550
|
+
]
|
|
551
|
+
case codex_schema.ItemCompleted(
|
|
552
|
+
item=codex_schema.AgentMessageItem(text=text)
|
|
553
|
+
):
|
|
554
|
+
if state.final_answer is None:
|
|
555
|
+
state.final_answer = text
|
|
556
|
+
else:
|
|
557
|
+
logger.debug("codex.multiple_agent_messages")
|
|
558
|
+
state.final_answer = text
|
|
559
|
+
case _:
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
return translate_codex_event(
|
|
563
|
+
data,
|
|
564
|
+
title=self.session_title,
|
|
565
|
+
factory=factory,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def process_error_events(
|
|
569
|
+
self,
|
|
570
|
+
rc: int,
|
|
571
|
+
*,
|
|
572
|
+
resume: ResumeToken | None,
|
|
573
|
+
found_session: ResumeToken | None,
|
|
574
|
+
state: CodexRunState,
|
|
575
|
+
) -> list[TakopiEvent]:
|
|
576
|
+
message = f"codex exec failed (rc={rc})."
|
|
577
|
+
resume_for_completed = found_session or resume
|
|
578
|
+
return [
|
|
579
|
+
self.note_event(
|
|
580
|
+
message,
|
|
581
|
+
state=state,
|
|
582
|
+
ok=False,
|
|
583
|
+
),
|
|
584
|
+
state.factory.completed_error(
|
|
585
|
+
error=message,
|
|
586
|
+
answer=state.final_answer or "",
|
|
587
|
+
resume=resume_for_completed,
|
|
588
|
+
),
|
|
589
|
+
]
|
|
590
|
+
|
|
591
|
+
def stream_end_events(
|
|
592
|
+
self,
|
|
593
|
+
*,
|
|
594
|
+
resume: ResumeToken | None,
|
|
595
|
+
found_session: ResumeToken | None,
|
|
596
|
+
state: CodexRunState,
|
|
597
|
+
) -> list[TakopiEvent]:
|
|
598
|
+
if not found_session:
|
|
599
|
+
message = "codex exec finished but no session_id/thread_id was captured"
|
|
600
|
+
resume_for_completed = resume
|
|
601
|
+
return [
|
|
602
|
+
state.factory.completed_error(
|
|
603
|
+
error=message,
|
|
604
|
+
answer=state.final_answer or "",
|
|
605
|
+
resume=resume_for_completed,
|
|
606
|
+
)
|
|
607
|
+
]
|
|
608
|
+
logger.info("codex.session.completed", resume=found_session.value)
|
|
609
|
+
return [
|
|
610
|
+
state.factory.completed_ok(
|
|
611
|
+
answer=state.final_answer or "",
|
|
612
|
+
resume=found_session,
|
|
613
|
+
)
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def build_runner(config: EngineConfig, config_path: Path) -> Runner:
|
|
618
|
+
codex_cmd = "codex"
|
|
619
|
+
|
|
620
|
+
extra_args_value = config.get("extra_args")
|
|
621
|
+
if extra_args_value is None:
|
|
622
|
+
extra_args = ["-c", "notify=[]"]
|
|
623
|
+
elif isinstance(extra_args_value, list) and all(
|
|
624
|
+
isinstance(item, str) for item in extra_args_value
|
|
625
|
+
):
|
|
626
|
+
extra_args = list(extra_args_value)
|
|
627
|
+
else:
|
|
628
|
+
raise ConfigError(
|
|
629
|
+
f"Invalid `codex.extra_args` in {config_path}; expected a list of strings."
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
exec_only_flag = find_exec_only_flag(extra_args)
|
|
633
|
+
if exec_only_flag:
|
|
634
|
+
raise ConfigError(
|
|
635
|
+
f"Invalid `codex.extra_args` in {config_path}; exec-only flag "
|
|
636
|
+
f"{exec_only_flag!r} is managed by Takopi."
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
title = "Codex"
|
|
640
|
+
profile_value = config.get("profile")
|
|
641
|
+
if profile_value:
|
|
642
|
+
if not isinstance(profile_value, str):
|
|
643
|
+
raise ConfigError(
|
|
644
|
+
f"Invalid `codex.profile` in {config_path}; expected a string."
|
|
645
|
+
)
|
|
646
|
+
extra_args.extend(["--profile", profile_value])
|
|
647
|
+
title = profile_value
|
|
648
|
+
|
|
649
|
+
return CodexRunner(codex_cmd=codex_cmd, extra_args=extra_args, title=title)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
BACKEND = EngineBackend(
|
|
653
|
+
id="codex",
|
|
654
|
+
build_runner=build_runner,
|
|
655
|
+
install_cmd="npm install -g @openai/codex",
|
|
656
|
+
)
|