docs/development/agent-goals-design.md
An agent goal should not be another kind of chat session or a process that stays alive forever. It should be a durable control loop whose execution plan is represented by the existing task tree.
The proposed relationship is:
Agent
-> Goal (intent, success contract, policy, budget, lifecycle)
-> root Task (current plan)
-> subtasks + dependencies (execution graph)
-> Task Topics (individual attempts)
-> Agent Operations (runtime/cost/trace)
The agent is the owner and executor. The goal is the long-lived outcome contract. Tasks are the mutable execution plan. Topics are finite execution attempts. No worker or LLM call is kept alive between attempts.
This reuses the strongest parts of the task system without making every task a permanent agent goal.
The current task system already provides:
A task can therefore be the execution substrate of a goal. It should not be the goal aggregate itself, because a durable goal has semantics that do not belong to an individual plan node:
If these are stored only in tasks.config, the root task becomes overloaded, goal discovery by
agent is weak, status transitions become ambiguous, and future replanning cannot replace the root
plan cleanly.
agent_goalsRecommended columns:
| Field | Purpose |
|---|---|
id | Stable goal identity |
agentId | Agent that owns and pursues the goal |
userId, workspaceId, visibility | Existing ownership and workspace rules |
rootTaskId | Current root task / plan entry point |
title, objective | Human-readable outcome; objective is immutable while a run is active |
successCriteria | Structured verify criteria and evaluator configuration |
status | draft, active, waiting, paused, achieved, blocked, canceled, failed |
policy | Replan cadence, retry/fuse, allowed triggers, concurrency |
budget | Max USD, active runtime, rounds, operations, and optional deadline |
progress | Cached summary, score, last decision, and counters for cheap UI reads |
nextWakeAt | Durable time-based wake-up hint |
leaseOwner, leaseExpiresAt | Single-controller execution lease |
startedAt, completedAt, timestamps | Lifecycle audit |
Keep rootTaskId nullable during draft creation. Do not put goalId only inside task JSONB: add a
nullable indexed tasks.goalId foreign key so every plan node and operation can be queried cheaply.
The root task is the node whose id === goal.rootTaskId.
An agent may own multiple goals, but MVP should default to one active goal per agent. Supporting multiple active goals later is a scheduling-policy decision, not a schema rewrite.
agent_goal_runsEach controller decision is an append-only run, distinct from a task topic:
| Field | Purpose |
|---|---|
goalId, seq | Ordered controller history and idempotency boundary |
triggerType, triggerId | created, timer, signal, task_completed, verify_settled, manual |
status | queued, running, waiting, completed, failed, canceled |
decision | execute, decompose, replan, verify, wait, achieve, pause, block |
reason | Human-readable controller rationale |
inputSnapshot, output | Reproducible state and selected actions |
operationId | Agent operation used for planning, when an LLM was needed |
| timestamps | Latency and audit |
Use a uniqueness constraint over the durable trigger identity, for example
(goalId, triggerType, triggerId), so at-least-once queue delivery cannot create two rounds.
agent_goal_eventsDo not add this in the first migration unless product needs a user-facing activity stream that
cannot be reconstructed. Agent Signal traces plus agent_goal_runs cover the initial audit need.
“7x24 running” means that the goal can always resume from durable state. It must not mean an open HTTP response, an endless LLM loop, or an in-memory timer.
Every controller turn is finite:
wake event
-> acquire goal lease
-> load goal + task graph + latest handoffs + verify state + budgets
-> choose exactly one bounded decision
-> persist decision and enqueue/execute bounded actions
-> set nextWakeAt or wait for an event
-> release lease
Production execution should use a durable queue/workflow. Local mode may use in-process scheduling for developer ergonomics, but must be documented as non-durable across restarts.
The controller is not another general chat agent. Give it a narrow output schema:
type GoalDecision =
| { type: 'execute'; taskIds: string[] }
| { type: 'decompose'; parentTaskId: string; tasks: ProposedTask[] }
| { type: 'replan'; mutations: TaskGraphMutation[]; reason: string }
| { type: 'verify'; subjectTaskId: string }
| { type: 'wait'; reason: string; wakeAt?: string }
| { type: 'achieve'; evidence: EvidenceRef[] }
| { type: 'pause'; reason: string }
| { type: 'block'; reason: string; requestedInput?: string };
One turn may start a bounded set of ready tasks in parallel, subject to the goal concurrency policy. It must not recursively call itself. Completion events enqueue the next controller turn.
Use Agent Signal as the event interpretation and routing layer:
task topic completed / verify settled / document changed / connector event / timer
-> source event
-> signal.goal.wake-requested
-> action.goal.enqueue-turn
-> durable goal workflow
Agent Signal should decide whether an event matters and dedupe it. The goal controller should own goal state transitions and task-plan mutation. This avoids coupling every connector or task hook to goal orchestration.
Time wake-ups can reuse the task scheduler conceptually, but should expose a generic durable
scheduleGoalWake API rather than pretending every wake is a heartbeat task topic. A delayed
message is one-shot; after handling it, the controller chooses whether another wake is needed.
The existing TaskRunnerService remains the only path that starts task work. A goal controller
selects ready tasks; it does not execute tools itself.
When a topic completes:
goalId.This keeps lifecycle hooks observational and makes retries safe.
The controller may propose achieve, but the goal only reaches achieved after its structured
success criteria pass. Reuse Verify as the acceptance plane. Evidence must reference durable
artifacts, task outputs, connector objects, or checks rather than only an LLM narrative.
Use two nested loops:
inner loop: one task attempt -> verify/repair that delivery
outer loop: goal progress review -> execute more tasks or replan the task graph
The existing feat/goal-loop-server exploration already demonstrates the useful inner primitive:
a failed verify result can spawn a fresh topic, carry handoffs and failed checks forward, and stop on
round/cost budgets. The agent-level goal controller should generalize the outer loop rather than
duplicating that logic inside every task.
waiting: healthy; awaiting a known event or future time. May auto-resume.blocked: the agent cannot make meaningful progress without new authority/input. Never
auto-resume unless the blocking condition changes.paused: explicitly stopped by a user, policy, fuse, or budget. Requires an explicit resume or
policy change.failed: unrecoverable controller/system failure after the retry policy is exhausted.A goal should not become blocked after one weak attempt. Persist a repeated-condition fingerprint; only block once the same condition survives the configured number of controller turns.
Every active goal needs hard server-enforced limits:
Budget checks happen both before enqueue and immediately before execution. The database is the authority because queued work may be stale.
Never promise literal uninterrupted execution. Billing failure, revoked credentials, connector outages, approval requirements, and infrastructure incidents must transition to a visible state and brief rather than spin indefinitely.
At-least-once delivery is assumed.
goalId before creating a run.goal:{goalId}:run:{seq}:task:{taskId}:execute.nextWakeAt before publishing the delayed wake; save the returned queue message id when
cancellation is supported.nextWakeAt no longer
matches.Add a generation integer to the goal if edits/resume should invalidate all queued work cheaply.
Before adding the aggregate, finish or reuse the existing feat/goal-loop-server work:
TaskGoalConfig under tasks.config.goal;Treat this as a task-level execution primitive, not the final agent-goal model.
agent_goals, agent_goal_runs, and indexed tasks.goalId.This phase proves the semantics and UI without claiming 7x24 durability.
nextWakeAt is overdue and have no valid lease.At this point the system can honestly claim unattended continuous pursuit in queue mode.
packages/database/src/schemas/agentGoal.ts
packages/database/src/models/agentGoal.ts
packages/database/src/models/agentGoalRun.ts
packages/types/src/agentGoal/
apps/server/src/services/agentGoal/
AgentGoalService.ts # CRUD and lifecycle invariants
GoalController.ts # bounded decide/apply turn
GoalContextBuilder.ts # goal + task graph + evidence snapshot
GoalBudgetService.ts
GoalLeaseService.ts
decisions.ts # schema and validation
apps/server/src/workflows/agentGoal/
apps/server/src/services/agentSignal/policies/goalWake/
apps/server/src/routers/lambda/agentGoal.ts
Avoid placing the controller inside TaskLifecycleService; lifecycle completion must remain
bounded and resilient. It should emit/enqueue and return.
The first durable release is complete when all of these are true:
achieved.The primary agent surface should show a single “Current goal” card with:
The user should not need to understand controller turns. They should see what the agent is pursuing, what it is doing now, what it is waiting for, and what proof will count as done.