openreflex 0.1.2__tar.gz → 0.2.0__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.1.2/src/openreflex.egg-info → openreflex-0.2.0}/PKG-INFO +24 -11
- {openreflex-0.1.2 → openreflex-0.2.0}/README.md +23 -10
- {openreflex-0.1.2 → openreflex-0.2.0}/pyproject.toml +1 -1
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/__init__.py +1 -1
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/cli.py +2 -0
- openreflex-0.2.0/src/openreflex/control.py +119 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/detect.py +11 -9
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/engine.py +109 -34
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/hooks.py +40 -2
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/install.py +2 -1
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/mcp_server.py +48 -11
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/metrics.py +9 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/models.py +10 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/privacy.py +28 -4
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/project.py +2 -1
- openreflex-0.2.0/src/openreflex/routing.py +176 -0
- {openreflex-0.1.2 → openreflex-0.2.0/src/openreflex.egg-info}/PKG-INFO +24 -11
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex.egg-info/SOURCES.txt +2 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/tests/test_engine.py +18 -1
- {openreflex-0.1.2 → openreflex-0.2.0}/tests/test_hooks.py +37 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/tests/test_install_cli.py +19 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/tests/test_mcp_metrics_benchmark.py +3 -3
- openreflex-0.2.0/tests/test_routing_control.py +160 -0
- openreflex-0.1.2/src/openreflex/routing.py +0 -66
- {openreflex-0.1.2 → openreflex-0.2.0}/LICENSE +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/setup.cfg +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/__main__.py +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/benchmark.py +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/integrations/opencode.ts +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/learning.py +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex/store.py +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex.egg-info/dependency_links.txt +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex.egg-info/entry_points.txt +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex.egg-info/requires.txt +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/src/openreflex.egg-info/top_level.txt +0 -0
- {openreflex-0.1.2 → openreflex-0.2.0}/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.2.0
|
|
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
|
|
@@ -31,6 +31,7 @@ Requires-Dist: twine>=6; extra == "dev"
|
|
|
31
31
|
Dynamic: license-file
|
|
32
32
|
|
|
33
33
|
<div align="center">
|
|
34
|
+
<!-- mcp-name: io.github.vishnu-77/openreflex -->
|
|
34
35
|
|
|
35
36
|
<a href="https://openreflex.cc" target="_blank">
|
|
36
37
|
<picture>
|
|
@@ -124,8 +125,8 @@ OpenReflex installs per project with `openreflex install <agent>`, or as a plugi
|
|
|
124
125
|
| Cursor | `openreflex install cursor` |
|
|
125
126
|
| OpenCode | `openreflex install opencode` |
|
|
126
127
|
|
|
127
|
-
With a plugin install, enable each project with `openreflex approve`. Claude Code
|
|
128
|
-
the
|
|
128
|
+
With a plugin install, enable each project with `openreflex approve`. Claude Code and Codex are verified in live
|
|
129
|
+
sessions; the Cursor and OpenCode integrations follow each agent's documented hook protocol.
|
|
129
130
|
|
|
130
131
|
## How it works
|
|
131
132
|
|
|
@@ -139,16 +140,28 @@ Execution -failed_with-> ToolCall -resolved_by-> ToolCall
|
|
|
139
140
|
Execution -caused-> Outcome -caused-> Experience -caused-> Lesson
|
|
140
141
|
```
|
|
141
142
|
|
|
142
|
-
- **Before a task:** similar experiences are retrieved
|
|
143
|
-
`incremental`) are
|
|
144
|
-
reversibility, and
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
- **Before a task:** similar experiences are retrieved and three strategies (`inspect-first`, `test-first`,
|
|
144
|
+
`incremental`) are estimated on success probability, time, tool calls, context cost, risk, uncertainty,
|
|
145
|
+
reversibility, and expected regret. Strategies that another one beats on every measure are dropped as
|
|
146
|
+
dominated (Pareto efficiency), the rest are ranked by utility within any limits you set, and the chosen path
|
|
147
|
+
gets an execution budget for time, tool calls, and context. A context of at most 1,400 characters is
|
|
148
|
+
injected; nothing is injected without relevant experience.
|
|
149
|
+
- **During a task:** when a detector finds a failure loop, repeated calls, stalled progress, context growth,
|
|
150
|
+
or work past the budget, OpenReflex estimates the marginal value of more work. The current path's success
|
|
151
|
+
estimate is updated with each call that makes no progress or fails, and compared with the cost of the work
|
|
152
|
+
left and with the best untried alternative. The alert ends with a recommendation to **continue**, **pivot**
|
|
153
|
+
to another strategy, or **stop** and ask the user. Each problem, pivot, or stop is raised once, with a cooldown
|
|
154
|
+
between messages.
|
|
147
155
|
- **After a task:** outcome and chosen path come from the agent's `record_outcome` / `choose_path` MCP calls when
|
|
148
|
-
available, and are otherwise inferred from tool activity. Execution Regret
|
|
156
|
+
available, and are otherwise inferred from tool activity. Execution Regret compares the path taken with the
|
|
157
|
+
best plausible alternative; it is withheld when the outcome is unknown, and feeds back into how strategies
|
|
158
|
+
are ranked next time, along with success, cost, and how often a strategy ran into trouble.
|
|
149
159
|
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
Set optional limits for every task with `OPENREFLEX_BUDGET`, for example `calls=40,minutes=20,tokens=60000`.
|
|
161
|
+
|
|
162
|
+
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`.
|
|
152
165
|
|
|
153
166
|
## CLI
|
|
154
167
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
+
<!-- mcp-name: io.github.vishnu-77/openreflex -->
|
|
2
3
|
|
|
3
4
|
<a href="https://openreflex.cc" target="_blank">
|
|
4
5
|
<picture>
|
|
@@ -92,8 +93,8 @@ OpenReflex installs per project with `openreflex install <agent>`, or as a plugi
|
|
|
92
93
|
| Cursor | `openreflex install cursor` |
|
|
93
94
|
| OpenCode | `openreflex install opencode` |
|
|
94
95
|
|
|
95
|
-
With a plugin install, enable each project with `openreflex approve`. Claude Code
|
|
96
|
-
the
|
|
96
|
+
With a plugin install, enable each project with `openreflex approve`. Claude Code and Codex are verified in live
|
|
97
|
+
sessions; the Cursor and OpenCode integrations follow each agent's documented hook protocol.
|
|
97
98
|
|
|
98
99
|
## How it works
|
|
99
100
|
|
|
@@ -107,16 +108,28 @@ Execution -failed_with-> ToolCall -resolved_by-> ToolCall
|
|
|
107
108
|
Execution -caused-> Outcome -caused-> Experience -caused-> Lesson
|
|
108
109
|
```
|
|
109
110
|
|
|
110
|
-
- **Before a task:** similar experiences are retrieved
|
|
111
|
-
`incremental`) are
|
|
112
|
-
reversibility, and
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
- **Before a task:** similar experiences are retrieved and three strategies (`inspect-first`, `test-first`,
|
|
112
|
+
`incremental`) are estimated on success probability, time, tool calls, context cost, risk, uncertainty,
|
|
113
|
+
reversibility, and expected regret. Strategies that another one beats on every measure are dropped as
|
|
114
|
+
dominated (Pareto efficiency), the rest are ranked by utility within any limits you set, and the chosen path
|
|
115
|
+
gets an execution budget for time, tool calls, and context. A context of at most 1,400 characters is
|
|
116
|
+
injected; nothing is injected without relevant experience.
|
|
117
|
+
- **During a task:** when a detector finds a failure loop, repeated calls, stalled progress, context growth,
|
|
118
|
+
or work past the budget, OpenReflex estimates the marginal value of more work. The current path's success
|
|
119
|
+
estimate is updated with each call that makes no progress or fails, and compared with the cost of the work
|
|
120
|
+
left and with the best untried alternative. The alert ends with a recommendation to **continue**, **pivot**
|
|
121
|
+
to another strategy, or **stop** and ask the user. Each problem, pivot, or stop is raised once, with a cooldown
|
|
122
|
+
between messages.
|
|
115
123
|
- **After a task:** outcome and chosen path come from the agent's `record_outcome` / `choose_path` MCP calls when
|
|
116
|
-
available, and are otherwise inferred from tool activity. Execution Regret
|
|
124
|
+
available, and are otherwise inferred from tool activity. Execution Regret compares the path taken with the
|
|
125
|
+
best plausible alternative; it is withheld when the outcome is unknown, and feeds back into how strategies
|
|
126
|
+
are ranked next time, along with success, cost, and how often a strategy ran into trouble.
|
|
117
127
|
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
Set optional limits for every task with `OPENREFLEX_BUDGET`, for example `calls=40,minutes=20,tokens=60000`.
|
|
129
|
+
|
|
130
|
+
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`.
|
|
120
133
|
|
|
121
134
|
## CLI
|
|
122
135
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "openreflex"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
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"
|
|
@@ -102,6 +102,8 @@ def cmd_status(args) -> int:
|
|
|
102
102
|
f"{eff['without_prior_experience']['tool_calls']} (observational)")
|
|
103
103
|
print(f" mean execution regret: {regret['mean']} routing agreement: {routing['agreement']}")
|
|
104
104
|
print(f" live alerts: {data['live_alerts'] or '-'}")
|
|
105
|
+
control = data["execution_control"]
|
|
106
|
+
print(f" verdicts: {control['verdicts'] or '-'} tasks within tool-call budget: {control['tasks_within_tool_call_budget']}")
|
|
105
107
|
return 0
|
|
106
108
|
|
|
107
109
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Budget-aware execution control: is more computation on the current path still worth it?
|
|
2
|
+
|
|
3
|
+
The current path's success estimate starts from its planned probability and is updated with Bayes' rule as
|
|
4
|
+
the task runs. A tool call that makes no progress, and more so a failure, is likelier on a path that will not
|
|
5
|
+
work than on one that will, so each lowers the odds. Failures also weigh, more lightly, on the alternatives,
|
|
6
|
+
since some causes (a broken environment, a wrong assumption about the task) defeat any approach.
|
|
7
|
+
|
|
8
|
+
The marginal value of continuing is the success the path can still deliver minus the cost of the work it is
|
|
9
|
+
expected to need; pivoting is valued the same way for the best untried alternative that no other untried one
|
|
10
|
+
dominates, charged
|
|
11
|
+
the part of its work a fresh start repeats; stopping is the zero point. Work past the budget costs more per
|
|
12
|
+
unit. A verdict other than "continue" is only acted on when a detector has already found trouble.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from .models import CandidatePath, Execution, ToolCall
|
|
19
|
+
from .privacy import PROGRESS
|
|
20
|
+
from .routing import Budget, dominates, marginal_cost
|
|
21
|
+
|
|
22
|
+
SUCCESS_VALUE = 0.55 # utility weight of success, as in routing.utility
|
|
23
|
+
IDLE_LIKELIHOOD = 0.85 # P(call without progress | path will succeed) / P(... | path will not)
|
|
24
|
+
FAILURE_LIKELIHOOD = 0.6 # the same ratio for a failed call
|
|
25
|
+
SHARED_FAILURE_LIKELIHOOD = 0.85 # how much each failure also counts against the alternatives
|
|
26
|
+
PIVOT_REUSE = 0.3 # most of an alternative's work that exploration so far can already cover
|
|
27
|
+
PIVOT_MARGIN = 0.03 # a pivot must beat continuing by this much utility
|
|
28
|
+
MIN_REMAINING = 0.3 # continuing always needs at least this share of the path's expected work
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Verdict:
|
|
33
|
+
action: str # "continue", "pivot" or "stop"
|
|
34
|
+
current: str | None
|
|
35
|
+
success_estimate: float
|
|
36
|
+
value_continue: float
|
|
37
|
+
alternative: CandidatePath | None
|
|
38
|
+
alternative_estimate: float | None
|
|
39
|
+
value_pivot: float | None
|
|
40
|
+
budget_used: float
|
|
41
|
+
|
|
42
|
+
def summary(self) -> str:
|
|
43
|
+
parts = [f"success estimate ~{self.success_estimate:.0%} on {self.current or 'the current path'}"]
|
|
44
|
+
if self.alternative is not None and self.alternative_estimate is not None:
|
|
45
|
+
parts.append(f"~{self.alternative_estimate:.0%} for {self.alternative.strategy}")
|
|
46
|
+
parts.append(f"budget {self.budget_used:.0%} used")
|
|
47
|
+
return ", ".join(parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _update(probability: float, likelihood_ratio: float) -> float:
|
|
51
|
+
probability = min(max(probability, 1e-6), 1 - 1e-6)
|
|
52
|
+
odds = probability / (1 - probability) * likelihood_ratio
|
|
53
|
+
return odds / (1 + odds)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def assess(execution: Execution, calls: list[ToolCall], current: CandidatePath | None,
|
|
57
|
+
alternatives: list[CandidatePath], budget: Budget, active_seconds: float, since: float,
|
|
58
|
+
tried: set[str] = frozenset()) -> Verdict:
|
|
59
|
+
"""Verdict for the execution so far. `since` is when evidence against the current path starts counting:
|
|
60
|
+
the last progress, or the moment the path was switched to. `tried` strategies are not offered again."""
|
|
61
|
+
finished = [c for c in calls if c.status != "running"]
|
|
62
|
+
recent = [c for c in finished if c.started_at > since]
|
|
63
|
+
failures = sum(c.status == "failure" for c in recent)
|
|
64
|
+
idle = sum(c.status == "success" and c.category not in PROGRESS for c in recent)
|
|
65
|
+
used = budget.usage(active_seconds, len(calls), execution.output_tokens_estimate)
|
|
66
|
+
overrun = max(1.0, used)
|
|
67
|
+
|
|
68
|
+
prior = current.success_probability if current else 0.5
|
|
69
|
+
estimate = _update(prior, IDLE_LIKELIHOOD ** idle * FAILURE_LIKELIHOOD ** failures)
|
|
70
|
+
expected_calls = current.tool_calls if current else 20.0
|
|
71
|
+
remaining = max(expected_calls - len(calls), MIN_REMAINING * expected_calls) / max(expected_calls, 1.0)
|
|
72
|
+
value_continue = SUCCESS_VALUE * estimate - overrun * marginal_cost(
|
|
73
|
+
(current.time_seconds if current else 600) * remaining, expected_calls * remaining,
|
|
74
|
+
(current.context_tokens if current else 6000) * remaining)
|
|
75
|
+
|
|
76
|
+
total_failures = sum(c.status == "failure" for c in finished)
|
|
77
|
+
# Pivot options are compared with each other, not with the current path: its planning-time estimates are
|
|
78
|
+
# exactly what the evidence in this task is overturning.
|
|
79
|
+
pool = [p for p in alternatives if p.within_limits and p.strategy not in tried
|
|
80
|
+
and not (current and p.strategy == current.strategy)]
|
|
81
|
+
best, best_estimate, best_value = None, None, None
|
|
82
|
+
for path in pool:
|
|
83
|
+
if any(dominates(other, path) for other in pool if other is not path):
|
|
84
|
+
continue
|
|
85
|
+
path_estimate = _update(path.success_probability, SHARED_FAILURE_LIKELIHOOD ** total_failures)
|
|
86
|
+
repeat = 1 - min(PIVOT_REUSE, len(calls) / max(path.tool_calls, 1.0))
|
|
87
|
+
value = SUCCESS_VALUE * path_estimate - overrun * marginal_cost(
|
|
88
|
+
path.time_seconds * repeat, path.tool_calls * repeat, path.context_tokens * repeat)
|
|
89
|
+
if best_value is None or value > best_value:
|
|
90
|
+
best, best_estimate, best_value = path, path_estimate, value
|
|
91
|
+
|
|
92
|
+
if best_value is not None and best_value > value_continue + PIVOT_MARGIN and best_value > 0:
|
|
93
|
+
action = "pivot"
|
|
94
|
+
elif value_continue <= 0 and (best_value is None or best_value <= 0) and used >= 1:
|
|
95
|
+
action = "stop"
|
|
96
|
+
else:
|
|
97
|
+
action = "continue"
|
|
98
|
+
return Verdict(action, current.strategy if current else None, round(estimate, 4), round(value_continue, 4),
|
|
99
|
+
best, None if best_estimate is None else round(best_estimate, 4),
|
|
100
|
+
None if best_value is None else round(best_value, 4), round(used, 3))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def pivots(execution: Execution) -> list[tuple[str, str, int]]:
|
|
104
|
+
"""(from, to, call count) for each pivot recommended so far; verdicts read "pivot:<from>><to>@<calls>"."""
|
|
105
|
+
found = []
|
|
106
|
+
for verdict in execution.verdicts:
|
|
107
|
+
head, _, count = verdict.rpartition("@")
|
|
108
|
+
if head.startswith("pivot:") and ">" in head and count.isdigit():
|
|
109
|
+
source, _, target = head[len("pivot:"):].partition(">")
|
|
110
|
+
found.append((source, target, int(count)))
|
|
111
|
+
return found
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def switch_time(execution: Execution, calls: list[ToolCall]) -> float:
|
|
115
|
+
"""When the path last changed: the call at which the latest pivot was recommended."""
|
|
116
|
+
history = pivots(execution)
|
|
117
|
+
if not history or not calls:
|
|
118
|
+
return -math.inf
|
|
119
|
+
return calls[max(1, min(history[-1][2], len(calls))) - 1].started_at
|
|
@@ -7,15 +7,15 @@ interrupted when replanning is justified by evidence rather than by a single slo
|
|
|
7
7
|
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
|
|
10
|
-
from .models import
|
|
10
|
+
from .models import Execution, ToolCall
|
|
11
11
|
from .privacy import PROGRESS
|
|
12
|
+
from .routing import Budget
|
|
12
13
|
|
|
13
14
|
COOLDOWN_SECONDS = 180
|
|
14
15
|
REPEAT_THRESHOLD = 3
|
|
15
16
|
FAILURE_LOOP_THRESHOLD = 3
|
|
16
17
|
STAGNATION_CALLS = 15
|
|
17
18
|
STAGNATION_SECONDS = 600
|
|
18
|
-
CONTEXT_FLOOR_TOKENS = 30000
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
@dataclass(frozen=True)
|
|
@@ -25,7 +25,7 @@ class Alert:
|
|
|
25
25
|
detail: str
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
def detect(execution: Execution, calls: list[ToolCall],
|
|
28
|
+
def detect(execution: Execution, calls: list[ToolCall], budget: Budget, now: float, active_seconds: float = 0) -> list[Alert]:
|
|
29
29
|
alerts: list[Alert] = []
|
|
30
30
|
finished = [c for c in calls if c.status != "running"]
|
|
31
31
|
|
|
@@ -61,15 +61,17 @@ def detect(execution: Execution, calls: list[ToolCall], recommended: CandidatePa
|
|
|
61
61
|
alerts.append(Alert("stagnation", 0.7, f"{len(since_progress)} calls over {idle / 60:.0f} min without a "
|
|
62
62
|
f"successful edit or check (only {', '.join(kinds)})"))
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
if execution.output_tokens_estimate > budget or execution.compactions:
|
|
64
|
+
if execution.output_tokens_estimate > budget.context_tokens or execution.compactions:
|
|
66
65
|
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)"
|
|
66
|
+
f"~{execution.output_tokens_estimate // 1000}k tokens of tool output (budget ~{int(budget.context_tokens) // 1000}k)"
|
|
68
67
|
alerts.append(Alert("context_growth", 0.5, reason))
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
recent_progress = any(c.category in PROGRESS and c.status == "success" for c in calls[-8:])
|
|
70
|
+
# The time budget, like stagnation, only applies once implementation has begun: reading can take a while.
|
|
71
|
+
if not recent_progress and (len(calls) > budget.tool_calls or (implementing and active_seconds > budget.seconds)):
|
|
72
|
+
detail = (f"{len(calls)} tool calls (budget ~{budget.tool_calls:.0f})" if len(calls) > budget.tool_calls
|
|
73
|
+
else f"{active_seconds / 60:.0f} min of work (budget ~{budget.seconds / 60:.0f} min)")
|
|
74
|
+
alerts.append(Alert("over_budget", 0.5, detail))
|
|
73
75
|
return alerts
|
|
74
76
|
|
|
75
77
|
|
|
@@ -14,10 +14,10 @@ import time
|
|
|
14
14
|
from collections import Counter
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
-
from . import detect, learning
|
|
17
|
+
from . import control, detect, learning
|
|
18
18
|
from .models import CandidatePath, Context, Execution, Experience, Lesson, Outcome, Status, Task, ToolCall, uid
|
|
19
19
|
from .privacy import PROGRESS, categorize, error_signature, file_paths, fingerprint, redact
|
|
20
|
-
from .routing import candidates, classify, embed, similarity
|
|
20
|
+
from .routing import Budget, Limits, budget_for, candidates, classify, embed, limits_from_env, similarity
|
|
21
21
|
from .store import Store, database_path
|
|
22
22
|
|
|
23
23
|
FOLLOW_UP = re.compile(r"^\s*(yes|no|ok(ay)?|thanks|thank you|continue|go on|go ahead|proceed|sure|lgtm|do it|"
|
|
@@ -155,18 +155,32 @@ class Engine:
|
|
|
155
155
|
recent = [e for e in self.store.list("Execution", limit=50) if now - e.started_at < max_age]
|
|
156
156
|
return max(recent, key=lambda e: (e.ended_at is None, e.started_at), default=None)
|
|
157
157
|
|
|
158
|
-
def context_for(self, description: str, agent: str = "mcp", session: str | None = None
|
|
159
|
-
|
|
158
|
+
def context_for(self, description: str, agent: str = "mcp", session: str | None = None,
|
|
159
|
+
limits: Limits | None = None) -> tuple[Execution, Context]:
|
|
160
|
+
"""Explicit request for an Execution Context; always returns the full text, even without experience.
|
|
161
|
+
Limits, when given, re-plan the current task so its path and budget fit them."""
|
|
160
162
|
now = self.clock()
|
|
161
163
|
with self.store.transaction():
|
|
162
164
|
current = self.current_execution(agent if session else None, session, max_age=1800)
|
|
163
165
|
if current is not None and current.ended_at is None:
|
|
164
166
|
task = self._task(current)
|
|
165
|
-
if task.description == UNTRACKED or not task.substantial:
|
|
166
|
-
|
|
167
|
+
if task.description == UNTRACKED or not task.substantial or limits is not None:
|
|
168
|
+
keep = task.description if task.description != UNTRACKED and task.substantial else description
|
|
169
|
+
self._describe(current, keep, now, limits)
|
|
167
170
|
context = self.store.list("Context", "task_id", current.task_id, limit=1)[0]
|
|
168
171
|
return current, context
|
|
169
|
-
return self._start(agent, session or f"mcp-{int(now)}", description, now, substantial=True)
|
|
172
|
+
return self._start(agent, session or f"mcp-{int(now)}", description, now, substantial=True, limits=limits)
|
|
173
|
+
|
|
174
|
+
def progress(self, agent: str | None = None, session: str | None = None):
|
|
175
|
+
"""Explicit progress check: (execution, verdict, detected problems, budget). Records nothing."""
|
|
176
|
+
with self.store.transaction():
|
|
177
|
+
execution = self.current_execution(agent if session else None, session)
|
|
178
|
+
if execution is None:
|
|
179
|
+
raise ValueError("No current execution; request an execution context first")
|
|
180
|
+
calls, now = self._calls(execution.id), self.clock()
|
|
181
|
+
budget, active = self._budget(execution), learning.active_seconds(execution, calls, now)
|
|
182
|
+
problems = detect.detect(execution, calls, budget, now, active)
|
|
183
|
+
return execution, self._verdict(execution, calls, budget, active), problems, budget
|
|
170
184
|
|
|
171
185
|
def choose_path(self, strategy: str, steps: list[str] | None = None, execution_id: str | None = None) -> CandidatePath:
|
|
172
186
|
with self.store.transaction():
|
|
@@ -182,6 +196,9 @@ class Engine:
|
|
|
182
196
|
"steps": [redact(s, 200) for s in (steps or [])][:8], "evidence_count": 0, "uncertainty": 1.0})
|
|
183
197
|
self.store.put(path)
|
|
184
198
|
execution.chosen_path_id, execution.chosen_inferred = path.id, False
|
|
199
|
+
limit = self._budget(execution) if execution.budget_source == "limit" else None
|
|
200
|
+
self._set_budget(execution, budget_for(path, Limits(limit.seconds, limit.tool_calls, limit.context_tokens)
|
|
201
|
+
if limit else limits_from_env()))
|
|
185
202
|
self.store.put(execution)
|
|
186
203
|
self.store.link(execution.id, "used", path.id)
|
|
187
204
|
return path
|
|
@@ -198,8 +215,9 @@ class Engine:
|
|
|
198
215
|
task = Task(description=redact(description, 1000), task_class=classify(description), session_id="preview",
|
|
199
216
|
agent="preview", started_at=self.clock())
|
|
200
217
|
retrieved = self.retrieve(task.description, task.task_class)
|
|
201
|
-
|
|
202
|
-
|
|
218
|
+
limits = limits_from_env()
|
|
219
|
+
paths = candidates(task.id, task.task_class, self._evidence(task.task_class, task.description), limits)
|
|
220
|
+
return render_context(task, paths, retrieved, self.lessons([x for x, _ in retrieved]), budget_for(paths[0], limits))
|
|
203
221
|
|
|
204
222
|
def retrieve(self, description: str, task_class: str | None = None, k: int = 5) -> list[tuple[Experience, float]]:
|
|
205
223
|
query = embed(description)
|
|
@@ -265,47 +283,52 @@ class Engine:
|
|
|
265
283
|
return similar if len(similar) >= 3 else same
|
|
266
284
|
|
|
267
285
|
def _start(self, agent: str, session: str, description: str, now: float,
|
|
268
|
-
substantial: bool) -> tuple[Execution, Context]:
|
|
286
|
+
substantial: bool, limits: Limits | None = None) -> tuple[Execution, Context]:
|
|
269
287
|
task = Task(description=redact(description, 1000), task_class=classify(description), session_id=session,
|
|
270
288
|
agent=agent, started_at=now, substantial=substantial)
|
|
271
|
-
|
|
289
|
+
limits = limits or limits_from_env()
|
|
290
|
+
paths = candidates(task.id, task.task_class, self._evidence(task.task_class, task.description), limits)
|
|
272
291
|
execution = Execution(task_id=task.id, agent=agent, session_id=session, recommended_path_id=paths[0].id,
|
|
273
292
|
started_at=now, last_progress_at=now)
|
|
293
|
+
self._set_budget(execution, budget_for(paths[0], limits))
|
|
274
294
|
self.store.put(task)
|
|
275
295
|
for path in paths:
|
|
276
296
|
self.store.put(path)
|
|
277
297
|
self.store.link(path.id, "recommended_for", task.id)
|
|
278
298
|
self.store.put(execution)
|
|
279
299
|
self.store.link(task.id, "caused", execution.id)
|
|
280
|
-
context = self._write_context(task, paths)
|
|
300
|
+
context = self._write_context(task, paths, self._budget(execution))
|
|
281
301
|
self.store.link(execution.id, "used", context.id)
|
|
282
302
|
self.store.bind_session(agent, session, execution.id)
|
|
283
303
|
return execution, context
|
|
284
304
|
|
|
285
|
-
def _describe(self, execution: Execution, description: str, now: float) -> Context:
|
|
286
|
-
"""Attach a real description to a task that began without one, and re-plan it."""
|
|
305
|
+
def _describe(self, execution: Execution, description: str, now: float, limits: Limits | None = None) -> Context:
|
|
306
|
+
"""Attach a real description to a task that began without one (or new limits), and re-plan it."""
|
|
287
307
|
task = self._task(execution)
|
|
288
308
|
task.description, task.task_class = redact(description, 1000), classify(description)
|
|
289
309
|
task.substantial = is_substantial(description) or task.substantial
|
|
290
310
|
self.store.put(task)
|
|
291
311
|
for node in self.store.list("CandidatePath", "task_id", task.id) + self.store.list("Context", "task_id", task.id):
|
|
292
312
|
self.store.delete(node.id)
|
|
293
|
-
|
|
313
|
+
limits = limits or limits_from_env()
|
|
314
|
+
paths = candidates(task.id, task.task_class, self._evidence(task.task_class, task.description), limits)
|
|
294
315
|
for path in paths:
|
|
295
316
|
self.store.put(path)
|
|
296
317
|
self.store.link(path.id, "recommended_for", task.id)
|
|
297
318
|
execution.recommended_path_id = paths[0].id
|
|
298
|
-
|
|
319
|
+
# A declared path was deleted with the old plan above; re-planning starts from the new recommendation.
|
|
320
|
+
execution.chosen_path_id, execution.chosen_inferred = None, False
|
|
321
|
+
self._set_budget(execution, budget_for(paths[0], limits))
|
|
299
322
|
self.store.put(execution)
|
|
300
|
-
context = self._write_context(task, paths)
|
|
323
|
+
context = self._write_context(task, paths, self._budget(execution))
|
|
301
324
|
self.store.link(execution.id, "used", context.id)
|
|
302
325
|
return context
|
|
303
326
|
|
|
304
|
-
def _write_context(self, task: Task, paths: list[CandidatePath]) -> Context:
|
|
327
|
+
def _write_context(self, task: Task, paths: list[CandidatePath], budget: Budget | None = None) -> Context:
|
|
305
328
|
retrieved = self.retrieve(task.description, task.task_class) if task.substantial else []
|
|
306
329
|
experiences = [e for e, _ in retrieved]
|
|
307
330
|
lessons = self.lessons(experiences)
|
|
308
|
-
text = render_context(task, paths, retrieved, lessons)
|
|
331
|
+
text = render_context(task, paths, retrieved, lessons, budget)
|
|
309
332
|
context = Context(task_id=task.id, text=text, experience_ids=[e.id for e in experiences],
|
|
310
333
|
lesson_ids=[lesson.id for lesson, _, _ in lessons],
|
|
311
334
|
injected=task.substantial and bool(experiences))
|
|
@@ -330,27 +353,70 @@ class Engine:
|
|
|
330
353
|
f"\"{signature}\". Change the input or approach before retrying.")
|
|
331
354
|
return None
|
|
332
355
|
|
|
333
|
-
def
|
|
356
|
+
def _set_budget(self, execution: Execution, budget: Budget) -> None:
|
|
357
|
+
execution.budget_seconds, execution.budget_tool_calls = round(budget.seconds, 1), round(budget.tool_calls, 1)
|
|
358
|
+
execution.budget_tokens, execution.budget_source = round(budget.context_tokens), budget.source
|
|
359
|
+
|
|
360
|
+
def _budget(self, execution: Execution) -> Budget:
|
|
361
|
+
if execution.budget_tool_calls is not None:
|
|
362
|
+
return Budget(execution.budget_seconds, execution.budget_tool_calls, execution.budget_tokens,
|
|
363
|
+
execution.budget_source or "estimated")
|
|
334
364
|
recommended = self.store.get(execution.recommended_path_id) if self.store.exists(execution.recommended_path_id) else None
|
|
335
|
-
|
|
336
|
-
|
|
365
|
+
return budget_for(recommended) if recommended else Budget(1200.0, 30.0, 30000.0, "estimated")
|
|
366
|
+
|
|
367
|
+
def _verdict(self, execution: Execution, calls: list[ToolCall], budget: Budget, active: float) -> control.Verdict:
|
|
368
|
+
paths = self.store.list("CandidatePath", "task_id", execution.task_id)
|
|
369
|
+
by_strategy = {p.strategy: p for p in paths}
|
|
370
|
+
history = control.pivots(execution)
|
|
371
|
+
declared = self.store.get(execution.chosen_path_id) if execution.chosen_path_id and not execution.chosen_inferred \
|
|
372
|
+
and self.store.exists(execution.chosen_path_id) else None
|
|
373
|
+
recommended = next((p for p in paths if p.id == execution.recommended_path_id), None)
|
|
374
|
+
# Mid-task, running checks before any edit is already test-first, even though no edit confirms it yet.
|
|
375
|
+
inferred = learning.infer_strategy(calls) or ("test-first" if any(c.category == "test" for c in calls) else "")
|
|
376
|
+
current = (by_strategy.get(history[-1][1]) if history else None) or declared or \
|
|
377
|
+
by_strategy.get(inferred) or recommended
|
|
378
|
+
tried = {name for source, target, _ in history for name in (source, target)}
|
|
379
|
+
since = max(execution.last_progress_at, control.switch_time(execution, calls))
|
|
380
|
+
return control.assess(execution, calls, current, paths, budget, active, since, tried)
|
|
381
|
+
|
|
382
|
+
def _alert(self, execution: Execution, calls: list[ToolCall], now: float) -> str | None:
|
|
383
|
+
"""At most one message per cooldown: a newly detected problem, or a new pivot/stop verdict when a
|
|
384
|
+
problem persists after its alert was already given."""
|
|
385
|
+
if now - execution.last_alert_at < detect.COOLDOWN_SECONDS:
|
|
386
|
+
return None
|
|
387
|
+
budget, active = self._budget(execution), learning.active_seconds(execution, calls, now)
|
|
388
|
+
problems = detect.detect(execution, calls, budget, now, active)
|
|
389
|
+
if not problems:
|
|
337
390
|
return None
|
|
338
|
-
|
|
391
|
+
verdict = self._verdict(execution, calls, budget, active)
|
|
392
|
+
key = {"pivot": f"pivot:{verdict.current or 'unplanned'}>{verdict.alternative.strategy}" if verdict.alternative else None,
|
|
393
|
+
"stop": "stop"}.get(verdict.action)
|
|
394
|
+
fresh_verdict = key is not None and key not in {v.rpartition("@")[0] for v in execution.verdicts}
|
|
395
|
+
alert = detect.select(execution, problems, now)
|
|
396
|
+
if alert is None and not fresh_verdict:
|
|
397
|
+
return None
|
|
398
|
+
if alert is not None:
|
|
399
|
+
execution.alerts.append(alert.kind)
|
|
400
|
+
alert = alert or max(problems, key=lambda a: a.severity)
|
|
339
401
|
execution.last_alert_at = now
|
|
340
|
-
|
|
341
|
-
paths = sorted(self.store.list("CandidatePath", "task_id", task.id), key=lambda p: -p.score)
|
|
342
|
-
current = learning.infer_strategy(calls) or (recommended.strategy if recommended else None)
|
|
343
|
-
alternative = next((p for p in paths if p.strategy != current), None)
|
|
402
|
+
execution.verdicts.append(f"{key or verdict.action}@{len(calls)}")
|
|
344
403
|
advice = {
|
|
345
|
-
"failure_loop": "
|
|
404
|
+
"failure_loop": "Don't retry the same fix: re-read the error and question the assumption behind it",
|
|
346
405
|
"repeated_action": "Reuse the earlier result instead of re-running the same call",
|
|
347
406
|
"stagnation": "Progress has stalled; re-plan before continuing",
|
|
348
407
|
"context_growth": "Prefer targeted reads (line ranges, filtered or tail'ed command output) over full dumps",
|
|
349
|
-
"over_budget": "This is
|
|
408
|
+
"over_budget": "This is past the execution budget; re-scope or re-plan",
|
|
350
409
|
}[alert.kind]
|
|
351
|
-
parts = [f"OpenReflex: {alert.detail}.
|
|
352
|
-
if
|
|
353
|
-
parts.append(f"
|
|
410
|
+
parts = [f"OpenReflex: {alert.detail}."]
|
|
411
|
+
if verdict.action == "stop":
|
|
412
|
+
parts.append(f"Recommendation: stop. More work is unlikely to pay off ({verdict.summary()}). "
|
|
413
|
+
"Summarize what was tried and what failed, and ask the user how to proceed.")
|
|
414
|
+
elif verdict.action == "pivot":
|
|
415
|
+
path = verdict.alternative
|
|
416
|
+
parts.append(f"{advice}. Recommendation: pivot to {path.strategy} - {' -> '.join(path.steps)} "
|
|
417
|
+
f"({verdict.summary()}).")
|
|
418
|
+
else:
|
|
419
|
+
parts.append(f"{advice}. Recommendation: continue on this path ({verdict.summary()}).")
|
|
354
420
|
signature = next((c.error_signature for c in reversed(calls) if c.status == "failure"), None)
|
|
355
421
|
if signature and alert.kind == "failure_loop":
|
|
356
422
|
known = [item for item in self.store.list("Lesson", "kind", "resolution", limit=500) if signature in item.text]
|
|
@@ -407,6 +473,7 @@ class Engine:
|
|
|
407
473
|
strategy=outcome.chosen_strategy, status=status, elapsed_seconds=outcome.elapsed_seconds,
|
|
408
474
|
tool_calls=outcome.tool_calls, output_tokens_estimate=outcome.output_tokens_estimate, files=files[:20],
|
|
409
475
|
benefited=bool(context and context.experience_ids), estimated_regret=outcome.estimated_regret,
|
|
476
|
+
alerts=[a for a in execution.alerts if not a.startswith("retry:")], verdicts=list(execution.verdicts),
|
|
410
477
|
embedding=embed(task.description), created_at=now)
|
|
411
478
|
self.store.put(experience)
|
|
412
479
|
self.store.link(outcome.id, "caused", experience.id)
|
|
@@ -422,7 +489,7 @@ class Engine:
|
|
|
422
489
|
|
|
423
490
|
|
|
424
491
|
def render_context(task: Task, paths: list[CandidatePath], retrieved: list[tuple[Experience, float]],
|
|
425
|
-
lessons: list[tuple[Lesson, float, int]]) -> str:
|
|
492
|
+
lessons: list[tuple[Lesson, float, int]], budget: Budget | None = None) -> str:
|
|
426
493
|
"""Compact Execution Context. Budgeted in characters so it stays a small, predictable token cost."""
|
|
427
494
|
experiences = [e for e, _ in retrieved]
|
|
428
495
|
succeeded = sum(e.status == "success" for e in experiences)
|
|
@@ -432,7 +499,15 @@ def render_context(task: Task, paths: list[CandidatePath], retrieved: list[tuple
|
|
|
432
499
|
lines.append(f"Suggested path: {best.strategy} - {' -> '.join(best.steps)} "
|
|
433
500
|
f"(success~{best.success_probability:.0%}, ~{best.tool_calls:.0f} tool calls, {basis}).")
|
|
434
501
|
if rest:
|
|
435
|
-
|
|
502
|
+
def label(p: CandidatePath) -> str:
|
|
503
|
+
if p.dominated_by:
|
|
504
|
+
return f"{p.strategy} (dominated by {p.dominated_by})"
|
|
505
|
+
return f"{p.strategy} ({'over your limit' if not p.within_limits else f'{p.score:+.2f} vs {best.score:+.2f}'})"
|
|
506
|
+
lines.append("Alternatives: " + ", ".join(label(p) for p in rest))
|
|
507
|
+
if budget is not None:
|
|
508
|
+
lines.append(f"Budget: ~{budget.tool_calls:.0f} tool calls, ~{budget.seconds / 60:.0f} min, "
|
|
509
|
+
f"~{budget.context_tokens / 1000:.0f}k tokens of tool output"
|
|
510
|
+
+ (" (your limit)." if budget.source == "limit" else "."))
|
|
436
511
|
files = Counter(f for e in experiences if e.status == "success" for f in e.files[:6])
|
|
437
512
|
if files:
|
|
438
513
|
lines.append("Likely relevant files: " + ", ".join(f for f, _ in files.most_common(6)))
|
|
@@ -21,6 +21,7 @@ import traceback
|
|
|
21
21
|
from dataclasses import dataclass, field
|
|
22
22
|
|
|
23
23
|
from .engine import Engine
|
|
24
|
+
from .privacy import categorize
|
|
24
25
|
from .project import approval, log_error, project_root, should_notify_unapproved
|
|
25
26
|
|
|
26
27
|
AGENTS = ("claude-code", "codex", "cursor", "opencode")
|
|
@@ -72,8 +73,42 @@ def response_failure(response: object) -> tuple[bool, str | None]:
|
|
|
72
73
|
return False, None
|
|
73
74
|
if isinstance(response, str):
|
|
74
75
|
match = EXIT_CODE.search(response[:400])
|
|
75
|
-
if match
|
|
76
|
-
return
|
|
76
|
+
if match:
|
|
77
|
+
return int(match.group(1)) != 0, response[:4000] if int(match.group(1)) != 0 else None
|
|
78
|
+
return False, None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Summary lines that tools print when a run fails. Codex reports shell results as plain output with no exit
|
|
82
|
+
# code, so for verification commands the output itself is the only failure signal.
|
|
83
|
+
OUTPUT_FAILURE = re.compile(
|
|
84
|
+
r"(?im)"
|
|
85
|
+
r"\b\d+ (failed|errors?)\b.*\bin \d" # pytest summary: "1 failed, 2 passed in 0.1s"
|
|
86
|
+
r"|^={3,} (FAILURES|ERRORS) ={3,}" # pytest sections
|
|
87
|
+
r"|^FAILED \((failures|errors)=" # unittest
|
|
88
|
+
r"|^Tests?:\s+\d+ failed" # jest / vitest
|
|
89
|
+
r"|^(--- )?FAIL\b" # go test, jest file lines
|
|
90
|
+
r"|test result: FAILED" # cargo test
|
|
91
|
+
r"|error(\[E\d+\])?: could not compile|^error\[E\d+\]" # rustc
|
|
92
|
+
r"|\berror TS\d+:" # tsc
|
|
93
|
+
r"|✖ \d+ problems?" # eslint
|
|
94
|
+
r"|^Found \d+ errors?\b" # ruff, mypy
|
|
95
|
+
r"|npm ERR!|ERR_PNPM_|Command failed with exit code [1-9]"
|
|
96
|
+
r"|BUILD FAILED|^FAILURE: Build failed" # ant, gradle
|
|
97
|
+
)
|
|
98
|
+
SHELL_FAILURE = re.compile(
|
|
99
|
+
r"(?i)is not recognized as (the name of a cmdlet|an internal or external command)"
|
|
100
|
+
r"|CommandNotFoundException|: command not found|No such file or directory"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def output_failure(category: str, output: str) -> tuple[bool, str | None]:
|
|
105
|
+
"""Failure inferred from command output, for agents whose hook payloads carry no exit status."""
|
|
106
|
+
tail = output[-6000:]
|
|
107
|
+
# Only short outputs: a long listing or log that merely contains "not found" text is not a failed command.
|
|
108
|
+
if len(output) < 800 and SHELL_FAILURE.search(output):
|
|
109
|
+
return True, output
|
|
110
|
+
if category in {"test", "lint", "build"} and OUTPUT_FAILURE.search(tail):
|
|
111
|
+
return True, tail[-4000:]
|
|
77
112
|
return False, None
|
|
78
113
|
|
|
79
114
|
|
|
@@ -119,6 +154,9 @@ def normalize(agent: str, name: str, payload: dict) -> Event:
|
|
|
119
154
|
response = payload.get("tool_response")
|
|
120
155
|
event.output_chars = _size(response)
|
|
121
156
|
failed, error = response_failure(response)
|
|
157
|
+
if agent == "codex" and not failed and isinstance(response, str) and not EXIT_CODE.search(response[:400]):
|
|
158
|
+
# Codex shell results are plain output without an exit status (verified live on codex-cli 0.154).
|
|
159
|
+
failed, error = output_failure(categorize(event.tool, event.arguments), response)
|
|
122
160
|
event.success, event.error = not failed, error
|
|
123
161
|
return event
|
|
124
162
|
|