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
codexloop/cli/render.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Human-readable rendering of CLI results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
from codexloop.application.dto import RunResult
|
|
9
|
+
from codexloop.domain.session import ThreadRef
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_result(result: RunResult) -> str:
|
|
13
|
+
status = "done" if result.success else result.reason
|
|
14
|
+
thread = result.thread_id or "-"
|
|
15
|
+
return f"{status} turns={result.turns} thread={thread}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_threads(threads: Sequence[ThreadRef]) -> str:
|
|
19
|
+
if not threads:
|
|
20
|
+
return "No threads recorded."
|
|
21
|
+
return "\n".join(
|
|
22
|
+
f"{ref.thread_id}\t{ref.model}\t{ref.cwd}\t{ref.started_at.isoformat()}" for ref in threads
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_runs(records: Sequence[Mapping[str, object]]) -> str:
|
|
27
|
+
if not records:
|
|
28
|
+
return "No runs."
|
|
29
|
+
return "\n".join(str(record.get("run_id", "-")) for record in records)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_status(record: Mapping[str, object] | None) -> str:
|
|
33
|
+
if record is None:
|
|
34
|
+
return "No runs."
|
|
35
|
+
return json.dumps(dict(record), indent=2, default=str)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_logs(text: str) -> str:
|
|
39
|
+
return text if text else "No logs."
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Pure domain layer — stdlib only."""
|
|
2
|
+
|
|
3
|
+
from codexloop.domain.error_codes import ErrorClass, classify_code
|
|
4
|
+
from codexloop.domain.errors import (
|
|
5
|
+
AuthError,
|
|
6
|
+
BudgetExceeded,
|
|
7
|
+
CapacityError,
|
|
8
|
+
CodexBinaryError,
|
|
9
|
+
CodexloopError,
|
|
10
|
+
CodexProtocolError,
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
WaitDeadlineExceeded,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AuthError",
|
|
17
|
+
"BudgetExceeded",
|
|
18
|
+
"CapacityError",
|
|
19
|
+
"CodexBinaryError",
|
|
20
|
+
"CodexloopError",
|
|
21
|
+
"CodexProtocolError",
|
|
22
|
+
"ConfigurationError",
|
|
23
|
+
"ErrorClass",
|
|
24
|
+
"WaitDeadlineExceeded",
|
|
25
|
+
"classify_code",
|
|
26
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Approval policy × sandbox mode. Defaults never wait and stay in-workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from codexloop.domain.errors import ConfigurationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApprovalPolicy(StrEnum):
|
|
11
|
+
NEVER = "never"
|
|
12
|
+
ON_REQUEST = "on-request"
|
|
13
|
+
ON_FAILURE = "on-failure"
|
|
14
|
+
UNTRUSTED = "untrusted"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SandboxMode(StrEnum):
|
|
18
|
+
READ_ONLY = "read-only"
|
|
19
|
+
WORKSPACE_WRITE = "workspace-write"
|
|
20
|
+
DANGER_FULL_ACCESS = "danger-full-access"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_APPROVAL = ApprovalPolicy.NEVER
|
|
24
|
+
DEFAULT_SANDBOX = SandboxMode.WORKSPACE_WRITE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate(
|
|
28
|
+
policy: ApprovalPolicy,
|
|
29
|
+
sandbox: SandboxMode,
|
|
30
|
+
*,
|
|
31
|
+
allow_dangerous: bool = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Raise unless ``danger-full-access`` is paired with ``allow_dangerous=True``."""
|
|
34
|
+
if sandbox is SandboxMode.DANGER_FULL_ACCESS and not allow_dangerous:
|
|
35
|
+
raise ConfigurationError(
|
|
36
|
+
f"sandbox mode {sandbox.value} requires allow_dangerous=True"
|
|
37
|
+
f" (approval_policy={policy.value})"
|
|
38
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Pure exponential backoff with injected jitter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def backoff(
|
|
11
|
+
attempt: int,
|
|
12
|
+
*,
|
|
13
|
+
base: timedelta,
|
|
14
|
+
ceiling: timedelta,
|
|
15
|
+
jitter_ratio: float,
|
|
16
|
+
rand: Callable[[], float],
|
|
17
|
+
) -> timedelta:
|
|
18
|
+
"""Return ``min(ceiling, base * 2^attempt)`` scaled by jitter from ``rand``.
|
|
19
|
+
|
|
20
|
+
``rand`` must return a float in ``[0, 1]``. Jitter maps that onto
|
|
21
|
+
``[1 - jitter_ratio, 1 + jitter_ratio]``. The result is never negative and
|
|
22
|
+
never exceeds ``ceiling``.
|
|
23
|
+
"""
|
|
24
|
+
ceiling_s = max(ceiling.total_seconds(), 0.0)
|
|
25
|
+
base_s = max(base.total_seconds(), 0.0)
|
|
26
|
+
try:
|
|
27
|
+
exponential = math.ldexp(base_s, attempt)
|
|
28
|
+
except (OverflowError, ValueError):
|
|
29
|
+
exponential = math.inf
|
|
30
|
+
delay_s = min(ceiling_s, exponential)
|
|
31
|
+
factor = (1.0 - jitter_ratio) + (2.0 * jitter_ratio * rand())
|
|
32
|
+
jittered = delay_s * factor
|
|
33
|
+
clamped = min(max(jittered, 0.0), ceiling_s)
|
|
34
|
+
return timedelta(seconds=clamped)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Turn / dollar / wall-clock budgets and a monotonic ledger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from codexloop.domain.errors import ConfigurationError
|
|
9
|
+
|
|
10
|
+
_ZERO = timedelta(0)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Budget:
|
|
15
|
+
max_turns: int | None
|
|
16
|
+
max_dollars: float | None
|
|
17
|
+
max_wall_clock: timedelta | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BudgetLedger:
|
|
21
|
+
"""Accumulates usage against a :class:`Budget`. Counters never decrease."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, budget: Budget) -> None:
|
|
24
|
+
self._budget = budget
|
|
25
|
+
self._turns = 0
|
|
26
|
+
self._dollars = 0.0
|
|
27
|
+
self._elapsed = _ZERO
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def turns(self) -> int:
|
|
31
|
+
return self._turns
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def dollars(self) -> float:
|
|
35
|
+
return self._dollars
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def elapsed(self) -> timedelta:
|
|
39
|
+
return self._elapsed
|
|
40
|
+
|
|
41
|
+
def record(self, turns: int = 0, dollars: float = 0.0, elapsed: timedelta = _ZERO) -> None:
|
|
42
|
+
if turns < 0 or dollars < 0 or elapsed < _ZERO:
|
|
43
|
+
raise ConfigurationError("budget ledger cannot decrease")
|
|
44
|
+
self._turns += turns
|
|
45
|
+
self._dollars += dollars
|
|
46
|
+
self._elapsed += elapsed
|
|
47
|
+
|
|
48
|
+
def exceeded(self) -> str | None:
|
|
49
|
+
budget = self._budget
|
|
50
|
+
if budget.max_turns is not None and self._turns >= budget.max_turns:
|
|
51
|
+
return "turns"
|
|
52
|
+
if budget.max_dollars is not None and self._dollars >= budget.max_dollars:
|
|
53
|
+
return "dollars"
|
|
54
|
+
if budget.max_wall_clock is not None and self._elapsed >= budget.max_wall_clock:
|
|
55
|
+
return "wall_clock"
|
|
56
|
+
return None
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Capacity value objects and waitability for plan windows / rate limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import assert_never
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class RateLimitWindow:
|
|
12
|
+
used_percent: float | None
|
|
13
|
+
window_minutes: int
|
|
14
|
+
resets_at: datetime | None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def remaining_percent(self) -> float | None:
|
|
18
|
+
if self.used_percent is None:
|
|
19
|
+
return None
|
|
20
|
+
return max(0.0, min(100.0, 100.0 - self.used_percent))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class PlanWindows:
|
|
25
|
+
primary: RateLimitWindow | None
|
|
26
|
+
secondary: RateLimitWindow | None
|
|
27
|
+
plan_type: str | None
|
|
28
|
+
limit_reached: str | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Available:
|
|
33
|
+
plan_windows: PlanWindows | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ThrottleExhausted:
|
|
38
|
+
retry_after: timedelta | None = None
|
|
39
|
+
aggressive: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class WindowExhausted:
|
|
44
|
+
resets_at: datetime | None = None
|
|
45
|
+
window: str = "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class QuotaExhausted:
|
|
50
|
+
reason: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class AuthFailed:
|
|
55
|
+
reason: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class TransientBackendError:
|
|
60
|
+
retry_after: timedelta | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
CapacityState = (
|
|
64
|
+
Available
|
|
65
|
+
| ThrottleExhausted
|
|
66
|
+
| WindowExhausted
|
|
67
|
+
| QuotaExhausted
|
|
68
|
+
| AuthFailed
|
|
69
|
+
| TransientBackendError
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_waitable(state: CapacityState) -> bool:
|
|
74
|
+
"""Return False only for billing walls and auth failures."""
|
|
75
|
+
match state:
|
|
76
|
+
case QuotaExhausted() | AuthFailed():
|
|
77
|
+
return False
|
|
78
|
+
case Available() | ThrottleExhausted() | WindowExhausted() | TransientBackendError():
|
|
79
|
+
return True
|
|
80
|
+
case _: # pragma: no cover — match is exhaustive over CapacityState
|
|
81
|
+
assert_never(state)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Classify TurnSignals into a CapacityState (body first, status second)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from codexloop.domain.capacity import (
|
|
8
|
+
AuthFailed,
|
|
9
|
+
Available,
|
|
10
|
+
CapacityState,
|
|
11
|
+
PlanWindows,
|
|
12
|
+
QuotaExhausted,
|
|
13
|
+
ThrottleExhausted,
|
|
14
|
+
TransientBackendError,
|
|
15
|
+
WindowExhausted,
|
|
16
|
+
)
|
|
17
|
+
from codexloop.domain.error_codes import ErrorClass, classify_code
|
|
18
|
+
from codexloop.domain.signals import TurnSignals
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def classify(signals: TurnSignals) -> CapacityState:
|
|
22
|
+
"""Map turn signals to a capacity state using the documented ladder.
|
|
23
|
+
|
|
24
|
+
Completion claims, Retry-After headers, and high ``used_percent`` never
|
|
25
|
+
outrank a body-level billing or auth marker. HTTP 429 is consulted only
|
|
26
|
+
after ``error.code`` / ``error.type``.
|
|
27
|
+
"""
|
|
28
|
+
code_cls = classify_code(signals.error_code, None)
|
|
29
|
+
type_cls = classify_code(None, signals.error_type)
|
|
30
|
+
status = signals.http_status
|
|
31
|
+
|
|
32
|
+
if _has(ErrorClass.AUTH, code_cls, type_cls) or status == 401:
|
|
33
|
+
return AuthFailed(reason=_reason(signals, ErrorClass.AUTH, "unauthorized"))
|
|
34
|
+
|
|
35
|
+
if _has(ErrorClass.QUOTA, code_cls, type_cls):
|
|
36
|
+
return QuotaExhausted(reason=_reason(signals, ErrorClass.QUOTA, "quota"))
|
|
37
|
+
|
|
38
|
+
if _has(ErrorClass.WINDOW, code_cls, type_cls):
|
|
39
|
+
resets_at, window = _window_from_plan(signals.plan_windows)
|
|
40
|
+
return WindowExhausted(resets_at=resets_at, window=window)
|
|
41
|
+
|
|
42
|
+
if _has(ErrorClass.THROTTLE, code_cls, type_cls):
|
|
43
|
+
return ThrottleExhausted(
|
|
44
|
+
retry_after=_retry_after(signals.retry_after_s),
|
|
45
|
+
aggressive=_is_slow_down(signals),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if _has(ErrorClass.TRANSIENT, code_cls, type_cls):
|
|
49
|
+
return TransientBackendError(retry_after=_retry_after(signals.retry_after_s))
|
|
50
|
+
|
|
51
|
+
if _has(ErrorClass.FATAL, code_cls, type_cls):
|
|
52
|
+
return Available(plan_windows=signals.plan_windows)
|
|
53
|
+
|
|
54
|
+
if status is not None and 500 <= status <= 599:
|
|
55
|
+
return TransientBackendError(retry_after=_retry_after(signals.retry_after_s))
|
|
56
|
+
|
|
57
|
+
if status == 429:
|
|
58
|
+
return WindowExhausted(resets_at=None, window="unknown")
|
|
59
|
+
|
|
60
|
+
return Available(plan_windows=signals.plan_windows)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _has(target: ErrorClass, code_cls: ErrorClass, type_cls: ErrorClass) -> bool:
|
|
64
|
+
return target is code_cls or target is type_cls
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _reason(signals: TurnSignals, wanted: ErrorClass, fallback: str) -> str:
|
|
68
|
+
if classify_code(signals.error_code, None) is wanted and signals.error_code is not None:
|
|
69
|
+
return signals.error_code
|
|
70
|
+
if classify_code(None, signals.error_type) is wanted and signals.error_type is not None:
|
|
71
|
+
return signals.error_type
|
|
72
|
+
if signals.error_code is not None:
|
|
73
|
+
return signals.error_code
|
|
74
|
+
if signals.error_type is not None:
|
|
75
|
+
return signals.error_type
|
|
76
|
+
return fallback
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _retry_after(seconds: float | None) -> timedelta | None:
|
|
80
|
+
if seconds is None:
|
|
81
|
+
return None
|
|
82
|
+
return timedelta(seconds=seconds)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_slow_down(signals: TurnSignals) -> bool:
|
|
86
|
+
return signals.error_code == "slow_down" or signals.error_type == "slow_down"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _window_from_plan(plan: PlanWindows | None) -> tuple[datetime | None, str]:
|
|
90
|
+
if plan is None:
|
|
91
|
+
return None, "unknown"
|
|
92
|
+
primary = plan.primary
|
|
93
|
+
if primary is not None and primary.resets_at is not None:
|
|
94
|
+
return primary.resets_at, "five_hour"
|
|
95
|
+
secondary = plan.secondary
|
|
96
|
+
if secondary is not None and secondary.resets_at is not None:
|
|
97
|
+
return secondary.resets_at, "weekly"
|
|
98
|
+
return None, "unknown"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Three-layer completion evaluation: structured output → marker → continue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import assert_never
|
|
9
|
+
|
|
10
|
+
from codexloop.domain.capacity import (
|
|
11
|
+
AuthFailed,
|
|
12
|
+
Available,
|
|
13
|
+
CapacityState,
|
|
14
|
+
QuotaExhausted,
|
|
15
|
+
ThrottleExhausted,
|
|
16
|
+
TransientBackendError,
|
|
17
|
+
WindowExhausted,
|
|
18
|
+
)
|
|
19
|
+
from codexloop.domain.signals import TurnSignals
|
|
20
|
+
|
|
21
|
+
DEFAULT_DONE_MARKER = "CODEXLOOP_TASK_FULLY_COMPLETE"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Done:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Continue:
|
|
31
|
+
remaining: list[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Blocked:
|
|
36
|
+
reason: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
CompletionVerdict = Done | Continue | Blocked
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CompletionEvaluator:
|
|
43
|
+
"""Map turn signals + capacity into a completion verdict.
|
|
44
|
+
|
|
45
|
+
Capacity rejection always outranks a completion claim. Within an
|
|
46
|
+
``Available`` turn the layers are: structured output, then a done-marker
|
|
47
|
+
line in the final message, then ``Continue``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, done_marker: str = DEFAULT_DONE_MARKER) -> None:
|
|
51
|
+
self._done_marker = done_marker
|
|
52
|
+
|
|
53
|
+
def evaluate(self, signals: TurnSignals, capacity: CapacityState) -> CompletionVerdict:
|
|
54
|
+
parsed = _parse_structured(signals.structured_output)
|
|
55
|
+
|
|
56
|
+
if not _is_available(capacity):
|
|
57
|
+
return Continue(remaining=_remaining_from(parsed))
|
|
58
|
+
|
|
59
|
+
if parsed is not None:
|
|
60
|
+
structured = _verdict_from_structured(parsed)
|
|
61
|
+
if structured is not None:
|
|
62
|
+
return structured
|
|
63
|
+
|
|
64
|
+
if _marker_on_own_line(signals.final_message, self._done_marker):
|
|
65
|
+
return Done()
|
|
66
|
+
|
|
67
|
+
return Continue(remaining=[])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_available(capacity: CapacityState) -> bool:
|
|
71
|
+
match capacity:
|
|
72
|
+
case Available():
|
|
73
|
+
return True
|
|
74
|
+
case (
|
|
75
|
+
ThrottleExhausted()
|
|
76
|
+
| WindowExhausted()
|
|
77
|
+
| QuotaExhausted()
|
|
78
|
+
| AuthFailed()
|
|
79
|
+
| TransientBackendError()
|
|
80
|
+
):
|
|
81
|
+
return False
|
|
82
|
+
case _: # pragma: no cover — match is exhaustive over CapacityState
|
|
83
|
+
assert_never(capacity)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_structured(value: object | None) -> Mapping[str, object] | None:
|
|
87
|
+
if value is None:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(value, Mapping):
|
|
90
|
+
return value
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
try:
|
|
93
|
+
loaded = json.loads(value)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
return None
|
|
96
|
+
if isinstance(loaded, Mapping):
|
|
97
|
+
return loaded
|
|
98
|
+
return None
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _verdict_from_structured(parsed: Mapping[str, object]) -> CompletionVerdict | None:
|
|
103
|
+
blocked_on = parsed.get("blocked_on")
|
|
104
|
+
if blocked_on:
|
|
105
|
+
return Blocked(reason=str(blocked_on))
|
|
106
|
+
|
|
107
|
+
remaining = _remaining_from(parsed)
|
|
108
|
+
complete = parsed.get("complete")
|
|
109
|
+
if complete is True:
|
|
110
|
+
if not remaining:
|
|
111
|
+
return Done()
|
|
112
|
+
return Continue(remaining=remaining)
|
|
113
|
+
if complete is False:
|
|
114
|
+
return Continue(remaining=remaining)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _remaining_from(parsed: Mapping[str, object] | None) -> list[str]:
|
|
119
|
+
if parsed is None:
|
|
120
|
+
return []
|
|
121
|
+
raw = parsed.get("remaining_work")
|
|
122
|
+
if not isinstance(raw, list):
|
|
123
|
+
return []
|
|
124
|
+
return [str(item) for item in raw]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _marker_on_own_line(message: str | None, done_marker: str) -> bool:
|
|
128
|
+
if message is None:
|
|
129
|
+
return False
|
|
130
|
+
return any(line.strip() == done_marker for line in message.splitlines())
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Operator inbox commands: parse a JSON dict, never ignore unknown kinds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from codexloop.domain.approval import ApprovalPolicy, SandboxMode
|
|
11
|
+
from codexloop.domain.errors import ConfigurationError
|
|
12
|
+
from codexloop.domain.model_profile import Effort
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PromptTiming(StrEnum):
|
|
16
|
+
NOW = "now"
|
|
17
|
+
NEXT_TURN = "next_turn"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Stop:
|
|
22
|
+
def to_dict(self) -> dict[str, object]:
|
|
23
|
+
return {"kind": "stop"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Prompt:
|
|
28
|
+
text: str
|
|
29
|
+
timing: PromptTiming
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, object]:
|
|
32
|
+
return {"kind": "prompt", "text": self.text, "timing": self.timing.value}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class SetModel:
|
|
37
|
+
model: str
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, object]:
|
|
40
|
+
return {"kind": "set_model", "model": self.model}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class SetEffort:
|
|
45
|
+
effort: Effort
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, object]:
|
|
48
|
+
return {"kind": "set_effort", "effort": self.effort.value}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class SetApproval:
|
|
53
|
+
policy: ApprovalPolicy
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, object]:
|
|
56
|
+
return {"kind": "set_approval", "policy": self.policy.value}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class SetSandbox:
|
|
61
|
+
sandbox: SandboxMode
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, object]:
|
|
64
|
+
return {"kind": "set_sandbox", "sandbox": self.sandbox.value}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class SetCwd:
|
|
69
|
+
cwd: str
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, object]:
|
|
72
|
+
return {"kind": "set_cwd", "cwd": self.cwd}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class Snapshot:
|
|
77
|
+
def to_dict(self) -> dict[str, object]:
|
|
78
|
+
return {"kind": "snapshot"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class ResourceMutate:
|
|
83
|
+
payload: dict[str, object]
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
object.__setattr__(self, "payload", dict(self.payload))
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, object]:
|
|
89
|
+
return {"kind": "resource_mutate", "payload": dict(self.payload)}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
ControlCommand = (
|
|
93
|
+
Stop
|
|
94
|
+
| Prompt
|
|
95
|
+
| SetModel
|
|
96
|
+
| SetEffort
|
|
97
|
+
| SetApproval
|
|
98
|
+
| SetSandbox
|
|
99
|
+
| SetCwd
|
|
100
|
+
| Snapshot
|
|
101
|
+
| ResourceMutate
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_control(data: Mapping[str, object]) -> ControlCommand:
|
|
106
|
+
kind = data.get("kind")
|
|
107
|
+
if not isinstance(kind, str):
|
|
108
|
+
raise ConfigurationError("control command missing kind")
|
|
109
|
+
builder = _BUILDERS.get(kind)
|
|
110
|
+
if builder is None:
|
|
111
|
+
raise ConfigurationError(f"unknown control command kind: {kind!r}")
|
|
112
|
+
try:
|
|
113
|
+
return builder(data)
|
|
114
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
115
|
+
raise ConfigurationError(f"invalid control command {kind!r}: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_stop(_data: Mapping[str, object]) -> Stop:
|
|
119
|
+
return Stop()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_prompt(data: Mapping[str, object]) -> Prompt:
|
|
123
|
+
return Prompt(text=str(data["text"]), timing=PromptTiming(str(data["timing"])))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_set_model(data: Mapping[str, object]) -> SetModel:
|
|
127
|
+
return SetModel(model=str(data["model"]))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_set_effort(data: Mapping[str, object]) -> SetEffort:
|
|
131
|
+
return SetEffort(effort=Effort(str(data["effort"])))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_set_approval(data: Mapping[str, object]) -> SetApproval:
|
|
135
|
+
return SetApproval(policy=ApprovalPolicy(str(data["policy"])))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_set_sandbox(data: Mapping[str, object]) -> SetSandbox:
|
|
139
|
+
return SetSandbox(sandbox=SandboxMode(str(data["sandbox"])))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_set_cwd(data: Mapping[str, object]) -> SetCwd:
|
|
143
|
+
return SetCwd(cwd=str(data["cwd"]))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_snapshot(_data: Mapping[str, object]) -> Snapshot:
|
|
147
|
+
return Snapshot()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_resource_mutate(data: Mapping[str, object]) -> ResourceMutate:
|
|
151
|
+
payload = data["payload"]
|
|
152
|
+
if not isinstance(payload, Mapping):
|
|
153
|
+
raise TypeError("resource_mutate payload must be a mapping")
|
|
154
|
+
return ResourceMutate(payload=dict(cast(Mapping[str, object], payload)))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_BUILDERS: dict[str, Callable[[Mapping[str, object]], ControlCommand]] = {
|
|
158
|
+
"stop": _parse_stop,
|
|
159
|
+
"prompt": _parse_prompt,
|
|
160
|
+
"set_model": _parse_set_model,
|
|
161
|
+
"set_effort": _parse_set_effort,
|
|
162
|
+
"set_approval": _parse_set_approval,
|
|
163
|
+
"set_sandbox": _parse_set_sandbox,
|
|
164
|
+
"set_cwd": _parse_set_cwd,
|
|
165
|
+
"snapshot": _parse_snapshot,
|
|
166
|
+
"resource_mutate": _parse_resource_mutate,
|
|
167
|
+
}
|