openreflex 0.2.0__tar.gz → 0.3.1__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.
Files changed (45) hide show
  1. {openreflex-0.2.0/src/openreflex.egg-info → openreflex-0.3.1}/PKG-INFO +25 -4
  2. {openreflex-0.2.0 → openreflex-0.3.1}/README.md +24 -3
  3. {openreflex-0.2.0 → openreflex-0.3.1}/pyproject.toml +2 -2
  4. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/__init__.py +1 -1
  5. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/cli.py +120 -21
  6. openreflex-0.3.1/src/openreflex/control.py +108 -0
  7. openreflex-0.3.1/src/openreflex/decision.py +221 -0
  8. openreflex-0.3.1/src/openreflex/detect.py +91 -0
  9. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/engine.py +200 -80
  10. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/hooks.py +20 -24
  11. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/install.py +65 -1
  12. openreflex-0.3.1/src/openreflex/mcp_server.py +355 -0
  13. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/models.py +4 -2
  14. openreflex-0.3.1/src/openreflex/policies/default.toml +165 -0
  15. openreflex-0.3.1/src/openreflex/policy.py +144 -0
  16. openreflex-0.3.1/src/openreflex/project.py +154 -0
  17. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/routing.py +81 -67
  18. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/store.py +5 -0
  19. {openreflex-0.2.0 → openreflex-0.3.1/src/openreflex.egg-info}/PKG-INFO +25 -4
  20. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/SOURCES.txt +5 -0
  21. openreflex-0.3.1/tests/test_mcp_metrics_benchmark.py +91 -0
  22. openreflex-0.3.1/tests/test_policy_recap.py +149 -0
  23. openreflex-0.3.1/tests/test_reliability_lifecycle.py +108 -0
  24. openreflex-0.2.0/src/openreflex/control.py +0 -119
  25. openreflex-0.2.0/src/openreflex/detect.py +0 -84
  26. openreflex-0.2.0/src/openreflex/mcp_server.py +0 -149
  27. openreflex-0.2.0/src/openreflex/project.py +0 -104
  28. openreflex-0.2.0/tests/test_mcp_metrics_benchmark.py +0 -56
  29. {openreflex-0.2.0 → openreflex-0.3.1}/LICENSE +0 -0
  30. {openreflex-0.2.0 → openreflex-0.3.1}/setup.cfg +0 -0
  31. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/__main__.py +0 -0
  32. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/benchmark.py +0 -0
  33. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/integrations/opencode.ts +0 -0
  34. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/learning.py +0 -0
  35. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/metrics.py +0 -0
  36. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/privacy.py +0 -0
  37. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/dependency_links.txt +0 -0
  38. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/entry_points.txt +0 -0
  39. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/requires.txt +0 -0
  40. {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/top_level.txt +0 -0
  41. {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_engine.py +0 -0
  42. {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_hooks.py +0 -0
  43. {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_install_cli.py +0 -0
  44. {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_routing_control.py +0 -0
  45. {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_stress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreflex
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Muscle memory for AI coding agents: learns from every task, advises the next one, and flags failure loops live
5
5
  Author: vishnu-77
6
6
  License-Expression: MIT
@@ -128,6 +128,17 @@ OpenReflex installs per project with `openreflex install <agent>`, or as a plugi
128
128
  With a plugin install, enable each project with `openreflex approve`. Claude Code and Codex are verified in live
129
129
  sessions; the Cursor and OpenCode integrations follow each agent's documented hook protocol.
130
130
 
131
+ ## See it work
132
+
133
+ A real Claude Code session in a repository where OpenReflex has seen one similar task before. The
134
+ `UserPromptSubmit` line shows the past experience found and the context added; the `Stop` line shows the verified
135
+ outcome, the path taken and the cost.
136
+
137
+ ![Claude Code session with OpenReflex](https://raw.githubusercontent.com/vishnu-77/openreflex/main/docs/screenshots/claude-code-session-2.png)
138
+
139
+ The full walkthrough, with both sessions, the exact context the model received and the CLI output, is in
140
+ [docs/walkthrough.md](docs/walkthrough.md).
141
+
131
142
  ## How it works
132
143
 
133
144
  Hooks send lifecycle events (prompt, tool start, tool end, compaction, stop) to the OpenReflex engine, which
@@ -157,21 +168,31 @@ Execution -caused-> Outcome -caused-> Experience -caused-> Lesson
157
168
  best plausible alternative; it is withheld when the outcome is unknown, and feeds back into how strategies
158
169
  are ranked next time, along with success, cost, and how often a strategy ran into trouble.
159
170
 
171
+ Every recommendation is stored as a decision snapshot with a **Reflex Score** (0-100): how strong the
172
+ recommendation is, which is separate from the estimated chance that the task succeeds. `openreflex why` explains
173
+ the latest decision and `openreflex trace` shows the timeline. In Claude Code, a short recap appears when a task
174
+ starts, when OpenReflex recommends a pivot or stop or detects trouble, and when the task completes.
175
+
160
176
  Set optional limits for every task with `OPENREFLEX_BUDGET`, for example `calls=40,minutes=20,tokens=60000`.
177
+ Routing, scoring, budget and recap settings come from a versioned policy: the packaged defaults, overridden by
178
+ `~/.openreflex/config.toml` and then by `.openreflex.toml` in the project.
161
179
 
162
180
  Agents can also query OpenReflex directly through its MCP server: `get_execution_context` (optionally with
163
- `max_tool_calls`, `max_minutes`, `max_context_tokens`), `choose_path`, `check_progress`, `record_outcome`,
164
- `search_experience`, `explain_node`, `project_insights`, and `approve_project`.
181
+ `max_tool_calls`, `max_minutes`, `max_context_tokens`), `choose_path`, `check_progress`, `explain_decision`,
182
+ `get_execution_trace`, `get_reflex_score`, `record_outcome`, `search_experience`, `explain_node`,
183
+ `get_project_insights`, `approve_project`, and `forget_experience` (deletes one past task's memory, only when the user asks).
165
184
 
166
185
  ## CLI
167
186
 
168
187
  | Command | Purpose |
169
188
  |---|---|
170
189
  | `install <agent> [--dry-run]` | Write project hooks and MCP config, and enable the project |
190
+ | `uninstall <agent> [--dry-run]` | Remove OpenReflex's project hooks and MCP config; captured memory is kept |
171
191
  | `approve` / `revoke` | Enable or disable capture for the current project |
172
192
  | `context "<task>"` | Preview the Execution Context a task would receive |
173
193
  | `status [--json]` | What has been captured, reused, and learned |
174
- | `doctor` | Installation checks and recent hook errors |
194
+ | `why` / `trace` | Explain the latest recommendation, or show the decision timeline |
195
+ | `doctor` | Installation, project resolution, and recent hook activity checks |
175
196
  | `forget --yes` | Delete the project's data |
176
197
  | `benchmark` | Run the simulated benchmark |
177
198
  | `hook <agent> <event>` / `mcp` | Used by agent configs |
@@ -96,6 +96,17 @@ OpenReflex installs per project with `openreflex install <agent>`, or as a plugi
96
96
  With a plugin install, enable each project with `openreflex approve`. Claude Code and Codex are verified in live
97
97
  sessions; the Cursor and OpenCode integrations follow each agent's documented hook protocol.
98
98
 
99
+ ## See it work
100
+
101
+ A real Claude Code session in a repository where OpenReflex has seen one similar task before. The
102
+ `UserPromptSubmit` line shows the past experience found and the context added; the `Stop` line shows the verified
103
+ outcome, the path taken and the cost.
104
+
105
+ ![Claude Code session with OpenReflex](https://raw.githubusercontent.com/vishnu-77/openreflex/main/docs/screenshots/claude-code-session-2.png)
106
+
107
+ The full walkthrough, with both sessions, the exact context the model received and the CLI output, is in
108
+ [docs/walkthrough.md](docs/walkthrough.md).
109
+
99
110
  ## How it works
100
111
 
101
112
  Hooks send lifecycle events (prompt, tool start, tool end, compaction, stop) to the OpenReflex engine, which
@@ -125,21 +136,31 @@ Execution -caused-> Outcome -caused-> Experience -caused-> Lesson
125
136
  best plausible alternative; it is withheld when the outcome is unknown, and feeds back into how strategies
126
137
  are ranked next time, along with success, cost, and how often a strategy ran into trouble.
127
138
 
139
+ Every recommendation is stored as a decision snapshot with a **Reflex Score** (0-100): how strong the
140
+ recommendation is, which is separate from the estimated chance that the task succeeds. `openreflex why` explains
141
+ the latest decision and `openreflex trace` shows the timeline. In Claude Code, a short recap appears when a task
142
+ starts, when OpenReflex recommends a pivot or stop or detects trouble, and when the task completes.
143
+
128
144
  Set optional limits for every task with `OPENREFLEX_BUDGET`, for example `calls=40,minutes=20,tokens=60000`.
145
+ Routing, scoring, budget and recap settings come from a versioned policy: the packaged defaults, overridden by
146
+ `~/.openreflex/config.toml` and then by `.openreflex.toml` in the project.
129
147
 
130
148
  Agents can also query OpenReflex directly through its MCP server: `get_execution_context` (optionally with
131
- `max_tool_calls`, `max_minutes`, `max_context_tokens`), `choose_path`, `check_progress`, `record_outcome`,
132
- `search_experience`, `explain_node`, `project_insights`, and `approve_project`.
149
+ `max_tool_calls`, `max_minutes`, `max_context_tokens`), `choose_path`, `check_progress`, `explain_decision`,
150
+ `get_execution_trace`, `get_reflex_score`, `record_outcome`, `search_experience`, `explain_node`,
151
+ `get_project_insights`, `approve_project`, and `forget_experience` (deletes one past task's memory, only when the user asks).
133
152
 
134
153
  ## CLI
135
154
 
136
155
  | Command | Purpose |
137
156
  |---|---|
138
157
  | `install <agent> [--dry-run]` | Write project hooks and MCP config, and enable the project |
158
+ | `uninstall <agent> [--dry-run]` | Remove OpenReflex's project hooks and MCP config; captured memory is kept |
139
159
  | `approve` / `revoke` | Enable or disable capture for the current project |
140
160
  | `context "<task>"` | Preview the Execution Context a task would receive |
141
161
  | `status [--json]` | What has been captured, reused, and learned |
142
- | `doctor` | Installation checks and recent hook errors |
162
+ | `why` / `trace` | Explain the latest recommendation, or show the decision timeline |
163
+ | `doctor` | Installation, project resolution, and recent hook activity checks |
143
164
  | `forget --yes` | Delete the project's data |
144
165
  | `benchmark` | Run the simulated benchmark |
145
166
  | `hook <agent> <event>` / `mcp` | Used by agent configs |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "openreflex"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Muscle memory for AI coding agents: learns from every task, advises the next one, and flags failure loops live"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -41,7 +41,7 @@ openreflex = "openreflex.cli:main"
41
41
  where = ["src"]
42
42
 
43
43
  [tool.setuptools.package-data]
44
- openreflex = ["integrations/*.ts"]
44
+ openreflex = ["integrations/*.ts", "policies/*.toml"]
45
45
 
46
46
  [tool.pytest.ini_options]
47
47
  testpaths = ["tests"]
@@ -1,3 +1,3 @@
1
1
  """Ambient, local execution intelligence."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
@@ -6,13 +6,12 @@ import time
6
6
  from pathlib import Path
7
7
 
8
8
  from . import __version__
9
- from .project import approval, approve, home, project_root, revoke
9
+ from .project import approval, approve, home, project_resolution, project_root, revoke
10
10
 
11
11
  AGENT_CHOICES = ["claude-code", "codex", "cursor", "opencode"]
12
12
 
13
13
 
14
14
  def _project(value: str | None) -> Path:
15
- # An unexpanded ${VAR} placeholder from an agent config means "not provided".
16
15
  return project_root(None if not value or "${" in value else value)
17
16
 
18
17
 
@@ -32,7 +31,7 @@ def cmd_hook(args) -> int:
32
31
  if output:
33
32
  sys.stdout.write(output)
34
33
  sys.stdout.flush()
35
- return 0 # never block the agent
34
+ return 0
36
35
 
37
36
 
38
37
  def cmd_mcp(args) -> int:
@@ -60,17 +59,46 @@ def cmd_install(args) -> int:
60
59
 
61
60
  project = _project(args.project)
62
61
  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.")
62
+ verb = "would update" if args.dry_run else "updated"
63
+ print("OPENREFLEX / CONNECT")
64
+ print(f" agent {args.agent}")
65
+ print(f" project {project}")
66
+ if changes:
67
+ print(f" config {verb} {len(changes)} file{'s' if len(changes) != 1 else ''}")
68
+ for change in changes:
69
+ print(f" {change}")
70
+ else:
71
+ print(" config already connected")
68
72
  if not args.dry_run:
69
- print(f"Enabled for {project}.")
73
+ print(" memory enabled")
74
+ print(" storage local")
75
+ print("\n [ok] reflex active")
70
76
  if args.agent == "codex":
71
- print("Codex asks you to trust new hooks once: open /hooks in Codex and approve the OpenReflex entries.")
77
+ print("\nCodex requires one trust step: open /hooks and approve the OpenReflex entries.")
72
78
  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 .`.")
79
+ print("\nWarning: `openreflex` is not on PATH; hooks will not run. Install with `uv tool install .` or `pipx install .`.")
80
+ return 0
81
+
82
+
83
+ def cmd_uninstall(args) -> int:
84
+ from .install import uninstall
85
+
86
+ project = _project(args.project)
87
+ changes = uninstall(args.agent, project, dry_run=args.dry_run)
88
+ verb = "would remove" if args.dry_run else "removed"
89
+ print("OPENREFLEX / DISCONNECT")
90
+ print(f" agent {args.agent}")
91
+ print(f" project {project}")
92
+ if changes:
93
+ print(f" config {verb} OpenReflex entries from {len(changes)} file{'s' if len(changes) != 1 else ''}")
94
+ for change in changes:
95
+ print(f" {change}")
96
+ else:
97
+ print(" config no project integration found")
98
+ print(" memory preserved")
99
+ if not args.dry_run:
100
+ print("\n [ok] disconnected")
101
+ print("\nRun `openreflex forget --yes` separately if you also want to delete local experience data.")
74
102
  return 0
75
103
 
76
104
 
@@ -118,22 +146,91 @@ def cmd_context(args) -> int:
118
146
  return 0
119
147
 
120
148
 
149
+ def cmd_why(args) -> int:
150
+ from .engine import Engine
151
+
152
+ engine = Engine(_project(args.project))
153
+ try:
154
+ print(engine.why())
155
+ finally:
156
+ engine.close()
157
+ return 0
158
+
159
+
160
+ def cmd_trace(args) -> int:
161
+ from .engine import Engine
162
+
163
+ engine = Engine(_project(args.project))
164
+ try:
165
+ print(engine.trace())
166
+ finally:
167
+ engine.close()
168
+ return 0
169
+
170
+
171
+ def _json(path: Path) -> dict:
172
+ try:
173
+ value = json.loads(path.read_text(encoding="utf-8"))
174
+ return value if isinstance(value, dict) else {}
175
+ except (OSError, ValueError):
176
+ return {}
177
+
178
+
179
+ def _claude_plugin_enabled() -> bool:
180
+ settings = _json(Path.home() / ".claude" / "settings.json")
181
+ enabled = settings.get("enabledPlugins")
182
+ if isinstance(enabled, dict):
183
+ return any("openreflex" in str(name).lower() and value is not False for name, value in enabled.items())
184
+ if isinstance(enabled, list):
185
+ return any("openreflex" in str(name).lower() for name in enabled)
186
+ return False
187
+
188
+
189
+ def _recent_hook_trace(project: Path) -> str | None:
190
+ path = home() / "logs" / "hooks.log"
191
+ try:
192
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[-100:]
193
+ except OSError:
194
+ return None
195
+ target = str(project)
196
+ for line in reversed(lines):
197
+ if target in line:
198
+ return line
199
+ return lines[-1] if lines else None
200
+
201
+
121
202
  def cmd_doctor(args) -> int:
122
203
  from .store import database_path
123
204
 
124
205
  project = _project(args.project)
206
+ resolution = project_resolution(args.project)
207
+ claude_project = "openreflex hook" in _read(project / ".claude" / "settings.json")
208
+ claude_plugin = _claude_plugin_enabled()
209
+ trace = _recent_hook_trace(project)
125
210
  checks = [
126
211
  ("openreflex on PATH", shutil.which("openreflex") is not None),
127
212
  ("project enabled", approval(project) is not None),
128
213
  ("database exists", database_path(project).exists()),
129
- ("Claude Code hooks (project)", "openreflex hook" in _read(project / ".claude" / "settings.json")),
214
+ ("Claude Code integration", claude_project or claude_plugin),
130
215
  ("Codex hooks (project)", "openreflex hook" in _read(project / ".codex" / "hooks.json")),
131
216
  ("Cursor hooks (project)", "openreflex hook" in _read(project / ".cursor" / "hooks.json")),
132
217
  ("OpenCode plugin (project)", (project / ".opencode" / "plugins" / "openreflex.ts").exists()),
218
+ ("hook activity observed", trace is not None),
133
219
  ]
134
- print(f"Project: {project}\nData: {database_path(project)}")
220
+ print("OPENREFLEX / DOCTOR")
221
+ print(f" project {project}")
222
+ print(f" resolution {resolution['source']}")
223
+ if resolution.get("cwd"):
224
+ print(f" hook cwd {resolution['cwd']}")
225
+ if resolution.get("agent_hint"):
226
+ print(f" agent hint {resolution['agent_hint']}")
227
+ print(f" data {database_path(project)}")
228
+ if claude_plugin and not claude_project:
229
+ print(" Claude plugin/global install detected")
135
230
  for name, ok in checks:
136
231
  print(f" [{'ok' if ok else '--'}] {name}")
232
+ if trace:
233
+ print(f"Last hook trace:\n {trace}")
137
234
  log = home() / "logs" / "errors.log"
138
235
  if log.exists():
139
236
  tail = log.read_text(encoding="utf-8", errors="replace").splitlines()[-5:]
@@ -178,7 +275,6 @@ def build_parser() -> argparse.ArgumentParser:
178
275
  sub = parser.add_subparsers(dest="command", required=True)
179
276
 
180
277
  hook = sub.add_parser("hook", help="Handle a lifecycle hook (JSON on stdin); used by agent configs")
181
- # No `choices`: argparse exits 2 on bad input, and exit code 2 means "block" to Claude Code and Codex.
182
278
  hook.add_argument("agent", help=", ".join(AGENT_CHOICES))
183
279
  hook.add_argument("event", nargs="?")
184
280
  hook.set_defaults(func=cmd_hook)
@@ -189,16 +285,20 @@ def build_parser() -> argparse.ArgumentParser:
189
285
 
190
286
  for name, func, text in (("approve", cmd_approve, "Enable capture for a project"),
191
287
  ("revoke", cmd_revoke, "Disable capture for a project"),
192
- ("doctor", cmd_doctor, "Check installation and recent hook errors")):
288
+ ("doctor", cmd_doctor, "Check installation, project resolution, and recent hook activity"),
289
+ ("why", cmd_why, "Explain the latest OpenReflex execution decision"),
290
+ ("trace", cmd_trace, "Show the latest execution decision timeline")):
193
291
  command = sub.add_parser(name, help=text)
194
292
  command.add_argument("--project")
195
293
  command.set_defaults(func=func)
196
294
 
197
- install = sub.add_parser("install", help="Write project hook + MCP config for an agent and enable the project")
198
- install.add_argument("agent", choices=AGENT_CHOICES)
199
- install.add_argument("--project")
200
- install.add_argument("--dry-run", action="store_true")
201
- install.set_defaults(func=cmd_install)
295
+ for name, func, text in (("install", cmd_install, "Connect OpenReflex hooks + MCP for an agent"),
296
+ ("uninstall", cmd_uninstall, "Disconnect OpenReflex hooks + MCP while preserving memory")):
297
+ command = sub.add_parser(name, help=text)
298
+ command.add_argument("agent", choices=AGENT_CHOICES)
299
+ command.add_argument("--project")
300
+ command.add_argument("--dry-run", action="store_true")
301
+ command.set_defaults(func=func)
202
302
 
203
303
  status = sub.add_parser("status", help="Show capture, reuse, regret, and routing metrics")
204
304
  status.add_argument("--project")
@@ -227,7 +327,6 @@ def main(argv: list[str] | None = None) -> int:
227
327
  _utf8_stdio()
228
328
  argv = sys.argv[1:] if argv is None else argv
229
329
  if argv[:1] == ["hook"]:
230
- # Hook invocations must never fail loudly or return a blocking exit code, whatever the arguments.
231
330
  try:
232
331
  return cmd_hook(argparse.Namespace(agent=argv[1] if len(argv) > 1 else "", event=argv[2] if len(argv) > 2 else ""))
233
332
  except BaseException: # noqa: BLE001
@@ -0,0 +1,108 @@
1
+ """Budget-aware execution control driven by the versioned OpenReflex policy."""
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+
6
+ from .models import CandidatePath, Execution, ToolCall
7
+ from .policy import Policy, load_policy
8
+ from .privacy import PROGRESS
9
+ from .routing import Budget, dominates, marginal_cost
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Verdict:
14
+ action: str
15
+ current: str | None
16
+ success_estimate: float
17
+ value_continue: float
18
+ alternative: CandidatePath | None
19
+ alternative_estimate: float | None
20
+ value_pivot: float | None
21
+ budget_used: float
22
+
23
+ def summary(self) -> str:
24
+ parts = [f"success estimate ~{self.success_estimate:.0%} on {self.current or 'the current path'}"]
25
+ if self.alternative is not None and self.alternative_estimate is not None:
26
+ parts.append(f"~{self.alternative_estimate:.0%} for {self.alternative.strategy}")
27
+ parts.append(f"budget {self.budget_used:.0%} used")
28
+ return ", ".join(parts)
29
+
30
+
31
+ def _update(probability: float, likelihood_ratio: float, epsilon: float) -> float:
32
+ probability = min(max(probability, epsilon), 1 - epsilon)
33
+ odds = probability / (1 - probability) * likelihood_ratio
34
+ return odds / (1 + odds)
35
+
36
+
37
+ def assess(execution: Execution, calls: list[ToolCall], current: CandidatePath | None,
38
+ alternatives: list[CandidatePath], budget: Budget, active_seconds: float, since: float,
39
+ tried: set[str] = frozenset(), policy: Policy | None = None) -> Verdict:
40
+ cfg = policy or load_policy()
41
+ values = cfg.table("control")
42
+ success_value = float(values["success_value"])
43
+ idle_likelihood = float(values["idle_likelihood_ratio"])
44
+ failure_likelihood = float(values["failure_likelihood_ratio"])
45
+ shared_failure_likelihood = float(values["shared_failure_likelihood_ratio"])
46
+ pivot_reuse = float(values["pivot_reuse"])
47
+ pivot_margin = float(values["pivot_margin"])
48
+ minimum_remaining = float(values["minimum_remaining"])
49
+ epsilon = float(values["probability_epsilon"])
50
+
51
+ finished = [c for c in calls if c.status != "running"]
52
+ recent = [c for c in finished if c.started_at > since]
53
+ failures = sum(c.status == "failure" for c in recent)
54
+ idle = sum(c.status == "success" and c.category not in PROGRESS for c in recent)
55
+ used = budget.usage(active_seconds, len(calls), execution.output_tokens_estimate)
56
+ overrun = max(1.0, used)
57
+
58
+ prior = current.success_probability if current else float(values["fallback_success_probability"])
59
+ estimate = _update(prior, idle_likelihood ** idle * failure_likelihood ** failures, epsilon)
60
+ expected_calls = current.tool_calls if current else cfg.number("routing.budget.minimum_tool_calls")
61
+ remaining = max(expected_calls - len(calls), minimum_remaining * expected_calls) / max(expected_calls, 1.0)
62
+ value_continue = success_value * estimate - overrun * marginal_cost(
63
+ (current.time_seconds if current else cfg.number("routing.budget.minimum_seconds")) * remaining,
64
+ expected_calls * remaining,
65
+ (current.context_tokens if current else cfg.number("routing.budget.minimum_context_tokens")) * remaining,
66
+ cfg,
67
+ )
68
+
69
+ total_failures = sum(c.status == "failure" for c in finished)
70
+ pool = [p for p in alternatives if p.within_limits and p.strategy not in tried
71
+ and not (current and p.strategy == current.strategy)]
72
+ best, best_estimate, best_value = None, None, None
73
+ for path in pool:
74
+ if any(dominates(other, path, cfg) for other in pool if other is not path):
75
+ continue
76
+ path_estimate = _update(path.success_probability, shared_failure_likelihood ** total_failures, epsilon)
77
+ repeat = 1 - min(pivot_reuse, len(calls) / max(path.tool_calls, 1.0))
78
+ value = success_value * path_estimate - overrun * marginal_cost(
79
+ path.time_seconds * repeat, path.tool_calls * repeat, path.context_tokens * repeat, cfg)
80
+ if best_value is None or value > best_value:
81
+ best, best_estimate, best_value = path, path_estimate, value
82
+
83
+ if best_value is not None and best_value > value_continue + pivot_margin and best_value > 0:
84
+ action = "pivot"
85
+ elif value_continue <= 0 and (best_value is None or best_value <= 0) and used >= 1:
86
+ action = "stop"
87
+ else:
88
+ action = "continue"
89
+ return Verdict(action, current.strategy if current else None, round(estimate, 4), round(value_continue, 4),
90
+ best, None if best_estimate is None else round(best_estimate, 4),
91
+ None if best_value is None else round(best_value, 4), round(used, 3))
92
+
93
+
94
+ def pivots(execution: Execution) -> list[tuple[str, str, int]]:
95
+ found = []
96
+ for verdict in execution.verdicts:
97
+ head, _, count = verdict.rpartition("@")
98
+ if head.startswith("pivot:") and ">" in head and count.isdigit():
99
+ source, _, target = head[len("pivot:"):].partition(">")
100
+ found.append((source, target, int(count)))
101
+ return found
102
+
103
+
104
+ def switch_time(execution: Execution, calls: list[ToolCall]) -> float:
105
+ history = pivots(execution)
106
+ if not history or not calls:
107
+ return -math.inf
108
+ return calls[max(1, min(history[-1][2], len(calls))) - 1].started_at