Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions backend/routers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
from models import Artifact, Experiment, LogEvent, Message, Metric, Task
from models import Session as SessionModel
from schemas import (
ApprovalReply,
ClarificationReply,
MessageCreate,
SessionResume,
TaskCreate,
TaskUpdate,
)
from services import approvals as approvals_svc
from services.agent import abort_agent, run_agent
from services.agent.resume import (
build_resume_context,
Expand Down Expand Up @@ -148,6 +150,11 @@ async def send_message(
if session.experiment and session.experiment.project:
sandbox_config = session.experiment.project.sandbox_config or {}

# HITL approval gates (issue #108): assert the per-session flag from
# the message's toggle state on every launch. Omitted/False keeps the
# default (gates off) — the runner's injection is a no-op then.
approvals_svc.set_enabled(session_id, bool(body.approvals))

# Mark the session "running" eagerly so the sidebar spinner + the
# restore-on-tab-switch path both see the live state. Completion /
# failure / cancellation paths below overwrite this.
Expand Down Expand Up @@ -374,6 +381,41 @@ async def reply_to_clarification(
return {"status": "ok"}


@router.post("/sessions/{session_id}/approvals/{approval_id}")
async def reply_to_approval(
session_id: str,
approval_id: str,
body: ApprovalReply,
):
"""User verdict on a pending approval gate — unblocks the waiting agent.

The `approval_resolved` SSE/persistence event is published by the skill
handler once its future resolves, so this route only resolves the future.
"""
edits = body.edits.strip()
if body.decision == "edit" and not edits:
raise HTTPException(
status_code=400,
detail="decision='edit' requires non-empty edits.",
)
answered = resolve_clarification(
session_id,
approval_id,
{
"decision": body.decision,
"answer": edits,
"answered_by": "user",
"timeout": False,
},
)
if not answered:
raise HTTPException(
status_code=404,
detail="No pending approval with that approval_id (already answered or expired).",
)
return {"status": "ok", "decision": body.decision}


@router.get("/sessions/{session_id}/artifacts")
async def get_artifacts(session_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Artifact).where(Artifact.session_id == session_id))
Expand Down
12 changes: 12 additions & 0 deletions backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,24 @@ class MessageCreate(BaseModel):
# services/llm/thinking.py. Ignored for models that don't support it.
agent_thinking: Optional[dict[str, str]] = Field(default=None)
mentions: Optional[list[Mention]] = Field(default=None, max_length=64)
# HITL approval gates (issue #108). Opt-in: when true, agents launched by
# this message gain the `request-approval` skill and block consequential
# decisions on user Approve/Edit. Omitted/None/False → default behavior.
approvals: Optional[bool] = Field(default=None)


class ClarificationReply(BaseModel):
answer: str = Field(..., max_length=_CLARIFICATION_MAX)


class ApprovalReply(BaseModel):
"""User verdict on a pending approval gate (issue #108)."""

decision: Literal["approve", "edit"]
# Required (non-empty) when decision == "edit" — enforced in the route.
edits: str = Field(default="", max_length=_CLARIFICATION_MAX)


class SessionResume(BaseModel):
"""Body for POST /sessions/{id}/resume. Everything is optional — the
default is 'relaunch with the same knobs the session already has'."""
Expand Down
11 changes: 11 additions & 0 deletions backend/services/agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,17 @@ async def _publish(
if "delegate-task" in agent_skills and not can_delegate(agent_type, depth):
agent_skills = [s for s in agent_skills if s != "delegate-task"]

# HITL approval gates (issue #108): opt-in per session. When the flag
# is off this is an identity call — skills and prompt come back
# unchanged, so the default path is untouched. Applied per agent run,
# so delegated sub-agents inherit the gate through the shared
# session_id without any parameter threading.
from services.approvals import apply_approval_gate

agent_skills, system_prompt = apply_approval_gate(
session_id, agent_skills, system_prompt
)

logger.info(
"Starting agent=%s id=%s parent=%s stage=%s session=%s provider=%s model=%s depth=%d skills=%s",
agent_type,
Expand Down
80 changes: 80 additions & 0 deletions backend/services/approvals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Human-in-the-loop approval gates (issue #108).

Opt-in, per-session: when the user switches approvals on (a toggle in the
chat input, sent with each run-triggering message), every agent run in the
session gains the `request-approval` capability skill plus a system-prompt
block telling it to post consequential decisions (target column, prep /
leakage plan, model shortlist, expensive runs) as an actionable card and
BLOCK until the user approves or edits.

The blocking future itself is services.clarifications — approvals reuse the
same (session_id, question_id) registry, timeout machinery, and
session-cleanup cancellation. This module only owns the enable flag and the
skill/prompt injection applied by the runner.

Default behavior is byte-identical when the flag is off: `apply_approval_gate`
returns its inputs unchanged.
"""

from __future__ import annotations

APPROVAL_SKILL_SLUG = "request-approval"

APPROVAL_GATE_PROMPT = """## Approval gates (ENABLED for this session)

The user switched on human-in-the-loop approvals. Before COMMITTING to any
consequential decision, call `request-approval` with the decision and block
on the result. Consequential decisions are:

- the prediction **target column** (and problem framing),
- the **data-prep / leakage-handling plan** (drops, imputations, split
strategy, leakage mitigations),
- the **model shortlist** or final model/hyperparameter-search choice,
- anything that starts a long or expensive run (training sweeps, GPU jobs).

Rules:
- Call `request-approval` BEFORE acting on the decision, never after.
- If the result is `approved`, proceed exactly as proposed.
- If the result is `edited`, the user's revision REPLACES your proposal —
follow it exactly.
- If the approval times out with no response, proceed with your proposal but
clearly flag in your report that it was not explicitly approved.
- Don't gate trivial choices (plot styles, file names, obvious defaults) —
one gate per consequential decision, not per step."""

# Sessions with approval gates switched on. In-memory by design (mirrors the
# clarification futures it gates on): the flag is re-asserted by the frontend
# on every run-triggering message, so a backend restart just falls back to
# the default-off state until the next message.
_enabled_sessions: set[str] = set()


def set_enabled(session_id: str, enabled: bool) -> None:
"""Assert the approval-gate flag for a session (called per run launch)."""
if enabled:
_enabled_sessions.add(session_id)
else:
_enabled_sessions.discard(session_id)


def is_enabled(session_id: str) -> bool:
return session_id in _enabled_sessions


def apply_approval_gate(
session_id: str, agent_skills: list[str], system_prompt: str
) -> tuple[list[str], str]:
"""Inject the approval skill + prompt block when the session opts in.

When the flag is off this is an identity function — the returned list has
the same contents and the returned prompt is the same string object, so
the default path is provably unchanged.
"""
if not is_enabled(session_id):
return agent_skills, system_prompt
skills = (
agent_skills
if APPROVAL_SKILL_SLUG in agent_skills
else [*agent_skills, APPROVAL_SKILL_SLUG]
)
return skills, system_prompt + "\n\n" + APPROVAL_GATE_PROMPT
33 changes: 33 additions & 0 deletions backend/skills/request-approval/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
name: request-approval
description: >
Post a consequential decision (target column, prep/leakage plan, model
shortlist, expensive run) to the user as an actionable approval card and
BLOCK until they Approve or Edit it.
when_to_use: >
Only available when the session has approval gates switched on. Call it
BEFORE committing to a consequential decision — never after acting on it.
version: '0.1'
kind: capability
---

# request-approval

Human-in-the-loop approval gate. Renders an actionable card in the studio
chat with your proposed decision; your run blocks until the user clicks
**Approve** or submits an **Edit** (or the approval window times out).

## Arguments

- `title` — short label for the card, e.g. "Target column" or "Prep plan".
- `decision` — the concrete decision you propose, stated so the user can
approve it as-is. Markdown allowed.
- `kind` — one of `target_column`, `prep_plan`, `model_shortlist`, `other`.
- `context` (optional) — why you chose this, alternatives you rejected.

## Result contract

- `approved` — proceed exactly as proposed.
- `edited` — the user's revision replaces your proposal; follow it exactly.
- `timeout` — no response within the window; proceed with your proposal but
flag in your report that it was not explicitly approved.
Loading