openreflex 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.
openreflex/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Ambient, local execution intelligence."""
2
+
3
+ __version__ = "0.1.0"
openreflex/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,225 @@
1
+ """Simulated benchmark: baseline agent vs. the same agent guided by OpenReflex.
2
+
3
+ What this measures: whether the real engine (hook events in, contexts/alerts out) learns from noisy outcomes
4
+ fast enough to route toward better strategies, surface useful files, and cut failure loops, and how that
5
+ compounds over repeated task families.
6
+
7
+ What this does NOT measure: real-world agent gains. Every environment parameter below (strategy success
8
+ rates, call counts, how much a file hint saves, how often an agent heeds an alert) is an explicit assumption
9
+ in SIMULATION_ASSUMPTIONS. Validate claims with A/B runs on real tasks before quoting them.
10
+ """
11
+
12
+ import random
13
+ import tempfile
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from statistics import mean
17
+
18
+ from .engine import Engine
19
+ from .routing import utility
20
+ from .store import Store
21
+
22
+ SIMULATION_ASSUMPTIONS = {
23
+ "baseline_strategy": "inspect-first 60%, otherwise uniform among the three strategies (no memory)",
24
+ "guided_follow_rate": 0.8,
25
+ "file_hint_search_reduction": "search/read calls drop from 6-9 to 2-3 when the context names relevant files",
26
+ "alert_heed_rate": 0.7,
27
+ "failure_loop": "probability per task is family-specific; an unheeded loop runs 5 extra failing attempts",
28
+ "seconds_per_call": 25,
29
+ "tokens_per_call": {"search": 400, "read": 1500, "edit": 300, "test": 1100},
30
+ }
31
+ STRATEGIES = ("inspect-first", "test-first", "incremental")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Family:
36
+ name: str
37
+ templates: tuple[str, ...]
38
+ success: dict[str, float] # true success probability per strategy
39
+ extra_calls: dict[str, int] # strategy-specific extra work in this area of the codebase
40
+ loop_probability: float
41
+ files: tuple[str, ...]
42
+
43
+
44
+ FAMILIES = (
45
+ Family("auth-expiry", ("Fix the login bug where expired {x} tokens are accepted",
46
+ "Fix expired {x} token still accepted by the login check"),
47
+ {"inspect-first": .55, "test-first": .9, "incremental": .7}, {"inspect-first": 2, "test-first": 1, "incremental": 5},
48
+ .35, ("src/auth/session.py", "tests/test_session.py")),
49
+ Family("webhook-signature", ("Fix failing {x} webhook signature verification",
50
+ "Fix {x} webhook signature check rejecting valid payloads"),
51
+ {"inspect-first": .8, "test-first": .6, "incremental": .65}, {"inspect-first": 0, "test-first": 6, "incremental": 4},
52
+ .3, ("src/payments/webhooks.py",)),
53
+ Family("billing-refactor", ("Refactor the {x} billing module into smaller services",
54
+ "Refactor {x} billing calculations out of the invoice controller"),
55
+ {"inspect-first": .5, "test-first": .6, "incremental": .88}, {"inspect-first": 3, "test-first": 4, "incremental": 2},
56
+ .2, ("src/billing/invoice.py", "src/billing/tax.py", "src/billing/service.py")),
57
+ Family("csv-export", ("Add {x} CSV export to the reports page",
58
+ "Build a {x} CSV export endpoint for monthly reports"),
59
+ {"inspect-first": .85, "test-first": .8, "incremental": .8}, {"inspect-first": 0, "test-first": 3, "incremental": 5},
60
+ .1, ("src/reports/export.py", "src/reports/views.py")),
61
+ Family("parser-tests", ("Increase test coverage for the {x} config parser",
62
+ "Add tests covering {x} edge cases in the config parser"),
63
+ {"inspect-first": .75, "test-first": .9, "incremental": .8}, {"inspect-first": 2, "test-first": 0, "incremental": 3},
64
+ .15, ("src/config/parser.py", "tests/test_parser.py")),
65
+ )
66
+ VARIANTS = ("admin", "mobile", "legacy", "tenant", "guest", "partner", "nightly", "api")
67
+ TOKENS = SIMULATION_ASSUMPTIONS["tokens_per_call"]
68
+
69
+
70
+ def true_utility(family: Family, strategy: str, search_calls: float = 7.5) -> float:
71
+ calls = search_calls + 4 + family.extra_calls[strategy] + family.loop_probability * 2.5
72
+ return utility(family.success[strategy], calls * 25, calls, calls * 800, 0.12, 0, 0.93)
73
+
74
+
75
+ def oracle(family: Family) -> str:
76
+ return max(STRATEGIES, key=lambda s: true_utility(family, s))
77
+
78
+
79
+ class Agent:
80
+ def __init__(self, guided: bool, rng: random.Random, engine: Engine, clock: list[float]):
81
+ self.guided, self.rng, self.engine, self.clock = guided, rng, engine, clock
82
+ self.calls = 0
83
+
84
+ def tick(self, seconds: float = 25):
85
+ self.clock[0] += seconds * self.rng.uniform(0.6, 1.4)
86
+
87
+ def tool(self, session: str, name: str, arguments: dict, success: bool = True, error: str | None = None,
88
+ category: str = "read") -> str | None:
89
+ self.calls += 1
90
+ self.tick()
91
+ tool_id = f"{session}-{self.calls}"
92
+ warning = self.engine.tool_start("sim", session, tool_id, name, arguments)
93
+ self.tick(3)
94
+ alert = self.engine.tool_end("sim", session, tool_id, name, arguments, success, error, TOKENS[category] * 4)
95
+ return alert or warning
96
+
97
+
98
+ def episode(agent: Agent, family: Family, index: int) -> dict:
99
+ rng, session = agent.rng, f"{family.name}-{index}"
100
+ prompt = rng.choice(family.templates).format(x=rng.choice(VARIANTS))
101
+ context = agent.engine.prompt("sim", session, prompt)
102
+ engine_pick = agent.engine.store.get(agent.engine.store.latest("sim", session).recommended_path_id).strategy
103
+
104
+ habit = "inspect-first" if rng.random() < 0.6 else rng.choice(STRATEGIES)
105
+ strategy = engine_pick if agent.guided and rng.random() < SIMULATION_ASSUMPTIONS["guided_follow_rate"] else habit
106
+ hinted = agent.guided and context is not None and any(f in context for f in family.files)
107
+
108
+ start_calls, tokens = agent.calls, 0
109
+ area = family.files[0].rsplit("/", 1)[0]
110
+ for i in range(rng.randint(2, 3) if hinted else rng.randint(6, 9)):
111
+ category = "search" if i % 2 == 0 else "read"
112
+ name, args = ("Grep", {"pattern": f"{family.name}-{i}", "path": area}) if category == "search" else \
113
+ ("Read", {"file_path": family.files[i % len(family.files)]})
114
+ agent.tool(session, name, args, category=category)
115
+ tokens += TOKENS[category]
116
+
117
+ test = ("Bash", {"command": f"pytest tests -k {family.name}"})
118
+ edit = ("Edit", {"file_path": family.files[0], "old_string": "a", "new_string": str(index)})
119
+ succeeded = rng.random() < family.success[strategy]
120
+ failure = f"AssertionError: {family.name} check failed"
121
+
122
+ def run(name_args, category, success=True, error=None):
123
+ nonlocal tokens
124
+ tokens += TOKENS[category]
125
+ return agent.tool(session, *name_args, success=success, error=error, category=category)
126
+
127
+ if strategy == "test-first":
128
+ run(test, "test", False, failure)
129
+ for i in range(family.extra_calls[strategy]):
130
+ run(("Read", {"file_path": family.files[i % len(family.files)], "offset": i * 50}), "read")
131
+ slices = 3 if strategy == "incremental" else 1
132
+ for i in range(slices):
133
+ run(edit, "edit")
134
+ if i < slices - 1:
135
+ run(test, "test")
136
+
137
+ if rng.random() < family.loop_probability:
138
+ for attempt in range(5):
139
+ alert = run(test, "test", False, failure)
140
+ run(edit, "edit")
141
+ if alert and agent.guided and rng.random() < SIMULATION_ASSUMPTIONS["alert_heed_rate"]:
142
+ break
143
+ run(test, "test", succeeded, None if succeeded else failure)
144
+ agent.engine.stop("sim", session)
145
+ agent.tick(120)
146
+
147
+ best = oracle(family)
148
+ return {"family": family.name, "strategy": strategy, "recommended": engine_pick, "oracle": best,
149
+ "success": succeeded, "tool_calls": agent.calls - start_calls, "tokens": tokens,
150
+ "seconds": (agent.calls - start_calls) * SIMULATION_ASSUMPTIONS["seconds_per_call"],
151
+ "regret": round(true_utility(family, best) - true_utility(family, strategy), 4)}
152
+
153
+
154
+ def run_arm(guided: bool, episodes: int, seed: int) -> list[dict]:
155
+ rng = random.Random(seed)
156
+ with tempfile.TemporaryDirectory() as directory:
157
+ clock = [1_700_000_000.0]
158
+ engine = Engine(Path(directory), Store(Path(directory) / "bench.sqlite3"), clock=lambda: clock[0])
159
+ try:
160
+ agent = Agent(guided, rng, engine, clock)
161
+ return [episode(agent, FAMILIES[i % len(FAMILIES)] if i % 3 else rng.choice(FAMILIES), i)
162
+ for i in range(episodes)]
163
+ finally:
164
+ engine.close()
165
+
166
+
167
+ def summarize(results: list[dict]) -> dict:
168
+ quarter = max(1, len(results) // 4)
169
+ second_half = results[len(results) // 2:]
170
+ return {
171
+ "tool_calls": round(mean(r["tool_calls"] for r in results), 2),
172
+ "tokens": round(mean(r["tokens"] for r in results), 1),
173
+ "seconds": round(mean(r["seconds"] for r in results), 1),
174
+ "success_rate": round(mean(r["success"] for r in results), 3),
175
+ "regret_first_quarter": round(mean(r["regret"] for r in results[:quarter]), 4),
176
+ "regret_last_quarter": round(mean(r["regret"] for r in results[-quarter:]), 4),
177
+ "routing_agreement_second_half": round(mean(r["recommended"] == r["oracle"] for r in second_half), 3),
178
+ }
179
+
180
+
181
+ def run_benchmark(episodes: int = 150, seeds: int = 3) -> dict:
182
+ per_seed = []
183
+ for seed in range(seeds):
184
+ per_seed.append({"seed": seed, "baseline": summarize(run_arm(False, episodes, seed)),
185
+ "guided": summarize(run_arm(True, episodes, seed))})
186
+ keys = per_seed[0]["baseline"].keys()
187
+ baseline = {k: round(mean(s["baseline"][k] for s in per_seed), 4) for k in keys}
188
+ guided = {k: round(mean(s["guided"][k] for s in per_seed), 4) for k in keys}
189
+
190
+ def change(key):
191
+ return round((guided[key] - baseline[key]) / baseline[key], 3) if baseline[key] else None
192
+
193
+ return {
194
+ "kind": "simulation",
195
+ "episodes_per_arm": episodes, "seeds": seeds,
196
+ "assumptions": SIMULATION_ASSUMPTIONS,
197
+ "oracle_best_strategy": {f.name: oracle(f) for f in FAMILIES},
198
+ "baseline": baseline, "guided": guided,
199
+ "relative_change": {"tool_calls": change("tool_calls"), "tokens": change("tokens"), "time": change("seconds"),
200
+ "success_rate_points": round(guided["success_rate"] - baseline["success_rate"], 3)},
201
+ "targets": {"tool_calls": -0.20, "tokens": -0.20, "time": -0.15, "routing_agreement": 0.70},
202
+ "per_seed": per_seed,
203
+ }
204
+
205
+
206
+ def format_report(report: dict) -> str:
207
+ b, g, c = report["baseline"], report["guided"], report["relative_change"]
208
+ rows = [
209
+ ("tool calls / task", b["tool_calls"], g["tool_calls"], f"{c['tool_calls']:+.0%}", "-20..-30%"),
210
+ ("output tokens / task", b["tokens"], g["tokens"], f"{c['tokens']:+.0%}", "-20%"),
211
+ ("time / task (s)", b["seconds"], g["seconds"], f"{c['time']:+.0%}", "-15%"),
212
+ ("success rate", b["success_rate"], g["success_rate"], f"{c['success_rate_points']:+.3f} pts", ">= baseline"),
213
+ ("regret, first quarter", b["regret_first_quarter"], g["regret_first_quarter"], "", ""),
214
+ ("regret, last quarter", b["regret_last_quarter"], g["regret_last_quarter"], "", "declining"),
215
+ ("routing agreement (2nd half)", "-", g["routing_agreement_second_half"], "", "> 0.70"),
216
+ ]
217
+ lines = [f"SIMULATED benchmark - {report['episodes_per_arm']} tasks/arm x {report['seeds']} seeds "
218
+ "(assumption-driven; not evidence of real-world gains)", ""]
219
+ lines.append(f"{'metric':30} {'baseline':>10} {'guided':>10} {'change':>12} target")
220
+ def cell(value):
221
+ return f"{value:,.3f}".rstrip("0").rstrip(".") if isinstance(value, float) else str(value)
222
+
223
+ for name, base, guide, delta, target in rows:
224
+ lines.append(f"{name:30} {cell(base):>10} {cell(guide):>10} {delta:>12} {target}")
225
+ return "\n".join(lines)
openreflex/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ import argparse
2
+ import json
3
+ import shutil
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .project import approval, approve, home, project_root, revoke
10
+
11
+ AGENT_CHOICES = ["claude-code", "codex", "cursor", "opencode"]
12
+
13
+
14
+ def _project(value: str | None) -> Path:
15
+ # An unexpanded ${VAR} placeholder from an agent config means "not provided".
16
+ return project_root(None if not value or "${" in value else value)
17
+
18
+
19
+ def _utf8_stdio() -> None:
20
+ for stream in (sys.stdout, sys.stderr):
21
+ try:
22
+ stream.reconfigure(encoding="utf-8", errors="replace")
23
+ except (AttributeError, ValueError):
24
+ pass
25
+
26
+
27
+ def cmd_hook(args) -> int:
28
+ from .hooks import safe_handle
29
+
30
+ raw = sys.stdin.buffer.read().decode("utf-8", errors="replace")
31
+ output = safe_handle(args.agent, args.event or "", raw)
32
+ if output:
33
+ sys.stdout.write(output)
34
+ sys.stdout.flush()
35
+ return 0 # never block the agent
36
+
37
+
38
+ def cmd_mcp(args) -> int:
39
+ from .mcp_server import serve
40
+
41
+ serve(None if not args.project or "${" in args.project else args.project)
42
+ return 0
43
+
44
+
45
+ def cmd_approve(args) -> int:
46
+ project = _project(args.project)
47
+ record = approve(project)
48
+ print(f"OpenReflex enabled for {project} (since {time.strftime('%Y-%m-%d %H:%M', time.localtime(record['approved_at']))}).")
49
+ return 0
50
+
51
+
52
+ def cmd_revoke(args) -> int:
53
+ project = _project(args.project)
54
+ print(f"Capture disabled for {project}." if revoke(project) else f"{project} was not enabled.")
55
+ return 0
56
+
57
+
58
+ def cmd_install(args) -> int:
59
+ from .install import install
60
+
61
+ project = _project(args.project)
62
+ changes = install(args.agent, project, dry_run=args.dry_run)
63
+ verb = "Would write" if args.dry_run else "Wrote"
64
+ for change in changes:
65
+ print(f"{verb} {change}")
66
+ if not changes:
67
+ print("Already installed; nothing to change.")
68
+ if not args.dry_run:
69
+ print(f"Enabled for {project}.")
70
+ if args.agent == "codex":
71
+ print("Codex asks you to trust new hooks once: open /hooks in Codex and approve the OpenReflex entries.")
72
+ if shutil.which("openreflex") is None:
73
+ print("Warning: `openreflex` is not on PATH; hooks will not run. Install with `uv tool install .` or `pipx install .`.")
74
+ return 0
75
+
76
+
77
+ def cmd_status(args) -> int:
78
+ from .engine import Engine
79
+ from .metrics import project_metrics
80
+
81
+ project = _project(args.project)
82
+ record = approval(project)
83
+ engine = Engine(project)
84
+ try:
85
+ data = project_metrics(engine, record)
86
+ finally:
87
+ engine.close()
88
+ data["enabled"] = record is not None
89
+ if args.json:
90
+ print(json.dumps(data, indent=2))
91
+ return 0
92
+ reuse, regret, routing = data["experience_reuse"], data["execution_regret"], data["routing"]
93
+ eff = data["efficiency_observational"]
94
+ print(f"OpenReflex {__version__} - {project}")
95
+ print(f" enabled: {data['enabled']} agents: {', '.join(data['engagement']['agents']) or '-'}")
96
+ print(f" tasks: {data['engagement']['tasks']} experiences: {data['engagement']['experiences']} lessons: {data['lessons']}")
97
+ print(f" first session captured: {data['activation']['first_session_captured']} "
98
+ f"seconds to first task: {data['activation']['seconds_to_first_task']}")
99
+ print(f" tasks benefiting from prior experience: {reuse['benefit_rate']}")
100
+ print(f" success rate (known outcomes): {data['outcomes']['success_rate']} verified: {data['outcomes']['verified']}")
101
+ print(f" tool calls with vs without prior experience: {eff['with_prior_experience']['tool_calls']} vs "
102
+ f"{eff['without_prior_experience']['tool_calls']} (observational)")
103
+ print(f" mean execution regret: {regret['mean']} routing agreement: {routing['agreement']}")
104
+ print(f" live alerts: {data['live_alerts'] or '-'}")
105
+ return 0
106
+
107
+
108
+ def cmd_context(args) -> int:
109
+ from .engine import Engine
110
+
111
+ engine = Engine(_project(args.project))
112
+ try:
113
+ print(engine.preview(args.task))
114
+ finally:
115
+ engine.close()
116
+ return 0
117
+
118
+
119
+ def cmd_doctor(args) -> int:
120
+ from .store import database_path
121
+
122
+ project = _project(args.project)
123
+ checks = [
124
+ ("openreflex on PATH", shutil.which("openreflex") is not None),
125
+ ("project enabled", approval(project) is not None),
126
+ ("database exists", database_path(project).exists()),
127
+ ("Claude Code hooks (project)", "openreflex hook" in _read(project / ".claude" / "settings.json")),
128
+ ("Codex hooks (project)", "openreflex hook" in _read(project / ".codex" / "hooks.json")),
129
+ ("Cursor hooks (project)", "openreflex hook" in _read(project / ".cursor" / "hooks.json")),
130
+ ("OpenCode plugin (project)", (project / ".opencode" / "plugins" / "openreflex.ts").exists()),
131
+ ]
132
+ print(f"Project: {project}\nData: {database_path(project)}")
133
+ for name, ok in checks:
134
+ print(f" [{'ok' if ok else '--'}] {name}")
135
+ log = home() / "logs" / "errors.log"
136
+ if log.exists():
137
+ tail = log.read_text(encoding="utf-8", errors="replace").splitlines()[-5:]
138
+ print("Recent hook errors:\n " + "\n ".join(tail))
139
+ return 0
140
+
141
+
142
+ def _read(path: Path) -> str:
143
+ try:
144
+ return path.read_text(encoding="utf-8")
145
+ except OSError:
146
+ return ""
147
+
148
+
149
+ def cmd_forget(args) -> int:
150
+ from .store import database_path
151
+
152
+ project = _project(args.project)
153
+ directory = database_path(project).parent
154
+ if not args.yes:
155
+ print(f"This deletes all OpenReflex data for {project} ({directory}). Re-run with --yes to confirm.")
156
+ return 1
157
+ shutil.rmtree(directory, ignore_errors=True)
158
+ print(f"Deleted {directory}.")
159
+ return 0
160
+
161
+
162
+ def cmd_benchmark(args) -> int:
163
+ from .benchmark import format_report, run_benchmark
164
+
165
+ report = run_benchmark(episodes=args.episodes, seeds=args.seeds)
166
+ print(format_report(report))
167
+ if args.out:
168
+ Path(args.out).write_text(json.dumps(report, indent=2), encoding="utf-8")
169
+ print(f"\nWrote {args.out}")
170
+ return 0
171
+
172
+
173
+ def build_parser() -> argparse.ArgumentParser:
174
+ parser = argparse.ArgumentParser(prog="openreflex", description="Ambient execution intelligence for coding agents.")
175
+ parser.add_argument("--version", action="version", version=__version__)
176
+ sub = parser.add_subparsers(dest="command", required=True)
177
+
178
+ hook = sub.add_parser("hook", help="Handle a lifecycle hook (JSON on stdin); used by agent configs")
179
+ # No `choices`: argparse exits 2 on bad input, and exit code 2 means "block" to Claude Code and Codex.
180
+ hook.add_argument("agent", help=", ".join(AGENT_CHOICES))
181
+ hook.add_argument("event", nargs="?")
182
+ hook.set_defaults(func=cmd_hook)
183
+
184
+ mcp = sub.add_parser("mcp", help="Run the MCP stdio server")
185
+ mcp.add_argument("--project")
186
+ mcp.set_defaults(func=cmd_mcp)
187
+
188
+ for name, func, text in (("approve", cmd_approve, "Enable capture for a project"),
189
+ ("revoke", cmd_revoke, "Disable capture for a project"),
190
+ ("doctor", cmd_doctor, "Check installation and recent hook errors")):
191
+ command = sub.add_parser(name, help=text)
192
+ command.add_argument("--project")
193
+ command.set_defaults(func=func)
194
+
195
+ install = sub.add_parser("install", help="Write project hook + MCP config for an agent and enable the project")
196
+ install.add_argument("agent", choices=AGENT_CHOICES)
197
+ install.add_argument("--project")
198
+ install.add_argument("--dry-run", action="store_true")
199
+ install.set_defaults(func=cmd_install)
200
+
201
+ status = sub.add_parser("status", help="Show capture, reuse, regret, and routing metrics")
202
+ status.add_argument("--project")
203
+ status.add_argument("--json", action="store_true")
204
+ status.set_defaults(func=cmd_status)
205
+
206
+ context = sub.add_parser("context", help="Preview the Execution Context for a task description")
207
+ context.add_argument("task")
208
+ context.add_argument("--project")
209
+ context.set_defaults(func=cmd_context)
210
+
211
+ forget = sub.add_parser("forget", help="Delete all captured data for a project")
212
+ forget.add_argument("--project")
213
+ forget.add_argument("--yes", action="store_true")
214
+ forget.set_defaults(func=cmd_forget)
215
+
216
+ bench = sub.add_parser("benchmark", help="Run the simulated baseline-vs-guided benchmark suite")
217
+ bench.add_argument("--episodes", type=int, default=150)
218
+ bench.add_argument("--seeds", type=int, default=3)
219
+ bench.add_argument("--out", default="benchmark-results.json")
220
+ bench.set_defaults(func=cmd_benchmark)
221
+ return parser
222
+
223
+
224
+ def main(argv: list[str] | None = None) -> int:
225
+ _utf8_stdio()
226
+ argv = sys.argv[1:] if argv is None else argv
227
+ if argv[:1] == ["hook"]:
228
+ # Hook invocations must never fail loudly or return a blocking exit code, whatever the arguments.
229
+ try:
230
+ return cmd_hook(argparse.Namespace(agent=argv[1] if len(argv) > 1 else "", event=argv[2] if len(argv) > 2 else ""))
231
+ except BaseException: # noqa: BLE001
232
+ return 0
233
+ args = build_parser().parse_args(argv)
234
+ return args.func(args)
235
+
236
+
237
+ if __name__ == "__main__":
238
+ sys.exit(main())
openreflex/detect.py ADDED
@@ -0,0 +1,82 @@
1
+ """Live inefficiency detection. Pure functions over the current execution's minimized telemetry.
2
+
3
+ Alerts are deliberately conservative: each kind fires at most once per execution (context growth may
4
+ escalate once more after compaction), and any alert respects a cooldown, so the agent is only
5
+ interrupted when replanning is justified by evidence rather than by a single slow step.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from .models import CandidatePath, Execution, ToolCall
11
+ from .privacy import PROGRESS
12
+
13
+ COOLDOWN_SECONDS = 180
14
+ REPEAT_THRESHOLD = 3
15
+ FAILURE_LOOP_THRESHOLD = 3
16
+ STAGNATION_CALLS = 15
17
+ STAGNATION_SECONDS = 600
18
+ CONTEXT_FLOOR_TOKENS = 30000
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Alert:
23
+ kind: str
24
+ severity: float
25
+ detail: str
26
+
27
+
28
+ def detect(execution: Execution, calls: list[ToolCall], recommended: CandidatePath | None, now: float) -> list[Alert]:
29
+ alerts: list[Alert] = []
30
+ finished = [c for c in calls if c.status != "running"]
31
+
32
+ recent = calls[-10:]
33
+ counts: dict[str, int] = {}
34
+ for call in recent:
35
+ counts[call.fingerprint] = counts.get(call.fingerprint, 0) + 1
36
+ repeated = max(counts.values(), default=0)
37
+ if repeated >= REPEAT_THRESHOLD:
38
+ name = next(c.name for c in reversed(recent) if counts[c.fingerprint] == repeated)
39
+ alerts.append(Alert("repeated_action", 0.6, f"the same {name} call ran {repeated}x in the last {len(recent)} calls"))
40
+
41
+ streak = 0
42
+ for call in reversed(finished):
43
+ if call.status != "failure":
44
+ break
45
+ streak += 1
46
+ same_signature = [c.error_signature for c in finished[-5:] if c.status == "failure" and c.error_signature]
47
+ top_signature = max(set(same_signature), key=same_signature.count, default=None)
48
+ if streak >= FAILURE_LOOP_THRESHOLD:
49
+ alerts.append(Alert("failure_loop", 0.9, f"{streak} consecutive tool failures"))
50
+ elif top_signature and same_signature.count(top_signature) >= FAILURE_LOOP_THRESHOLD:
51
+ alerts.append(Alert("failure_loop", 0.8, f"\"{top_signature}\" recurred {same_signature.count(top_signature)}x in 5 calls"))
52
+
53
+ since_progress = [c for c in calls if c.started_at > execution.last_progress_at]
54
+ idle = now - execution.last_progress_at
55
+ # Exploration and Q&A legitimately read without editing; only judge stagnation once implementation has
56
+ # begun (an edit or check was attempted), or when reading has gone on far longer than any plan needs.
57
+ implementing = any(c.category in PROGRESS for c in calls)
58
+ threshold = STAGNATION_CALLS if implementing else 2 * STAGNATION_CALLS
59
+ if len(since_progress) >= threshold or (implementing and idle >= STAGNATION_SECONDS and len(since_progress) >= 5):
60
+ kinds = sorted({c.category for c in since_progress})
61
+ alerts.append(Alert("stagnation", 0.7, f"{len(since_progress)} calls over {idle / 60:.0f} min without a "
62
+ f"successful edit or check (only {', '.join(kinds)})"))
63
+
64
+ budget = max(CONTEXT_FLOOR_TOKENS, 2 * (recommended.context_tokens if recommended else 8000))
65
+ if execution.output_tokens_estimate > budget or execution.compactions:
66
+ reason = "context was compacted" if execution.compactions else \
67
+ f"~{execution.output_tokens_estimate // 1000}k tokens of tool output (budget ~{int(budget) // 1000}k)"
68
+ alerts.append(Alert("context_growth", 0.5, reason))
69
+
70
+ if recommended and len(calls) > max(30, 2.5 * recommended.tool_calls) and not any(
71
+ c.category in PROGRESS and c.status == "success" for c in calls[-8:]):
72
+ alerts.append(Alert("over_budget", 0.5, f"{len(calls)} tool calls vs ~{recommended.tool_calls:.0f} expected"))
73
+ return alerts
74
+
75
+
76
+ def select(execution: Execution, alerts: list[Alert], now: float) -> Alert | None:
77
+ """Choose at most one alert that is new for this execution and outside the cooldown."""
78
+ if now - execution.last_alert_at < COOLDOWN_SECONDS:
79
+ return None
80
+ fresh = [a for a in alerts if a.kind not in execution.alerts or
81
+ (a.kind == "context_growth" and execution.alerts.count(a.kind) < 1 + min(execution.compactions, 1))]
82
+ return max(fresh, key=lambda a: a.severity, default=None)