Skip to content
This repository was archived by the owner on Oct 17, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,8 @@ Flow's [execution profiles](https://github.com/OpenAdaptAI/openadapt-flow/blob/m
| `--config YAML` | Flow's deployment config ([example](https://github.com/OpenAdaptAI/openadapt-flow/blob/main/docs/deployment.example.yaml)), which sets the profile and whether model grounding is allowed. |
| `--policy NAME-OR-PATH` | The policy Flow certifies against, such as `clinical-write` or `permissive`. |
| `--timeout SECONDS` | How long one run may work (default 600). Time a person spends approving a save doesn't count. A run stopped at the limit is `not_sure_if_saved`. |
| `--approval-mode MODE` | Whether a person approves each save. Without it, Flow decides: under `standard` and `regulated` a run stops before each save until a person approves it (`each_item`). `not_gated` turns that off for every run this server starts; each result records it under `technical.approval_mode`. |
| `--approval-mode MODE` | Whether each save waits for approval. Without it, Flow decides: under `standard` and `regulated` a run stops before each save until it's approved (`each_item`). `not_gated` turns that off for every run this server starts; each result records it under `technical.approval_mode`. |
| `--delegate-approvals AGENT_ID` | Lets your agent answer a waiting save as `AGENT_ID` (an opaque id such as `agent:intake-bot`), with `list_approval_requests` and `answer_approval_request`. Flow accepts the answer only where the run's approver policy allows an agent for that step, and records the agent as the approver. By default only a person may answer. Needs an openadapt-flow with delegated approvers (not released yet). |
| `--headed` | Shows the browser. A person needs it to continue or skip a stopped run in a web app. |
| `--extra-run-arg ARG`, `--flow-cli CMD` | Change how Flow is called. Either one can relax Flow's checks, for example `--extra-run-arg=--allow-unencrypted`. |
| `--allow-protected-export`, `--allow-synthetic-recorded-defaults`, `--allow-model-grounding` | Off by default. See [Security](#security). |
Expand Down Expand Up @@ -407,7 +408,7 @@ openadapt-agent seal verify https://example.com/receipt.json --json
- The check that stops a second entry covers one runs folder. Pass the same absolute `--runs-dir` every time.
- If the server exits during a run, the run reads as `not_sure_if_saved`, and Flow may still finish it.
- This server issues no signed receipts itself. `proof` is `sealed` only when a run is connected to OpenAdapt Cloud Execute, Cloud signed its receipt, and that signature verifies against keys you pinned (see [Seals](#seals)).
- With openadapt-flow 2, every save waits for a person's approval unless the operator names another mode (`serve --approval-mode problems_only` or `not_gated`). Your agent gets `needs_review` with the reason `waiting_for_approval`, and a person approves on the computer that runs OpenAdapt. Nothing is saved without that answer.
- With openadapt-flow 2, each save waits for approval unless the operator names another mode (`serve --approval-mode problems_only` or `not_gated`). Your agent gets `needs_review` with the reason `waiting_for_approval`. A person answers on the computer that runs OpenAdapt, or your agent does when the server runs with `--delegate-approvals` and the run's approver policy allows an agent for that step. Nothing is saved without that answer. `get_approval_mode` tells your agent which mode applies; no tool changes it.
- Each call carries one request. There's no batch call.
- Accounts, separation between customers, fleet policy, and hosted runs belong to OpenAdapt Cloud.

Expand Down
83 changes: 83 additions & 0 deletions src/openadapt_agent/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from pathlib import Path
from typing import Any, Optional

from openadapt_agent import delegated
from openadapt_agent.approval import report_approval
from openadapt_agent.attended import (
ATTENDED_TOOLS,
Expand Down Expand Up @@ -351,8 +352,86 @@ def _contract_specs(self) -> list[ToolSpec]:
)
)
specs.extend(self._repair_specs())
specs.extend(self._approval_specs())
return specs

def _delegate_id(self) -> Optional[str]:
if not self.allow_run or self.sandbox is not None:
return None
return self.runner_config.delegate_approver_id

def _approval_specs(self) -> list[ToolSpec]:
"""``get_approval_mode`` always; the answer tools only when the
operator started the server with ``--delegate-approvals``."""
if not self.allow_run or self.sandbox is not None:
return []
specs = [
ToolSpec(
name="get_approval_mode",
description=delegated.GET_MODE_DESCRIPTION,
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
annotations=_READ_ONLY_ANNOTATIONS,
)
]
if self._delegate_id() is None:
return specs
list_schema = delegated.list_schema()
answer_schema = delegated.answer_schema()
for schema in (list_schema, answer_schema):
schema["properties"]["run_id"]["pattern"] = _RUN_ID_PATTERN
specs += [
ToolSpec(
name="list_approval_requests",
description=delegated.LIST_DESCRIPTION,
input_schema=list_schema,
annotations=_READ_ONLY_ANNOTATIONS,
),
ToolSpec(
name="answer_approval_request",
description=delegated.ANSWER_DESCRIPTION,
input_schema=answer_schema,
annotations={
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": True,
"openWorldHint": True,
},
),
]
return specs

def _safe_run_dir(self, run_id: str) -> Path:
if not run_id or not is_safe_run_id(run_id):
raise BridgeError("run_id must be a single path component")
runs_candidate = Path(self.runner_config.runs_dir)
run_candidate = runs_candidate / run_id
if runs_candidate.is_symlink() or run_candidate.is_symlink():
raise BridgeError("the configured run evidence boundary is unavailable")
run_dir = run_candidate.resolve()
if runs_candidate.resolve() not in run_dir.parents or not run_dir.is_dir():
raise BridgeError("no such run in the server's runs directory")
return run_dir

def _approval_dispatch(self, name: str, arguments: dict) -> dict:
if name == "get_approval_mode":
return delegated.mode_view(self.runner_config.approval_mode, self._delegate_id())
delegate_id = self._delegate_id()
if delegate_id is None:
raise BridgeError(
"this agent can't answer approvals: the operator didn't start the "
"server with --delegate-approvals"
)
run_dir = self._safe_run_dir(str(arguments.get("run_id", "")))
if name == "list_approval_requests":
return delegated.project_requests(self.runner.item_approval("show", run_dir, []))
try:
verb, args = delegated.answer_args(arguments, delegate_id)
except delegated.DelegationRefused as exc:
raise BridgeError(str(exc)) from exc
result = self.runner.item_approval(verb, run_dir, args)
allowed = ("ok", "code", "message", "request_id", "decision", "answer_sha256")
return {k: result[k] for k in allowed if k in result}

def _repair_tools_enabled(self) -> bool:
from openadapt_agent.repair import teach_supported

Expand Down Expand Up @@ -589,6 +668,10 @@ def dispatch(self, name: str, arguments: Optional[dict]) -> dict:
)
except ServiceError as exc:
raise BridgeError(str(exc)) from exc
if name in ("get_approval_mode", "list_approval_requests", "answer_approval_request"):
if not any(spec.name == name for spec in self._approval_specs()):
raise BridgeError("unknown tool name")
return self._approval_dispatch(name, arguments)
if name in ("get_repair_screen", "teach_repair"):
if not self._repair_tools_enabled():
raise BridgeError("repair tools are off on this server")
Expand Down
38 changes: 36 additions & 2 deletions src/openadapt_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,26 @@ def build_parser() -> argparse.ArgumentParser:
help=(
"Per-save approval passed to every `openadapt-flow run`. Unset "
"(the default): Flow decides, and under the Standard and Regulated "
"profiles a person approves each save (each_item); a run waiting "
"profiles each save waits for approval (each_item); a run waiting "
"for that approval reports needs_review. not_gated turns the pause "
"off for every run this server starts, and each run's result and "
"Flow's report record that. Callers can't change it."
),
)
p.add_argument(
"--delegate-approvals",
default=None,
metavar="AGENT_ID",
help=(
"Let the calling agent answer saves that wait for an approval, as "
"the opaque id AGENT_ID (for example agent:intake-bot). Adds the "
"list_approval_requests and answer_approval_request tools. Flow "
"accepts the agent's answer only where the run's approver policy "
"(openadapt-flow run --approver-policy) allows an agent for that "
"step; by default only a person may answer. Flow records the agent "
"as the approver."
),
)
p.add_argument(
"--allow-url-override",
action="store_true",
Expand Down Expand Up @@ -531,6 +545,25 @@ def _cmd_serve(args: argparse.Namespace) -> int:
file=sys.stderr,
)
return 2
if args.delegate_approvals is not None:
import re as _re

from openadapt_agent.delegated import DELEGATE_ID_PATTERN

if mode not in {"production", "attended"}:
print(
"serve: --delegate-approvals needs --mode production or --mode "
"attended with --bundles",
file=sys.stderr,
)
return 2
if not _re.match(DELEGATE_ID_PATTERN, args.delegate_approvals):
print(
"serve: --delegate-approvals needs an opaque id such as "
"agent:intake-bot, never a name or email",
file=sys.stderr,
)
return 2
if args.allow_synthetic_recorded_defaults and not (args.allow_run and args.bundles):
print(
"serve: --allow-synthetic-recorded-defaults requires --allow-run",
Expand Down Expand Up @@ -643,6 +676,7 @@ def _cmd_serve(args: argparse.Namespace) -> int:
allow_url_override=args.allow_url_override,
extra_run_args=tuple(extra_run_args),
approval_mode=args.approval_mode,
delegate_approver_id=args.delegate_approvals,
hold_on_halt_s=(
max(0.0, min(args.hold_on_halt, 1800.0)) if args.repair_access else 0.0
),
Expand Down Expand Up @@ -681,7 +715,7 @@ def _cmd_serve(args: argparse.Namespace) -> int:
+ (
f"approval mode {args.approval_mode} (chosen with --approval-mode); "
if args.approval_mode
else "approval mode set by Flow (a person approves each save "
else "approval mode set by Flow (each save waits for approval "
"under the Standard and Regulated profiles); "
)
+ f"runs folder {runs_dir}",
Expand Down
181 changes: 181 additions & 0 deletions src/openadapt_agent/delegated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Delegated approval tools: the approval mode, and answering as an agent.

The operator, never the calling agent, decides whether the agent may answer
a waiting save: ``serve --delegate-approvals AGENT_ID``. Without it these
tools aren't listed, except the read-only ``get_approval_mode``.

Even then the agent decides nothing on its own authority. Its answer goes to
``openadapt-flow item-approval`` as ``decided_by: automation`` from the
``mcp`` route, naming the agent, and Flow accepts it only where the run's
approver policy allows an agent for that step. The default policy allows
only a person, and a clinical write needs the customer's pinned opt-in.
Flow records the agent as the approver, and its decision never becomes a
training label.

There is no tool to change the mode: an agent that could lower its own
approval gate would make the gate pointless. There is no tool to admit a
demonstrated check either; a person admits those.
"""

from __future__ import annotations

import re
from typing import Any, Optional

from openadapt_agent.contract import APPROVAL_MODES

#: The opaque ids Flow accepts for a delegated approver.
DELEGATE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$"
_REQUEST_ID = r"^[0-9a-f]{32}$"
_SHA256 = r"^sha256:[0-9a-f]{64}$"
DECLINE_REASONS = ("wrong_values", "wrong_record", "screen_problem")
#: The only request fields an agent sees. No step words, no screenshot.
_REQUEST_FIELDS = (
"request_id",
"request_sha256",
"status",
"step_id",
"step_index",
"step_class",
"mode",
"created_at",
"expires_at",
)
_RUN_ID = {
"type": "string",
"description": "The run_id from run_workflow.",
}
_READ_ONLY = {
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
}
_ANSWER = {
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": True,
"openWorldHint": True,
}

GET_MODE_DESCRIPTION = (
"Say how saves are approved on this server: the approval mode the "
"operator chose (each_item, problems_only, not_gated, or flow_default, "
"where each save waits for approval), and whether you may answer a "
"waiting save yourself. You can't change either."
)
LIST_DESCRIPTION = (
"List the saves in one run that wait for an approval, without values "
"or screenshots: request_id, request_sha256, step and step class, and "
"when each expires. Use it when run_workflow says waiting_for_approval."
)
ANSWER_DESCRIPTION = (
"Approve or decline one waiting save as this agent. The operator allowed "
"it for this server, and Flow accepts it only where the run's approver "
"policy allows an agent for that step (never a clinical write without "
"the customer's opt-in). Flow records you as the approver. Send the "
"request_id and request_sha256 from list_approval_requests. A person "
"can still answer in OpenAdapt Desktop first."
)


def mode_view(approval_mode: Optional[str], delegate_id: Optional[str]) -> dict[str, Any]:
return {
"approval_mode": approval_mode if approval_mode in APPROVAL_MODES else "flow_default",
"delegated_approvals": delegate_id is not None,
"delegate_id": delegate_id,
}


def list_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {"run_id": _RUN_ID},
"required": ["run_id"],
"additionalProperties": False,
}


def answer_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"run_id": _RUN_ID,
"request_id": {"type": "string", "pattern": _REQUEST_ID},
"request_sha256": {"type": "string", "pattern": _SHA256},
"decision": {"type": "string", "enum": ["approve", "decline"]},
"decline_reason": {"type": "string", "enum": list(DECLINE_REASONS)},
},
"required": ["run_id", "request_id", "request_sha256", "decision"],
"additionalProperties": False,
}


def project_requests(payload: dict[str, Any]) -> dict[str, Any]:
"""Keep only the allow-listed, PHI-free fields of Flow's request list."""

if payload.get("ok") is False:
return {"ok": False, "code": str(payload.get("code", "flow_refused"))}
rows = payload.get("requests")
if not isinstance(rows, list):
return {"ok": False, "code": "flow_unreadable"}
out = []
for row in rows:
if isinstance(row, dict):
out.append({k: row[k] for k in _REQUEST_FIELDS if k in row})
return {"ok": True, "requests": out}


class DelegationRefused(ValueError):
pass


def answer_args(arguments: dict[str, Any], delegate_id: str) -> tuple[str, list[str]]:
"""Check the tool arguments and build Flow's item-approval arguments."""

request_id = arguments.get("request_id")
digest = arguments.get("request_sha256")
decision = arguments.get("decision")
reason = arguments.get("decline_reason")
if not isinstance(request_id, str) or not re.match(_REQUEST_ID, request_id):
raise DelegationRefused("request_id must be the 32-hex id from list_approval_requests")
if not isinstance(digest, str) or not re.match(_SHA256, digest):
raise DelegationRefused("request_sha256 must be the digest from list_approval_requests")
if decision not in ("approve", "decline"):
raise DelegationRefused("decision must be approve or decline")
if (decision == "decline") != (reason is not None):
raise DelegationRefused("a decline needs exactly one decline_reason")
if reason is not None and reason not in DECLINE_REASONS:
raise DelegationRefused("decline_reason is not one of the closed reasons")
args = [
"--request-id",
request_id,
"--request-sha256",
digest,
"--decided-by",
"automation",
"--route",
"mcp",
"--delegate-kind",
"agent",
"--delegate-id",
delegate_id,
]
if reason is not None:
args += ["--reason", reason]
return decision, args


__all__ = [
"ANSWER_DESCRIPTION",
"DECLINE_REASONS",
"DELEGATE_ID_PATTERN",
"DelegationRefused",
"GET_MODE_DESCRIPTION",
"LIST_DESCRIPTION",
"answer_args",
"answer_schema",
"list_schema",
"mode_view",
"project_requests",
]
Loading
Loading