Merge pull request #262 from Molecule-AI/feat/plugin-molecule-hitl

feat(plugin): molecule-hitl — opt-in HITL gates (#257)
This commit is contained in:
Hongming Wang 2026-04-15 14:03:44 -07:00 committed by GitHub
commit 22e008aeef
2 changed files with 154 additions and 0 deletions

View File

@ -0,0 +1,24 @@
name: molecule-hitl
version: 1.0.0
description: >
Human-in-the-loop gates for any async callable. Wraps builtin_tools/hitl.py:
@requires_approval decorator, pause_task/resume_task tools, multi-channel
notification (dashboard/Slack/email), RBAC bypass roles. Opt-in per workspace.
author: Molecule AI
tags: [hitl, approvals, human-in-the-loop, safety]
# Runtimes that can use this plugin. The Python primitives in hitl.py are
# LangChain-based, so LangGraph + Claude Code (which wraps LangChain tools)
# are the direct consumers. DeepAgents also embeds LangChain.
runtimes:
- langgraph
- claude_code
- deepagents
# Skills shipped by the plugin — a single "hitl-gates" skill that tells the
# agent WHEN to call request_approval / pause_task / resume_task. The
# implementation lives in workspace-template/builtin_tools/hitl.py (already
# in every image) — this plugin is the opt-in policy layer that activates
# the decorator pattern for specific roles.
skills:
- hitl-gates

View File

@ -0,0 +1,130 @@
---
name: hitl-gates
description: "Gate irreversible actions behind a human approval request. Use when an async callable (tool, method, or standalone function) performs a destructive or public action: deployment, deletion, outbound message, or issue/PR creation. Prevents unattended agents from shipping destructive work."
---
# HITL Gates
Human-in-the-loop gates for any async callable. Wraps the `@requires_approval`
decorator and `pause_task` / `resume_task` tools from
`builtin_tools/hitl.py`, which are already present in every runtime image.
This skill is the opt-in policy layer that tells an agent *when* to call
them — the Python implementation is always available; only workspaces that
install this plugin consult the policy.
## When to use a gate
Always, before any of these classes of action:
| Class | Examples |
|---|---|
| **Deployment** | `fly deploy`, `docker push`, kubectl apply, Vercel deploy |
| **Irreversible filesystem** | `rm -rf`, `git push --force`, DB `DROP TABLE`, `TRUNCATE` |
| **Public / external message** | Opening a GitHub issue or PR, posting to Slack, sending an email, posting on social media |
| **Production mutation** | Database migration against prod, secret rotation, cache invalidation that affects users |
| **Cross-workspace destructive** | Deleting another agent's memories, removing another workspace, cancelling another agent's delegations |
Reversible, scoped-to-self actions (editing local files, running tests,
reading documentation, saving memories to your own namespace) do **not**
need a gate.
## Usage — decorator form
For any async callable you own, wrap it in `@requires_approval`:
```python
from builtin_tools.hitl import requires_approval
@requires_approval(
action="deploy_production",
reason="Fly deploy to molecule-cp — affects all tenants",
timeout=300,
bypass_roles=["operator"],
)
async def deploy_fly_machine(app: str, image: str) -> dict:
...
```
What happens at call time:
1. The decorator fires `notify_humans(action, reason)` via the channels
configured under `hitl:` in `config.yaml` (dashboard approval + optional
Slack/email).
2. The caller's task is paused until a human clicks approve/deny or the
`timeout` expires.
3. Timeout → rejected → raises `HITLRejectedError`. Caller handles it.
4. Approved → the wrapped function runs normally.
5. If the caller's role is in `bypass_roles`, the gate is skipped entirely
(useful for an `operator` role that's already human-driven).
## Usage — explicit pause/resume
For cases where the decorator pattern is awkward (multi-step workflows
where the pause point is dynamic), use the pause/resume tools directly:
```python
from builtin_tools.hitl import pause_task, resume_task
task_id = await pause_task(
task_id="deploy-abc",
reason="About to run destructive migration 0042",
timeout=600,
)
# External signal wakes us up:
# - dashboard click
# - another agent calling resume_task("deploy-abc", decision="approved")
# - timeout → resumes with decision="timeout"
outcome = await resume_task(task_id) # blocks until resolved
if outcome.decision != "approved":
return {"status": "cancelled", "reason": outcome.decision}
```
## Configuration
Add to `config.yaml`:
```yaml
hitl:
channels:
- type: dashboard # always on — uses the platform approval API
- type: slack
webhook_url: ${SLACK_HITL_WEBHOOK}
default_timeout: 300 # seconds
bypass_roles: [operator] # roles that skip the gate entirely
```
Secrets referenced via `${ENV_VAR}` come from the workspace's secrets
store (set via `POST /workspaces/:id/secrets`).
## Anti-patterns
- **Don't** wrap read-only tools. A gate on `read_file` just annoys humans.
- **Don't** call `request_approval` from inside a cron tick — the human
can't approve in time and the tick times out. Cron-fired actions should
defer destructive steps to a follow-up task the human can approve.
- **Don't** rely on `molecule-careful-bash` + HITL together for the same
action. HITL is the policy layer; careful-bash is the harness-level
safety net. Pick one per call site or they double-prompt.
- **Don't** set a `timeout` shorter than ~60s. Humans need time to see the
notification and context-switch.
## Test plan
1. Install this plugin on a workspace: `POST /workspaces/:id/plugins` with
`{"source": "builtin://molecule-hitl"}`.
2. Configure `hitl.channels` + `bypass_roles` in the workspace's
`config.yaml`.
3. Ask the agent to perform a gated action; verify a pending approval
appears in `GET /approvals/pending`.
4. Approve via the canvas approval banner; verify the agent resumes and
completes the action.
5. Deny via the canvas; verify the agent raises `HITLRejectedError` and
responds with a graceful cancellation.
## Related
- `builtin_tools/hitl.py` — the implementation this plugin activates
- `builtin_tools/approval.py` — the lower-level approval store
- `molecule-careful-bash` — harness-level bash REFUSE list (complementary,
not a replacement for HITL on non-bash actions)
- Issue #257 — the proposal that led to this plugin