Goal-Directed Agents: Beyond Flowcharts

Traditional workflow engines ask you to draw a flowchart. Step 1 → Step 2 → Decision → Step 3a or Step 3b. It works until it doesn't.

What happens when Step 2 needs to run in parallel with Step 3? What happens when a new requirement means Step 1.5 must exist? What happens when Step 4 fails and Steps 1–3 need to roll back — but Step 2 called an external API that can't be "un-called"?

You redraw the flowchart. You add error handlers. You add retry logic. You add compensation logic. The flowchart becomes a spaghetti diagram that nobody wants to touch.

A Different Model

TIATON doesn't use flowcharts. Instead, you declare:

  1. Goals — the desired end state
  2. Skills — the actions the agent can perform
  3. Predicates — conditions that describe the current state
  4. Compensators — how to undo a skill's effects on failure

The runtime decides which skills to execute, in what order, and how to parallelize them.

{
  "name": "loan_processing",
  "goals": ["loan_decided", "applicant_notified"],
  "skills": [
    {
      "key": "validate_application",
      "handler": "validate_app",
      "requires": [],
      "ensures": ["application_validated"]
    },
    {
      "key": "check_credit",
      "handler": "check_credit_score",
      "requires": ["application_validated"],
      "ensures": ["credit_checked"],
      "compensator": "void_credit_check"
    },
    {
      "key": "evaluate_risk",
      "handler": "evaluate_risk_dmn",
      "requires": ["credit_checked"],
      "ensures": ["risk_evaluated"]
    },
    {
      "key": "decide_loan",
      "handler": "make_decision",
      "requires": ["risk_evaluated"],
      "ensures": ["loan_decided"]
    },
    {
      "key": "notify_applicant",
      "handler": "send_notification",
      "requires": ["loan_decided"],
      "ensures": ["applicant_notified"],
      "async_ops": [
        {
          "method": "notifications.v1.EmailService/Send",
          "on_complete": "on_email_sent"
        }
      ]
    }
  ]
}

How the Runtime Decides

Each tick, the agent evaluates:

  1. Which goals are unmet? — Check predicates against the goal list
  2. Which skills are eligible? — Requirements met, at least one ensure not yet met
  3. Goal affinity — BFS backward from unmet goals through ensures→requires chains
  4. Parallel dispatch — Independent skills (no overlapping ensures/requires) run simultaneously

The decision algorithm is deterministic. Given the same state, the agent always picks the same skills. No randomness, no LLM guessing, no hidden heuristics.

Tick 1:
  Unmet goals: [loan_decided, applicant_notified]
  Eligible: [validate_application] (no requires)
  Selected: validate_application
  → Execute → SUCCESS → application_validated = true

Tick 2:
  Eligible: [check_credit] (requires application_validated ✓)
  Selected: check_credit
  → Execute → SUCCESS → credit_checked = true

Tick 3:
  Eligible: [evaluate_risk]
  Selected: evaluate_risk
  → Execute → SUCCESS → risk_evaluated = true

Tick 4:
  Eligible: [decide_loan]
  Selected: decide_loan
  → Execute → SUCCESS → loan_decided = true ✓

Tick 5:
  Eligible: [notify_applicant]
  Selected: notify_applicant
  → Execute → RUNNING (async email send)
  → Status: WAITING (pending job)

... email service responds ...

Tick 6:
  → Resume with job_succeeded event
  → applicant_notified = true ✓
  → All goals met → DONE

Automatic Compensation

When a skill fails, the agent triggers compensation for all previously completed skills — in reverse order.

Tick 4:
  decide_loan → FAILURE (underwriting service down)

Compensation cascade:
  1. void_credit_check() — cancel the credit inquiry
  2. (validate_application has no compensator — stateless)

Final status: FAILURE with full audit trail

No manual rollback logic. No forgotten cleanup. The agent traces every step and undoes what needs undoing.

Async by Default

Skills that call external services return RUNNING with a job submission. The agent pauses, the session serializes, and when the external service responds — the agent resumes exactly where it left off.

# In Starlark handler
def send_notification(ctx, state):
    email = new("notifications.v1.SendEmailRequest", {
        "to": state["applicant_email"],
        "template": "loan_decision",
        "data": {"decision": state["decision"]}
    })
    return RUNNING, submit_job("notifications.v1.EmailService/Send", email)

def on_email_sent(ctx, state):
    # ctx.event.result is typed notifications.v1.SendEmailResponse
    state["notification_id"] = ctx.event.result.message_id
    return SUCCESS

The session can wait hours, days, or weeks. State is preserved. When the response arrives, the agent picks up exactly where it stopped.

Why Not a Flowchart?

Flowchart EngineGoal-Directed Agent
You define the pathYou define the destination
Add step = redesign graphAdd skill = auto-integrated
Parallel = explicit fork/joinParallel = automatic
Error = add handler per stepError = automatic compensation
Async = callback hellAsync = pause/resume
State = scattered variablesState = single typed object

The agent doesn't care about the order. It cares about the goals. You describe what should be true at the end — the runtime figures out how to get there.