Skip to content

Commit 92f41e9

Browse files
committed
Add human-in-the-loop approval for destructive tool calls
- Introduce ApprovalRequest dataclass and ApprovalHandler callback type - In prompt permission mode, pause the run with status "pending_approval" when no handler is configured, recording the approval request in result metadata for resume - Add --hitl-approval CLI flag that prompts y/N on stderr for each unapproved destructive call - TUI always attaches an approval handler that prompts inline and records the approved call_id for the session - Emit tool_call_pending_approval, tool_call_approved, tool_call_denied, and run_paused audit events through the lifecycle - Track pending_approval status in RunStore heartbeat and summarize
1 parent 0eb6af6 commit 92f41e9

12 files changed

Lines changed: 329 additions & 43 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export OPENCODEZEN_API_KEY=...
7070
|------|----------|
7171
| `read-only` | Blocks all destructive tools |
7272
| `workspace-write` | Allows file writes; blocks shell mutation |
73-
| `prompt` | Destructive tools require approval token |
73+
| `prompt` | Destructive tools pause for HITL approval or require an approval token |
7474
| `allow` | Allows destructive tools for the session |
7575
| `danger-full-access` | Full access; reserve for trusted automation |
7676

docs/cli.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,14 @@ teaagent agent run gpt "Create a TODO.md summary" --approve-call-id write-todo-1
384384

385385
The model decision must use the approved `call_id` for that exact destructive tool call. Other destructive calls remain blocked.
386386

387+
For interactive HITL approval during a CLI run, use:
388+
389+
```bash
390+
teaagent agent run gpt "Create a TODO.md summary" --hitl-approval
391+
```
392+
393+
Without `--hitl-approval`, an unapproved destructive tool in `prompt` mode returns `pending_approval` with the required `call_id`. Re-run with `--approve-call-id <call_id>` or use `agent resume` with the same approval token.
394+
387395
Prefer explicit permission modes for regular use:
388396

389397
```bash
@@ -397,7 +405,7 @@ Permission modes:
397405

398406
- `read-only`: blocks every destructive tool.
399407
- `workspace-write`: allows file write/patch/hash-edit tools, blocks shell mutation.
400-
- `prompt`: destructive tools require an approval token.
408+
- `prompt`: destructive tools pause for HITL approval or require an approval token.
401409
- `allow`: allows destructive tools for the session.
402410
- `danger-full-access`: allows destructive tools; reserve for trusted automation.
403411

@@ -431,6 +439,7 @@ teaagent agent resume gpt <run_id> --root /path/to/repo --approve-call-id write-
431439
Inside TUI:
432440

433441
```text
442+
ask write TODO.md # prompts y/N when a destructive call is proposed
434443
approve write-todo-1
435444
approvals
436445
unapprove write-todo-1

teaagent/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@
5555
from teaagent.rag import Document, InMemoryRetriever, agentic_retrieve
5656
from teaagent.readiness import ReadinessReport, assess_managed_agent_readiness
5757
from teaagent.run_store import RunStore, RunSummary
58-
from teaagent.runner import AgentRunner, Decision, FinalAnswer, ToolRequest
58+
from teaagent.runner import (
59+
AgentRunner,
60+
ApprovalRequest,
61+
Decision,
62+
FinalAnswer,
63+
ToolRequest,
64+
)
5965
from teaagent.skill_review import SkillReviewResult, review_skill
6066
from teaagent.stateless_mcp import (
6167
StatelessMCPRequest,
@@ -75,6 +81,7 @@
7581
"AIBOMManifest",
7682
"AgentRunner",
7783
"ApprovalPolicy",
84+
"ApprovalRequest",
7885
"AuditLogger",
7986
"ChatAgentConfig",
8087
"ClarificationResult",

teaagent/chat_agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
parse_model_decision,
2020
)
2121
from teaagent.run_store import RunStore
22-
from teaagent.runner import AgentRunner, Decision, RunResult
22+
from teaagent.runner import AgentRunner, ApprovalHandler, Decision, RunResult
2323
from teaagent.tools import ToolAnnotations, ToolRegistry
2424
from teaagent.workspace_tools import build_workspace_tool_registry
2525

@@ -39,6 +39,7 @@ class ChatAgentConfig:
3939
heartbeat_seconds: float = 0.0
4040
stream: bool = False
4141
on_chunk: Optional[Callable[[str], None]] = None
42+
approval_handler: Optional[ApprovalHandler] = None
4243

4344
@classmethod
4445
def from_root(cls, root: str | Path, **kwargs) -> "ChatAgentConfig":
@@ -121,6 +122,7 @@ def run_chat_agent(
121122
allow_all_destructive=config.allow_destructive,
122123
permission_mode=config.permission_mode,
123124
),
125+
approval_handler=config.approval_handler,
124126
compactor=ContextCompactor(memory_keys=("task_spec", "memories")),
125127
)
126128
run_id = uuid4().hex

teaagent/cli.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import argparse
44
import json
5+
import sys
56
from typing import Any, Optional
67

78
from teaagent import __version__
@@ -25,6 +26,7 @@
2526
from teaagent.policy import PermissionMode, parse_permission_mode
2627
from teaagent.preflight import preflight
2728
from teaagent.run_store import RunStore
29+
from teaagent.runner import ApprovalRequest, RunResult
2830
from teaagent.tui import run_tui
2931
from teaagent.ultrawork import UltraworkStore
3032
from teaagent.workspace_tools import build_workspace_tool_registry
@@ -104,6 +106,11 @@ def build_parser() -> argparse.ArgumentParser:
104106
default=[],
105107
help="Approve one exact destructive tool call id. Can be repeated.",
106108
)
109+
agent_run.add_argument(
110+
"--hitl-approval",
111+
action="store_true",
112+
help="Prompt before executing unapproved destructive tool calls in prompt permission mode.",
113+
)
107114
agent_run.add_argument(
108115
"--permission-mode",
109116
choices=[mode.value for mode in PermissionMode],
@@ -166,6 +173,7 @@ def build_parser() -> argparse.ArgumentParser:
166173
default=[],
167174
help="Approve one exact destructive tool call id. Can be repeated.",
168175
)
176+
agent_resume.add_argument("--hitl-approval", action="store_true", help="Prompt before unapproved destructive tool calls.")
169177
agent_resume.add_argument(
170178
"--permission-mode",
171179
choices=[mode.value for mode in PermissionMode],
@@ -364,6 +372,7 @@ def _execute_agent_task(args: argparse.Namespace, task: str, *, resumed_from: Op
364372
adapter = create_llm_adapter(args.provider, model=selected_model)
365373
store = RunStore(args.root)
366374
audit = store.audit_logger()
375+
approval_handler = cli_approval_handler if args.hitl_approval else None
367376
result = run_chat_agent(
368377
task=task,
369378
adapter=adapter,
@@ -378,24 +387,42 @@ def _execute_agent_task(args: argparse.Namespace, task: str, *, resumed_from: Op
378387
enable_subagent=args.subagent,
379388
max_subagent_depth=args.max_subagent_depth,
380389
heartbeat_seconds=args.heartbeat,
390+
approval_handler=approval_handler,
381391
),
382392
audit=audit,
383393
task_spec=task_spec,
384394
)
385395
store.logger_for_result(result, audit)
396+
payload = run_result_payload(result, routing=routing.to_dict() if routing else None)
397+
if resumed_from:
398+
payload["resumed_from"] = resumed_from
399+
payload["task"] = task
400+
print_json(payload)
401+
return 0 if result.status == "completed" else 1
402+
403+
404+
def run_result_payload(result: RunResult, *, routing: Optional[dict[str, Any]]) -> dict[str, Any]:
386405
payload: dict[str, Any] = {
387406
"run_id": result.run_id,
388407
"status": result.status,
389408
"iterations": result.iterations,
390409
"tool_calls": result.tool_calls,
391-
"routing": routing.to_dict() if routing else None,
410+
"routing": routing,
392411
"final_answer": result.final_answer.content if result.final_answer else None,
393412
}
394-
if resumed_from:
395-
payload["resumed_from"] = resumed_from
396-
payload["task"] = task
397-
print_json(payload)
398-
return 0 if result.status == "completed" else 1
413+
if "approval" in result.metadata:
414+
payload["approval"] = result.metadata["approval"]
415+
return payload
416+
417+
418+
def cli_approval_handler(request: ApprovalRequest) -> bool:
419+
print(
420+
json.dumps({"status": "approval_required", "approval": request.to_dict()}, sort_keys=True),
421+
file=sys.stderr,
422+
)
423+
print(f"Approve destructive tool call {request.call_id} ({request.tool_name})? [y/N] ", end="", file=sys.stderr)
424+
answer = input()
425+
return answer.strip().lower() in {"y", "yes"}
399426

400427

401428
def agent_preflight_command(args: argparse.Namespace) -> int:

teaagent/run_store.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ def heartbeat_for_run(self, run_id: str) -> dict[str, Any]:
8383
last_heartbeat = event
8484
elif event_type in {"run_completed", "run_failed"}:
8585
terminal_status = "completed" if event_type == "run_completed" else f"failed:{event.get('payload', {}).get('category', 'unknown')}"
86+
elif event_type == "run_paused":
87+
terminal_status = event.get("payload", {}).get("status", "paused")
8688
return {
8789
"run_id": run_id,
8890
"status": terminal_status or "running",
@@ -118,6 +120,8 @@ def summarize(self, path: Path) -> Optional[RunSummary]:
118120
final_answer = payload.get("answer")
119121
elif event_type == "run_failed":
120122
status = f"failed:{payload.get('category', 'unknown')}"
123+
elif event_type == "run_paused":
124+
status = payload.get("status", "paused")
121125
return RunSummary(
122126
run_id=run_id,
123127
task=task,

teaagent/runner.py

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
ErrorCategory,
1515
ToolPermissionError,
1616
)
17-
from teaagent.policy import ApprovalPolicy
17+
from teaagent.policy import ApprovalPolicy, PermissionMode
1818
from teaagent.tools import ToolRegistry
1919

2020

@@ -35,13 +35,35 @@ class FinalAnswer:
3535
DecisionFn = Callable[[dict[str, Any]], Decision]
3636

3737

38+
@dataclass(frozen=True)
39+
class ApprovalRequest:
40+
call_id: str
41+
tool_name: str
42+
arguments: dict[str, Any]
43+
reason: str
44+
annotations: dict[str, bool]
45+
46+
def to_dict(self) -> dict[str, Any]:
47+
return {
48+
"call_id": self.call_id,
49+
"tool_name": self.tool_name,
50+
"arguments": self.arguments,
51+
"reason": self.reason,
52+
"annotations": self.annotations,
53+
}
54+
55+
56+
ApprovalHandler = Callable[[ApprovalRequest], bool]
57+
58+
3859
@dataclass(frozen=True)
3960
class RunResult:
4061
run_id: str
4162
final_answer: Optional[FinalAnswer]
4263
iterations: int
4364
tool_calls: int
4465
status: str
66+
metadata: dict[str, Any] = field(default_factory=dict)
4567

4668

4769
class AgentRunner:
@@ -52,6 +74,7 @@ def __init__(
5274
audit: AuditLogger,
5375
budget: Optional[RunBudget] = None,
5476
approval_policy: Optional[ApprovalPolicy] = None,
77+
approval_handler: Optional[ApprovalHandler] = None,
5578
compactor: Optional[ContextCompactor] = None,
5679
compact_after_observations: int = 20,
5780
) -> None:
@@ -60,6 +83,7 @@ def __init__(
6083
self.budget = budget or RunBudget()
6184
self.budget.validate()
6285
self.approval_policy = approval_policy or ApprovalPolicy()
86+
self.approval_handler = approval_handler
6387
self.compactor = compactor
6488
self.compact_after_observations = compact_after_observations
6589

@@ -103,38 +127,76 @@ def run(self, *, task: str, decide: DecisionFn, run_id: Optional[str] = None) ->
103127
raise BudgetExceededError("tool-call budget exceeded")
104128

105129
tool = self.registry.get(decision.tool_name)
130+
annotations = {
131+
"read_only": tool.annotations.read_only,
132+
"destructive": tool.annotations.destructive,
133+
"idempotent": tool.annotations.idempotent,
134+
}
106135
try:
107136
self.approval_policy.assert_allowed(
108137
tool_name=decision.tool_name,
109138
call_id=decision.call_id,
110139
destructive=tool.annotations.destructive,
111140
)
112141
except ToolPermissionError as exc:
113-
self.audit.record(
114-
"tool_call_blocked",
115-
current_run_id,
142+
approval_request = ApprovalRequest(
116143
call_id=decision.call_id,
117144
tool_name=decision.tool_name,
118145
arguments=decision.arguments,
119146
reason=str(exc),
120-
annotations={
121-
"read_only": tool.annotations.read_only,
122-
"destructive": tool.annotations.destructive,
123-
"idempotent": tool.annotations.idempotent,
124-
},
147+
annotations=annotations,
125148
)
126-
raise
149+
if self._can_request_approval(tool.annotations.destructive):
150+
self.audit.record(
151+
"tool_call_pending_approval",
152+
current_run_id,
153+
**approval_request.to_dict(),
154+
)
155+
if self.approval_handler is None:
156+
self.audit.record(
157+
"run_paused",
158+
current_run_id,
159+
status="pending_approval",
160+
approval=approval_request.to_dict(),
161+
cost_cents=cost_cents,
162+
)
163+
return RunResult(
164+
run_id=current_run_id,
165+
final_answer=None,
166+
iterations=iterations,
167+
tool_calls=tool_calls,
168+
status="pending_approval",
169+
metadata={"approval": approval_request.to_dict()},
170+
)
171+
if self.approval_handler(approval_request):
172+
self.audit.record(
173+
"tool_call_approved",
174+
current_run_id,
175+
call_id=decision.call_id,
176+
tool_name=decision.tool_name,
177+
)
178+
else:
179+
self.audit.record(
180+
"tool_call_denied",
181+
current_run_id,
182+
call_id=decision.call_id,
183+
tool_name=decision.tool_name,
184+
)
185+
raise
186+
else:
187+
self.audit.record(
188+
"tool_call_blocked",
189+
current_run_id,
190+
**approval_request.to_dict(),
191+
)
192+
raise
127193
self.audit.record(
128194
"tool_call_started",
129195
current_run_id,
130196
call_id=decision.call_id,
131197
tool_name=decision.tool_name,
132198
arguments=decision.arguments,
133-
annotations={
134-
"read_only": tool.annotations.read_only,
135-
"destructive": tool.annotations.destructive,
136-
"idempotent": tool.annotations.idempotent,
137-
},
199+
annotations=annotations,
138200
)
139201
result = self.registry.execute(decision.tool_name, decision.arguments)
140202
tool_calls += 1
@@ -196,3 +258,6 @@ def run(self, *, task: str, decide: DecisionFn, run_id: Optional[str] = None) ->
196258
tool_calls=tool_calls,
197259
status=f"failed:{ErrorCategory.MODEL_LOGIC}",
198260
)
261+
262+
def _can_request_approval(self, destructive: bool) -> bool:
263+
return destructive and self.approval_policy.permission_mode == PermissionMode.PROMPT

0 commit comments

Comments
 (0)