mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-11 04:31:34 -10:00
feat(backend/copilot): the chat's approval gate answers the review pauses of workflows it started (#15564)
When a workflow Otto started reaches a step that can't be undone (a block marked irreversible: sending mail, posting, paying), the chat that started the run now decides it with its own approval gate, the same way it decides a direct call of that block; and when the gate says yes to a direct block call, the block no longer asks a second time. Resolves #15563 ### Why / What / How **Why:** two approval systems ask the user today. The gate decides Otto's own tool calls by the chat's mode (Ask first, Auto, Unsupervised) and the rules the user set from earlier cards. The older human review pauses any irreversible step of a workflow and waits on the run page or Home, under its own "approve all future" toggle and none of the chat's rules. So a workflow Otto runs from a chat asks somewhere else, under different rules, and a direct block call the gate already allowed (by a rule, by Unsupervised, or by the supervisor) is asked about again. **What:** for a run started from a chat whose gate is active, an irreversible step's pause goes to that chat's gate: - a rule or the mode says run: the step runs at once, and the run page records an approved review "Allowed by Otto: …"; - anything says ask: the gate's ordinary card appears in that chat; approving or declining it answers the step and resumes the run. As on the run page, the card takes its answer once the run has paused; while other steps of the run are still running, an answer is refused and the card stays open. The card offers the same rules as an action card ("Approve … from now on", "Let Otto judge … from now on", for this chat, the Expert's every chat or the whole team), set on the block's own key, so one rule covers later steps, later runs and direct calls of that block. Home shows the card, not the step's row as well. Answering the row on the run page instead closes the card, and stopping the run deletes it. For direct calls, any verdict an active gate reaches (a card, an allow rule, a mode that says run, a supervisor allow) now satisfies the block's own review. Runs started from the run page, a schedule, a webhook, the API, an automation chat, or a chat whose gate is inactive keep today's human review unchanged, as does any pause in a run whose owner has no active plan (its resume meets the paywall like any other). The Human-in-the-loop block's questions and expert spend approvals are untouched. **How:** the step still parks exactly as today, and the gate decides about it the moment it parks. - `_add_graph_execution` stamps `ExecutionContext.approval_chat_id`: a COPILOT trigger's chat on create, the parent's stamp for a sub-graph, and on every resume the chat at the end of the trigger chain (`starting_chat`, which `_started_from_attended_chat` now shares). Dry runs are never stamped. It also keeps the workflow's own `sensitive_action_safe_mode` apart from the pause the chat forces. - `HITLReviewHelper._handle_review_request` hands the pause to `copilot/gate/workflow.py` after `check_approval` and only when the block passed itself, so only an irreversible block's own pause can route. `review_route` checks stamp → flag → the owner's plan (`is_user_paywalled`, before any rule or supervisor call; a failed lookup also leaves a human review) → chat → active gate; `judge_step` judges the step through `rule_on`, the rule → mode → supervisor section extracted from `check_action` so both paths share one ordering, and hands the supervisor the block's tier and name as a direct call does. The step's subject is the block's own `block:<id>`, so a rule set from either kind of card covers both. - A run verdict writes an approved, processed row (`record_gate_approval`); a decision already on the row stands. An ask writes the waiting row without its alert and opens the card (id derived from the step, `step_card_id`); if the card cannot open, the row alerts as an ordinary human review. - The review route's chat branch refuses a workflow card whose run has not paused, answers each workflow card's row, consumes the card once the row has the answer (a failed update returns the card to waiting and the request fails, so answering again finishes it), sets the chat's ask rule on a decline, and resumes the run through `resume_reviewed_execution` (extracted from the graph branch). Workflow cards are left out of the held-call wake, because a step is not a model's tool call and its run has its own resume. - Direct path: `Decision.decided` → `GATE_DECIDED` (stripped from the model's arguments like `GATE_APPROVED`) → `RunBlockTool` skips `check_hitl_review` when the flag is on. `run_agent` ignores it: only a card approval lifts a workflow run's forced pause, and its steps' pauses now go to the gate on their own. - `view_agent_output` and `run_agent` say a parked run "is waiting for the user's approval in this chat" when its pause has a card. ### Implementation decisions Built as the design recommended; each is a one-line switch: 1. **Unsupervised runs an irreversible block without asking**, in workflows and in direct calls, because the mode's table is now final. `UNSUPERVISED_RUNS_IRREVERSIBLE_BLOCKS = False` in `copilot/gate/__init__.py` makes Unsupervised ask with a card instead. 2. **When a workflow has its own "Ask before irreversible actions" setting on, running it from a chat still asks before those steps, even in Unsupervised.** Switched off, the chat's mode decides instead, so Unsupervised would run them without asking. Either way, an "always allow" rule the user set in that chat for the block still lets it run, and where the mode hands a step to Otto, Otto still judges it; only the mode's plain "run" becomes an ask. Switch: `WORKFLOW_SETTING_IS_A_FLOOR` in `copilot/gate/workflow.py`. ### Rollout and undo Behind `copilot-unified-approvals`, read with `default=True`: a flag PostHog does not know is ON, and a read that fails is ON. It only reaches users the gate already reaches, the `copilot-auto-mode` cohort. - **Undo:** create `copilot-unified-approvals` in PostHog with value false, or deploy with `FORCE_FLAG_COPILOT_UNIFIED_APPROVALS=false`. New pauses are human reviews again and direct calls go back to the second ask after a rule, mode or supervisor yes. Cards already open still finish their runs: nothing on the answer side reads the flag. Nothing is migrated. - **Runs parked on a human review at deploy stay human reviews**, answered on the run page or Home as today; nothing backfills cards. A run between pauses at deploy has no stamp in its queued payload, so its next pause in that pass is a human review too. Every later resume stamps it. - **Rolling-deploy window:** an API pod still on the previous deploy that answers a new workflow card treats it as a held call whose details were lost: the chat says "Nothing ran: the approved action's details were lost…" and the step's row stays waiting on the run page. It fails closed, and only during the deploy. An executor on this deploy reaching a database manager on the previous one gets a 404 for `record_gate_approval`, which the RPC client does not retry, so that step becomes a human review; the older manager also ignores the silent-write keyword, so an asked step's row alerts beside its card. ### Verified I ran the gate, review-route, review-helper, human-review, executor and touched copilot tool suites with `architecture_test.py` and `test_block.py` on the merged tree (4,872 passed, 88 skipped), then all of `backend/copilot` (9,495 passed; the two local failures are this machine's database, and both pass in CI), including nine integration tests that park a real step against Postgres, Redis and the database manager and answer its card through the real `/api/review/action` route. Every new test was run against a mutation of the code it guards: 44 of 45 went red, and the one that did not is an equivalent mutant (a guard the route's catch-all already covers). The frontend approval-queue tests render the real step card. I only reasoned about the rolling-deploy behaviour; nothing here was run on a deployed environment. Full tables and screenshots are in the PR comments. ### Changes 🏗️ - `util/feature_flag`: `Flag.COPILOT_UNIFIED_APPROVALS`. - `data/execution.py`: `ExecutionContext.approval_chat_id`, `graph_sensitive_action_safe_mode`. - `executor/utils.py`: the stamp, and `starting_chat`. - `copilot/gate/workflow.py` (new): `review_route`, `judge_step`, `open_step_card`, `step_card_payload`, `finish_step_cards`, `close_step_cards`, `card_chat_for_run`; `copilot/constants.py`: `step_card_id`. - `copilot/gate/__init__.py`: `rule_on` extracted from `check_action`, `Decision.decided`, `unified_approvals`; `copilot/gate/review.py`: the card's `workflow` field. - `blocks/helpers/review.py`, `blocks/_base.py`: the seam. - `data/human_review.py`, `data/db_manager.py`: `record_gate_approval`, a row written without its alert, Home's one-item filter, the stop path deleting cards. - Review route: workflow-card rule keys from the card, `finish_step_cards`, `resume_reviewed_execution`, the run page closing cards. - `copilot/tools/`: `GATE_DECIDED` through `base.py`, `run_capability.py`, `run_block.py`; `run_agent.py` ignores it; the waiting wording in `agent_output.py` and `run_agent.py`. - Frontend: no code change. `realCards.json` and `referenceCards.json` are regenerated by their builders (each payload gains `workflow: null`, and the real cards gain a "Workflow Step" card built by `step_card_payload`), and `ApprovalQueue.stories.tsx` gains `RealWorkflowStep`. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] A step of an Otto-started run parks, opens its card in the starting chat, and Home lists the card, not the row (integration, real route) - [x] Approving the card answers the row and resumes the run; declining rejects the step and sets the chat's ask rule; the card never becomes a held call - [x] Answering the row on the run page or stopping the run closes the card - [x] An allow rule runs the step with "Allowed by Otto: …" on its row; a missing stamp, a deleted chat or a card that cannot open leaves an alerted human review - [x] A card opened before the flag went off still resumes its run; the next pause is a human review - [x] Direct calls: a rule or Unsupervised yes skips the block's own review with the flag on, not with it off or with the gate inactive; a forged `_gate_decided` changes nothing; `run_agent` keeps its forced pause - [x] The executor stamps the starting chat on create, sub-graph and resume, never on a dry run or a broken chain #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**): one feature flag, on by default, no new environment variable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6c89b1607f
commit
ede2df2aec
31 files changed
+2894
-191
No files matched your search
@@ -21,6 +21,7 @@ _PAYWALL_CHECK_SITES = (
|
||||
"backend.copilot.rate_limit.is_user_paywalled",
|
||||
"backend.executor.utils.is_user_paywalled",
|
||||
"backend.api.features.integrations.router.is_user_paywalled",
|
||||
"backend.copilot.gate.workflow.is_user_paywalled",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,13 @@ from backend.copilot.constants import legacy_chat_session_id, parse_node_id_from
|
||||
from backend.copilot.gate.chat_rules import set_answer_rules as set_chat_rules
|
||||
from backend.copilot.gate.held import subject_keys as held_subject_keys
|
||||
from backend.copilot.gate.held import wake as wake_for_held_calls
|
||||
from backend.copilot.gate.workflow import (
|
||||
close_step_cards,
|
||||
finish_step_cards,
|
||||
is_step_card,
|
||||
step_of,
|
||||
step_rule_keys,
|
||||
)
|
||||
from backend.copilot.rate_limit import enforce_payment_paywall
|
||||
from backend.data.execution import (
|
||||
ExecutionContext,
|
||||
@@ -230,6 +237,16 @@ async def process_review_action(
|
||||
|
||||
graph_exec_id, chat_session_id = next(iter(scopes))
|
||||
|
||||
# A run still running would finish its pass with nothing waiting, the step
|
||||
# unrun; a queued or ended run is safe to answer.
|
||||
for review in reviews_map.values():
|
||||
step = step_of(review)
|
||||
if step is not None and await _running(user_id, step.graph_exec_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This workflow is still running other steps. Answer once it pauses.",
|
||||
)
|
||||
|
||||
# Validate execution status for graph executions; a chat has none
|
||||
if graph_exec_id is not None:
|
||||
graph_exec_meta = await get_graph_execution_meta(
|
||||
@@ -268,10 +285,10 @@ async def process_review_action(
|
||||
auto_approve_requests[review.node_exec_id] = review.auto_approve_future
|
||||
|
||||
# Read before the answer lands: a turn it wakes claims the held calls.
|
||||
ruled = [r.node_exec_id for r in request.reviews if r.chat_rule]
|
||||
chat_rule_keys = (
|
||||
await held_subject_keys(
|
||||
chat_session_id, [r.node_exec_id for r in request.reviews if r.chat_rule]
|
||||
)
|
||||
await held_subject_keys(chat_session_id, ruled)
|
||||
| step_rule_keys(reviews_map[node_exec_id] for node_exec_id in ruled)
|
||||
if chat_session_id is not None
|
||||
else {}
|
||||
)
|
||||
@@ -371,49 +388,48 @@ async def process_review_action(
|
||||
{review.node_exec_id: review.chat_rule_scope for review in request.reviews},
|
||||
)
|
||||
|
||||
# A held call finishes on its own: the answer starts the chat's next turn.
|
||||
if chat_session_id is not None and updated_reviews:
|
||||
await wake_for_held_calls(user_id, chat_session_id, updated_reviews.values())
|
||||
# A workflow step's card answers the step's own row; its run resumes
|
||||
# below, so the card never wakes the chat.
|
||||
finished = await finish_step_cards(user_id, updated_reviews.values())
|
||||
for run in {step.graph_exec_id: step for step in finished.answered}.values():
|
||||
meta = await get_graph_execution_meta(
|
||||
user_id=user_id, execution_id=run.graph_exec_id
|
||||
)
|
||||
if (
|
||||
meta is not None
|
||||
and meta.status in (ExecutionStatus.REVIEW, ExecutionStatus.INCOMPLETE)
|
||||
and not await has_pending_reviews_for_graph_exec(run.graph_exec_id)
|
||||
):
|
||||
await resume_reviewed_execution(
|
||||
user_id, run.graph_exec_id, run.graph_id, run.graph_version
|
||||
)
|
||||
# A held call finishes on its own: the answer starts the chat's next turn.
|
||||
await wake_for_held_calls(
|
||||
user_id,
|
||||
chat_session_id,
|
||||
[r for r in updated_reviews.values() if not is_step_card(r)],
|
||||
)
|
||||
if finished.failed:
|
||||
# Each card is kept, so answering it again finishes the job.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The answer could not reach its workflow step. Try again.",
|
||||
)
|
||||
|
||||
# Resume graph execution only for real graph executions; a chat is resumed
|
||||
# by the LLM calling resume_capability with the review_id
|
||||
if graph_exec_id is not None and updated_reviews:
|
||||
still_has_pending = await has_pending_reviews_for_graph_exec(graph_exec_id)
|
||||
|
||||
if not still_has_pending:
|
||||
await close_step_cards(user_id, updated_reviews)
|
||||
if not await has_pending_reviews_for_graph_exec(graph_exec_id):
|
||||
first_review = next(iter(updated_reviews.values()))
|
||||
assert first_review.graph_id and first_review.graph_version is not None
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(user_id)
|
||||
settings = await get_graph_settings(
|
||||
user_id=user_id,
|
||||
graph_id=first_review.graph_id,
|
||||
graph_version=first_review.graph_version,
|
||||
)
|
||||
|
||||
user_timezone = (
|
||||
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
|
||||
)
|
||||
|
||||
workspace = await get_or_create_workspace(user_id)
|
||||
|
||||
execution_context = ExecutionContext(
|
||||
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode,
|
||||
sensitive_action_safe_mode=settings.sensitive_action_safe_mode,
|
||||
user_timezone=user_timezone,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
|
||||
await add_graph_execution(
|
||||
graph_id=first_review.graph_id,
|
||||
user_id=user_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
logger.info(f"Resumed execution {graph_exec_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resume execution {graph_exec_id}: {str(e)}")
|
||||
await resume_reviewed_execution(
|
||||
user_id,
|
||||
graph_exec_id,
|
||||
first_review.graph_id,
|
||||
first_review.graph_version,
|
||||
)
|
||||
|
||||
# Build error message if auto-approvals failed
|
||||
error_message = None
|
||||
@@ -429,3 +445,39 @@ async def process_review_action(
|
||||
failed_count=auto_approval_failed_count,
|
||||
error=error_message,
|
||||
)
|
||||
|
||||
|
||||
async def _running(user_id: str, graph_exec_id: str) -> bool:
|
||||
meta = await get_graph_execution_meta(user_id=user_id, execution_id=graph_exec_id)
|
||||
return meta is not None and meta.status == ExecutionStatus.RUNNING
|
||||
|
||||
|
||||
async def resume_reviewed_execution(
|
||||
user_id: str, graph_exec_id: str, graph_id: str, graph_version: int
|
||||
) -> None:
|
||||
"""Re-queue a run whose reviews are all answered; its paused steps re-run
|
||||
and find their answers."""
|
||||
try:
|
||||
user = await get_user_by_id(user_id)
|
||||
settings = await get_graph_settings(
|
||||
user_id=user_id, graph_id=graph_id, graph_version=graph_version
|
||||
)
|
||||
user_timezone = (
|
||||
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
|
||||
)
|
||||
workspace = await get_or_create_workspace(user_id)
|
||||
execution_context = ExecutionContext(
|
||||
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode,
|
||||
sensitive_action_safe_mode=settings.sensitive_action_safe_mode,
|
||||
user_timezone=user_timezone,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
await add_graph_execution(
|
||||
graph_id=graph_id,
|
||||
user_id=user_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
logger.info(f"Resumed execution {graph_exec_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resume execution {graph_exec_id}: {str(e)}")
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
"""A workflow step Otto started, parked on its card in the starting chat, against
|
||||
Postgres, Redis and the real review route.
|
||||
|
||||
The step pauses through the block's own review; only the queue publish of the
|
||||
resume is captured, so everything a card answer touches is what ships.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from prisma.enums import AgentExecutionStatus, ReviewStatus
|
||||
from prisma.models import (
|
||||
AgentGraph,
|
||||
AgentGraphExecution,
|
||||
AgentNode,
|
||||
AgentNodeExecution,
|
||||
User,
|
||||
)
|
||||
|
||||
from backend.api.rest_api import app
|
||||
from backend.blocks.discord.bot_blocks import SendDiscordMessageBlock
|
||||
from backend.blocks.helpers.review import HITLReviewHelper
|
||||
from backend.copilot.constants import step_card_id
|
||||
from backend.copilot.gate import RUNS_KEY, chat_rules, check_action, held
|
||||
from backend.copilot.gate.classifier import Judgement
|
||||
from backend.copilot.model import (
|
||||
ChatSession,
|
||||
get_chat_session,
|
||||
update_session_autopilot_mode,
|
||||
upsert_chat_session,
|
||||
)
|
||||
from backend.copilot.tools.run_capability import RunCapabilityTool
|
||||
from backend.data.execution import ExecutionContext
|
||||
from backend.data.human_review import (
|
||||
cancel_pending_reviews_for_execution,
|
||||
get_pending_reviews_for_user,
|
||||
get_reviews_by_node_exec_ids,
|
||||
has_pending_reviews_for_graph_exec,
|
||||
)
|
||||
from backend.data.redis_client import get_redis_async
|
||||
from backend.util.exceptions import BlockExecutionError
|
||||
from backend.util.feature_flag import Flag
|
||||
|
||||
_ROUTES = "backend.api.features.graph_executions.review.routes"
|
||||
_BLOCK = SendDiscordMessageBlock()
|
||||
_INPUT = {"channel_name": "general", "message_content": "The digest is out"}
|
||||
_KEY = f"block:{_BLOCK.id}"
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="session")
|
||||
async def client(server, mock_jwt_user) -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
from autogpt_libs.auth.jwt_utils import get_jwt_payload
|
||||
|
||||
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as http_client:
|
||||
yield http_client
|
||||
app.dependency_overrides.pop(get_jwt_payload, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flags():
|
||||
"""Auto mode on for this user; unified approvals as each test sets it."""
|
||||
values = {Flag.COPILOT_AUTO_MODE: True, Flag.COPILOT_UNIFIED_APPROVALS: True}
|
||||
|
||||
async def read(flag, user_id, default=False):
|
||||
return values[flag]
|
||||
|
||||
with patch("backend.copilot.gate.is_feature_enabled", AsyncMock(side_effect=read)):
|
||||
yield values
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resumed():
|
||||
with patch(f"{_ROUTES}.add_graph_execution", AsyncMock()) as add:
|
||||
yield add
|
||||
|
||||
|
||||
class _Run:
|
||||
def __init__(self, user_id: str, chat_id: str | None) -> None:
|
||||
self.user_id = user_id
|
||||
self.graph_id = str(uuid.uuid4())
|
||||
self.node_id = str(uuid.uuid4())
|
||||
self.graph_exec_id = str(uuid.uuid4())
|
||||
self.node_exec_id = str(uuid.uuid4())
|
||||
self.chat_id = chat_id
|
||||
|
||||
@property
|
||||
def card_id(self) -> str:
|
||||
return step_card_id(self.user_id, self.node_exec_id)
|
||||
|
||||
async def create(
|
||||
self, status: AgentExecutionStatus = AgentExecutionStatus.REVIEW
|
||||
) -> "_Run":
|
||||
await AgentGraph.prisma().create(
|
||||
data={
|
||||
"id": self.graph_id,
|
||||
"version": 1,
|
||||
"name": "Morning digest",
|
||||
"description": "",
|
||||
"userId": self.user_id,
|
||||
"isActive": True,
|
||||
}
|
||||
)
|
||||
await AgentNode.prisma().create(
|
||||
data={
|
||||
"id": self.node_id,
|
||||
"agentBlockId": _BLOCK.id,
|
||||
"agentGraphId": self.graph_id,
|
||||
"agentGraphVersion": 1,
|
||||
}
|
||||
)
|
||||
await AgentGraphExecution.prisma().create(
|
||||
data={
|
||||
"id": self.graph_exec_id,
|
||||
"agentGraphId": self.graph_id,
|
||||
"agentGraphVersion": 1,
|
||||
"userId": self.user_id,
|
||||
"executionStatus": status,
|
||||
"triggerSource": "copilot",
|
||||
"triggerRef": self.chat_id,
|
||||
}
|
||||
)
|
||||
await AgentNodeExecution.prisma().create(
|
||||
data={
|
||||
"id": self.node_exec_id,
|
||||
"agentGraphExecutionId": self.graph_exec_id,
|
||||
"agentNodeId": self.node_id,
|
||||
"executionStatus": AgentExecutionStatus.RUNNING,
|
||||
}
|
||||
)
|
||||
return self
|
||||
|
||||
async def next_step(self) -> "_Run":
|
||||
"""A later pause of the same block in this run."""
|
||||
self.node_exec_id = str(uuid.uuid4())
|
||||
await AgentNodeExecution.prisma().create(
|
||||
data={
|
||||
"id": self.node_exec_id,
|
||||
"agentGraphExecutionId": self.graph_exec_id,
|
||||
"agentNodeId": self.node_id,
|
||||
"executionStatus": AgentExecutionStatus.RUNNING,
|
||||
}
|
||||
)
|
||||
return self
|
||||
|
||||
async def step(self, stamped: bool = True) -> tuple[bool, dict]:
|
||||
"""The block's own review pause, as the executor reaches it."""
|
||||
ctx = ExecutionContext(
|
||||
user_id=self.user_id,
|
||||
graph_id=self.graph_id,
|
||||
graph_exec_id=self.graph_exec_id,
|
||||
graph_version=1,
|
||||
sensitive_action_safe_mode=True,
|
||||
graph_sensitive_action_safe_mode=False,
|
||||
approval_chat_id=self.chat_id if stamped else None,
|
||||
)
|
||||
return await _BLOCK.is_block_exec_need_review(
|
||||
_INPUT,
|
||||
user_id=self.user_id,
|
||||
node_id=self.node_id,
|
||||
node_exec_id=self.node_exec_id,
|
||||
graph_exec_id=self.graph_exec_id,
|
||||
graph_id=self.graph_id,
|
||||
graph_version=1,
|
||||
execution_context=ctx,
|
||||
)
|
||||
|
||||
async def row(self, node_exec_id: str | None = None):
|
||||
node_exec_id = node_exec_id or self.node_exec_id
|
||||
rows = await get_reviews_by_node_exec_ids([node_exec_id], self.user_id)
|
||||
return rows.get(node_exec_id)
|
||||
|
||||
async def card(self):
|
||||
return await self.row(self.card_id)
|
||||
|
||||
|
||||
async def _chat(user_id: str) -> ChatSession:
|
||||
session = await upsert_chat_session(ChatSession.new(user_id=user_id, dry_run=False))
|
||||
await update_session_autopilot_mode(session.session_id, user_id, "ask_first")
|
||||
reloaded = await get_chat_session(session.session_id, user_id)
|
||||
assert reloaded is not None
|
||||
return reloaded
|
||||
|
||||
|
||||
async def _parked(user_id: str) -> tuple[ChatSession, _Run]:
|
||||
chat = await _chat(user_id)
|
||||
run = await _Run(user_id, chat.session_id).create()
|
||||
paused, _ = await run.step()
|
||||
assert paused
|
||||
return chat, run
|
||||
|
||||
|
||||
async def _answer(client: httpx.AsyncClient, node_exec_id: str, approved: bool):
|
||||
response = await client.post(
|
||||
"/api/review/action",
|
||||
json={"reviews": [{"node_exec_id": node_exec_id, "approved": approved}]},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def test_a_step_parks_on_its_card_and_the_card_resumes_the_run(
|
||||
setup_test_user, test_user_id, flags, client, resumed
|
||||
):
|
||||
chat, run = await _parked(test_user_id)
|
||||
|
||||
row, card = await run.row(), await run.card()
|
||||
assert row is not None and row.status == ReviewStatus.WAITING
|
||||
assert card is not None and card.status == ReviewStatus.WAITING
|
||||
assert card.session_id == chat.session_id
|
||||
assert card.payload["workflow"]["node_exec_id"] == run.node_exec_id
|
||||
assert card.payload["tool_call_id"] == ""
|
||||
node = await AgentNodeExecution.prisma().find_unique(where={"id": run.node_exec_id})
|
||||
assert node is not None and node.executionStatus == AgentExecutionStatus.REVIEW
|
||||
# One decision, one item on Home: the card.
|
||||
home = {r.node_exec_id for r in await get_pending_reviews_for_user(test_user_id)}
|
||||
assert run.card_id in home and run.node_exec_id not in home
|
||||
|
||||
await _answer(client, run.card_id, approved=True)
|
||||
|
||||
row = await run.row()
|
||||
assert row is not None and row.status == ReviewStatus.APPROVED
|
||||
assert await run.card() is None
|
||||
assert resumed.await_args.kwargs["graph_exec_id"] == run.graph_exec_id
|
||||
# The card finished through the run's resume, not as a held call.
|
||||
assert await held._held(chat.session_id) == {}
|
||||
assert await run.step() == (False, _INPUT)
|
||||
|
||||
|
||||
async def test_a_card_declined_in_chat_rejects_the_step_and_asks_from_now_on(
|
||||
setup_test_user, test_user_id, flags, client, resumed
|
||||
):
|
||||
chat, run = await _parked(test_user_id)
|
||||
|
||||
await _answer(client, run.card_id, approved=False)
|
||||
|
||||
row = await run.row()
|
||||
assert row is not None and row.status == ReviewStatus.REJECTED
|
||||
resumed.assert_awaited_once()
|
||||
rule = await chat_rules.rule_for(
|
||||
chat.session_id, f"block:{_BLOCK.id}", test_user_id, None
|
||||
)
|
||||
assert rule is not None and rule.rule == "ask"
|
||||
with pytest.raises(BlockExecutionError):
|
||||
await run.step()
|
||||
|
||||
|
||||
async def test_a_page_of_home_stays_full_while_steps_wait_on_their_cards(server, flags):
|
||||
"""A client that stops at a short page would never see the later cards."""
|
||||
user_id = str(uuid.uuid4())
|
||||
await User.prisma().create(data={"id": user_id, "email": f"{user_id}@example.com"})
|
||||
chat = await _chat(user_id)
|
||||
run = await _Run(user_id, chat.session_id).create()
|
||||
cards = []
|
||||
for _ in range(3):
|
||||
assert (await run.step())[0]
|
||||
cards.append(run.card_id)
|
||||
await run.next_step()
|
||||
|
||||
pages = [
|
||||
[
|
||||
review.node_exec_id
|
||||
for review in await get_pending_reviews_for_user(user_id, page, 2)
|
||||
]
|
||||
for page in (1, 2, 3)
|
||||
]
|
||||
|
||||
assert [len(page) for page in pages] == [2, 1, 0]
|
||||
assert sorted(sum(pages, [])) == sorted(cards)
|
||||
|
||||
|
||||
async def test_a_step_answered_on_the_run_page_closes_its_card(
|
||||
setup_test_user, test_user_id, flags, client, resumed
|
||||
):
|
||||
_, run = await _parked(test_user_id)
|
||||
|
||||
await _answer(client, run.node_exec_id, approved=True)
|
||||
|
||||
assert await run.card() is None
|
||||
resumed.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_a_stopped_run_closes_its_steps_cards(
|
||||
setup_test_user, test_user_id, flags
|
||||
):
|
||||
_, run = await _parked(test_user_id)
|
||||
|
||||
await cancel_pending_reviews_for_execution(run.graph_exec_id, test_user_id)
|
||||
|
||||
assert await run.card() is None
|
||||
|
||||
|
||||
async def test_an_allow_rule_runs_the_step_and_records_why(
|
||||
setup_test_user, test_user_id, flags
|
||||
):
|
||||
chat = await _chat(test_user_id)
|
||||
run = await _Run(test_user_id, chat.session_id).create()
|
||||
await chat_rules.set_rule(chat.session_id, f"block:{_BLOCK.id}", "allow")
|
||||
|
||||
assert await run.step() == (False, _INPUT)
|
||||
|
||||
row = await run.row()
|
||||
assert row is not None and row.status == ReviewStatus.APPROVED
|
||||
assert row.review_message == (
|
||||
"Allowed by Otto: you set Send Discord Message to run without asking."
|
||||
)
|
||||
assert await run.card() is None
|
||||
|
||||
|
||||
async def test_a_cheap_outward_step_reaches_the_supervisor_as_its_direct_call_does(
|
||||
setup_test_user, test_user_id, flags
|
||||
):
|
||||
"""Auto judges a cheap outward block by the lighter rubric, naming the block."""
|
||||
chat = await _chat(test_user_id)
|
||||
await update_session_autopilot_mode(chat.session_id, test_user_id, "auto")
|
||||
chat = await get_chat_session(chat.session_id, test_user_id)
|
||||
assert chat is not None
|
||||
run = await _Run(test_user_id, chat.session_id).create()
|
||||
args = {"id": _BLOCK.id, "input": _INPUT}
|
||||
|
||||
async def subject():
|
||||
return await RunCapabilityTool().gate_subject(test_user_id, chat, args)
|
||||
|
||||
supervise = AsyncMock(return_value=Judgement(allowed=True, reason="ok"))
|
||||
with (
|
||||
patch("backend.copilot.gate.workflow.supervise", supervise),
|
||||
patch("backend.copilot.gate.supervise", supervise),
|
||||
):
|
||||
step = await run.step()
|
||||
direct = await check_action(
|
||||
"run_capability", args, test_user_id, chat, subject_of=subject
|
||||
)
|
||||
|
||||
assert step == (False, _INPUT) and direct.allowed
|
||||
for call in supervise.await_args_list:
|
||||
assert call.kwargs.get("outward_judged") is True
|
||||
assert call.kwargs["args"].get(RUNS_KEY) == "Send Discord Message"
|
||||
assert supervise.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["no stamp", "chat gone", "card fails"])
|
||||
async def test_a_step_the_gate_cannot_answer_is_an_alerted_human_review(
|
||||
setup_test_user, test_user_id, flags, failure
|
||||
):
|
||||
chat = await _chat(test_user_id)
|
||||
run = await _Run(
|
||||
test_user_id, str(uuid.uuid4()) if failure == "chat gone" else chat.session_id
|
||||
).create()
|
||||
spy = AsyncMock(wraps=HITLReviewHelper.get_or_create_human_review)
|
||||
open_row = AsyncMock(return_value=failure != "card fails")
|
||||
with (
|
||||
patch.object(HITLReviewHelper, "get_or_create_human_review", spy),
|
||||
patch("backend.copilot.gate.review.open_review_row", open_row),
|
||||
):
|
||||
paused, _ = await run.step(stamped=failure != "no stamp")
|
||||
|
||||
assert paused
|
||||
row = await run.row()
|
||||
assert row is not None and row.status == ReviewStatus.WAITING
|
||||
assert await run.card() is None
|
||||
# The last write of the row is the one that alerts.
|
||||
assert spy.await_args.kwargs.get("alert", True) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runs_it", ["allow rule", "unsupervised", "supervisor allow"])
|
||||
async def test_a_paywalled_owners_step_parks_however_the_gate_would_run_it(
|
||||
setup_test_user, test_user_id, flags, paywalled_user, runs_it
|
||||
):
|
||||
"""Without the gate this step parks and its resume meets the paywall, so the
|
||||
gate must not run it instead."""
|
||||
chat = await _chat(test_user_id)
|
||||
if runs_it == "unsupervised":
|
||||
await update_session_autopilot_mode(
|
||||
chat.session_id, test_user_id, "unsupervised"
|
||||
)
|
||||
else:
|
||||
rule = "allow" if runs_it == "allow rule" else "judge"
|
||||
await chat_rules.set_rule(chat.session_id, _KEY, rule)
|
||||
run = await _Run(test_user_id, chat.session_id).create()
|
||||
supervise = AsyncMock(return_value=Judgement(allowed=True, reason="ok"))
|
||||
spy = AsyncMock(wraps=HITLReviewHelper.get_or_create_human_review)
|
||||
with (
|
||||
patch("backend.copilot.gate.workflow.supervise", supervise),
|
||||
patch.object(HITLReviewHelper, "get_or_create_human_review", spy),
|
||||
):
|
||||
paused, _ = await run.step()
|
||||
|
||||
assert paused
|
||||
row = await run.row()
|
||||
assert row is not None and row.status == ReviewStatus.WAITING
|
||||
assert await run.card() is None
|
||||
assert spy.await_args.kwargs.get("alert", True) is True
|
||||
supervise.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_a_card_opened_before_the_flag_went_off_still_resumes_its_run(
|
||||
setup_test_user, test_user_id, flags, client, resumed
|
||||
):
|
||||
chat, run = await _parked(test_user_id)
|
||||
flags[Flag.COPILOT_UNIFIED_APPROVALS] = False
|
||||
|
||||
await _answer(client, run.card_id, approved=True)
|
||||
|
||||
resumed.assert_awaited_once()
|
||||
later = await _Run(test_user_id, chat.session_id).create()
|
||||
paused, _ = await later.step()
|
||||
assert paused and await later.card() is None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="session")
|
||||
async def wider_rules_cleared(test_user_id):
|
||||
"""A wider rule outlives its test and would let every later step run."""
|
||||
keys = [
|
||||
chat_rules._scoped_key(scope, test_user_id, None, _KEY)
|
||||
for scope in ("expert", "team")
|
||||
]
|
||||
redis = await get_redis_async()
|
||||
await redis.delete(*keys)
|
||||
yield
|
||||
await redis.delete(*keys)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["chat", "expert", "team"])
|
||||
@pytest.mark.parametrize("rule", ["allow", "judge"])
|
||||
async def test_a_rule_from_a_step_card_covers_every_later_call_of_its_block(
|
||||
setup_test_user,
|
||||
test_user_id,
|
||||
flags,
|
||||
client,
|
||||
resumed,
|
||||
wider_rules_cleared,
|
||||
rule,
|
||||
scope,
|
||||
):
|
||||
"""Answered like an action card: the rule lands on the block itself, so a
|
||||
later step of the run, a later run and a direct call of the block follow it."""
|
||||
chat, run = await _parked(test_user_id)
|
||||
response = await client.post(
|
||||
"/api/review/action",
|
||||
json={
|
||||
"reviews": [
|
||||
{
|
||||
"node_exec_id": run.card_id,
|
||||
"approved": True,
|
||||
"chat_rule": rule,
|
||||
"chat_rule_scope": scope,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
later_chat = chat if scope == "chat" else await _chat(test_user_id)
|
||||
args = {"id": _BLOCK.id, "input": _INPUT}
|
||||
|
||||
async def subject():
|
||||
return await RunCapabilityTool().gate_subject(test_user_id, later_chat, args)
|
||||
|
||||
supervise = AsyncMock(return_value=Judgement(allowed=True, reason="ok"))
|
||||
with (
|
||||
patch("backend.copilot.gate.workflow.supervise", supervise),
|
||||
patch("backend.copilot.gate.supervise", supervise),
|
||||
):
|
||||
same_run = await (await run.next_step()).step()
|
||||
later = await _Run(test_user_id, later_chat.session_id).create()
|
||||
later_run = await later.step()
|
||||
direct = await check_action(
|
||||
"run_capability", args, test_user_id, later_chat, subject_of=subject
|
||||
)
|
||||
|
||||
assert same_run == (False, _INPUT)
|
||||
assert later_run == (False, _INPUT)
|
||||
assert direct.allowed and direct.decided
|
||||
assert supervise.await_count == (3 if rule == "judge" else 0)
|
||||
if scope == "chat":
|
||||
elsewhere = await _Run(
|
||||
test_user_id, (await _chat(test_user_id)).session_id
|
||||
).create()
|
||||
assert (await elsewhere.step())[0]
|
||||
|
||||
|
||||
async def test_a_card_answered_before_its_run_pauses_waits_until_it_has(
|
||||
setup_test_user, test_user_id, flags, client, resumed
|
||||
):
|
||||
"""While other steps still run, the run cannot be resumed: an answer the
|
||||
step took then would let the executor finish the pass with nothing waiting
|
||||
and mark the run complete, the step never run."""
|
||||
chat = await _chat(test_user_id)
|
||||
run = await _Run(test_user_id, chat.session_id).create(
|
||||
status=AgentExecutionStatus.RUNNING
|
||||
)
|
||||
paused, _ = await run.step()
|
||||
assert paused
|
||||
|
||||
early = await client.post(
|
||||
"/api/review/action",
|
||||
json={"reviews": [{"node_exec_id": run.card_id, "approved": True}]},
|
||||
)
|
||||
|
||||
assert early.status_code == 409, early.text
|
||||
row, card = await run.row(), await run.card()
|
||||
assert row is not None and row.status == ReviewStatus.WAITING
|
||||
assert card is not None and card.status == ReviewStatus.WAITING
|
||||
# The executor ends its pass in REVIEW only while a row still waits.
|
||||
assert await has_pending_reviews_for_graph_exec(run.graph_exec_id)
|
||||
resumed.assert_not_awaited()
|
||||
|
||||
await AgentGraphExecution.prisma().update(
|
||||
where={"id": run.graph_exec_id},
|
||||
data={"executionStatus": AgentExecutionStatus.REVIEW},
|
||||
)
|
||||
await _answer(client, run.card_id, approved=True)
|
||||
|
||||
row = await run.row()
|
||||
assert row is not None and row.status == ReviewStatus.APPROVED
|
||||
assert resumed.await_args.kwargs["graph_exec_id"] == run.graph_exec_id
|
||||
|
||||
|
||||
async def test_an_answer_the_step_could_not_take_keeps_its_card_for_a_retry(
|
||||
setup_test_user, test_user_id, flags, client, resumed
|
||||
):
|
||||
_, run = await _parked(test_user_id)
|
||||
|
||||
with patch(
|
||||
"backend.data.human_review.process_all_reviews_for_execution",
|
||||
AsyncMock(side_effect=OSError("database unavailable")),
|
||||
):
|
||||
failed = await client.post(
|
||||
"/api/review/action",
|
||||
json={"reviews": [{"node_exec_id": run.card_id, "approved": True}]},
|
||||
)
|
||||
|
||||
assert failed.status_code == 503, failed.text
|
||||
# Back in the chat's queue, and still the one item Home shows.
|
||||
card = await run.card()
|
||||
assert card is not None and card.status == ReviewStatus.WAITING
|
||||
home = {r.node_exec_id for r in await get_pending_reviews_for_user(test_user_id)}
|
||||
assert run.card_id in home and run.node_exec_id not in home
|
||||
row = await run.row()
|
||||
assert row is not None and row.status == ReviewStatus.WAITING
|
||||
resumed.assert_not_awaited()
|
||||
|
||||
await _answer(client, run.card_id, approved=True)
|
||||
|
||||
row = await run.row()
|
||||
assert row is not None and row.status == ReviewStatus.APPROVED
|
||||
assert await run.card() is None
|
||||
resumed.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ended", [AgentExecutionStatus.FAILED, AgentExecutionStatus.TERMINATED]
|
||||
)
|
||||
async def test_a_card_whose_run_has_ended_can_still_be_answered_and_goes(
|
||||
setup_test_user, test_user_id, flags, client, resumed, ended
|
||||
):
|
||||
_, run = await _parked(test_user_id)
|
||||
await AgentGraphExecution.prisma().update(
|
||||
where={"id": run.graph_exec_id}, data={"executionStatus": ended}
|
||||
)
|
||||
|
||||
await _answer(client, run.card_id, approved=False)
|
||||
|
||||
assert await run.card() is None
|
||||
resumed.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_a_card_that_could_not_be_reopened_leaves_the_step_to_the_run_page(
|
||||
setup_test_user, test_user_id, flags, client, resumed
|
||||
):
|
||||
"""With both writes failing, the step's own row is the way back: Home lists
|
||||
it again, and answering it there closes the card."""
|
||||
_, run = await _parked(test_user_id)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.data.human_review.process_all_reviews_for_execution",
|
||||
AsyncMock(side_effect=OSError("database unavailable")),
|
||||
),
|
||||
patch(
|
||||
"backend.data.human_review.reopen_review",
|
||||
AsyncMock(side_effect=OSError("database unavailable")),
|
||||
),
|
||||
):
|
||||
failed = await client.post(
|
||||
"/api/review/action",
|
||||
json={"reviews": [{"node_exec_id": run.card_id, "approved": True}]},
|
||||
)
|
||||
|
||||
assert failed.status_code == 503, failed.text
|
||||
home = {r.node_exec_id for r in await get_pending_reviews_for_user(test_user_id)}
|
||||
assert run.node_exec_id in home and run.card_id not in home
|
||||
|
||||
await _answer(client, run.node_exec_id, approved=True)
|
||||
|
||||
assert await run.card() is None
|
||||
resumed.assert_awaited_once()
|
||||
@@ -882,6 +882,8 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
||||
chat_session_id=(
|
||||
None if is_graph_execution else execution_context.session_id
|
||||
),
|
||||
block=self,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
|
||||
if decision is None:
|
||||
|
||||
@@ -4,7 +4,7 @@ Used by both the dedicated HumanInTheLoopBlock and blocks that require human rev
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
from prisma.enums import ReviewStatus
|
||||
from pydantic import BaseModel
|
||||
@@ -12,6 +12,10 @@ from pydantic import BaseModel
|
||||
from backend.data.human_review import ReviewResult
|
||||
from backend.util.clients import get_database_manager_async_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.blocks._base import Block
|
||||
from backend.data.execution import ExecutionContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -47,6 +51,11 @@ class HITLReviewHelper:
|
||||
db_client=get_database_manager_async_client(), **kwargs
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def record_gate_approval(**kwargs) -> Optional[ReviewResult]:
|
||||
"""Record the chat gate's yes on a workflow step."""
|
||||
return await get_database_manager_async_client().record_gate_approval(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
async def update_review_processed_status(
|
||||
node_exec_id: str, processed: bool
|
||||
@@ -71,6 +80,8 @@ class HITLReviewHelper:
|
||||
organization_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
chat_session_id: Optional[str] = None,
|
||||
block: "Block | None" = None,
|
||||
execution_context: "ExecutionContext | None" = None,
|
||||
) -> Optional[ReviewResult]:
|
||||
"""
|
||||
Handle a review request for a block that requires human review.
|
||||
@@ -86,6 +97,8 @@ class HITLReviewHelper:
|
||||
chat_session_id: The chat asking, when not a graph execution
|
||||
block_name: Name of the block requesting review
|
||||
editable: Whether the reviewer can edit the data
|
||||
block, execution_context: an irreversible block's own pause, which
|
||||
the approval gate of the chat that started the run may answer
|
||||
|
||||
Returns:
|
||||
ReviewResult if review is complete, None if waiting for human input
|
||||
@@ -134,19 +147,32 @@ class HITLReviewHelper:
|
||||
node_exec_id=node_exec_id,
|
||||
)
|
||||
|
||||
result = await HITLReviewHelper.get_or_create_human_review(
|
||||
user_id=user_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
chat_session_id=chat_session_id,
|
||||
input_data=input_data,
|
||||
message=block_name, # Use block_name directly as the message
|
||||
editable=editable,
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
review = {
|
||||
"user_id": user_id,
|
||||
"node_exec_id": node_exec_id,
|
||||
"graph_exec_id": graph_exec_id,
|
||||
"graph_id": graph_id,
|
||||
"graph_version": graph_version,
|
||||
"chat_session_id": chat_session_id,
|
||||
"input_data": input_data,
|
||||
"message": block_name, # Use block_name directly as the message
|
||||
"editable": editable,
|
||||
"organization_id": organization_id,
|
||||
"team_id": team_id,
|
||||
}
|
||||
gated = (
|
||||
await HITLReviewHelper._gate_review(block, execution_context, review)
|
||||
if block is not None
|
||||
and execution_context is not None
|
||||
and is_graph_execution
|
||||
else None
|
||||
)
|
||||
if isinstance(gated, ReviewResult):
|
||||
result: Optional[ReviewResult] = gated
|
||||
elif gated == "parked":
|
||||
result = None
|
||||
else:
|
||||
result = await HITLReviewHelper.get_or_create_human_review(**review)
|
||||
|
||||
if result is None:
|
||||
logger.info(
|
||||
@@ -167,6 +193,79 @@ class HITLReviewHelper:
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def _gate_review(
|
||||
block: "Block", ctx: "ExecutionContext", review: dict[str, Any]
|
||||
) -> ReviewResult | Literal["parked"] | None:
|
||||
"""The chat's approval gate answers the pause: a decision to act on,
|
||||
``"parked"`` once its card waits in the chat, or None for a human review.
|
||||
"""
|
||||
# Deferred: the gate imports the block registry, which imports this module.
|
||||
from backend.copilot.gate import workflow
|
||||
|
||||
session = await workflow.review_route(ctx, review["user_id"])
|
||||
step = (
|
||||
await workflow.workflow_step(
|
||||
review["graph_exec_id"],
|
||||
review["graph_id"],
|
||||
review["graph_version"],
|
||||
review["node_exec_id"],
|
||||
)
|
||||
if session is not None
|
||||
else None
|
||||
)
|
||||
if session is None or step is None:
|
||||
return None
|
||||
try:
|
||||
ruling = await workflow.judge_step(
|
||||
session, block, review["input_data"], ctx, step
|
||||
)
|
||||
if ruling.run:
|
||||
return (
|
||||
await HITLReviewHelper.record_gate_approval(
|
||||
user_id=review["user_id"],
|
||||
node_exec_id=step.node_exec_id,
|
||||
graph_exec_id=step.graph_exec_id,
|
||||
graph_id=step.graph_id,
|
||||
graph_version=step.graph_version,
|
||||
input_data=review["input_data"],
|
||||
block_name=review["message"],
|
||||
message=ruling.reason,
|
||||
organization_id=review["organization_id"],
|
||||
team_id=review["team_id"],
|
||||
)
|
||||
or "parked"
|
||||
)
|
||||
waiting = await HITLReviewHelper.get_or_create_human_review(
|
||||
**review, alert=False
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Gate could not answer step {review['node_exec_id']}; "
|
||||
"it waits for a human review",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
if waiting is not None:
|
||||
return waiting
|
||||
try:
|
||||
opened = await workflow.open_step_card(
|
||||
session, block, review["input_data"], step, ruling
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Gate could not open the card of step {step.node_exec_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
opened = False
|
||||
if not opened:
|
||||
# No card, so the row is an ordinary human review and alerts as one.
|
||||
try:
|
||||
await HITLReviewHelper.get_or_create_human_review(**review)
|
||||
except Exception:
|
||||
logger.warning(f"Could not alert review {step.node_exec_id}")
|
||||
return "parked"
|
||||
|
||||
@staticmethod
|
||||
async def handle_review_decision(
|
||||
input_data: Any,
|
||||
@@ -182,6 +281,8 @@ class HITLReviewHelper:
|
||||
organization_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
chat_session_id: Optional[str] = None,
|
||||
block: "Block | None" = None,
|
||||
execution_context: "ExecutionContext | None" = None,
|
||||
) -> Optional[ReviewDecision]:
|
||||
"""
|
||||
Handle a review request and return the decision in a single call.
|
||||
@@ -216,6 +317,8 @@ class HITLReviewHelper:
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
chat_session_id=chat_session_id,
|
||||
block=block,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
|
||||
if review_result is None:
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Unit tests for the HITL review helper chain."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.blocks.discord.bot_blocks import SendDiscordMessageBlock
|
||||
from backend.blocks.helpers.review import HITLReviewHelper
|
||||
from backend.copilot.gate import Ruling
|
||||
from backend.data.execution import ExecutionContext, ExecutionStatus
|
||||
from backend.data.human_review import ReviewResult, ReviewStatus
|
||||
from backend.util.exceptions import BlockExecutionError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -44,3 +49,214 @@ async def test_handle_review_decision_threads_org_to_review_row():
|
||||
kwargs = get_or_create.await_args.kwargs
|
||||
assert kwargs["organization_id"] == "org-exec"
|
||||
assert kwargs["team_id"] == "team-exec"
|
||||
|
||||
|
||||
# ---- the chat's approval gate answers an irreversible step's pause --------
|
||||
|
||||
_WORKFLOW = "backend.copilot.gate.workflow"
|
||||
_STEP = SimpleNamespace(
|
||||
graph_exec_id="ge-1",
|
||||
node_exec_id="ne-1",
|
||||
graph_id="g-1",
|
||||
graph_version=1,
|
||||
name="Morning digest",
|
||||
)
|
||||
_INPUT = {"channel_name": "general", "message_content": "hi"}
|
||||
|
||||
|
||||
def _approved(message: str = "ok") -> ReviewResult:
|
||||
return ReviewResult(
|
||||
data=_INPUT,
|
||||
status=ReviewStatus.APPROVED,
|
||||
message=message,
|
||||
processed=True,
|
||||
node_exec_id="ne-1",
|
||||
)
|
||||
|
||||
|
||||
def _pause(**helpers):
|
||||
"""The block's own pause, with the review helper's DB calls stubbed."""
|
||||
stubs = {
|
||||
"check_approval": AsyncMock(return_value=None),
|
||||
"get_or_create_human_review": AsyncMock(return_value=None),
|
||||
"record_gate_approval": AsyncMock(return_value=_approved()),
|
||||
"update_node_execution_status": AsyncMock(),
|
||||
"update_review_processed_status": AsyncMock(),
|
||||
**helpers,
|
||||
}
|
||||
patches = [patch.object(HITLReviewHelper, k, v) for k, v in stubs.items()]
|
||||
return stubs, patches
|
||||
|
||||
|
||||
async def _run(
|
||||
stubs,
|
||||
patches,
|
||||
ruling=None,
|
||||
routed=True,
|
||||
card_opens=True,
|
||||
judge_error=None,
|
||||
card_error=None,
|
||||
):
|
||||
ctx = ExecutionContext(
|
||||
user_id="u1",
|
||||
graph_id="g-1",
|
||||
graph_exec_id="ge-1",
|
||||
graph_version=1,
|
||||
sensitive_action_safe_mode=True,
|
||||
approval_chat_id="chat-1",
|
||||
)
|
||||
judge = AsyncMock(
|
||||
return_value=ruling or Ruling(run=False, reason="asks"),
|
||||
side_effect=judge_error,
|
||||
)
|
||||
open_card = AsyncMock(return_value=card_opens, side_effect=card_error)
|
||||
chat = SimpleNamespace(session_id="chat-1") if routed else None
|
||||
with (
|
||||
patch(f"{_WORKFLOW}.review_route", AsyncMock(return_value=chat)),
|
||||
patch(f"{_WORKFLOW}.workflow_step", AsyncMock(return_value=_STEP)),
|
||||
patch(f"{_WORKFLOW}.judge_step", judge),
|
||||
patch(f"{_WORKFLOW}.open_step_card", open_card),
|
||||
):
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
result = await SendDiscordMessageBlock().is_block_exec_need_review(
|
||||
_INPUT,
|
||||
user_id="u1",
|
||||
node_id="n-1",
|
||||
node_exec_id="ne-1",
|
||||
graph_exec_id="ge-1",
|
||||
graph_id="g-1",
|
||||
graph_version=1,
|
||||
execution_context=ctx,
|
||||
)
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
return result, judge, open_card
|
||||
|
||||
|
||||
async def test_a_step_the_gate_does_not_answer_is_a_human_review():
|
||||
stubs, patches = _pause()
|
||||
(paused, _), judge, _ = await _run(stubs, patches, routed=False)
|
||||
assert paused
|
||||
judge.assert_not_awaited()
|
||||
assert "alert" not in stubs["get_or_create_human_review"].await_args.kwargs
|
||||
|
||||
|
||||
async def test_a_gate_yes_records_the_approval_and_the_step_runs():
|
||||
stubs, patches = _pause()
|
||||
ruling = Ruling(run=True, reason="Allowed by Otto: x.", by="mode")
|
||||
(paused, data), _, open_card = await _run(stubs, patches, ruling=ruling)
|
||||
assert not paused and data == _INPUT
|
||||
assert stubs["record_gate_approval"].await_args.kwargs["message"] == ruling.reason
|
||||
stubs["get_or_create_human_review"].assert_not_awaited()
|
||||
open_card.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_a_gate_yes_cannot_overturn_a_decline_already_on_the_row():
|
||||
declined = _approved().model_copy(update={"status": ReviewStatus.REJECTED})
|
||||
stubs, patches = _pause(record_gate_approval=AsyncMock(return_value=declined))
|
||||
with pytest.raises(BlockExecutionError):
|
||||
await _run(stubs, patches, ruling=Ruling(run=True, by="mode"))
|
||||
|
||||
|
||||
async def test_a_gate_ask_parks_the_step_silently_behind_its_card():
|
||||
stubs, patches = _pause()
|
||||
(paused, _), _, open_card = await _run(stubs, patches)
|
||||
assert paused
|
||||
open_card.assert_awaited_once()
|
||||
assert stubs["get_or_create_human_review"].await_count == 1
|
||||
assert stubs["get_or_create_human_review"].await_args.kwargs["alert"] is False
|
||||
status = stubs["update_node_execution_status"].await_args.kwargs["status"]
|
||||
assert status == ExecutionStatus.REVIEW
|
||||
|
||||
|
||||
@pytest.mark.parametrize("card_error", [None, OSError])
|
||||
async def test_a_card_that_cannot_open_leaves_an_alerted_human_review(card_error):
|
||||
stubs, patches = _pause()
|
||||
(paused, _), _, _ = await _run(
|
||||
stubs, patches, card_opens=False, card_error=card_error
|
||||
)
|
||||
assert paused
|
||||
calls = stubs["get_or_create_human_review"].await_args_list
|
||||
assert [c.kwargs.get("alert", True) for c in calls] == [False, True]
|
||||
|
||||
|
||||
async def test_a_gate_that_fails_leaves_a_human_review():
|
||||
stubs, patches = _pause()
|
||||
(paused, _), _, open_card = await _run(stubs, patches, judge_error=OSError)
|
||||
assert paused
|
||||
open_card.assert_not_awaited()
|
||||
assert "alert" not in stubs["get_or_create_human_review"].await_args.kwargs
|
||||
|
||||
|
||||
async def test_a_human_in_the_loop_pause_never_reaches_the_gate():
|
||||
"""Only an irreversible block's own pause passes the block; a question
|
||||
the workflow's author put to a person stays theirs."""
|
||||
route = AsyncMock()
|
||||
_, patches = _pause()
|
||||
with patch(f"{_WORKFLOW}.review_route", route):
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
await HITLReviewHelper.handle_review_decision(
|
||||
input_data=_INPUT,
|
||||
user_id="u1",
|
||||
node_id="n-1",
|
||||
node_exec_id="ne-1",
|
||||
graph_exec_id="ge-1",
|
||||
graph_id="g-1",
|
||||
graph_version=1,
|
||||
)
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
route.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_a_sub_graphs_step_names_its_own_run_not_its_parents():
|
||||
"""A sub-graph's context is copied from its parent run, so the step's own
|
||||
ids come from the pause, never from the context."""
|
||||
stubs, patches = _pause()
|
||||
ctx = ExecutionContext(
|
||||
user_id="u1",
|
||||
graph_id="parent-graph",
|
||||
graph_exec_id="parent-run",
|
||||
graph_version=7,
|
||||
sensitive_action_safe_mode=True,
|
||||
approval_chat_id="chat-1",
|
||||
)
|
||||
judge = AsyncMock(return_value=Ruling(run=True, reason="ok", by="mode"))
|
||||
graphs = SimpleNamespace(
|
||||
get_graph_metadata=AsyncMock(return_value=SimpleNamespace(name="Child"))
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
f"{_WORKFLOW}.review_route",
|
||||
AsyncMock(return_value=SimpleNamespace(session_id="chat-1")),
|
||||
),
|
||||
patch(f"{_WORKFLOW}.judge_step", judge),
|
||||
patch(f"{_WORKFLOW}.graph_db", return_value=graphs),
|
||||
):
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
await SendDiscordMessageBlock().is_block_exec_need_review(
|
||||
_INPUT,
|
||||
user_id="u1",
|
||||
node_id="n-1",
|
||||
node_exec_id="ne-1",
|
||||
graph_exec_id="ge-1",
|
||||
graph_id="g-1",
|
||||
graph_version=1,
|
||||
execution_context=ctx,
|
||||
)
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
step = judge.await_args.args[4]
|
||||
assert (step.graph_exec_id, step.graph_id, step.graph_version) == ("ge-1", "g-1", 1)
|
||||
graphs.get_graph_metadata.assert_awaited_once_with("g-1", 1)
|
||||
recorded = stubs["record_gate_approval"].await_args.kwargs
|
||||
assert (recorded["graph_exec_id"], recorded["graph_id"]) == ("ge-1", "g-1")
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Shared constants for the CoPilot module."""
|
||||
|
||||
import hashlib
|
||||
|
||||
# Otto is the account's built-in helper — the identity every session
|
||||
# without an expert speaks as, and the fixed author of the morning briefing.
|
||||
AUTOPILOT_NAME = "Otto"
|
||||
@@ -65,6 +67,9 @@ SPEND_REVIEW_MARKER = "expert-spend:"
|
||||
# Format: "{node_id}:{random_hex}" — extract node_id via rsplit(":", 1)[0]
|
||||
COPILOT_NODE_EXEC_ID_SEPARATOR = ":"
|
||||
|
||||
# The approval gate's card for a workflow step, in the chat that started the run.
|
||||
COPILOT_STEP_CARD_PREFIX = f"{COPILOT_NODE_PREFIX}gate-workflow_step"
|
||||
|
||||
# Compaction notice messages shown to users.
|
||||
COMPACTION_DONE_MSG = "Earlier messages were summarized to fit within context limits."
|
||||
COMPACTION_DROPPED_MSG = "Earlier messages were dropped to fit within context limits."
|
||||
@@ -98,6 +103,13 @@ HUNG_TOOL_CAP_SECONDS = 2 * 60 * 60
|
||||
STREAM_LOCK_PREFIX = "copilot:stream:lock:"
|
||||
|
||||
|
||||
def step_card_id(user_id: str, node_exec_id: str) -> str:
|
||||
"""Derived from the step alone, so the run page, Home and a stopped run
|
||||
can find the card the gate opened for it."""
|
||||
digest = hashlib.sha256(f"{user_id}\x00{node_exec_id}".encode()).hexdigest()[:16]
|
||||
return f"{COPILOT_STEP_CARD_PREFIX}{COPILOT_NODE_EXEC_ID_SEPARATOR}{digest}"
|
||||
|
||||
|
||||
def is_copilot_synthetic_id(id_value: str) -> bool:
|
||||
"""Check if an ID is a CoPilot synthetic ID (not from a real graph execution)."""
|
||||
return id_value.startswith(COPILOT_SYNTHETIC_ID_PREFIX)
|
||||
|
||||
@@ -14,7 +14,7 @@ a run into a question, never the reverse.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable
|
||||
from typing import Any, Awaitable, Callable, Literal
|
||||
|
||||
from prisma.enums import ReviewStatus
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -28,7 +28,7 @@ from backend.util.feature_flag import Flag, is_feature_enabled
|
||||
|
||||
from . import chat_rules, held
|
||||
from . import review as review_store
|
||||
from .classifier import DecidedBy, supervise
|
||||
from .classifier import DecidedBy, Judgement, supervise
|
||||
from .headline import Headline
|
||||
from .policy import (
|
||||
DEFAULT_MODE,
|
||||
@@ -83,6 +83,9 @@ _RULE_VERDICTS = {
|
||||
"ask": Verdict.ASK,
|
||||
"unreadable": Verdict.ASK,
|
||||
}
|
||||
# Decision 1 (OPEN-3592): the mode's table is final, so Unsupervised runs an
|
||||
# irreversible block without asking. False makes Unsupervised ask before one.
|
||||
UNSUPERVISED_RUNS_IRREVERSIBLE_BLOCKS = True
|
||||
|
||||
|
||||
class Decision(BaseModel):
|
||||
@@ -97,9 +100,27 @@ class Decision(BaseModel):
|
||||
headline: Headline | None = None
|
||||
# The user approved this exact call on a card, so nothing downstream asks again.
|
||||
approved: bool = False
|
||||
# An active gate reached a verdict on this call: a card, a rule, the mode
|
||||
# or the supervisor. With unified approvals on, nothing downstream asks again.
|
||||
decided: bool = False
|
||||
|
||||
|
||||
class Ruling(BaseModel):
|
||||
"""The user's rule, the mode or the supervisor on one subject."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
run: bool
|
||||
# Why it asks, or what let it run: the audit line on an approved step.
|
||||
reason: str = ""
|
||||
reason_kind: review_store.ReasonKind = "mode"
|
||||
by: Literal["rule", "mode", "supervisor"] = "mode"
|
||||
decided_by: DecidedBy | None = None
|
||||
spend: dict[str, int] | None = None
|
||||
|
||||
|
||||
ALLOW = Decision(allowed=True)
|
||||
DECIDED = Decision(allowed=True, decided=True)
|
||||
|
||||
|
||||
async def gate_active(user_id: str | None, session: ChatSession) -> bool:
|
||||
@@ -112,6 +133,17 @@ async def gate_active(user_id: str | None, session: ChatSession) -> bool:
|
||||
return await is_feature_enabled(Flag.COPILOT_AUTO_MODE, user_id, default=False)
|
||||
|
||||
|
||||
async def unified_approvals(user_id: str) -> bool:
|
||||
"""On by decision, so a read that fails means on."""
|
||||
try:
|
||||
return await is_feature_enabled(
|
||||
Flag.COPILOT_UNIFIED_APPROVALS, user_id, default=True
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Unified approvals flag unreadable; on", exc_info=True)
|
||||
return True
|
||||
|
||||
|
||||
def resolve_mode(session: ChatSession) -> AutopilotMode:
|
||||
if session.metadata.source_platform in LINKED_CHAT_PLATFORMS:
|
||||
return "auto"
|
||||
@@ -165,7 +197,7 @@ async def check_action(
|
||||
if await review_store.consume(review_id, user_id):
|
||||
if review_store.is_spend_card(review):
|
||||
await raise_ceiling(CEILING_UNIT_MICRODOLLARS)
|
||||
return Decision(allowed=True, approved=True)
|
||||
return Decision(allowed=True, approved=True, decided=True)
|
||||
return Decision(allowed=False, reason=_CONSUMED)
|
||||
if review is not None and review.status == ReviewStatus.REJECTED:
|
||||
await review_store.consume(review_id, user_id)
|
||||
@@ -186,69 +218,57 @@ async def check_action(
|
||||
if effect is Effect.UNGATED:
|
||||
return ALLOW
|
||||
rule_key = subject.key if subject is not None else tool_name
|
||||
mode = resolve_mode(session)
|
||||
# Only a subject that can be parked can carry a rule, so reads and
|
||||
# workspace work skip the Redis round trip.
|
||||
hit = (
|
||||
await chat_rules.rule_for(session_id, rule_key, user_id, session.expert_id)
|
||||
if effect in PARKABLE
|
||||
else None
|
||||
)
|
||||
rule = hit.rule if hit else None
|
||||
# No supervisor reads a scheduled run, so a judge rule falls back to the mode.
|
||||
if rule == "judge" and subject is not None and subject.schedules:
|
||||
rule = None
|
||||
# A judge rule covers irreversible subjects too: the user chose the supervisor.
|
||||
verdict = _RULE_VERDICTS[rule] if rule else verdict_for_effect(mode, effect)
|
||||
estimate = subject.estimate if subject is not None else estimate_for(tool_name)
|
||||
spend = spend_shown = None
|
||||
if effect in METERED and estimate > 0 and mode != "unsupervised":
|
||||
unsupervised = resolve_mode(session) == "unsupervised"
|
||||
|
||||
async def over_ceiling() -> Ruling | None:
|
||||
if effect not in METERED or estimate <= 0 or unsupervised:
|
||||
return None
|
||||
spend = await spent_past_ceiling(user_id)
|
||||
reason_kind: review_store.ReasonKind
|
||||
decided_by: DecidedBy | None = None
|
||||
if hit and rule in ("ask", "unreadable"):
|
||||
reason, reason_kind = hit.reason, "rule"
|
||||
elif spend is not None:
|
||||
spend_shown = _spend_shown(estimate, *spend)
|
||||
reason = (
|
||||
f"costs about {_dollars(estimate)}, and this chat has spent "
|
||||
f"{_dollars(spend[0])} of its {_dollars(spend[1])} ceiling; approving "
|
||||
f"adds {_dollars(CEILING_UNIT_MICRODOLLARS)} to it"
|
||||
if spend is None:
|
||||
return None
|
||||
return Ruling(
|
||||
run=False,
|
||||
reason=(
|
||||
f"costs about {_dollars(estimate)}, and this chat has spent "
|
||||
f"{_dollars(spend[0])} of its {_dollars(spend[1])} ceiling; "
|
||||
f"approving adds {_dollars(CEILING_UNIT_MICRODOLLARS)} to it"
|
||||
),
|
||||
reason_kind="spend",
|
||||
spend=_spend_shown(estimate, *spend),
|
||||
)
|
||||
reason_kind = "spend"
|
||||
elif verdict is Verdict.RUN:
|
||||
return ALLOW
|
||||
elif verdict is Verdict.ASK and subject is not None and subject.reason:
|
||||
reason, reason_kind = subject.reason, "subject"
|
||||
elif verdict is Verdict.ASK:
|
||||
reason = _ASK_FIRST if mode == "ask_first" else _OUTWARD
|
||||
reason_kind = "mode"
|
||||
else:
|
||||
reason_kind = "supervisor"
|
||||
|
||||
async def judge() -> Judgement:
|
||||
files = await context_of() if context_of is not None else None
|
||||
if unread := [path for path, text in (files or {}).items() if text is None]:
|
||||
reason = (
|
||||
f"Could not read {', '.join(unread)}, so this could not be checked."
|
||||
return Judgement(
|
||||
allowed=False,
|
||||
reason=(
|
||||
f"Could not read {', '.join(unread)}, so this could not be "
|
||||
"checked."
|
||||
),
|
||||
)
|
||||
else:
|
||||
outward_judged = effect is Effect.EXTERNAL_JUDGED
|
||||
# A workflow's name is its author's free text; only a block's is vouched for.
|
||||
runs = (
|
||||
subject.name
|
||||
if outward_judged
|
||||
and subject is not None
|
||||
and subject.key.startswith("block:")
|
||||
else None
|
||||
)
|
||||
judgement = await supervise(
|
||||
tool_name=tool_name,
|
||||
args=_judged_args(args, files, runs),
|
||||
user_messages=_typed_messages(session),
|
||||
outward_judged=outward_judged,
|
||||
)
|
||||
if judgement.allowed:
|
||||
return ALLOW
|
||||
reason, decided_by = judgement.reason, judgement.decided_by
|
||||
outward_judged = effect is Effect.EXTERNAL_JUDGED
|
||||
# A workflow's name is its author's free text; only a block's is vouched for.
|
||||
runs = (
|
||||
subject.name
|
||||
if outward_judged
|
||||
and subject is not None
|
||||
and subject.key.startswith("block:")
|
||||
else None
|
||||
)
|
||||
return await supervise(
|
||||
tool_name=tool_name,
|
||||
args=_judged_args(args, files, runs),
|
||||
user_messages=_typed_messages(session),
|
||||
outward_judged=outward_judged,
|
||||
)
|
||||
|
||||
ruling = await rule_on(
|
||||
session, user_id, rule_key, effect, subject, judge, over_ceiling
|
||||
)
|
||||
if ruling.run:
|
||||
return DECIDED
|
||||
call = held.HeldCall(
|
||||
review_id=review_id,
|
||||
tool_name=tool_name,
|
||||
@@ -259,7 +279,84 @@ async def check_action(
|
||||
cost_context=await capture_cost_context(user_id),
|
||||
)
|
||||
return await _park(
|
||||
call, user_id, session, reason, reason_kind, subject, decided_by, spend_shown
|
||||
call,
|
||||
user_id,
|
||||
session,
|
||||
ruling.reason,
|
||||
ruling.reason_kind,
|
||||
subject,
|
||||
ruling.decided_by,
|
||||
ruling.spend,
|
||||
)
|
||||
|
||||
|
||||
async def rule_on(
|
||||
session: ChatSession,
|
||||
user_id: str,
|
||||
rule_key: str,
|
||||
effect: Effect,
|
||||
subject: Subject | None,
|
||||
judge: Callable[[], Awaitable[Judgement]],
|
||||
over_ceiling: Callable[[], Awaitable[Ruling | None]] | None = None,
|
||||
*,
|
||||
floor: bool = False,
|
||||
) -> Ruling:
|
||||
"""The user's rule on the subject in this chat, else the mode's verdict for
|
||||
its effect, the supervisor deciding where either says judge. ``floor``
|
||||
turns the mode's run into an ask; a rule still decides."""
|
||||
mode = resolve_mode(session)
|
||||
# Only a subject that can be parked can carry a rule, so reads and
|
||||
# workspace work skip the Redis round trip.
|
||||
hit = (
|
||||
await chat_rules.rule_for(
|
||||
session.session_id, rule_key, user_id, session.expert_id
|
||||
)
|
||||
if effect in PARKABLE
|
||||
else None
|
||||
)
|
||||
rule = hit.rule if hit else None
|
||||
if hit and rule in ("ask", "unreadable"):
|
||||
return Ruling(run=False, reason=hit.reason, reason_kind="rule", by="rule")
|
||||
if over_ceiling is not None and (held_for_spend := await over_ceiling()):
|
||||
return held_for_spend
|
||||
# No supervisor reads a scheduled run, so a judge rule falls back to the mode.
|
||||
if rule == "judge" and subject is not None and subject.schedules:
|
||||
rule = None
|
||||
# A judge rule covers irreversible subjects too: the user chose the supervisor.
|
||||
verdict = _RULE_VERDICTS[rule] if rule else verdict_for_effect(mode, effect)
|
||||
if rule is None and verdict is Verdict.RUN and _mode_asks(mode, subject, floor):
|
||||
verdict = Verdict.ASK
|
||||
by = "rule" if rule else "mode"
|
||||
if verdict is Verdict.RUN:
|
||||
return Ruling(run=True, by=by)
|
||||
if verdict is Verdict.ASK and subject is not None and subject.reason:
|
||||
return Ruling(run=False, reason=subject.reason, reason_kind="subject", by=by)
|
||||
if verdict is Verdict.ASK:
|
||||
reason = _ASK_FIRST if mode == "ask_first" else _OUTWARD
|
||||
return Ruling(run=False, reason=reason, by=by)
|
||||
judgement = await judge()
|
||||
# Only an ask carries the supervisor's words onto a card.
|
||||
if judgement.allowed:
|
||||
return Ruling(run=True, by="supervisor")
|
||||
return Ruling(
|
||||
run=False,
|
||||
reason=judgement.reason,
|
||||
reason_kind="supervisor",
|
||||
by="supervisor",
|
||||
decided_by=judgement.decided_by,
|
||||
)
|
||||
|
||||
|
||||
def _mode_asks(mode: AutopilotMode, subject: Subject | None, floor: bool) -> bool:
|
||||
irreversible_block = (
|
||||
subject is not None
|
||||
and subject.irreversible
|
||||
and subject.key.startswith("block:")
|
||||
)
|
||||
return floor or (
|
||||
mode == "unsupervised"
|
||||
and irreversible_block
|
||||
and not UNSUPERVISED_RUNS_IRREVERSIBLE_BLOCKS
|
||||
)
|
||||
|
||||
|
||||
@@ -342,15 +439,16 @@ def _judged_args(
|
||||
return judged
|
||||
|
||||
|
||||
def _typed_messages(session: ChatSession) -> list[str]:
|
||||
"""What the user typed, oldest first; an answered card's rows and a row of
|
||||
nothing but the server's context are not the user's words."""
|
||||
def _typed_messages(session: ChatSession, upto: int | None = None) -> list[str]:
|
||||
"""What the user typed, oldest first, among the first ``upto`` messages when
|
||||
given; an answered card's rows and a row of nothing but the server's context
|
||||
are not the user's words."""
|
||||
# Deferred: copilot.service imports the tool registry, which imports this gate.
|
||||
from backend.copilot.service import strip_injected_context_for_display
|
||||
|
||||
typed = [
|
||||
strip_injected_context_for_display(message.content)
|
||||
for message in session.messages
|
||||
for message in session.messages[:upto]
|
||||
if message.role == "user"
|
||||
and message.content
|
||||
and not held.is_answer_row(message)
|
||||
@@ -362,6 +460,7 @@ __all__ = [
|
||||
"RUNS_KEY",
|
||||
"RUN_FILES_KEY",
|
||||
"Decision",
|
||||
"Ruling",
|
||||
"LINKED_CHAT_PLATFORMS",
|
||||
"active_mode",
|
||||
"check_action",
|
||||
@@ -369,4 +468,6 @@ __all__ = [
|
||||
"gate_active",
|
||||
"refusal_message",
|
||||
"resolve_mode",
|
||||
"rule_on",
|
||||
"unified_approvals",
|
||||
]
|
||||
@@ -15,9 +15,17 @@ from backend.blocks.code_executor import ExecuteCodeStepBlock
|
||||
from backend.blocks.google.gmail import GmailSendBlock
|
||||
from backend.blocks.google.sheets import GoogleSheetsUpdateRowBlock
|
||||
from backend.blocks.http import SendWebRequestBlock
|
||||
from backend.copilot.constants import step_card_id
|
||||
from backend.copilot.gate import Ruling
|
||||
from backend.copilot.gate.policy import Effect
|
||||
from backend.copilot.gate.review import payload_headline, review_id_for, review_payload
|
||||
from backend.copilot.gate.review import (
|
||||
WorkflowStep,
|
||||
payload_headline,
|
||||
review_id_for,
|
||||
review_payload,
|
||||
)
|
||||
from backend.copilot.gate.subject import block_subject, workflow_subject
|
||||
from backend.copilot.gate.workflow import step_card_payload, step_subject
|
||||
from backend.data.graph import GraphModel, NodeModel
|
||||
|
||||
FIXTURE = (
|
||||
@@ -126,6 +134,7 @@ def build_cards() -> list[dict[str, Any]]:
|
||||
cards = [_block_card(*entry) for entry in _BLOCKS]
|
||||
cards.append(_workflow_card())
|
||||
cards.append(_sandbox_command_card())
|
||||
cards.append(_workflow_step_card())
|
||||
return json.loads(json.dumps(cards, default=str))
|
||||
|
||||
|
||||
@@ -190,6 +199,46 @@ def _workflow_card() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _workflow_step_card() -> dict[str, Any]:
|
||||
"""A Gmail Send step of a workflow Otto started, parked on its own review."""
|
||||
send = GmailSendBlock()
|
||||
inputs = dict(_BLOCKS[0][2])
|
||||
step = WorkflowStep(
|
||||
graph_exec_id="run-digest",
|
||||
node_exec_id="node-exec-send",
|
||||
graph_id="g-digest",
|
||||
graph_version=1,
|
||||
name="Morning digest",
|
||||
)
|
||||
subject = step_subject(send, inputs, step.name)
|
||||
payload = step_card_payload(
|
||||
send,
|
||||
inputs,
|
||||
step,
|
||||
Ruling(run=False, reason=subject.reason, reason_kind="subject"),
|
||||
mode="auto",
|
||||
turn=1,
|
||||
)
|
||||
review_id = step_card_id("user-1", step.node_exec_id)
|
||||
return {
|
||||
"story": "Workflow Step",
|
||||
"review": {
|
||||
"node_exec_id": review_id,
|
||||
"node_id": review_id.split(":")[0],
|
||||
"user_id": "user-1",
|
||||
"session_id": "s1",
|
||||
"graph_exec_id": None,
|
||||
"graph_id": None,
|
||||
"graph_version": None,
|
||||
"payload": payload,
|
||||
"instructions": payload_headline(payload),
|
||||
"editable": False,
|
||||
"status": "WAITING",
|
||||
},
|
||||
"schema": send.input_schema.jsonschema(),
|
||||
}
|
||||
|
||||
|
||||
def _sandbox_command_card() -> dict[str, Any]:
|
||||
args = {"command": _COMMAND, "timeout": 60}
|
||||
row = _row("bash_exec", args, None, _SUPERVISOR_WRITE, "supervisor")
|
||||
|
||||
@@ -17,7 +17,7 @@ import pytest
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.copilot.context import set_turn_unattended
|
||||
from backend.copilot.gate import held, reads
|
||||
from backend.copilot.gate import ALLOW, Decision, held, reads
|
||||
from backend.copilot.gate import review as review_store
|
||||
from backend.copilot.gate.content import ContentVerdict
|
||||
from backend.copilot.gate.reads import read_review_id
|
||||
@@ -868,7 +868,7 @@ async def test_a_blocks_own_output_is_still_judged(rows):
|
||||
|
||||
|
||||
def _no_action_gate():
|
||||
return patch.object(BaseTool, "_gate", AsyncMock(return_value=(None, False)))
|
||||
return patch.object(BaseTool, "_gate", AsyncMock(return_value=(None, ALLOW)))
|
||||
|
||||
|
||||
# What a producer declares came from outside AutoGPT is all the judge reads.
|
||||
@@ -984,7 +984,13 @@ async def test_a_blocks_output_is_judged_without_the_platforms_message(rows):
|
||||
with (
|
||||
patch(f"{_READS}.judge_content", judge),
|
||||
# Approved, so the block runs without its own review pause.
|
||||
patch.object(BaseTool, "_gate", AsyncMock(return_value=(None, True))),
|
||||
patch.object(
|
||||
BaseTool,
|
||||
"_gate",
|
||||
AsyncMock(
|
||||
return_value=(None, Decision(allowed=True, approved=True, decided=True))
|
||||
),
|
||||
),
|
||||
_resolves_to(_capability("block", _STORE_VALUE)),
|
||||
patch("backend.copilot.tools.helpers.workspace_db", lambda: workspace),
|
||||
patch("backend.copilot.tools.helpers.user_db", lambda: users),
|
||||
|
||||
@@ -64,6 +64,16 @@ class FieldLabel(BaseModel):
|
||||
format: str | None = None
|
||||
|
||||
|
||||
class WorkflowStep(BaseModel):
|
||||
"""The workflow step a card answers: its row is the step's own review."""
|
||||
|
||||
graph_exec_id: str
|
||||
node_exec_id: str
|
||||
graph_id: str
|
||||
graph_version: int
|
||||
name: str
|
||||
|
||||
|
||||
class GateReviewPayload(BaseModel):
|
||||
"""What the approval card, Home and the channels render: never the raw call."""
|
||||
|
||||
@@ -88,6 +98,7 @@ class GateReviewPayload(BaseModel):
|
||||
headline: Headline
|
||||
# A money card's estimate, spend so far and ceiling.
|
||||
spend: dict[str, int] | None = None
|
||||
workflow: WorkflowStep | None = None
|
||||
|
||||
|
||||
# An approval must not run a call long after the user gave it; the answered
|
||||
@@ -131,6 +142,7 @@ def review_payload(
|
||||
tool_call_id: str = "",
|
||||
turn: int = 0,
|
||||
references: list[Reference] | None = None,
|
||||
workflow: WorkflowStep | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""The subject is kept as decided when the card opened: what the user saw,
|
||||
not a recomputation over a tree that may have moved since."""
|
||||
@@ -164,6 +176,7 @@ def review_payload(
|
||||
else headline_for(tool_name, args, references)
|
||||
),
|
||||
spend=spend,
|
||||
workflow=workflow,
|
||||
).model_dump()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""The gate answers the review pause of a workflow step AutoPilot started.
|
||||
|
||||
The step parks as a human review always does; the gate then judges it as it
|
||||
would a direct call of the same block in the chat that started the run. A run
|
||||
verdict approves the row at once; an ask opens the gate's card in that chat,
|
||||
pointing at the row, and answering either closes the other. Every failure
|
||||
leaves an ordinary human review.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from prisma.enums import ReviewStatus
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.api.features.graph_executions.review.model import PendingHumanReviewModel
|
||||
from backend.blocks._base import Block
|
||||
from backend.copilot.constants import (
|
||||
AUTOPILOT_NAME,
|
||||
COPILOT_STEP_CARD_PREFIX,
|
||||
step_card_id,
|
||||
)
|
||||
from backend.copilot.model import (
|
||||
ChatSession,
|
||||
get_chat_session,
|
||||
get_chat_session_metadata,
|
||||
)
|
||||
from backend.copilot.rate_limit import is_user_paywalled
|
||||
from backend.data.db_accessors import graph_db, review_db
|
||||
from backend.data.execution import ExecutionContext
|
||||
|
||||
from . import Ruling, _judged_args, _typed_messages, chat_rules, gate_active
|
||||
from . import review as review_store
|
||||
from . import rule_on, unified_approvals
|
||||
from .classifier import Judgement, supervise
|
||||
from .policy import DEFAULT_MODE, Effect
|
||||
from .subject import Subject, block_subject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STEP_TOOL = "workflow_step"
|
||||
# Decision 2 (OPEN-3592): a workflow's own "ask before irreversible actions"
|
||||
# is a floor, so the mode cannot run its steps; a rule on the block still can.
|
||||
WORKFLOW_SETTING_IS_A_FLOOR = True
|
||||
APPROVED_IN_CHAT = "Approved in chat"
|
||||
DECLINED_IN_CHAT = "Declined in chat"
|
||||
|
||||
|
||||
async def review_route(ctx: ExecutionContext, user_id: str) -> ChatSession | None:
|
||||
"""The chat whose gate answers this pause, or None for a human review."""
|
||||
if not ctx.approval_chat_id:
|
||||
return None
|
||||
try:
|
||||
if not await unified_approvals(user_id):
|
||||
return None
|
||||
# Without a plan the step must wait for a resume, which the paywall answers.
|
||||
if await is_user_paywalled(user_id):
|
||||
return None
|
||||
session = await get_chat_session(ctx.approval_chat_id, user_id)
|
||||
if session is None or not await gate_active(user_id, session):
|
||||
return None
|
||||
return session
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Gate could not route the review of run {ctx.graph_exec_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def judge_step(
|
||||
session: ChatSession,
|
||||
block: Block,
|
||||
inputs: dict[str, Any],
|
||||
ctx: ExecutionContext,
|
||||
step: review_store.WorkflowStep,
|
||||
) -> Ruling:
|
||||
"""What the chat's gate says about this step, as about a direct call of its
|
||||
block: the user's rule on the block, else the mode, else the supervisor."""
|
||||
subject = step_subject(block, inputs, step.name)
|
||||
|
||||
async def judge() -> Judgement:
|
||||
# Judged as a direct call of the block would be: same rubric, vouched name.
|
||||
outward_judged = subject.effect is Effect.EXTERNAL_JUDGED
|
||||
args = {"workflow": step.name, "step": subject.name, "input": inputs}
|
||||
return await supervise(
|
||||
tool_name=STEP_TOOL,
|
||||
args=_judged_args(args, None, subject.name if outward_judged else None),
|
||||
user_messages=request_that_started(
|
||||
session, ctx.root_execution_id or step.graph_exec_id
|
||||
),
|
||||
outward_judged=outward_judged,
|
||||
)
|
||||
|
||||
ruling = await rule_on(
|
||||
session,
|
||||
session.user_id,
|
||||
subject.key,
|
||||
subject.effect,
|
||||
subject,
|
||||
judge,
|
||||
# A context from before the setting was carried counts it as on.
|
||||
floor=WORKFLOW_SETTING_IS_A_FLOOR
|
||||
and ctx.graph_sensitive_action_safe_mode is not False,
|
||||
)
|
||||
if ruling.run:
|
||||
return ruling.model_copy(update={"reason": _allowed_by(ruling, subject)})
|
||||
return ruling
|
||||
|
||||
|
||||
async def open_step_card(
|
||||
session: ChatSession,
|
||||
block: Block,
|
||||
inputs: dict[str, Any],
|
||||
step: review_store.WorkflowStep,
|
||||
ruling: Ruling,
|
||||
) -> bool:
|
||||
"""The gate's card in the chat that started the run, pointing at the
|
||||
step's row; False when it could not be opened."""
|
||||
user_id = session.user_id
|
||||
try:
|
||||
payload = step_card_payload(
|
||||
block,
|
||||
inputs,
|
||||
step,
|
||||
ruling,
|
||||
mode=session.metadata.autopilot_mode or DEFAULT_MODE,
|
||||
turn=review_store.turn_of(session),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Gate could not build the card for step {step.node_exec_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return await review_store.open_review_row(
|
||||
step_card_id(user_id, step.node_exec_id),
|
||||
user_id,
|
||||
session,
|
||||
payload,
|
||||
review_store.payload_headline(payload),
|
||||
)
|
||||
|
||||
|
||||
def step_card_payload(
|
||||
block: Block,
|
||||
inputs: dict[str, Any],
|
||||
step: review_store.WorkflowStep,
|
||||
ruling: Ruling,
|
||||
*,
|
||||
mode: str,
|
||||
turn: int,
|
||||
) -> dict[str, Any]:
|
||||
"""The card a direct call of the block would show, naming its workflow."""
|
||||
return review_store.review_payload(
|
||||
STEP_TOOL,
|
||||
{"input": inputs},
|
||||
step_subject(block, inputs, step.name),
|
||||
reason=ruling.reason,
|
||||
reason_kind=ruling.reason_kind,
|
||||
decided_by=ruling.decided_by,
|
||||
mode=mode,
|
||||
turn=turn,
|
||||
workflow=step,
|
||||
)
|
||||
|
||||
|
||||
class StepAnswers(BaseModel):
|
||||
# The steps whose rows took their card's answer, for the route to resume.
|
||||
answered: list[review_store.WorkflowStep] = []
|
||||
# Cards whose step could not take the answer; each stays for a retry.
|
||||
failed: list[str] = []
|
||||
|
||||
|
||||
async def finish_step_cards(
|
||||
user_id: str, cards: Iterable[PendingHumanReviewModel]
|
||||
) -> StepAnswers:
|
||||
"""Answer each answered workflow card's row the way the card was answered.
|
||||
A card is consumed once its row has the answer, or already had another."""
|
||||
finished = StepAnswers()
|
||||
for card in cards:
|
||||
step = step_of(card)
|
||||
if step is None or card.status == ReviewStatus.WAITING:
|
||||
continue
|
||||
approved = card.status == ReviewStatus.APPROVED
|
||||
try:
|
||||
# No reviewed data: the step runs on the input its card showed.
|
||||
await review_db().process_all_reviews_for_execution(
|
||||
user_id=user_id,
|
||||
review_decisions={
|
||||
step.node_exec_id: (
|
||||
card.status,
|
||||
None,
|
||||
APPROVED_IN_CHAT if approved else DECLINED_IN_CHAT,
|
||||
)
|
||||
},
|
||||
)
|
||||
finished.answered.append(step)
|
||||
except ValueError:
|
||||
# Answered differently on the run page first: that answer stands.
|
||||
logger.warning(
|
||||
f"Step {step.node_exec_id} kept its answer from the run page",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Step {step.node_exec_id} could not take its card's answer",
|
||||
exc_info=True,
|
||||
)
|
||||
finished.failed.append(card.node_exec_id)
|
||||
await _reopen(user_id, card.node_exec_id)
|
||||
continue
|
||||
await review_store.consume(card.node_exec_id, user_id)
|
||||
if not approved and card.session_id:
|
||||
await _ask_from_now_on(user_id, card)
|
||||
return finished
|
||||
|
||||
|
||||
async def close_step_cards(user_id: str, node_exec_ids: Iterable[str]) -> None:
|
||||
"""The steps were answered on the run page, so their cards leave the chat."""
|
||||
for node_exec_id in node_exec_ids:
|
||||
await review_store.consume(step_card_id(user_id, node_exec_id), user_id)
|
||||
|
||||
|
||||
async def card_chat_for_run(user_id: str, graph_exec_id: str) -> str | None:
|
||||
"""The chat holding a card for one of this run's waiting steps, if any."""
|
||||
try:
|
||||
rows = await review_db().get_pending_reviews_for_execution(
|
||||
graph_exec_id, user_id
|
||||
)
|
||||
cards = await review_db().get_reviews_by_node_exec_ids(
|
||||
[step_card_id(user_id, row.node_exec_id) for row in rows], user_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(f"Could not read step cards of run {graph_exec_id}")
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
card.session_id
|
||||
for card in cards.values()
|
||||
if card.status == ReviewStatus.WAITING and card.session_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def awaiting_whom(card_chat: str | None, session_id: str | None) -> str:
|
||||
"""How a parked run's state reads to the model that asks about it."""
|
||||
if card_chat is None:
|
||||
return "is awaiting human review"
|
||||
if card_chat == session_id:
|
||||
return "is waiting for the user's approval in this chat"
|
||||
return "is waiting for the user's approval in the chat that started it"
|
||||
|
||||
|
||||
async def workflow_step(
|
||||
graph_exec_id: str | None,
|
||||
graph_id: str | None,
|
||||
graph_version: int | None,
|
||||
node_exec_id: str,
|
||||
) -> review_store.WorkflowStep | None:
|
||||
"""What the card says about the step, from the pause's own ids: a
|
||||
sub-graph's context still names its parent's run. None outside a run."""
|
||||
if not (graph_exec_id and graph_id and graph_version is not None):
|
||||
return None
|
||||
try:
|
||||
graph = await graph_db().get_graph_metadata(graph_id, graph_version)
|
||||
name = graph.name if graph and graph.name else ""
|
||||
except Exception:
|
||||
logger.warning(f"Could not name workflow {graph_id}", exc_info=True)
|
||||
name = ""
|
||||
return review_store.WorkflowStep(
|
||||
graph_exec_id=graph_exec_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
name=name or "Untitled workflow",
|
||||
)
|
||||
|
||||
|
||||
def step_subject(block: Block, inputs: dict[str, Any], workflow: str) -> Subject:
|
||||
"""The block's own subject, so a rule set from either kind of card covers
|
||||
both, with the card's reason naming the workflow."""
|
||||
subject = block_subject(block, inputs, priced=False)
|
||||
return subject.model_copy(
|
||||
update={"reason": f"A step of {workflow}, which reaches outside the platform."}
|
||||
)
|
||||
|
||||
|
||||
def step_of(card: PendingHumanReviewModel) -> review_store.WorkflowStep | None:
|
||||
if not is_step_card(card):
|
||||
return None
|
||||
payload = card.payload if isinstance(card.payload, dict) else {}
|
||||
try:
|
||||
return review_store.WorkflowStep.model_validate(payload.get("workflow"))
|
||||
except Exception:
|
||||
logger.warning(f"Workflow card {card.node_exec_id} names no step")
|
||||
return None
|
||||
|
||||
|
||||
def is_step_card(card: PendingHumanReviewModel) -> bool:
|
||||
return card.node_exec_id.startswith(COPILOT_STEP_CARD_PREFIX)
|
||||
|
||||
|
||||
def step_rule_keys(cards: Iterable[PendingHumanReviewModel]) -> dict[str, str]:
|
||||
"""The subject each workflow card can set a rule on, from the card the
|
||||
server wrote: a step has no held call to read it from."""
|
||||
keys: dict[str, str] = {}
|
||||
for card in cards:
|
||||
payload = card.payload if isinstance(card.payload, dict) else {}
|
||||
subject = payload.get("subject")
|
||||
if is_step_card(card) and isinstance(subject, dict) and subject.get("key"):
|
||||
keys[card.node_exec_id] = str(subject["key"])
|
||||
return keys
|
||||
|
||||
|
||||
def request_that_started(session: ChatSession, run_id: str | None) -> list[str]:
|
||||
"""What the user typed up to the call that started the run: a run parked
|
||||
for days must not be judged against whatever they said since."""
|
||||
started = next(
|
||||
(
|
||||
index
|
||||
for index, message in enumerate(session.messages)
|
||||
if run_id
|
||||
and message.role == "tool"
|
||||
and message.content
|
||||
and run_id in message.content
|
||||
),
|
||||
None,
|
||||
)
|
||||
if started is None:
|
||||
return []
|
||||
return _typed_messages(session, upto=started)
|
||||
|
||||
|
||||
async def _reopen(user_id: str, card_id: str) -> None:
|
||||
# Back in the chat's queue, so the user can answer it again.
|
||||
try:
|
||||
await review_db().reopen_review(card_id, user_id)
|
||||
except Exception:
|
||||
logger.warning(f"Could not reopen card {card_id}", exc_info=True)
|
||||
|
||||
|
||||
async def _ask_from_now_on(user_id: str, card: PendingHumanReviewModel) -> None:
|
||||
# As a declined direct call does: the block asks for the rest of the chat.
|
||||
key = step_rule_keys([card]).get(card.node_exec_id)
|
||||
if not key or not card.session_id:
|
||||
return
|
||||
try:
|
||||
chat = await get_chat_session_metadata(card.session_id, user_id)
|
||||
await chat_rules.set_ask(
|
||||
card.session_id, key, user_id, chat.expert_id if chat else None
|
||||
)
|
||||
except Exception:
|
||||
# The step already has its answer; a lost rule must not strand its run.
|
||||
logger.warning(
|
||||
f"Could not set an ask rule from card {card.node_exec_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _allowed_by(ruling: Ruling, subject: Subject) -> str:
|
||||
"""The audit line on the step's approved row, read on the run page."""
|
||||
if ruling.by == "rule":
|
||||
why = f"you set {subject.name} to run without asking"
|
||||
elif ruling.by == "supervisor":
|
||||
why = "its check found this step matches your request"
|
||||
else:
|
||||
why = "Unsupervised is on in the chat that started this run"
|
||||
return f"Allowed by {AUTOPILOT_NAME}: {why}."
|
||||
@@ -0,0 +1,409 @@
|
||||
"""The gate's answer to a workflow step's own review pause.
|
||||
|
||||
A step is judged as a direct call of its block in the chat that started the
|
||||
run, so these pin that the two share their subject, their rules and their
|
||||
modes, and that every missing piece leaves the step to a human review.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.api.features.graph_executions.review.model import PendingHumanReviewModel
|
||||
from backend.blocks.discord.bot_blocks import SendDiscordMessageBlock
|
||||
from backend.blocks.email_block import SendEmailBlock
|
||||
from backend.copilot.constants import COPILOT_STEP_CARD_PREFIX, step_card_id
|
||||
from backend.copilot.gate import RUNS_KEY, chat_rules
|
||||
from backend.copilot.gate import review as review_store
|
||||
from backend.copilot.gate import unified_approvals, workflow
|
||||
from backend.copilot.gate.chat_rules import DECLINED
|
||||
from backend.copilot.gate.chat_rules_test import _Redis
|
||||
from backend.copilot.gate.classifier import Judgement
|
||||
from backend.copilot.model import (
|
||||
AutopilotMode,
|
||||
ChatMessage,
|
||||
ChatSession,
|
||||
ChatSessionMetadata,
|
||||
)
|
||||
from backend.copilot.tools.run_capability import RunCapabilityTool
|
||||
from backend.data.execution import ExecutionContext
|
||||
from backend.util.feature_flag import Flag
|
||||
|
||||
_GATE = "backend.copilot.gate"
|
||||
_WORKFLOW = "backend.copilot.gate.workflow"
|
||||
_BLOCK = SendDiscordMessageBlock()
|
||||
_INPUTS = {"channel_name": "general", "message_content": "Digest is out"}
|
||||
_STEP = review_store.WorkflowStep(
|
||||
graph_exec_id="run-1",
|
||||
node_exec_id="node-exec-1",
|
||||
graph_id="graph-1",
|
||||
graph_version=1,
|
||||
name="Morning digest",
|
||||
)
|
||||
_FALLBACK = "Could not verify this action automatically."
|
||||
|
||||
|
||||
def _session(mode: AutopilotMode = "auto", messages=()) -> ChatSession:
|
||||
return ChatSession(
|
||||
session_id="chat-1",
|
||||
user_id="user-1",
|
||||
usage=[],
|
||||
started_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
metadata=ChatSessionMetadata(origin="interactive", autopilot_mode=mode),
|
||||
messages=list(messages),
|
||||
)
|
||||
|
||||
|
||||
def _ctx(**update) -> ExecutionContext:
|
||||
return ExecutionContext(
|
||||
user_id="user-1",
|
||||
graph_id="graph-1",
|
||||
graph_exec_id="run-1",
|
||||
graph_version=1,
|
||||
sensitive_action_safe_mode=True,
|
||||
approval_chat_id="chat-1",
|
||||
graph_sensitive_action_safe_mode=False,
|
||||
).model_copy(update=update)
|
||||
|
||||
|
||||
def _flags(unified: bool = True, auto_mode: bool = True):
|
||||
values = {
|
||||
Flag.COPILOT_UNIFIED_APPROVALS: unified,
|
||||
Flag.COPILOT_AUTO_MODE: auto_mode,
|
||||
}
|
||||
|
||||
async def read(flag, user_id, default=False):
|
||||
return values[flag]
|
||||
|
||||
return patch(f"{_GATE}.is_feature_enabled", AsyncMock(side_effect=read))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def redis():
|
||||
store = _Redis()
|
||||
with patch.object(chat_rules, "get_redis_async", AsyncMock(return_value=store)):
|
||||
yield store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def paywalled():
|
||||
with patch(
|
||||
f"{_WORKFLOW}.is_user_paywalled", AsyncMock(return_value=False)
|
||||
) as check:
|
||||
yield check
|
||||
|
||||
|
||||
# ---- which pauses go to the gate -----------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"missing", ["nothing", "stamp", "flag", "chat", "gate", "plan"]
|
||||
)
|
||||
async def test_a_pause_goes_to_the_gate_only_with_every_piece(missing, paywalled):
|
||||
paywalled.return_value = missing == "plan"
|
||||
session = _session()
|
||||
ctx = _ctx(approval_chat_id=None) if missing == "stamp" else _ctx()
|
||||
with (
|
||||
_flags(unified=missing != "flag", auto_mode=missing != "gate"),
|
||||
patch(
|
||||
f"{_WORKFLOW}.get_chat_session",
|
||||
AsyncMock(return_value=None if missing == "chat" else session),
|
||||
),
|
||||
):
|
||||
routed = await workflow.review_route(ctx, "user-1")
|
||||
assert routed is (session if missing == "nothing" else None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failing", ["chat", "plan"])
|
||||
async def test_a_lookup_that_fails_leaves_a_human_review(failing, paywalled):
|
||||
if failing == "plan":
|
||||
paywalled.side_effect = OSError
|
||||
load = AsyncMock(side_effect=OSError if failing == "chat" else None)
|
||||
load.return_value = _session()
|
||||
with _flags(), patch(f"{_WORKFLOW}.get_chat_session", load):
|
||||
assert await workflow.review_route(_ctx(), "user-1") is None
|
||||
|
||||
|
||||
async def test_a_flag_nobody_created_is_on():
|
||||
"""PostHog answers an unknown flag with the default, so the default decides."""
|
||||
|
||||
async def unknown(flag, user_id, default=False):
|
||||
return default
|
||||
|
||||
with patch(f"{_GATE}.is_feature_enabled", AsyncMock(side_effect=unknown)):
|
||||
assert await unified_approvals("user-1")
|
||||
|
||||
|
||||
async def test_a_flag_read_that_fails_is_on():
|
||||
with patch(f"{_GATE}.is_feature_enabled", AsyncMock(side_effect=OSError)):
|
||||
assert await unified_approvals("user-1")
|
||||
|
||||
|
||||
# ---- what the gate says about a step -------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode, verdict", [("ask_first", "ask"), ("auto", "judge"), ("unsupervised", "run")]
|
||||
)
|
||||
async def test_a_step_follows_the_modes_table_for_a_cheap_outward_block(
|
||||
redis, mode, verdict
|
||||
):
|
||||
supervise = AsyncMock(return_value=Judgement(allowed=False, reason=_FALLBACK))
|
||||
with patch(f"{_WORKFLOW}.supervise", supervise):
|
||||
ruling = await workflow.judge_step(
|
||||
_session(mode), _BLOCK, _INPUTS, _ctx(), _STEP
|
||||
)
|
||||
assert ruling.run is (verdict == "run")
|
||||
assert supervise.await_count == (verdict == "judge")
|
||||
if verdict == "ask":
|
||||
assert ruling.reason == (
|
||||
"A step of Morning digest, which reaches outside the platform."
|
||||
)
|
||||
|
||||
|
||||
async def test_auto_asks_before_a_step_of_a_block_hard_to_take_back(redis):
|
||||
block = SendEmailBlock()
|
||||
supervise = AsyncMock()
|
||||
with patch(f"{_WORKFLOW}.supervise", supervise):
|
||||
ruling = await workflow.judge_step(
|
||||
_session("auto"), block, {"to_email": "[email protected]"}, _ctx(), _STEP
|
||||
)
|
||||
assert not ruling.run
|
||||
supervise.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_an_allow_rule_runs_the_step_and_says_so_on_the_run_page(redis):
|
||||
await chat_rules.set_rule("chat-1", f"block:{_BLOCK.id}", "allow")
|
||||
ruling = await workflow.judge_step(
|
||||
_session("ask_first"), _BLOCK, _INPUTS, _ctx(), _STEP
|
||||
)
|
||||
assert ruling.run
|
||||
assert ruling.reason == (
|
||||
"Allowed by Otto: you set Send Discord Message to run without asking."
|
||||
)
|
||||
|
||||
|
||||
async def test_a_declined_block_asks_even_in_unsupervised(redis):
|
||||
await chat_rules.set_ask("chat-1", f"block:{_BLOCK.id}", "user-1", None)
|
||||
ruling = await workflow.judge_step(
|
||||
_session("unsupervised"), _BLOCK, _INPUTS, _ctx(), _STEP
|
||||
)
|
||||
assert not ruling.run
|
||||
assert (ruling.reason, ruling.reason_kind) == (DECLINED, "rule")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"judgement, runs, reason",
|
||||
[
|
||||
(Judgement(allowed=True, reason="Allowed.", decided_by="llm"), True, None),
|
||||
(Judgement(allowed=False, reason=_FALLBACK), False, _FALLBACK),
|
||||
],
|
||||
ids=["supervisor allows", "supervisor down"],
|
||||
)
|
||||
async def test_a_judge_rule_hands_the_step_to_the_supervisor(
|
||||
redis, judgement, runs, reason
|
||||
):
|
||||
await chat_rules.set_rule("chat-1", f"block:{_BLOCK.id}", "judge")
|
||||
supervise = AsyncMock(return_value=judgement)
|
||||
with patch(f"{_WORKFLOW}.supervise", supervise):
|
||||
ruling = await workflow.judge_step(
|
||||
_session("ask_first"), _BLOCK, _INPUTS, _ctx(), _STEP
|
||||
)
|
||||
assert ruling.run is runs
|
||||
assert supervise.await_args.kwargs["tool_name"] == "workflow_step"
|
||||
assert supervise.await_args.kwargs["outward_judged"] is True
|
||||
assert supervise.await_args.kwargs["args"] == {
|
||||
"workflow": "Morning digest",
|
||||
"step": "Send Discord Message",
|
||||
"input": _INPUTS,
|
||||
RUNS_KEY: "Send Discord Message",
|
||||
}
|
||||
if reason:
|
||||
assert (ruling.reason, ruling.reason_kind) == (reason, "supervisor")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"own_setting, rule, runs",
|
||||
[
|
||||
(True, None, False),
|
||||
# A context from before the setting was carried counts it as on.
|
||||
(None, None, False),
|
||||
(False, None, True),
|
||||
(True, "allow", True),
|
||||
],
|
||||
)
|
||||
async def test_the_workflows_own_pause_is_a_floor_the_mode_cannot_lift(
|
||||
redis, own_setting, rule, runs
|
||||
):
|
||||
if rule:
|
||||
await chat_rules.set_rule("chat-1", f"block:{_BLOCK.id}", rule)
|
||||
ctx = _ctx(graph_sensitive_action_safe_mode=own_setting)
|
||||
ruling = await workflow.judge_step(
|
||||
_session("unsupervised"), _BLOCK, _INPUTS, ctx, _STEP
|
||||
)
|
||||
assert ruling.run is runs
|
||||
|
||||
|
||||
async def test_a_step_and_a_direct_call_of_its_block_share_one_rule_key():
|
||||
session = _session()
|
||||
direct = await RunCapabilityTool().gate_subject(
|
||||
"user-1", session, {"id": _BLOCK.id, "input": _INPUTS}
|
||||
)
|
||||
step = workflow.step_subject(_BLOCK, _INPUTS, "Morning digest")
|
||||
assert direct is not None and direct.key == step.key == f"block:{_BLOCK.id}"
|
||||
assert step.irreversible
|
||||
|
||||
|
||||
async def test_the_supervisor_reads_the_request_that_started_the_run(redis):
|
||||
await chat_rules.set_rule("chat-1", f"block:{_BLOCK.id}", "judge")
|
||||
session = _session(
|
||||
"ask_first",
|
||||
[
|
||||
ChatMessage(role="user", content="Post the digest to #general"),
|
||||
ChatMessage(role="assistant", content="Starting it."),
|
||||
ChatMessage(role="tool", content='{"execution_id": "run-1"}'),
|
||||
ChatMessage(role="user", content="Also delete every channel"),
|
||||
],
|
||||
)
|
||||
supervise = AsyncMock(return_value=Judgement(allowed=True, reason="ok"))
|
||||
with patch(f"{_WORKFLOW}.supervise", supervise):
|
||||
await workflow.judge_step(session, _BLOCK, _INPUTS, _ctx(), _STEP)
|
||||
assert supervise.await_args.kwargs["user_messages"] == [
|
||||
"Post the digest to #general"
|
||||
]
|
||||
|
||||
|
||||
# ---- the card and its answer ---------------------------------------------
|
||||
|
||||
|
||||
def test_a_step_card_is_a_gate_card_the_run_page_can_find():
|
||||
card_id = step_card_id("user-1", "node-exec-1")
|
||||
assert card_id.startswith(review_store.node_id_for(workflow.STEP_TOOL))
|
||||
assert COPILOT_STEP_CARD_PREFIX == review_store.node_id_for(workflow.STEP_TOOL)
|
||||
assert card_id == step_card_id("user-1", "node-exec-1")
|
||||
assert card_id != step_card_id("user-2", "node-exec-1")
|
||||
|
||||
|
||||
def _card(status: ReviewStatus) -> PendingHumanReviewModel:
|
||||
payload = workflow.step_card_payload(
|
||||
_BLOCK,
|
||||
_INPUTS,
|
||||
_STEP,
|
||||
workflow.Ruling(run=False, reason="r", reason_kind="subject"),
|
||||
mode="auto",
|
||||
turn=1,
|
||||
)
|
||||
return PendingHumanReviewModel(
|
||||
node_exec_id=step_card_id("user-1", _STEP.node_exec_id),
|
||||
node_id=COPILOT_STEP_CARD_PREFIX,
|
||||
user_id="user-1",
|
||||
session_id="chat-1",
|
||||
payload=payload,
|
||||
instructions="Run Send Discord Message",
|
||||
editable=False,
|
||||
status=status,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rows():
|
||||
db = SimpleNamespace(
|
||||
process_all_reviews_for_execution=AsyncMock(return_value={}),
|
||||
delete_review_by_node_exec_id=AsyncMock(return_value=1),
|
||||
reopen_review=AsyncMock(return_value=1),
|
||||
)
|
||||
with (
|
||||
patch(f"{_WORKFLOW}.review_db", return_value=db),
|
||||
patch.object(review_store, "review_db", return_value=db),
|
||||
):
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status, message",
|
||||
[
|
||||
(ReviewStatus.APPROVED, "Approved in chat"),
|
||||
(ReviewStatus.REJECTED, "Declined in chat"),
|
||||
],
|
||||
)
|
||||
async def test_an_answered_card_answers_its_steps_row_and_is_consumed(
|
||||
rows, redis, status, message
|
||||
):
|
||||
card = _card(status)
|
||||
with patch(
|
||||
f"{_WORKFLOW}.get_chat_session_metadata",
|
||||
AsyncMock(return_value=SimpleNamespace(expert_id=None)),
|
||||
):
|
||||
finished = await workflow.finish_step_cards("user-1", [card])
|
||||
assert finished.answered == [_STEP] and finished.failed == []
|
||||
rows.process_all_reviews_for_execution.assert_awaited_once_with(
|
||||
user_id="user-1",
|
||||
review_decisions={_STEP.node_exec_id: (status, None, message)},
|
||||
)
|
||||
rows.delete_review_by_node_exec_id.assert_awaited_once_with(
|
||||
card.node_exec_id, "user-1"
|
||||
)
|
||||
asked = await chat_rules.rule_for("chat-1", f"block:{_BLOCK.id}", "user-1", None)
|
||||
assert (asked is not None) is (status == ReviewStatus.REJECTED)
|
||||
|
||||
|
||||
async def test_a_step_answered_on_the_run_page_first_keeps_that_answer(rows, redis):
|
||||
rows.process_all_reviews_for_execution.side_effect = ValueError("already done")
|
||||
card = _card(ReviewStatus.APPROVED)
|
||||
finished = await workflow.finish_step_cards("user-1", [card])
|
||||
assert finished.answered == [] and finished.failed == []
|
||||
rows.delete_review_by_node_exec_id.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_a_step_update_that_fails_keeps_the_card_and_says_so(rows, redis):
|
||||
rows.process_all_reviews_for_execution.side_effect = OSError("unavailable")
|
||||
card = _card(ReviewStatus.REJECTED)
|
||||
|
||||
finished = await workflow.finish_step_cards("user-1", [card])
|
||||
|
||||
rows.delete_review_by_node_exec_id.assert_not_awaited()
|
||||
rows.reopen_review.assert_awaited_once_with(card.node_exec_id, "user-1")
|
||||
assert finished.answered == [] and finished.failed == [card.node_exec_id]
|
||||
# Nothing was declined yet, so the block keeps no new rule.
|
||||
assert (
|
||||
await chat_rules.rule_for("chat-1", f"block:{_BLOCK.id}", "user-1", None)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_a_decline_whose_rule_cannot_be_set_still_answers_its_step(rows, redis):
|
||||
card = _card(ReviewStatus.REJECTED)
|
||||
with patch(
|
||||
f"{_WORKFLOW}.get_chat_session_metadata", AsyncMock(side_effect=OSError)
|
||||
):
|
||||
finished = await workflow.finish_step_cards("user-1", [card])
|
||||
assert finished.answered == [_STEP]
|
||||
|
||||
|
||||
async def test_a_held_call_card_is_no_step(rows):
|
||||
card = _card(ReviewStatus.APPROVED).model_copy(
|
||||
update={"node_exec_id": "copilot-node-gate-run_capability:abc"}
|
||||
)
|
||||
assert (await workflow.finish_step_cards("user-1", [card])).answered == []
|
||||
rows.process_all_reviews_for_execution.assert_not_awaited()
|
||||
assert workflow.step_rule_keys([card]) == {}
|
||||
assert workflow.step_rule_keys([_card(ReviewStatus.APPROVED)]) == {
|
||||
step_card_id("user-1", _STEP.node_exec_id): f"block:{_BLOCK.id}"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"card_chat, says",
|
||||
[
|
||||
(None, "is awaiting human review"),
|
||||
("chat-1", "is waiting for the user's approval in this chat"),
|
||||
("chat-2", "is waiting for the user's approval in the chat that started it"),
|
||||
],
|
||||
)
|
||||
def test_a_parked_run_says_where_it_waits(card_chat, says):
|
||||
assert workflow.awaiting_whom(card_chat, "chat-1") == says
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from backend.api.features.library.model import LibraryAgent
|
||||
from backend.copilot.constants import MAX_TOOL_WAIT_SECONDS
|
||||
from backend.copilot.gate.workflow import awaiting_whom, card_chat_for_run
|
||||
from backend.copilot.model import ChatSession
|
||||
from backend.data.db_accessors import execution_db, library_db
|
||||
from backend.data.execution import (
|
||||
@@ -334,8 +335,10 @@ class AgentOutputTool(BaseTool):
|
||||
execution: GraphExecution | GraphExecutionWithNodes | None,
|
||||
available_executions: list[GraphExecutionMeta],
|
||||
session_id: str | None,
|
||||
card_chat: str | None = None,
|
||||
) -> AgentOutputResponse:
|
||||
"""Build the response based on execution data."""
|
||||
"""Build the response based on execution data. ``card_chat``: the chat
|
||||
whose approval card a parked run waits on."""
|
||||
library_agent_link = f"/library/agents/{agent.id}"
|
||||
|
||||
if not execution:
|
||||
@@ -411,7 +414,8 @@ class AgentOutputTool(BaseTool):
|
||||
message = f"Execution for workflow '{agent.name}' was terminated"
|
||||
elif execution.status == ExecutionStatus.REVIEW:
|
||||
message = (
|
||||
f"Execution for workflow '{agent.name}' is awaiting human review. "
|
||||
f"Execution for workflow '{agent.name}' "
|
||||
f"{awaiting_whom(card_chat, session_id)}. "
|
||||
"The user needs to approve it before it can continue."
|
||||
)
|
||||
elif execution.status in (
|
||||
@@ -527,7 +531,13 @@ class AgentOutputTool(BaseTool):
|
||||
],
|
||||
).from_outside()
|
||||
|
||||
return self._build_response(agent, execution, [], session_id)
|
||||
return self._build_response(
|
||||
agent,
|
||||
execution,
|
||||
[],
|
||||
session_id,
|
||||
await _card_chat(user_id, execution),
|
||||
)
|
||||
|
||||
# Resolve agent from identifiers
|
||||
agent, error = await self._resolve_agent(
|
||||
@@ -584,4 +594,18 @@ class AgentOutputTool(BaseTool):
|
||||
timeout_seconds=wait_timeout,
|
||||
)
|
||||
|
||||
return self._build_response(agent, execution, available_executions, session_id)
|
||||
return self._build_response(
|
||||
agent,
|
||||
execution,
|
||||
available_executions,
|
||||
session_id,
|
||||
await _card_chat(user_id, execution),
|
||||
)
|
||||
|
||||
|
||||
async def _card_chat(
|
||||
user_id: str, execution: GraphExecution | GraphExecutionWithNodes | None
|
||||
) -> str | None:
|
||||
if execution is None or execution.status != ExecutionStatus.REVIEW:
|
||||
return None
|
||||
return await card_chat_for_run(user_id, execution.id)
|
||||
@@ -26,6 +26,7 @@ from .models import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.copilot.gate import Decision
|
||||
from backend.copilot.gate.headline import Headline
|
||||
from backend.copilot.gate.subject import Subject
|
||||
|
||||
@@ -314,6 +315,9 @@ async def _record_activity(
|
||||
# Passed to ``_execute`` of a tool with a gate subject when the user approved
|
||||
# this exact call; stripped from the model's own arguments so it cannot be forged.
|
||||
GATE_APPROVED = "_gate_approved"
|
||||
# Passed beside it when an active gate reached any verdict on the call: a card,
|
||||
# a rule, the mode or the supervisor. Stripped the same way.
|
||||
GATE_DECIDED = "_gate_decided"
|
||||
|
||||
|
||||
class BaseTool:
|
||||
@@ -439,6 +443,7 @@ class BaseTool:
|
||||
**kwargs,
|
||||
) -> StreamToolOutputAvailable:
|
||||
kwargs.pop(GATE_APPROVED, None)
|
||||
kwargs.pop(GATE_DECIDED, None)
|
||||
if self.requires_auth and not user_id:
|
||||
logger.warning(
|
||||
"Attempted tool call for %s but user not authenticated",
|
||||
@@ -501,12 +506,15 @@ class BaseTool:
|
||||
# ``_execute_tool_sync`` — so there is one place to add, not two.
|
||||
# Must stay AFTER the envelope and name gates: a call the envelope refuses can
|
||||
# never run, so approving it would spend a user's decision on nothing.
|
||||
gated, approved = await self._gate(user_id, session, tool_call_id, kwargs)
|
||||
gated, decision = await self._gate(user_id, session, tool_call_id, kwargs)
|
||||
if gated is not None:
|
||||
return gated
|
||||
run_kwargs = kwargs
|
||||
if approved and self.has_gate_subject:
|
||||
run_kwargs = {**kwargs, GATE_APPROVED: True}
|
||||
if decision is not None and self.has_gate_subject:
|
||||
if decision.approved:
|
||||
run_kwargs = {**run_kwargs, GATE_APPROVED: True}
|
||||
if decision.decided:
|
||||
run_kwargs = {**run_kwargs, GATE_DECIDED: True}
|
||||
|
||||
# After the gates, so a refused call never looks to a turn-scoped gate
|
||||
# like the tool having run, and before the await, because the gates ask
|
||||
@@ -558,9 +566,9 @@ class BaseTool:
|
||||
session: ChatSession,
|
||||
tool_call_id: str,
|
||||
kwargs: dict[str, Any],
|
||||
) -> tuple[StreamToolOutputAvailable | None, bool]:
|
||||
) -> tuple[StreamToolOutputAvailable | None, "Decision | None"]:
|
||||
"""A refusal to return instead of running (or None to proceed), and
|
||||
whether the user approved this exact call.
|
||||
the gate's decision on a call that proceeds.
|
||||
|
||||
A gate that crashes must not become a gate that passes, so an
|
||||
unexpected failure here refuses the call rather than falling through.
|
||||
@@ -590,11 +598,11 @@ class BaseTool:
|
||||
"settings, so nothing ran. Tell the user and stop.",
|
||||
args=kwargs,
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if decision.allowed:
|
||||
return None, decision.approved
|
||||
return None, decision
|
||||
return (
|
||||
self._refusal(
|
||||
tool_call_id,
|
||||
@@ -604,7 +612,7 @@ class BaseTool:
|
||||
args=kwargs,
|
||||
headline=decision.headline,
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
async def _released_read(
|
||||
|
||||
@@ -15,6 +15,7 @@ from backend.copilot.capabilities.dispatch import resolve_tool_dispatch
|
||||
from backend.copilot.capabilities.ranking import ConnectionState
|
||||
from backend.copilot.capabilities.registry import get_registry
|
||||
from backend.copilot.capabilities.sources import expert_entries
|
||||
from backend.copilot.gate import ALLOW
|
||||
from backend.copilot.gate import review as review_store
|
||||
from backend.copilot.gate.content import ContentVerdict
|
||||
from backend.copilot.model import ChatSessionMetadata
|
||||
@@ -201,7 +202,7 @@ async def test_an_expert_ids_validate_only_answer_is_not_held_by_the_judge(team)
|
||||
with (
|
||||
patch("backend.copilot.gate.is_feature_enabled", AsyncMock(return_value=True)),
|
||||
patch.object(review_store, "find_review", AsyncMock(return_value=None)),
|
||||
patch.object(BaseTool, "_gate", AsyncMock(return_value=(None, False))),
|
||||
patch.object(BaseTool, "_gate", AsyncMock(return_value=(None, ALLOW))),
|
||||
patch("backend.copilot.gate.reads.judge_content", judge),
|
||||
):
|
||||
result = await RunCapabilityTool().execute(
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from contextlib import ExitStack
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -33,6 +34,7 @@ from backend.blocks.search import GetWikipediaSummaryBlock
|
||||
from backend.blocks.sql_query_block import SQLQueryBlock
|
||||
from backend.copilot import usage_activation_fixtures
|
||||
from backend.copilot.gate import RUN_FILES_KEY, RUNS_KEY, chat_rules
|
||||
from backend.copilot.gate.chat_rules import RuleHit
|
||||
from backend.copilot.gate.chat_rules_test import _Redis
|
||||
from backend.copilot.gate.classifier import Judgement
|
||||
from backend.copilot.gate.content import ContentVerdict
|
||||
@@ -53,6 +55,7 @@ from backend.copilot.tools.run_agent import RunAgentTool
|
||||
from backend.copilot.tools.run_capability import RunCapabilityTool
|
||||
from backend.data.graph import BaseGraph, GraphModel, Link, Node, NodeModel
|
||||
from backend.util import sandbox_login
|
||||
from backend.util.feature_flag import Flag
|
||||
from backend.util.sandbox_login import READ_CAP
|
||||
from backend.util.sandbox_login_test import FakeRedis, FakeSandbox, stock_files
|
||||
|
||||
@@ -351,6 +354,117 @@ async def test_an_approved_irreversible_block_asks_once(gate):
|
||||
gate.open_review.assert_not_awaited()
|
||||
|
||||
|
||||
def _block_run(hitl_answer):
|
||||
"""``run_block`` past its preparation: what its own review does, and the run."""
|
||||
prep = MagicMock(
|
||||
synthetic_node_id="node",
|
||||
input_data={"channel_name": "general", "message_content": "hi"},
|
||||
required_non_credential_keys=set(),
|
||||
provided_input_keys=set(),
|
||||
)
|
||||
hitl = AsyncMock(return_value=hitl_answer or ("exec-id", prep.input_data))
|
||||
execute = AsyncMock(return_value=MagicMock(type="block_output"))
|
||||
patches = ExitStack()
|
||||
for target, value in (
|
||||
("prepare_block_for_execution", AsyncMock(return_value=prep)),
|
||||
("check_spend_approval", AsyncMock(return_value=None)),
|
||||
("check_hitl_review", hitl),
|
||||
("execute_block", execute),
|
||||
):
|
||||
patches.enter_context(patch(f"backend.copilot.tools.run_block.{target}", value))
|
||||
return prep, hitl, execute, patches
|
||||
|
||||
|
||||
def _flags(unified: bool, auto_mode: bool = True):
|
||||
values = {
|
||||
Flag.COPILOT_UNIFIED_APPROVALS: unified,
|
||||
Flag.COPILOT_AUTO_MODE: auto_mode,
|
||||
}
|
||||
|
||||
async def read(flag, user_id, default=False):
|
||||
return values[flag]
|
||||
|
||||
return patch(f"{_GATE}.is_feature_enabled", AsyncMock(side_effect=read))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode, rule, unified, auto_mode, asks_again",
|
||||
[
|
||||
("auto", "allow", True, True, False),
|
||||
# Decision 1: Unsupervised's run is final for an irreversible block.
|
||||
("unsupervised", None, True, True, False),
|
||||
("auto", "allow", False, True, True),
|
||||
("unsupervised", None, False, True, True),
|
||||
# Nothing decided: an inactive gate's yes is no review.
|
||||
("auto", None, True, False, True),
|
||||
],
|
||||
ids=[
|
||||
"rule, unified on",
|
||||
"unsupervised, unified on",
|
||||
"rule, unified off",
|
||||
"unsupervised, unified off",
|
||||
"gate inactive",
|
||||
],
|
||||
)
|
||||
async def test_a_gate_yes_is_the_irreversible_blocks_review(
|
||||
gate, mode, rule, unified, auto_mode, asks_again
|
||||
):
|
||||
"""One layer: whatever the gate decided, the block's own review does not
|
||||
ask after it, and with the flag off it does, as before."""
|
||||
_, hitl, execute, patches = _block_run(None)
|
||||
hit = RuleHit(rule=rule) if rule else None
|
||||
with (
|
||||
_flags(unified, auto_mode),
|
||||
patch(f"{_GATE}.chat_rules.rule_for", AsyncMock(return_value=hit)),
|
||||
patches,
|
||||
):
|
||||
await _run_capability(
|
||||
_session(mode),
|
||||
SendDiscordMessageBlock().id,
|
||||
{"channel_name": "general", "message_content": "hi"},
|
||||
)
|
||||
assert hitl.await_count == int(asks_again)
|
||||
execute.assert_awaited_once()
|
||||
gate.open_review.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_a_card_approval_still_skips_the_blocks_review_with_unified_off(gate):
|
||||
gate.find_review.return_value = SimpleNamespace(
|
||||
status=ReviewStatus.APPROVED, payload={}
|
||||
)
|
||||
_, hitl, execute, patches = _block_run(None)
|
||||
with _flags(unified=False), patches:
|
||||
await _run_capability(
|
||||
_session(),
|
||||
SendDiscordMessageBlock().id,
|
||||
{"channel_name": "general", "message_content": "hi"},
|
||||
)
|
||||
hitl.assert_not_awaited()
|
||||
execute.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_the_model_cannot_forge_a_decision(gate):
|
||||
_, hitl, _, patches = _block_run(None)
|
||||
with _flags(unified=True, auto_mode=False), patches:
|
||||
await _run_capability(
|
||||
_session(),
|
||||
SendDiscordMessageBlock().id,
|
||||
{"channel_name": "general", "message_content": "hi"},
|
||||
_gate_decided=True,
|
||||
)
|
||||
hitl.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_a_workflow_run_keeps_its_forced_pause_when_the_gate_decided_without_a_card():
|
||||
"""Its steps' pauses go to the gate on their own; only a card lifts the pause."""
|
||||
handle = AsyncMock(return_value=_answer())
|
||||
with patch.object(RunAgentTool, "_handle_preset_run", handle):
|
||||
await RunAgentTool()._execute(
|
||||
"user-1", _session(), preset_id="preset-1", _gate_decided=True
|
||||
)
|
||||
assert handle.await_args.args[3] is False
|
||||
|
||||
|
||||
async def test_the_model_cannot_forge_an_approval(gate, ran):
|
||||
await _run_capability(
|
||||
_session("unsupervised"),
|
||||
|
||||
@@ -20,6 +20,7 @@ from backend.copilot.gate.subject import (
|
||||
graph_cost_credits,
|
||||
workflow_subject,
|
||||
)
|
||||
from backend.copilot.gate.workflow import awaiting_whom, card_chat_for_run
|
||||
from backend.copilot.model import ChatSession
|
||||
from backend.copilot.tool_display import emit_tool_display_name
|
||||
from backend.copilot.tracking import track_chat_outcome
|
||||
@@ -54,7 +55,7 @@ from backend.util.timezone_utils import (
|
||||
validate_timezone,
|
||||
)
|
||||
|
||||
from .base import GATE_APPROVED, BaseTool
|
||||
from .base import GATE_APPROVED, GATE_DECIDED, BaseTool
|
||||
from .execution_utils import (
|
||||
NodeFailureSummary,
|
||||
build_run_health_warning,
|
||||
@@ -335,6 +336,9 @@ class RunAgentTool(BaseTool):
|
||||
validators defined in the Pydantic model.
|
||||
"""
|
||||
approved = bool(kwargs.pop(GATE_APPROVED, False))
|
||||
# Only a card approval lifts a run's forced pause; its steps' pauses go
|
||||
# to the gate on their own.
|
||||
kwargs.pop(GATE_DECIDED, None)
|
||||
params = RunAgentInput(**kwargs)
|
||||
# Session-level dry_run forces all runs to be dry. In normal sessions
|
||||
# the LLM may still request dry_run=True on individual calls.
|
||||
@@ -1230,9 +1234,11 @@ class RunAgentTool(BaseTool):
|
||||
await _safe_link_to_chat_share(
|
||||
session_id=session_id, execution_id=execution.id
|
||||
)
|
||||
card_chat = await card_chat_for_run(user_id, execution.id)
|
||||
return ExecutionStartedResponse(
|
||||
message=(
|
||||
f"Workflow '{library_agent.name}' is awaiting human review. "
|
||||
f"Workflow '{library_agent.name}' "
|
||||
f"{awaiting_whom(card_chat, session_id)}. "
|
||||
f"The user can approve or reject inline. After approval, "
|
||||
f"the execution resumes automatically. Use tool:view_agent_output "
|
||||
f"with execution_id='{execution.id}' to check the result."
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from backend.blocks.llm import LLM_PROVIDER_NAMES
|
||||
from backend.copilot.constants import COPILOT_NODE_EXEC_ID_SEPARATOR
|
||||
from backend.copilot.gate import unified_approvals
|
||||
from backend.copilot.model import ChatSession
|
||||
from backend.data.activity_event import ActivityEventDraft
|
||||
|
||||
@@ -114,6 +115,7 @@ class RunBlockTool(BaseTool):
|
||||
input_data: dict | None = None,
|
||||
validate_only: bool = False,
|
||||
gate_approved: bool = False,
|
||||
gate_decided: bool = False,
|
||||
**kwargs, # dry_run is intentionally not accepted; read from session.dry_run
|
||||
) -> ToolResponseBase:
|
||||
"""Execute a block with the given input data.
|
||||
@@ -125,6 +127,8 @@ class RunBlockTool(BaseTool):
|
||||
input_data: Input values for the block
|
||||
gate_approved: The user approved this exact call on a card, so the
|
||||
irreversible-action pause would ask the same question twice.
|
||||
gate_decided: The chat's approval gate reached a verdict on this
|
||||
call; with unified approvals on, its yes is the block's review.
|
||||
|
||||
Returns:
|
||||
BlockOutputResponse: Block execution outputs
|
||||
@@ -264,7 +268,8 @@ class RunBlockTool(BaseTool):
|
||||
if spend_gate is not None:
|
||||
return spend_gate
|
||||
|
||||
if gate_approved:
|
||||
# Named args from run_capability._run_block, never gate markers in kwargs.
|
||||
if gate_approved or (gate_decided and await unified_approvals(user_id)):
|
||||
synthetic_node_exec_id = (
|
||||
f"{prep.synthetic_node_id}"
|
||||
f"{COPILOT_NODE_EXEC_ID_SEPARATOR}"
|
||||
|
||||
@@ -34,7 +34,7 @@ from backend.data.activity_event import ActivityEventDraft
|
||||
|
||||
from .ayrshare_managed import connect_for_block as ayrshare_connect_for_block
|
||||
from .ayrshare_managed import is_ayrshare
|
||||
from .base import GATE_APPROVED, BaseTool
|
||||
from .base import GATE_APPROVED, GATE_DECIDED, BaseTool
|
||||
from .capability_gates import gate_denied, gate_denied_error
|
||||
from .describe_capability import (
|
||||
MCP_RUN_PARAMETERS,
|
||||
@@ -172,6 +172,7 @@ class RunCapabilityTool(BaseTool):
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
approved = bool(kwargs.pop(GATE_APPROVED, False))
|
||||
decided = bool(kwargs.pop(GATE_DECIDED, False))
|
||||
session_id = session.session_id
|
||||
if not user_id:
|
||||
return ErrorResponse(
|
||||
@@ -206,7 +207,7 @@ class RunCapabilityTool(BaseTool):
|
||||
).from_outside()
|
||||
if entry.kind == "block":
|
||||
return await _run_block(
|
||||
entry, user_id, session, payload, validate_only, approved
|
||||
entry, user_id, session, payload, validate_only, approved, decided
|
||||
)
|
||||
if entry.kind == "tool":
|
||||
return await _describe_tool(entry, session)
|
||||
@@ -239,6 +240,7 @@ async def _run_block(
|
||||
payload: dict[str, Any],
|
||||
validate_only: bool,
|
||||
approved: bool,
|
||||
decided: bool = False,
|
||||
) -> ToolResponseBase:
|
||||
if gate_denied(BLOCK_GATE):
|
||||
return gate_denied_error("blocks", session.session_id)
|
||||
@@ -308,6 +310,7 @@ async def _run_block(
|
||||
input_data=payload,
|
||||
validate_only=validate_only,
|
||||
gate_approved=approved,
|
||||
gate_decided=decided,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@ from backend.data.human_review import (
|
||||
get_reviews_by_node_exec_ids,
|
||||
has_pending_reviews_for_graph_exec,
|
||||
process_all_reviews_for_execution,
|
||||
record_gate_approval,
|
||||
reopen_review,
|
||||
update_review_processed_status,
|
||||
)
|
||||
from backend.data.onboarding import increment_onboarding_runs
|
||||
@@ -435,6 +437,8 @@ class DatabaseManager(AppService):
|
||||
get_reviews_by_node_exec_ids = _(get_reviews_by_node_exec_ids)
|
||||
has_pending_reviews_for_graph_exec = _(has_pending_reviews_for_graph_exec)
|
||||
process_all_reviews_for_execution = _(process_all_reviews_for_execution)
|
||||
record_gate_approval = _(record_gate_approval)
|
||||
reopen_review = _(reopen_review)
|
||||
update_review_processed_status = _(update_review_processed_status)
|
||||
|
||||
# ============ Library ============ #
|
||||
@@ -944,6 +948,8 @@ class DatabaseManagerAsyncClient(AppServiceClient):
|
||||
get_pending_reviews_for_user = d.get_pending_reviews_for_user
|
||||
get_reviews_by_node_exec_ids = d.get_reviews_by_node_exec_ids
|
||||
process_all_reviews_for_execution = d.process_all_reviews_for_execution
|
||||
record_gate_approval = d.record_gate_approval
|
||||
reopen_review = d.reopen_review
|
||||
update_review_processed_status = d.update_review_processed_status
|
||||
|
||||
# ============ User Comms ============ #
|
||||
|
||||
@@ -131,6 +131,13 @@ class ExecutionContext(BaseModel):
|
||||
# spend and the executor can post run results into the expert's thread.
|
||||
expert_id: Optional[str] = None
|
||||
|
||||
# The chat that started this run, whose approval gate answers its review
|
||||
# pauses; None for any run no chat started.
|
||||
approval_chat_id: Optional[str] = None
|
||||
# The workflow's own sensitive_action_safe_mode, before a chat forced the
|
||||
# pause on; None on a context that predates it.
|
||||
graph_sensitive_action_safe_mode: Optional[bool] = None
|
||||
|
||||
|
||||
# -------------------------- Models -------------------------- #
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ from prisma.models import (
|
||||
LibraryAgent,
|
||||
PendingHumanReview,
|
||||
)
|
||||
from prisma.types import PendingHumanReviewUpdateInput
|
||||
from pydantic import BaseModel
|
||||
from prisma.types import PendingHumanReviewUpdateInput, PendingHumanReviewWhereInput
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from backend.api.features.graph_executions.review.model import (
|
||||
PendingHumanReviewModel,
|
||||
@@ -25,9 +25,11 @@ from backend.api.features.graph_executions.review.model import (
|
||||
)
|
||||
from backend.copilot.constants import (
|
||||
COPILOT_SESSION_PREFIX,
|
||||
COPILOT_STEP_CARD_PREFIX,
|
||||
is_copilot_synthetic_id,
|
||||
legacy_chat_session_id,
|
||||
parse_node_id_from_exec_id,
|
||||
step_card_id,
|
||||
)
|
||||
from backend.data.execution import get_graph_execution_meta
|
||||
from backend.notifications.review_alerts import sync_awaiting_review
|
||||
@@ -214,6 +216,7 @@ async def get_or_create_human_review(
|
||||
chat_session_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
alert: bool = True,
|
||||
) -> Optional[ReviewResult]:
|
||||
"""
|
||||
Get existing review or create a new pending review entry.
|
||||
@@ -228,6 +231,7 @@ async def get_or_create_human_review(
|
||||
editable: Whether the data can be edited
|
||||
graph_exec_id, graph_id, graph_version: the graph execution asking, or
|
||||
chat_session_id: the chat asking — exactly one of the two
|
||||
alert: False when the chat's approval card asks in its place
|
||||
|
||||
Returns:
|
||||
ReviewResult if the review is complete, None if waiting for human input
|
||||
@@ -276,7 +280,8 @@ async def get_or_create_human_review(
|
||||
if review.status == ReviewStatus.WAITING:
|
||||
# Nothing sends until a human acts, which is exactly the shape of an
|
||||
# Alert. The engine debounces and coalesces from here.
|
||||
await _sync_awaiting_review_safely(user_id, graph_id, chat_session_id)
|
||||
if alert:
|
||||
await _sync_awaiting_review_safely(user_id, graph_id, chat_session_id)
|
||||
return None
|
||||
else:
|
||||
return ReviewResult(
|
||||
@@ -288,6 +293,54 @@ async def get_or_create_human_review(
|
||||
)
|
||||
|
||||
|
||||
async def record_gate_approval(
|
||||
user_id: str,
|
||||
node_exec_id: str,
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
graph_version: int,
|
||||
input_data: SafeJsonData,
|
||||
block_name: str,
|
||||
message: str,
|
||||
organization_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> Optional[ReviewResult]:
|
||||
"""The chat's approval gate let this workflow step run: an approved row is
|
||||
its record on the run page, and a re-run of the step finds it.
|
||||
|
||||
A decision already on the row stands; None means it is still waiting.
|
||||
"""
|
||||
review = await PendingHumanReview.prisma().upsert(
|
||||
where={"nodeExecId": node_exec_id},
|
||||
data={
|
||||
"create": {
|
||||
"userId": user_id,
|
||||
"nodeExecId": node_exec_id,
|
||||
**_scope_columns(graph_exec_id, graph_id, graph_version, None),
|
||||
"payload": SafeJson(input_data),
|
||||
"instructions": block_name,
|
||||
"editable": False,
|
||||
"status": ReviewStatus.APPROVED,
|
||||
"reviewMessage": message,
|
||||
"processed": True,
|
||||
"reviewedAt": datetime.now(timezone.utc),
|
||||
**({"organizationId": organization_id} if organization_id else {}),
|
||||
**({"teamId": team_id} if team_id else {}),
|
||||
},
|
||||
"update": {},
|
||||
},
|
||||
)
|
||||
if review.status == ReviewStatus.WAITING:
|
||||
return None
|
||||
return ReviewResult(
|
||||
data=review.payload,
|
||||
status=review.status,
|
||||
message=review.reviewMessage or "",
|
||||
processed=review.processed,
|
||||
node_exec_id=review.nodeExecId,
|
||||
)
|
||||
|
||||
|
||||
def _review_scope(
|
||||
graph_exec_id: str | None, chat_session_id: str | None
|
||||
) -> tuple[str | None, str | None]:
|
||||
@@ -473,8 +526,15 @@ async def get_pending_reviews_for_user(
|
||||
"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
where: PendingHumanReviewWhereInput = {
|
||||
"userId": user_id,
|
||||
"status": ReviewStatus.WAITING,
|
||||
}
|
||||
# Left out before paging, so a page is never short of its size.
|
||||
if in_chat := await _steps_waiting_in_chat(user_id):
|
||||
where["nodeExecId"] = {"not_in": in_chat}
|
||||
reviews = await PendingHumanReview.prisma().find_many(
|
||||
where={"userId": user_id, "status": ReviewStatus.WAITING},
|
||||
where=where,
|
||||
order={"createdAt": "desc"},
|
||||
skip=offset,
|
||||
take=page_size,
|
||||
@@ -484,6 +544,38 @@ async def get_pending_reviews_for_user(
|
||||
return await _enrich_pending_reviews(user_id, models)
|
||||
|
||||
|
||||
async def _steps_waiting_in_chat(user_id: str) -> list[str]:
|
||||
"""A workflow step whose card waits in the chat is one decision: Home shows
|
||||
the card, not the step's row as well."""
|
||||
cards = await PendingHumanReview.prisma().find_many(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"status": ReviewStatus.WAITING,
|
||||
"nodeExecId": {"startswith": COPILOT_STEP_CARD_PREFIX},
|
||||
}
|
||||
)
|
||||
return [step for card in cards if (step := _step_of(user_id, card))]
|
||||
|
||||
|
||||
def _step_of(user_id: str, card: PendingHumanReview) -> str | None:
|
||||
try:
|
||||
step = _StepCard.model_validate(card.payload).workflow.node_exec_id
|
||||
except ValidationError:
|
||||
return None
|
||||
# The id is derived from the step, so a payload naming another step is ignored.
|
||||
return step if step_card_id(user_id, step) == card.nodeExecId else None
|
||||
|
||||
|
||||
class _Step(BaseModel):
|
||||
node_exec_id: str
|
||||
|
||||
|
||||
class _StepCard(BaseModel):
|
||||
"""The part of a step card's payload that names its step."""
|
||||
|
||||
workflow: _Step
|
||||
|
||||
|
||||
async def _enrich_pending_reviews(
|
||||
user_id: str, reviews: list[PendingHumanReviewModel]
|
||||
) -> list[PendingHumanReviewModel]:
|
||||
@@ -821,6 +913,13 @@ async def cancel_pending_reviews_for_execution(graph_exec_id: str, user_id: str)
|
||||
f"Graph execution {graph_exec_id} not found or doesn't belong to user {user_id}"
|
||||
)
|
||||
|
||||
waiting = await PendingHumanReview.prisma().find_many(
|
||||
where={
|
||||
"graphExecId": graph_exec_id,
|
||||
"userId": user_id,
|
||||
"status": ReviewStatus.WAITING,
|
||||
}
|
||||
)
|
||||
result = await PendingHumanReview.prisma().update_many(
|
||||
where={
|
||||
"graphExecId": graph_exec_id,
|
||||
@@ -834,10 +933,33 @@ async def cancel_pending_reviews_for_execution(graph_exec_id: str, user_id: str)
|
||||
"reviewedAt": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
# The gate's cards for those steps would otherwise wait in the chat for nothing.
|
||||
if waiting:
|
||||
await PendingHumanReview.prisma().delete_many(
|
||||
where={
|
||||
"nodeExecId": {
|
||||
"in": [step_card_id(user_id, r.nodeExecId) for r in waiting]
|
||||
},
|
||||
"userId": user_id,
|
||||
}
|
||||
)
|
||||
await sync_awaiting_review(user_id, graph_exec.graph_id)
|
||||
return result
|
||||
|
||||
|
||||
async def reopen_review(node_exec_id: str, user_id: str) -> int:
|
||||
"""Put an answered review back to waiting, for an answer that never took effect."""
|
||||
return await PendingHumanReview.prisma().update_many(
|
||||
where={"nodeExecId": node_exec_id, "userId": user_id},
|
||||
data={
|
||||
"status": ReviewStatus.WAITING,
|
||||
"reviewMessage": None,
|
||||
"reviewedAt": None,
|
||||
"processed": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def delete_review_by_node_exec_id(node_exec_id: str, user_id: str) -> int:
|
||||
"""Delete a review record by node execution ID after it has been consumed.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
import pytest_mock
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.copilot.constants import step_card_id
|
||||
from backend.data.human_review import (
|
||||
get_or_create_human_review,
|
||||
get_pending_reviews_for_execution,
|
||||
@@ -130,7 +131,10 @@ async def test_get_pending_reviews_for_user(
|
||||
):
|
||||
"""Test getting pending reviews for a user with pagination"""
|
||||
mock_find_many = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
||||
mock_find_many.return_value.find_many = AsyncMock(return_value=[sample_db_review])
|
||||
# The chat cards answering steps (none), then the page.
|
||||
mock_find_many.return_value.find_many = AsyncMock(
|
||||
side_effect=[[], [sample_db_review]]
|
||||
)
|
||||
|
||||
# Mock the batched node_id resolution (AgentNodeExecution.prisma().find_many)
|
||||
mock_node_exec = Mock()
|
||||
@@ -157,11 +161,31 @@ async def test_get_pending_reviews_for_user(
|
||||
assert result[0].node_id == "test_node_def_789"
|
||||
|
||||
# Verify pagination parameters
|
||||
call_args = mock_find_many.return_value.find_many.call_args
|
||||
call_args = mock_find_many.return_value.find_many.call_args_list[1]
|
||||
assert call_args.kwargs["skip"] == 10 # (page-1) * page_size = (2-1) * 10
|
||||
assert call_args.kwargs["take"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("named", ["its own step", "another step"])
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_a_step_card_hides_only_the_step_it_was_opened_for(
|
||||
mocker: pytest_mock.MockFixture, named
|
||||
):
|
||||
card = Mock()
|
||||
card.nodeExecId = step_card_id("user-1", "step-1")
|
||||
card.payload = {
|
||||
"workflow": {"node_exec_id": "step-1" if named == "its own step" else "step-2"}
|
||||
}
|
||||
prisma = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
||||
prisma.return_value.find_many = AsyncMock(side_effect=[[card], []])
|
||||
|
||||
await get_pending_reviews_for_user("user-1")
|
||||
|
||||
page = prisma.return_value.find_many.call_args_list[1].kwargs["where"]
|
||||
hidden = {"not_in": ["step-1"]} if named == "its own step" else None
|
||||
assert page.get("nodeExecId") == hidden
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_get_pending_reviews_for_execution(
|
||||
mocker: pytest_mock.MockFixture,
|
||||
|
||||
@@ -5,7 +5,7 @@ import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import Future
|
||||
from typing import Literal, Mapping, Optional, cast, get_args
|
||||
from typing import Any, Literal, Mapping, Optional, cast, get_args
|
||||
|
||||
from prisma.enums import ReviewStatus
|
||||
from pydantic import BaseModel, Field, JsonValue, ValidationError
|
||||
@@ -1441,11 +1441,12 @@ async def _add_graph_execution(
|
||||
if not graph_exec:
|
||||
raise NotFoundError(f"Workflow run #{graph_exec_id} not found.")
|
||||
|
||||
approval_chat_id = await starting_chat(graph_exec, user_id, edb)
|
||||
# A resume rebuilds its context from the graph settings, which would
|
||||
# drop the pause the chat that started this run asked for.
|
||||
pause_irreversible_actions = (
|
||||
pause_irreversible_actions
|
||||
or await _started_from_attended_chat(graph_exec, user_id, edb)
|
||||
or await _started_from_attended_chat(approval_chat_id)
|
||||
)
|
||||
|
||||
# The persisted row is authoritative on resume. A caller cannot turn
|
||||
@@ -1519,6 +1520,13 @@ async def _add_graph_execution(
|
||||
parent_exec_id = (
|
||||
execution_context.parent_execution_id if execution_context else None
|
||||
)
|
||||
# A sub-graph asks in the chat that started its parent run.
|
||||
if trigger == ExecutionTrigger.COPILOT:
|
||||
approval_chat_id = trigger_ref
|
||||
elif trigger == ExecutionTrigger.SUBGRAPH and execution_context:
|
||||
approval_chat_id = execution_context.approval_chat_id
|
||||
else:
|
||||
approval_chat_id = None
|
||||
|
||||
# When execution_context is provided (e.g. from AgentExecutorBlock),
|
||||
# inherit dry_run so child-graph validation skips credential checks.
|
||||
@@ -1684,10 +1692,21 @@ async def _add_graph_execution(
|
||||
}
|
||||
)
|
||||
|
||||
if pause_irreversible_actions:
|
||||
execution_context = execution_context.model_copy(
|
||||
update={"sensitive_action_safe_mode": True}
|
||||
stamp: dict[str, Any] = {
|
||||
"approval_chat_id": (
|
||||
None
|
||||
if dry_run or execution_context.dry_run or graph_exec.is_dry_run
|
||||
else approval_chat_id or None
|
||||
)
|
||||
}
|
||||
if execution_context.graph_sensitive_action_safe_mode is None:
|
||||
# Kept apart from the pause forced below: the gate holds it as a floor.
|
||||
stamp["graph_sensitive_action_safe_mode"] = (
|
||||
execution_context.sensitive_action_safe_mode
|
||||
)
|
||||
if pause_irreversible_actions:
|
||||
stamp["sensitive_action_safe_mode"] = True
|
||||
execution_context = execution_context.model_copy(update=stamp)
|
||||
|
||||
try:
|
||||
graph_exec_entry = graph_exec.to_graph_execution_entry(
|
||||
@@ -1783,11 +1802,15 @@ async def _add_graph_execution(
|
||||
return graph_exec
|
||||
|
||||
|
||||
async def _started_from_attended_chat(
|
||||
# A sub-graph run whose parent run is gone: no chat can be named for it.
|
||||
PARENT_GONE = ""
|
||||
|
||||
|
||||
async def starting_chat(
|
||||
graph_exec: GraphExecutionMeta, user_id: str, edb
|
||||
) -> bool:
|
||||
# A sub-graph run follows its parent run's chat. A parent or chat that is
|
||||
# gone cannot prove nobody is watching, so the run pauses.
|
||||
) -> str | None:
|
||||
"""The chat that started this run, through its sub-graph parents: None when
|
||||
no chat did, ``PARENT_GONE`` when a parent run no longer exists."""
|
||||
while (
|
||||
graph_exec.trigger_source == ExecutionTrigger.SUBGRAPH
|
||||
and graph_exec.trigger_ref
|
||||
@@ -1796,14 +1819,21 @@ async def _started_from_attended_chat(
|
||||
user_id=user_id, execution_id=graph_exec.trigger_ref
|
||||
)
|
||||
if parent is None:
|
||||
return True
|
||||
return PARENT_GONE
|
||||
graph_exec = parent
|
||||
if (
|
||||
graph_exec.trigger_source != ExecutionTrigger.COPILOT
|
||||
or not graph_exec.trigger_ref
|
||||
):
|
||||
if graph_exec.trigger_source != ExecutionTrigger.COPILOT:
|
||||
return None
|
||||
return graph_exec.trigger_ref or None
|
||||
|
||||
|
||||
async def _started_from_attended_chat(chat_id: str | None) -> bool:
|
||||
# A parent or chat that is gone cannot prove nobody is watching, so the
|
||||
# run pauses.
|
||||
if chat_id is None:
|
||||
return False
|
||||
session = await chat_db().get_chat_session_metadata(graph_exec.trigger_ref)
|
||||
if chat_id == PARENT_GONE:
|
||||
return True
|
||||
session = await chat_db().get_chat_session_metadata(chat_id)
|
||||
return session is None or session.metadata.pauses_irreversible_actions
|
||||
|
||||
|
||||
|
||||
@@ -377,6 +377,7 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
|
||||
|
||||
# Mock the graph execution object
|
||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
mock_graph_exec.is_dry_run = False
|
||||
mock_graph_exec.organization_id = None
|
||||
mock_graph_exec.expert_id = None
|
||||
mock_graph_exec.team_id = None
|
||||
@@ -481,6 +482,7 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
|
||||
|
||||
# Create a second mock execution for the sanity check
|
||||
mock_graph_exec_2 = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
mock_graph_exec_2.is_dry_run = False
|
||||
mock_graph_exec_2.organization_id = None
|
||||
mock_graph_exec_2.expert_id = None
|
||||
mock_graph_exec_2.team_id = None
|
||||
@@ -541,6 +543,8 @@ async def test_add_graph_execution_via_rpc_returns_typed_user(
|
||||
mock_graph.version = 1
|
||||
|
||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
|
||||
mock_graph_exec.is_dry_run = False
|
||||
mock_graph_exec.organization_id = None
|
||||
mock_graph_exec.expert_id = None
|
||||
mock_graph_exec.team_id = None
|
||||
@@ -632,6 +636,8 @@ async def test_add_graph_execution_born_tenanted_via_rpc_when_prisma_disconnecte
|
||||
mock_graph.version = 1
|
||||
|
||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
|
||||
mock_graph_exec.is_dry_run = False
|
||||
mock_graph_exec.organization_id = "org-rpc"
|
||||
mock_graph_exec.expert_id = None
|
||||
mock_graph_exec.team_id = "team-rpc"
|
||||
@@ -859,6 +865,7 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
|
||||
|
||||
# Mock the graph execution object
|
||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
mock_graph_exec.is_dry_run = False
|
||||
mock_graph_exec.organization_id = None
|
||||
mock_graph_exec.expert_id = None
|
||||
mock_graph_exec.team_id = None
|
||||
@@ -952,6 +959,7 @@ async def test_add_graph_execution_resume_backfills_org_from_row(mocker: MockerF
|
||||
|
||||
# Existing row carries org/team; the resume caller's context does not.
|
||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
mock_graph_exec.is_dry_run = False
|
||||
mock_graph_exec.id = "exec-resume-1"
|
||||
mock_graph_exec.node_executions = []
|
||||
mock_graph_exec.status = ExecutionStatus.QUEUED
|
||||
@@ -2138,6 +2146,8 @@ def _mock_add_graph_execution_create_path(
|
||||
mock_graph.version = 1
|
||||
|
||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
|
||||
mock_graph_exec.is_dry_run = False
|
||||
mock_graph_exec.organization_id = org_id
|
||||
mock_graph_exec.expert_id = None
|
||||
mock_graph_exec.team_id = team_id
|
||||
@@ -2229,6 +2239,8 @@ def _mock_add_graph_execution_requeue_path(
|
||||
from backend.data.execution import GraphExecutionWithNodes
|
||||
|
||||
graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
|
||||
graph_exec.is_dry_run = False
|
||||
graph_exec.id = "existing-execution"
|
||||
graph_exec.node_executions = []
|
||||
graph_exec.status = ExecutionStatus.QUEUED
|
||||
@@ -3219,3 +3231,137 @@ async def test_subgraph_inherits_the_pause_through_its_context(
|
||||
|
||||
child_context = add.await_args.kwargs["execution_context"]
|
||||
assert child_context.sensitive_action_safe_mode is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# A run carries the chat that started it, whose approval gate answers its
|
||||
# steps' review pauses (OPEN-3592).
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"trigger, dry_run, stamped",
|
||||
[
|
||||
(ExecutionTrigger.COPILOT, False, "chat-1"),
|
||||
(ExecutionTrigger.COPILOT, True, None),
|
||||
(ExecutionTrigger.MANUAL, False, None),
|
||||
],
|
||||
)
|
||||
async def test_a_new_run_is_stamped_with_the_chat_that_started_it(
|
||||
mocker: MockerFixture, trigger, dry_run: bool, stamped
|
||||
):
|
||||
mock_edb, _ = _mock_add_graph_execution_create_path(mocker)
|
||||
graph_exec = mock_edb.create_graph_execution.return_value
|
||||
graph_exec.is_dry_run = dry_run
|
||||
|
||||
await add_graph_execution(
|
||||
graph_id="g",
|
||||
user_id="u",
|
||||
trigger=trigger,
|
||||
trigger_ref="chat-1",
|
||||
dry_run=dry_run,
|
||||
pause_irreversible_actions=True,
|
||||
)
|
||||
|
||||
context = graph_exec.to_graph_execution_entry.call_args.kwargs["execution_context"]
|
||||
assert context.approval_chat_id == stamped
|
||||
# The workflow's own setting is kept apart from the pause the chat forced.
|
||||
assert context.sensitive_action_safe_mode is True
|
||||
assert context.graph_sensitive_action_safe_mode is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"trigger, stamped",
|
||||
[(ExecutionTrigger.SUBGRAPH, "chat-1"), (ExecutionTrigger.MANUAL, None)],
|
||||
)
|
||||
async def test_only_a_sub_graph_inherits_its_parents_chat(
|
||||
mocker: MockerFixture, trigger, stamped
|
||||
):
|
||||
from backend.data.execution import ExecutionContext
|
||||
|
||||
mock_edb, _ = _mock_add_graph_execution_create_path(mocker)
|
||||
graph_exec = mock_edb.create_graph_execution.return_value
|
||||
graph_exec.is_dry_run = False
|
||||
|
||||
await add_graph_execution(
|
||||
graph_id="g",
|
||||
user_id="u",
|
||||
trigger=trigger,
|
||||
trigger_ref="parent-exec",
|
||||
execution_context=ExecutionContext(
|
||||
approval_chat_id="chat-1",
|
||||
parent_execution_id="parent-exec",
|
||||
sensitive_action_safe_mode=True,
|
||||
graph_sensitive_action_safe_mode=False,
|
||||
),
|
||||
)
|
||||
|
||||
context = graph_exec.to_graph_execution_entry.call_args.kwargs["execution_context"]
|
||||
assert context.approval_chat_id == stamped
|
||||
assert context.graph_sensitive_action_safe_mode is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("nested", [False, True])
|
||||
async def test_a_resumed_run_is_stamped_with_the_chat_at_the_end_of_its_chain(
|
||||
mocker: MockerFixture, nested: bool
|
||||
):
|
||||
from backend.copilot.model import ChatSessionMetadata
|
||||
from backend.data.execution import ExecutionContext
|
||||
|
||||
graph_exec, execution_store, _, captured = _mock_add_graph_execution_requeue_path(
|
||||
mocker, expert_id=None, organization_id="org", team_id="team"
|
||||
)
|
||||
graph_exec.is_dry_run = False
|
||||
if nested:
|
||||
graph_exec.trigger_source = ExecutionTrigger.SUBGRAPH.value
|
||||
graph_exec.trigger_ref = "parent-exec"
|
||||
execution_store.get_graph_execution_meta = mocker.AsyncMock(
|
||||
return_value=mocker.MagicMock(
|
||||
trigger_source=ExecutionTrigger.COPILOT.value, trigger_ref="session-1"
|
||||
)
|
||||
)
|
||||
else:
|
||||
graph_exec.trigger_source = ExecutionTrigger.COPILOT.value
|
||||
graph_exec.trigger_ref = "session-1"
|
||||
chat_store = mocker.MagicMock()
|
||||
chat_store.get_chat_session_metadata = mocker.AsyncMock(
|
||||
return_value=mocker.MagicMock(
|
||||
metadata=ChatSessionMetadata(origin="interactive")
|
||||
)
|
||||
)
|
||||
mocker.patch("backend.executor.utils.chat_db", return_value=chat_store)
|
||||
|
||||
# As the review route resumes it: a context rebuilt from the graph settings.
|
||||
await add_graph_execution(
|
||||
graph_id="g",
|
||||
user_id="u",
|
||||
graph_exec_id="existing-execution",
|
||||
execution_context=ExecutionContext(sensitive_action_safe_mode=False),
|
||||
)
|
||||
|
||||
assert captured["execution_context"].approval_chat_id == "session-1"
|
||||
assert captured["execution_context"].graph_sensitive_action_safe_mode is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_resumed_sub_graph_whose_parent_is_gone_names_no_chat(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
graph_exec, execution_store, _, captured = _mock_add_graph_execution_requeue_path(
|
||||
mocker, expert_id=None, organization_id="org", team_id="team"
|
||||
)
|
||||
graph_exec.is_dry_run = False
|
||||
graph_exec.trigger_source = ExecutionTrigger.SUBGRAPH.value
|
||||
graph_exec.trigger_ref = "deleted-parent"
|
||||
execution_store.get_graph_execution_meta = mocker.AsyncMock(return_value=None)
|
||||
|
||||
await add_graph_execution(
|
||||
graph_id="g", user_id="u", graph_exec_id="existing-execution"
|
||||
)
|
||||
|
||||
context = captured["execution_context"]
|
||||
assert context.approval_chat_id is None
|
||||
assert context.sensitive_action_safe_mode is True
|
||||
@@ -73,6 +73,10 @@ class Flag(str, Enum):
|
||||
# ungated behaviour, which is why it defaults False rather than
|
||||
# fail-closed. Failures *inside* an enabled gate fail to ASK.
|
||||
COPILOT_AUTO_MODE = "copilot-auto-mode"
|
||||
# The gate answers the review pauses of workflows AutoPilot started, and
|
||||
# its yes satisfies a direct block call's own review. A rollout control
|
||||
# that defaults ON by decision; off restores the two approval layers.
|
||||
COPILOT_UNIFIED_APPROVALS = "copilot-unified-approvals"
|
||||
# Self-distilled skills registry (store_skill / read_skill /
|
||||
# delete_skill / list_skills + the per-turn <available_skills>
|
||||
# context block). Default-on — flip off in LaunchDarkly to disable
|
||||
|
||||
+2
@@ -462,3 +462,5 @@ export const RealPostToX = realStory("Post To X");
|
||||
export const RealWorkflow = realStory("Workflow");
|
||||
export const RealSandboxCommand = realStory("Sandbox Command");
|
||||
export const RealGmailNewsletter = realStory("Gmail Newsletter");
|
||||
// A step of a workflow Otto started, parked on its own review: its card in the chat.
|
||||
export const RealWorkflowStep = realStory("Workflow Step");
|
||||
+46
-1
@@ -5,7 +5,14 @@ import { server } from "@/mocks/mock-server";
|
||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
||||
import { CopilotChatActionsProvider } from "../../CopilotChatActionsProvider/CopilotChatActionsProvider";
|
||||
import { CopilotPendingReviews } from "../../CopilotPendingReviews/CopilotPendingReviews";
|
||||
import { createAgent, mail, mcpTool, CHAT_SESSION } from "./fixtures";
|
||||
import {
|
||||
createAgent,
|
||||
mail,
|
||||
mcpTool,
|
||||
realCardSchemaHandler,
|
||||
realCards,
|
||||
CHAT_SESSION,
|
||||
} from "./fixtures";
|
||||
|
||||
function serve(
|
||||
review: ReturnType<typeof mail> = mail("m1", ["allow", "judge"]),
|
||||
@@ -154,3 +161,41 @@ test.each([
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
// The card the backend builds for a step of a workflow Otto started.
|
||||
const STEP = realCards().find((card) => card.story === "Workflow Step")!.review;
|
||||
|
||||
test.each([
|
||||
["This chat", "chat", `Approve ${MAIL} from now on`, "allow"],
|
||||
["Otto, every chat", "expert", `Let Otto judge ${MAIL} from now on`, "judge"],
|
||||
["Every Expert on my team", "team", `Approve ${MAIL} from now on`, "allow"],
|
||||
[
|
||||
"Every Expert on my team",
|
||||
"team",
|
||||
`Let Otto judge ${MAIL} from now on`,
|
||||
"judge",
|
||||
],
|
||||
])(
|
||||
"a workflow step's card sets a rule as an action's does: %s, %s",
|
||||
async (label, scope, item, rule) => {
|
||||
const sent = serve(STEP);
|
||||
server.use(realCardSchemaHandler(realCards()));
|
||||
renderQueue(null);
|
||||
|
||||
await openMenu();
|
||||
await userEvent.click(
|
||||
await screen.findByRole("menuitemradio", { name: label }),
|
||||
);
|
||||
await userEvent.click(await screen.findByText(item));
|
||||
|
||||
await waitFor(() => expect(sent).toHaveLength(1));
|
||||
expect(sent[0].reviews).toEqual([
|
||||
expect.objectContaining({
|
||||
node_exec_id: STEP.node_exec_id,
|
||||
approved: true,
|
||||
chat_rule: rule,
|
||||
chat_rule_scope: scope,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
+208
-8
@@ -62,7 +62,8 @@
|
||||
"object": "Gmail Send",
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "Gmail Send",
|
||||
"editable": false,
|
||||
@@ -252,7 +253,8 @@
|
||||
"object": "Google Sheets Update Row",
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "Google Sheets Update Row",
|
||||
"editable": false,
|
||||
@@ -475,7 +477,8 @@
|
||||
"object": "Execute Code Step",
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "Execute Code Step",
|
||||
"editable": false,
|
||||
@@ -628,7 +631,8 @@
|
||||
"object": "Send Web Request",
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "Send Web Request",
|
||||
"editable": false,
|
||||
@@ -775,7 +779,8 @@
|
||||
"object": "Post To X",
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "Post To X",
|
||||
"editable": false,
|
||||
@@ -1124,7 +1129,8 @@
|
||||
"object": "Gmail Send",
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "Gmail Send",
|
||||
"editable": false,
|
||||
@@ -1299,7 +1305,8 @@
|
||||
"object": "Morning digest",
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "Morning digest",
|
||||
"editable": false,
|
||||
@@ -1358,12 +1365,205 @@
|
||||
"object": null,
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "Run a command in the sandbox",
|
||||
"editable": false,
|
||||
"status": "WAITING"
|
||||
},
|
||||
"schema": null
|
||||
},
|
||||
{
|
||||
"story": "Workflow Step",
|
||||
"review": {
|
||||
"node_exec_id": "copilot-node-gate-workflow_step:8381086bb449da65",
|
||||
"node_id": "copilot-node-gate-workflow_step",
|
||||
"user_id": "user-1",
|
||||
"session_id": "s1",
|
||||
"graph_exec_id": null,
|
||||
"graph_id": null,
|
||||
"graph_version": null,
|
||||
"payload": {
|
||||
"tool": "workflow_step",
|
||||
"arguments": {
|
||||
"to": ["[email protected]"],
|
||||
"cc": ["[email protected]"],
|
||||
"subject": "Q3 invoices missing PO numbers",
|
||||
"body": "Hi Dana,\n\nThe Q3 invoice pack is in the shared Invoices folder. Three of them are still missing a PO number:\n\n- INV-2041\n- INV-2044\n- INV-2051\n\nCould you look before Friday?\n\nThanks,\nOtto"
|
||||
},
|
||||
"clipped": [],
|
||||
"fields": [
|
||||
{
|
||||
"key": "to",
|
||||
"label": "To",
|
||||
"format": null
|
||||
},
|
||||
{
|
||||
"key": "cc",
|
||||
"label": "Cc",
|
||||
"format": null
|
||||
},
|
||||
{
|
||||
"key": "subject",
|
||||
"label": "Subject",
|
||||
"format": null
|
||||
},
|
||||
{
|
||||
"key": "body",
|
||||
"label": "Body",
|
||||
"format": null
|
||||
}
|
||||
],
|
||||
"references": [],
|
||||
"reference_totals": {},
|
||||
"tool_call_id": "",
|
||||
"turn": 1,
|
||||
"mode": "auto",
|
||||
"subject": {
|
||||
"kind": "block",
|
||||
"key": "block:6c27abc2-e51d-499e-a85f-5a0041ba94f0",
|
||||
"name": "Gmail Send",
|
||||
"effect": "external",
|
||||
"irreversible": true,
|
||||
"block_id": "6c27abc2-e51d-499e-a85f-5a0041ba94f0"
|
||||
},
|
||||
"reason": "A step of Morning digest, which reaches outside the platform.",
|
||||
"reason_kind": "subject",
|
||||
"decided_by": null,
|
||||
"chat_rules_allowed": ["allow", "judge"],
|
||||
"headline": {
|
||||
"ask": "Run",
|
||||
"object": "Gmail Send",
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null,
|
||||
"workflow": {
|
||||
"graph_exec_id": "run-digest",
|
||||
"node_exec_id": "node-exec-send",
|
||||
"graph_id": "g-digest",
|
||||
"graph_version": 1,
|
||||
"name": "Morning digest"
|
||||
}
|
||||
},
|
||||
"instructions": "Run “Gmail Send”",
|
||||
"editable": false,
|
||||
"status": "WAITING"
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"credentials": {
|
||||
"credentials_provider": ["google"],
|
||||
"credentials_types": ["oauth2"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Title"
|
||||
},
|
||||
"provider": {
|
||||
"const": "google",
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "oauth2",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "provider", "type"],
|
||||
"title": "CredentialsMetaInput[Literal[<ProviderName.GOOGLE: 'google'>], Literal['oauth2']]",
|
||||
"type": "object",
|
||||
"credentials_scopes": ["https://www.googleapis.com/auth/gmail.send"],
|
||||
"description": "The Google integration requires OAuth2 authentication."
|
||||
},
|
||||
"to": {
|
||||
"advanced": false,
|
||||
"description": "Recipient email addresses",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"secret": false,
|
||||
"title": "To",
|
||||
"type": "array"
|
||||
},
|
||||
"subject": {
|
||||
"advanced": false,
|
||||
"description": "Email subject",
|
||||
"secret": false,
|
||||
"title": "Subject",
|
||||
"type": "string"
|
||||
},
|
||||
"body": {
|
||||
"advanced": false,
|
||||
"description": "Email body (plain text or HTML)",
|
||||
"secret": false,
|
||||
"title": "Body",
|
||||
"type": "string"
|
||||
},
|
||||
"cc": {
|
||||
"advanced": true,
|
||||
"description": "CC recipients",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"secret": false,
|
||||
"title": "Cc",
|
||||
"type": "array"
|
||||
},
|
||||
"bcc": {
|
||||
"advanced": true,
|
||||
"description": "BCC recipients",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"secret": false,
|
||||
"title": "Bcc",
|
||||
"type": "array"
|
||||
},
|
||||
"content_type": {
|
||||
"advanced": true,
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["auto", "plain", "html"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Content type: 'auto' (default - detects HTML), 'plain', or 'html'",
|
||||
"secret": false,
|
||||
"title": "Content Type"
|
||||
},
|
||||
"attachments": {
|
||||
"advanced": true,
|
||||
"description": "Files to attach",
|
||||
"items": {
|
||||
"format": "file",
|
||||
"type": "string"
|
||||
},
|
||||
"secret": false,
|
||||
"title": "Attachments",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": ["credentials", "to", "subject", "body"],
|
||||
"title": "Input",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
+40
-22
@@ -80,7 +80,8 @@
|
||||
"object": "Q3 reports",
|
||||
"object_key": "folder_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "delete_folder",
|
||||
"editable": false,
|
||||
@@ -300,8 +301,8 @@
|
||||
}
|
||||
],
|
||||
"reference_totals": {
|
||||
"folder_id": 1,
|
||||
"agent_ids": 7
|
||||
"agent_ids": 7,
|
||||
"folder_id": 1
|
||||
},
|
||||
"tool_call_id": "call-move_agents_to_folder",
|
||||
"turn": 1,
|
||||
@@ -323,7 +324,8 @@
|
||||
"object": "Archive",
|
||||
"object_key": "folder_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "move_agents_to_folder",
|
||||
"editable": false,
|
||||
@@ -411,7 +413,8 @@
|
||||
"object": "Daily digest",
|
||||
"object_key": "schedule_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "pause_schedule",
|
||||
"editable": false,
|
||||
@@ -498,7 +501,8 @@
|
||||
"object": "Ada",
|
||||
"object_key": "name"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "hire_expert",
|
||||
"editable": false,
|
||||
@@ -586,7 +590,8 @@
|
||||
"object": "Q3 planning",
|
||||
"object_key": "session_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "message_session",
|
||||
"editable": false,
|
||||
@@ -696,7 +701,8 @@
|
||||
"object": "GitHub (work)",
|
||||
"object_key": "credential_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "grant_expert_credential",
|
||||
"editable": false,
|
||||
@@ -784,7 +790,8 @@
|
||||
"object": "Morning digest",
|
||||
"object_key": "agent_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "edit_agent",
|
||||
"editable": false,
|
||||
@@ -860,7 +867,8 @@
|
||||
"object": "Weekly digest",
|
||||
"object_key": "preset_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "update_preset",
|
||||
"editable": false,
|
||||
@@ -936,7 +944,8 @@
|
||||
"object": "New invoice email",
|
||||
"object_key": "preset_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "delete_preset",
|
||||
"editable": false,
|
||||
@@ -1012,7 +1021,8 @@
|
||||
"object": "Month-end close",
|
||||
"object_key": "routine_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "schedule_routine",
|
||||
"editable": false,
|
||||
@@ -1105,8 +1115,8 @@
|
||||
}
|
||||
],
|
||||
"reference_totals": {
|
||||
"store_listing_version_id": 1,
|
||||
"expert_id": 1
|
||||
"expert_id": 1,
|
||||
"store_listing_version_id": 1
|
||||
},
|
||||
"tool_call_id": "call-install_expert_workflow",
|
||||
"turn": 1,
|
||||
@@ -1128,7 +1138,8 @@
|
||||
"object": null,
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "install_expert_workflow",
|
||||
"editable": false,
|
||||
@@ -1197,7 +1208,8 @@
|
||||
"object": "Receipt matcher",
|
||||
"object_key": "workflow_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "remove_expert_workflow",
|
||||
"editable": false,
|
||||
@@ -1279,7 +1291,8 @@
|
||||
"object": "Grace",
|
||||
"object_key": "confirmation_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "confirm_expert_change",
|
||||
"editable": false,
|
||||
@@ -1379,7 +1392,8 @@
|
||||
"object": "/reports/q3-report.pdf",
|
||||
"object_key": "path"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "delete_workspace_file",
|
||||
"editable": false,
|
||||
@@ -1448,7 +1462,8 @@
|
||||
"object": null,
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "delete_preset",
|
||||
"editable": false,
|
||||
@@ -1542,7 +1557,8 @@
|
||||
"object": null,
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "schedule_followup",
|
||||
"editable": false,
|
||||
@@ -1600,7 +1616,8 @@
|
||||
"object": null,
|
||||
"object_key": null
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "schedule_followup",
|
||||
"editable": false,
|
||||
@@ -1688,7 +1705,8 @@
|
||||
"object": "Ada",
|
||||
"object_key": "expert_id"
|
||||
},
|
||||
"spend": null
|
||||
"spend": null,
|
||||
"workflow": null
|
||||
},
|
||||
"instructions": "delegate_to_expert",
|
||||
"editable": false,
|
||||
|
||||
Reference in new issue
Block a user