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.

Part of the Anthus Platform
Tactus icon

Tactus

Tactus is a programming language and runtime for durable AI agent procedures with checkpointing, sandboxing, and built-in human-in-the-loop controls.

PART OF

The Anthus Platform

Solve complex business problems with AI and ML using a proven, reusable technology stack. These interoperable building blocks give our solutions a stronger operational foundation: durable procedures, MLOps control loops, workload orchestration, knowledge systems, observability, and programmable media workflows.

Plexus

MLOps platform for agent evaluation and iteration.

Tactus

Durable runtime for agent procedures.

Korporus

Agent operating system and federated shell.

Biblicus

Corpus analysis for extraction and retrieval.

Babulus

Marketing automation built around VideoML.

Kanbus

Durable multi-agent task management.

Caducus

Monitoring, alerts, and operator support.

Free and open-source softwareDesigned cybernetically by Ryan Porter
Contact us

How can we help?

GitHub

Browse the code.

LinkedIn

Company updates.

Discord

Join the chat.