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.
- codexloop/__init__.py +3 -0
- codexloop/application/__init__.py +1 -0
- codexloop/application/dto.py +41 -0
- codexloop/application/ports.py +158 -0
- codexloop/application/runner.py +467 -0
- codexloop/application/usecases/__init__.py +1 -0
- codexloop/application/usecases/doctor.py +37 -0
- codexloop/application/usecases/list_threads.py +14 -0
- codexloop/application/usecases/preflight.py +10 -0
- codexloop/application/usecases/resume_thread.py +11 -0
- codexloop/application/usecases/run_control.py +20 -0
- codexloop/application/usecases/run_plan.py +11 -0
- codexloop/bootstrap.py +461 -0
- codexloop/cli/__init__.py +1 -0
- codexloop/cli/app.py +94 -0
- codexloop/cli/asyncio.py +80 -0
- codexloop/cli/commands/__init__.py +1 -0
- codexloop/cli/commands/approval_cmd.py +24 -0
- codexloop/cli/commands/capacity.py +33 -0
- codexloop/cli/commands/cwd_cmd.py +22 -0
- codexloop/cli/commands/doctor.py +27 -0
- codexloop/cli/commands/effort_cmd.py +24 -0
- codexloop/cli/commands/logs.py +15 -0
- codexloop/cli/commands/model_cmd.py +22 -0
- codexloop/cli/commands/prompt.py +32 -0
- codexloop/cli/commands/reset.py +25 -0
- codexloop/cli/commands/resume.py +38 -0
- codexloop/cli/commands/run.py +50 -0
- codexloop/cli/commands/runs.py +13 -0
- codexloop/cli/commands/sandbox_cmd.py +24 -0
- codexloop/cli/commands/savepoints.py +25 -0
- codexloop/cli/commands/snapshot.py +26 -0
- codexloop/cli/commands/status.py +15 -0
- codexloop/cli/commands/stop.py +21 -0
- codexloop/cli/commands/threads.py +15 -0
- codexloop/cli/commands/unwind.py +24 -0
- codexloop/cli/commands/watch.py +66 -0
- codexloop/cli/render.py +39 -0
- codexloop/domain/__init__.py +26 -0
- codexloop/domain/approval.py +38 -0
- codexloop/domain/backoff.py +34 -0
- codexloop/domain/budget.py +56 -0
- codexloop/domain/capacity.py +81 -0
- codexloop/domain/classify.py +98 -0
- codexloop/domain/completion.py +130 -0
- codexloop/domain/control.py +167 -0
- codexloop/domain/error_codes.py +75 -0
- codexloop/domain/errors.py +35 -0
- codexloop/domain/loop.py +190 -0
- codexloop/domain/model_profile.py +30 -0
- codexloop/domain/plan.py +38 -0
- codexloop/domain/savepoint.py +32 -0
- codexloop/domain/savepoint_message.py +56 -0
- codexloop/domain/session.py +32 -0
- codexloop/domain/signals.py +25 -0
- codexloop/domain/waiting.py +120 -0
- codexloop/infrastructure/__init__.py +0 -0
- codexloop/infrastructure/agent/__init__.py +0 -0
- codexloop/infrastructure/agent/argv.py +102 -0
- codexloop/infrastructure/agent/events.py +274 -0
- codexloop/infrastructure/agent/gateway.py +189 -0
- codexloop/infrastructure/agent/probe.py +74 -0
- codexloop/infrastructure/agent/process.py +208 -0
- codexloop/infrastructure/agent/schema.py +31 -0
- codexloop/infrastructure/agent/scripted.py +201 -0
- codexloop/infrastructure/agent/translate.py +120 -0
- codexloop/infrastructure/api/__init__.py +26 -0
- codexloop/infrastructure/api/api_baseline.json +340 -0
- codexloop/infrastructure/api/binder.py +170 -0
- codexloop/infrastructure/api/gateway.py +142 -0
- codexloop/infrastructure/api/introspect.py +248 -0
- codexloop/infrastructure/api/json_io.py +26 -0
- codexloop/infrastructure/api/params.py +162 -0
- codexloop/infrastructure/api/providers.py +70 -0
- codexloop/infrastructure/api/registry.py +13 -0
- codexloop/infrastructure/appserver/__init__.py +6 -0
- codexloop/infrastructure/appserver/client.py +245 -0
- codexloop/infrastructure/appserver/gateway.py +437 -0
- codexloop/infrastructure/appserver/ratelimits.py +100 -0
- codexloop/infrastructure/audit.py +26 -0
- codexloop/infrastructure/capacity_probe.py +57 -0
- codexloop/infrastructure/clock.py +26 -0
- codexloop/infrastructure/config.py +150 -0
- codexloop/infrastructure/control.py +89 -0
- codexloop/infrastructure/doctor_env.py +239 -0
- codexloop/infrastructure/events.py +23 -0
- codexloop/infrastructure/git_savepoints.py +176 -0
- codexloop/infrastructure/lock.py +88 -0
- codexloop/infrastructure/logging.py +124 -0
- codexloop/infrastructure/notify.py +27 -0
- codexloop/infrastructure/progress.py +14 -0
- codexloop/infrastructure/redact.py +52 -0
- codexloop/infrastructure/rollout.py +113 -0
- codexloop/infrastructure/rundir.py +57 -0
- codexloop/infrastructure/snapshot.py +39 -0
- codexloop/infrastructure/state.py +32 -0
- codexloop/infrastructure/state_bus.py +27 -0
- codexloop/infrastructure/stream_ui.py +44 -0
- codexloop/py.typed +0 -0
- codexloop-0.1.0.dist-info/METADATA +104 -0
- codexloop-0.1.0.dist-info/RECORD +104 -0
- codexloop-0.1.0.dist-info/WHEEL +4 -0
- codexloop-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|