openreflex 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openreflex-0.3.0/src/openreflex.egg-info → openreflex-0.3.1}/PKG-INFO +2 -2
- {openreflex-0.3.0 → openreflex-0.3.1}/README.md +1 -1
- {openreflex-0.3.0 → openreflex-0.3.1}/pyproject.toml +1 -1
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/__init__.py +1 -1
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/engine.py +30 -0
- openreflex-0.3.1/src/openreflex/mcp_server.py +355 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/store.py +5 -0
- {openreflex-0.3.0 → openreflex-0.3.1/src/openreflex.egg-info}/PKG-INFO +2 -2
- {openreflex-0.3.0 → openreflex-0.3.1}/tests/test_mcp_metrics_benchmark.py +23 -5
- openreflex-0.3.0/src/openreflex/mcp_server.py +0 -280
- {openreflex-0.3.0 → openreflex-0.3.1}/LICENSE +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/setup.cfg +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/__main__.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/benchmark.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/cli.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/control.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/decision.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/detect.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/hooks.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/install.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/integrations/opencode.ts +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/learning.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/metrics.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/models.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/policies/default.toml +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/policy.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/privacy.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/project.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex/routing.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex.egg-info/SOURCES.txt +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex.egg-info/dependency_links.txt +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex.egg-info/entry_points.txt +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex.egg-info/requires.txt +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/src/openreflex.egg-info/top_level.txt +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/tests/test_engine.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/tests/test_hooks.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/tests/test_install_cli.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/tests/test_policy_recap.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/tests/test_reliability_lifecycle.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/tests/test_routing_control.py +0 -0
- {openreflex-0.3.0 → openreflex-0.3.1}/tests/test_stress.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openreflex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Muscle memory for AI coding agents: learns from every task, advises the next one, and flags failure loops live
|
|
5
5
|
Author: vishnu-77
|
|
6
6
|
License-Expression: MIT
|
|
@@ -180,7 +180,7 @@ Routing, scoring, budget and recap settings come from a versioned policy: the pa
|
|
|
180
180
|
Agents can also query OpenReflex directly through its MCP server: `get_execution_context` (optionally with
|
|
181
181
|
`max_tool_calls`, `max_minutes`, `max_context_tokens`), `choose_path`, `check_progress`, `explain_decision`,
|
|
182
182
|
`get_execution_trace`, `get_reflex_score`, `record_outcome`, `search_experience`, `explain_node`,
|
|
183
|
-
`
|
|
183
|
+
`get_project_insights`, `approve_project`, and `forget_experience` (deletes one past task's memory, only when the user asks).
|
|
184
184
|
|
|
185
185
|
## CLI
|
|
186
186
|
|
|
@@ -148,7 +148,7 @@ Routing, scoring, budget and recap settings come from a versioned policy: the pa
|
|
|
148
148
|
Agents can also query OpenReflex directly through its MCP server: `get_execution_context` (optionally with
|
|
149
149
|
`max_tool_calls`, `max_minutes`, `max_context_tokens`), `choose_path`, `check_progress`, `explain_decision`,
|
|
150
150
|
`get_execution_trace`, `get_reflex_score`, `record_outcome`, `search_experience`, `explain_node`,
|
|
151
|
-
`
|
|
151
|
+
`get_project_insights`, `approve_project`, and `forget_experience` (deletes one past task's memory, only when the user asks).
|
|
152
152
|
|
|
153
153
|
## CLI
|
|
154
154
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "openreflex"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Muscle memory for AI coding agents: learns from every task, advises the next one, and flags failure loops live"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -216,6 +216,36 @@ class Engine:
|
|
|
216
216
|
raise ValueError("No execution to record an outcome for")
|
|
217
217
|
return self._finalize(execution, status=status, evidence=redact(evidence, 500), verified=True)
|
|
218
218
|
|
|
219
|
+
def forget_experience(self, experience_id: str) -> dict[str, int]:
|
|
220
|
+
"""Delete one past task and everything learned from it; returns how many nodes of each kind were removed."""
|
|
221
|
+
with self.store.transaction():
|
|
222
|
+
experience = self.store.get(experience_id) if self.store.exists(experience_id) else None
|
|
223
|
+
if not isinstance(experience, Experience):
|
|
224
|
+
raise ValueError("Unknown experience id")
|
|
225
|
+
removed: Counter = Counter()
|
|
226
|
+
|
|
227
|
+
def drop(node) -> None:
|
|
228
|
+
removed[type(node).__name__] += 1
|
|
229
|
+
self.store.delete(node.id)
|
|
230
|
+
|
|
231
|
+
for execution in self.store.list("Execution", "task_id", experience.task_id):
|
|
232
|
+
for call in self.store.list("ToolCall", "execution_id", execution.id, limit=5000):
|
|
233
|
+
drop(call)
|
|
234
|
+
for outcome in self.store.find("Outcome", execution_id=execution.id):
|
|
235
|
+
drop(outcome)
|
|
236
|
+
for item in self.store.find("Experience", execution_id=execution.id):
|
|
237
|
+
for lesson in self.store.list("Lesson", "experience_id", item.id):
|
|
238
|
+
drop(lesson)
|
|
239
|
+
drop(item)
|
|
240
|
+
self.store.forget_execution(execution.id)
|
|
241
|
+
drop(execution)
|
|
242
|
+
for kind in ("Context", "CandidatePath"):
|
|
243
|
+
for node in self.store.list(kind, "task_id", experience.task_id):
|
|
244
|
+
drop(node)
|
|
245
|
+
if self.store.exists(experience.task_id):
|
|
246
|
+
drop(self.store.get(experience.task_id))
|
|
247
|
+
return dict(removed)
|
|
248
|
+
|
|
219
249
|
def preview(self, description: str) -> str:
|
|
220
250
|
task = Task(description=redact(description, 1000), task_class=classify(description), session_id="preview",
|
|
221
251
|
agent="preview", started_at=self.clock())
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""MCP stdio server. Hooks do the ambient capture; these tools let an agent ask for and report on execution."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import FastMCP
|
|
9
|
+
from mcp.types import ToolAnnotations
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
|
|
12
|
+
from . import metrics
|
|
13
|
+
from .engine import Engine
|
|
14
|
+
from .project import approval, approve, project_root
|
|
15
|
+
from .routing import Limits
|
|
16
|
+
|
|
17
|
+
INSTRUCTIONS = """OpenReflex is local execution memory for this project. Hooks capture work automatically.
|
|
18
|
+
Use get_execution_context before a substantial task if no [OpenReflex] context was already provided.
|
|
19
|
+
Call choose_path if you deliberately take a different approach than suggested, check_progress when unsure
|
|
20
|
+
whether more work is paying off, and record_outcome once the result is verified (tests pass, user confirmed).
|
|
21
|
+
Use explain_decision or get_execution_trace when the user asks why OpenReflex recommended something.
|
|
22
|
+
Never call approve_project or forget_experience unless the user explicitly asked for it."""
|
|
23
|
+
|
|
24
|
+
# Every tool works on local data only. Reads never change the Experience Graph; writes never touch project files.
|
|
25
|
+
READ = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)
|
|
26
|
+
WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=False)
|
|
27
|
+
PLAN = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False)
|
|
28
|
+
DELETE = ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=True, openWorldHint=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_server(project: Path) -> FastMCP:
|
|
32
|
+
server = FastMCP("openreflex", instructions=INSTRUCTIONS, log_level="WARNING")
|
|
33
|
+
|
|
34
|
+
def engine() -> Engine:
|
|
35
|
+
if not approval(project):
|
|
36
|
+
raise PermissionError(f"OpenReflex is not enabled for {project}. The user can run "
|
|
37
|
+
"`openreflex approve` (or ask you to call approve_project).")
|
|
38
|
+
return Engine(project)
|
|
39
|
+
|
|
40
|
+
def tool(title: str, annotations: ToolAnnotations):
|
|
41
|
+
"""Register a tool whose description is its docstring."""
|
|
42
|
+
def register(function):
|
|
43
|
+
return server.tool(title=title, description=inspect.cleandoc(function.__doc__ or ""),
|
|
44
|
+
annotations=annotations)(function)
|
|
45
|
+
return register
|
|
46
|
+
|
|
47
|
+
def run(operation):
|
|
48
|
+
try:
|
|
49
|
+
instance = engine()
|
|
50
|
+
except PermissionError as error:
|
|
51
|
+
return str(error)
|
|
52
|
+
try:
|
|
53
|
+
return operation(instance)
|
|
54
|
+
except ValueError as error:
|
|
55
|
+
return f"OpenReflex: {error}"
|
|
56
|
+
finally:
|
|
57
|
+
instance.close()
|
|
58
|
+
|
|
59
|
+
@tool("Get execution context", PLAN)
|
|
60
|
+
def get_execution_context(
|
|
61
|
+
task: Annotated[str, Field(description="The task in one or two plain sentences, e.g. 'Fix the login redirect "
|
|
62
|
+
"loop after logout'. Used to find similar past tasks; secrets are "
|
|
63
|
+
"redacted before it is stored.")],
|
|
64
|
+
max_tool_calls: Annotated[int | None, Field(description="Optional cap on tool calls for this task; a "
|
|
65
|
+
"positive integer. Default: no cap.")] = None,
|
|
66
|
+
max_minutes: Annotated[float | None, Field(description="Optional cap on active working time in minutes; "
|
|
67
|
+
"a positive number. Default: no cap.")] = None,
|
|
68
|
+
max_context_tokens: Annotated[int | None, Field(description="Optional cap on tokens of tool output added "
|
|
69
|
+
"to context; a positive integer. Default: no "
|
|
70
|
+
"cap.")] = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Plan a task from this project's past experience.
|
|
73
|
+
Returns: plain text: the [OpenReflex] context block (similar past tasks, the suggested strategy with
|
|
74
|
+
alternatives, an execution budget, likely relevant files, lessons), then each candidate strategy's estimates
|
|
75
|
+
(success, tool calls, minutes, context tokens, risk, uncertainty, reversibility, expected regret, evidence
|
|
76
|
+
count; dominated or over-limit strategies are marked) and the execution id.
|
|
77
|
+
Use when: starting a bug fix, feature, refactor or migration and no [OpenReflex] block was injected.
|
|
78
|
+
Not for: looking up history (use search_experience) or checking progress mid-task (use check_progress).
|
|
79
|
+
Side effects: starts or re-plans the current task in the local Experience Graph; touches no project files,
|
|
80
|
+
runs no commands, sends nothing over the network. Calling it again for the same task returns the same plan
|
|
81
|
+
unless new limits are given.
|
|
82
|
+
Errors: 'limits must be positive' for a zero or negative limit; a 'not enabled' message until the project
|
|
83
|
+
is approved."""
|
|
84
|
+
def operation(e: Engine):
|
|
85
|
+
given = [value for value in (max_tool_calls, max_minutes, max_context_tokens) if value is not None]
|
|
86
|
+
if any(value <= 0 for value in given):
|
|
87
|
+
raise ValueError("limits must be positive")
|
|
88
|
+
limits = Limits(max_minutes * 60 if max_minutes else None, max_tool_calls, max_context_tokens) if given else None
|
|
89
|
+
execution, context = e.context_for(task, limits=limits)
|
|
90
|
+
paths = e.store.list("CandidatePath", "task_id", execution.task_id)
|
|
91
|
+
order = [execution.recommended_path_id] + [p.id for p in sorted(paths, key=lambda p: -p.score)]
|
|
92
|
+
paths = sorted(paths, key=lambda p: order.index(p.id))
|
|
93
|
+
|
|
94
|
+
def status(p) -> str:
|
|
95
|
+
return (f"dominated by {p.dominated_by}" if p.dominated_by else
|
|
96
|
+
"over the limits" if not p.within_limits else "Pareto-efficient")
|
|
97
|
+
scored = "\n".join(
|
|
98
|
+
f"- {p.strategy}: score {p.score:+.3f} ({status(p)}), success~{p.success_probability:.0%}, "
|
|
99
|
+
f"~{p.tool_calls:.0f} calls, ~{p.time_seconds / 60:.0f} min, ~{p.context_tokens / 1000:.1f}k tokens, "
|
|
100
|
+
f"risk {p.risk:.2f}, uncertainty {p.uncertainty:.2f}, reversibility {p.reversibility:.2f}, "
|
|
101
|
+
f"expected regret {p.expected_regret:.3f}, evidence n={p.evidence_count}" for p in paths)
|
|
102
|
+
return f"{context.text}\n\nCandidate paths:\n{scored}\n(execution {execution.id})"
|
|
103
|
+
return run(operation)
|
|
104
|
+
|
|
105
|
+
@tool("Check progress", READ)
|
|
106
|
+
def check_progress() -> str:
|
|
107
|
+
"""Estimate whether more work on the current task's path is still worth it.
|
|
108
|
+
Returns: plain text starting with 'Recommendation: continue', 'pivot' or 'stop', then the success estimates
|
|
109
|
+
for the current path and the best alternative, the marginal value of each option, the execution budget and
|
|
110
|
+
how much of it is used, and any detected problems (failure loop, repeated calls, stalled progress, context
|
|
111
|
+
growth, budget overrun).
|
|
112
|
+
Use when: unsure mid-task whether to keep going. Follow 'pivot' by switching to the named strategy; follow
|
|
113
|
+
'stop' by summarizing what was tried and asking the user.
|
|
114
|
+
Not for: planning a task (use get_execution_context).
|
|
115
|
+
Side effects: none; read-only on the most recent task.
|
|
116
|
+
Errors: asks for get_execution_context first when no task exists; a 'not enabled' message until the project
|
|
117
|
+
is approved."""
|
|
118
|
+
def operation(e: Engine):
|
|
119
|
+
execution, verdict, problems, budget = e.progress()
|
|
120
|
+
action = verdict.action if problems else "continue"
|
|
121
|
+
lines = [f"Recommendation: {action}" + ("" if problems else " (no signs of trouble)") + ".",
|
|
122
|
+
f"Estimate: {verdict.summary()}.",
|
|
123
|
+
f"Marginal value: continue {verdict.value_continue:+.3f}"
|
|
124
|
+
+ (f", pivot to {verdict.alternative.strategy} {verdict.value_pivot:+.3f}" if verdict.alternative else "")
|
|
125
|
+
+ ", stop +0.000.",
|
|
126
|
+
f"Budget: ~{budget.tool_calls:.0f} tool calls, ~{budget.seconds / 60:.0f} min, "
|
|
127
|
+
f"~{budget.context_tokens / 1000:.0f}k tokens of tool output ({budget.source})."]
|
|
128
|
+
if problems:
|
|
129
|
+
lines.append("Detected: " + "; ".join(p.detail for p in problems) + ".")
|
|
130
|
+
if action == "pivot":
|
|
131
|
+
lines.append(f"{verdict.alternative.strategy}: {' -> '.join(verdict.alternative.steps)}.")
|
|
132
|
+
return "\n".join(lines) + f"\n(execution {execution.id})"
|
|
133
|
+
return run(operation)
|
|
134
|
+
|
|
135
|
+
@tool("Explain decision", READ)
|
|
136
|
+
def explain_decision() -> str:
|
|
137
|
+
"""Explain OpenReflex's latest recommendation for the most recent task.
|
|
138
|
+
Returns: plain text with the recommended strategy, the Reflex Score (0-100: how strong the recommendation
|
|
139
|
+
is, not the chance of success), the estimated success probability, confidence, the signals behind the
|
|
140
|
+
score, the next-best strategy with its route advantage, and the policy version.
|
|
141
|
+
Use when: the user asks why OpenReflex suggested a path, pivot or stop.
|
|
142
|
+
Not for: the same numbers as JSON (use get_reflex_score) or every decision in order (use get_execution_trace).
|
|
143
|
+
Side effects: none; read-only.
|
|
144
|
+
Errors: 'No decision snapshot recorded yet' before any task was planned; a 'not enabled' message until the
|
|
145
|
+
project is approved."""
|
|
146
|
+
return run(lambda e: e.why())
|
|
147
|
+
|
|
148
|
+
@tool("Get execution trace", READ)
|
|
149
|
+
def get_execution_trace() -> str:
|
|
150
|
+
"""Return the decision timeline of the most recent task.
|
|
151
|
+
Returns: plain text, one line per decision in order, with elapsed time, phase (start, runtime, complete),
|
|
152
|
+
action, strategy, Reflex Score and the event that triggered it. Never includes prompts, commands or tool
|
|
153
|
+
output.
|
|
154
|
+
Use when: reviewing how a task unfolded.
|
|
155
|
+
Not for: only the latest decision (use explain_decision).
|
|
156
|
+
Side effects: none; read-only.
|
|
157
|
+
Errors: 'No decision snapshots recorded yet' before any task was planned; a 'not enabled' message until the
|
|
158
|
+
project is approved."""
|
|
159
|
+
return run(lambda e: e.trace())
|
|
160
|
+
|
|
161
|
+
@tool("Get Reflex Score", READ)
|
|
162
|
+
def get_reflex_score() -> str:
|
|
163
|
+
"""Return the latest decision's Reflex Score and its components as JSON.
|
|
164
|
+
Returns: {"available": true, "reflex_score": 0-100 (how strong the recommendation is, not the chance of
|
|
165
|
+
success), "success_probability", "decision_confidence", "strategy", "next_best_strategy", "route_advantage",
|
|
166
|
+
"evidence_count", "context_tokens", "budget_used", "signals": {name: 0-1}, "policy_version"}, or
|
|
167
|
+
{"available": false, "reason"} before any decision exists.
|
|
168
|
+
Use when: a program needs the numbers.
|
|
169
|
+
Not for: a readable explanation (use explain_decision).
|
|
170
|
+
Side effects: none; read-only.
|
|
171
|
+
Errors: a 'not enabled' message until the project is approved."""
|
|
172
|
+
def operation(e: Engine):
|
|
173
|
+
snapshots = e.decision_snapshots()
|
|
174
|
+
if not snapshots:
|
|
175
|
+
return json.dumps({"available": False, "reason": "no decision snapshot recorded yet"})
|
|
176
|
+
snapshot = snapshots[-1]
|
|
177
|
+
return json.dumps({
|
|
178
|
+
"available": True,
|
|
179
|
+
"reflex_score": snapshot.reflex_score,
|
|
180
|
+
"success_probability": snapshot.success_probability,
|
|
181
|
+
"decision_confidence": snapshot.decision_confidence,
|
|
182
|
+
"strategy": snapshot.strategy,
|
|
183
|
+
"next_best_strategy": snapshot.next_best_strategy,
|
|
184
|
+
"route_advantage": snapshot.route_advantage,
|
|
185
|
+
"evidence_count": snapshot.evidence_count,
|
|
186
|
+
"context_tokens": snapshot.context_tokens,
|
|
187
|
+
"budget_used": snapshot.budget_used,
|
|
188
|
+
"signals": snapshot.score_components,
|
|
189
|
+
"policy_version": snapshot.policy_version,
|
|
190
|
+
}, indent=2)
|
|
191
|
+
return run(operation)
|
|
192
|
+
|
|
193
|
+
@tool("Choose path", WRITE)
|
|
194
|
+
def choose_path(
|
|
195
|
+
strategy: Annotated[str, Field(description="The strategy being followed: one of the suggested names "
|
|
196
|
+
"'inspect-first', 'test-first' or 'incremental', or a short "
|
|
197
|
+
"kebab-case name for your own, e.g. 'spike-then-rewrite'.")],
|
|
198
|
+
steps: Annotated[list[str] | None, Field(description="For a custom strategy only: 2-4 short steps, e.g. "
|
|
199
|
+
"['Prototype the toml loader', 'Swap the callers']. "
|
|
200
|
+
"Ignored for suggested strategies.")] = None,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Declare the strategy you are following for the most recent task.
|
|
203
|
+
Returns: one line, 'Recorded chosen path: <strategy>'.
|
|
204
|
+
Use when: deliberately departing from the suggested strategy, so the outcome and Execution Regret (how much
|
|
205
|
+
better the best alternative was estimated to do) are judged against the right plan and the budget follows
|
|
206
|
+
your path.
|
|
207
|
+
Not for: tasks that follow the suggestion; the path is then inferred from tool activity.
|
|
208
|
+
Side effects: writes the choice to the local Experience Graph; calling it again replaces the earlier choice.
|
|
209
|
+
Touches no project files.
|
|
210
|
+
Errors: 'No current execution' before any task was planned (call get_execution_context first); a 'not
|
|
211
|
+
enabled' message until the project is approved."""
|
|
212
|
+
return run(lambda e: f"Recorded chosen path: {e.choose_path(strategy, steps).strategy}")
|
|
213
|
+
|
|
214
|
+
@tool("Record outcome", WRITE)
|
|
215
|
+
def record_outcome(
|
|
216
|
+
status: Annotated[Literal["success", "failure"], Field(description="'success' when tests, lint or build "
|
|
217
|
+
"passed or the user confirmed the result; "
|
|
218
|
+
"'failure' when the task failed or was "
|
|
219
|
+
"abandoned.")],
|
|
220
|
+
evidence: Annotated[str, Field(description="Short proof of the result, e.g. 'pytest tests/test_auth.py "
|
|
221
|
+
"passed' or 'user confirmed the fix'. Up to 500 characters; "
|
|
222
|
+
"secrets are redacted.")],
|
|
223
|
+
) -> str:
|
|
224
|
+
"""Record the verified outcome of the most recent task and learn from it.
|
|
225
|
+
Returns: one line with the recorded status and the Execution Regret estimate (how much better the best
|
|
226
|
+
alternative strategy was estimated to do; 0.00 means none), or 'n/a' with the reason.
|
|
227
|
+
Use when: the result is verified: tests, lint or build passed, the user confirmed, or the task failed or was
|
|
228
|
+
abandoned. Without this call the outcome is inferred from the checks that ran after the last edit, which is
|
|
229
|
+
less reliable.
|
|
230
|
+
Not for: declaring the strategy (use choose_path).
|
|
231
|
+
Side effects: finalizes the task in the local Experience Graph and updates its experience, lessons and
|
|
232
|
+
regret; calling it again for the same task replaces the recorded outcome. Touches no project files.
|
|
233
|
+
Errors: 'No execution to record an outcome for' before any task was planned; a 'not enabled' message until
|
|
234
|
+
the project is approved."""
|
|
235
|
+
def operation(e: Engine):
|
|
236
|
+
outcome = e.record_outcome(status, evidence)
|
|
237
|
+
regret = "n/a" if outcome.estimated_regret is None else f"{outcome.estimated_regret:.3f}"
|
|
238
|
+
return f"Outcome recorded: {outcome.status}. Execution regret {regret} ({outcome.regret_basis})."
|
|
239
|
+
return run(operation)
|
|
240
|
+
|
|
241
|
+
@tool("Search experience", READ)
|
|
242
|
+
def search_experience(
|
|
243
|
+
query: Annotated[str, Field(description="Words describing the topic, e.g. 'expired token login bug'. "
|
|
244
|
+
"Matched lexically against past task descriptions.")],
|
|
245
|
+
limit: Annotated[int, Field(description="Maximum number of past tasks to return, 1-20; values outside the "
|
|
246
|
+
"range are clamped. Default 5.")] = 5,
|
|
247
|
+
) -> str:
|
|
248
|
+
"""Search this project's past tasks by description similarity.
|
|
249
|
+
Returns: JSON with 'experiences' (id, score, description, class, agent, strategy, status, tool_calls,
|
|
250
|
+
minutes, files), best match first, and 'lessons' learned from them (text, confidence, support). Both lists
|
|
251
|
+
are empty when nothing is similar enough.
|
|
252
|
+
Use when: the user asks what was learned or what worked before, or to find an experience id for
|
|
253
|
+
explain_node or forget_experience.
|
|
254
|
+
Not for: planning a new task (use get_execution_context).
|
|
255
|
+
Side effects: none; read-only. Matching is lexical, on task descriptions only.
|
|
256
|
+
Errors: a 'not enabled' message until the project is approved."""
|
|
257
|
+
def operation(e: Engine):
|
|
258
|
+
found = e.retrieve(query, k=max(1, min(limit, 20)))
|
|
259
|
+
lessons = e.lessons([x for x, _ in found], limit=8)
|
|
260
|
+
return json.dumps({
|
|
261
|
+
"experiences": [{"id": x.id, "score": s, "description": x.description, "class": x.task_class,
|
|
262
|
+
"agent": x.agent, "strategy": x.strategy, "status": x.status,
|
|
263
|
+
"tool_calls": x.tool_calls, "minutes": round(x.elapsed_seconds / 60, 1),
|
|
264
|
+
"files": x.files[:8]} for x, s in found],
|
|
265
|
+
"lessons": [{"text": item.text, "confidence": c, "support": n} for item, c, n in lessons],
|
|
266
|
+
}, indent=2)
|
|
267
|
+
return run(operation)
|
|
268
|
+
|
|
269
|
+
@tool("Explain graph node", READ)
|
|
270
|
+
def explain_node(
|
|
271
|
+
node_id: Annotated[str, Field(description="Id of an Experience Graph node: an experience id from "
|
|
272
|
+
"search_experience (starts with 'exp-'), the execution id from "
|
|
273
|
+
"get_execution_context, or any node id from an earlier "
|
|
274
|
+
"explain_node result.")],
|
|
275
|
+
) -> str:
|
|
276
|
+
"""Show one Experience Graph node with its direct relations.
|
|
277
|
+
Returns: JSON with 'root', 'nodes' (each with its kind: Task, Execution, ToolCall, Outcome, Experience,
|
|
278
|
+
Lesson, Context or CandidatePath, plus its stored fields) and 'edges' (used, caused, failed_with,
|
|
279
|
+
resolved_by, recommended_for). Embeddings are omitted.
|
|
280
|
+
Use when: tracing why a lesson or recommendation exists, after finding an id with search_experience or
|
|
281
|
+
get_execution_context.
|
|
282
|
+
Not for: searching by topic (use search_experience).
|
|
283
|
+
Side effects: none; read-only.
|
|
284
|
+
Errors: 'Unknown graph node' for an id that does not exist; a 'not enabled' message until the project is
|
|
285
|
+
approved."""
|
|
286
|
+
def operation(e: Engine):
|
|
287
|
+
graph = e.store.graph(node_id)
|
|
288
|
+
for node in graph["nodes"]:
|
|
289
|
+
node.pop("embedding", None)
|
|
290
|
+
return json.dumps(graph, indent=2, default=str)
|
|
291
|
+
return run(operation)
|
|
292
|
+
|
|
293
|
+
@tool("Get project insights", READ)
|
|
294
|
+
def get_project_insights() -> str:
|
|
295
|
+
"""Summarize what OpenReflex has recorded and learned in this project.
|
|
296
|
+
Returns: JSON with engagement (tasks, experiences, lessons), experience reuse (share of tasks that received
|
|
297
|
+
past experience), outcomes (known, verified, success rate), efficiency with and without prior experience
|
|
298
|
+
(observational, not a controlled comparison), the Execution Regret trend, routing agreement (how often the
|
|
299
|
+
recommended strategy matched the one that did best afterwards), live alerts, verdicts (continue, pivot,
|
|
300
|
+
stop) and budget adherence.
|
|
301
|
+
Use when: the user asks how OpenReflex is doing in this project.
|
|
302
|
+
Not for: individual past tasks (use search_experience).
|
|
303
|
+
Side effects: none; read-only.
|
|
304
|
+
Errors: a 'not enabled' message until the project is approved."""
|
|
305
|
+
return run(lambda e: json.dumps(metrics.project_metrics(e, approval(project)), indent=2))
|
|
306
|
+
|
|
307
|
+
@tool("Enable project", WRITE)
|
|
308
|
+
def approve_project(
|
|
309
|
+
confirm: Annotated[bool, Field(description="true only when the user has explicitly asked to enable "
|
|
310
|
+
"OpenReflex for this project; with false (the default) nothing "
|
|
311
|
+
"changes.")] = False,
|
|
312
|
+
) -> str:
|
|
313
|
+
"""Enable OpenReflex capture for this project.
|
|
314
|
+
Returns: one line confirming the project is enabled, or 'Not approved' when confirm is false.
|
|
315
|
+
Use when: the user explicitly asks to enable OpenReflex here. Never call it on your own initiative; the user
|
|
316
|
+
can also run `openreflex approve`, and `openreflex revoke` disables capture again.
|
|
317
|
+
Not for: anything else; every other tool answers with a 'not enabled' message until this has happened.
|
|
318
|
+
Side effects: writes the project's approval to the local OpenReflex home; enabling an already enabled
|
|
319
|
+
project changes nothing. Touches no project files.
|
|
320
|
+
Errors: none."""
|
|
321
|
+
if not confirm:
|
|
322
|
+
return "Not approved: pass confirm=true only after the user explicitly asked to enable OpenReflex."
|
|
323
|
+
approve(project, source="mcp")
|
|
324
|
+
return f"OpenReflex enabled for {project}."
|
|
325
|
+
|
|
326
|
+
@tool("Forget experience", DELETE)
|
|
327
|
+
def forget_experience(
|
|
328
|
+
experience_id: Annotated[str, Field(description="The experience id to delete, as returned by "
|
|
329
|
+
"search_experience; starts with 'exp-'.")],
|
|
330
|
+
confirm: Annotated[bool, Field(description="true only when the user has explicitly asked to forget this "
|
|
331
|
+
"task; with false (the default) nothing is deleted.")] = False,
|
|
332
|
+
) -> str:
|
|
333
|
+
"""Delete one past task from this project's memory.
|
|
334
|
+
Returns: one line listing what was removed (task, executions, tool calls, outcomes, experience, lessons), or
|
|
335
|
+
'Not forgotten' when confirm is false.
|
|
336
|
+
Use when: the user explicitly asks to forget a specific past task, for example one that involved sensitive
|
|
337
|
+
work or taught a wrong lesson. Never call it on your own initiative.
|
|
338
|
+
Not for: deleting everything; the user runs `openreflex forget --yes` for that.
|
|
339
|
+
Side effects: permanently removes that task's nodes from the local Experience Graph; other tasks are
|
|
340
|
+
untouched. Touches no project files.
|
|
341
|
+
Errors: 'Unknown experience id' for an id that does not exist, is not an experience, or was already
|
|
342
|
+
forgotten; a 'not enabled' message until the project is approved."""
|
|
343
|
+
if not confirm:
|
|
344
|
+
return "Not forgotten: pass confirm=true only after the user explicitly asked to forget this task."
|
|
345
|
+
def operation(e: Engine):
|
|
346
|
+
removed = e.forget_experience(experience_id)
|
|
347
|
+
detail = ", ".join(f"{count} {kind}" for kind, count in sorted(removed.items()))
|
|
348
|
+
return f"Forgotten: {detail} removed from this project's memory."
|
|
349
|
+
return run(operation)
|
|
350
|
+
|
|
351
|
+
return server
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def serve(project: str | None = None) -> None:
|
|
355
|
+
build_server(project_root(project)).run("stdio")
|
|
@@ -134,6 +134,11 @@ class Store:
|
|
|
134
134
|
self.db.execute("DELETE FROM edges WHERE source=? OR target=?", (node_id, node_id))
|
|
135
135
|
self.db.execute("DELETE FROM nodes WHERE id=?", (node_id,))
|
|
136
136
|
|
|
137
|
+
def forget_execution(self, execution_id: str):
|
|
138
|
+
"""Drop the session binding and event records that point at an execution being deleted."""
|
|
139
|
+
self.db.execute("DELETE FROM sessions WHERE execution_id=?", (execution_id,))
|
|
140
|
+
self.db.execute("DELETE FROM events WHERE execution_id=?", (execution_id,))
|
|
141
|
+
|
|
137
142
|
def exists(self, node_id: str) -> bool:
|
|
138
143
|
return self.db.execute("SELECT 1 FROM nodes WHERE id=?", (node_id,)).fetchone() is not None
|
|
139
144
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openreflex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Muscle memory for AI coding agents: learns from every task, advises the next one, and flags failure loops live
|
|
5
5
|
Author: vishnu-77
|
|
6
6
|
License-Expression: MIT
|
|
@@ -180,7 +180,7 @@ Routing, scoring, budget and recap settings come from a versioned policy: the pa
|
|
|
180
180
|
Agents can also query OpenReflex directly through its MCP server: `get_execution_context` (optionally with
|
|
181
181
|
`max_tool_calls`, `max_minutes`, `max_context_tokens`), `choose_path`, `check_progress`, `explain_decision`,
|
|
182
182
|
`get_execution_trace`, `get_reflex_score`, `record_outcome`, `search_experience`, `explain_node`,
|
|
183
|
-
`
|
|
183
|
+
`get_project_insights`, `approve_project`, and `forget_experience` (deletes one past task's memory, only when the user asks).
|
|
184
184
|
|
|
185
185
|
## CLI
|
|
186
186
|
|
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import json
|
|
3
3
|
|
|
4
4
|
from openreflex import benchmark, metrics
|
|
5
|
+
from openreflex.engine import Engine
|
|
5
6
|
from openreflex.mcp_server import build_server
|
|
6
7
|
from openreflex.project import approve, approval
|
|
7
8
|
|
|
@@ -15,12 +16,12 @@ def tool_text(server, name, arguments):
|
|
|
15
16
|
return "".join(getattr(item, "text", "") for item in content)
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
def test_mcp_tools_require_approval_then_work(project):
|
|
19
|
+
def test_mcp_tools_require_approval_then_work(project, clock):
|
|
19
20
|
server = build_server(project)
|
|
20
21
|
names = {tool.name for tool in asyncio.run(server.list_tools())}
|
|
21
22
|
assert {"get_execution_context", "choose_path", "check_progress", "record_outcome", "search_experience",
|
|
22
|
-
"explain_decision", "get_execution_trace", "get_reflex_score", "explain_node", "
|
|
23
|
-
"approve_project"} == names
|
|
23
|
+
"explain_decision", "get_execution_trace", "get_reflex_score", "explain_node", "get_project_insights",
|
|
24
|
+
"approve_project", "forget_experience"} == names
|
|
24
25
|
assert "not enabled" in tool_text(server, "get_execution_context", {"task": "Fix the login redirect loop"})
|
|
25
26
|
assert "Not approved" in tool_text(server, "approve_project", {})
|
|
26
27
|
assert "enabled" in tool_text(server, "approve_project", {"confirm": True})
|
|
@@ -34,8 +35,23 @@ def test_mcp_tools_require_approval_then_work(project):
|
|
|
34
35
|
assert "Recorded chosen path: incremental" in tool_text(server, "choose_path", {"strategy": "incremental"})
|
|
35
36
|
outcome = tool_text(server, "record_outcome", {"status": "success", "evidence": "pytest passed"})
|
|
36
37
|
assert outcome.startswith("Outcome recorded: success")
|
|
37
|
-
insights = json.loads(tool_text(server, "
|
|
38
|
+
insights = json.loads(tool_text(server, "get_project_insights", {}))
|
|
38
39
|
assert insights["outcomes"]["verified"] == 1
|
|
40
|
+
# A task with real tool activity becomes an experience that can be searched for and forgotten.
|
|
41
|
+
engine = Engine(project, clock=clock)
|
|
42
|
+
try:
|
|
43
|
+
run_task(engine, clock, "s-forget", "Fix the expired token bug in the session check", FIX_SCRIPT)
|
|
44
|
+
finally:
|
|
45
|
+
engine.close()
|
|
46
|
+
found = json.loads(tool_text(server, "search_experience", {"query": "Fix the expired token bug in the session check"}))
|
|
47
|
+
experience_id = found["experiences"][0]["id"]
|
|
48
|
+
assert "Not forgotten" in tool_text(server, "forget_experience", {"experience_id": experience_id})
|
|
49
|
+
assert "Unknown experience id" in tool_text(server, "forget_experience", {"experience_id": "exp-nope", "confirm": True})
|
|
50
|
+
forgotten = tool_text(server, "forget_experience", {"experience_id": experience_id, "confirm": True})
|
|
51
|
+
assert forgotten.startswith("Forgotten:") and "1 Task" in forgotten and "1 Experience" in forgotten
|
|
52
|
+
assert json.loads(tool_text(server, "search_experience", {"query": "Fix the expired token bug in the session check"}))["experiences"] == []
|
|
53
|
+
assert "Unknown graph node" in tool_text(server, "explain_node", {"node_id": experience_id})
|
|
54
|
+
assert json.loads(tool_text(server, "get_project_insights", {}))["engagement"]["tasks"] == 1
|
|
39
55
|
|
|
40
56
|
|
|
41
57
|
def test_metrics_report_reuse_and_regret(engine, clock, project):
|
|
@@ -65,9 +81,11 @@ def test_every_mcp_tool_is_fully_described(project):
|
|
|
65
81
|
tools = asyncio.run(build_server(project).list_tools())
|
|
66
82
|
for tool in tools:
|
|
67
83
|
assert tool.title and len(tool.description) > 200, tool.name
|
|
84
|
+
for label in ("Returns:", "Use when:", "Side effects:", "Errors:"):
|
|
85
|
+
assert label in tool.description, f"{tool.name} lacks {label}"
|
|
68
86
|
assert tool.annotations is not None and tool.annotations.openWorldHint is False, tool.name
|
|
69
87
|
for name, schema in tool.inputSchema.get("properties", {}).items():
|
|
70
88
|
assert schema.get("description"), f"{tool.name}.{name} has no description"
|
|
71
89
|
read_only = {t.name for t in tools if t.annotations.readOnlyHint}
|
|
72
90
|
assert read_only == {"check_progress", "explain_decision", "get_execution_trace", "get_reflex_score",
|
|
73
|
-
"search_experience", "explain_node", "
|
|
91
|
+
"search_experience", "explain_node", "get_project_insights"}
|
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
"""MCP stdio server. Hooks do the ambient capture; these tools let an agent ask for and report on execution."""
|
|
2
|
-
|
|
3
|
-
import inspect
|
|
4
|
-
import json
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Annotated, Literal
|
|
7
|
-
|
|
8
|
-
from mcp.server.fastmcp import FastMCP
|
|
9
|
-
from mcp.types import ToolAnnotations
|
|
10
|
-
from pydantic import Field
|
|
11
|
-
|
|
12
|
-
from . import metrics
|
|
13
|
-
from .engine import Engine
|
|
14
|
-
from .project import approval, approve, project_root
|
|
15
|
-
from .routing import Limits
|
|
16
|
-
|
|
17
|
-
INSTRUCTIONS = """OpenReflex is local execution memory for this project. Hooks capture work automatically.
|
|
18
|
-
Use get_execution_context before a substantial task if no [OpenReflex] context was already provided.
|
|
19
|
-
Call choose_path if you deliberately take a different approach than suggested, check_progress when unsure
|
|
20
|
-
whether more work is paying off, and record_outcome once the result is verified (tests pass, user confirmed).
|
|
21
|
-
Use explain_decision or get_execution_trace when the user asks why OpenReflex recommended something.
|
|
22
|
-
Never call approve_project unless the user asked for it."""
|
|
23
|
-
|
|
24
|
-
# Every tool works on local data only. Reads never change the Experience Graph; writes never delete from it.
|
|
25
|
-
READ = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
|
|
26
|
-
WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=False)
|
|
27
|
-
PLAN = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False)
|
|
28
|
-
NOT_ENABLED = " Until the project is enabled, every tool except approve_project returns a 'not enabled' message."
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def build_server(project: Path) -> FastMCP:
|
|
32
|
-
server = FastMCP("openreflex", instructions=INSTRUCTIONS, log_level="WARNING")
|
|
33
|
-
|
|
34
|
-
def engine() -> Engine:
|
|
35
|
-
if not approval(project):
|
|
36
|
-
raise PermissionError(f"OpenReflex is not enabled for {project}. The user can run "
|
|
37
|
-
"`openreflex approve` (or ask you to call approve_project).")
|
|
38
|
-
return Engine(project)
|
|
39
|
-
|
|
40
|
-
def tool(title: str, annotations: ToolAnnotations, gated: bool = True):
|
|
41
|
-
"""Register a tool whose description is its docstring, plus the not-enabled note for gated tools."""
|
|
42
|
-
def register(function):
|
|
43
|
-
description = inspect.cleandoc(function.__doc__ or "") + (NOT_ENABLED if gated else "")
|
|
44
|
-
return server.tool(title=title, description=description, annotations=annotations)(function)
|
|
45
|
-
return register
|
|
46
|
-
|
|
47
|
-
def run(operation):
|
|
48
|
-
try:
|
|
49
|
-
instance = engine()
|
|
50
|
-
except PermissionError as error:
|
|
51
|
-
return str(error)
|
|
52
|
-
try:
|
|
53
|
-
return operation(instance)
|
|
54
|
-
except ValueError as error:
|
|
55
|
-
return f"OpenReflex: {error}"
|
|
56
|
-
finally:
|
|
57
|
-
instance.close()
|
|
58
|
-
|
|
59
|
-
@tool("Get execution context", PLAN)
|
|
60
|
-
def get_execution_context(
|
|
61
|
-
task: Annotated[str, Field(description="One or two plain sentences describing the task, e.g. 'Fix the login "
|
|
62
|
-
"redirect loop after logout'. Secrets are redacted before storage.")],
|
|
63
|
-
max_tool_calls: Annotated[int | None, Field(description="Optional upper limit on tool calls for this task. "
|
|
64
|
-
"Must be positive.")] = None,
|
|
65
|
-
max_minutes: Annotated[float | None, Field(description="Optional upper limit on active working time, in "
|
|
66
|
-
"minutes. Must be positive.")] = None,
|
|
67
|
-
max_context_tokens: Annotated[int | None, Field(description="Optional upper limit on tokens of tool output "
|
|
68
|
-
"added to context. Must be positive.")] = None,
|
|
69
|
-
) -> str:
|
|
70
|
-
"""Plan a substantial task from this project's past experience. Returns plain text: similar past tasks, the
|
|
71
|
-
suggested strategy with alternatives (dominated or over-limit ones marked), an execution budget, likely
|
|
72
|
-
relevant files and lessons, followed by every candidate path's estimates and the execution id.
|
|
73
|
-
|
|
74
|
-
Use it before a bug fix, feature, refactor or migration when no [OpenReflex] block was injected. Use
|
|
75
|
-
search_experience instead to look up history without planning a task. Starts or re-plans the current task
|
|
76
|
-
in the local graph; calling it again for the same task returns the same plan unless new limits are given."""
|
|
77
|
-
def operation(e: Engine):
|
|
78
|
-
given = [value for value in (max_tool_calls, max_minutes, max_context_tokens) if value is not None]
|
|
79
|
-
if any(value <= 0 for value in given):
|
|
80
|
-
raise ValueError("limits must be positive")
|
|
81
|
-
limits = Limits(max_minutes * 60 if max_minutes else None, max_tool_calls, max_context_tokens) if given else None
|
|
82
|
-
execution, context = e.context_for(task, limits=limits)
|
|
83
|
-
paths = e.store.list("CandidatePath", "task_id", execution.task_id)
|
|
84
|
-
order = [execution.recommended_path_id] + [p.id for p in sorted(paths, key=lambda p: -p.score)]
|
|
85
|
-
paths = sorted(paths, key=lambda p: order.index(p.id))
|
|
86
|
-
|
|
87
|
-
def status(p) -> str:
|
|
88
|
-
return (f"dominated by {p.dominated_by}" if p.dominated_by else
|
|
89
|
-
"over the limits" if not p.within_limits else "Pareto-efficient")
|
|
90
|
-
scored = "\n".join(
|
|
91
|
-
f"- {p.strategy}: score {p.score:+.3f} ({status(p)}), success~{p.success_probability:.0%}, "
|
|
92
|
-
f"~{p.tool_calls:.0f} calls, ~{p.time_seconds / 60:.0f} min, ~{p.context_tokens / 1000:.1f}k tokens, "
|
|
93
|
-
f"risk {p.risk:.2f}, uncertainty {p.uncertainty:.2f}, reversibility {p.reversibility:.2f}, "
|
|
94
|
-
f"expected regret {p.expected_regret:.3f}, evidence n={p.evidence_count}" for p in paths)
|
|
95
|
-
return f"{context.text}\n\nCandidate paths:\n{scored}\n(execution {execution.id})"
|
|
96
|
-
return run(operation)
|
|
97
|
-
|
|
98
|
-
@tool("Check progress", READ)
|
|
99
|
-
def check_progress() -> str:
|
|
100
|
-
"""Estimate whether more work on the current task's path is still worth it. Returns plain text starting with
|
|
101
|
-
'Recommendation: continue', 'pivot' or 'stop', then the success estimates, the marginal value of each option,
|
|
102
|
-
the execution budget and any detected problems (failure loops, stalled progress, budget overruns).
|
|
103
|
-
|
|
104
|
-
Use it mid-task when unsure whether to keep going; follow a pivot by switching to the named strategy, and a
|
|
105
|
-
stop by summarizing and asking the user. Reads the most recent task only and records nothing. Returns a
|
|
106
|
-
message asking for get_execution_context first when no task exists."""
|
|
107
|
-
def operation(e: Engine):
|
|
108
|
-
execution, verdict, problems, budget = e.progress()
|
|
109
|
-
action = verdict.action if problems else "continue"
|
|
110
|
-
lines = [f"Recommendation: {action}" + ("" if problems else " (no signs of trouble)") + ".",
|
|
111
|
-
f"Estimate: {verdict.summary()}.",
|
|
112
|
-
f"Marginal value: continue {verdict.value_continue:+.3f}"
|
|
113
|
-
+ (f", pivot to {verdict.alternative.strategy} {verdict.value_pivot:+.3f}" if verdict.alternative else "")
|
|
114
|
-
+ ", stop +0.000.",
|
|
115
|
-
f"Budget: ~{budget.tool_calls:.0f} tool calls, ~{budget.seconds / 60:.0f} min, "
|
|
116
|
-
f"~{budget.context_tokens / 1000:.0f}k tokens of tool output ({budget.source})."]
|
|
117
|
-
if problems:
|
|
118
|
-
lines.append("Detected: " + "; ".join(p.detail for p in problems) + ".")
|
|
119
|
-
if action == "pivot":
|
|
120
|
-
lines.append(f"{verdict.alternative.strategy}: {' -> '.join(verdict.alternative.steps)}.")
|
|
121
|
-
return "\n".join(lines) + f"\n(execution {execution.id})"
|
|
122
|
-
return run(operation)
|
|
123
|
-
|
|
124
|
-
@tool("Explain decision", READ)
|
|
125
|
-
def explain_decision() -> str:
|
|
126
|
-
"""Explain the latest recommendation for the most recent task in plain text: the chosen route, Reflex Score
|
|
127
|
-
and its component signals, confidence, evidence and the next-best route.
|
|
128
|
-
|
|
129
|
-
Use it when the user asks why OpenReflex suggested a path, pivot or stop. Use get_reflex_score for the same
|
|
130
|
-
numbers as JSON, and get_execution_trace for every decision in order. Records nothing."""
|
|
131
|
-
return run(lambda e: e.why())
|
|
132
|
-
|
|
133
|
-
@tool("Get execution trace", READ)
|
|
134
|
-
def get_execution_trace() -> str:
|
|
135
|
-
"""Return the most recent task's decision timeline as plain text: one line per decision OpenReflex made, in
|
|
136
|
-
order, with elapsed time, phase, action, strategy, Reflex Score and the triggering event. Never includes
|
|
137
|
-
prompts, commands or tool output.
|
|
138
|
-
|
|
139
|
-
Use it to review how a task unfolded; use explain_decision for only the latest decision. Records nothing."""
|
|
140
|
-
return run(lambda e: e.trace())
|
|
141
|
-
|
|
142
|
-
@tool("Get Reflex Score", READ)
|
|
143
|
-
def get_reflex_score() -> str:
|
|
144
|
-
"""Return the latest decision's Reflex Score as JSON: reflex_score (0-100, the strength of the recommendation,
|
|
145
|
-
not the chance of success), success_probability, decision_confidence, strategy, next_best_strategy,
|
|
146
|
-
route_advantage, evidence_count, budget_used, component signals and policy_version. Returns
|
|
147
|
-
{"available": false} before any decision exists.
|
|
148
|
-
|
|
149
|
-
Use it when you need the numbers programmatically; use explain_decision for a readable explanation.
|
|
150
|
-
Records nothing."""
|
|
151
|
-
def operation(e: Engine):
|
|
152
|
-
snapshots = e.decision_snapshots()
|
|
153
|
-
if not snapshots:
|
|
154
|
-
return json.dumps({"available": False, "reason": "no decision snapshot recorded yet"})
|
|
155
|
-
snapshot = snapshots[-1]
|
|
156
|
-
return json.dumps({
|
|
157
|
-
"available": True,
|
|
158
|
-
"reflex_score": snapshot.reflex_score,
|
|
159
|
-
"success_probability": snapshot.success_probability,
|
|
160
|
-
"decision_confidence": snapshot.decision_confidence,
|
|
161
|
-
"strategy": snapshot.strategy,
|
|
162
|
-
"next_best_strategy": snapshot.next_best_strategy,
|
|
163
|
-
"route_advantage": snapshot.route_advantage,
|
|
164
|
-
"evidence_count": snapshot.evidence_count,
|
|
165
|
-
"context_tokens": snapshot.context_tokens,
|
|
166
|
-
"budget_used": snapshot.budget_used,
|
|
167
|
-
"signals": snapshot.score_components,
|
|
168
|
-
"policy_version": snapshot.policy_version,
|
|
169
|
-
}, indent=2)
|
|
170
|
-
return run(operation)
|
|
171
|
-
|
|
172
|
-
@tool("Choose path", WRITE)
|
|
173
|
-
def choose_path(
|
|
174
|
-
strategy: Annotated[str, Field(description="The strategy being followed: a suggested one ('inspect-first', "
|
|
175
|
-
"'test-first', 'incremental') or a short name for your own.")],
|
|
176
|
-
steps: Annotated[list[str] | None, Field(description="For a custom strategy only: 2-4 short steps, e.g. "
|
|
177
|
-
"['Prototype the parser', 'Swap callers']. Ignored for "
|
|
178
|
-
"suggested strategies.")] = None,
|
|
179
|
-
) -> str:
|
|
180
|
-
"""Declare the strategy you are following for the most recent task, so the outcome and Execution Regret are
|
|
181
|
-
compared against the right plan and the budget follows that path. Returns a one-line confirmation.
|
|
182
|
-
|
|
183
|
-
Call it only when you deliberately depart from the suggested path; otherwise the path is inferred from tool
|
|
184
|
-
activity. Calling it again replaces the earlier choice."""
|
|
185
|
-
return run(lambda e: f"Recorded chosen path: {e.choose_path(strategy, steps).strategy}")
|
|
186
|
-
|
|
187
|
-
@tool("Record outcome", WRITE)
|
|
188
|
-
def record_outcome(
|
|
189
|
-
status: Annotated[Literal["success", "failure"], Field(description="'success' once checks pass or the user "
|
|
190
|
-
"confirms; 'failure' if the task failed "
|
|
191
|
-
"or was abandoned.")],
|
|
192
|
-
evidence: Annotated[str, Field(description="Short proof of the result, e.g. 'pytest tests/test_auth.py "
|
|
193
|
-
"passed' or 'user confirmed the fix'. Kept up to 500 characters.")],
|
|
194
|
-
) -> str:
|
|
195
|
-
"""Record the verified outcome of the most recent task, then learn from it: the experience, lessons and
|
|
196
|
-
Execution Regret are updated. Returns a line with the status and the regret estimate.
|
|
197
|
-
|
|
198
|
-
Call it once the result is verified; unverified work is otherwise inferred from tool activity, less
|
|
199
|
-
reliably. Calling it again for the same task replaces the recorded outcome rather than adding another."""
|
|
200
|
-
def operation(e: Engine):
|
|
201
|
-
outcome = e.record_outcome(status, evidence)
|
|
202
|
-
regret = "n/a" if outcome.estimated_regret is None else f"{outcome.estimated_regret:.3f}"
|
|
203
|
-
return f"Outcome recorded: {outcome.status}. Execution regret {regret} ({outcome.regret_basis})."
|
|
204
|
-
return run(operation)
|
|
205
|
-
|
|
206
|
-
@tool("Search experience", READ)
|
|
207
|
-
def search_experience(
|
|
208
|
-
query: Annotated[str, Field(description="Words describing the topic, e.g. 'expired token login bug'. Matched "
|
|
209
|
-
"lexically against past task descriptions.")],
|
|
210
|
-
limit: Annotated[int, Field(description="Maximum number of past tasks to return, 1-20. Default 5.")] = 5,
|
|
211
|
-
) -> str:
|
|
212
|
-
"""Search this project's past tasks by description similarity. Returns JSON: 'experiences' (id, score,
|
|
213
|
-
description, class, agent, strategy, status, tool_calls, minutes, files), best match first, and the
|
|
214
|
-
'lessons' learned from them.
|
|
215
|
-
|
|
216
|
-
Use it to answer "what did we learn / what worked before?" without starting a task; use
|
|
217
|
-
get_execution_context to plan a new one. Pass an experience id to explain_node for its graph. Records
|
|
218
|
-
nothing."""
|
|
219
|
-
def operation(e: Engine):
|
|
220
|
-
found = e.retrieve(query, k=max(1, min(limit, 20)))
|
|
221
|
-
lessons = e.lessons([x for x, _ in found], limit=8)
|
|
222
|
-
return json.dumps({
|
|
223
|
-
"experiences": [{"id": x.id, "score": s, "description": x.description, "class": x.task_class,
|
|
224
|
-
"agent": x.agent, "strategy": x.strategy, "status": x.status,
|
|
225
|
-
"tool_calls": x.tool_calls, "minutes": round(x.elapsed_seconds / 60, 1),
|
|
226
|
-
"files": x.files[:8]} for x, s in found],
|
|
227
|
-
"lessons": [{"text": item.text, "confidence": c, "support": n} for item, c, n in lessons],
|
|
228
|
-
}, indent=2)
|
|
229
|
-
return run(operation)
|
|
230
|
-
|
|
231
|
-
@tool("Explain graph node", READ)
|
|
232
|
-
def explain_node(
|
|
233
|
-
node_id: Annotated[str, Field(description="Id of an Experience Graph node, as returned by search_experience "
|
|
234
|
-
"(experience ids start with 'exp-') or get_execution_context "
|
|
235
|
-
"('execution <id>').")],
|
|
236
|
-
) -> str:
|
|
237
|
-
"""Show one Experience Graph node and its direct relations (used, caused, failed_with, resolved_by,
|
|
238
|
-
recommended_for) as JSON with 'root', 'nodes' and 'edges'. Embeddings are omitted.
|
|
239
|
-
|
|
240
|
-
Use it to trace why a lesson or path exists after finding an id with search_experience. Returns 'Unknown
|
|
241
|
-
graph node' for an id that does not exist. Records nothing."""
|
|
242
|
-
def operation(e: Engine):
|
|
243
|
-
graph = e.store.graph(node_id)
|
|
244
|
-
for node in graph["nodes"]:
|
|
245
|
-
node.pop("embedding", None)
|
|
246
|
-
return json.dumps(graph, indent=2, default=str)
|
|
247
|
-
return run(operation)
|
|
248
|
-
|
|
249
|
-
@tool("Project insights", READ)
|
|
250
|
-
def project_insights() -> str:
|
|
251
|
-
"""Summarize what OpenReflex has recorded and learned in this project, as JSON: tasks and experiences
|
|
252
|
-
captured, how often past experience was reused, known outcomes and success rate, observational efficiency
|
|
253
|
-
with and without prior experience, the Execution Regret trend, routing agreement, alerts raised, verdicts
|
|
254
|
-
(continue, pivot, stop) and lesson count.
|
|
255
|
-
|
|
256
|
-
Use it when the user asks how OpenReflex is doing in this project; use search_experience for individual
|
|
257
|
-
past tasks. Records nothing."""
|
|
258
|
-
return run(lambda e: json.dumps(metrics.project_metrics(e, approval(project)), indent=2))
|
|
259
|
-
|
|
260
|
-
@tool("Enable project", WRITE, gated=False)
|
|
261
|
-
def approve_project(
|
|
262
|
-
confirm: Annotated[bool, Field(description="Set to true only when the user has explicitly asked to enable "
|
|
263
|
-
"OpenReflex for this project. With false (the default) nothing "
|
|
264
|
-
"is changed.")] = False,
|
|
265
|
-
) -> str:
|
|
266
|
-
"""Enable OpenReflex capture for this project, so hooks start recording tasks locally. Returns a one-line
|
|
267
|
-
confirmation. Enabling an already-enabled project changes nothing.
|
|
268
|
-
|
|
269
|
-
Never call it on your own initiative: only when the user explicitly asks to enable OpenReflex. The user can
|
|
270
|
-
disable it again with `openreflex revoke`."""
|
|
271
|
-
if not confirm:
|
|
272
|
-
return "Not approved: pass confirm=true only after the user explicitly asked to enable OpenReflex."
|
|
273
|
-
approve(project, source="mcp")
|
|
274
|
-
return f"OpenReflex enabled for {project}."
|
|
275
|
-
|
|
276
|
-
return server
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def serve(project: str | None = None) -> None:
|
|
280
|
-
build_server(project_root(project)).run("stdio")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|