deviatdd 2.5.1__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.
- deviatdd-2.5.1.dist-info/METADATA +386 -0
- deviatdd-2.5.1.dist-info/RECORD +124 -0
- deviatdd-2.5.1.dist-info/WHEEL +4 -0
- deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
- deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
- deviate/__init__.py +3 -0
- deviate/cli/__init__.py +824 -0
- deviate/cli/_common.py +155 -0
- deviate/cli/adhoc.py +183 -0
- deviate/cli/constitution.py +113 -0
- deviate/cli/feature.py +94 -0
- deviate/cli/init.py +485 -0
- deviate/cli/inspect.py +208 -0
- deviate/cli/macro.py +937 -0
- deviate/cli/meso.py +1894 -0
- deviate/cli/micro.py +3249 -0
- deviate/cli/review.py +441 -0
- deviate/core/__init__.py +0 -0
- deviate/core/_shared.py +20 -0
- deviate/core/agent.py +505 -0
- deviate/core/cache_discipline.py +65 -0
- deviate/core/commands.py +202 -0
- deviate/core/commit.py +86 -0
- deviate/core/complexity.py +36 -0
- deviate/core/constitution.py +85 -0
- deviate/core/contract.py +17 -0
- deviate/core/convention.py +147 -0
- deviate/core/epic.py +73 -0
- deviate/core/issues.py +46 -0
- deviate/core/prd.py +18 -0
- deviate/core/profile.py +33 -0
- deviate/core/repo.py +47 -0
- deviate/core/run_logger.py +50 -0
- deviate/core/tasks_ledger.py +69 -0
- deviate/core/treesitter/__init__.py +31 -0
- deviate/core/treesitter/analysis.py +457 -0
- deviate/core/treesitter/models.py +35 -0
- deviate/core/treesitter/parser.py +184 -0
- deviate/core/treesitter/queries/bash.scm +7 -0
- deviate/core/treesitter/queries/c_sharp.scm +11 -0
- deviate/core/treesitter/queries/cpp.scm +9 -0
- deviate/core/treesitter/queries/css.scm +4 -0
- deviate/core/treesitter/queries/dockerfile.scm +8 -0
- deviate/core/treesitter/queries/elixir.scm +6 -0
- deviate/core/treesitter/queries/go.scm +9 -0
- deviate/core/treesitter/queries/hcl.scm +3 -0
- deviate/core/treesitter/queries/html.scm +3 -0
- deviate/core/treesitter/queries/javascript.scm +12 -0
- deviate/core/treesitter/queries/json.scm +4 -0
- deviate/core/treesitter/queries/kotlin.scm +8 -0
- deviate/core/treesitter/queries/markdown.scm +4 -0
- deviate/core/treesitter/queries/python.scm +10 -0
- deviate/core/treesitter/queries/rust.scm +12 -0
- deviate/core/treesitter/queries/sql.scm +7 -0
- deviate/core/treesitter/queries/swift.scm +10 -0
- deviate/core/treesitter/queries/toml.scm +3 -0
- deviate/core/treesitter/queries/tsx.scm +14 -0
- deviate/core/treesitter/queries/typescript.scm +14 -0
- deviate/core/treesitter/queries/yaml.scm +4 -0
- deviate/core/validation.py +153 -0
- deviate/core/worktree.py +141 -0
- deviate/main.py +4 -0
- deviate/prompts/__init__.py +0 -0
- deviate/prompts/assembly.py +122 -0
- deviate/prompts/auto/__init__.py +1 -0
- deviate/prompts/auto/execute.md +62 -0
- deviate/prompts/auto/explore.md +103 -0
- deviate/prompts/auto/green.md +137 -0
- deviate/prompts/auto/judge.md +260 -0
- deviate/prompts/auto/plan.md +127 -0
- deviate/prompts/auto/prd.md +108 -0
- deviate/prompts/auto/red.md +156 -0
- deviate/prompts/auto/refactor.md +166 -0
- deviate/prompts/auto/research.md +111 -0
- deviate/prompts/auto/shard.md +90 -0
- deviate/prompts/auto/specify.md +77 -0
- deviate/prompts/auto/tasks.md +191 -0
- deviate/prompts/commands/deviate-adhoc.md +216 -0
- deviate/prompts/commands/deviate-architecture.md +147 -0
- deviate/prompts/commands/deviate-constitution.md +215 -0
- deviate/prompts/commands/deviate-e2e.md +264 -0
- deviate/prompts/commands/deviate-execute.md +223 -0
- deviate/prompts/commands/deviate-explore.md +253 -0
- deviate/prompts/commands/deviate-flows.md +226 -0
- deviate/prompts/commands/deviate-green.md +217 -0
- deviate/prompts/commands/deviate-hotfix.md +188 -0
- deviate/prompts/commands/deviate-init.md +170 -0
- deviate/prompts/commands/deviate-judge.md +193 -0
- deviate/prompts/commands/deviate-merge.md +221 -0
- deviate/prompts/commands/deviate-plan.md +158 -0
- deviate/prompts/commands/deviate-pr.md +144 -0
- deviate/prompts/commands/deviate-prd.md +216 -0
- deviate/prompts/commands/deviate-prune.md +260 -0
- deviate/prompts/commands/deviate-red.md +231 -0
- deviate/prompts/commands/deviate-refactor.md +211 -0
- deviate/prompts/commands/deviate-release.md +123 -0
- deviate/prompts/commands/deviate-research.md +359 -0
- deviate/prompts/commands/deviate-review.md +289 -0
- deviate/prompts/commands/deviate-shard.md +226 -0
- deviate/prompts/commands/deviate-tasks.md +281 -0
- deviate/prompts/commands/deviate-triage.md +141 -0
- deviate/prompts/constitution_seed.md +51 -0
- deviate/prompts/core/core.md +21 -0
- deviate/prompts/core/macro-auto.md +59 -0
- deviate/prompts/core/macro-command.md +54 -0
- deviate/prompts/core/macro-skill.md +54 -0
- deviate/prompts/core/meso-auto.md +63 -0
- deviate/prompts/core/meso-command.md +58 -0
- deviate/prompts/core/meso-skill.md +58 -0
- deviate/prompts/core/micro-auto.md +76 -0
- deviate/prompts/core/micro-command.md +67 -0
- deviate/prompts/core/micro-skill.md +67 -0
- deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
- deviate/prompts/governance/__init__.py +0 -0
- deviate/prompts/governance/agents_seed.md +1 -0
- deviate/prompts/governance/claudemd_seed.md +1 -0
- deviate/prompts/governance/graphite_seed.md +18 -0
- deviate/prompts/governance/libref_seed.md +3 -0
- deviate/state/__init__.py +0 -0
- deviate/state/config.py +257 -0
- deviate/state/ledger.py +407 -0
- deviate/ui/__init__.py +5 -0
- deviate/ui/monitor.py +187 -0
- deviate/ui/render.py +26 -0
deviate/core/agent.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
|
|
14
|
+
from deviate.state.config import AgentConfig
|
|
15
|
+
|
|
16
|
+
OutputCallback = Callable[[str], None]
|
|
17
|
+
|
|
18
|
+
BackendName = Literal["opencode", "claude", "droid", "pi", "omp", "stub"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HandoverManifest(BaseModel):
|
|
22
|
+
phase: str
|
|
23
|
+
status: str
|
|
24
|
+
task_id: str | None = None
|
|
25
|
+
test_file: str | None = None
|
|
26
|
+
verification_command: str | None = None
|
|
27
|
+
expected_failure_node: str | None = None
|
|
28
|
+
rationale: str | None = None
|
|
29
|
+
next_phase: str | None = None
|
|
30
|
+
|
|
31
|
+
model_config = {"extra": "allow"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentTimeoutError(Exception):
|
|
35
|
+
def __init__(
|
|
36
|
+
self, message: str, partial_stdout: str = "", partial_stderr: str = ""
|
|
37
|
+
):
|
|
38
|
+
self.partial_stdout = partial_stdout
|
|
39
|
+
self.partial_stderr = partial_stderr
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentSubprocessError(Exception):
|
|
44
|
+
def __init__(self, message: str, exit_code: int = 1) -> None:
|
|
45
|
+
self.exit_code = exit_code
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MalformedHandoverManifestError(Exception):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AgentBinaryNotFoundError(Exception):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EmptyOutputError(Exception):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
BACKEND_COMMANDS: dict[str, str] = {
|
|
62
|
+
"opencode": "opencode run",
|
|
63
|
+
"claude": "claude -p --permission-mode auto",
|
|
64
|
+
"droid": "droid exec",
|
|
65
|
+
"pi": "pi -p",
|
|
66
|
+
# Oh-My-Pi: a distinct CLI binary that wraps Pi internally but is
|
|
67
|
+
# invoked directly (``omp -p``). The dispatch layer treats ``omp``
|
|
68
|
+
# as a first-class backend, not an alias for ``pi`` — model flag,
|
|
69
|
+
# timeout, and YAML manifest extraction all apply.
|
|
70
|
+
"omp": "omp -p",
|
|
71
|
+
"stub": "stub",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Map a user-facing agent name (CLI ``--agent`` value, or ``[agent].backend``
|
|
75
|
+
# in ``.deviate/config.toml``) to the canonical backend that the dispatch
|
|
76
|
+
# layer invokes. Only ``factory`` remains as an alias — the Factory Droid
|
|
77
|
+
# IDE drives the ``droid`` binary under the hood. All other names are
|
|
78
|
+
# canonical: ``omp`` is its own backend (not an alias for ``pi``); the
|
|
79
|
+
# remaining names already match the dispatch-layer identifier.
|
|
80
|
+
AGENT_TO_BACKEND: dict[str, str] = {
|
|
81
|
+
"factory": "droid",
|
|
82
|
+
"droid": "droid",
|
|
83
|
+
"claude": "claude",
|
|
84
|
+
"opencode": "opencode",
|
|
85
|
+
"pi": "pi",
|
|
86
|
+
"omp": "omp",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_agent_to_backend(agent: str) -> str:
|
|
91
|
+
"""Return the canonical backend for *agent*.
|
|
92
|
+
|
|
93
|
+
User-facing aliases (``factory``) are mapped to their underlying
|
|
94
|
+
backend binary. Already-canonical names (``opencode``, ``claude``,
|
|
95
|
+
``droid``, ``pi``, ``omp``) pass through unchanged. Unknown values
|
|
96
|
+
are returned unchanged so the caller can surface a validation error
|
|
97
|
+
against :class:`~deviate.state.config.AgentConfig`'s ``backend``
|
|
98
|
+
Literal.
|
|
99
|
+
"""
|
|
100
|
+
return AGENT_TO_BACKEND.get(agent, agent)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
PI_RPC_COMMAND: list[str] = ["pi", "--mode", "rpc", "--no-session"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Per-backend model-flag dispatch. ``None`` means the backend does not
|
|
107
|
+
# accept ``--model`` on the CLI (model routing is the operator's
|
|
108
|
+
# responsibility — claude ignores model config entirely).
|
|
109
|
+
# ``["--model"]`` means the backend accepts the ``--model <id>`` flag
|
|
110
|
+
# (``opencode``, ``droid``, ``pi``, and ``omp`` all do).
|
|
111
|
+
MODEL_FLAGS: dict[str, list[str] | None] = {
|
|
112
|
+
"pi": ["--model"],
|
|
113
|
+
"claude": None,
|
|
114
|
+
"opencode": ["--model"],
|
|
115
|
+
"droid": ["--model"],
|
|
116
|
+
"omp": ["--model"],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Backends whose CLI expects the prompt as a positional argument rather
|
|
120
|
+
# than via stdin. The prompt gets appended as the last element of the
|
|
121
|
+
# command list before spawning the subprocess.
|
|
122
|
+
PROMPT_AS_ARG_BACKENDS: frozenset[str] = frozenset({"omp"})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_YAML_BLOCK_RE = re.compile(r"```(?:yaml)?\s*\n(.*?)```", re.DOTALL)
|
|
126
|
+
_YAML_MAPPING_START_RE = re.compile(r"^[\w_]+:\s", re.MULTILINE)
|
|
127
|
+
_YAML_HANDOVER_MARKER_RE = re.compile(
|
|
128
|
+
r"<handover_manifest>\s*(?:\n```(?:yaml)?\s*\n)?(.*?)(?:\n```\s*)?$",
|
|
129
|
+
re.DOTALL,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _strip_md_for_yaml(text: str) -> str:
|
|
134
|
+
"""Strip markdown artifacts that confuse YAML parsing in bare output."""
|
|
135
|
+
text = re.sub(r"^<handover_manifest>\s*$", "", text, flags=re.MULTILINE)
|
|
136
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
|
137
|
+
text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"\1", text)
|
|
138
|
+
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
|
139
|
+
return text.strip()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class AgentBackend:
|
|
143
|
+
def __init__(self, config: AgentConfig | None = None) -> None:
|
|
144
|
+
self.config = config or AgentConfig()
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _extract_yaml_block(text: str) -> str:
|
|
148
|
+
m = _YAML_BLOCK_RE.search(text)
|
|
149
|
+
if m:
|
|
150
|
+
return m.group(1).strip()
|
|
151
|
+
|
|
152
|
+
m = _YAML_HANDOVER_MARKER_RE.search(text)
|
|
153
|
+
if m:
|
|
154
|
+
candidate = m.group(1).strip()
|
|
155
|
+
if candidate:
|
|
156
|
+
return candidate
|
|
157
|
+
|
|
158
|
+
m = _YAML_MAPPING_START_RE.search(text)
|
|
159
|
+
if m:
|
|
160
|
+
return text[m.start() :].strip()
|
|
161
|
+
|
|
162
|
+
cleaned = _strip_md_for_yaml(text)
|
|
163
|
+
if cleaned:
|
|
164
|
+
try:
|
|
165
|
+
yaml.safe_load(cleaned)
|
|
166
|
+
except yaml.YAMLError:
|
|
167
|
+
return ""
|
|
168
|
+
return cleaned
|
|
169
|
+
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _yaml_error_hint(text: str) -> str:
|
|
174
|
+
has_yaml_fence = bool(re.search(r"```\s*yaml", text, re.IGNORECASE))
|
|
175
|
+
has_yaml_content = bool(_YAML_MAPPING_START_RE.search(text))
|
|
176
|
+
has_handover_marker = bool(re.search(r"<handover_manifest>", text))
|
|
177
|
+
if has_handover_marker and not has_yaml_fence:
|
|
178
|
+
return (
|
|
179
|
+
" Found <handover_manifest> tag but could not extract YAML —"
|
|
180
|
+
" ensure the YAML content follows the tag,"
|
|
181
|
+
" optionally inside a ```yaml block."
|
|
182
|
+
)
|
|
183
|
+
if not has_yaml_fence and has_yaml_content:
|
|
184
|
+
return (
|
|
185
|
+
" Expected ```yaml block, found inline YAML —"
|
|
186
|
+
" wrap in ```yaml for reliability, or ensure"
|
|
187
|
+
" no explanatory text precedes the YAML content."
|
|
188
|
+
)
|
|
189
|
+
if not has_yaml_fence:
|
|
190
|
+
return (
|
|
191
|
+
" No YAML handover manifest detected in agent output."
|
|
192
|
+
" The agent must emit a YAML manifest (plain or in a ```yaml block)."
|
|
193
|
+
)
|
|
194
|
+
if re.search(r"(?<!\"):\s+\w", text):
|
|
195
|
+
return " Check that all YAML string values are double-quoted."
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def parse_output(
|
|
200
|
+
stdout: str,
|
|
201
|
+
backend_name: str,
|
|
202
|
+
) -> HandoverManifest:
|
|
203
|
+
if not stdout.strip():
|
|
204
|
+
raise EmptyOutputError(
|
|
205
|
+
f"Agent backend '{backend_name}' returned empty output"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
yaml_text = AgentBackend._extract_yaml_block(stdout)
|
|
209
|
+
|
|
210
|
+
if not yaml_text:
|
|
211
|
+
hint = AgentBackend._yaml_error_hint(stdout)
|
|
212
|
+
raise MalformedHandoverManifestError(
|
|
213
|
+
f"No YAML handover manifest detected in agent output.{hint}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
data = yaml.safe_load(yaml_text)
|
|
218
|
+
except yaml.YAMLError as e:
|
|
219
|
+
hint = AgentBackend._yaml_error_hint(stdout)
|
|
220
|
+
raise MalformedHandoverManifestError(
|
|
221
|
+
f"Failed to parse YAML handover manifest: {e}{hint}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if not isinstance(data, dict):
|
|
225
|
+
hint = AgentBackend._yaml_error_hint(stdout)
|
|
226
|
+
raise MalformedHandoverManifestError(
|
|
227
|
+
f"YAML handover manifest is not a mapping (got {type(data).__name__})."
|
|
228
|
+
f" The manifest must be a key: value mapping.{hint}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
return HandoverManifest(**data)
|
|
233
|
+
except ValidationError as e:
|
|
234
|
+
raise MalformedHandoverManifestError(
|
|
235
|
+
f"Handover manifest failed schema validation: {e}"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def _invoke_blocking(
|
|
239
|
+
self,
|
|
240
|
+
proc: subprocess.Popen[bytes],
|
|
241
|
+
cmd: list[str],
|
|
242
|
+
prompt: str,
|
|
243
|
+
timeout_secs: int,
|
|
244
|
+
backend_name: str,
|
|
245
|
+
) -> tuple[str, str]:
|
|
246
|
+
try:
|
|
247
|
+
stdout_bytes, stderr_bytes = proc.communicate(
|
|
248
|
+
input=prompt.encode("utf-8"),
|
|
249
|
+
timeout=timeout_secs,
|
|
250
|
+
)
|
|
251
|
+
except subprocess.TimeoutExpired as e:
|
|
252
|
+
proc.kill()
|
|
253
|
+
proc.wait()
|
|
254
|
+
partial_out = e.output.decode("utf-8") if e.output else ""
|
|
255
|
+
partial_err = e.stderr.decode("utf-8") if e.stderr else ""
|
|
256
|
+
raise AgentTimeoutError(
|
|
257
|
+
f"Agent backend '{backend_name}' timed out "
|
|
258
|
+
f"after {timeout_secs}s"
|
|
259
|
+
f" (retried once with 30s backoff)",
|
|
260
|
+
partial_stdout=partial_out,
|
|
261
|
+
partial_stderr=partial_err,
|
|
262
|
+
)
|
|
263
|
+
stdout = stdout_bytes.decode("utf-8") if stdout_bytes else ""
|
|
264
|
+
stderr = stderr_bytes.decode("utf-8") if stderr_bytes else ""
|
|
265
|
+
if proc.returncode != 0:
|
|
266
|
+
raise AgentSubprocessError(
|
|
267
|
+
message=stderr or f"Agent exited with code {proc.returncode}",
|
|
268
|
+
exit_code=proc.returncode,
|
|
269
|
+
)
|
|
270
|
+
return stdout, stderr
|
|
271
|
+
|
|
272
|
+
def _invoke_streaming(
|
|
273
|
+
self,
|
|
274
|
+
proc: subprocess.Popen[bytes],
|
|
275
|
+
cmd: list[str],
|
|
276
|
+
prompt: str,
|
|
277
|
+
timeout_secs: int,
|
|
278
|
+
backend_name: str,
|
|
279
|
+
output_callback: OutputCallback,
|
|
280
|
+
) -> tuple[str, str]:
|
|
281
|
+
proc.stdin.write(prompt.encode("utf-8"))
|
|
282
|
+
proc.stdin.close()
|
|
283
|
+
|
|
284
|
+
stdout_lines: list[str] = []
|
|
285
|
+
stderr_lines: list[str] = []
|
|
286
|
+
stdout_done = False
|
|
287
|
+
|
|
288
|
+
def read_stdout() -> None:
|
|
289
|
+
nonlocal stdout_done
|
|
290
|
+
for raw_line in proc.stdout:
|
|
291
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
|
292
|
+
stdout_lines.append(line)
|
|
293
|
+
output_callback(line)
|
|
294
|
+
stdout_done = True
|
|
295
|
+
|
|
296
|
+
def read_stderr() -> None:
|
|
297
|
+
for raw_line in proc.stderr:
|
|
298
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
|
299
|
+
stderr_lines.append(line)
|
|
300
|
+
|
|
301
|
+
threads = [
|
|
302
|
+
threading.Thread(target=read_stdout),
|
|
303
|
+
threading.Thread(target=read_stderr),
|
|
304
|
+
]
|
|
305
|
+
for t in threads:
|
|
306
|
+
t.start()
|
|
307
|
+
for t in threads:
|
|
308
|
+
t.join(timeout=timeout_secs)
|
|
309
|
+
|
|
310
|
+
if not stdout_done or any(t.is_alive() for t in threads):
|
|
311
|
+
proc.kill()
|
|
312
|
+
for t in threads:
|
|
313
|
+
t.join(timeout=5)
|
|
314
|
+
raise AgentTimeoutError(
|
|
315
|
+
f"Agent backend '{backend_name}' timed out after {timeout_secs}s",
|
|
316
|
+
partial_stdout="\n".join(stdout_lines),
|
|
317
|
+
partial_stderr="\n".join(stderr_lines),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
proc.wait()
|
|
321
|
+
stdout = "\n".join(stdout_lines)
|
|
322
|
+
stderr = "\n".join(stderr_lines)
|
|
323
|
+
|
|
324
|
+
if proc.returncode != 0:
|
|
325
|
+
raise AgentSubprocessError(
|
|
326
|
+
message=stderr or f"Agent exited with code {proc.returncode}",
|
|
327
|
+
exit_code=proc.returncode,
|
|
328
|
+
)
|
|
329
|
+
return stdout, stderr
|
|
330
|
+
|
|
331
|
+
def _invoke_rpc_blocking(
|
|
332
|
+
self,
|
|
333
|
+
proc: subprocess.Popen[bytes],
|
|
334
|
+
cmd: list[str],
|
|
335
|
+
prompt: str,
|
|
336
|
+
timeout_secs: int,
|
|
337
|
+
backend_name: str,
|
|
338
|
+
) -> tuple[str, str]:
|
|
339
|
+
payload = (json.dumps({"type": "prompt", "content": prompt}) + "\n").encode(
|
|
340
|
+
"utf-8"
|
|
341
|
+
)
|
|
342
|
+
try:
|
|
343
|
+
stdout_bytes, stderr_bytes = proc.communicate(
|
|
344
|
+
input=payload, timeout=timeout_secs
|
|
345
|
+
)
|
|
346
|
+
except subprocess.TimeoutExpired as e:
|
|
347
|
+
proc.kill()
|
|
348
|
+
proc.wait()
|
|
349
|
+
partial_out = e.output.decode("utf-8") if e.output else ""
|
|
350
|
+
partial_err = e.stderr.decode("utf-8") if e.stderr else ""
|
|
351
|
+
raise AgentTimeoutError(
|
|
352
|
+
f"Agent backend '{backend_name}' timed out after {timeout_secs}s",
|
|
353
|
+
partial_stdout=partial_out,
|
|
354
|
+
partial_stderr=partial_err,
|
|
355
|
+
)
|
|
356
|
+
stdout = stdout_bytes.decode("utf-8") if stdout_bytes else ""
|
|
357
|
+
stderr = stderr_bytes.decode("utf-8") if stderr_bytes else ""
|
|
358
|
+
if proc.returncode != 0:
|
|
359
|
+
raise AgentSubprocessError(
|
|
360
|
+
message=stderr or f"Agent exited with code {proc.returncode}",
|
|
361
|
+
exit_code=proc.returncode,
|
|
362
|
+
)
|
|
363
|
+
manifest_text = ""
|
|
364
|
+
for raw_line in stdout.splitlines():
|
|
365
|
+
line = raw_line.strip()
|
|
366
|
+
if not line:
|
|
367
|
+
continue
|
|
368
|
+
try:
|
|
369
|
+
event = json.loads(line)
|
|
370
|
+
except json.JSONDecodeError:
|
|
371
|
+
continue
|
|
372
|
+
if not isinstance(event, dict):
|
|
373
|
+
continue
|
|
374
|
+
if event.get("type") != "agent_end":
|
|
375
|
+
continue
|
|
376
|
+
message = event.get("message")
|
|
377
|
+
if not isinstance(message, dict):
|
|
378
|
+
continue
|
|
379
|
+
content = message.get("content", "")
|
|
380
|
+
if isinstance(content, str):
|
|
381
|
+
manifest_text = content
|
|
382
|
+
break
|
|
383
|
+
return manifest_text, stderr
|
|
384
|
+
|
|
385
|
+
def invoke(
|
|
386
|
+
self,
|
|
387
|
+
prompt: str,
|
|
388
|
+
backend: BackendName | None = None,
|
|
389
|
+
timeout: int | None = None,
|
|
390
|
+
output_callback: OutputCallback | None = None,
|
|
391
|
+
cwd: str | None = None,
|
|
392
|
+
model: str | None = None,
|
|
393
|
+
) -> HandoverManifest:
|
|
394
|
+
backend_name: BackendName = backend or self.config.backend
|
|
395
|
+
use_rpc = backend_name == "pi" and self.config.pi_rpc
|
|
396
|
+
|
|
397
|
+
if use_rpc:
|
|
398
|
+
cmd = list(PI_RPC_COMMAND)
|
|
399
|
+
else:
|
|
400
|
+
backend_cmd = BACKEND_COMMANDS.get(backend_name)
|
|
401
|
+
if backend_cmd is None:
|
|
402
|
+
raise AgentBinaryNotFoundError(f"Unknown backend: {backend_name}")
|
|
403
|
+
|
|
404
|
+
cmd = backend_cmd.split()
|
|
405
|
+
model_flag = MODEL_FLAGS.get(backend_name, ["--model"])
|
|
406
|
+
if model is not None and model_flag is not None:
|
|
407
|
+
cmd.extend([model_flag[0], model])
|
|
408
|
+
# Backends that expect the prompt as a positional CLI argument
|
|
409
|
+
# (e.g. ``omp -p "prompt"``) get the prompt appended to the
|
|
410
|
+
# command. The ``prompt`` variable is then cleared so the
|
|
411
|
+
# subprocess dispatch does not send it via stdin.
|
|
412
|
+
if backend_name in PROMPT_AS_ARG_BACKENDS:
|
|
413
|
+
cmd.append(prompt)
|
|
414
|
+
prompt = ""
|
|
415
|
+
effective_timeout = timeout or self.config.timeout
|
|
416
|
+
|
|
417
|
+
popen_kwargs: dict[str, Any] = dict(
|
|
418
|
+
stdin=subprocess.PIPE,
|
|
419
|
+
stdout=subprocess.PIPE,
|
|
420
|
+
stderr=subprocess.PIPE,
|
|
421
|
+
)
|
|
422
|
+
if cwd is not None:
|
|
423
|
+
popen_kwargs["cwd"] = cwd
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
proc = subprocess.Popen(cmd, **popen_kwargs)
|
|
427
|
+
except FileNotFoundError:
|
|
428
|
+
raise AgentBinaryNotFoundError(
|
|
429
|
+
f"Agent binary not found on PATH for backend: {backend_name}"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
stdout, stderr = self._dispatch_invocation(
|
|
434
|
+
proc,
|
|
435
|
+
cmd,
|
|
436
|
+
prompt,
|
|
437
|
+
effective_timeout,
|
|
438
|
+
backend_name,
|
|
439
|
+
output_callback,
|
|
440
|
+
use_rpc,
|
|
441
|
+
)
|
|
442
|
+
except AgentTimeoutError:
|
|
443
|
+
time.sleep(30)
|
|
444
|
+
retry_proc = subprocess.Popen(cmd, **popen_kwargs)
|
|
445
|
+
stdout, stderr = self._dispatch_invocation(
|
|
446
|
+
retry_proc,
|
|
447
|
+
cmd,
|
|
448
|
+
prompt,
|
|
449
|
+
effective_timeout,
|
|
450
|
+
backend_name,
|
|
451
|
+
output_callback,
|
|
452
|
+
use_rpc,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
return self.parse_output(stdout, backend_name)
|
|
456
|
+
|
|
457
|
+
def _dispatch_invocation(
|
|
458
|
+
self,
|
|
459
|
+
proc: subprocess.Popen[bytes],
|
|
460
|
+
cmd: list[str],
|
|
461
|
+
prompt: str,
|
|
462
|
+
timeout_secs: int,
|
|
463
|
+
backend_name: str,
|
|
464
|
+
output_callback: OutputCallback | None,
|
|
465
|
+
use_rpc: bool,
|
|
466
|
+
) -> tuple[str, str]:
|
|
467
|
+
if use_rpc:
|
|
468
|
+
return self._invoke_rpc_blocking(
|
|
469
|
+
proc, cmd, prompt, timeout_secs, backend_name
|
|
470
|
+
)
|
|
471
|
+
if output_callback is not None:
|
|
472
|
+
return self._invoke_streaming(
|
|
473
|
+
proc, cmd, prompt, timeout_secs, backend_name, output_callback
|
|
474
|
+
)
|
|
475
|
+
return self._invoke_blocking(proc, cmd, prompt, timeout_secs, backend_name)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class StubAgentBackend(AgentBackend):
|
|
479
|
+
def __init__(self, config: AgentConfig | None = None) -> None:
|
|
480
|
+
super().__init__(config)
|
|
481
|
+
self._invoked = False
|
|
482
|
+
|
|
483
|
+
def invoke(
|
|
484
|
+
self,
|
|
485
|
+
prompt: str,
|
|
486
|
+
backend: BackendName | None = None,
|
|
487
|
+
timeout: int | None = None,
|
|
488
|
+
output_callback: OutputCallback | None = None,
|
|
489
|
+
cwd: str | None = None,
|
|
490
|
+
model: str | None = None,
|
|
491
|
+
) -> HandoverManifest:
|
|
492
|
+
self._invoked = True
|
|
493
|
+
if output_callback is not None:
|
|
494
|
+
output_callback(prompt)
|
|
495
|
+
return HandoverManifest(phase="RED", status="success")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class StubPiBackend(StubAgentBackend):
|
|
499
|
+
"""Pi-shaped stub backend for downstream test isolation.
|
|
500
|
+
|
|
501
|
+
Marker subclass of :class:`StubAgentBackend` — shares the inherited
|
|
502
|
+
``invoke()``, ``_invoked`` flag, callable surface, and
|
|
503
|
+
:class:`HandoverManifest` contract. Provides Pi-specific identity for
|
|
504
|
+
downstream fixtures that need to distinguish Pi-stub from generic stub.
|
|
505
|
+
"""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class CacheStore:
|
|
8
|
+
model: str
|
|
9
|
+
tool_definitions: list = field(default_factory=list)
|
|
10
|
+
system_prompt: str = ""
|
|
11
|
+
test_files: dict[str, str] = field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CacheDisciplineViolation(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CacheDiscipline:
|
|
19
|
+
@staticmethod
|
|
20
|
+
def _check_drift(
|
|
21
|
+
current_val: object,
|
|
22
|
+
previous_val: object,
|
|
23
|
+
label: str,
|
|
24
|
+
message: str,
|
|
25
|
+
) -> None:
|
|
26
|
+
if current_val != previous_val:
|
|
27
|
+
raise CacheDisciplineViolation(message)
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def validate(
|
|
31
|
+
phase: str,
|
|
32
|
+
current: CacheStore,
|
|
33
|
+
previous: CacheStore | None = None,
|
|
34
|
+
repo_path: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
if previous is None:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
CacheDiscipline._check_drift(
|
|
40
|
+
current.model,
|
|
41
|
+
previous.model,
|
|
42
|
+
"model",
|
|
43
|
+
f"model_switch: {previous.model} -> {current.model}",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
CacheDiscipline._check_drift(
|
|
47
|
+
current.tool_definitions,
|
|
48
|
+
previous.tool_definitions,
|
|
49
|
+
"tool_definitions",
|
|
50
|
+
"tool_change: tool definitions differ between phases",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
CacheDiscipline._check_drift(
|
|
54
|
+
current.system_prompt,
|
|
55
|
+
previous.system_prompt,
|
|
56
|
+
"system_prompt",
|
|
57
|
+
"prompt_change: system prompt mutated between phases",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
CacheDiscipline._check_drift(
|
|
61
|
+
current.test_files,
|
|
62
|
+
previous.test_files,
|
|
63
|
+
"test_files",
|
|
64
|
+
"test_change: test files modified between phases",
|
|
65
|
+
)
|