lictor 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.
- lictor/__init__.py +8 -0
- lictor/adapters/__init__.py +0 -0
- lictor/adapters/agents/__init__.py +0 -0
- lictor/adapters/agents/claude_cli.py +394 -0
- lictor/adapters/agents/fake.py +166 -0
- lictor/adapters/config/__init__.py +0 -0
- lictor/adapters/config/loader.py +338 -0
- lictor/adapters/config/models.py +345 -0
- lictor/adapters/config/profiles/__init__.py +0 -0
- lictor/adapters/config/profiles/python-uv.json +82 -0
- lictor/adapters/config/profiles/python_uv/__init__.py +0 -0
- lictor/adapters/config/profiles/python_uv/lictor_outcome.py +61 -0
- lictor/adapters/config/profiles/python_uv/lictor_project_path.py +18 -0
- lictor/adapters/readers/__init__.py +0 -0
- lictor/adapters/readers/exit_code.py +41 -0
- lictor/adapters/readers/junit.py +137 -0
- lictor/adapters/readers/mutation.py +68 -0
- lictor/adapters/runner/__init__.py +0 -0
- lictor/adapters/runner/fake.py +60 -0
- lictor/adapters/runner/subprocess_runner.py +161 -0
- lictor/adapters/spec/__init__.py +0 -0
- lictor/adapters/spec/gherkin_source.py +377 -0
- lictor/adapters/store/__init__.py +0 -0
- lictor/adapters/store/clock.py +49 -0
- lictor/adapters/store/jsonl_eventlog.py +61 -0
- lictor/adapters/vcs/__init__.py +0 -0
- lictor/adapters/vcs/git_cli.py +351 -0
- lictor/application/__init__.py +0 -0
- lictor/application/anchor.py +21 -0
- lictor/application/events.py +28 -0
- lictor/application/ports.py +266 -0
- lictor/application/run_files.py +292 -0
- lictor/application/seal.py +87 -0
- lictor/application/spec_history.py +77 -0
- lictor/application/usecases/__init__.py +0 -0
- lictor/application/usecases/agent_events.py +110 -0
- lictor/application/usecases/build_report.py +542 -0
- lictor/application/usecases/check_project.py +235 -0
- lictor/application/usecases/check_red.py +570 -0
- lictor/application/usecases/check_working_tree.py +116 -0
- lictor/application/usecases/commit_green.py +77 -0
- lictor/application/usecases/implement_scenario.py +216 -0
- lictor/application/usecases/init_project.py +86 -0
- lictor/application/usecases/preconditions.py +161 -0
- lictor/application/usecases/prepare_driver.py +246 -0
- lictor/application/usecases/quarantine.py +55 -0
- lictor/application/usecases/register_scenarios.py +104 -0
- lictor/application/usecases/verify_attempt.py +704 -0
- lictor/application/verification_env.py +334 -0
- lictor/bootstrap.py +289 -0
- lictor/cli.py +786 -0
- lictor/domain/__init__.py +0 -0
- lictor/domain/errors.py +83 -0
- lictor/domain/model.py +423 -0
- lictor/domain/routing.py +111 -0
- lictor/domain/rules.py +808 -0
- lictor/orchestration/__init__.py +0 -0
- lictor/orchestration/graph.py +851 -0
- lictor/prompts/driver.md +24 -0
- lictor/prompts/implement.md +22 -0
- lictor/py.typed +0 -0
- lictor-0.1.0.dist-info/METADATA +623 -0
- lictor-0.1.0.dist-info/RECORD +66 -0
- lictor-0.1.0.dist-info/WHEEL +4 -0
- lictor-0.1.0.dist-info/entry_points.txt +3 -0
- lictor-0.1.0.dist-info/licenses/LICENSE +190 -0
lictor/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Lictor: implementa escenarios Gherkin con un agente y los verifica de forma determinista."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("lictor")
|
|
7
|
+
except PackageNotFoundError: # a source tree that was never installed
|
|
8
|
+
__version__ = "0+desconocida"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Claude CLI adapter executing claude -p non-interactively."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from lictor.application.ports import CommandRunner
|
|
10
|
+
from lictor.domain.model import (
|
|
11
|
+
AgentResult,
|
|
12
|
+
AgentStatus,
|
|
13
|
+
AgentTask,
|
|
14
|
+
CostKind,
|
|
15
|
+
Health,
|
|
16
|
+
PermissionDenial,
|
|
17
|
+
Tokens,
|
|
18
|
+
)
|
|
19
|
+
from lictor.domain.rules import model_responded
|
|
20
|
+
|
|
21
|
+
AuthKind = Literal["subscription", "api_key"]
|
|
22
|
+
_HTTP_UNAUTHORIZED = 401
|
|
23
|
+
_HTTP_TOO_MANY_REQUESTS = 429
|
|
24
|
+
_AUTH_CHECK_TIMEOUT_S = 10
|
|
25
|
+
_VERSION_TIMEOUT_S = 10
|
|
26
|
+
_DEFAULT_BASE_TOOLS = ("Read", "Edit", "Write", "Glob", "Grep")
|
|
27
|
+
_DEFAULT_DISALLOWED_TOOLS = (
|
|
28
|
+
"Bash(git:*)",
|
|
29
|
+
"Read(.lictor/**)",
|
|
30
|
+
"Edit(.lictor/**)",
|
|
31
|
+
"Write(.lictor/**)",
|
|
32
|
+
)
|
|
33
|
+
# Automatic memory is on even with no setting sources: it would let one call configure the next.
|
|
34
|
+
_SETTINGS = json.dumps({"autoMemoryEnabled": False})
|
|
35
|
+
_NO_AUTO_MEMORY = "CLAUDE_CODE_DISABLE_AUTO_MEMORY"
|
|
36
|
+
# Where the CLI keeps its configuration and OAuth session: dropping it would log the agent out.
|
|
37
|
+
_KEPT_CLAUDE_VARIABLES = frozenset({"CLAUDE_CONFIG_DIR"})
|
|
38
|
+
# What the caller's virtual environment says, which is Lictor's and not the project's.
|
|
39
|
+
_CALLER_VENV_VARIABLES = ("VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class _Stream:
|
|
44
|
+
"""What a call's stream of events says, read once."""
|
|
45
|
+
|
|
46
|
+
events: tuple[dict[str, Any], ...]
|
|
47
|
+
result: dict[str, Any] | None
|
|
48
|
+
rate_limit_status: str | None
|
|
49
|
+
cli_version: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ClaudeCLI:
|
|
53
|
+
"""Executes Claude Code CLI commands non-interactively through a command runner."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
runner: CommandRunner,
|
|
58
|
+
auth: AuthKind = "subscription",
|
|
59
|
+
cli_binary: str = "claude",
|
|
60
|
+
) -> None:
|
|
61
|
+
self.runner = runner
|
|
62
|
+
self.auth: AuthKind = auth
|
|
63
|
+
self.cli_binary: str = cli_binary
|
|
64
|
+
|
|
65
|
+
def health(self) -> Health:
|
|
66
|
+
"""Verify CLI availability and authentication configuration."""
|
|
67
|
+
res = self.runner.run(
|
|
68
|
+
[self.cli_binary, "--version"], cwd=Path.cwd(), timeout_s=_VERSION_TIMEOUT_S
|
|
69
|
+
)
|
|
70
|
+
if not res.started:
|
|
71
|
+
return Health(
|
|
72
|
+
ok=False,
|
|
73
|
+
cli_version="",
|
|
74
|
+
auth_status="none",
|
|
75
|
+
message=f"No se pudo ejecutar el CLI '{self.cli_binary}': {res.stderr.strip()}",
|
|
76
|
+
)
|
|
77
|
+
if res.timed_out or res.returncode != 0:
|
|
78
|
+
return Health(
|
|
79
|
+
ok=False,
|
|
80
|
+
cli_version="",
|
|
81
|
+
auth_status="error",
|
|
82
|
+
message=f"El CLI devolvió el código {res.returncode}: {res.stderr.strip()}",
|
|
83
|
+
)
|
|
84
|
+
version_str = res.stdout.strip()
|
|
85
|
+
|
|
86
|
+
if self.auth == "subscription":
|
|
87
|
+
return self._subscription_health(version_str)
|
|
88
|
+
|
|
89
|
+
return Health(ok=True, cli_version=version_str, auth_status="api_key")
|
|
90
|
+
|
|
91
|
+
def _subscription_health(self, version: str) -> Health:
|
|
92
|
+
if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
|
93
|
+
return Health(
|
|
94
|
+
ok=False,
|
|
95
|
+
cli_version=version,
|
|
96
|
+
auth_status="api_key_override",
|
|
97
|
+
message=(
|
|
98
|
+
"ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN present while auth is 'subscription'"
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
logged_in = self._logged_in()
|
|
102
|
+
if logged_in is None:
|
|
103
|
+
return Health(
|
|
104
|
+
ok=False,
|
|
105
|
+
cli_version=version,
|
|
106
|
+
auth_status="error",
|
|
107
|
+
message="No se pudo comprobar la sesión del CLI",
|
|
108
|
+
)
|
|
109
|
+
if not logged_in:
|
|
110
|
+
return Health(
|
|
111
|
+
ok=False,
|
|
112
|
+
cli_version=version,
|
|
113
|
+
auth_status="none",
|
|
114
|
+
message="El CLI no tiene sesión iniciada",
|
|
115
|
+
)
|
|
116
|
+
return Health(ok=True, cli_version=version, auth_status="subscription")
|
|
117
|
+
|
|
118
|
+
def _logged_in(self) -> bool | None:
|
|
119
|
+
"""Ask the CLI for its session, which costs no model call; None if it can't tell."""
|
|
120
|
+
res = self.runner.run(
|
|
121
|
+
[self.cli_binary, "auth", "status", "--json"],
|
|
122
|
+
cwd=Path.cwd(),
|
|
123
|
+
timeout_s=_AUTH_CHECK_TIMEOUT_S,
|
|
124
|
+
env=self._prepare_env(),
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(res.stdout)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
return None
|
|
130
|
+
logged_in = data.get("loggedIn") if isinstance(data, dict) else None
|
|
131
|
+
return logged_in if isinstance(logged_in, bool) else None
|
|
132
|
+
|
|
133
|
+
def _build_command(self, task: AgentTask) -> list[str]:
|
|
134
|
+
tools: list[str] = list(_DEFAULT_BASE_TOOLS)
|
|
135
|
+
for tool in task.allowed_tools:
|
|
136
|
+
if tool not in tools:
|
|
137
|
+
tools.append(tool)
|
|
138
|
+
# `--tools` takes names, not rules: `Bash(cmd:*)` makes Bash available, the rule limits it.
|
|
139
|
+
available = list(dict.fromkeys(tool.split("(", 1)[0] for tool in tools))
|
|
140
|
+
|
|
141
|
+
return [
|
|
142
|
+
self.cli_binary,
|
|
143
|
+
"-p",
|
|
144
|
+
task.prompt,
|
|
145
|
+
"--output-format",
|
|
146
|
+
"stream-json",
|
|
147
|
+
"--verbose",
|
|
148
|
+
"--model",
|
|
149
|
+
task.model,
|
|
150
|
+
"--max-turns",
|
|
151
|
+
str(task.max_turns),
|
|
152
|
+
# Only the listed tools exist for the agent: no web, subagents, schedules or messages.
|
|
153
|
+
"--tools",
|
|
154
|
+
",".join(available),
|
|
155
|
+
# `acceptEdits` also lets through shell commands it takes for edits, such as `touch`;
|
|
156
|
+
# `dontAsk` refuses whatever no rule allows.
|
|
157
|
+
"--permission-mode",
|
|
158
|
+
"dontAsk",
|
|
159
|
+
"--allowedTools",
|
|
160
|
+
*tools,
|
|
161
|
+
"--disallowedTools",
|
|
162
|
+
*_DEFAULT_DISALLOWED_TOOLS,
|
|
163
|
+
# No user, project or local settings: their hooks, permissions and CLAUDE.md would
|
|
164
|
+
# make the run depend on the machine, and the agent can write the project's ones.
|
|
165
|
+
"--setting-sources",
|
|
166
|
+
"",
|
|
167
|
+
"--settings",
|
|
168
|
+
_SETTINGS,
|
|
169
|
+
"--strict-mcp-config",
|
|
170
|
+
"--no-session-persistence",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
def _prepare_env(self) -> dict[str, str]:
|
|
174
|
+
"""The caller's environment, without what belongs to the caller's own session.
|
|
175
|
+
|
|
176
|
+
Launched from a Claude Code session, the agent would inherit its variables
|
|
177
|
+
(`CLAUDECODE`, the session id, the effort...), and from Lictor's own virtual environment
|
|
178
|
+
the variables that name it.
|
|
179
|
+
"""
|
|
180
|
+
env = {
|
|
181
|
+
name: value
|
|
182
|
+
for name, value in os.environ.items()
|
|
183
|
+
if not (name.startswith("CLAUDE") and name not in _KEPT_CLAUDE_VARIABLES)
|
|
184
|
+
and name not in _CALLER_VENV_VARIABLES
|
|
185
|
+
}
|
|
186
|
+
env[_NO_AUTO_MEMORY] = "1"
|
|
187
|
+
if self.auth == "subscription":
|
|
188
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
189
|
+
env.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
190
|
+
env["DISABLE_AUTOUPDATER"] = "1"
|
|
191
|
+
return env
|
|
192
|
+
|
|
193
|
+
def _extract_spec_conflict(self, text: str) -> str | None:
|
|
194
|
+
last_conflict: str | None = None
|
|
195
|
+
for line in text.splitlines():
|
|
196
|
+
stripped = line.strip()
|
|
197
|
+
if stripped.startswith("LICTOR: SPEC_CONFLICT"):
|
|
198
|
+
reason = stripped[len("LICTOR: SPEC_CONFLICT") :].strip()
|
|
199
|
+
last_conflict = reason if reason else "SPEC_CONFLICT"
|
|
200
|
+
return last_conflict
|
|
201
|
+
|
|
202
|
+
def _is_auth_failure(self, data: dict[str, Any]) -> bool:
|
|
203
|
+
if data.get("api_error_status") == _HTTP_UNAUTHORIZED:
|
|
204
|
+
return True
|
|
205
|
+
return data.get("terminal_reason") == "api_error" and self._logged_in() is False
|
|
206
|
+
|
|
207
|
+
def _determine_status(
|
|
208
|
+
self,
|
|
209
|
+
data: dict[str, Any],
|
|
210
|
+
rate_limit_status: str | None,
|
|
211
|
+
exit_code: int,
|
|
212
|
+
) -> AgentStatus:
|
|
213
|
+
subtype = str(data.get("subtype", ""))
|
|
214
|
+
is_error = bool(data.get("is_error", False))
|
|
215
|
+
failed = is_error or exit_code != 0 or subtype == "error_during_execution"
|
|
216
|
+
|
|
217
|
+
if subtype == "error_max_turns":
|
|
218
|
+
return "max_turns"
|
|
219
|
+
if subtype == "rate_limited":
|
|
220
|
+
return "rate_limited"
|
|
221
|
+
if failed and data.get("api_error_status") == _HTTP_TOO_MANY_REQUESTS:
|
|
222
|
+
return "rate_limited"
|
|
223
|
+
if failed and rate_limit_status == "rejected":
|
|
224
|
+
return "rate_limited"
|
|
225
|
+
if is_error and self._is_auth_failure(data):
|
|
226
|
+
return "auth"
|
|
227
|
+
return "error" if failed else "ok"
|
|
228
|
+
|
|
229
|
+
def _cost_kind(self) -> CostKind:
|
|
230
|
+
return "notional_subscription" if self.auth == "subscription" else "estimated"
|
|
231
|
+
|
|
232
|
+
def _read_stream(self, stdout: str) -> _Stream:
|
|
233
|
+
"""Read the events of the stream, skipping what isn't one."""
|
|
234
|
+
events: list[dict[str, Any]] = []
|
|
235
|
+
result: dict[str, Any] | None = None
|
|
236
|
+
rate_limit_status: str | None = None
|
|
237
|
+
cli_version = ""
|
|
238
|
+
for line in stdout.splitlines():
|
|
239
|
+
try:
|
|
240
|
+
event = json.loads(line)
|
|
241
|
+
except json.JSONDecodeError:
|
|
242
|
+
continue
|
|
243
|
+
if not isinstance(event, dict):
|
|
244
|
+
continue
|
|
245
|
+
events.append(event)
|
|
246
|
+
kind = event.get("type")
|
|
247
|
+
if kind == "result":
|
|
248
|
+
result = event
|
|
249
|
+
elif kind == "rate_limit_event":
|
|
250
|
+
info = event.get("rate_limit_info")
|
|
251
|
+
if isinstance(info, dict) and isinstance(info.get("status"), str):
|
|
252
|
+
rate_limit_status = info["status"]
|
|
253
|
+
elif kind == "system" and event.get("subtype") == "init":
|
|
254
|
+
cli_version = str(event.get("claude_code_version") or "")
|
|
255
|
+
return _Stream(tuple(events), result, rate_limit_status, cli_version)
|
|
256
|
+
|
|
257
|
+
def _read_denials(self, data: dict[str, Any]) -> tuple[PermissionDenial, ...]:
|
|
258
|
+
denials = data.get("permission_denials")
|
|
259
|
+
if not isinstance(denials, list):
|
|
260
|
+
return ()
|
|
261
|
+
parsed: list[PermissionDenial] = []
|
|
262
|
+
for denial in denials:
|
|
263
|
+
if not isinstance(denial, dict):
|
|
264
|
+
parsed.append(PermissionDenial(tool="", target=str(denial)))
|
|
265
|
+
continue
|
|
266
|
+
tool_input = denial.get("tool_input")
|
|
267
|
+
fields = tool_input if isinstance(tool_input, dict) else {}
|
|
268
|
+
target = fields.get("command") or fields.get("file_path") or ""
|
|
269
|
+
parsed.append(
|
|
270
|
+
PermissionDenial(tool=str(denial.get("tool_name", "")), target=str(target))
|
|
271
|
+
)
|
|
272
|
+
return tuple(parsed)
|
|
273
|
+
|
|
274
|
+
def _parse_agent_data(
|
|
275
|
+
self,
|
|
276
|
+
data: dict[str, Any],
|
|
277
|
+
rate_limit_status: str | None,
|
|
278
|
+
exit_code: int,
|
|
279
|
+
duration_ms: int,
|
|
280
|
+
raw_path: Path,
|
|
281
|
+
) -> AgentResult:
|
|
282
|
+
# error_max_turns has no `result`, only an `errors` list.
|
|
283
|
+
errors = data.get("errors")
|
|
284
|
+
fallback = "; ".join(str(e) for e in errors) if isinstance(errors, list) else ""
|
|
285
|
+
result_text = str(data.get("result") or fallback)
|
|
286
|
+
|
|
287
|
+
status = self._determine_status(data, rate_limit_status, exit_code)
|
|
288
|
+
spec_conflict = self._extract_spec_conflict(result_text)
|
|
289
|
+
|
|
290
|
+
usage = data.get("usage") or {}
|
|
291
|
+
tokens = Tokens(
|
|
292
|
+
input=int(usage.get("input_tokens", 0)),
|
|
293
|
+
output=int(usage.get("output_tokens", 0)),
|
|
294
|
+
cache_read=int(usage.get("cache_read_input_tokens", 0)),
|
|
295
|
+
cache_write=int(usage.get("cache_creation_input_tokens", 0)),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
cost_usd = float(data["total_cost_usd"]) if data.get("total_cost_usd") is not None else None
|
|
299
|
+
reported_duration = data.get("duration_ms")
|
|
300
|
+
effective_duration = (
|
|
301
|
+
int(reported_duration) if reported_duration is not None else duration_ms
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
session_id = data.get("session_id")
|
|
305
|
+
return AgentResult(
|
|
306
|
+
status=status,
|
|
307
|
+
spec_conflict=spec_conflict,
|
|
308
|
+
text=result_text,
|
|
309
|
+
session_id=str(session_id) if session_id is not None else None,
|
|
310
|
+
num_turns=int(data.get("num_turns", 0)),
|
|
311
|
+
duration_ms=effective_duration,
|
|
312
|
+
tokens=tokens,
|
|
313
|
+
cost_usd=cost_usd,
|
|
314
|
+
cost_kind=self._cost_kind(),
|
|
315
|
+
raw_path=raw_path,
|
|
316
|
+
permission_denials=self._read_denials(data),
|
|
317
|
+
exit_code=exit_code,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _save_stderr(self, raw_path: Path, stderr: str) -> Path | None:
|
|
321
|
+
"""Keep stderr beside the raw stdout, which each call already has for itself."""
|
|
322
|
+
if not stderr:
|
|
323
|
+
return None
|
|
324
|
+
stderr_path = raw_path.with_suffix(".stderr.log")
|
|
325
|
+
stderr_path.write_text(stderr, encoding="utf-8", errors="replace")
|
|
326
|
+
return stderr_path
|
|
327
|
+
|
|
328
|
+
def run(self, task: AgentTask) -> AgentResult:
|
|
329
|
+
"""Run claude -p for the given task and map the output to AgentResult."""
|
|
330
|
+
out = self.runner.run(
|
|
331
|
+
self._build_command(task),
|
|
332
|
+
cwd=task.cwd,
|
|
333
|
+
timeout_s=task.timeout_s,
|
|
334
|
+
env=self._prepare_env(),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
task.raw_path.parent.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
# Whatever the call left, even cut short by the timeout, is the evidence of what it did.
|
|
339
|
+
task.raw_path.write_text(out.stdout, encoding="utf-8")
|
|
340
|
+
stderr_path = self._save_stderr(task.raw_path, out.stderr)
|
|
341
|
+
stream = self._read_stream(out.stdout)
|
|
342
|
+
common: dict[str, Any] = {
|
|
343
|
+
"duration_ms": out.duration_ms,
|
|
344
|
+
"cost_kind": self._cost_kind(),
|
|
345
|
+
"raw_path": task.raw_path,
|
|
346
|
+
"stderr_path": stderr_path,
|
|
347
|
+
"cli_version": stream.cli_version,
|
|
348
|
+
}
|
|
349
|
+
if out.timed_out:
|
|
350
|
+
return self._timed_out(task, stream, common)
|
|
351
|
+
|
|
352
|
+
exit_code = out.returncode
|
|
353
|
+
if stream.result is None:
|
|
354
|
+
return AgentResult(
|
|
355
|
+
status="rate_limited" if stream.rate_limit_status == "rejected" else "error",
|
|
356
|
+
text=(
|
|
357
|
+
f"El CLI no emitió el evento result (código de salida {exit_code}). "
|
|
358
|
+
f"Stderr: {out.stderr}\nStdout: {out.stdout}"
|
|
359
|
+
),
|
|
360
|
+
exit_code=exit_code,
|
|
361
|
+
**common,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return replace(
|
|
365
|
+
self._parse_agent_data(
|
|
366
|
+
data=stream.result,
|
|
367
|
+
rate_limit_status=stream.rate_limit_status,
|
|
368
|
+
exit_code=exit_code,
|
|
369
|
+
duration_ms=out.duration_ms,
|
|
370
|
+
raw_path=task.raw_path,
|
|
371
|
+
),
|
|
372
|
+
stderr_path=stderr_path,
|
|
373
|
+
cli_version=stream.cli_version,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
@staticmethod
|
|
377
|
+
def _timed_out(task: AgentTask, stream: _Stream, common: dict[str, Any]) -> AgentResult:
|
|
378
|
+
"""A model that wrote something ran out of time; one that never answered is the backend.
|
|
379
|
+
|
|
380
|
+
A backend that rejected the call for its rate limit before answering is that limit.
|
|
381
|
+
"""
|
|
382
|
+
if model_responded(stream.events):
|
|
383
|
+
return AgentResult(
|
|
384
|
+
status="timeout", text=f"Sin tiempo tras {task.timeout_s} s", **common
|
|
385
|
+
)
|
|
386
|
+
if stream.rate_limit_status == "rejected":
|
|
387
|
+
return AgentResult(
|
|
388
|
+
status="rate_limited",
|
|
389
|
+
text=f"Límite de uso alcanzado; el modelo no respondió en {task.timeout_s} s",
|
|
390
|
+
**common,
|
|
391
|
+
)
|
|
392
|
+
return AgentResult(
|
|
393
|
+
status="error", text=f"El modelo no respondió en {task.timeout_s} s", **common
|
|
394
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Deterministic FakeExecutor adapter for testing without LLM quota."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from lictor.domain.model import (
|
|
10
|
+
AgentResult,
|
|
11
|
+
AgentRole,
|
|
12
|
+
AgentStatus,
|
|
13
|
+
AgentTask,
|
|
14
|
+
Health,
|
|
15
|
+
Tokens,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
FAKE_CLI_VERSION = "fake-1.0.0"
|
|
19
|
+
|
|
20
|
+
FakeMode = Literal["good", "fail", "cheat", "conflict", "rate_limit", "crash"]
|
|
21
|
+
FakeEffect = Callable[[AgentTask, str | None, int], AgentStatus | None]
|
|
22
|
+
_SCENARIO_TAG_RE = re.compile(r"([A-Z][A-Z0-9]*-[0-9]+)")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FakeExecutor:
|
|
26
|
+
"""Deterministic agent test double simulating LLM runs with configured scripts and modes."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
mode: FakeMode = "good",
|
|
31
|
+
*,
|
|
32
|
+
scenario_modes: dict[str, FakeMode] | None = None,
|
|
33
|
+
fixtures_dir: Path | None = None,
|
|
34
|
+
files_to_write: dict[str, dict[str, str]] | None = None,
|
|
35
|
+
crash_on_role: AgentRole | None = None,
|
|
36
|
+
crash_on_scenario: str | None = None,
|
|
37
|
+
tokens_per_call: Tokens | None = None,
|
|
38
|
+
cost_per_call: float = 0.005,
|
|
39
|
+
duration_ms: int = 150,
|
|
40
|
+
effect: FakeEffect | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.default_mode = mode
|
|
43
|
+
self.scenario_modes = scenario_modes or {}
|
|
44
|
+
self.fixtures_dir = Path(fixtures_dir) if fixtures_dir else None
|
|
45
|
+
self.files_to_write = files_to_write or {}
|
|
46
|
+
self.crash_on_role = crash_on_role
|
|
47
|
+
self.crash_on_scenario = crash_on_scenario
|
|
48
|
+
self.tokens_per_call = tokens_per_call or Tokens(
|
|
49
|
+
input=100, output=50, cache_read=200, cache_write=20
|
|
50
|
+
)
|
|
51
|
+
self.cost_per_call = cost_per_call
|
|
52
|
+
self.duration_ms = duration_ms
|
|
53
|
+
self.effect = effect
|
|
54
|
+
self.calls: list[str] = []
|
|
55
|
+
|
|
56
|
+
def health(self) -> Health:
|
|
57
|
+
"""Return healthy fake status."""
|
|
58
|
+
return Health(
|
|
59
|
+
ok=True,
|
|
60
|
+
cli_version=FAKE_CLI_VERSION,
|
|
61
|
+
auth_status="subscription",
|
|
62
|
+
message=None,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _detect_scenario_id(self, prompt: str) -> str | None:
|
|
66
|
+
match = _SCENARIO_TAG_RE.search(prompt)
|
|
67
|
+
return match.group(1) if match else None
|
|
68
|
+
|
|
69
|
+
def _apply_filesystem_effects(
|
|
70
|
+
self,
|
|
71
|
+
mode: FakeMode,
|
|
72
|
+
cwd: Path,
|
|
73
|
+
role: str | None = None,
|
|
74
|
+
scenario_id: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
keys_to_apply: list[str] = [mode]
|
|
77
|
+
if role:
|
|
78
|
+
keys_to_apply.append(f"{mode}:{role}")
|
|
79
|
+
keys_to_apply.append(role)
|
|
80
|
+
if scenario_id:
|
|
81
|
+
keys_to_apply.append(f"{mode}:{scenario_id}")
|
|
82
|
+
keys_to_apply.append(scenario_id)
|
|
83
|
+
|
|
84
|
+
for key in keys_to_apply:
|
|
85
|
+
mode_files = self.files_to_write.get(key, {})
|
|
86
|
+
for rel_path, content in mode_files.items():
|
|
87
|
+
dest = cwd / rel_path
|
|
88
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
dest.write_text(content, encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
if self.fixtures_dir:
|
|
92
|
+
src_mode_dir = self.fixtures_dir / mode
|
|
93
|
+
if src_mode_dir.is_dir():
|
|
94
|
+
for p in src_mode_dir.rglob("*"):
|
|
95
|
+
if p.is_file():
|
|
96
|
+
rel = p.relative_to(src_mode_dir)
|
|
97
|
+
dest = cwd / rel
|
|
98
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
dest.write_bytes(p.read_bytes())
|
|
100
|
+
|
|
101
|
+
def _build_mode_outcome(self, mode: FakeMode) -> tuple[AgentStatus, str | None, str]:
|
|
102
|
+
if mode == "conflict":
|
|
103
|
+
spec_conflict = "Escenario imposible o contradictorio con la especificación"
|
|
104
|
+
return "ok", spec_conflict, f"Analizando...\nLICTOR: SPEC_CONFLICT {spec_conflict}"
|
|
105
|
+
if mode == "rate_limit":
|
|
106
|
+
return "rate_limited", None, "API rate limit reached"
|
|
107
|
+
return "ok", None, "Fake execution completed"
|
|
108
|
+
|
|
109
|
+
def run(self, task: AgentTask) -> AgentResult:
|
|
110
|
+
"""Execute a deterministic fake agent run according to active mode."""
|
|
111
|
+
scenario_id = self._detect_scenario_id(task.prompt) if task.role == "implement" else None
|
|
112
|
+
mode: FakeMode = self.default_mode
|
|
113
|
+
if scenario_id and scenario_id in self.scenario_modes:
|
|
114
|
+
mode = self.scenario_modes[scenario_id]
|
|
115
|
+
|
|
116
|
+
if self.crash_on_role and task.role == self.crash_on_role:
|
|
117
|
+
raise RuntimeError(f"Simulated FakeExecutor crash on role {task.role}")
|
|
118
|
+
|
|
119
|
+
if self.crash_on_scenario and scenario_id and scenario_id == self.crash_on_scenario:
|
|
120
|
+
raise RuntimeError(f"Simulated FakeExecutor crash on scenario {scenario_id}")
|
|
121
|
+
|
|
122
|
+
if mode == "crash":
|
|
123
|
+
raise RuntimeError("Simulated FakeExecutor crash")
|
|
124
|
+
|
|
125
|
+
call_key = scenario_id or task.role
|
|
126
|
+
self.calls.append(call_key)
|
|
127
|
+
self._apply_filesystem_effects(mode, task.cwd, role=task.role, scenario_id=scenario_id)
|
|
128
|
+
status, spec_conflict, result_text = self._build_mode_outcome(mode)
|
|
129
|
+
if self.effect is not None:
|
|
130
|
+
status = self.effect(task, scenario_id, self.calls.count(call_key)) or status
|
|
131
|
+
|
|
132
|
+
tokens = self.tokens_per_call if status != "rate_limited" else Tokens()
|
|
133
|
+
cost = self.cost_per_call if status != "rate_limited" else 0.0
|
|
134
|
+
|
|
135
|
+
raw_data: dict[str, Any] = {
|
|
136
|
+
"mode": mode,
|
|
137
|
+
"status": status,
|
|
138
|
+
"spec_conflict": spec_conflict,
|
|
139
|
+
"result": result_text,
|
|
140
|
+
"num_turns": 1 if status != "rate_limited" else 0,
|
|
141
|
+
"duration_ms": self.duration_ms,
|
|
142
|
+
"usage": {
|
|
143
|
+
"input_tokens": tokens.input,
|
|
144
|
+
"output_tokens": tokens.output,
|
|
145
|
+
"cache_read_input_tokens": tokens.cache_read,
|
|
146
|
+
"cache_creation_input_tokens": tokens.cache_write,
|
|
147
|
+
},
|
|
148
|
+
"total_cost_usd": cost,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
task.raw_path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
task.raw_path.write_text(json.dumps(raw_data, indent=2), encoding="utf-8")
|
|
153
|
+
|
|
154
|
+
return AgentResult(
|
|
155
|
+
status=status,
|
|
156
|
+
spec_conflict=spec_conflict,
|
|
157
|
+
text=result_text,
|
|
158
|
+
session_id="fake-session-id",
|
|
159
|
+
num_turns=1 if status != "rate_limited" else 0,
|
|
160
|
+
duration_ms=self.duration_ms,
|
|
161
|
+
tokens=tokens,
|
|
162
|
+
cost_usd=cost,
|
|
163
|
+
cost_kind="notional_subscription",
|
|
164
|
+
raw_path=task.raw_path,
|
|
165
|
+
cli_version=FAKE_CLI_VERSION,
|
|
166
|
+
)
|
|
File without changes
|