Examples / Human-in-the-Loop (HITL) / Agent Driven Approval

Agent Driven Approval

Has SpecsRequires API Keys

An AI agent that dynamically decides when to request human approval and constructs context-aware approval messages. This demonstrates **agent-driven HITL** where the agent has tools that wrap HITL primitives and uses them intelligently based on context.

Source Code

--[[
Agent-Driven HITL Approval

This example demonstrates agent-driven human-in-the-loop (HITL), where an AI agent
dynamically decides when to request human input and constructs the questions itself.

KEY INNOVATION:
- Traditional HITL: Procedure code hardcodes calls to Human.approve()
- Agent-Driven HITL: Agent has tools that wrap HITL primitives and decides when/how to use them

The agent:
1. Analyzes the deployment request
2. Decides to call the ask_approval tool
3. Constructs a context-aware approval message
4. Waits for human response
5. Proceeds based on approval/rejection

This makes HITL workflows much more flexible and intelligent compared to hardcoded approaches.

REQUIREMENTS:
- Run from Tactus IDE or VSCode extension (HITL modals require UI)
- Running from CLI will execute but cannot display the approval modal

RUN:
  tactus run 03-human-in-the-loop/01-agent-driven-approval.tac \
    --param app_name="MyApp" \
    --param environment="production"
--]]

local done = require("tactus.tools.done")

-- Define HITL approval toolset
-- This wraps the Human.approve() primitive so agents can call it as a tool
Toolset "hitl_approval" {
    description = "Tools for requesting human input and approval",

    tools = {
        {
            name = "ask_approval",
            description = "Ask the human for deployment approval. Pass a clear message describing what needs approval.",
            input = {
                message = field.string{required = true, description = "The approval question to ask the human"}
            },
            handler = function(args)
                Log.info("[Agent → Human] Asking for approval: " .. args.message)

                -- Use Human.approve() to get approval
                local approved = Human.approve({
                    message = args.message
                })

                Log.info("[Human → Agent] Approval response: " .. tostring(approved))

                return {
                    approved = approved,
                    message = "Human " .. (approved and "approved" or "rejected") .. " the request"
                }
            end
        }
    }
}

-- Define deployment review agent
-- The agent decides when to call ask_approval based on its instructions
deployment_reviewer = Agent {
    model = "openai/gpt-4o-mini",
    tool_choice = "required",

    system_prompt = [[You are a deployment review assistant.

Your job is to review deployment requests and ask the human for approval.

When you receive a deployment request:
1. Call the ask_approval tool with a clear message about what deployment you need approval for
2. Wait for the human response
3. Call the done tool with the approval result

Important: You MUST call ask_approval exactly once, then call done with the result.]],

    tools = {"hitl_approval", done}
}

Procedure {
    input = {
        app_name = field.string{required = false},
        environment = field.string{required = false}
    },
    output = {
        success = field.boolean{required = true},
        app_name = field.string{required = true},
        environment = field.string{required = true},
        approved = field.boolean{required = true},
        done_called = field.boolean{required = true}
    },
    function(input)
        print("=== Agent-Driven HITL Example ===\n")

        -- Input validation
        local app_name = input.app_name or "MyApp"
        local environment = input.environment or "production"

        print("Deployment request:")
        print("  App: " .. app_name)
        print("  Environment: " .. environment)
        print("")

        -- Run the agent (it will call ask_approval tool and done tool)
        print("Invoking deployment review agent...")

        -- Format the initial message with deployment details
        local message = string.format(
            "Please review this deployment request:\n\nApp: %s\nEnvironment: %s\n\nCall the ask_approval tool to get human approval.",
            app_name,
            environment
        )

        local max_turns = 5
        local turn_count = 0

        -- Run agent until done is called or max turns reached
        while not done.called() and turn_count < max_turns do
            turn_count = turn_count + 1

            -- Pass message on first turn
            if turn_count == 1 then
                deployment_reviewer(message)
            else
                deployment_reviewer()
            end
        end

        print("\nAgent execution complete.")

        -- Verify the agent called the ask_approval tool
        if not Tool.called("hitl_approval_ask_approval") then
            print("\n❌ SPEC VIOLATION: Agent did not call ask_approval tool")
            return {
                success = false,
                reason = "Agent did not ask for approval"
            }
        end

        print("\n✓ Agent successfully used HITL tool")

        -- Get the approval result
        local approval_result = Tool.last_result("hitl_approval_ask_approval")
        local done_result = done.called() and done.last_call() or nil

        print("\nApproval result:")
        if approval_result then
            local function safe_get(obj, key)
                if obj == nil then
                    return nil
                end
                if type(obj) == "table" then
                    return obj[key]
                end
                local ok, val = pcall(function()
                    return obj[key]
                end)
                if ok then
                    return val
                end
                return nil
            end

            -- In mocked runs, tool results may be bridged as Python dict-like objects.
            -- Use safe_get to avoid KeyError-style exceptions through the bridge.
            print("  Approved: " .. tostring(safe_get(approval_result, "approved")))
            print("  Message: " .. tostring(safe_get(approval_result, "message")))
        end

        if done_result then
            print("\nDone called with reason: " .. tostring(done_result.args.reason))
        end

        -- Extract approval status safely
        local approved = false
        if approval_result then
            local ok, val = pcall(function() return approval_result["approved"] end)
            if ok and val ~= nil then
                approved = val and true or false
            elseif type(approval_result) == "table" and approval_result.approved ~= nil then
                approved = approval_result.approved and true or false
            else
                -- In mocked agent runs, tool calls are recorded with a generic result
                -- like {tool=..., args=...} (the tool handler is not executed). For
                -- this educational example, treat the presence of the ask_approval
                -- tool call as an approval in mock mode.
                local ok_tool, tool_name = pcall(function() return approval_result["tool"] end)
                if ok_tool and tool_name == "hitl_approval_ask_approval" then
                    approved = true
                elseif type(approval_result) == "table" and approval_result.tool == "hitl_approval_ask_approval" then
                    approved = true
                end
            end
        end

        return {
            success = true,
            app_name = app_name,
            environment = environment,
            approved = approved,
            done_called = done.called()
        }
    end
}

Specification([[
Feature: Agent-driven HITL approval (mocked)

  Scenario: Agent asks for approval and completes
    Given the procedure has started
    And the input app_name is "MyApp"
    And the input environment is "production"
    And the agent "deployment_reviewer" responds with "I will ask for approval."
    And the agent "deployment_reviewer" calls tool "hitl_approval_ask_approval" with args "{'message': 'Approve deployment of MyApp to production?'}"
    And the agent "deployment_reviewer" calls tool "done" with args "{'reason': 'approval complete'}"
    When the procedure runs
    Then the procedure should complete successfully
    And the output success should be true
    And the output approved should be true
    And the output done_called should be true
]])

Quick Start

Run the example:

$tactus run 03-human-in-the-loop/02-agent-driven-approval.tac

Test with mocks:

$tactus test 03-human-in-the-loop/02-agent-driven-approval.tac --mock

Note

This example requires API keys. Set your OPENAI_API_KEY environment variable before running.

View source on GitHub →

Explore more examples

Learn Tactus through practical, runnable examples organized by topic.