Skip to content

Commit 8a15888

Browse files
leo-aa88cursoragent
andcommitted
feat(engine,policy): human-in-the-loop approvals (issue #106)
Add Policy.spec.hitl configuration and pause gated tool calls at checkpoints with approve, reject, edit, and switch decisions. Non-interactive runs exit interrupted; resume via --decision or --auto-approve. Emit approval.requested and approval.resolved trace events with actor attribution and edit diffs. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0b870ae commit 8a15888

24 files changed

Lines changed: 1982 additions & 95 deletions

internal/cli/hitl.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package cli
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"strings"
10+
11+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/policy"
12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/runtime"
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
14+
"github.com/mattn/go-isatty"
15+
)
16+
17+
// EnvHitlActor overrides the actor recorded on approval trace events.
18+
const EnvHitlActor = "AGENTCTL_HITL_ACTOR"
19+
20+
func hitlActorFromEnv() string {
21+
if v := strings.TrimSpace(os.Getenv(EnvHitlActor)); v != "" {
22+
return v
23+
}
24+
if u := strings.TrimSpace(os.Getenv("USER")); u != "" {
25+
return u
26+
}
27+
return "operator"
28+
}
29+
30+
func applyHitlRunOptions(opts *runtime.WorkflowRunOptions, autoApprove bool, decision string, editJSON string, switchTarget string) error {
31+
opts.AutoApprove = autoApprove || envAutoApproveEnabled()
32+
opts.HitlActor = hitlActorFromEnv()
33+
decision = strings.TrimSpace(decision)
34+
if decision == "" {
35+
return nil
36+
}
37+
kind, err := spec.ParseHitlDecisionKind(decision)
38+
if err != nil {
39+
return err
40+
}
41+
hd := &runtime.HitlDecisionOptions{Kind: string(kind)}
42+
switch kind {
43+
case spec.HitlDecisionEdit:
44+
if strings.TrimSpace(editJSON) == "" {
45+
return fmt.Errorf("run: --decision edit requires --decision-edit-json")
46+
}
47+
var m map[string]any
48+
if err := json.Unmarshal([]byte(editJSON), &m); err != nil {
49+
return fmt.Errorf("run: --decision-edit-json: %w", err)
50+
}
51+
hd.EditedWith = m
52+
case spec.HitlDecisionSwitch:
53+
hd.SwitchTarget = strings.TrimSpace(switchTarget)
54+
if hd.SwitchTarget == "" {
55+
return fmt.Errorf("run: --decision switch requires --decision-switch-target")
56+
}
57+
}
58+
opts.HitlDecision = hd
59+
return nil
60+
}
61+
62+
func maybePromptHitlDecision(in io.Reader, out io.Writer, gate policy.HitlGate) (*policy.HitlDecisionInput, error) {
63+
if !isatty.IsTerminal(os.Stdin.Fd()) {
64+
return nil, nil
65+
}
66+
actor := hitlActorFromEnv()
67+
display := policy.RedactHitlArgs(gate.With, gate.Review.RedactKeys)
68+
fmt.Fprintf(out, "\n%s\n", gate.Review.Description)
69+
fmt.Fprintf(out, "Tool: %s\nArguments: %v\n", gate.Uses, display)
70+
fmt.Fprintf(out, "Allowed decisions: %v\n", gate.Review.AllowedDecisions)
71+
if len(gate.Review.SwitchTargets) > 0 {
72+
fmt.Fprintf(out, "Switch targets: %v\n", gate.Review.SwitchTargets)
73+
}
74+
for {
75+
fmt.Fprintf(out, "Decision [approve/reject/edit/switch]: ")
76+
line, err := readLine(in)
77+
if err != nil {
78+
return nil, err
79+
}
80+
kind, err := spec.ParseHitlDecisionKind(line)
81+
if err != nil {
82+
fmt.Fprintf(out, "Unknown decision %q\n", line)
83+
continue
84+
}
85+
if !decisionAllowed(kind, gate.Review.AllowedDecisions) {
86+
fmt.Fprintf(out, "Decision %q is not allowed for this call\n", kind)
87+
continue
88+
}
89+
dec := &policy.HitlDecisionInput{Kind: kind, Actor: actor}
90+
switch kind {
91+
case spec.HitlDecisionEdit:
92+
fmt.Fprintf(out, "Edited args JSON: ")
93+
editLine, err := readLine(in)
94+
if err != nil {
95+
return nil, err
96+
}
97+
var m map[string]any
98+
if err := json.Unmarshal([]byte(editLine), &m); err != nil {
99+
fmt.Fprintf(out, "Invalid JSON: %v\n", err)
100+
continue
101+
}
102+
if err := policy.ValidateHitlEdit(gate.With, m, gate.Review); err != nil {
103+
fmt.Fprintf(out, "%v\n", err)
104+
continue
105+
}
106+
dec.EditedWith = m
107+
case spec.HitlDecisionSwitch:
108+
fmt.Fprintf(out, "Switch target operation: ")
109+
target, err := readLine(in)
110+
if err != nil {
111+
return nil, err
112+
}
113+
dec.SwitchTarget = target
114+
}
115+
return dec, nil
116+
}
117+
}
118+
119+
func decisionAllowed(kind spec.HitlDecisionKind, allowed []spec.HitlDecisionKind) bool {
120+
for _, a := range allowed {
121+
if a == kind {
122+
return true
123+
}
124+
}
125+
return false
126+
}
127+
128+
func readLine(r io.Reader) (string, error) {
129+
sc := bufio.NewScanner(r)
130+
if !sc.Scan() {
131+
if err := sc.Err(); err != nil {
132+
return "", err
133+
}
134+
return "", fmt.Errorf("run: unexpected EOF reading hitl decision")
135+
}
136+
return strings.TrimSpace(sc.Text()), nil
137+
}

internal/cli/hitl_load.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/policy"
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
10+
)
11+
12+
type checkpointHitlPayload struct {
13+
PendingHitl *pendingHitlJSON `json:"pendingHitl,omitempty"`
14+
}
15+
16+
type pendingHitlJSON struct {
17+
StepID string `json:"stepId"`
18+
Uses string `json:"uses"`
19+
With map[string]any `json:"with"`
20+
Review policy.ResolvedHitlReview `json:"review"`
21+
}
22+
23+
// loadPendingHitlGate reads the latest interrupted checkpoint for a run awaiting HITL input.
24+
func loadPendingHitlGate(ctx context.Context, st state.RuntimeStore, runID string) (*policy.HitlGate, error) {
25+
cp, err := st.GetLatestCheckpoint(ctx, runID)
26+
if err != nil {
27+
return nil, err
28+
}
29+
var payload checkpointHitlPayload
30+
if err := json.Unmarshal([]byte(cp.ContextJSON), &payload); err != nil {
31+
return nil, fmt.Errorf("unmarshal checkpoint: %w", err)
32+
}
33+
if payload.PendingHitl == nil {
34+
return nil, nil
35+
}
36+
p := payload.PendingHitl
37+
return &policy.HitlGate{
38+
Uses: p.Uses,
39+
With: p.With,
40+
Review: p.Review,
41+
}, nil
42+
}

internal/cli/run.go

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,21 @@ import (
1414
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
1515
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/runtime"
1616
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/runtime/local"
17+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
1718
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
1819
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
20+
"github.com/mattn/go-isatty"
1921
"github.com/spf13/cobra"
2022
)
2123

2224
func newRunCmd() *cobra.Command {
2325
var inputFile string
2426
var inputPairs []string
2527
var approves []string
28+
var autoApprove bool
29+
var decision string
30+
var decisionEditJSON string
31+
var decisionSwitchTarget string
2632
var resumeRunID string
2733

2834
cmd := &cobra.Command{
@@ -37,6 +43,8 @@ Workflow input is built from optional --input-file (JSON object) plus repeated -
3743
--approve using the full uses string (e.g. tool.helper.echo).
3844
3945
Resume an interrupted or incomplete run with --resume <run-id> (no workflow argument).
46+
When a run pauses for human approval, resume with --decision and related flags, or use
47+
--auto-approve / AGENTCTL_AUTO_APPROVE=1 for non-interactive approval.
4048
4149
Examples:
4250
agentctl run workflow/demo --input topic=hello
@@ -71,12 +79,16 @@ Exit codes (section 11.2):
7179
return NewExitError(ExitValidationError, err)
7280
}
7381
}
74-
return runRun(cmd, wfName, resumeRunID, inputFile, inputPairs, approves)
82+
return runRun(cmd, wfName, resumeRunID, inputFile, inputPairs, approves, autoApprove, decision, decisionEditJSON, decisionSwitchTarget)
7583
},
7684
}
7785
cmd.Flags().StringVar(&inputFile, "input-file", "", "path to JSON file with workflow input object")
7886
cmd.Flags().StringArrayVar(&inputPairs, "input", nil, "workflow input as key=value (repeatable; values are strings)")
7987
cmd.Flags().StringArrayVar(&approves, "approve", nil, "approve a policy-gated tool uses string (repeatable)")
88+
cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "auto-approve human-in-the-loop gates (or set AGENTCTL_AUTO_APPROVE=1)")
89+
cmd.Flags().StringVar(&decision, "decision", "", "HITL decision when resuming: approve, reject, edit, or switch")
90+
cmd.Flags().StringVar(&decisionEditJSON, "decision-edit-json", "", "JSON object of edited tool args when --decision edit")
91+
cmd.Flags().StringVar(&decisionSwitchTarget, "decision-switch-target", "", "target operation when --decision switch")
8092
cmd.Flags().StringVar(&resumeRunID, "resume", "", "resume an interrupted or incomplete run by id")
8193
return cmd
8294
}
@@ -165,7 +177,7 @@ func classifyRunError(err error) int {
165177
}
166178
}
167179

168-
func runRun(cmd *cobra.Command, wfName, resumeRunID, inputFile string, inputPairs, approves []string) error {
180+
func runRun(cmd *cobra.Command, wfName, resumeRunID, inputFile string, inputPairs, approves []string, autoApprove bool, decision, decisionEditJSON, decisionSwitchTarget string) error {
169181
ctx := context.Background()
170182
g := Globals()
171183

@@ -203,33 +215,68 @@ func runRun(cmd *cobra.Command, wfName, resumeRunID, inputFile string, inputPair
203215
defer func() { _ = st.Close() }()
204216

205217
rt := local.NewRuntime(root, st)
206-
opts := runtime.WorkflowRunOptions{
207-
EnvironmentName: strings.TrimSpace(g.Env),
208-
Env: env,
209-
InputJSON: inputJSON,
210-
ApprovedActions: approves,
211-
Resume: resumeID != "",
212-
RunID: resumeID,
213-
}
214-
if !opts.Resume {
215-
opts.WorkflowName = wfName
216-
}
217-
runID, runErr := rt.ExecuteWorkflow(ctx, opts)
218218

219-
outWfName := wfName
220-
if opts.Resume && runID != "" {
221-
if r, gerr := st.GetRun(ctx, runID); gerr == nil && r != nil {
222-
outWfName = r.WorkflowName
219+
for {
220+
opts := runtime.WorkflowRunOptions{
221+
EnvironmentName: strings.TrimSpace(g.Env),
222+
Env: env,
223+
InputJSON: inputJSON,
224+
ApprovedActions: approves,
225+
Resume: resumeID != "",
226+
RunID: resumeID,
223227
}
224-
}
228+
if err := applyHitlRunOptions(&opts, autoApprove, decision, decisionEditJSON, decisionSwitchTarget); err != nil {
229+
return NewExitError(ExitValidationError, err)
230+
}
231+
if !opts.Resume {
232+
opts.WorkflowName = wfName
233+
}
234+
runID, runErr := rt.ExecuteWorkflow(ctx, opts)
225235

226-
if werr := writeRunOutput(cmd, ctx, st, env, dsn, outWfName, runID, runErr, g); werr != nil {
227-
return werr
228-
}
229-
if runErr != nil {
230-
return NewExitError(classifyRunError(runErr), fmt.Errorf("run: %w", runErr))
236+
outWfName := wfName
237+
if opts.Resume && runID != "" {
238+
if r, gerr := st.GetRun(ctx, runID); gerr == nil && r != nil {
239+
outWfName = r.WorkflowName
240+
}
241+
}
242+
243+
if runErr == nil && runID != "" {
244+
if r, gerr := st.GetRun(ctx, runID); gerr == nil && r != nil && r.Status == state.RunStatusInterrupted {
245+
if opts.AutoApprove || strings.TrimSpace(decision) != "" {
246+
resumeID = runID
247+
continue
248+
}
249+
gate, gerr := loadPendingHitlGate(ctx, st, runID)
250+
if gerr != nil {
251+
return fmt.Errorf("run: load hitl gate: %w", gerr)
252+
}
253+
if gate != nil && isatty.IsTerminal(os.Stdin.Fd()) {
254+
dec, perr := maybePromptHitlDecision(cmd.InOrStdin(), cmd.OutOrStdout(), *gate)
255+
if perr != nil {
256+
return perr
257+
}
258+
if dec != nil {
259+
resumeID = runID
260+
decision = string(dec.Kind)
261+
if dec.Kind == spec.HitlDecisionEdit {
262+
b, _ := json.Marshal(dec.EditedWith)
263+
decisionEditJSON = string(b)
264+
}
265+
decisionSwitchTarget = dec.SwitchTarget
266+
continue
267+
}
268+
}
269+
}
270+
}
271+
272+
if werr := writeRunOutput(cmd, ctx, st, env, dsn, outWfName, runID, runErr, g); werr != nil {
273+
return werr
274+
}
275+
if runErr != nil {
276+
return NewExitError(classifyRunError(runErr), fmt.Errorf("run: %w", runErr))
277+
}
278+
return nil
231279
}
232-
return nil
233280
}
234281

235282
func writeRunOutput(cmd *cobra.Command, ctx context.Context, st *sqlite.Store, env, dsn, wfName, runID string, runErr error, g *Global) error {

0 commit comments

Comments
 (0)