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,102 @@
|
|
|
1
|
+
"""Pure Codex CLI argv construction — list building only, no subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from codexloop.domain.approval import (
|
|
8
|
+
DEFAULT_APPROVAL,
|
|
9
|
+
DEFAULT_SANDBOX,
|
|
10
|
+
ApprovalPolicy,
|
|
11
|
+
SandboxMode,
|
|
12
|
+
)
|
|
13
|
+
from codexloop.domain.model_profile import Effort
|
|
14
|
+
|
|
15
|
+
PROBE_PROMPT = "reply with the single word OK"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _quote_c_value(value: str) -> str:
|
|
19
|
+
"""Wrap ``value`` in double quotes, escaping any internal quotes."""
|
|
20
|
+
return '"' + value.replace('"', '\\"') + '"'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _c_override(key: str, value: str) -> list[str]:
|
|
24
|
+
return ["-c", f"{key}={_quote_c_value(value)}"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ExecOpts:
|
|
29
|
+
"""Options for ``codex exec`` / ``codex exec resume`` argv construction."""
|
|
30
|
+
|
|
31
|
+
prompt: str
|
|
32
|
+
model: str | None = None
|
|
33
|
+
effort: Effort | None = None
|
|
34
|
+
approval: ApprovalPolicy = DEFAULT_APPROVAL
|
|
35
|
+
sandbox: SandboxMode = DEFAULT_SANDBOX
|
|
36
|
+
add_dirs: tuple[str, ...] = ()
|
|
37
|
+
output_schema: str | None = None
|
|
38
|
+
output_last_message: str | None = None
|
|
39
|
+
skip_git_repo_check: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _shared_flags(opts: ExecOpts) -> list[str]:
|
|
43
|
+
"""Flags common to exec and resume (after ``--json``)."""
|
|
44
|
+
argv: list[str] = []
|
|
45
|
+
if opts.model is not None:
|
|
46
|
+
argv.extend(["--model", opts.model])
|
|
47
|
+
argv.extend(_c_override("approval_policy", opts.approval.value))
|
|
48
|
+
argv.extend(_c_override("sandbox_mode", opts.sandbox.value))
|
|
49
|
+
if opts.effort is not None:
|
|
50
|
+
argv.extend(_c_override("model_reasoning_effort", opts.effort.value))
|
|
51
|
+
for directory in opts.add_dirs:
|
|
52
|
+
argv.extend(["--add-dir", directory])
|
|
53
|
+
if opts.output_schema is not None:
|
|
54
|
+
argv.extend(["--output-schema", opts.output_schema])
|
|
55
|
+
if opts.output_last_message is not None:
|
|
56
|
+
argv.extend(["--output-last-message", opts.output_last_message])
|
|
57
|
+
if opts.skip_git_repo_check:
|
|
58
|
+
argv.append("--skip-git-repo-check")
|
|
59
|
+
return argv
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_exec_argv(opts: ExecOpts) -> list[str]:
|
|
63
|
+
"""Build ``codex exec … -- <prompt>``."""
|
|
64
|
+
argv: list[str] = ["codex", "exec", "--json"]
|
|
65
|
+
argv.extend(_shared_flags(opts))
|
|
66
|
+
argv.extend(["--", opts.prompt])
|
|
67
|
+
return argv
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_resume_argv(thread_id: str | None, opts: ExecOpts) -> list[str]:
|
|
71
|
+
"""Build ``codex exec resume <id|--last> … -- <prompt>``.
|
|
72
|
+
|
|
73
|
+
Policy is always expressed via ``-c`` overrides — never a bare ``--sandbox``.
|
|
74
|
+
"""
|
|
75
|
+
argv: list[str] = ["codex", "exec", "resume"]
|
|
76
|
+
if thread_id is None:
|
|
77
|
+
argv.append("--last")
|
|
78
|
+
else:
|
|
79
|
+
argv.append(thread_id)
|
|
80
|
+
argv.append("--json")
|
|
81
|
+
argv.extend(_shared_flags(opts))
|
|
82
|
+
argv.extend(["--", opts.prompt])
|
|
83
|
+
return argv
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_probe_argv(opts: ExecOpts) -> list[str]:
|
|
87
|
+
"""Build the capacity-probe argv (ephemeral, read-only, fixed prompt).
|
|
88
|
+
|
|
89
|
+
``opts`` is accepted for API symmetry with exec/resume; probe policy and
|
|
90
|
+
prompt are fixed and do not vary with ``opts``.
|
|
91
|
+
"""
|
|
92
|
+
_ = opts
|
|
93
|
+
return [
|
|
94
|
+
"codex",
|
|
95
|
+
"exec",
|
|
96
|
+
"--json",
|
|
97
|
+
"--ephemeral",
|
|
98
|
+
*_c_override("approval_policy", ApprovalPolicy.NEVER.value),
|
|
99
|
+
*_c_override("sandbox_mode", SandboxMode.READ_ONLY.value),
|
|
100
|
+
"--",
|
|
101
|
+
PROBE_PROMPT,
|
|
102
|
+
]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Forgiving parser for ``codex exec --json`` JSONL events (R3, R4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from codexloop.domain.capacity import PlanWindows, RateLimitWindow
|
|
12
|
+
|
|
13
|
+
_ERROR_PATHS: tuple[tuple[str, ...], ...] = (
|
|
14
|
+
("error",),
|
|
15
|
+
("payload", "error"),
|
|
16
|
+
("item", "error"),
|
|
17
|
+
("turn", "error"),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Usage:
|
|
23
|
+
input_tokens: int | None = None
|
|
24
|
+
cached_input_tokens: int | None = None
|
|
25
|
+
output_tokens: int | None = None
|
|
26
|
+
reasoning_output_tokens: int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ErrorPayload:
|
|
31
|
+
code: str | None
|
|
32
|
+
type: str | None
|
|
33
|
+
message: str | None
|
|
34
|
+
status: int | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ThreadStarted:
|
|
39
|
+
thread_id: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class TurnStarted:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class TurnCompleted:
|
|
49
|
+
usage: Usage | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class TurnFailed:
|
|
54
|
+
error: ErrorPayload | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class ItemStarted:
|
|
59
|
+
item: Mapping[str, object] | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ItemCompleted:
|
|
64
|
+
item: Mapping[str, object] | None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class RateLimitsUpdated:
|
|
69
|
+
plan_windows: PlanWindows | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class ErrorEvent:
|
|
74
|
+
error: ErrorPayload | None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class UnknownEvent:
|
|
79
|
+
type: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
CodexEvent = (
|
|
83
|
+
ThreadStarted
|
|
84
|
+
| TurnStarted
|
|
85
|
+
| TurnCompleted
|
|
86
|
+
| TurnFailed
|
|
87
|
+
| ItemStarted
|
|
88
|
+
| ItemCompleted
|
|
89
|
+
| RateLimitsUpdated
|
|
90
|
+
| ErrorEvent
|
|
91
|
+
| UnknownEvent
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class JsonlParser:
|
|
96
|
+
"""Parse one JSONL line at a time. Unknown and malformed input never raises."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, *, now: datetime | None = None) -> None:
|
|
99
|
+
self._now = now
|
|
100
|
+
self.malformed_count = 0
|
|
101
|
+
|
|
102
|
+
def parse_line(self, line: str) -> CodexEvent | None:
|
|
103
|
+
stripped = line.strip()
|
|
104
|
+
if not stripped:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
decoded: object = json.loads(stripped)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
self.malformed_count += 1
|
|
110
|
+
return None
|
|
111
|
+
if not isinstance(decoded, dict):
|
|
112
|
+
self.malformed_count += 1
|
|
113
|
+
return None
|
|
114
|
+
return self._parse_obj(decoded)
|
|
115
|
+
|
|
116
|
+
def _parse_obj(self, obj: dict[str, Any]) -> CodexEvent | None:
|
|
117
|
+
event_type = obj.get("type")
|
|
118
|
+
if not isinstance(event_type, str) or not event_type:
|
|
119
|
+
return None
|
|
120
|
+
match event_type:
|
|
121
|
+
case "thread.started":
|
|
122
|
+
return ThreadStarted(thread_id=_opt_str(obj.get("thread_id")))
|
|
123
|
+
case "turn.started":
|
|
124
|
+
return TurnStarted()
|
|
125
|
+
case "turn.completed":
|
|
126
|
+
return TurnCompleted(usage=_usage(obj.get("usage")))
|
|
127
|
+
case "turn.failed":
|
|
128
|
+
return TurnFailed(error=_extract_error(obj))
|
|
129
|
+
case "item.started":
|
|
130
|
+
return ItemStarted(item=_item(obj.get("item")))
|
|
131
|
+
case "item.completed":
|
|
132
|
+
return ItemCompleted(item=_item(obj.get("item")))
|
|
133
|
+
case "rate_limits.updated":
|
|
134
|
+
return RateLimitsUpdated(plan_windows=self._plan_windows(obj))
|
|
135
|
+
case "event_msg":
|
|
136
|
+
payload = obj.get("payload")
|
|
137
|
+
if isinstance(payload, Mapping) and payload.get("type") == "token_count":
|
|
138
|
+
return RateLimitsUpdated(plan_windows=self._plan_windows(obj))
|
|
139
|
+
return UnknownEvent(type=event_type)
|
|
140
|
+
case "error":
|
|
141
|
+
return ErrorEvent(error=_extract_error(obj))
|
|
142
|
+
case _:
|
|
143
|
+
return UnknownEvent(type=event_type)
|
|
144
|
+
|
|
145
|
+
def _plan_windows(self, obj: Mapping[str, Any]) -> PlanWindows | None:
|
|
146
|
+
blob = _rate_limits_blob(obj)
|
|
147
|
+
if blob is None:
|
|
148
|
+
return None
|
|
149
|
+
if not isinstance(blob, Mapping):
|
|
150
|
+
return None
|
|
151
|
+
now = self._now if self._now is not None else datetime.now(UTC)
|
|
152
|
+
return PlanWindows(
|
|
153
|
+
primary=_window(blob.get("primary"), now=now),
|
|
154
|
+
secondary=_window(blob.get("secondary"), now=now),
|
|
155
|
+
plan_type=_opt_str(blob.get("plan_type")),
|
|
156
|
+
limit_reached=_opt_str(blob.get("rate_limit_reached_type")),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _rate_limits_blob(obj: Mapping[str, Any]) -> object:
|
|
161
|
+
if "rate_limits" in obj:
|
|
162
|
+
return obj["rate_limits"]
|
|
163
|
+
payload = obj.get("payload")
|
|
164
|
+
if isinstance(payload, Mapping) and "rate_limits" in payload:
|
|
165
|
+
return payload["rate_limits"]
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _window(value: object, *, now: datetime) -> RateLimitWindow | None:
|
|
170
|
+
if not isinstance(value, Mapping):
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
minutes = _opt_int(value.get("window_minutes"))
|
|
174
|
+
if minutes is None:
|
|
175
|
+
return None
|
|
176
|
+
return RateLimitWindow(
|
|
177
|
+
used_percent=_opt_float(value.get("used_percent")),
|
|
178
|
+
window_minutes=minutes,
|
|
179
|
+
resets_at=_resets_at(value, now=now),
|
|
180
|
+
)
|
|
181
|
+
except (TypeError, ValueError, OverflowError, OSError):
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _resets_at(window: Mapping[str, Any], *, now: datetime) -> datetime | None:
|
|
186
|
+
raw_at = window.get("resets_at")
|
|
187
|
+
if isinstance(raw_at, bool):
|
|
188
|
+
raw_at = None
|
|
189
|
+
if isinstance(raw_at, int | float):
|
|
190
|
+
try:
|
|
191
|
+
return datetime.fromtimestamp(float(raw_at), tz=UTC)
|
|
192
|
+
except (OSError, OverflowError, ValueError):
|
|
193
|
+
return None
|
|
194
|
+
raw_in = window.get("resets_in_seconds")
|
|
195
|
+
if isinstance(raw_in, bool):
|
|
196
|
+
raw_in = None
|
|
197
|
+
if isinstance(raw_in, int | float):
|
|
198
|
+
try:
|
|
199
|
+
return now + timedelta(seconds=float(raw_in))
|
|
200
|
+
except (OverflowError, ValueError):
|
|
201
|
+
return None
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _usage(value: object) -> Usage | None:
|
|
206
|
+
if not isinstance(value, Mapping):
|
|
207
|
+
return None
|
|
208
|
+
return Usage(
|
|
209
|
+
input_tokens=_opt_int(value.get("input_tokens")),
|
|
210
|
+
cached_input_tokens=_opt_int(value.get("cached_input_tokens")),
|
|
211
|
+
output_tokens=_opt_int(value.get("output_tokens")),
|
|
212
|
+
reasoning_output_tokens=_opt_int(value.get("reasoning_output_tokens")),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _item(value: object) -> dict[str, object] | None:
|
|
217
|
+
if not isinstance(value, Mapping):
|
|
218
|
+
return None
|
|
219
|
+
return dict(value)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _extract_error(obj: Mapping[str, Any]) -> ErrorPayload | None:
|
|
223
|
+
for path in _ERROR_PATHS:
|
|
224
|
+
found = _dig(obj, path)
|
|
225
|
+
if found is None:
|
|
226
|
+
continue
|
|
227
|
+
payload = _error_payload(found)
|
|
228
|
+
if payload is not None:
|
|
229
|
+
return payload
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _dig(obj: Mapping[str, Any], path: tuple[str, ...]) -> object | None:
|
|
234
|
+
current: object = obj
|
|
235
|
+
for key in path:
|
|
236
|
+
if not isinstance(current, Mapping) or key not in current:
|
|
237
|
+
return None
|
|
238
|
+
current = current[key]
|
|
239
|
+
return current
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _error_payload(value: object) -> ErrorPayload | None:
|
|
243
|
+
if isinstance(value, str):
|
|
244
|
+
return ErrorPayload(code=None, type=None, message=value, status=None)
|
|
245
|
+
if not isinstance(value, Mapping):
|
|
246
|
+
return None
|
|
247
|
+
return ErrorPayload(
|
|
248
|
+
code=_opt_str(value.get("code")),
|
|
249
|
+
type=_opt_str(value.get("type")),
|
|
250
|
+
message=_opt_str(value.get("message")),
|
|
251
|
+
status=_opt_int(value.get("status")),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _opt_str(value: object) -> str | None:
|
|
256
|
+
if isinstance(value, str):
|
|
257
|
+
return value
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _opt_int(value: object) -> int | None:
|
|
262
|
+
if isinstance(value, bool):
|
|
263
|
+
return None
|
|
264
|
+
if isinstance(value, int):
|
|
265
|
+
return value
|
|
266
|
+
if isinstance(value, float) and value.is_integer():
|
|
267
|
+
return int(value)
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _opt_float(value: object) -> float | None:
|
|
272
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
273
|
+
return None
|
|
274
|
+
return float(value)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""``codex exec --json`` adapter implementing :class:`AgentGateway`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol, assert_never
|
|
12
|
+
|
|
13
|
+
from codexloop.application.dto import TokenUsage, TurnOutcome
|
|
14
|
+
from codexloop.application.ports import PermissionMode
|
|
15
|
+
from codexloop.domain.approval import ApprovalPolicy, SandboxMode
|
|
16
|
+
from codexloop.domain.model_profile import ModelEffortProfile
|
|
17
|
+
from codexloop.infrastructure.agent.argv import ExecOpts, build_exec_argv, build_resume_argv
|
|
18
|
+
from codexloop.infrastructure.agent.events import CodexEvent, JsonlParser, ThreadStarted, Usage
|
|
19
|
+
from codexloop.infrastructure.agent.process import ProcessResult
|
|
20
|
+
from codexloop.infrastructure.agent.process import run_codex as default_run_codex
|
|
21
|
+
from codexloop.infrastructure.agent.schema import write_output_schema
|
|
22
|
+
from codexloop.infrastructure.agent.translate import to_turn_signals
|
|
23
|
+
|
|
24
|
+
_DEFAULT_TIMEOUT_S = 300.0
|
|
25
|
+
_DEFAULT_MAX_LINE_BYTES = 1_048_576
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RunCodex(Protocol):
|
|
29
|
+
async def __call__(
|
|
30
|
+
self,
|
|
31
|
+
argv: Sequence[str],
|
|
32
|
+
*,
|
|
33
|
+
cwd: str | Path,
|
|
34
|
+
env: Mapping[str, str],
|
|
35
|
+
timeout: float,
|
|
36
|
+
max_line_bytes: int,
|
|
37
|
+
) -> ProcessResult: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CodexExecGateway:
|
|
41
|
+
"""One-shot ``codex exec`` / ``codex exec resume <thread_id>`` session.
|
|
42
|
+
|
|
43
|
+
Records ``thread_id`` from the first ``thread.started`` event and resumes by
|
|
44
|
+
that id thereafter — never ``--last``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
cwd: str | Path,
|
|
51
|
+
env: Mapping[str, str] | None = None,
|
|
52
|
+
opts: ExecOpts | None = None,
|
|
53
|
+
now: datetime | None = None,
|
|
54
|
+
run_codex: RunCodex | None = None,
|
|
55
|
+
timeout: float = _DEFAULT_TIMEOUT_S,
|
|
56
|
+
max_line_bytes: int = _DEFAULT_MAX_LINE_BYTES,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._cwd = Path(cwd)
|
|
59
|
+
self._env = dict(env) if env is not None else None
|
|
60
|
+
self._opts = opts if opts is not None else ExecOpts(prompt="")
|
|
61
|
+
self._now = now
|
|
62
|
+
self._run_codex = run_codex if run_codex is not None else default_run_codex
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self._max_line_bytes = max_line_bytes
|
|
65
|
+
self._thread_id: str | None = None
|
|
66
|
+
self._closed = False
|
|
67
|
+
|
|
68
|
+
async def send_turn(self, prompt: str) -> TurnOutcome:
|
|
69
|
+
control = self._cwd / ".codexloop"
|
|
70
|
+
control.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
schema_path = write_output_schema(control / "completion.schema.json")
|
|
72
|
+
last_message_path = control / "last-message.json"
|
|
73
|
+
if last_message_path.exists():
|
|
74
|
+
last_message_path.unlink()
|
|
75
|
+
opts = replace(
|
|
76
|
+
self._opts,
|
|
77
|
+
prompt=prompt,
|
|
78
|
+
output_schema=str(schema_path),
|
|
79
|
+
output_last_message=str(last_message_path),
|
|
80
|
+
)
|
|
81
|
+
if self._thread_id is None:
|
|
82
|
+
argv = build_exec_argv(opts)
|
|
83
|
+
else:
|
|
84
|
+
argv = build_resume_argv(self._thread_id, opts)
|
|
85
|
+
env = self._env if self._env is not None else os.environ.copy()
|
|
86
|
+
result = await self._run_codex(
|
|
87
|
+
argv,
|
|
88
|
+
cwd=self._cwd,
|
|
89
|
+
env=env,
|
|
90
|
+
timeout=self._timeout,
|
|
91
|
+
max_line_bytes=self._max_line_bytes,
|
|
92
|
+
)
|
|
93
|
+
events = self._parse_lines(result.stdout_lines)
|
|
94
|
+
self._record_thread_id(events)
|
|
95
|
+
signals = to_turn_signals(
|
|
96
|
+
events,
|
|
97
|
+
exit_code=result.exit_code,
|
|
98
|
+
stderr_tail=result.stderr_tail,
|
|
99
|
+
now=self._now if self._now is not None else datetime.now(UTC),
|
|
100
|
+
)
|
|
101
|
+
structured = _read_structured(last_message_path)
|
|
102
|
+
if structured is not None:
|
|
103
|
+
signals = replace(signals, structured_output=structured)
|
|
104
|
+
return TurnOutcome(
|
|
105
|
+
signals=signals,
|
|
106
|
+
usage=_token_usage(signals.usage),
|
|
107
|
+
exit_code=result.exit_code,
|
|
108
|
+
thread_id=self._thread_id,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def close(self) -> None:
|
|
112
|
+
if self._closed:
|
|
113
|
+
return
|
|
114
|
+
self._closed = True
|
|
115
|
+
|
|
116
|
+
async def set_profile(self, profile: ModelEffortProfile) -> None:
|
|
117
|
+
self._opts = replace(self._opts, model=profile.model, effort=profile.effort)
|
|
118
|
+
|
|
119
|
+
async def set_permission_mode(self, mode: PermissionMode) -> None:
|
|
120
|
+
match mode:
|
|
121
|
+
case PermissionMode.AUTONOMOUS:
|
|
122
|
+
approval, sandbox = ApprovalPolicy.NEVER, SandboxMode.WORKSPACE_WRITE
|
|
123
|
+
case PermissionMode.READ_ONLY:
|
|
124
|
+
approval, sandbox = ApprovalPolicy.NEVER, SandboxMode.READ_ONLY
|
|
125
|
+
case PermissionMode.FULL_ACCESS:
|
|
126
|
+
approval, sandbox = ApprovalPolicy.NEVER, SandboxMode.DANGER_FULL_ACCESS
|
|
127
|
+
case _: # pragma: no cover — exhaustive StrEnum
|
|
128
|
+
assert_never(mode)
|
|
129
|
+
self._opts = replace(self._opts, approval=approval, sandbox=sandbox)
|
|
130
|
+
|
|
131
|
+
async def set_cwd(self, path: str) -> None:
|
|
132
|
+
self._cwd = Path(path)
|
|
133
|
+
|
|
134
|
+
async def set_session_resources(self, resources: Mapping[str, object]) -> None:
|
|
135
|
+
updates: dict[str, object] = {}
|
|
136
|
+
add_dirs = resources.get("add_dirs")
|
|
137
|
+
if isinstance(add_dirs, list | tuple) and all(isinstance(item, str) for item in add_dirs):
|
|
138
|
+
updates["add_dirs"] = tuple(add_dirs)
|
|
139
|
+
approval = resources.get("approval_policy")
|
|
140
|
+
if isinstance(approval, str):
|
|
141
|
+
updates["approval"] = ApprovalPolicy(approval)
|
|
142
|
+
sandbox = resources.get("sandbox_mode")
|
|
143
|
+
if isinstance(sandbox, str):
|
|
144
|
+
updates["sandbox"] = SandboxMode(sandbox)
|
|
145
|
+
if updates:
|
|
146
|
+
self._opts = replace(self._opts, **updates) # type: ignore[arg-type]
|
|
147
|
+
|
|
148
|
+
def resolve_tool_approval(self, request_id: str, *, allow: bool, reason: str = "") -> bool:
|
|
149
|
+
_ = request_id, reason
|
|
150
|
+
return allow
|
|
151
|
+
|
|
152
|
+
def _parse_lines(self, lines: Sequence[str]) -> list[CodexEvent | None]:
|
|
153
|
+
parser = JsonlParser(now=self._now if self._now is not None else datetime.now(UTC))
|
|
154
|
+
return [parser.parse_line(line) for line in lines]
|
|
155
|
+
|
|
156
|
+
def _record_thread_id(self, events: Sequence[CodexEvent | None]) -> None:
|
|
157
|
+
if self._thread_id is not None:
|
|
158
|
+
return
|
|
159
|
+
for event in events:
|
|
160
|
+
if isinstance(event, ThreadStarted) and event.thread_id:
|
|
161
|
+
self._thread_id = event.thread_id
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _read_structured(path: Path) -> object | None:
|
|
166
|
+
if not path.is_file():
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
170
|
+
except OSError:
|
|
171
|
+
return None
|
|
172
|
+
if not text:
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
parsed: object = json.loads(text)
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
return text
|
|
178
|
+
return parsed
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _token_usage(usage: object | None) -> TokenUsage | None:
|
|
182
|
+
if not isinstance(usage, Usage):
|
|
183
|
+
return None
|
|
184
|
+
return TokenUsage(
|
|
185
|
+
input_tokens=usage.input_tokens or 0,
|
|
186
|
+
cached_input_tokens=usage.cached_input_tokens or 0,
|
|
187
|
+
output_tokens=usage.output_tokens or 0,
|
|
188
|
+
reasoning_output_tokens=usage.reasoning_output_tokens or 0,
|
|
189
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Ephemeral read-only ``codex exec`` capacity probe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from codexloop.application.dto import ProbeResult
|
|
11
|
+
from codexloop.domain.capacity import TransientBackendError
|
|
12
|
+
from codexloop.domain.classify import classify
|
|
13
|
+
from codexloop.domain.errors import CodexBinaryError
|
|
14
|
+
from codexloop.infrastructure.agent.argv import ExecOpts, build_probe_argv
|
|
15
|
+
from codexloop.infrastructure.agent.events import CodexEvent, JsonlParser
|
|
16
|
+
from codexloop.infrastructure.agent.gateway import RunCodex
|
|
17
|
+
from codexloop.infrastructure.agent.process import run_codex as default_run_codex
|
|
18
|
+
from codexloop.infrastructure.agent.translate import to_turn_signals
|
|
19
|
+
|
|
20
|
+
_DEFAULT_TIMEOUT_S = 300.0
|
|
21
|
+
_DEFAULT_MAX_LINE_BYTES = 1_048_576
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExecCapacityProbe:
|
|
25
|
+
"""Capacity probe that never resumes and never writes a thread.
|
|
26
|
+
|
|
27
|
+
Always uses :func:`build_probe_argv` (``--ephemeral``, read-only sandbox).
|
|
28
|
+
Spawn failures become :class:`TransientBackendError` rather than raising.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
cwd: str | Path,
|
|
35
|
+
env: Mapping[str, str] | None = None,
|
|
36
|
+
opts: ExecOpts | None = None,
|
|
37
|
+
now: datetime | None = None,
|
|
38
|
+
run_codex: RunCodex | None = None,
|
|
39
|
+
timeout: float = _DEFAULT_TIMEOUT_S,
|
|
40
|
+
max_line_bytes: int = _DEFAULT_MAX_LINE_BYTES,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._cwd = Path(cwd)
|
|
43
|
+
self._env = dict(env) if env is not None else None
|
|
44
|
+
self._opts = opts if opts is not None else ExecOpts(prompt="")
|
|
45
|
+
self._now = now
|
|
46
|
+
self._run_codex = run_codex if run_codex is not None else default_run_codex
|
|
47
|
+
self._timeout = timeout
|
|
48
|
+
self._max_line_bytes = max_line_bytes
|
|
49
|
+
|
|
50
|
+
async def probe(self) -> ProbeResult:
|
|
51
|
+
argv = build_probe_argv(self._opts)
|
|
52
|
+
env = self._env if self._env is not None else os.environ.copy()
|
|
53
|
+
try:
|
|
54
|
+
result = await self._run_codex(
|
|
55
|
+
argv,
|
|
56
|
+
cwd=self._cwd,
|
|
57
|
+
env=env,
|
|
58
|
+
timeout=self._timeout,
|
|
59
|
+
max_line_bytes=self._max_line_bytes,
|
|
60
|
+
)
|
|
61
|
+
except (OSError, CodexBinaryError):
|
|
62
|
+
return ProbeResult(outcome=TransientBackendError())
|
|
63
|
+
events = self._parse_lines(result.stdout_lines)
|
|
64
|
+
signals = to_turn_signals(
|
|
65
|
+
events,
|
|
66
|
+
exit_code=result.exit_code,
|
|
67
|
+
stderr_tail=result.stderr_tail,
|
|
68
|
+
now=self._now if self._now is not None else datetime.now(UTC),
|
|
69
|
+
)
|
|
70
|
+
return ProbeResult(outcome=classify(signals), snapshot=signals.plan_windows)
|
|
71
|
+
|
|
72
|
+
def _parse_lines(self, lines: Sequence[str]) -> list[CodexEvent | None]:
|
|
73
|
+
parser = JsonlParser(now=self._now if self._now is not None else datetime.now(UTC))
|
|
74
|
+
return [parser.parse_line(line) for line in lines]
|