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.
- {openreflex-0.2.0/src/openreflex.egg-info → openreflex-0.3.1}/PKG-INFO +25 -4
- {openreflex-0.2.0 → openreflex-0.3.1}/README.md +24 -3
- {openreflex-0.2.0 → openreflex-0.3.1}/pyproject.toml +2 -2
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/__init__.py +1 -1
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/cli.py +120 -21
- openreflex-0.3.1/src/openreflex/control.py +108 -0
- openreflex-0.3.1/src/openreflex/decision.py +221 -0
- openreflex-0.3.1/src/openreflex/detect.py +91 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/engine.py +200 -80
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/hooks.py +20 -24
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/install.py +65 -1
- openreflex-0.3.1/src/openreflex/mcp_server.py +355 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/models.py +4 -2
- openreflex-0.3.1/src/openreflex/policies/default.toml +165 -0
- openreflex-0.3.1/src/openreflex/policy.py +144 -0
- openreflex-0.3.1/src/openreflex/project.py +154 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/routing.py +81 -67
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/store.py +5 -0
- {openreflex-0.2.0 → openreflex-0.3.1/src/openreflex.egg-info}/PKG-INFO +25 -4
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/SOURCES.txt +5 -0
- openreflex-0.3.1/tests/test_mcp_metrics_benchmark.py +91 -0
- openreflex-0.3.1/tests/test_policy_recap.py +149 -0
- openreflex-0.3.1/tests/test_reliability_lifecycle.py +108 -0
- openreflex-0.2.0/src/openreflex/control.py +0 -119
- openreflex-0.2.0/src/openreflex/detect.py +0 -84
- openreflex-0.2.0/src/openreflex/mcp_server.py +0 -149
- openreflex-0.2.0/src/openreflex/project.py +0 -104
- openreflex-0.2.0/tests/test_mcp_metrics_benchmark.py +0 -56
- {openreflex-0.2.0 → openreflex-0.3.1}/LICENSE +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/setup.cfg +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/__main__.py +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/benchmark.py +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/integrations/opencode.ts +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/learning.py +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/metrics.py +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex/privacy.py +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/dependency_links.txt +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/entry_points.txt +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/requires.txt +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/src/openreflex.egg-info/top_level.txt +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_engine.py +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_hooks.py +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_install_cli.py +0 -0
- {openreflex-0.2.0 → openreflex-0.3.1}/tests/test_routing_control.py +0 -0
- {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.
|
|
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
|
+

|
|
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`, `
|
|
164
|
-
`
|
|
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
|
-
| `
|
|
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
|
+

|
|
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`, `
|
|
132
|
-
`
|
|
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
|
-
| `
|
|
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.
|
|
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"]
|
|
@@ -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
|
|
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 = "
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
73
|
+
print(" memory enabled")
|
|
74
|
+
print(" storage local")
|
|
75
|
+
print("\n [ok] reflex active")
|
|
70
76
|
if args.agent == "codex":
|
|
71
|
-
print("
|
|
77
|
+
print("\nCodex requires one trust step: open /hooks and approve the OpenReflex entries.")
|
|
72
78
|
if shutil.which("openreflex") is None:
|
|
73
|
-
print("
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|