codexloop 0.1.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.
Files changed (104) hide show
  1. codexloop/__init__.py +3 -0
  2. codexloop/application/__init__.py +1 -0
  3. codexloop/application/dto.py +41 -0
  4. codexloop/application/ports.py +158 -0
  5. codexloop/application/runner.py +467 -0
  6. codexloop/application/usecases/__init__.py +1 -0
  7. codexloop/application/usecases/doctor.py +37 -0
  8. codexloop/application/usecases/list_threads.py +14 -0
  9. codexloop/application/usecases/preflight.py +10 -0
  10. codexloop/application/usecases/resume_thread.py +11 -0
  11. codexloop/application/usecases/run_control.py +20 -0
  12. codexloop/application/usecases/run_plan.py +11 -0
  13. codexloop/bootstrap.py +461 -0
  14. codexloop/cli/__init__.py +1 -0
  15. codexloop/cli/app.py +94 -0
  16. codexloop/cli/asyncio.py +80 -0
  17. codexloop/cli/commands/__init__.py +1 -0
  18. codexloop/cli/commands/approval_cmd.py +24 -0
  19. codexloop/cli/commands/capacity.py +33 -0
  20. codexloop/cli/commands/cwd_cmd.py +22 -0
  21. codexloop/cli/commands/doctor.py +27 -0
  22. codexloop/cli/commands/effort_cmd.py +24 -0
  23. codexloop/cli/commands/logs.py +15 -0
  24. codexloop/cli/commands/model_cmd.py +22 -0
  25. codexloop/cli/commands/prompt.py +32 -0
  26. codexloop/cli/commands/reset.py +25 -0
  27. codexloop/cli/commands/resume.py +38 -0
  28. codexloop/cli/commands/run.py +50 -0
  29. codexloop/cli/commands/runs.py +13 -0
  30. codexloop/cli/commands/sandbox_cmd.py +24 -0
  31. codexloop/cli/commands/savepoints.py +25 -0
  32. codexloop/cli/commands/snapshot.py +26 -0
  33. codexloop/cli/commands/status.py +15 -0
  34. codexloop/cli/commands/stop.py +21 -0
  35. codexloop/cli/commands/threads.py +15 -0
  36. codexloop/cli/commands/unwind.py +24 -0
  37. codexloop/cli/commands/watch.py +66 -0
  38. codexloop/cli/render.py +39 -0
  39. codexloop/domain/__init__.py +26 -0
  40. codexloop/domain/approval.py +38 -0
  41. codexloop/domain/backoff.py +34 -0
  42. codexloop/domain/budget.py +56 -0
  43. codexloop/domain/capacity.py +81 -0
  44. codexloop/domain/classify.py +98 -0
  45. codexloop/domain/completion.py +130 -0
  46. codexloop/domain/control.py +167 -0
  47. codexloop/domain/error_codes.py +75 -0
  48. codexloop/domain/errors.py +35 -0
  49. codexloop/domain/loop.py +190 -0
  50. codexloop/domain/model_profile.py +30 -0
  51. codexloop/domain/plan.py +38 -0
  52. codexloop/domain/savepoint.py +32 -0
  53. codexloop/domain/savepoint_message.py +56 -0
  54. codexloop/domain/session.py +32 -0
  55. codexloop/domain/signals.py +25 -0
  56. codexloop/domain/waiting.py +120 -0
  57. codexloop/infrastructure/__init__.py +0 -0
  58. codexloop/infrastructure/agent/__init__.py +0 -0
  59. codexloop/infrastructure/agent/argv.py +102 -0
  60. codexloop/infrastructure/agent/events.py +274 -0
  61. codexloop/infrastructure/agent/gateway.py +189 -0
  62. codexloop/infrastructure/agent/probe.py +74 -0
  63. codexloop/infrastructure/agent/process.py +208 -0
  64. codexloop/infrastructure/agent/schema.py +31 -0
  65. codexloop/infrastructure/agent/scripted.py +201 -0
  66. codexloop/infrastructure/agent/translate.py +120 -0
  67. codexloop/infrastructure/api/__init__.py +26 -0
  68. codexloop/infrastructure/api/api_baseline.json +340 -0
  69. codexloop/infrastructure/api/binder.py +170 -0
  70. codexloop/infrastructure/api/gateway.py +142 -0
  71. codexloop/infrastructure/api/introspect.py +248 -0
  72. codexloop/infrastructure/api/json_io.py +26 -0
  73. codexloop/infrastructure/api/params.py +162 -0
  74. codexloop/infrastructure/api/providers.py +70 -0
  75. codexloop/infrastructure/api/registry.py +13 -0
  76. codexloop/infrastructure/appserver/__init__.py +6 -0
  77. codexloop/infrastructure/appserver/client.py +245 -0
  78. codexloop/infrastructure/appserver/gateway.py +437 -0
  79. codexloop/infrastructure/appserver/ratelimits.py +100 -0
  80. codexloop/infrastructure/audit.py +26 -0
  81. codexloop/infrastructure/capacity_probe.py +57 -0
  82. codexloop/infrastructure/clock.py +26 -0
  83. codexloop/infrastructure/config.py +150 -0
  84. codexloop/infrastructure/control.py +89 -0
  85. codexloop/infrastructure/doctor_env.py +239 -0
  86. codexloop/infrastructure/events.py +23 -0
  87. codexloop/infrastructure/git_savepoints.py +176 -0
  88. codexloop/infrastructure/lock.py +88 -0
  89. codexloop/infrastructure/logging.py +124 -0
  90. codexloop/infrastructure/notify.py +27 -0
  91. codexloop/infrastructure/progress.py +14 -0
  92. codexloop/infrastructure/redact.py +52 -0
  93. codexloop/infrastructure/rollout.py +113 -0
  94. codexloop/infrastructure/rundir.py +57 -0
  95. codexloop/infrastructure/snapshot.py +39 -0
  96. codexloop/infrastructure/state.py +32 -0
  97. codexloop/infrastructure/state_bus.py +27 -0
  98. codexloop/infrastructure/stream_ui.py +44 -0
  99. codexloop/py.typed +0 -0
  100. codexloop-0.1.0.dist-info/METADATA +104 -0
  101. codexloop-0.1.0.dist-info/RECORD +104 -0
  102. codexloop-0.1.0.dist-info/WHEEL +4 -0
  103. codexloop-0.1.0.dist-info/entry_points.txt +2 -0
  104. codexloop-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,208 @@
1
+ """Supervised ``codex`` subprocess: concurrent pumps and process-group teardown.
2
+
3
+ ``ProcessResult.stderr_tail`` is the last :data:`STDERR_TAIL_BYTES` (8 KiB) of
4
+ stderr, not the full stream. Oversized stdout lines are truncated to
5
+ ``max_line_bytes`` and counted in ``truncated_lines``; they never raise and
6
+ never accumulate past that ceiling.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import signal
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Final
17
+
18
+ import anyio
19
+ from anyio import EndOfStream
20
+ from anyio.abc import ByteReceiveStream, Process
21
+
22
+ from codexloop.domain.errors import CodexBinaryError
23
+
24
+ STDERR_TAIL_BYTES: Final[int] = 8 * 1024
25
+ _KILL_GRACE_SECONDS: Final[float] = 0.5
26
+ _RECEIVE_CHUNK: Final[int] = 8192
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ProcessResult:
31
+ stdout_lines: list[str]
32
+ stderr_tail: str
33
+ exit_code: int
34
+ truncated_lines: int
35
+
36
+
37
+ async def run_codex(
38
+ argv: Sequence[str],
39
+ *,
40
+ cwd: str | Path,
41
+ env: Mapping[str, str],
42
+ timeout: float,
43
+ max_line_bytes: int,
44
+ ) -> ProcessResult:
45
+ """Run ``argv`` (no shell) with concurrent stdout/stderr pumps.
46
+
47
+ The child is started in its own session (``start_new_session=True``) so
48
+ timeout and cancellation send ``SIGTERM`` then ``SIGKILL`` to the whole
49
+ process group. Stdin is ``DEVNULL`` (not a TTY; reads return EOF).
50
+ """
51
+ if not argv:
52
+ raise CodexBinaryError("argv must be a non-empty list")
53
+
54
+ stdout_collector = _StdoutCollector(max_line_bytes)
55
+ stderr_collector = _StderrTail()
56
+ # Child stdin is not a TTY; reads return EOF immediately.
57
+ devnull_fd = os.open(os.devnull, os.O_RDONLY)
58
+ try:
59
+ process = await anyio.open_process(
60
+ list(argv),
61
+ stdin=devnull_fd,
62
+ cwd=os.fspath(cwd),
63
+ env=dict(env),
64
+ start_new_session=True,
65
+ )
66
+ finally:
67
+ os.close(devnull_fd)
68
+ try:
69
+ with anyio.fail_after(timeout):
70
+ async with anyio.create_task_group() as tg:
71
+ if process.stdout is not None: # pragma: no branch
72
+ tg.start_soon(_pump_stdout, process.stdout, stdout_collector, max_line_bytes)
73
+ if process.stderr is not None: # pragma: no branch
74
+ tg.start_soon(_pump_stderr, process.stderr, stderr_collector)
75
+ await process.wait()
76
+ except TimeoutError:
77
+ await _terminate_group(process)
78
+ raise CodexBinaryError(f"timed out after {timeout}s") from None
79
+ except BaseException:
80
+ await _terminate_group(process)
81
+ raise
82
+ else:
83
+ code = process.returncode
84
+ return ProcessResult(
85
+ stdout_lines=stdout_collector.lines,
86
+ stderr_tail=stderr_collector.text(),
87
+ exit_code=0 if code is None else code,
88
+ truncated_lines=stdout_collector.truncated_lines,
89
+ )
90
+ finally:
91
+ with anyio.CancelScope(shield=True):
92
+ await process.aclose()
93
+
94
+
95
+ async def _pump_stdout(
96
+ stream: ByteReceiveStream,
97
+ collector: _StdoutCollector,
98
+ max_line_bytes: int,
99
+ ) -> None:
100
+ chunk_size = max(1, min(_RECEIVE_CHUNK, max_line_bytes))
101
+ try:
102
+ while True:
103
+ try:
104
+ chunk = await stream.receive(chunk_size)
105
+ except EndOfStream:
106
+ break
107
+ collector.feed(chunk)
108
+ finally:
109
+ collector.flush()
110
+
111
+
112
+ async def _pump_stderr(stream: ByteReceiveStream, collector: _StderrTail) -> None:
113
+ while True:
114
+ try:
115
+ chunk = await stream.receive(_RECEIVE_CHUNK)
116
+ except EndOfStream:
117
+ break
118
+ collector.feed(chunk)
119
+
120
+
121
+ async def _terminate_group(process: Process) -> None:
122
+ pid = process.pid
123
+ with anyio.CancelScope(shield=True):
124
+ _signal_group(pid, signal.SIGTERM)
125
+ with anyio.move_on_after(_KILL_GRACE_SECONDS):
126
+ await process.wait()
127
+ _signal_group(pid, signal.SIGKILL)
128
+ with anyio.move_on_after(2.0):
129
+ await process.wait()
130
+
131
+
132
+ def _signal_group(pid: int, sig: int) -> None:
133
+ try:
134
+ os.killpg(pid, sig)
135
+ except ProcessLookupError: # pragma: no cover — process already gone
136
+ return
137
+ except OSError: # pragma: no cover — fall back to kill(pid)
138
+ try:
139
+ os.kill(pid, sig)
140
+ except (ProcessLookupError, OSError):
141
+ return
142
+
143
+
144
+ class _StdoutCollector:
145
+ """Bound stdout to ``max_line_bytes`` per line; skip overflow until newline."""
146
+
147
+ def __init__(self, max_line_bytes: int) -> None:
148
+ self.max_line_bytes = max_line_bytes
149
+ self.lines: list[str] = []
150
+ self.truncated_lines = 0
151
+ self._buf = bytearray()
152
+ self._skipping = False
153
+
154
+ def feed(self, chunk: bytes) -> None:
155
+ offset = 0
156
+ length = len(chunk)
157
+ while offset < length:
158
+ if self._skipping:
159
+ nl = chunk.find(b"\n", offset)
160
+ if nl == -1:
161
+ return
162
+ self._skipping = False
163
+ offset = nl + 1
164
+ continue
165
+ nl = chunk.find(b"\n", offset)
166
+ end = length if nl == -1 else nl
167
+ piece = chunk[offset:end]
168
+ space = self.max_line_bytes - len(self._buf)
169
+ if len(piece) > space:
170
+ if space > 0:
171
+ self._buf.extend(piece[:space])
172
+ self._emit(truncated=True)
173
+ if nl == -1:
174
+ self._skipping = True
175
+ return
176
+ offset = nl + 1
177
+ continue
178
+ self._buf.extend(piece)
179
+ if nl == -1:
180
+ return
181
+ self._emit(truncated=False)
182
+ offset = nl + 1
183
+
184
+ def flush(self) -> None:
185
+ if self._buf:
186
+ self._emit(truncated=False)
187
+
188
+ def _emit(self, *, truncated: bool) -> None:
189
+ if truncated:
190
+ self.truncated_lines += 1
191
+ self.lines.append(bytes(self._buf).decode("utf-8", errors="replace"))
192
+ self._buf.clear()
193
+
194
+
195
+ class _StderrTail:
196
+ """Rolling last-``STDERR_TAIL_BYTES`` of stderr."""
197
+
198
+ def __init__(self) -> None:
199
+ self._buf = bytearray()
200
+
201
+ def feed(self, chunk: bytes) -> None:
202
+ self._buf.extend(chunk)
203
+ extra = len(self._buf) - STDERR_TAIL_BYTES
204
+ if extra > 0:
205
+ del self._buf[:extra]
206
+
207
+ def text(self) -> str:
208
+ return bytes(self._buf).decode("utf-8", errors="replace")
@@ -0,0 +1,31 @@
1
+ """JSON Schema for structured completion verdicts (R12)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Final
8
+
9
+ COMPLETION_OUTPUT_SCHEMA: Final[dict[str, object]] = {
10
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
11
+ "title": "CodexloopCompletionVerdict",
12
+ "type": "object",
13
+ "additionalProperties": False,
14
+ "required": ["complete", "remaining_work", "blocked_on", "summary"],
15
+ "properties": {
16
+ "complete": {"type": "boolean"},
17
+ "remaining_work": {
18
+ "type": "array",
19
+ "items": {"type": "string"},
20
+ },
21
+ "blocked_on": {"type": ["string", "null"]},
22
+ "summary": {"type": "string"},
23
+ },
24
+ }
25
+
26
+
27
+ def write_output_schema(path: str | Path) -> Path:
28
+ """Write the completion JSON Schema to ``path`` and return that path."""
29
+ dest = Path(path)
30
+ dest.write_text(json.dumps(COMPLETION_OUTPUT_SCHEMA, indent=2) + "\n", encoding="utf-8")
31
+ return dest
@@ -0,0 +1,201 @@
1
+ """JSON-scripted AgentGateway / CapacityProbe for system-live tests.
2
+
3
+ Activated only via the composition-root test gate in ``bootstrap`` when both
4
+ ``CODEXLOOP_ALLOW_TEST_AGENT=1`` and ``CODEXLOOP_TEST_AGENT_SCRIPT`` are set.
5
+ Not a user-facing feature.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import dataclass, field, fields
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from codexloop.application.dto import ProbeResult, TurnOutcome
18
+ from codexloop.application.ports import PermissionMode
19
+ from codexloop.domain.capacity import PlanWindows, RateLimitWindow
20
+ from codexloop.domain.classify import classify
21
+ from codexloop.domain.model_profile import ModelEffortProfile
22
+ from codexloop.domain.signals import TurnSignals
23
+
24
+ ALLOW_TEST_AGENT_ENV = "CODEXLOOP_ALLOW_TEST_AGENT"
25
+ TEST_AGENT_SCRIPT_ENV = "CODEXLOOP_TEST_AGENT_SCRIPT"
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ScriptedTurn:
30
+ signals: TurnSignals = field(default_factory=TurnSignals)
31
+ thread_id: str | None = "scripted-session"
32
+ cost_dollars: float = 0.0
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class AgentScript:
37
+ probes: tuple[TurnSignals, ...]
38
+ turns: tuple[ScriptedTurn, ...]
39
+
40
+
41
+ class ScriptedAgentGateway:
42
+ """Replays scripted turns for system harnesses."""
43
+
44
+ def __init__(self, script: Sequence[ScriptedTurn]) -> None:
45
+ self._script = list(script)
46
+ self.sent_prompts: list[str] = []
47
+ self.closed = False
48
+ self.profiles: list[ModelEffortProfile] = []
49
+ self.permission_modes: list[PermissionMode] = []
50
+ self.cwds: list[str] = []
51
+ self.resource_updates: list[Mapping[str, object]] = []
52
+ self.tool_resolutions: list[tuple[str, bool, str]] = []
53
+
54
+ async def send_turn(self, prompt: str) -> TurnOutcome:
55
+ self.sent_prompts.append(prompt)
56
+ if not self._script:
57
+ raise IndexError(f"ScriptedAgentGateway: no turns left (prompt={prompt!r})")
58
+ turn = self._script.pop(0)
59
+ return TurnOutcome(
60
+ signals=turn.signals,
61
+ thread_id=turn.thread_id,
62
+ cost_dollars=turn.cost_dollars,
63
+ )
64
+
65
+ async def close(self) -> None:
66
+ self.closed = True
67
+
68
+ async def set_profile(self, profile: ModelEffortProfile) -> None:
69
+ self.profiles.append(profile)
70
+
71
+ async def set_permission_mode(self, mode: PermissionMode) -> None:
72
+ self.permission_modes.append(mode)
73
+
74
+ async def set_cwd(self, path: str) -> None:
75
+ self.cwds.append(path)
76
+
77
+ async def set_session_resources(self, resources: Mapping[str, object]) -> None:
78
+ self.resource_updates.append(resources)
79
+
80
+ def resolve_tool_approval(self, request_id: str, *, allow: bool, reason: str = "") -> bool:
81
+ self.tool_resolutions.append((request_id, allow, reason))
82
+ return allow
83
+
84
+
85
+ class ScriptedCapacityProbe:
86
+ def __init__(self, script: Sequence[TurnSignals]) -> None:
87
+ self._script = list(script)
88
+ self.calls = 0
89
+
90
+ async def probe(self) -> ProbeResult:
91
+ self.calls += 1
92
+ if not self._script:
93
+ raise IndexError("ScriptedCapacityProbe: no probes left in script")
94
+ signals = self._script.pop(0)
95
+ return ProbeResult(outcome=classify(signals), snapshot=signals.plan_windows)
96
+
97
+
98
+ def load_agent_script(path: Path | str) -> AgentScript:
99
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
100
+ if not isinstance(raw, dict):
101
+ raise ValueError("agent script root must be a JSON object")
102
+ probes_raw = raw.get("probes", [{}])
103
+ turns_raw = raw.get("turns", [])
104
+ if not isinstance(probes_raw, list) or not isinstance(turns_raw, list):
105
+ raise ValueError("agent script 'probes' and 'turns' must be arrays")
106
+ if not turns_raw:
107
+ raise ValueError("agent script must include at least one turn")
108
+ probes = tuple(_parse_signals(item) for item in probes_raw)
109
+ turns = tuple(_parse_turn(item) for item in turns_raw)
110
+ return AgentScript(probes=probes, turns=turns)
111
+
112
+
113
+ def resolve_test_agent_from_env() -> tuple[ScriptedAgentGateway, ScriptedCapacityProbe] | None:
114
+ """Return scripted adapters when the test gate is fully enabled.
115
+
116
+ Raises ``RuntimeError`` if the script path is set without the allow flag.
117
+ """
118
+ allow = os.environ.get(ALLOW_TEST_AGENT_ENV, "").strip()
119
+ script_path = os.environ.get(TEST_AGENT_SCRIPT_ENV, "").strip()
120
+ if script_path and allow not in {"1", "true", "TRUE", "yes", "YES"}:
121
+ raise RuntimeError(
122
+ f"{TEST_AGENT_SCRIPT_ENV} is set but {ALLOW_TEST_AGENT_ENV}=1 is "
123
+ "required. The scripted agent is test-only and will not activate "
124
+ "without the allow flag."
125
+ )
126
+ if not script_path:
127
+ return None
128
+ if allow not in {"1", "true", "TRUE", "yes", "YES"}: # pragma: no cover — raised above
129
+ return None
130
+ script = load_agent_script(script_path)
131
+ return ScriptedAgentGateway(script.turns), ScriptedCapacityProbe(script.probes)
132
+
133
+
134
+ def _parse_turn(item: object) -> ScriptedTurn:
135
+ if not isinstance(item, dict):
136
+ raise ValueError("each turn must be a JSON object")
137
+ signals = _parse_signals(item.get("signals", {}))
138
+ thread_id = item.get("thread_id", "scripted-session")
139
+ return ScriptedTurn(
140
+ signals=signals,
141
+ thread_id=None if thread_id is None else str(thread_id),
142
+ cost_dollars=float(item.get("cost_dollars", 0.0)),
143
+ )
144
+
145
+
146
+ def _parse_signals(item: object) -> TurnSignals:
147
+ if isinstance(item, dict) and "signals" in item and set(item) <= {"signals"}:
148
+ item = item["signals"]
149
+ if not isinstance(item, dict):
150
+ raise ValueError("signals must be a JSON object")
151
+ data: dict[str, Any] = dict(item)
152
+ if "plan_windows" in data:
153
+ data["plan_windows"] = _parse_plan_windows(data["plan_windows"])
154
+ known = {f.name for f in fields(TurnSignals)}
155
+ filtered = {k: v for k, v in data.items() if k in known}
156
+ return TurnSignals(**filtered)
157
+
158
+
159
+ def _parse_plan_windows(raw: object) -> PlanWindows | None:
160
+ if raw is None:
161
+ return None
162
+ if not isinstance(raw, dict):
163
+ raise ValueError("plan_windows must be an object")
164
+ return PlanWindows(
165
+ primary=_parse_window(raw.get("primary")),
166
+ secondary=_parse_window(raw.get("secondary")),
167
+ plan_type=None if raw.get("plan_type") is None else str(raw["plan_type"]),
168
+ limit_reached=(
169
+ None
170
+ if raw.get("rate_limit_reached_type") is None
171
+ else str(raw["rate_limit_reached_type"])
172
+ ),
173
+ )
174
+
175
+
176
+ def _parse_window(raw: object) -> RateLimitWindow | None:
177
+ if raw is None:
178
+ return None
179
+ if not isinstance(raw, dict):
180
+ return None
181
+ minutes = raw.get("window_minutes")
182
+ if not isinstance(minutes, int):
183
+ return None
184
+ used = raw.get("used_percent")
185
+ return RateLimitWindow(
186
+ used_percent=float(used) if isinstance(used, int | float) else None,
187
+ window_minutes=minutes,
188
+ resets_at=None,
189
+ )
190
+
191
+
192
+ __all__ = [
193
+ "ALLOW_TEST_AGENT_ENV",
194
+ "TEST_AGENT_SCRIPT_ENV",
195
+ "AgentScript",
196
+ "ScriptedAgentGateway",
197
+ "ScriptedCapacityProbe",
198
+ "ScriptedTurn",
199
+ "load_agent_script",
200
+ "resolve_test_agent_from_env",
201
+ ]
@@ -0,0 +1,120 @@
1
+ """JSONL events + process result → TurnSignals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from datetime import datetime
7
+ from typing import assert_never
8
+
9
+ from codexloop.domain.capacity import PlanWindows
10
+ from codexloop.domain.signals import TurnSignals
11
+ from codexloop.infrastructure.agent.events import (
12
+ CodexEvent,
13
+ ErrorEvent,
14
+ ErrorPayload,
15
+ ItemCompleted,
16
+ ItemStarted,
17
+ RateLimitsUpdated,
18
+ ThreadStarted,
19
+ TurnCompleted,
20
+ TurnFailed,
21
+ TurnStarted,
22
+ UnknownEvent,
23
+ )
24
+
25
+
26
+ def to_turn_signals(
27
+ events: Iterable[CodexEvent | None],
28
+ *,
29
+ exit_code: int,
30
+ stderr_tail: str,
31
+ now: datetime,
32
+ ) -> TurnSignals:
33
+ """Fold a parsed event stream plus process result into classification signals.
34
+
35
+ ``exit_code`` and ``stderr_tail`` are always taken from the process result so a
36
+ malformed or truncated JSONL stream still yields a classifiable outcome.
37
+ """
38
+ _ = now
39
+ error_code: str | None = None
40
+ error_type: str | None = None
41
+ http_status: int | None = None
42
+ retry_after_s: float | None = None
43
+ plan_windows: PlanWindows | None = None
44
+ completed = False
45
+ failed = False
46
+ final_message: str | None = None
47
+ usage: object | None = None
48
+
49
+ for event in events:
50
+ if event is None:
51
+ continue
52
+ match event:
53
+ case TurnCompleted(usage=turn_usage):
54
+ completed = True
55
+ usage = turn_usage
56
+ case TurnFailed(error=error):
57
+ failed = True
58
+ error_code, error_type, http_status, retry_after_s = _merge_error(
59
+ error, error_code, error_type, http_status, retry_after_s
60
+ )
61
+ case ErrorEvent(error=error):
62
+ error_code, error_type, http_status, retry_after_s = _merge_error(
63
+ error, error_code, error_type, http_status, retry_after_s
64
+ )
65
+ case RateLimitsUpdated(plan_windows=windows):
66
+ if windows is not None:
67
+ plan_windows = windows
68
+ case ItemCompleted(item=item):
69
+ text = _agent_message_text(item)
70
+ if text is not None:
71
+ final_message = text
72
+ case ThreadStarted() | TurnStarted() | ItemStarted() | UnknownEvent():
73
+ continue
74
+ case _: # pragma: no cover — exhaustive CodexEvent union
75
+ assert_never(event)
76
+
77
+ return TurnSignals(
78
+ error_code=error_code,
79
+ error_type=error_type,
80
+ http_status=http_status,
81
+ retry_after_s=retry_after_s,
82
+ plan_windows=plan_windows,
83
+ completed=completed,
84
+ failed=failed,
85
+ final_message=final_message,
86
+ usage=usage,
87
+ exit_code=exit_code,
88
+ stderr_tail=stderr_tail,
89
+ )
90
+
91
+
92
+ def _merge_error(
93
+ error: ErrorPayload | None,
94
+ error_code: str | None,
95
+ error_type: str | None,
96
+ http_status: int | None,
97
+ retry_after_s: float | None,
98
+ ) -> tuple[str | None, str | None, int | None, float | None]:
99
+ if error is None:
100
+ return error_code, error_type, http_status, retry_after_s
101
+ extra = getattr(error, "retry_after_s", None)
102
+ if isinstance(extra, int | float) and not isinstance(extra, bool):
103
+ retry_after_s = float(extra)
104
+ return (
105
+ error.code if error.code is not None else error_code,
106
+ error.type if error.type is not None else error_type,
107
+ error.status if error.status is not None else http_status,
108
+ retry_after_s,
109
+ )
110
+
111
+
112
+ def _agent_message_text(item: Mapping[str, object] | None) -> str | None:
113
+ if item is None:
114
+ return None
115
+ if item.get("type") != "agent_message":
116
+ return None
117
+ text = item.get("text")
118
+ if isinstance(text, str):
119
+ return text
120
+ return None
@@ -0,0 +1,26 @@
1
+ """Generated OpenAI SDK REST CLI (M4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codexloop.infrastructure.api.binder import build_api_typer_app
6
+ from codexloop.infrastructure.api.gateway import OpenAIApiGateway, default_gateway
7
+ from codexloop.infrastructure.api.introspect import (
8
+ LOCAL_HELPER_PATHS,
9
+ SDK_VERSION,
10
+ EndpointSpec,
11
+ discover_surface,
12
+ method_by_path,
13
+ resolve_callable,
14
+ )
15
+
16
+ __all__ = [
17
+ "LOCAL_HELPER_PATHS",
18
+ "SDK_VERSION",
19
+ "EndpointSpec",
20
+ "OpenAIApiGateway",
21
+ "build_api_typer_app",
22
+ "default_gateway",
23
+ "discover_surface",
24
+ "method_by_path",
25
+ "resolve_callable",
26
+ ]