autoloop 0.1.0__tar.gz
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.
- autoloop-0.1.0/LICENSE +21 -0
- autoloop-0.1.0/PKG-INFO +123 -0
- autoloop-0.1.0/README.md +98 -0
- autoloop-0.1.0/pyproject.toml +51 -0
- autoloop-0.1.0/setup.cfg +4 -0
- autoloop-0.1.0/src/autoloop/__init__.py +1 -0
- autoloop-0.1.0/src/autoloop/__main__.py +3 -0
- autoloop-0.1.0/src/autoloop/loop_control.py +173 -0
- autoloop-0.1.0/src/autoloop/main.py +4747 -0
- autoloop-0.1.0/src/autoloop/skill/SKILL.md +66 -0
- autoloop-0.1.0/src/autoloop/templates/implement_criteria.md +8 -0
- autoloop-0.1.0/src/autoloop/templates/implement_producer.md +51 -0
- autoloop-0.1.0/src/autoloop/templates/implement_verifier.md +46 -0
- autoloop-0.1.0/src/autoloop/templates/plan_criteria.md +8 -0
- autoloop-0.1.0/src/autoloop/templates/plan_producer.md +92 -0
- autoloop-0.1.0/src/autoloop/templates/plan_verifier.md +59 -0
- autoloop-0.1.0/src/autoloop/templates/test_criteria.md +8 -0
- autoloop-0.1.0/src/autoloop/templates/test_producer.md +42 -0
- autoloop-0.1.0/src/autoloop/templates/test_verifier.md +44 -0
- autoloop-0.1.0/src/autoloop.egg-info/PKG-INFO +123 -0
- autoloop-0.1.0/src/autoloop.egg-info/SOURCES.txt +30 -0
- autoloop-0.1.0/src/autoloop.egg-info/dependency_links.txt +1 -0
- autoloop-0.1.0/src/autoloop.egg-info/entry_points.txt +2 -0
- autoloop-0.1.0/src/autoloop.egg-info/requires.txt +1 -0
- autoloop-0.1.0/src/autoloop.egg-info/top_level.txt +1 -0
- autoloop-0.1.0/tests/test_autoloop_git_tracking.py +87 -0
- autoloop-0.1.0/tests/test_autoloop_observability.py +3939 -0
- autoloop-0.1.0/tests/test_imports.py +8 -0
- autoloop-0.1.0/tests/test_loop_control.py +140 -0
- autoloop-0.1.0/tests/test_module_entrypoint.py +74 -0
- autoloop-0.1.0/tests/test_phase_local_behavior.py +465 -0
- autoloop-0.1.0/tests/test_resources.py +18 -0
autoloop-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcelo Rauter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
autoloop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: autoloop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Protocol-driven agent loops for shipping real code with less babysitting.
|
|
5
|
+
Author: Marcelo Rauter
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mrauter1/autoloop
|
|
8
|
+
Project-URL: Repository, https://github.com/mrauter1/autoloop
|
|
9
|
+
Project-URL: Issues, https://github.com/mrauter1/autoloop/issues
|
|
10
|
+
Keywords: llm,agent,orchestration,cli,codex
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: PyYAML>=6.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# Autoloop
|
|
27
|
+
|
|
28
|
+
Protocol-driven agent loops for shipping real code with less babysitting.
|
|
29
|
+
|
|
30
|
+
Autoloop is a stateful orchestration runtime for repository work. It runs plan, implement, and test producer/verifier pairs with durable state, resumable runs, phase scoping, provider-neutral sessions, and git-backed checkpoints.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- Python 3.10+
|
|
35
|
+
- One supported provider CLI:
|
|
36
|
+
- `codex` for `provider.name: codex` (default)
|
|
37
|
+
- `claude` for `provider.name: claude`, with local `claude auth status` already working
|
|
38
|
+
- `git` is optional when running with `--no-git`
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
Install from PyPI:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install autoloop
|
|
46
|
+
autoloop --help
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Install from this repository:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m pip install -e .
|
|
53
|
+
autoloop --help
|
|
54
|
+
python -m autoloop --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or use the bundled installer:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
./install_autoloop.sh
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The installer sets up Autoloop itself and the packaged skill, but it does not install provider CLIs for you. Install `codex` or `claude` separately based on the provider you plan to run.
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
Autoloop reads configuration from these directories, in this order:
|
|
68
|
+
|
|
69
|
+
1. `$XDG_CONFIG_HOME/autoloop/` or `~/.config/autoloop/`
|
|
70
|
+
2. the selected workspace root passed to `--workspace`
|
|
71
|
+
|
|
72
|
+
Within each directory it looks for:
|
|
73
|
+
|
|
74
|
+
- `autoloop.yaml`
|
|
75
|
+
- `autoloop.config`
|
|
76
|
+
- legacy read-only fallback: `superloop.yaml`
|
|
77
|
+
- legacy read-only fallback: `superloop.config`
|
|
78
|
+
|
|
79
|
+
If more than one config file exists in the same directory, Autoloop fails fast instead of guessing.
|
|
80
|
+
|
|
81
|
+
Use the nested provider shape for new configs:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
provider:
|
|
85
|
+
name: codex
|
|
86
|
+
codex:
|
|
87
|
+
model: gpt-5.4
|
|
88
|
+
model_effort: null
|
|
89
|
+
claude:
|
|
90
|
+
model: null
|
|
91
|
+
effort: null
|
|
92
|
+
permission_strategy: inherit
|
|
93
|
+
runtime:
|
|
94
|
+
pairs: plan,implement,test
|
|
95
|
+
max_iterations: 15
|
|
96
|
+
phase_mode: single
|
|
97
|
+
intent_mode: preserve
|
|
98
|
+
full_auto_answers: false
|
|
99
|
+
no_git: false
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Notes:
|
|
103
|
+
|
|
104
|
+
- Legacy flat Codex keys `provider.model` and `provider.model_effort` are still read for backward compatibility.
|
|
105
|
+
- `--model` and `--model-effort` are Codex-only CLI overrides.
|
|
106
|
+
- Claude defaults intentionally inherit the operator's existing Claude Code environment. Default Claude runs do not add strict mode, `--bare`, tool restriction flags, or `stream-json`.
|
|
107
|
+
- `runtime.full_auto_answers` now uses the active provider instead of being Codex-only.
|
|
108
|
+
|
|
109
|
+
## Runtime State
|
|
110
|
+
|
|
111
|
+
Fresh runs write state under `.autoloop/`.
|
|
112
|
+
|
|
113
|
+
For the first Autoloop release, resume and task listing can still read legacy `.superloop/` workspaces when no `.autoloop/` state exists for the requested task or run.
|
|
114
|
+
|
|
115
|
+
Session files now store provider-neutral `provider` and `session_id` fields. Legacy Codex session files that only contain `thread_id` remain load-compatible.
|
|
116
|
+
|
|
117
|
+
## Claude Validation
|
|
118
|
+
|
|
119
|
+
Maintainer live-validation steps for inherited-environment Claude repos and resume config-drift checks are recorded in [docs/claude_rollout_checklist.md](docs/claude_rollout_checklist.md).
|
|
120
|
+
|
|
121
|
+
## Skill
|
|
122
|
+
|
|
123
|
+
The packaged Autoloop skill lives at `src/autoloop/skill/SKILL.md`.
|
autoloop-0.1.0/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Autoloop
|
|
2
|
+
|
|
3
|
+
Protocol-driven agent loops for shipping real code with less babysitting.
|
|
4
|
+
|
|
5
|
+
Autoloop is a stateful orchestration runtime for repository work. It runs plan, implement, and test producer/verifier pairs with durable state, resumable runs, phase scoping, provider-neutral sessions, and git-backed checkpoints.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.10+
|
|
10
|
+
- One supported provider CLI:
|
|
11
|
+
- `codex` for `provider.name: codex` (default)
|
|
12
|
+
- `claude` for `provider.name: claude`, with local `claude auth status` already working
|
|
13
|
+
- `git` is optional when running with `--no-git`
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Install from PyPI:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install autoloop
|
|
21
|
+
autoloop --help
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Install from this repository:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python -m pip install -e .
|
|
28
|
+
autoloop --help
|
|
29
|
+
python -m autoloop --help
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or use the bundled installer:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
./install_autoloop.sh
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The installer sets up Autoloop itself and the packaged skill, but it does not install provider CLIs for you. Install `codex` or `claude` separately based on the provider you plan to run.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Autoloop reads configuration from these directories, in this order:
|
|
43
|
+
|
|
44
|
+
1. `$XDG_CONFIG_HOME/autoloop/` or `~/.config/autoloop/`
|
|
45
|
+
2. the selected workspace root passed to `--workspace`
|
|
46
|
+
|
|
47
|
+
Within each directory it looks for:
|
|
48
|
+
|
|
49
|
+
- `autoloop.yaml`
|
|
50
|
+
- `autoloop.config`
|
|
51
|
+
- legacy read-only fallback: `superloop.yaml`
|
|
52
|
+
- legacy read-only fallback: `superloop.config`
|
|
53
|
+
|
|
54
|
+
If more than one config file exists in the same directory, Autoloop fails fast instead of guessing.
|
|
55
|
+
|
|
56
|
+
Use the nested provider shape for new configs:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
provider:
|
|
60
|
+
name: codex
|
|
61
|
+
codex:
|
|
62
|
+
model: gpt-5.4
|
|
63
|
+
model_effort: null
|
|
64
|
+
claude:
|
|
65
|
+
model: null
|
|
66
|
+
effort: null
|
|
67
|
+
permission_strategy: inherit
|
|
68
|
+
runtime:
|
|
69
|
+
pairs: plan,implement,test
|
|
70
|
+
max_iterations: 15
|
|
71
|
+
phase_mode: single
|
|
72
|
+
intent_mode: preserve
|
|
73
|
+
full_auto_answers: false
|
|
74
|
+
no_git: false
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Notes:
|
|
78
|
+
|
|
79
|
+
- Legacy flat Codex keys `provider.model` and `provider.model_effort` are still read for backward compatibility.
|
|
80
|
+
- `--model` and `--model-effort` are Codex-only CLI overrides.
|
|
81
|
+
- Claude defaults intentionally inherit the operator's existing Claude Code environment. Default Claude runs do not add strict mode, `--bare`, tool restriction flags, or `stream-json`.
|
|
82
|
+
- `runtime.full_auto_answers` now uses the active provider instead of being Codex-only.
|
|
83
|
+
|
|
84
|
+
## Runtime State
|
|
85
|
+
|
|
86
|
+
Fresh runs write state under `.autoloop/`.
|
|
87
|
+
|
|
88
|
+
For the first Autoloop release, resume and task listing can still read legacy `.superloop/` workspaces when no `.autoloop/` state exists for the requested task or run.
|
|
89
|
+
|
|
90
|
+
Session files now store provider-neutral `provider` and `session_id` fields. Legacy Codex session files that only contain `thread_id` remain load-compatible.
|
|
91
|
+
|
|
92
|
+
## Claude Validation
|
|
93
|
+
|
|
94
|
+
Maintainer live-validation steps for inherited-environment Claude repos and resume config-drift checks are recorded in [docs/claude_rollout_checklist.md](docs/claude_rollout_checklist.md).
|
|
95
|
+
|
|
96
|
+
## Skill
|
|
97
|
+
|
|
98
|
+
The packaged Autoloop skill lives at `src/autoloop/skill/SKILL.md`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "autoloop"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Protocol-driven agent loops for shipping real code with less babysitting."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Marcelo Rauter" }
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"PyYAML>=6.0",
|
|
18
|
+
]
|
|
19
|
+
keywords = ["llm", "agent", "orchestration", "cli", "codex"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Environment :: Console",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/mrauter1/autoloop"
|
|
34
|
+
Repository = "https://github.com/mrauter1/autoloop"
|
|
35
|
+
Issues = "https://github.com/mrauter1/autoloop/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
autoloop = "autoloop.main:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools]
|
|
41
|
+
package-dir = {"" = "src"}
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
include = ["autoloop*"]
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.package-data]
|
|
48
|
+
autoloop = [
|
|
49
|
+
"templates/*.md",
|
|
50
|
+
"skill/SKILL.md",
|
|
51
|
+
]
|
autoloop-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Autoloop package."""
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
CONTROL_SCHEMA_ID = "docloop.loop_control/v1"
|
|
10
|
+
|
|
11
|
+
PROMISE_COMPLETE = "COMPLETE"
|
|
12
|
+
PROMISE_INCOMPLETE = "INCOMPLETE"
|
|
13
|
+
PROMISE_BLOCKED = "BLOCKED"
|
|
14
|
+
PROMISE_VALUES = {PROMISE_COMPLETE, PROMISE_INCOMPLETE, PROMISE_BLOCKED}
|
|
15
|
+
|
|
16
|
+
CANONICAL_BLOCK_RE = re.compile(
|
|
17
|
+
r"<loop-control>\s*(.*?)\s*</loop-control>",
|
|
18
|
+
re.DOTALL | re.IGNORECASE,
|
|
19
|
+
)
|
|
20
|
+
LEGACY_QUESTION_RE = re.compile(r"<question>(.*?)</question>", re.DOTALL | re.IGNORECASE)
|
|
21
|
+
PROMISE_LINE_RE = re.compile(
|
|
22
|
+
r"^\s*<promise>(COMPLETE|INCOMPLETE|BLOCKED)</promise>\s*$",
|
|
23
|
+
re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
UNCHECKED_BOX_RE = re.compile(r"^\s*-\s\[ \]", re.MULTILINE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LoopControlParseError(ValueError):
|
|
29
|
+
"""Raised when loop-control output is malformed or conflicting."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class LoopQuestion:
|
|
34
|
+
text: str
|
|
35
|
+
best_supposition: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LoopControl:
|
|
40
|
+
question: LoopQuestion | None
|
|
41
|
+
promise: str | None
|
|
42
|
+
source: Literal["canonical", "legacy", "none"]
|
|
43
|
+
raw_payload: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def last_non_empty_line(text: str) -> str:
|
|
47
|
+
for line in reversed(text.splitlines()):
|
|
48
|
+
if line.strip():
|
|
49
|
+
return line.strip()
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_loop_control(stdout: str) -> LoopControl:
|
|
54
|
+
canonical_matches = list(CANONICAL_BLOCK_RE.finditer(stdout))
|
|
55
|
+
if len(canonical_matches) > 1:
|
|
56
|
+
raise LoopControlParseError("Multiple <loop-control> blocks found.")
|
|
57
|
+
|
|
58
|
+
if canonical_matches:
|
|
59
|
+
return _parse_canonical_control(stdout, canonical_matches[0])
|
|
60
|
+
|
|
61
|
+
return _parse_legacy_control(stdout)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def criteria_all_checked(criteria_file: Path) -> bool:
|
|
65
|
+
criteria_text = criteria_file.read_text(encoding="utf-8")
|
|
66
|
+
return UNCHECKED_BOX_RE.search(criteria_text) is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_canonical_control(stdout: str, canonical_match: re.Match[str]) -> LoopControl:
|
|
70
|
+
prefix = stdout[: canonical_match.start()]
|
|
71
|
+
suffix = stdout[canonical_match.end() :]
|
|
72
|
+
|
|
73
|
+
if suffix.strip():
|
|
74
|
+
raise LoopControlParseError(
|
|
75
|
+
"Canonical <loop-control> output must be the last non-empty block in stdout."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if LEGACY_QUESTION_RE.search(prefix):
|
|
79
|
+
raise LoopControlParseError(
|
|
80
|
+
"Canonical <loop-control> output cannot be combined with legacy <question> tags."
|
|
81
|
+
)
|
|
82
|
+
legacy_promise_match = PROMISE_LINE_RE.fullmatch(last_non_empty_line(prefix))
|
|
83
|
+
if legacy_promise_match:
|
|
84
|
+
raise LoopControlParseError(
|
|
85
|
+
"Canonical <loop-control> output cannot be combined with legacy final-line <promise> tags."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
raw_payload = canonical_match.group(1).strip()
|
|
89
|
+
try:
|
|
90
|
+
payload = json.loads(raw_payload)
|
|
91
|
+
except json.JSONDecodeError as exc:
|
|
92
|
+
raise LoopControlParseError(f"Invalid canonical loop-control JSON: {exc.msg}.") from exc
|
|
93
|
+
|
|
94
|
+
if not isinstance(payload, dict):
|
|
95
|
+
raise LoopControlParseError("Canonical loop-control payload must decode to a JSON object.")
|
|
96
|
+
_validate_canonical_schema(payload)
|
|
97
|
+
|
|
98
|
+
kind = payload.get("kind")
|
|
99
|
+
if kind == "question":
|
|
100
|
+
return _parse_canonical_question(payload, raw_payload)
|
|
101
|
+
if kind == "promise":
|
|
102
|
+
return _parse_canonical_promise(payload, raw_payload)
|
|
103
|
+
raise LoopControlParseError("Canonical loop-control kind must be 'question' or 'promise'.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_canonical_question(payload: dict[str, object], raw_payload: str) -> LoopControl:
|
|
107
|
+
if "promise" in payload:
|
|
108
|
+
raise LoopControlParseError("Canonical question payload must not include a promise field.")
|
|
109
|
+
|
|
110
|
+
question = _require_non_empty_string(payload.get("question"), "Canonical question payload requires 'question'.")
|
|
111
|
+
best_supposition = payload.get("best_supposition")
|
|
112
|
+
if best_supposition is not None:
|
|
113
|
+
best_supposition = _require_non_empty_string(
|
|
114
|
+
best_supposition,
|
|
115
|
+
"Canonical question payload best_supposition must be a non-empty string when provided.",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return LoopControl(
|
|
119
|
+
question=LoopQuestion(text=question, best_supposition=best_supposition),
|
|
120
|
+
promise=None,
|
|
121
|
+
source="canonical",
|
|
122
|
+
raw_payload=raw_payload,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_canonical_promise(payload: dict[str, object], raw_payload: str) -> LoopControl:
|
|
127
|
+
if "question" in payload or "best_supposition" in payload:
|
|
128
|
+
raise LoopControlParseError(
|
|
129
|
+
"Canonical promise payload must not include question or best_supposition fields."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
promise = _require_non_empty_string(payload.get("promise"), "Canonical promise payload requires 'promise'.")
|
|
133
|
+
promise = promise.upper()
|
|
134
|
+
if promise not in PROMISE_VALUES:
|
|
135
|
+
raise LoopControlParseError(
|
|
136
|
+
f"Canonical promise must be one of {PROMISE_COMPLETE}, {PROMISE_INCOMPLETE}, or {PROMISE_BLOCKED}, not {promise!r}."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return LoopControl(
|
|
140
|
+
question=None,
|
|
141
|
+
promise=promise,
|
|
142
|
+
source="canonical",
|
|
143
|
+
raw_payload=raw_payload,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _validate_canonical_schema(payload: dict[str, object]) -> None:
|
|
148
|
+
if payload.get("schema") != CONTROL_SCHEMA_ID:
|
|
149
|
+
raise LoopControlParseError(
|
|
150
|
+
f"Canonical loop-control schema must be {CONTROL_SCHEMA_ID!r}."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_legacy_control(stdout: str) -> LoopControl:
|
|
155
|
+
question_match = LEGACY_QUESTION_RE.search(stdout)
|
|
156
|
+
promise_match = PROMISE_LINE_RE.fullmatch(last_non_empty_line(stdout))
|
|
157
|
+
|
|
158
|
+
question = None
|
|
159
|
+
if question_match:
|
|
160
|
+
question_text = question_match.group(1).strip()
|
|
161
|
+
question = LoopQuestion(text=question_text)
|
|
162
|
+
|
|
163
|
+
promise = promise_match.group(1).upper() if promise_match else None
|
|
164
|
+
if question or promise:
|
|
165
|
+
return LoopControl(question=question, promise=promise, source="legacy", raw_payload=None)
|
|
166
|
+
|
|
167
|
+
return LoopControl(question=None, promise=None, source="none", raw_payload=None)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _require_non_empty_string(value: object, message: str) -> str:
|
|
171
|
+
if not isinstance(value, str) or not value.strip():
|
|
172
|
+
raise LoopControlParseError(message)
|
|
173
|
+
return value.strip()
|