From 57b19da0ad6ce7dd9eadffbe9d4785e9c03fa495 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 27 Jul 2026 23:29:06 -0400 Subject: [PATCH 1/5] feat(attended): give the bridge a brake, and say when a model pulled it Two changes that are only sound together. 1. `reject_attention`. Flow gained `reject` -- an explicit "stop, this is wrong" that TERMINATES a run. This bridge could already answer `continue`, which resumes the run and can write to the system of record, but had no way to say stop. A configuration that can say "proceed" and cannot say "stop" is not the safer one: the only thing withholding the action removes is the brake. It is NOT gated on `live_actions_ready`. That gate exists because continue and skip need Flow's deployment-bound executor to re-read the application and act on it. Rejecting actuates nothing and resumes nothing, so it has nothing to gate on -- the same reason Flow's own `_allowed_actions` offers it at a pause carrying no resolvable action step at all. The tool description states the two distinctions a model would otherwise collapse: escalate PARKS the run for a colleague, teach changes FUTURE runs, reject ends THIS one and asserts nothing about the saved workflow. Its confirmation flag must be explicitly true; ending a run is not something to do by omission. The schema stays closed with no free-text property. 2. `decided_by="automation"` on EVERY decision this bridge submits, not only on reject. This is the half that makes the first half safe. `operator` is derived from the same `_local_operator_identity()` a person's own console uses, so a model's answer and a person's answer from one machine were indistinguishable in Flow's journal, and any agreement rate computed over it silently mixed the two populations. The bias this fixes was always mostly about `continue`, which has been model-callable all along -- attaching provenance only to the action added last would leave the population that actually matters unlabelled. Measurement integrity does not require withholding the action. It requires knowing who decided. Those are different problems, and only the second one is real here. The `openadapt-flow` floor rises to 1.26.0: `reject`, its `rejected_by_operator` disposition, and the `decided_by` keyword are named symbols that only exist there. A resolver satisfied by 1.25.x fails at CALL time with a TypeError, inside the attended decision path. One stale expectation is corrected: the capability's `allowed_actions` now contains `reject` because the ENGINE offers it, not because this bridge adds one. The bridge still relays Flow's signed set verbatim. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM --- pyproject.toml | 7 +- src/openadapt_agent/attended.py | 46 ++++++++++- tests/test_attended_bridge.py | 130 +++++++++++++++++++++++++++++++- 3 files changed, 179 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bfe5e2d..76e3049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,12 @@ keywords = ["mcp", "agent-skills", "gui", "automation", "workflow", "openadapt", dependencies = [ # Governed workflow compiler/runtime this package bridges. Execution # shells out to its CLI; bundle metadata loads via its IR. - "openadapt-flow>=1.18.1,<2", + # FLOOR IS LOAD-BEARING. `reject`, its `rejected_by_operator` disposition, + # and the `decided_by` provenance keyword are named symbols that only exist + # from 1.26.0. A resolver satisfied by 1.25.x fails at CALL time, inside the + # attended decision path, with a TypeError rather than at resolve. Raise + # this with every new symbol taken from Flow's attended contract. + "openadapt-flow>=1.26.0,<2", # Official Model Context Protocol SDK (stdio server transport). "mcp>=1.28,<2", # Structured concurrency runtime the mcp SDK already uses; we call diff --git a/src/openadapt_agent/attended.py b/src/openadapt_agent/attended.py index 4cfe20a..7d7769f 100644 --- a/src/openadapt_agent/attended.py +++ b/src/openadapt_agent/attended.py @@ -46,6 +46,14 @@ "The escalation was recorded and the durable pause remains available " "for qualified assistance." ), + # Deliberately not worded like the escalation above. That one says the + # pause remains; this one says the run is over. A caller told the wrong + # one of those acts on it. + "rejected": ( + "The rejection was recorded and the run is terminal. Nothing was " + "actuated, no approval can resume it, and the durable pause is " + "retained only as the audit record of what was rejected." + ), } @@ -130,6 +138,22 @@ class AttendedTool: "pause for a qualified operator." ), ), + "reject_attention": AttendedTool( + action="reject", + confirmation="confirm_run_must_not_proceed", + disposition="rejected_by_operator", + description=( + "End this run because it must not proceed. Use it only after " + "reading the live application and concluding OpenAdapt was RIGHT " + "to stop. This TERMINATES the run: no approval resumes it, and " + "the durable pause is kept only as the audit record. It is not " + "escalate_attention, which parks the run for a colleague who can " + "still continue it, and it is not teach_attention, which changes " + "future runs. It asserts nothing about the saved workflow and " + "actuates nothing. Flow independently refuses a rejection whose " + "delivery may already have landed." + ), + ), } @@ -206,9 +230,22 @@ def live_actions_ready(self) -> bool: return self.allow_actions and self.service is not None def enabled_action_tools(self) -> tuple[str, ...]: + """Which action tools this bridge exposes, and why reject is not gated. + + ``reject_attention`` sits with teach and escalate rather than behind + ``live_actions_ready``. That gate exists because continue and skip need + Flow's deployment-bound live executor to re-read the application and + act on it. Rejecting actuates nothing and resumes nothing, so it has + nothing to gate on -- the same reason Flow's own ``_allowed_actions`` + offers it at a pause carrying no resolvable action step at all. + + Withholding it would also leave this bridge able to say "proceed" -- + which writes to the system of record -- while unable to say "stop". + The only thing removed by that configuration is the brake. + """ if not self.allow_actions: return () - tools = ["teach_attention", "escalate_attention"] + tools = ["reject_attention", "teach_attention", "escalate_attention"] if self.live_actions_ready: tools[0:0] = ["continue_attention", "skip_attention"] return tuple(tools) @@ -327,17 +364,24 @@ def act(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: disposition=tool.disposition, ) try: + # This bridge submits on behalf of a MODEL, and it derives + # `operator` from the same local OS identity a person's own console + # uses -- so the identity alone cannot tell the two apart. Declared + # here, at the one place that knows, so an agreement rate computed + # over Flow's journal can filter to decisions people actually made. if self.service is not None: decision = self.service.execute( path, request, operator=self.operator, + decided_by="automation", ) else: decision = execute_attended_action( path, request, operator=self.operator, + decided_by="automation", ) except (ApprovalRequired, AttendedActionRefused, ResumeRefused) as exc: _LOG.info( diff --git a/tests/test_attended_bridge.py b/tests/test_attended_bridge.py index aee6031..8cff881 100644 --- a/tests/test_attended_bridge.py +++ b/tests/test_attended_bridge.py @@ -4,6 +4,7 @@ import json import os +from pathlib import Path import pytest @@ -144,14 +145,19 @@ class DirectFlowService: def __init__(self, executor): self.executor = executor + self.deciders = [] - def execute(self, run_dir, request, *, operator): + def execute(self, run_dir, request, *, operator, decided_by="unknown"): from openadapt_flow.runtime.durable import execute_attended_action + # Recorded rather than defaulted: a fake that silently accepted + # `unknown` would hide a bridge that stopped declaring itself. + self.deciders.append(decided_by) return execute_attended_action( run_dir, request, operator=operator, + decided_by=decided_by, executor=self.executor, ) @@ -173,6 +179,7 @@ def item_and_args(bridge, *, action="continue"): confirmation = { "continue": "human_completed", "skip": "confirmed_not_applicable", + "reject": "confirm_run_must_not_proceed", "teach": "request_demonstration", "escalate": "request_assistance", }[action] @@ -200,8 +207,11 @@ def test_needs_attention_is_phi_safe_and_always_readable(paused_attention): item = listing["items"][0] assert item["human_required"] is True + # Relayed verbatim from Flow's signed capability. `reject` appears because + # the engine offers it at this pause, not because the bridge added it. assert item["capability"]["allowed_actions"] == [ "continue", + "reject", "teach", "escalate", ] @@ -285,8 +295,9 @@ def test_stale_capability_extra_fields_and_false_confirmation_never_execute( def test_flow_refusal_details_do_not_cross_the_phi_safe_bridge(paused_attention): class ProtectedRefusalService: - def execute(self, _run_dir, _request, *, operator): + def execute(self, _run_dir, _request, *, operator, decided_by="unknown"): assert operator + assert decided_by == "automation" from openadapt_flow.runtime.durable import AttendedActionRefused raise AttendedActionRefused("protected patient name and local workflow details") @@ -511,3 +522,118 @@ def test_symlinked_runs_root_is_not_scanned(paused_attention, tmp_path): alias.symlink_to(paused_attention["runs"], target_is_directory=True) bridge = AttendedBridge(alias) assert bridge.list()["items"] == [] + + +# --------------------------------------------------------------------------- +# Reject, and saying which kind of decider answered +# --------------------------------------------------------------------------- + + +def _journal(runs_dir): + """Flow's append-only attended decision records for the single paused run.""" + from openadapt_flow.runtime.durable.attended import AttendedActionStore + + run = next(p for p in Path(runs_dir).iterdir() if p.is_dir()) + return AttendedActionStore(run)._read_log().decisions + + +def test_reject_is_offered_without_a_live_executor_unlike_continue_and_skip( + paused_attention, +): + """The brake must not be gated on the machinery only the accelerator needs. + + ``live_actions_ready`` gates continue and skip because they need Flow's + deployment-bound executor to re-read the application and act on it. + Rejecting actuates nothing and resumes nothing, so it has nothing to gate + on. Gating it anyway would leave a configuration that can say "proceed" -- + which writes to the system of record -- but cannot say "stop", and the only + thing that removes is the brake. + """ + bridge = make_bridge(paused_attention, allow_actions=True, service=None) + specs = {spec.name for spec in bridge.list_tool_specs()} + + assert "reject_attention" in specs + assert {"continue_attention", "skip_attention"} & specs == set(), ( + "the premise is that no live executor is configured here" + ) + + # And it is still off entirely when actions are not enabled at all. + assert "reject_attention" not in { + spec.name for spec in make_bridge(paused_attention).list_tool_specs() + } + + +def test_reject_ends_the_run_and_is_recorded_as_an_automated_decision( + paused_attention, +): + """Two properties in one path, because they are only meaningful together. + + The bridge can now end a run. That is precisely why the record must say a + MODEL ended it: `operator` is derived from the same local OS identity a + person's own console uses, so without this the two are indistinguishable + and any agreement rate silently mixes them. + """ + bridge = make_bridge(paused_attention, allow_actions=True, service=None) + item, arguments = item_and_args(bridge, action="reject") + + result = bridge.dispatch("reject_attention", dict(arguments)) + assert result["action"] == "reject" + assert result["status"] == "rejected" + assert result["success"] is False + # The rejection copy must not read like the escalation copy: that one says + # the pause remains, this one says the run is over. + assert "terminal" in result["message"] + assert "remains available" not in result["message"] + + decision = _journal(paused_attention["runs"])[-1] + assert decision.action == "reject" + assert decision.decided_by == "automation", ( + "a model's answer must never be recorded as a person's" + ) + + +def test_every_bridge_decision_declares_itself_automated_including_continue( + paused_attention, +): + """Not only reject. The bias this fixes was always about `continue`. + + `continue` resumes the run and can write to the system of record, and it + has been model-callable all along. If provenance were attached only to the + action added last, the population that actually matters would stay + unlabelled. + """ + executor = ResultExecutor() + service = DirectFlowService(executor) + bridge = make_bridge(paused_attention, allow_actions=True, service=service) + item, arguments = item_and_args(bridge, action="continue") + + bridge.dispatch("continue_attention", dict(arguments)) + + assert service.deciders == ["automation"], service.deciders + assert _journal(paused_attention["runs"])[-1].decided_by == "automation" + + +def test_the_reject_tool_schema_is_closed_and_needs_explicit_confirmation( + paused_attention, +): + """Ending a run is not something a model may do by omission.""" + bridge = make_bridge(paused_attention, allow_actions=True, service=None) + schema = next( + spec.input_schema + for spec in bridge.list_tool_specs() + if spec.name == "reject_attention" + ) + assert schema["additionalProperties"] is False + assert schema["properties"]["confirm_run_must_not_proceed"]["const"] is True + assert "confirm_run_must_not_proceed" in schema["required"] + # No free text may reach a governed decision from a model. + assert not any( + prop.get("type") == "string" and "pattern" not in prop and "const" not in prop + for name, prop in schema["properties"].items() + ), schema["properties"] + + item, arguments = item_and_args(bridge, action="reject") + arguments["confirm_run_must_not_proceed"] = False + # `dispatch` re-raises the bridge's refusal as the public BridgeError. + with pytest.raises(BridgeError, match="must be explicitly true"): + bridge.dispatch("reject_attention", arguments) From 253c56bcb4e0f6ef5eb8eb77ff3af896fb10279e Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 27 Jul 2026 23:29:50 -0400 Subject: [PATCH 2/5] style(tests): ruff format the attended bridge tests The new reject/provenance cases introduced the only formatting drift in this file; main was clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM --- tests/test_attended_bridge.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_attended_bridge.py b/tests/test_attended_bridge.py index 8cff881..c29da14 100644 --- a/tests/test_attended_bridge.py +++ b/tests/test_attended_bridge.py @@ -619,9 +619,7 @@ def test_the_reject_tool_schema_is_closed_and_needs_explicit_confirmation( """Ending a run is not something a model may do by omission.""" bridge = make_bridge(paused_attention, allow_actions=True, service=None) schema = next( - spec.input_schema - for spec in bridge.list_tool_specs() - if spec.name == "reject_attention" + spec.input_schema for spec in bridge.list_tool_specs() if spec.name == "reject_attention" ) assert schema["additionalProperties"] is False assert schema["properties"]["confirm_run_must_not_proceed"]["const"] is True From f16f907347c3cc5088a980c01ea654f6ab804a57 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 18 Aug 2026 11:31:48 -0400 Subject: [PATCH 3/5] test: validate Flow dependency boundaries --- .github/workflows/ci.yml | 18 ++++- tests/test_attended_bridge.py | 123 ++++++++++++++++++++++++++++------ 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54fec09..4921eaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,14 +11,28 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + include: + # Cover both dependency edges without doubling the hosted matrix. + - python-version: "3.10" + dependency-set: floor + - python-version: "3.11" + dependency-set: current + - python-version: "3.12" + dependency-set: current steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: pip - - name: Install + - name: Install dependency floors + if: matrix.dependency-set == 'floor' + run: >- + pip install -e ".[dev]" + "openadapt-flow==1.26.0" + "mcp==1.28.0" + - name: Install current allowed dependencies + if: matrix.dependency-set == 'current' run: pip install -e ".[dev]" - name: Lint run: ruff check src tests scripts diff --git a/tests/test_attended_bridge.py b/tests/test_attended_bridge.py index c29da14..a174465 100644 --- a/tests/test_attended_bridge.py +++ b/tests/test_attended_bridge.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import io import json import os from pathlib import Path @@ -15,7 +17,7 @@ @pytest.fixture() -def paused_attention(tmp_path): +def paused_attention(tmp_path, monkeypatch): from openadapt_flow.ir import ( ActionKind, HaltObservation, @@ -32,6 +34,8 @@ def paused_attention(tmp_path): RunManifest, issue_attended_capability, ) + from openadapt_flow.runtime.durable.approval import approval_pause_digest + from openadapt_flow.runtime.durable.authority import DurableAuthority workflow = Workflow( name="Attended reference", @@ -60,16 +64,24 @@ def paused_attention(tmp_path): workflow.save(bundle) runs = tmp_path / "runs" run = runs / "run-one" + monkeypatch.setenv( + "OPENADAPT_DURABLE_AUTHORITY_DB", + str(tmp_path / "durable-authority" / "authority.sqlite3"), + ) store = CheckpointStore(run) - store.write_manifest( - RunManifest( - run_id="run-instance-a", - workflow_name=workflow.name, - bundle_dir=str(bundle), - params={}, - ) + manifest = RunManifest( + run_id="run-instance-a", + namespace_id="namespace-instance-a", + canonical_run_dir=str(run.resolve()), + workflow_name=workflow.name, + bundle_dir=str(bundle), + params={}, ) + store.write_fresh_manifest(manifest) + authority = DurableAuthority(run, store) + authority_digest = authority.validate(manifest).progress_digest pending = PendingEscalation( + run_id=manifest.run_id, workflow_name=workflow.name, step_index=0, step_id="human", @@ -104,6 +116,12 @@ def paused_attention(tmp_path): workflow=workflow, result=failed, ) + authority.advance( + manifest, + expected_progress_digest=authority_digest, + phase="paused", + pause_binding_sha256=approval_pause_digest(pending), + ) return { "bundle": bundle, "bundles": bundle.parent, @@ -117,21 +135,87 @@ def paused_attention(tmp_path): class ResultExecutor: def __init__(self): self.calls = 0 + self.actions = [] + + from openadapt_flow.runtime.durable import BoundAttendedExecutor + from openadapt_flow.runtime.replayer import Replayer + + actions = self.actions + + class Backend: + viewport = (300, 200) + + def __init__(self): + from PIL import Image + + buffer = io.BytesIO() + Image.new("RGB", self.viewport, (240, 240, 240)).save(buffer, format="PNG") + self.frame = buffer.getvalue() + self.guarded_keyboard_point = None + + def screenshot(self): + return self.frame + + def press(self, key): + actions.append(("press", key)) + + def guarded_keyboard_frame(self): + return self.frame + + def arm_guarded_keyboard(self, x, y): + self.guarded_keyboard_point = (int(x), int(y)) + + def cancel_guarded_keyboard(self): + self.guarded_keyboard_point = None + + def press_guarded(self, key, *, expected_frame_sha256): + from openadapt_flow.ir import ActionDeliveryReceipt + + assert self.guarded_keyboard_point is not None + self.guarded_keyboard_point = None + assert hashlib.sha256(self.frame).hexdigest() == expected_frame_sha256 + actions.append(("press", key)) + return ActionDeliveryReceipt( + receipt_id=f"agent-test-{len(actions)}", + operation="physical_press", + native=False, + delivered_at="2026-07-25T00:00:00+00:00", + ) + + class Vision: + @staticmethod + def text_present(_screen_png, text, *, region=None, min_ratio=0.8): + del region, min_ratio + return text == "DONE" + + @staticmethod + def wait_settled(backend, **_kwargs): + return backend.screenshot() + + @staticmethod + def phash_png(_png, region=None): + del region + return "aa" + + @staticmethod + def phash_distance(_left, _right): + return 0 + + self.bound = BoundAttendedExecutor( + lambda _manifest: Replayer( + Backend(), + vision=Vision(), + poll_interval_s=0.0, + ) + ) - def continue_run(self, _run_dir, capability, _approval): - from openadapt_flow.runtime.durable import AttendedExecutionResult - + def continue_run(self, run_dir, capability, approval): self.calls += 1 - return AttendedExecutionResult( - status="completed", - message="human outcome verified; deterministic continuation completed", - report_success=True, - resumed_from=capability.step_id, - next_transition=capability.expected_next_transition, - ) + return self.bound.continue_run(run_dir, capability, approval) def skip_run(self, run_dir, capability, approval): - return self.continue_run(run_dir, capability, approval) + self.calls += 1 + return self.bound.skip_run(run_dir, capability, approval) class FailingExecutor(ResultExecutor): @@ -253,6 +337,7 @@ def test_continue_is_exactly_bound_and_idempotent_without_reactuation( assert first["status"] == "completed" assert first["success"] is True assert executor.calls == 1 + assert executor.actions == [("press", "Tab")] decisions = json.loads((paused_attention["run"] / "attended_decisions.json").read_text())[ "decisions" ] From 545e62acb2edec9a5e5de05db657065ad6b208b9 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 18 Aug 2026 11:39:00 -0400 Subject: [PATCH 4/5] fix: confirm attended rejection over MCP --- src/openadapt_agent/mcp.py | 5 +++++ tests/test_mcp_server.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/openadapt_agent/mcp.py b/src/openadapt_agent/mcp.py index 94b6a7f..d342bd4 100644 --- a/src/openadapt_agent/mcp.py +++ b/src/openadapt_agent/mcp.py @@ -48,6 +48,11 @@ "Confirm that OpenAdapt should record an audited escalation and preserve " "the exact durable pause for qualified assistance." ), + "reject_attention": ( + "Confirm that OpenAdapt must end this run without resuming or actuating it. " + "The durable pause remains only as the audit record. Use escalation instead " + "when a qualified colleague must inspect and possibly continue the run." + ), } diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 825da11..0668e07 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -201,6 +201,20 @@ def test_attended_action_requires_protocol_native_human_confirmation(): assert request_id == "request-123" +def test_reject_requires_protocol_native_terminal_confirmation(): + session = ElicitationSession() + anyio.run( + _confirm_attended_action, + ElicitationServer(session), + "reject_attention", + ) + message, schema, request_id = session.calls[0] + assert "end this run" in message + assert "without resuming or actuating" in message + assert schema["properties"]["confirmed"]["type"] == "boolean" + assert request_id == "request-123" + + @pytest.mark.parametrize( ("session", "error"), [ From b634d1817fa40d42bd513c3b5fc2105028e9ed2e Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 18 Aug 2026 11:47:47 -0400 Subject: [PATCH 5/5] fix: make attended rejection contract explicit --- .github/workflows/ci.yml | 5 +++ README.md | 7 +-- docs/DESIGN.md | 18 ++++++-- docs/DISTRIBUTION.md | 6 +-- llms.txt | 3 +- manifest.json | 2 +- server.json | 2 +- src/openadapt_agent/attended.py | 18 +++++--- src/openadapt_agent/bridge.py | 2 +- src/openadapt_agent/cli.py | 2 +- src/openadapt_agent/mcp.py | 10 +++-- src/openadapt_agent/skill.py | 4 ++ tests/golden/skill_appendix.md | 4 ++ tests/test_attended_bridge.py | 40 ++++++++++++++++- tests/test_cli.py | 10 +++++ tests/test_mcp_server.py | 26 ++++++++++- tests/test_skill.py | 2 + uv.lock | 77 ++------------------------------- 18 files changed, 138 insertions(+), 100 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4921eaf..cf6ab38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,11 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: pip + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + if: matrix.dependency-set == 'floor' + - name: Verify lock consistency + if: matrix.dependency-set == 'floor' + run: uv lock --locked - name: Install dependency floors if: matrix.dependency-set == 'floor' run: >- diff --git a/README.md b/README.md index 0118e56..fd6d41d 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Attended actions are separate, exact tools: | --- | --- | | `continue_attention` | The operator confirms they completed the paused task in the live app. Flow revalidates its postconditions and independent effects, checkpoints it as human-completed, and resumes after it. It does not perform the completed action again. | | `skip_attention` | Flow applies only an already-declared, non-consequential skip. A stale, undeclared, consequential, or ambiguous skip is refused. | +| `reject_attention` | Terminates this run and dispatches no new action. Earlier run actions can still have effects, so review the protected local report and transaction outcome. Use Escalate if a qualified operator can still continue the run. | | `teach_attention` | Records an audited request for a corrective demonstration. Flow's existing revision and regression gates decide what can be promoted. | | `escalate_attention` | Records an audited escalation and leaves the exact durable pause intact for a qualified operator. | @@ -136,7 +137,7 @@ confirmation signal, not cryptographic proof that a particular person clicked it or proof of that person's identity. Flow separately records the effective local OS account as the operator. Clients without form elicitation cannot execute attended actions through this MCP bridge; the same -Continue, Skip, Teach, and Escalate capabilities remain available through +Continue, Skip, Reject, Teach, and Escalate capabilities remain available through Flow's attended console/CLI. MCP destructive/idempotent/open-world annotations give the host an additional approval signal. Neither those hints nor the elicitation replaces Flow's signed capability, live @@ -161,7 +162,7 @@ Retries with the same idempotency key return the prior terminal decision instead of repeating it. With `--allow-attended-actions` but no deployment `--config`, the safe -Teach and Escalate transitions remain available; Continue and Skip are +Reject, Teach, and Escalate transitions remain available; Continue and Skip are not registered until Flow can construct the deployment-bound live verifier and backend. @@ -175,7 +176,7 @@ verifier and backend. | `list_needs_attention` | Always | | `get_attention_item` | Always | | `run_workflow_` | `--allow-run` | -| `teach_attention`, `escalate_attention` | `--allow-attended-actions` | +| `reject_attention`, `teach_attention`, `escalate_attention` | `--allow-attended-actions` | | `continue_attention`, `skip_attention` | `--allow-attended-actions` plus a qualified deployment `--config` | ## Run outcomes diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d5b8374..edb352a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -90,7 +90,7 @@ operation: use demonstrated values. It requires run authority and is only for synthetic demos; it never places those values in a tool schema. -`--allow-attended-actions` registers Teach and Escalate. Continue and +`--allow-attended-actions` registers Reject, Teach, and Escalate. Continue and Skip are registered only when a deployment configuration lets Flow construct its bound live executor. @@ -160,9 +160,11 @@ person clicked, nor identity proof. Flow separately records the effective local OS account as the operator. A client that does not advertise form elicitation cannot execute attended mutations through MCP; the operator uses Flow's existing attended console/CLI instead, where all -four capabilities remain available. This is a transport authorization +five capabilities remain available. This is a transport authorization choice, not a read-only conversion. Tool annotations also mark Continue -and Skip as destructive, idempotent, and open-world so the host can apply +and Skip as destructive, idempotent, and open-world. Reject is destructive +and idempotent but not open-world because it dispatches no new application +action. These hints let the host apply its own approval policy. Annotations and elicitation do not replace Flow's signed capability, live revalidation, idempotency, or durable audit. @@ -190,6 +192,14 @@ and compiled workflow declare a safe, non-consequential skip. Flow rechecks that guard against current state. Consequential, stale, ambiguous, or undeclared skips are refused. +### Reject + +Reject terminates the current run and permanently prevents resume. The +rejection dispatches no new application action. Earlier steps in the run can +still have effects, so the operator must inspect the protected local report +and transaction outcome. Use Escalate instead when a qualified operator can +still inspect and continue the run. + ### Teach Teach records an audited request for a corrective demonstration. The @@ -262,7 +272,7 @@ Tests cover: - PHI-safe queue projections and path traversal refusal; - stale capability, unknown field, and false-confirmation refusal; - idempotent Continue without re-actuation; -- Teach and Escalate without a live service; +- Reject, Teach, and Escalate without a live service; - delegation to Flow's public service context; - compatibility with Flow's public, thread-owned attended service; - success/halt/refusal/timeout outcome mapping; diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index ed7b3dc..64f208a 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -34,9 +34,9 @@ default install yields PHI-safe inspection and Needs Attention tools only (`list_workflows`, `get_workflow`, `get_run_report`, `list_needs_attention`, and `get_attention_item`). Enabling workflow runs adds the dynamic `run_workflow_` tools; enabling attended actions -adds Teach and Escalate, while a qualified deployment config also makes +adds Reject, Teach, and Escalate, while a qualified deployment config also makes Continue and Skip available. Clients without MCP form elicitation use Flow's -attended console/CLI, where all four capabilities remain available. This +attended console/CLI, where all five capabilities remain available. This matches the security model in [`DESIGN.md`](DESIGN.md). > There is intentionally **no** hosted, multi-tenant "official OpenAdapt @@ -68,7 +68,7 @@ and [`../manifest.json`](../manifest.json). - `get_run_report` — PHI-safe status and count summary; raw evidence stays local unless protected export was explicitly enabled. - `list_needs_attention` / `get_attention_item` — PHI-safe durable-pause cards and current signed-capability metadata. - `run_workflow_` — execute through the governed `openadapt-flow run` CLI when `--allow-run`; returns `success` | `halt` | `refused` | `timeout` | `error`. - - `continue_attention` / `skip_attention` / `teach_attention` / `escalate_attention` — exact, elicited attended decisions under Flow's capability, idempotency, verification, and audit contract. + - `continue_attention` / `skip_attention` / `reject_attention` / `teach_attention` / `escalate_attention` — exact, elicited attended decisions under Flow's capability, idempotency, verification, and audit contract. - **Categories/tags:** mcp, agent-skills, automation, workflow, gui, governed, healthcare, rpa ## 3. Release automation vs. founder one-time actions diff --git a/llms.txt b/llms.txt index f588e9e..ddbaf07 100644 --- a/llms.txt +++ b/llms.txt @@ -5,13 +5,14 @@ ## What it provides - `openadapt-agent serve --bundles [--allow-run]`: a local MCP stdio server. `list_workflows`, `get_workflow`, `get_run_report`, `list_needs_attention`, and `get_attention_item` are always available as PHI-safe read-only projections. `run_workflow_` tools require `--allow-run`. -- `--allow-attended-actions` adds exact Teach and Escalate tools for signed durable pauses. With a qualified Flow `--config`, the same server also exposes Continue and Skip through Flow's deployment-bound live verifier and deterministic resume path. +- `--allow-attended-actions` adds exact Reject, Teach, and Escalate tools for signed durable pauses. With a qualified Flow `--config`, the same server also exposes Continue and Skip through Flow's deployment-bound live verifier and deterministic resume path. - `openadapt-agent emit-skill --out ` wraps Flow's skill emitter and appends MCP, halt, and attended-action guidance. ## Attended actions - `continue_attention`: after the local operator completes the paused task, Flow verifies the exact outcome and resumes without actuating that task again. - `skip_attention`: applies only a compiled, non-consequential skip that Flow revalidates at decision time. +- `reject_attention`: terminates this run and dispatches no new action. Earlier run actions may have effects, so review the protected local report and transaction outcome. Use escalation when a qualified operator can still continue the run. - `teach_attention`: records an audited corrective-demonstration request; Flow's revision and regression gates still decide promotion. - `escalate_attention`: records escalation and preserves the exact durable pause. diff --git a/manifest.json b/manifest.json index dd81bb4..0f6275d 100644 --- a/manifest.json +++ b/manifest.json @@ -107,7 +107,7 @@ "allow_attended_actions": { "type": "boolean", "title": "Allow Needs Attention decisions", - "description": "Add Teach and Escalate. A qualified deployment config also enables Continue and Skip with live revalidation.", + "description": "Add Reject, Teach, and Escalate. Reject dispatches no new action, but earlier run effects still require protected local review. A qualified deployment config also enables Continue and Skip with live revalidation.", "required": false, "default": false }, diff --git a/server.json b/server.json index ff61289..e76f1f5 100644 --- a/server.json +++ b/server.json @@ -47,7 +47,7 @@ ], "_meta": { "io.modelcontextprotocol.registry/publisher-provided": { - "notes": "PHI-safe inspection and Needs Attention tools are available by default. Run tools require --allow-run. Continue, Skip, Teach, and Escalate require --allow-attended-actions; Continue and Skip additionally require a qualified deployment --config. See docs/DISTRIBUTION.md." + "notes": "PHI-safe inspection and Needs Attention tools are available by default. Run tools require --allow-run. Reject, Teach, and Escalate require --allow-attended-actions; Continue and Skip also require --allow-attended-actions and a qualified deployment --config. Reject dispatches no new action, but earlier run actions may have effects and require review of the protected local outcome. See docs/DISTRIBUTION.md." } } } diff --git a/src/openadapt_agent/attended.py b/src/openadapt_agent/attended.py index 7d7769f..211d7a9 100644 --- a/src/openadapt_agent/attended.py +++ b/src/openadapt_agent/attended.py @@ -50,9 +50,11 @@ # pause remains; this one says the run is over. A caller told the wrong # one of those acts on it. "rejected": ( - "The rejection was recorded and the run is terminal. Nothing was " - "actuated, no approval can resume it, and the durable pause is " - "retained only as the audit record of what was rejected." + "The rejection was recorded and the run is terminal. This rejection " + "dispatched no new action. Earlier run actions may have effects; review " + "the protected local report and transaction outcome. No approval can " + "resume the run. The durable pause is retained only as the audit record " + "of what was rejected." ), } @@ -150,8 +152,10 @@ class AttendedTool: "escalate_attention, which parks the run for a colleague who can " "still continue it, and it is not teach_attention, which changes " "future runs. It asserts nothing about the saved workflow and " - "actuates nothing. Flow independently refuses a rejection whose " - "delivery may already have landed." + "dispatches no new action. Earlier run actions can still have " + "effects, so inspect the protected report and transaction outcome. " + "Flow independently refuses a rejection whose delivery may already " + "have landed." ), ), } @@ -235,8 +239,8 @@ def enabled_action_tools(self) -> tuple[str, ...]: ``reject_attention`` sits with teach and escalate rather than behind ``live_actions_ready``. That gate exists because continue and skip need Flow's deployment-bound live executor to re-read the application and - act on it. Rejecting actuates nothing and resumes nothing, so it has - nothing to gate on -- the same reason Flow's own ``_allowed_actions`` + act on it. Reject dispatches no new action and resumes nothing, so it + has nothing to gate on -- the same reason Flow's own ``_allowed_actions`` offers it at a pause carrying no resolvable action step at all. Withholding it would also leave this bridge able to say "proceed" -- diff --git a/src/openadapt_agent/bridge.py b/src/openadapt_agent/bridge.py index da0e6b0..7a60ddf 100644 --- a/src/openadapt_agent/bridge.py +++ b/src/openadapt_agent/bridge.py @@ -223,7 +223,7 @@ def list_tool_specs(self) -> list[ToolSpec]: input_schema=action_input_schema(tool), annotations={ "readOnlyHint": False, - "destructiveHint": tool.action in {"continue", "skip"}, + "destructiveHint": tool.action in {"continue", "skip", "reject"}, "idempotentHint": True, "openWorldHint": tool.action in {"continue", "skip"}, }, diff --git a/src/openadapt_agent/cli.py b/src/openadapt_agent/cli.py index 6b64cf5..992c468 100644 --- a/src/openadapt_agent/cli.py +++ b/src/openadapt_agent/cli.py @@ -82,7 +82,7 @@ def build_parser() -> argparse.ArgumentParser: "--allow-attended-actions", action="store_true", help=( - "Register governed Teach/Escalate tools for signed durable pauses. " + "Register governed Reject/Teach/Escalate tools for signed durable pauses. " "With --config, also register Continue/Skip through Flow's " "deployment-bound live verifier and deterministic resume path." ), diff --git a/src/openadapt_agent/mcp.py b/src/openadapt_agent/mcp.py index d342bd4..f7476c1 100644 --- a/src/openadapt_agent/mcp.py +++ b/src/openadapt_agent/mcp.py @@ -49,9 +49,11 @@ "the exact durable pause for qualified assistance." ), "reject_attention": ( - "Confirm that OpenAdapt must end this run without resuming or actuating it. " - "The durable pause remains only as the audit record. Use escalation instead " - "when a qualified colleague must inspect and possibly continue the run." + "Confirm that OpenAdapt must end this run without resuming it. This rejection " + "dispatches no new action, but earlier run actions may have effects. Review the " + "protected local report and transaction outcome. The durable pause remains only " + "as the audit record. Use escalation instead when a qualified colleague must " + "inspect and possibly continue the run." ), } @@ -106,6 +108,8 @@ def build_server(bridge: AgentBridge) -> Server: "plus protocol-native operator elicitation, an exact signed " "capability, live revalidation, and a stable idempotency key; they " "never re-actuate the human-completed step. " + "Reject terminates the run and dispatches no new action, but earlier " + "run effects still require review of the protected local outcome. " "Never report a halted, refused, or timed-out run as a success." ), ) diff --git a/src/openadapt_agent/skill.py b/src/openadapt_agent/skill.py index 1639ec5..8753871 100644 --- a/src/openadapt_agent/skill.py +++ b/src/openadapt_agent/skill.py @@ -74,6 +74,10 @@ the paused task in the live application. Flow revalidates the outcome and resumes after it; it never performs that completed action again. - `skip_attention` only for an allowed, declared skip. +- `reject_attention` to terminate this run without dispatching a new + action. Earlier run actions may have effects. Review the protected local + report and transaction outcome. Use escalation if a qualified operator + can still continue the run. - `teach_attention` to request a corrective demonstration. - `escalate_attention` to preserve the pause for qualified assistance. diff --git a/tests/golden/skill_appendix.md b/tests/golden/skill_appendix.md index 262f0ec..5f2d37c 100644 --- a/tests/golden/skill_appendix.md +++ b/tests/golden/skill_appendix.md @@ -53,6 +53,10 @@ that matches their explicit decision: the paused task in the live application. Flow revalidates the outcome and resumes after it; it never performs that completed action again. - `skip_attention` only for an allowed, declared skip. +- `reject_attention` to terminate this run without dispatching a new + action. Earlier run actions may have effects. Review the protected local + report and transaction outcome. Use escalation if a qualified operator + can still continue the run. - `teach_attention` to request a corrective demonstration. - `escalate_attention` to preserve the pause for qualified assistance. diff --git a/tests/test_attended_bridge.py b/tests/test_attended_bridge.py index a174465..d76e088 100644 --- a/tests/test_attended_bridge.py +++ b/tests/test_attended_bridge.py @@ -629,7 +629,7 @@ def test_reject_is_offered_without_a_live_executor_unlike_continue_and_skip( ``live_actions_ready`` gates continue and skip because they need Flow's deployment-bound executor to re-read the application and act on it. - Rejecting actuates nothing and resumes nothing, so it has nothing to gate + Reject dispatches no new action and resumes nothing, so it has nothing to gate on. Gating it anyway would leave a configuration that can say "proceed" -- which writes to the system of record -- but cannot say "stop", and the only thing that removes is the brake. @@ -658,6 +658,22 @@ def test_reject_ends_the_run_and_is_recorded_as_an_automated_decision( person's own console uses, so without this the two are indistinguishable and any agreement rate silently mixes them. """ + from openadapt_flow.ir import RunReport, StepResult + + # Model a later pause. A successful earlier step can have an external + # effect even though rejection itself dispatches no new action. + report_path = paused_attention["run"] / "report.json" + report = RunReport.model_validate_json(report_path.read_text()) + report.results.insert( + 0, + StepResult( + step_id="prior", + intent="complete an earlier consequential step", + ok=True, + ), + ) + report.save(paused_attention["run"]) + bridge = make_bridge(paused_attention, allow_actions=True, service=None) item, arguments = item_and_args(bridge, action="reject") @@ -669,6 +685,14 @@ def test_reject_ends_the_run_and_is_recorded_as_an_automated_decision( # the pause remains, this one says the run is over. assert "terminal" in result["message"] assert "remains available" not in result["message"] + assert "dispatched no new action" in result["message"] + assert "Earlier run actions may have effects" in result["message"] + + # A reject at a later pause does not erase the completed work before it. + # The public response must direct the caller to that protected evidence. + report = json.loads(report_path.read_text()) + assert report["results"][0]["step_id"] == "prior" + assert report["results"][0]["ok"] is True decision = _journal(paused_attention["runs"])[-1] assert decision.action == "reject" @@ -720,3 +744,17 @@ def test_the_reject_tool_schema_is_closed_and_needs_explicit_confirmation( # `dispatch` re-raises the bridge's refusal as the public BridgeError. with pytest.raises(BridgeError, match="must be explicitly true"): bridge.dispatch("reject_attention", arguments) + + +def test_reject_is_exported_as_a_destructive_idempotent_local_mutation( + paused_attention, +): + bridge = make_bridge(paused_attention, allow_actions=True, service=None) + spec = next(spec for spec in bridge.list_tool_specs() if spec.name == "reject_attention") + + assert spec.annotations == { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + } diff --git a/tests/test_cli.py b/tests/test_cli.py index a398e8c..2b9a2d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from openadapt_agent.cli import build_parser, main @@ -30,6 +32,14 @@ def test_attended_flags_parse_as_server_fixed_configuration(tmp_path): assert args.headed is True +def test_attended_help_names_the_no_config_reject_capability(capsys): + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args(["serve", "--help"]) + + assert exc_info.value.code == 0 + assert "Reject/Teach/Escalate" in capsys.readouterr().out + + def test_custom_flow_cli_is_refused_when_attended_actions_are_enabled(tmp_path, capsys): result = main( [ diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 0668e07..d0bd7d8 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -61,6 +61,29 @@ async def list_tools(): ] +def test_server_exports_reject_as_a_destructive_local_mutation( + bundles_root, + runner_config, +): + bridge = AgentBridge( + bundles_root, + runner_config, + allow_attended_actions=True, + ) + server = build_server(bridge) + + async def reject_tool(): + handler = server.request_handlers[types.ListToolsRequest] + result = await handler(types.ListToolsRequest(method="tools/list")) + return next(tool for tool in result.root.tools if tool.name == "reject_attention") + + annotations = anyio.run(reject_tool).annotations + assert annotations.readOnlyHint is False + assert annotations.destructiveHint is True + assert annotations.idempotentHint is True + assert annotations.openWorldHint is False + + def test_bridge_refusals_are_mcp_error_results(bundles_root, runner_config): runner_config.runs_dir.mkdir() bridge = AgentBridge(bundles_root, runner_config) @@ -210,7 +233,8 @@ def test_reject_requires_protocol_native_terminal_confirmation(): ) message, schema, request_id = session.calls[0] assert "end this run" in message - assert "without resuming or actuating" in message + assert "dispatches no new action" in message + assert "earlier run actions may have effects" in message assert schema["properties"]["confirmed"]["type"] == "boolean" assert request_id == "request-123" diff --git a/tests/test_skill.py b/tests/test_skill.py index 8799d58..af2b0af 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -45,3 +45,5 @@ def test_appendix_names_the_mcp_tool_and_params(bundle_dir, tmp_path): assert "NOTHING was executed" in text assert "continue_attention" in text assert "never performs that completed action again" in text + assert "reject_attention" in text + assert "Earlier run actions may have effects" in text diff --git a/uv.lock b/uv.lock index 9267b47..5f28a81 100644 --- a/uv.lock +++ b/uv.lock @@ -191,43 +191,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661 }, ] -[[package]] -name = "greenlet" -version = "3.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/9d/58f80897f4121f5c218bb931cf6d3b6514873f02ad0b729f744352926b9f/greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190", size = 293072 }, - { url = "https://files.pythonhosted.org/packages/dd/9f/b4bc9bbd6a7855cbd8ad8a83c874eeeca56c24de9132b3323f81c03a30ba/greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353", size = 609393 }, - { url = "https://files.pythonhosted.org/packages/05/0e/744b5e063af127d2e3c74fe0f1aef15573064c83b6066883524f5b258b17/greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606", size = 622750 }, - { url = "https://files.pythonhosted.org/packages/5d/5c/53d6b94742a6f1ee1877c7ff76262c909e137f3f7383ce96a8ab78e1ae31/greenlet-3.5.4-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52", size = 629659 }, - { url = "https://files.pythonhosted.org/packages/d6/6b/d78ea2908e8e08985348f28ac396c2950be7ab66321dfe0054c73bd1f456/greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7", size = 622920 }, - { url = "https://files.pythonhosted.org/packages/f2/34/957fc5577180ef2f57be82580ee1f59fdefad4f6c623c7d5e1b6980a76fb/greenlet-3.5.4-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c", size = 425580 }, - { url = "https://files.pythonhosted.org/packages/eb/e4/3ce7009c948920b01527f8d9da29f501a31ac3d98318829e981fd879b850/greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7", size = 1582262 }, - { url = "https://files.pythonhosted.org/packages/f2/4c/0408366102a33829f7bdd6a992dad75abbf75e86cc1e76caf19e57311d29/greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df", size = 1648906 }, - { url = "https://files.pythonhosted.org/packages/13/52/ebfe8f6a1aeb8e430540b406c844ecc4e3367072b0192f69dcb85eeeec2b/greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616", size = 246036 }, - { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266 }, - { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712 }, - { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582 }, - { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429 }, - { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663 }, - { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238 }, - { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010 }, - { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283 }, - { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434 }, - { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990 }, - { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909 }, - { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011 }, - { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299 }, - { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603 }, - { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541 }, - { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554 }, - { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444 }, - { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842 }, - { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169 }, - { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565 }, -] - [[package]] name = "h11" version = "0.16.0" @@ -453,14 +416,14 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0" }, { name = "build", marker = "extra == 'dev'", specifier = ">=1.2.0" }, { name = "mcp", specifier = ">=1.28,<2" }, - { name = "openadapt-flow", specifier = ">=1.18.1,<2" }, + { name = "openadapt-flow", specifier = ">=1.26.0,<2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, ] [[package]] name = "openadapt-flow" -version = "1.24.0" +version = "1.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -469,15 +432,14 @@ dependencies = [ { name = "numpy" }, { name = "opencv-python-headless" }, { name = "pillow" }, - { name = "playwright" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "rapidocr-onnxruntime" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/db/5b570f6511bcaed68134be094e9fc2b745f4f77c3cadf13a5d7518ac883f/openadapt_flow-1.24.0.tar.gz", hash = "sha256:2d4702e5ccbdfed0f78063ca510dc82894a2833edbed71d77edffbd0ffebd67d", size = 19275836 } +sdist = { url = "https://files.pythonhosted.org/packages/9b/47/07ea24067eaa93cc2416a432df83508fe9d2f77bdf7955298580f2e1d35f/openadapt_flow-1.31.0.tar.gz", hash = "sha256:cf1fc356d14d267df82be188de3e9a3575734f18f46ef91ac8075438cc731540", size = 20005876 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/fb/86a58376c9bb7e587c45f3d1a149486053efb0ae86069b46d7581696983a/openadapt_flow-1.24.0-py3-none-any.whl", hash = "sha256:170fdac154794292c99dc6eea6486e7a2c3fdf321bcd87976d924bccd3db4aef", size = 1414770 }, + { url = "https://files.pythonhosted.org/packages/a7/20/dd8ccd56afb0c3c369f13adb85695acf4f0495a0a61553f576ec0977ab19/openadapt_flow-1.31.0-py3-none-any.whl", hash = "sha256:81133db1528ad1bb1f26e3fcb6aea61b0651db6d905cf2e4943e8383c1f3d29c", size = 1774979 }, ] [[package]] @@ -567,25 +529,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786 }, ] -[[package]] -name = "playwright" -version = "1.61.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877 }, - { url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016 }, - { url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884 }, - { url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381 }, - { url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545 }, - { url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841 }, - { url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846 }, - { url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127 }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -746,18 +689,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715 }, ] -[[package]] -name = "pyee" -version = "13.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659 }, -] - [[package]] name = "pygments" version = "2.20.0"