openreflex 0.1.3__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.3/src/openreflex.egg-info → openreflex-0.2.0}/PKG-INFO +21 -9
- {openreflex-0.1.3 → openreflex-0.2.0}/README.md +20 -8
- {openreflex-0.1.3 → openreflex-0.2.0}/pyproject.toml +1 -1
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/__init__.py +1 -1
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/cli.py +2 -0
- openreflex-0.2.0/src/openreflex/control.py +119 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/detect.py +11 -9
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/engine.py +109 -34
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/mcp_server.py +48 -11
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/metrics.py +9 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/models.py +10 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/project.py +2 -1
- openreflex-0.2.0/src/openreflex/routing.py +176 -0
- {openreflex-0.1.3 → openreflex-0.2.0/src/openreflex.egg-info}/PKG-INFO +21 -9
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/SOURCES.txt +2 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/tests/test_engine.py +1 -1
- {openreflex-0.1.3 → 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.3/src/openreflex/routing.py +0 -66
- {openreflex-0.1.3 → openreflex-0.2.0}/LICENSE +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/setup.cfg +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/__main__.py +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/benchmark.py +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/hooks.py +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/install.py +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/integrations/opencode.ts +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/learning.py +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/privacy.py +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/store.py +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/dependency_links.txt +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/entry_points.txt +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/requires.txt +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/top_level.txt +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/tests/test_hooks.py +0 -0
- {openreflex-0.1.3 → openreflex-0.2.0}/tests/test_install_cli.py +0 -0
- {openreflex-0.1.3 → 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
|
|
@@ -140,16 +140,28 @@ Execution -failed_with-> ToolCall -resolved_by-> ToolCall
|
|
|
140
140
|
Execution -caused-> Outcome -caused-> Experience -caused-> Lesson
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
- **Before a task:** similar experiences are retrieved
|
|
144
|
-
`incremental`) are
|
|
145
|
-
reversibility, and
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
148
155
|
- **After a task:** outcome and chosen path come from the agent's `record_outcome` / `choose_path` MCP calls when
|
|
149
|
-
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.
|
|
150
159
|
|
|
151
|
-
|
|
152
|
-
|
|
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`.
|
|
153
165
|
|
|
154
166
|
## CLI
|
|
155
167
|
|
|
@@ -108,16 +108,28 @@ Execution -failed_with-> ToolCall -resolved_by-> ToolCall
|
|
|
108
108
|
Execution -caused-> Outcome -caused-> Experience -caused-> Lesson
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
-
- **Before a task:** similar experiences are retrieved
|
|
112
|
-
`incremental`) are
|
|
113
|
-
reversibility, and
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
116
123
|
- **After a task:** outcome and chosen path come from the agent's `record_outcome` / `choose_path` MCP calls when
|
|
117
|
-
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.
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
|
|
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`.
|
|
121
133
|
|
|
122
134
|
## CLI
|
|
123
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)))
|
|
@@ -9,11 +9,13 @@ from mcp.server.fastmcp import FastMCP
|
|
|
9
9
|
from . import metrics
|
|
10
10
|
from .engine import Engine
|
|
11
11
|
from .project import approval, approve, project_root
|
|
12
|
+
from .routing import Limits
|
|
12
13
|
|
|
13
14
|
INSTRUCTIONS = """OpenReflex is local execution memory for this project. Hooks capture work automatically.
|
|
14
15
|
Use get_execution_context before a substantial task if no [OpenReflex] context was already provided.
|
|
15
|
-
Call choose_path if you deliberately take a different approach than suggested,
|
|
16
|
-
result is verified (tests pass, user confirmed).
|
|
16
|
+
Call choose_path if you deliberately take a different approach than suggested, check_progress when unsure
|
|
17
|
+
whether more work is paying off, and record_outcome once the result is verified (tests pass, user confirmed).
|
|
18
|
+
Never call approve_project unless the user asked for it."""
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def build_server(project: Path) -> FastMCP:
|
|
@@ -38,17 +40,52 @@ def build_server(project: Path) -> FastMCP:
|
|
|
38
40
|
instance.close()
|
|
39
41
|
|
|
40
42
|
@server.tool()
|
|
41
|
-
def get_execution_context(task: str
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
def get_execution_context(task: str, max_tool_calls: int | None = None, max_minutes: float | None = None,
|
|
44
|
+
max_context_tokens: int | None = None) -> str:
|
|
45
|
+
"""Retrieve relevant past experience for a task: candidate strategies scored on success, time, tool calls,
|
|
46
|
+
context, risk, uncertainty and reversibility (dominated ones marked), an execution budget, likely files,
|
|
47
|
+
and lessons. Call before substantial work when no [OpenReflex] context was injected. Optional max_* limits
|
|
48
|
+
constrain the plan and budget."""
|
|
44
49
|
def operation(e: Engine):
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
given = [value for value in (max_tool_calls, max_minutes, max_context_tokens) if value is not None]
|
|
51
|
+
if any(value <= 0 for value in given):
|
|
52
|
+
raise ValueError("limits must be positive")
|
|
53
|
+
limits = Limits(max_minutes * 60 if max_minutes else None, max_tool_calls, max_context_tokens) if given else None
|
|
54
|
+
execution, context = e.context_for(task, limits=limits)
|
|
55
|
+
paths = e.store.list("CandidatePath", "task_id", execution.task_id)
|
|
56
|
+
order = [execution.recommended_path_id] + [p.id for p in sorted(paths, key=lambda p: -p.score)]
|
|
57
|
+
paths = sorted(paths, key=lambda p: order.index(p.id))
|
|
58
|
+
|
|
59
|
+
def status(p) -> str:
|
|
60
|
+
return (f"dominated by {p.dominated_by}" if p.dominated_by else
|
|
61
|
+
"over the limits" if not p.within_limits else "Pareto-efficient")
|
|
47
62
|
scored = "\n".join(
|
|
48
|
-
f"- {p.strategy}: score {p.score:+.3f}, success~{p.success_probability:.0%},
|
|
49
|
-
f"~{p.time_seconds / 60:.0f} min,
|
|
50
|
-
f"
|
|
51
|
-
|
|
63
|
+
f"- {p.strategy}: score {p.score:+.3f} ({status(p)}), success~{p.success_probability:.0%}, "
|
|
64
|
+
f"~{p.tool_calls:.0f} calls, ~{p.time_seconds / 60:.0f} min, ~{p.context_tokens / 1000:.1f}k tokens, "
|
|
65
|
+
f"risk {p.risk:.2f}, uncertainty {p.uncertainty:.2f}, reversibility {p.reversibility:.2f}, "
|
|
66
|
+
f"expected regret {p.expected_regret:.3f}, evidence n={p.evidence_count}" for p in paths)
|
|
67
|
+
return f"{context.text}\n\nCandidate paths:\n{scored}\n(execution {execution.id})"
|
|
68
|
+
return run(operation)
|
|
69
|
+
|
|
70
|
+
@server.tool()
|
|
71
|
+
def check_progress() -> str:
|
|
72
|
+
"""Estimate whether more work on the current path is still worth it: continue, pivot to another strategy,
|
|
73
|
+
or stop and ask the user. Based on progress so far, failures and the execution budget. Records nothing."""
|
|
74
|
+
def operation(e: Engine):
|
|
75
|
+
execution, verdict, problems, budget = e.progress()
|
|
76
|
+
action = verdict.action if problems else "continue"
|
|
77
|
+
lines = [f"Recommendation: {action}" + ("" if problems else " (no signs of trouble)") + ".",
|
|
78
|
+
f"Estimate: {verdict.summary()}.",
|
|
79
|
+
f"Marginal value: continue {verdict.value_continue:+.3f}"
|
|
80
|
+
+ (f", pivot to {verdict.alternative.strategy} {verdict.value_pivot:+.3f}" if verdict.alternative else "")
|
|
81
|
+
+ ", stop +0.000.",
|
|
82
|
+
f"Budget: ~{budget.tool_calls:.0f} tool calls, ~{budget.seconds / 60:.0f} min, "
|
|
83
|
+
f"~{budget.context_tokens / 1000:.0f}k tokens of tool output ({budget.source})."]
|
|
84
|
+
if problems:
|
|
85
|
+
lines.append("Detected: " + "; ".join(p.detail for p in problems) + ".")
|
|
86
|
+
if action == "pivot":
|
|
87
|
+
lines.append(f"{verdict.alternative.strategy}: {' -> '.join(verdict.alternative.steps)}.")
|
|
88
|
+
return "\n".join(lines) + f"\n(execution {execution.id})"
|
|
52
89
|
return run(operation)
|
|
53
90
|
|
|
54
91
|
@server.tool()
|