Repository navigation
Workflow as a step
A step of a path may be a whole workflow (#263): an ordinary @Workflow of the same application,
runnable and testable on its own (app.execute(VendorCheckWorkflowStart, …)), used by its parent
as a component "start input → one of its finishes' outputs". It is the answer to a chain of
steps per item — check → deep dive → score, with its own branches, repeats and failures per item —
where a per-item launch of one agent (.toParallelAgents(A)) runs exactly one step per item
(Workflow → Parallel launches).
Status. #263 PR 1 — the grammar, the analyser and the generated types; PR 2 — children at run time: a subgraph node of the parent, their own executions,
ChildExecutionFailedError, nested records (result.children),toHaveChildExecutions/toHaveHandledError, and a child as an error handler (toWorkflow(W)side paths). Recursion —ThisWorkflow,maxDepth, the soft stop — is #264 PR 1 (below); the tree'smaxConcurrentExecutionsis #264 PR 2 (FinOps → Concurrency of a tree). Runnable examples:examples/flow-patterns/src/vendor/,examples/flow-patterns/src/topic/.
// the child: one vendor, entered by a VendorDto or by a link; its own branches and soft failure
@Workflow({
name: "vendor-check-workflow",
version: "1.0.0",
flow: [
start(VendorCheckWorkflowStart)
.returns(VendorDto)
.toAgent(VendorCheckerAgent),
start(VendorLinkCheckWorkflowStart)
.returns(VendorLinkDto)
.toAgent(VendorCheckerAgent),
agent(VendorCheckerAgent) // two ways in (two starts) → its own path
.onError(
StepFailedError, // caught inside the child: one more result class for the parent
toAction(MarkVendorFailedAction)
.returns(VendorCheckFailedDto)
.toFinish(VendorCheckFailedWorkflowFinish),
)
.returns(oneOf(VendorScoreDto, VendorRejectedDto))
.switch(
caseOf(VendorScoreDto)
.toFinish(VendorScoredWorkflowFinish),
caseOf(VendorRejectedDto)
.toFinish(VendorRejectedWorkflowFinish),
),
],
maxSteps: 10,
})
export class VendorCheckWorkflow {}
// a parent, per item: one child per VendorDto (through vendor-check-workflow-start), 3 at once
export const perItemPaths = [
start(VendorScreeningWorkflowStart)
.returns(WorkflowStartTextDto)
.toAgent(VendorFinderAgent)
.returns(listOf(VendorDto))
.toParallelWorkflows(VendorCheckWorkflow, { maxConcurrency: 3, maxItems: 50 })
.join()
.onJoinError(
LimitExceededError, // a limit: only actions and finishes after it
toAction(NotifyOperatorAction)
.toFinish(ScreeningStoppedWorkflowFinish),
)
.returns(listOf(oneOf(VendorScoreDto, VendorRejectedDto, VendorCheckFailedDto)))
.toAgent(VendorRankerAgent)
.returns(VendorRankingDto)
.toFinish(VendorRankingWorkflowFinish),
] as const;
// a parent, one child, one way in: .onError right after it, one case per finish output class
export const inlinePaths = [
start(ContractWorkflowStart)
.returns(WorkflowStartTextDto)
.toAgent(VendorPickerAgent)
.returns(VendorDto) // enters vendor-check-workflow-start
.toWorkflow(VendorCheckWorkflow)
.onError(StepFailedError, toFinish(CheckErrorWorkflowFinish))
.returns(oneOf(VendorScoreDto, VendorRejectedDto, VendorCheckFailedDto))
.switch(
caseOf(VendorScoreDto)
.toAgent(ContractAgent)
.returns(ContractDto)
.toFinish(ContractWorkflowFinish),
caseOf(VendorRejectedDto)
.toFinish(NoContractRejectedWorkflowFinish),
caseOf(VendorCheckFailedDto)
.toFinish(NoContractFailedWorkflowFinish),
),
] as const;
// a parent, one child reached from two places: its own path, each edge with its own start
export const ownPaths = [
start(PickedContractWorkflowStart)
.returns(WorkflowStartTextDto)
.toAgent(VendorPickerAgent)
.returns(VendorDto) // enters vendor-check-workflow-start
.toWorkflow(VendorCheckWorkflow),
start(LinkContractWorkflowStart)
.returns(WorkflowStartTextDto)
.returns(VendorLinkDto) // enters vendor-link-check-workflow-start
.toWorkflow(VendorCheckWorkflow),
workflow(VendorCheckWorkflow)
.onError(StepFailedError, toFinish(CheckErrorWorkflowFinish))
.returns(oneOf(VendorScoreDto, VendorRejectedDto, VendorCheckFailedDto))
.switch(
caseOf(VendorScoreDto)
.toAgent(ContractAgent)
.returns(ContractDto)
.toFinish(ContractWorkflowFinish),
caseOf(VendorRejectedDto)
.toFinish(NoContractRejectedWorkflowFinish),
caseOf(VendorCheckFailedDto)
.toFinish(NoContractFailedWorkflowFinish),
),
] as const;| Form | |
|---|---|
.toWorkflow(W) |
one child execution of W; also route(W) among a router's routes |
.toParallelWorkflows(W, { maxConcurrency, maxItems }) |
one child per item, right after .returns(listOf(ItemDto)) (else it does not compile); one workflow class; maxItems required; followed by .join() like any launch (Workflow → Parallel launches); a router launches agents only |
| One way in → inline | a child reached from one place is continued right after .toWorkflow(W) (.onError(…), .returns(…), .switch(…)); an own path for it → [flow.single-entry-own-path]
|
| Two or more ways in → own path |
workflow(W).onError(…).returns(oneOf(…)).switch(…); every path into it ends with .toWorkflow(W); inline → [flow.shared-node-inline]
|
| One class = one node |
W mentioned again in the graph is the same node; two stages running the same child are two workflow classes ([flow.continuation-twice]) |
| The kind is checked |
toAgent(VendorCheckWorkflow) does not compile, and is [graph.wrong-kind] in untyped code |
| Not listed | a child need not be in @Application({ workflows }): it belongs to its parent's application, which assembles it and runs it alone through its own starts (app.execute(VendorCheckWorkflowStart, …)) |
The start is chosen per edge into the child, by class — it is never named, so two paths into
workflow(W) may enter through two starts (above: vendor-check-workflow-start from the picker,
vendor-link-check-workflow-start from the link):
| Edge | Start |
|---|---|
| The child has one start | it — also after an action without output or from route(W), where the chain carries no value |
Per item (.toParallelWorkflows) |
the start with a required field of the item's type; the item fills it |
Single launch (.toWorkflow, a join's .to…) |
the start with a required field of the chain's current value — the .returns / caseOf right before |
| Several such starts | the one whose other required fields are all on the parent's path |
None → [graph.no-matching-start], naming the starts and their inputs; still two →
[graph.ambiguous-start], fixed by a subclass as one start's input
(class QuickCheckRequestDto extends VendorDto {}). Both are compile errors too: the child's
generated Inputs entry carries the message, so .toWorkflow(W) does not compile. A start's input
may be a list (input: listOf(VendorDto) takes a whole list, .returns(listOf(VendorDto)).toWorkflow(W))
or a map of fields ({ vendor: VendorDto, request: optional(SearchRequestDto) }): the item or the
current value fills its field, the other fields come from the parent's path, and an optional one
may be missing — the same child is entered from parents that have it and parents that do not.
agboss describe prints each edge's start:
children
vendor-finder-agent → vendor-check-workflow per item: vendor-check-workflow-start (VendorDto)
link-contract-workflow-start → vendor-check-workflow: vendor-link-check-workflow-start (VendorLinkDto)
-
The child's scope starts with its start input only: nothing of the parent's scope is visible
inside it (Path scope → Child workflows). A node of the child that
reads a class only the parent has is
[input.missing-on-path]in the child's own check; on the parent's side the chosen start's required fields must be on every path into the edge ([input.missing-on-path],[input.outdated-on-path],[input.several-on-path]). -
It gives back one value: the output of the finish it ended at. Its result is the set of its
finishes' output classes —
.returns(XDto)for one class,.returns(oneOf(…)).switch(caseOf(…)…)with exactly one case per class otherwise (a missing or extra class →[flow.switch-not-exhaustive]/[flow.switch-unknown-case], and a compile error throughProducerOutputs). Two finishes with one output class are one case. AcaseOf(XDto)counts as readingXDto([flow.unused-output]never fires on a branch that ends in a finish). - After a per-item launch the join gives one polymorphic list:
.join().returns(listOf(oneOf(VendorScoreDto, VendorRejectedDto, VendorCheckFailedDto))), checked against the generatedJoinOutputsentrytoParallelWorkflows(vendor-check-workflow) per item; a node reads it as thatlistOf(oneOf(…))field (#299 S53: no filteredlistOf(VendorScoreDto)slot).
| Where | |
|---|---|
| One child, one way in |
.onError(E, path) right after .toWorkflow(W)
|
| One child, own path |
.onError(E, path) right after workflow(W)
|
| Per item |
.onJoinError(E, path) right after the join — .onError there does not compile, nor .onJoinError after .toWorkflow(W)
|
| Soft failure | an error the child catches itself, ending at a failure finish (VendorCheckFailedDto above), is one more ordinary result class for the parent — the way to skip a failed item and go on |
| No soft failure | a per-item child with no finish reachable from one of its .onError side paths warns [graph.child-without-failure-finish] — one failed item would cancel the others (FailFast); an error under agboss check --strict; no flag silences it |
| After a limit | a side path that can catch a LimitExceededError — the class, a subclass (BudgetExceededError, VisitsLimitExceededError, …) or a parent (AGBossError, Error) — leads only to actions and finishes, never paying past the limit: [graph.limit-handler-uses-model] when it reaches an agent, a router or a child workflow. This holds for every .onError / .onJoinError, not only a child's |
An uncaught error of the child fails it, and the parent's step fails with
ChildExecutionFailedError (step.child, a StepFailedError): cause — the child's error,
childExecutionId, itemIndex (a per-item run's), workflow, and the step's coordinates
(component, nodeId, visitNumber). The child's own limits are failures of the child and
wrapped the same way: its maxSteps (StepsLimitExceededError in cause), the maxVisits of its
nodes. So .onError(StepFailedError, …), .onError(ChildExecutionFailedError, …) and
.onJoinError(…) at the parent take them, and their side path may lead to an agent — the parent's
steps and the tree's money are not spent. A handler naming the cause (.onError(ItemFailedError, …), .onError(StepsLimitExceededError, …)) catches it through the cause chain; naming a limit, it
leads only to actions and finishes.
Tree-wide limits are never wrapped: an exhausted budget (BudgetExceededError) and a
cancellation (ExecutionCancelledError) stop the whole execution as they are — .onError(StepFailedError, …)
never takes them. Every call of the tree is counted the moment it is made (#338): when one child's
call exhausts a budget, the model and tool calls in flight of its sibling children (and of every
other branch of the tree) are aborted at once, those children fail with that BudgetExceededError
and their spend so far, and the join takes .onJoinError(BudgetExceededError, …)
(FinOps → At run time). Per item, FailFast holds: the running children are cancelled (their spend stays
counted, their observers get onExecutionError with ExecutionCancelledError), no new one starts,
and the join takes .onJoinError(…) against the parent's scope as it was before the launch —
the children that finished first are in result.children / result.record.children and the
traces, never in the side path's scope. There is no FailurePolicy.Skip for children: skipping a
failed item is the child catching its error and ending at a failure finish.
toWorkflow(W) is a side-path starter beside toAction / toAgent / toRouter / toFinish, in
.onError and .onJoinError: a failure is handled by a whole workflow, entered through its one
start (or the start the path's value fits), its result continued like after .toWorkflow(W). A
side path that can catch a limit stays actions and finishes only
([graph.limit-handler-uses-model]).
export const recoveryPaths = [
start(PayWorkflowStart)
.returns(PaymentDto)
.toAction(ChargeCardAction)
.onError(
StepFailedError, // the charge failed: the recovery workflow takes it from here
toWorkflow(PaymentRecoveryWorkflow)
.returns(PaymentDoneDto)
.toFinish(RecoveredWorkflowFinish),
)
.returns(PaymentDoneDto)
.toFinish(PaidWorkflowFinish),
] as const;A child is a node of its parent whose subgraph is the child's compiled graph (S24, S37):
LangGraph Studio draws it inside the parent (getGraphAsync({ xray: true })), and it runs on the
parent's checkpoint thread under the launching task's checkpoint_ns — the node and the task's id,
so a second pass of a loop or a neighbouring item never reads an earlier child's checkpoint. One
class is one node; only ThisWorkflow (#264) is run from the registry — the workflow's own
compiled graph, never its own subgraph (Recursion).
| Identity | its own executionId, made on its first entry and kept in its own state: a pause, a restart or a crash continues it with the same id — no second onExecutionStart, finished siblings not run again |
| What it sees | its start's input only: no thread history, nothing of the parent's scope or its siblings; its own path, steps and maxSteps
|
| Inherited | the thread, ownerId, the tenant (executionContext.tenant, the same TenantContext and ApplicationContext) and the execution's own fields of the declared ExecutionContext — through its state, so a resume keeps them |
| The tree |
executionContext.parent, .root, .depth, .itemIndex (a per-item child's index from 0; undefined for a single child and a root), .executionOf(W) — the nearest execution of W from this one up, at any depth; ExecutionTreeRepository keys are the root's |
| Money | a child spends from the root's budgets — the root's execution budget is the cost of one full pass, children included; its own spend is counted in the tenant and application budgets too, the moment each call is made (#338: its siblings see it while they run); a child workflow's own execution budget applies only when it runs as the root (app.execute) |
| PII tokens | it starts with the parent's token map (#120), so it reveals what the parent masked; its own come back with its value — or, when it fails, with its error into the parent's state (never on the error itself), so the side path that takes it reveals what the child masked in its finished steps; a per-item child's too |
| Steps | each workflow counts its own: a child's steps never spend the parent's maxSteps
|
| Concurrency |
maxConcurrency of a per-item launch: a sliding window, a child frees its slot when it ends, fails or pauses |
| Pauses | a paused child does not stop its siblings — the execution pauses only when nothing else can move; its pause is the root's, in result.pending with its childExecutionId (and itemIndex per item), and app.resume({ pauseId, decision }) continues it where it waits; once resolved, it is in the child's record as pauses (#267 S124) (Pauses). A sibling child's failure under FailFast, or a decided quorum, withdraws a waiting child's pauses (#337: a pause records the launch branches above it) |
| Traces | a nested run named after the child workflow under the parent's step, its metadata executionId, parentExecutionId, rootExecutionId, itemIndex
|
Records. Every child execution that ended is a nested record in result.children (and
result.record.children), in the order they ended — never in the thread's conversation history:
executionId, parentExecutionId, rootExecutionId, workflow, itemIndex, launchedBy
(nodeId, visitNumber), start, input, status (ChildExecutionStatus.Finished /
Failed / Cancelled), finish and output or error, its own path, its own spend and its own
children. result.spend holds the whole tree's. result.handledErrors lists the failures a side
path took (error, at, by).
The application's rules. A child need not be listed in @Application({ workflows }): the
workflows are collected transitively, and an unlisted child goes through every rule a listed one
does — names ([name.kind-suffix], [graph.duplicate-node-name]), its models
([model.unserved], [model.ambiguous-provider]), DI and infrastructure ([di.*], infrastructure
only in the application's providers, so [environment.missing-field] comes from what the
application lists), channel roles. Budgets aside: a child spends from its root's, so it needs no
execution budget of its own.
Worst case. The start log and describe count the children: one call of a start is the
execution itself plus each child edge's worst case, maxItems times per item (at most
maxConcurrency at once) — Worst case of one vendor-screening-workflow-start call: 51 workflow executions, 4 at the same time, ….
A workflow launches itself with the marker ThisWorkflow — a decorator's arguments are
evaluated before its class exists, so the class cannot name itself: .toWorkflow(ThisWorkflow),
route(ThisWorkflow), .toParallelWorkflows(ThisWorkflow, { maxConcurrency, maxItems }).
Everything else is a child's: its start chosen by class, its scope its start input only, its result
one of its finishes' outputs, its errors and limits. ThisWorkflow is one node, this-workflow
(from two places: its own path workflow(ThisWorkflow)…). A topic too broad for one report is split
and each subtopic researched the same way:
@Workflow({
name: "topic-research-workflow",
version: "1.0.0",
flow: [
start(TopicResearchWorkflowStart)
.returns(TopicDto)
.toAgent(ResearcherAgent)
.onError(StepFailedError, toAction(MarkTopicFailedAction)) // a failed topic is skipped softly
.returns(oneOf(TopicReportDto, TopicTooBroadDto))
.switch(
caseOf(TopicReportDto)
.toFinish(TopicReportWorkflowFinish),
caseOf(TopicTooBroadDto) // left out of the researcher's schema on the last level
.toAction(SubtopicsAction)
.returns(listOf(TopicDto))
.toParallelWorkflows(ThisWorkflow, { maxConcurrency: 3, maxItems: 5 })
.join()
.returns(listOf(oneOf(TopicReportDto, TopicFailedDto)))
.toAgent(MergeAgent)
.onError(StepFailedError, toAction(MarkTopicFailedAction))
.returns(TopicReportDto)
.toFinish(TopicReportWorkflowFinish),
),
action(MarkTopicFailedAction) // two ways in: its own path
.returns(TopicFailedDto)
.toFinish(TopicFailedWorkflowFinish),
],
maxDepth: 3,
})
export class TopicResearchWorkflow {}The depth. @Workflow({ maxDepth }) is required on a workflow that can reach ThisWorkflow
([graph.recursion-without-depth], a whole number from 1). executionContext.depth is 1 at the
root, 2 for its children and so on — the same in every child record (result.children[…].depth) and
in each child's trace metadata — so maxDepth: 3 allows levels 1–3 and the last level is
depth === maxDepth. No model sees the depth (Q52): instructions are static and context values
never enter a prompt; a level the domain cares about is data in input — TopicDto.level, set by the
action that splits a topic. The two differ anyway: a recursive workflow started as someone's child
starts deeper than 1 while its first topic's level is 0.
The soft stop. On the last level the framework, not the prompt, stops the recursion. Of every
branch — a .switch(caseOf(…)), a .chooseOne(route(…)) — the options whose path launches
ThisWorkflow before the next branch are not offered:
| Who decides | On the last level |
|---|---|
an agent's oneOf answer |
the recursive variants' answer tools are not bound and the answer is checked against the narrowed schema — a second static request prefix, so a second cache prefix for that level (Agents) |
| a model router | its model does not see the recursive routes (one route left: chosen without a model call) |
| a code router |
route() returning one fails with RouterDecisionError — "not ready on the last level" |
code or data (an action's or a child's oneOf, a launch with no branch) |
the launch past maxDepth does not start: the hard stop below |
So every branch with a recursive option keeps one that is not ([graph.recursion-without-exit]); a
path with no branch at all — an action whose list of subtopics comes back empty — ends by its data.
The hard stop. A launch decided by code past maxDepth does not start — no execution, no
onExecutionStart — and fails the launching step with ChildExecutionFailedError caused by
DepthLimitExceededError (limit.depth; workflow, maxDepth, depth: topic-research-workflow went 5 levels deep; its maxDepth is 4). It is a child's own limit (S25): .onError(LimitExceededError, …) right after .toWorkflow(ThisWorkflow), or .onJoinError(LimitExceededError, …) after a per-item
launch's join, takes it — leading only to actions and finishes ([graph.limit-handler-uses-model]).
The refused launch is a failed child record with an empty path.
The tree. Each execution has its own maxSteps; the root's execution budget is the whole pass,
every level included, and the tenant and application budgets count it too. Only direct recursion:
a workflow launching one that launches it again — or naming its own class — is
[graph.mutual-recursion] (as is a class undefined while the flow is built, the trace of a
circular import). At run time ThisWorkflow is the workflow's own compiled graph, taken from the
registry when its step runs (a graph cannot contain itself): one checkpoint thread, its children
under their tasks' checkpoint_ns, pauses inside any level resumed like any child's; Studio draws it
as the node this-workflow, which getGraphAsync({ xray: true }) marks recursive: maxDepth 3 in its
metadata (CLI → Watch mode and Studio).
The tree's cap. maxDepth bounds how deep the tree goes, the launches' maxConcurrency how
wide one launch goes, and the root's @Workflow({ maxConcurrentExecutions: 6 }) (default 10) how many
of the tree's executions work at once — the stricter wins. A level whose children run holds no slot,
so a cap of 1 still finishes; the queue serves a level closing its branch first, then the deepest
(FinOps → Concurrency of a tree).
Seen in. agboss describe: recursive: ThisWorkflow, maxDepth 3, on the last level: researcher-agent → TopicReportDto only, and each input with its producers — merge-agent ← topic: topic-research-workflow-start, results: this-workflow ×0–5: the execution's own topic from its start,
never a subtopic (a listOf(TopicDto) never fills one TopicDto field). The worst case counts the
levels down to maxDepth.
Typed per path (S112). ThisWorkflow is one class for every recursive workflow, so what it
gives is not one project-wide entry: agboss check writes one ThisWorkflowOutputs entry per
recursive workflow, keyed by its name — input (the problems of launching it, or NoInput),
output (what one child gives: its finishes' outputs) and, with a per-item launch, join:
interface ThisWorkflowOutputs {
"topic-research-workflow": { readonly input: NoInput; readonly output: TopicFailedDto | TopicReportDto; readonly join: readonly (TopicFailedDto | TopicReportDto)[] };
"org-chart-workflow": { readonly input: NoInput; readonly output: OrgChartFailedDto | TeamChartDto; readonly join: readonly (OrgChartFailedDto | TeamChartDto)[] };
}A .returns(…) right after a ThisWorkflow launch — .toWorkflow(ThisWorkflow), route(ThisWorkflow),
workflow(ThisWorkflow), the side-path starter toWorkflow(ThisWorkflow), or the .join() of
.toParallelWorkflows(ThisWorkflow, …) — is not checked where it is written: the path's type
carries it as a claim through every sub-path (caseOf, route, .switch, .chooseOne,
.onError, .onJoinError) up to @Workflow({ name, flow }), which checks every claim of its flow
against its own entry. A wrong class is a compile error on flow (a .returns(…) after ThisWorkflow is not what org-chart-workflow gives — see ThisWorkflowOutputs["org-chart-workflow"]["output"]);
the project code is the same as for any child. Several recursive workflows with different finishes
live in one project.
expect(result).toHaveChildExecutions(VendorCheckWorkflow, [
{ item: { name: "Acme" }, finish: VendorScoredWorkflowFinish },
{ item: { name: "Globex" }, failedWith: StepsLimitExceededError },
{ item: { name: "Initech" }, cancelled: true },
]);
expect(result).toHaveHandledError(ChildExecutionFailedError, {
at: VendorCheckWorkflow,
by: NotifyOperatorAction,
});toHaveChildExecutions(W, [...]) — the executions of W anywhere in the result's tree, in item
order, each with its item (its start input, or one field of a map input) and how it ended:
finish, failedWith (a class of its error or of a cause) or cancelled. toHaveHandledError(E, { at, by }) — an error of E (or caused by one) happened at at and the side path starting at by
took it. Testing has the rest.
-
Waves:
.toParallelAgents(A)→.join()→ an action mapping to the next list →.toParallelAgents(B)— everyone together, step by step, the same short chain for all. - A child workflow per item: each item its own way — no barrier between its steps, its own branches, repeats and failure isolation.
-
A child workflow to keep a path shallow: a branch deeper than 4 levels (
[flow.path-too-deep]) moves into a child.
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback — declared compensation
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Health checks
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Naming
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)