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:
- Goals — the desired end state
- Skills — the actions the agent can perform
- Predicates — conditions that describe the current state
- 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:
- Which goals are unmet? — Check predicates against the goal list
- Which skills are eligible? — Requirements met, at least one ensure not yet met
- Goal affinity — BFS backward from unmet goals through ensures→requires chains
- 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 Engine | Goal-Directed Agent |
|---|---|
| You define the path | You define the destination |
| Add step = redesign graph | Add skill = auto-integrated |
| Parallel = explicit fork/join | Parallel = automatic |
| Error = add handler per step | Error = automatic compensation |
| Async = callback hell | Async = pause/resume |
| State = scattered variables | State = 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.