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.
Files changed (36) hide show
  1. {openreflex-0.1.3/src/openreflex.egg-info → openreflex-0.2.0}/PKG-INFO +21 -9
  2. {openreflex-0.1.3 → openreflex-0.2.0}/README.md +20 -8
  3. {openreflex-0.1.3 → openreflex-0.2.0}/pyproject.toml +1 -1
  4. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/__init__.py +1 -1
  5. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/cli.py +2 -0
  6. openreflex-0.2.0/src/openreflex/control.py +119 -0
  7. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/detect.py +11 -9
  8. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/engine.py +109 -34
  9. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/mcp_server.py +48 -11
  10. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/metrics.py +9 -0
  11. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/models.py +10 -0
  12. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/project.py +2 -1
  13. openreflex-0.2.0/src/openreflex/routing.py +176 -0
  14. {openreflex-0.1.3 → openreflex-0.2.0/src/openreflex.egg-info}/PKG-INFO +21 -9
  15. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/SOURCES.txt +2 -0
  16. {openreflex-0.1.3 → openreflex-0.2.0}/tests/test_engine.py +1 -1
  17. {openreflex-0.1.3 → openreflex-0.2.0}/tests/test_mcp_metrics_benchmark.py +3 -3
  18. openreflex-0.2.0/tests/test_routing_control.py +160 -0
  19. openreflex-0.1.3/src/openreflex/routing.py +0 -66
  20. {openreflex-0.1.3 → openreflex-0.2.0}/LICENSE +0 -0
  21. {openreflex-0.1.3 → openreflex-0.2.0}/setup.cfg +0 -0
  22. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/__main__.py +0 -0
  23. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/benchmark.py +0 -0
  24. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/hooks.py +0 -0
  25. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/install.py +0 -0
  26. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/integrations/opencode.ts +0 -0
  27. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/learning.py +0 -0
  28. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/privacy.py +0 -0
  29. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex/store.py +0 -0
  30. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/dependency_links.txt +0 -0
  31. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/entry_points.txt +0 -0
  32. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/requires.txt +0 -0
  33. {openreflex-0.1.3 → openreflex-0.2.0}/src/openreflex.egg-info/top_level.txt +0 -0
  34. {openreflex-0.1.3 → openreflex-0.2.0}/tests/test_hooks.py +0 -0
  35. {openreflex-0.1.3 → openreflex-0.2.0}/tests/test_install_cli.py +0 -0
  36. {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.1.3
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, three strategies (`inspect-first`, `test-first`,
144
- `incremental`) are scored on success probability, time, tool calls, context cost, risk, uncertainty, and
145
- reversibility, and a context of at most 1,400 characters is injected. Nothing is injected without relevant
146
- experience.
147
- - **During a task:** each alert fires at most once per task with a cooldown, and names a concrete alternative.
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 is withheld when the outcome is unknown.
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
- Agents can also query OpenReflex directly through its MCP server: `get_execution_context`, `choose_path`,
152
- `record_outcome`, `search_experience`, `explain_node`, `project_insights`, and `approve_project`.
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, three strategies (`inspect-first`, `test-first`,
112
- `incremental`) are scored on success probability, time, tool calls, context cost, risk, uncertainty, and
113
- reversibility, and a context of at most 1,400 characters is injected. Nothing is injected without relevant
114
- experience.
115
- - **During a task:** each alert fires at most once per task with a cooldown, and names a concrete alternative.
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 is withheld when the outcome is unknown.
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
- Agents can also query OpenReflex directly through its MCP server: `get_execution_context`, `choose_path`,
120
- `record_outcome`, `search_experience`, `explain_node`, `project_insights`, and `approve_project`.
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.1.3"
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"
@@ -1,3 +1,3 @@
1
1
  """Ambient, local execution intelligence."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.2.0"
@@ -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 CandidatePath, Execution, ToolCall
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], recommended: CandidatePath | None, now: float) -> list[Alert]:
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
- budget = max(CONTEXT_FLOOR_TOKENS, 2 * (recommended.context_tokens if recommended else 8000))
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
- if recommended and len(calls) > max(30, 2.5 * recommended.tool_calls) and not any(
71
- c.category in PROGRESS and c.status == "success" for c in calls[-8:]):
72
- alerts.append(Alert("over_budget", 0.5, f"{len(calls)} tool calls vs ~{recommended.tool_calls:.0f} expected"))
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) -> tuple[Execution, Context]:
159
- """Explicit request for an Execution Context; always returns the full text, even without experience."""
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
- self._describe(current, description, now)
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
- paths = candidates(task.id, task.task_class, self._evidence(task.task_class, task.description))
202
- return render_context(task, paths, retrieved, self.lessons([x for x, _ in retrieved]))
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
- paths = candidates(task.id, task.task_class, self._evidence(task.task_class, task.description))
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
- paths = candidates(task.id, task.task_class, self._evidence(task.task_class, task.description))
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
- execution.chosen_path_id = None if execution.chosen_inferred else execution.chosen_path_id
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 _alert(self, execution: Execution, calls: list[ToolCall], now: float) -> str | None:
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
- alert = detect.select(execution, detect.detect(execution, calls, recommended, now), now)
336
- if alert is None:
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
- execution.alerts.append(alert.kind)
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
- task = self._task(execution)
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": "Stop retrying the same fix: re-read the error and question the assumption behind it",
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 well past the expected effort; re-scope or re-plan",
408
+ "over_budget": "This is past the execution budget; re-scope or re-plan",
350
409
  }[alert.kind]
351
- parts = [f"OpenReflex: {alert.detail}. {advice}."]
352
- if alternative and alert.kind in {"failure_loop", "stagnation", "over_budget"}:
353
- parts.append(f"Alternative path - {alternative.strategy}: {' -> '.join(alternative.steps)}.")
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
- lines.append("Alternatives: " + ", ".join(f"{p.strategy} ({p.score:+.2f} vs {best.score:+.2f})" for p in rest))
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, and record_outcome once the
16
- result is verified (tests pass, user confirmed). Never call approve_project unless the user asked for it."""
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) -> str:
42
- """Retrieve relevant past experience for a task, with 2-3 scored candidate strategies, likely files, and
43
- lessons. Call before substantial work when no [OpenReflex] context was injected."""
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
- execution, context = e.context_for(task)
46
- paths = sorted(e.store.list("CandidatePath", "task_id", execution.task_id), key=lambda p: -p.score)
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%}, ~{p.tool_calls:.0f} calls, "
49
- f"~{p.time_seconds / 60:.0f} min, risk {p.risk:.2f}, uncertainty {p.uncertainty:.2f}, "
50
- f"reversibility {p.reversibility:.2f}, evidence n={p.evidence_count}" for p in paths)
51
- return f"{context.text}\n\nCandidate scores:\n{scored}\n(execution {execution.id})"
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()