Repository navigation
Advanced Routing
This document serves as a cheat sheet and specification for the GraphCompose DSL flow: block, detailing all capabilities for routing, parallelism, and barrier synchronization.
The simplest scenario. Agents unconditionally pass execution to the next node in the chain.
flow: [
from(StartNode).next(AgentA),
from(AgentA).next(AgentB),
from(AgentB).next(FinishNode)
]Hardcoded branching. The graph unconditionally splits into a known number of paths and waits for all of them.
flow: [
from(StartNode).next(PreparationAgent),
// Branch into 3 parallel paths
// Note: you can use required() or background() to indicate if the main execution should wait for this branch
from(PreparationAgent).nextParallel({
awaiting: [AgentA, AgentB],
fireAndForget: [AgentC]
}),
// Barrier: waits for all 3 branches to finish
from(AgentA, AgentB, AgentC).join(AggregatorAgent),
from(AggregatorAgent).next(FinishNode)
]When splitting a process into multiple independent paths (not a map-reduce fork, but true structural branching), you use nextParallel. You can specify which branches the main execution should wait for (required) and which can be run asynchronously in a fire-and-forget manner (background).
flow: [
from(StartNode).next(OnboardingAgent),
// Branch the process into two independent tasks
from(OnboardingAgent).nextParallel({
awaiting: [MainAgent], // The user waits for this branch to finish
fireAndForget: [AnalyticsAgent] // Runs in the background, doesn't block the execution output
}),
// They don't need a join! They go to their own separate finishes:
from(MainAgent).next(SuccessFinish),
from(AnalyticsAgent).next(SilentlyFinish)
]Note: True early-return execution for background branches relies on engine-level support; otherwise it defaults to standard parallel execution.
The number of parallel branches is unknown until runtime (data-dependent). GraphCompose uses LangGraph's Send API under the hood to dynamically spawn N instances of an agent.
To preserve Dependency Injection and clean architecture, the extraction logic is placed in a dedicated @BatchStrategy class, rather than an inline lambda.
import { BatchStrategy, type BatchParallelStrategy } from "graphcompose/concurrency";
// The strategy decides WHICH elements are sent to the dynamic agents
@BatchStrategy({ name: "url_extractor" })
export class UrlExtractor implements BatchParallelStrategy<MyState, string> {
// Supports DI!
constructor(private readonly filterService: FilterService) {}
extract(state: MyState): string[] {
return this.filterService.filterUrls(state.urls);
}
}flow: [
from(WorkflowStart).next(DispatcherAgent),
// Extracts an array from the state via the DI Strategy class.
// Spawns a ScraperAgent for each element, limited to 5 concurrent instances.
// The join barrier automatically waits for all spawned instances to finish.
from(DispatcherAgent)
.batchParallel(ScraperAgent, UrlExtractor, { concurrency: 5 }),
from(ScraperAgent).join(SummaryAgent),
from(SummaryAgent).routes(WorkflowFinish)
]The router analyzes the payload and must choose strictly one of the provided paths.
flow: [
from(StartNode).next(ValidatorRouter),
// Either success or error (cannot run concurrently)
from(ValidatorRouter).routeOne(SuccessAgent, ErrorAgent),
// The barrier will wait for whichever path was chosen
from(SuccessAgent, ErrorAgent).join(Finalizer),
from(Finalizer).next(FinishNode)
]The classic "call an expert if needed, otherwise skip" scenario.
flow: [
from(StartNode).nextParallel(MainWorker, ComplianceRouter),
// The router either directs to LegalAgent, or cancels this branch (Skip)
from(ComplianceRouter).routeOneOrSkip(LegalAgent),
// Finalizer will wait for MainWorker and LegalAgent.
// If Legal was skipped, the barrier detects the 'aborted' status and proceeds without deadlocking.
from(MainWorker, LegalAgent).join(Finalizer),
from(Finalizer).next(FinishNode)
]A router can launch multiple branches in parallel. Any unselected branches are automatically skipped.
flow: [
from(StartNode).next(ExpertiseRouter),
// Choose any subset of the 3 paths. Provide constraints to the LLM router.
from(ExpertiseRouter).routeManyOrSkip(FinanceAgent, HrAgent, TechAgent, {
maxChoices: 2, // Cannot pick more than 2
required: [FinanceAgent] // Finance must always be included
}),
// Wait only for the branches the router actually launched (others are skipped)
from(FinanceAgent, HrAgent, TechAgent).join(Aggregator),
from(Aggregator).next(FinishNode)
]The @QuorumRouter is a specialized routing node in GraphCompose designed to act as an active synchronization barrier (fan-in) for parallel branches. Unlike a simple .join() which waits for all parallel agents to finish, .joinQuorum() actively monitors results as they come in, allowing for powerful short-circuiting, timeouts, and "Fail-Fast" behavior.
Configuration is supplied at the point of graph assembly to maximize router reusability:
from(Worker1, Worker2, Worker3)
.joinQuorum(SecurityQuorum, {
min: 2,
max: 3,
timeoutSeconds: 30
})Depending on how you combine min, max, and timeoutSeconds, the framework intelligently adapts its behavior. Here are the 4 primary scenarios:
(Under the hood, max automatically equals min)
-
Early Success: As soon as
minvotes are gathered, the remaining agents are aborted, and the router proceeds withhasQuorum = true. -
Fail-Fast: If too many agents vote "No" and it becomes mathematically impossible to reach
min, the remaining agents are aborted, and the router proceeds withhasQuorum = false.
Use case: "I want Y votes for a perfect consensus, but I'll settle for X if the others fail."
-
Keep Waiting: If we reach
min, we do not abort the others yet. We keep waiting in hopes of reachingmax. -
Early Success: As soon as we reach
max, we abort the remaining agents and proceed withhasQuorum = true. -
Natural Finish: If all agents finish and we gathered between
minandmaxvotes, we proceed withhasQuorum = true. If we gathered< min, we proceed withhasQuorum = false.
Use case: "Try to get a perfect Y consensus quickly, but if Z seconds pass, just check if we at least got X."
-
Early Success: If
maxvotes are gathered before the timeout, abort the rest and proceed withhasQuorum = true. -
Timeout Triggered: If
Zseconds pass, abort the remaining agents. Evaluate what was gathered: ifvotes >= min, proceed withhasQuorum = true. Otherwise,hasQuorum = false. -
Fail-Fast: If too many negative votes come in early, we don't even wait for the timeout. We abort immediately with
hasQuorum = false.
(Under the hood, max automatically equals min)
-
Early Success: As soon as
minvotes are gathered, abort the rest and proceed withhasQuorum = true. We do not wait for the timeout. -
Timeout Triggered: If
Zseconds pass, it means we haven't reachedminyet. Abort the remaining agents and proceed withhasQuorum = false.
import {
Agent, Workflow, QuorumRouter, type QuorumStrategy,
from, Append, Tool, type ToolHandler, WorkflowStart, WorkflowFinish
} from "graphcompose";
@QuorumRouter({ name: "security_quorum" })
class SecurityQuorum implements QuorumStrategy<CodeAuditState> {
// Evaluated every time ANY agent finishes
filterVote(state: CodeAuditState): boolean {
const latestReport = state.reports.at(-1);
return latestReport?.severity === "CRITICAL";
}
// Evaluated ONCE when the quorum terminates
route(state: CodeAuditState, hasQuorum: boolean) {
if (hasQuorum) {
console.warn("Quorum reached! Halting release.");
return EmergencyLockdown;
}
console.log("Code is safe (or quorum failed), proceeding.");
return FinishAudit;
}
}
@Workflow({
name: "security_audit",
version: "1.0.0",
flow: [
from(CodeAuditState).nextParallel(StaticAnalyzer, DynamicAnalyzer, DependencyChecker, PenTester),
// The active barrier
from(StaticAnalyzer, DynamicAnalyzer, DependencyChecker, PenTester)
.joinQuorum(SecurityQuorum, {
min: 2, // We need at least 2 critical reports
max: 3, // If we get 3, stop immediately
timeoutSeconds: 45 // Don't wait longer than 45s for the slow agents
})
.routes(EmergencyLockdown, FinishAudit),
from(EmergencyLockdown).next(FinishAudit)
]
})
class SecurityAuditWorkflow {}-
AgentOutput<T>: An interface for Agents to declare their output DTO shape, giving strict typing to.batchParallel(..., Strategy). -
JoinArray<T>: A type alias (Array<ForkOutput<T>>) that hides generic complexity when writing aJoinHandlerfor dynamic parallelism. -
AgentState<T>: Wraps LangGraph's global state, providing strict type-safety over thepayloadproperty inside an agent'srunmethod. -
WorkflowContext: Replaces the genericFlowContext, providing access to infrastructure (likecontext.storefor large artifact saving without bloating the graph state).
GraphCompose supports composing workflows by nesting them as subgraphs. A @Workflow class can be passed directly to routing methods like .next() or .batchParallel(), just like a regular Agent.
When nesting workflows, state isolation is handled automatically. The subgraph runs within its own local state limits, resets path/steps counters, and interacts with the global SpendLedger for tracking token usage.
import { Workflow, from, WorkflowStart, WorkflowFinish } from "graphcompose";
// Define a Subgraph
@Workflow({
name: "sub_workflow",
flow: [
from(WorkflowStart).next(WorkerAgent),
from(WorkerAgent).next(WorkflowFinish)
]
})
class SubWorkflow {}
// Use it in a Parent Workflow
@Workflow({
name: "parent_workflow",
flow: [
from(WorkflowStart).next(StartAgent),
// Call the SubWorkflow directly
from(StartAgent).next(SubWorkflow),
// The subgraph's WorkflowFinish passes control back to the parent
from(SubWorkflow).next(WorkflowFinish)
]
})
class ParentWorkflow {}You can declare fallback paths for specific error types using the catchError syntax. If an Agent throws an error, execution can be routed to a specific FallbackAgent rather than failing the entire workflow.
import { Workflow, from, WorkflowStart, WorkflowFinish } from "graphcompose";
class NetworkError extends Error {}
@Workflow({
name: "error_handling_workflow",
flow: [
from(WorkflowStart).next(RiskyAgent),
// Normal success path
from(RiskyAgent).next(SuccessAgent),
// Error routing path
catchError(RiskyAgent, NetworkError).next(FallbackAgent),
from(SuccessAgent).next(WorkflowFinish),
from(FallbackAgent).next(WorkflowFinish)
]
})
class ErrorHandlingWorkflow {}GraphCompose supports SAGA patterns for distributed transactions. You can specify a compensating agent for any agent using the compensate property in the @Agent metadata. The SagaOrchestrator can then be used to trigger rollbacks if a subsequent step fails.
import { Agent, Workflow, from, SagaOrchestrator, WorkflowStart, WorkflowFinish } from "graphcompose";
// Define a compensating Agent
@Agent({ name: "cancel_payment_agent" })
class CancelPaymentAgent {
async run(state: MyState) {
// Logic to refund or cancel payment
}
}
// Define the primary Agent with a compensation link
@Agent({
name: "process_payment_agent",
compensate: CancelPaymentAgent
})
class ProcessPaymentAgent {
async run(state: MyState) {
// Logic to process payment
}
}
@Agent({ name: "failing_inventory_agent" })
class FailingInventoryAgent {
async run(state: MyState) {
throw new Error("Inventory check failed!");
}
}
@Workflow({
name: "saga_workflow",
flow: [
from(WorkflowStart).next(ProcessPaymentAgent),
from(ProcessPaymentAgent).next(FailingInventoryAgent),
// Catch the error and trigger the SAGA rollback
catchError(FailingInventoryAgent, Error).next(SagaOrchestrator),
// SagaOrchestrator will automatically invoke CancelPaymentAgent
from(SagaOrchestrator).next(WorkflowFinish)
]
})
class SagaWorkflow {}You might wonder why compensation logic is hidden inside @Agent({ compensate: ... }) metadata instead of being explicitly wired at the flow level.
If you have a simple, linear graph, you do not need the SagaOrchestrator. You can explicitly declare the rollback path right in your flow using standard catchError:
// Explicit rollback flow (no SAGA needed)
catchError(BookHotel, Error).next(CancelHotel),
from(CancelHotel).next(CancelFlight)However, manually wiring the rollback path breaks down immediately when you introduce parallel branches or dynamic routing:
// Agents working in parallel:
from(Start).nextParallel(BookHotel, BookFlight, RentCar)If RentCar fails, but BookHotel has already succeeded, while BookFlight is still processing... it is impossible to describe a static fallback route in the flow array because you don't know in advance which agents actually finished and need cancelling.
This is why the SAGA pattern ties the rollback logic to the component itself rather than the graph. The component guarantees: "No matter which complex, parallel workflow I am used in, if I succeed, this is how you cancel me." The SagaOrchestrator then dynamically reads the exact transaction history (state.path) and reliably rolls back only what was actually executed.
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Workflow — the graph
- Advanced Routing (DSL)
- Routers
- Guards
- CLI — agboss
- App — createApp
- Context and Authorization
- Testing
- Suites and Comparisons
- Components
- Standard DTOs
- Example: job-scout
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pause seam
- Decisions (ADR)