From 111386e5d7cd282d9c9264c838f3d7615548b437 Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 16 Oct 2026 19:54:53 -0400 Subject: [PATCH 1/3] feat(repair): propose a branch after a step is repaired more than once RepairDesk keeps each demonstration per workflow and step. From the second one on, it also asks Flow's teach_branch_from_calling_agent for one branch candidate covering them. It goes through the same review and human approval; a refusal leaves the single-step candidate as it was, and an older Flow without branch support is skipped. Signed-off-by: Richard Abrich --- packages/agent/tests/test_repair_branch.py | 73 ++++++++++++++++++++ packages/flow/openadapt_flow/agent/repair.py | 54 ++++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/agent/tests/test_repair_branch.py diff --git a/packages/agent/tests/test_repair_branch.py b/packages/agent/tests/test_repair_branch.py new file mode 100644 index 000000000..44ab4856e --- /dev/null +++ b/packages/agent/tests/test_repair_branch.py @@ -0,0 +1,73 @@ +"""A second demonstration on one step also proposes a branch candidate.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from openadapt_agent.repair import RepairAccessPolicy, RepairDesk + + +class _Store: + def __init__(self) -> None: + self.added: list[object] = [] + + def add_candidate(self, candidate): + self.added.append(candidate) + return SimpleNamespace(candidate_id=f"cand-{len(self.added)}") + + +class _AgentTeach: + def __init__(self, fail: bool = False) -> None: + self.calls: list[list[tuple[str, str]]] = [] + self.fail = fail + + def teach_branch_from_calling_agent(self, examples, out, *, bundle, provenance): + self.calls.append(list(examples)) + if self.fail: + raise RuntimeError("no shared screen text") + return SimpleNamespace(candidate="branch-candidate") + + +def _demo(tmp: Path, name: str) -> tuple[dict, Path]: + run_dir = tmp / "runs" / name + recording = tmp / "teach" / name / "recording" + run_dir.mkdir(parents=True) + recording.mkdir(parents=True) + record = {"workflow": "wf", "step_id": "s_save", "run_dir": str(run_dir), "bundle_dir": "b"} + return record, recording + + +def test_first_demo_is_remembered_second_proposes_a_branch(tmp_path: Path) -> None: + desk = RepairDesk(tmp_path / ".repair", RepairAccessPolicy(None)) + store, teach = _Store(), _AgentTeach() + + record, rec = _demo(tmp_path, "r1") + assert desk._teach_branch(record, rec, tmp_path / "w1", store, teach, None) is None + assert teach.calls == [] + + record, rec = _demo(tmp_path, "r2") + assert desk._teach_branch(record, rec, tmp_path / "w2", store, teach, None) == "cand-1" + assert [Path(run).name for run, _ in teach.calls[0]] == ["r1", "r2"] + assert store.added == ["branch-candidate"] + + +def test_other_step_and_refusal_add_no_branch(tmp_path: Path) -> None: + desk = RepairDesk(tmp_path / ".repair", RepairAccessPolicy(None)) + store, teach = _Store(), _AgentTeach(fail=True) + record, rec = _demo(tmp_path, "r1") + desk._teach_branch(record, rec, tmp_path / "w1", store, teach, None) + other, rec2 = _demo(tmp_path, "r2") + other["step_id"] = "s_other" + assert desk._teach_branch(other, rec2, tmp_path / "w2", store, teach, None) is None + assert teach.calls == [] + record3, rec3 = _demo(tmp_path, "r3") + assert desk._teach_branch(record3, rec3, tmp_path / "w3", store, teach, None) is None + assert store.added == [] + + +def test_older_flow_without_branch_support_is_skipped(tmp_path: Path) -> None: + desk = RepairDesk(tmp_path / ".repair", RepairAccessPolicy(None)) + for name in ("r1", "r2"): + record, rec = _demo(tmp_path, name) + assert desk._teach_branch(record, rec, tmp_path / name, _Store(), object(), None) is None diff --git a/packages/flow/openadapt_flow/agent/repair.py b/packages/flow/openadapt_flow/agent/repair.py index f4db876b5..ee9d4dd5e 100644 --- a/packages/flow/openadapt_flow/agent/repair.py +++ b/packages/flow/openadapt_flow/agent/repair.py @@ -713,7 +713,7 @@ def _teach( } store = RepairStore(self.store_for(record["workflow"])) candidate = store.add_candidate(result.candidate) - return { + response: dict[str, Any] = { "status": "pending_approval", "candidate_id": candidate.candidate_id, "what_happened": ( @@ -722,6 +722,58 @@ def _teach( "then, this workflow still stops at this step." ), } + branch_id = self._teach_branch(record, recording, work, store, agent_teach, provenance) + if branch_id is not None: + response["branch_candidate_id"] = branch_id + response["what_happened"] += ( + " This step has now been repaired more than once, so OpenAdapt " + "also proposed one branch that covers these cases. It needs " + "the same review and approval." + ) + return response + + def _demos_path(self, record: dict[str, Any]) -> Path: + key = _sha(f"{record['workflow']}\0{record['step_id']}")[:16] + return self.root / "demos" / f"{key}.json" + + def _teach_branch( + self, + record: dict[str, Any], + recording: Path, + work: Path, + store: Any, + agent_teach: Any, + provenance: Any, + ) -> Optional[str]: + """Remember this demonstration; with two or more, propose a branch. + + The branch is a separate repair candidate. If Flow refuses it, the + single-step candidate stands on its own. + """ + path = self._demos_path(record) + demos = (_read_json(path) or {}).get("demos", []) + demos = [d for d in demos if isinstance(d, dict) and d.get("run_dir") != record["run_dir"]] + demos.append({"run_dir": record["run_dir"], "recording": str(recording)}) + _write_json(path, {"demos": demos}) + usable = [ + (d["run_dir"], d["recording"]) + for d in demos + if Path(d["run_dir"]).is_dir() and Path(d["recording"]).is_dir() + ] + teach_branch = getattr(agent_teach, "teach_branch_from_calling_agent", None) + if len(usable) < 2 or teach_branch is None: + return None + try: + result = teach_branch( + usable, + work / "branch", + bundle=record["bundle_dir"], + provenance=provenance, + ) + except Exception as exc: + _LOG.info("branch from several demonstrations refused: %s", exc) + return None + return str(store.add_candidate(result.candidate).candidate_id) def active_bundle(self, workflow: str, default: Path) -> Path: """The approved repaired bundle, when a person activated one.""" From 349bc198a4a069f5bbddd5a03f0d07ab1aca113c Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 17 Oct 2026 16:10:40 -0400 Subject: [PATCH 2/3] fix(repair): a failed branch proposal never loses the single-step candidate Signed-off-by: Richard Abrich --- packages/flow/openadapt_flow/agent/repair.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/flow/openadapt_flow/agent/repair.py b/packages/flow/openadapt_flow/agent/repair.py index ee9d4dd5e..f35fce2c9 100644 --- a/packages/flow/openadapt_flow/agent/repair.py +++ b/packages/flow/openadapt_flow/agent/repair.py @@ -722,7 +722,13 @@ def _teach( "then, this workflow still stops at this step." ), } - branch_id = self._teach_branch(record, recording, work, store, agent_teach, provenance) + try: + branch_id = self._teach_branch( + record, recording, work, store, agent_teach, provenance + ) + except Exception as exc: # the single-step candidate already stands + _LOG.info("branch proposal skipped: %s", exc) + branch_id = None if branch_id is not None: response["branch_candidate_id"] = branch_id response["what_happened"] += ( From b25f0be8958135d5eae4fb9142b9b4b4d2f650c7 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 17 Oct 2026 17:05:03 -0400 Subject: [PATCH 3/3] style(agent): format the carried-over agent code under the core's Ruff config openadapt-agent formatted with a longer line length; since the fold the MCP server lives in openadapt_flow.agent and follows openadapt-flow's ruff format settings. No behavior change. Signed-off-by: Richard Abrich --- packages/flow/openadapt_flow/agent/repair.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/flow/openadapt_flow/agent/repair.py b/packages/flow/openadapt_flow/agent/repair.py index f35fce2c9..29abf6eb6 100644 --- a/packages/flow/openadapt_flow/agent/repair.py +++ b/packages/flow/openadapt_flow/agent/repair.py @@ -758,7 +758,11 @@ def _teach_branch( """ path = self._demos_path(record) demos = (_read_json(path) or {}).get("demos", []) - demos = [d for d in demos if isinstance(d, dict) and d.get("run_dir") != record["run_dir"]] + demos = [ + d + for d in demos + if isinstance(d, dict) and d.get("run_dir") != record["run_dir"] + ] demos.append({"run_dir": record["run_dir"], "recording": str(recording)}) _write_json(path, {"demos": demos}) usable = [