# Spec: Agent DAGs ## Status Implemented. ## Scope This spec defines: - the `type: agent` DAG shape - the `tasks` root field and its role as the termination condition - how declared steps become a catalog of actions offered to a model - the decision loop, one action per turn - the `set_task_status` and `ask_user` tools - failure handling and action repetition - suspension for human input and resumption - terminal status derivation This spec does not define: - provider-specific model behavior or prompt engineering beyond the framing the agent supplies - `action: human.task` semantics, which are defined in `031-human-task.md` - REST, Web UI, MCP, notification, authentication, or authorization behavior - concurrent action execution ## Goal A workflow author declares *what a run must achieve* rather than *the order in which steps run*. Steps become capabilities; `tasks` become goals. A model selects one action per turn, observes its outcome, and marks tasks complete until every goal is satisfied, at which point the run concludes. Because the agent drives the existing node machinery rather than an in-process tool loop, an action may open a human task: the run releases its process and worker slot, and the agent resumes with its conversation and goal progress intact. ## Related Specs - `002-yaml-schema.md` — root fields - `031-human-task.md` — the waiting checkpoint an action may open ## DAG shape ```yaml type: agent llm: provider: anthropic model: claude-opus-5 system: | Optional framing prepended to the agent's own instructions. steps: - name: design action: dag.run with: { dag: design } - id: review name: review action: human.task with: prompt: Approve the design? tasks: - name: designed description: Finished when the design workflow ran and a person approved it. ``` ### Root fields Agent DAGs were previously called controller DAGs. `type: controller` remains accepted as a deprecated alias: it is canonicalized to `agent` before validation, so every rule stated for `type: agent` applies to it unchanged. Loading a DAG that uses it reports a deprecation warning naming the replacement. `tasks` is an array of objects with `name` and `description`. It is valid only when `type` is `agent`, and `type: agent` requires it. - `name` MUST be non-empty and unique within the DAG. - `description` MUST be non-empty. It states the completion criteria the agent decides against, so an empty description is a specification error rather than a stylistic one. `llm` MUST be present. Its `system` value, when set, is prepended to the agent's own framing rather than replacing it. `llm.model` MAY be an ordered array of model entries. The first entry is the primary model and each later entry is a fallback. Provider, model name, base URL, API-key name, and sampling overrides are taken from the selected entry in the same way as for a chat step. `llm.system` and every task `description` are author-written prompt text and MUST be resolved against the run's variables before the agent sees them, so a workflow can be steered by its parameters without editing the DAG. The resolved description is what gets persisted, since it is what the agent judged against. `llm.max_tool_iterations` bounds the number of decisions in a single run. When unset the bound is 50. `llm.observation_max_bytes` bounds each tool result added to the agent's conversation. It defaults to 524288 bytes (512 KiB). The limit applies only to the agent-facing copy: the step's output, logs, and human-task submission stay complete in their owning records. A truncated result uses an explicit marker when the configured limit can hold it and MUST remain valid UTF-8. A value of zero disables this size limit. `llm.max_context_tokens` is the prompt-token threshold for observation aging and defaults to 200000. Dagu does not infer it from the model. An author SHOULD override the default when needed to leave headroom below the provider's hard context limit. Once aging starts, `llm.observation_keep_recent` controls how many recent tool results remain complete; it defaults to 20. A zero `max_context_tokens` disables proactive aging. A zero `observation_keep_recent` disables aging entirely, including overflow recovery. These context-management fields are valid only in an agent DAG's root `llm` configuration and MUST NOT be set on an individual step. ### Step constraints An agent DAG MUST declare at least one step. For every declared step: - `depends` MUST NOT be set, and a step MUST NOT be explicitly marked as having no dependencies. Ordering belongs to the agent. - `router` MUST NOT be set. - The name `__agent__` is reserved and MUST NOT be used as a step name or ID. Every declared step is implicitly failure-tolerant: a failed action never aborts the run, because the failure is an observation the agent acts on. ### The agent step Building an agent DAG appends a synthesized step named `__agent__` carrying the DAG's `llm` configuration. It is the node the runner drives the loop from, and it holds the conversation transcript, the tool catalog that was offered, and the persisted goal progress. It is not an action the agent may select. ## The action catalog Each declared step is advertised to the model as one function-calling tool. - The tool name derives from the step `id`, or the step `name` when no `id` is set. Characters outside `[A-Za-z0-9_-]` are replaced with `_`, the result is truncated to 64 characters, and collisions are disambiguated with a numeric suffix. - The tool description is the step `description`; failing that, the human task prompt for a human task, the target workflow's description for `dag.run`, and otherwise a generated sentence naming the step. - Only a step that launches a child DAG accepts arguments. Its schema is derived from the target's parameter definitions, falling back to its default-params string. Every other step is a nullary action. - A parameter the step supplies a value for MUST NOT appear in that schema. A value written in the workflow is the author's decision, not one the agent restates, and a step that supplies every parameter is a nullary action. - Parameters supplied by a child-DAG step MUST use named form. Positional parameters are rejected because tool arguments are identified by name. The parameters a child DAG run receives are the ones the step supplies, plus an argument for each parameter the step left open. An argument naming a parameter the step supplies MUST be discarded rather than override it: the model was never offered that choice. Two additional tools are always offered. `ask_user` puts a question to a person. An agent DAG is built with a synthesized human task, named and identified `ask_user`, which the tool opens with the question the agent wrote. Answering it is an ordinary human task completion, and the reply returns as the next observation. That task MUST NOT count as a declared human task when deciding whether the DAG may run as somebody's child, or every agent would be barred from composition. Instead the agent declines to ask when it is not the root run. A question already answered MUST NOT be put to a person again: the answers so far are restated to the agent each turn, an exact repeat is refused with the prior answer, and a run may ask at most 5 questions. `set_task_status` is always offered. It takes a `task` name, a `status`, and a `reason`. It is reserved: no step tool may take that name. ## The decision loop Each turn: 1. The agent is sent a system message stating its role, the full task list with per-task completion status, and the operating rules, followed by the conversation so far. 2. The model replies. If it requests no tool call, see *Stalling* below. 3. Exactly one action is carried out per turn. When a reply contains several tool calls only the first is recorded and executed, so the conversation never references a result that was not produced. 4. The outcome is appended as the tool result: the resulting status, any error, and any human task submission. For a step that launched a child DAG, the rest of the observation is read from the child run itself: its status, the output variables its steps declared, and the name and error of any step that failed. The parent step's log MUST NOT be used as the source, because it only mirrors the child's status document, repeated once per internal retry, and is empty on a repeated run. For every other step, a bounded tail of stdout and stderr is reported. The complete tool result is then limited by `llm.observation_max_bytes`. Human answers repeated in the agent's system message use the same agent-facing limit. The loop ends when no task is open, when an action opens a human task, or when a limit is reached. ### Model fallback When `llm.model` is an array, every decision starts with the currently selected model. If its request still fails after the provider and logical retry budgets are exhausted, the agent advances through the remaining entries in order. A fallback that succeeds remains selected for later turns in the same agent process, so a sustained primary outage is not retried on every decision. A new process after suspension starts from the configured primary again. A failed model request MUST NOT consume an agent turn or append an assistant message. The next model receives the existing conversation unchanged, including assistant tool calls and tool results produced through earlier models. Every successful assistant message records the provider and model that produced it. Context-length recovery runs against the current model before advancing to a fallback. If all configured models fail, the run fails with an error identifying the exhausted models and preserving their underlying errors. ### Observation aging and context recovery Every successful decision records the provider-reported prompt token count. If that count reaches `llm.max_context_tokens`, observation aging starts before the next decision and remains active for the rest of the run. While aging is active, the newest `llm.observation_keep_recent` tool results remain complete. Every older tool result is replaced by a deterministic one-line summary derived from the matching decision-timeline event. The assistant tool call, tool result role, and tool-call ID MUST remain in the conversation so provider tool-call protocols remain valid. Compacted results are persisted and MUST NOT expand again after suspension or retry. If observation aging is enabled and a provider rejects a decision because its context is too long, Dagu enables aging immediately and replaces every tool result whose deterministic summary is smaller, including results normally protected by `llm.observation_keep_recent`. It retries the decision once only when that compaction changed the transcript. The rejected request does not consume an agent turn or add an assistant message. If nothing can be made smaller, or if the rebuilt request also fails, that model attempt fails and ordinary model fallback applies. No further overflow retries are made for that decision. When aging is disabled, a context-too-long response advances to the next configured model without a recovery retry, or fails the run when no fallback remains. ### Task status Every task starts `open`. The agent settles it with `set_task_status`: | Status | Meaning | Effect on the run | |---|---|---| | `completed` | The task's criteria are satisfied. | — | | `skipped` | The task turned out to be unnecessary. | None: the run still succeeds. | | `failed` | The task cannot be achieved. | The run fails. | | `open` | Undo an earlier decision that later work invalidated. | Returns the task to the loop. | `skipped` and `failed` MUST remain distinct: waiving a goal that never needed doing is not the same outcome as failing to reach one. Naming an unknown task, restating the status a task already holds, or passing a status outside this set is reported back as a tool error and the loop continues; none of these fail the run. The same applies to a call naming a tool that does not exist, or arguments that cannot be decoded. ### Failure A failed action is reported to the agent, which may retry it, choose a different action, or stop. The failure is an observation, not a run-level error: it MUST NOT by itself cause the run to report an error to its caller. Final status follows the steps' end state. A failed action that was re-run successfully leaves the run `succeeded`; an action left failed while every task completed leaves it `partially succeeded`. ### Repetition An action may be selected again after it has already run, which resets the node and marks it repeated so a child DAG run receives a fresh run ID. A single action may run at most 5 times per DAG run; beyond that, the request is refused as a tool error and the agent must choose differently. ### Stalling If the model replies without calling a tool while tasks remain open, it is reminded once which tasks are outstanding. A second consecutive reply without a tool call fails the run. Any turn that does use a tool clears the count, so occasional silence between real work is not fatal. ### Limits Reaching the turn limit with tasks still open fails the run, and the error names the outstanding tasks. A task the agent cannot achieve should be settled as `failed` rather than left open to exhaust the limit. ## Suspension and resumption When a chosen action ends in the `waiting` status — an `action: human.task` step, or a child DAG that is itself waiting — the agent records the in-flight tool call, persists its state, and returns. The agent step itself MUST NOT be left waiting, since an outstanding waiting step would prevent the run from being released when the human task completes. The run then reports `waiting`, the `onWait` handler runs, and the process exits. Completing the human task marks that step succeeded and re-queues the same DAG run. On the next attempt the agent restores its transcript and goal progress from the agent step, reports the outcome of the in-flight action as that turn's tool result, and continues. Restored state is reconciled against the current DAG: progress on a task that still exists is preserved, a task that has been removed does not linger, and a newly declared task starts open. ## Decision timeline An agent run records an ordered timeline of its decisions, persisted alongside goal progress and restored on resume. Each entry carries the turn it belongs to and one of these kinds: `action`, `task_status`, `ask_user`, `rejected`, `stalled`. An `action` entry additionally carries the resulting status, which attempt of that step it was, and the start and finish times. The timeline exists because an agent has no dependency edges: execution order is a property of the run, not of the DAG, and cannot be recovered from the step list. ## Variable scope An agent DAG has no dependency edges. Every action that has already finished is treated as upstream of the action starting now, so its outputs are in scope. ## Terminal status - No task open and none failed, no action failed → `succeeded`. Skipped tasks do not change this. - No task open and none failed, at least one action left failed → `partially succeeded`. - Any task settled as `failed` → `failed`, and the error names those tasks with the reasons given. - An action is waiting → `waiting`. - Turn limit reached with open tasks, a second consecutive reply without a tool call, or an unrecoverable agent error → `failed`. Steps the agent never selected are marked `skipped` when the run reaches a terminal state.