Step Functions: Orchestrate Multi-Step Coding Agent Graphs Without Recursive Chaos

0 views

Your coding agent needs to plan, call read_file, fan out three search tools, wait for a human to approve a git_push, then summarize. The naive pattern — a Lambda that invokes itself with InvocationType=Event until depth hits a ceiling — is how teams burn concurrency, lose stack traces, and invent homemade circuit breakers. Step Functions already solved orchestration: state machines with Map/Parallel, Express vs Standard cost models, and waitForTaskToken for human-in-the-loop. Use that instead of teaching every agent runtime to be a scheduler.

⚡ TL;DR: Model tool graphs as Step Functions (Express for high-throughput tool fan-out, Standard when you need human approval or runs >5 minutes). Prefer Map/Parallel over recursive Lambda. Pair with Lambda Recursive Loop Protection, AppConfig kill switches, and AI Coding Agent Tool Schemas. waitForTaskToken beats polling DynamoDB for approvals.

Express vs Standard for agent tool graphs

Pick the workflow type from the failure and duration profile of the agent turn — not from habit.

Need Prefer Why
Sub-second tool fan-out, >100k starts/day Express At-least-once, 5 min max, cheap per-transition
Human approval mid-graph, runs >5 min Standard Exactly-once execution semantics, 1 year max, history retained
Mix (fast tools then slow review) Standard outer + Express nested Nest Express for Map fan-out inside Standard
// ❌ Recursive self-invoke "orchestrator" — depth, quotas, silent drops
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
const lambda = new LambdaClient({});

export async function agentStep(event: { depth: number; messages: unknown[] }) {
  if (event.depth >= 8) throw new Error("max depth");
  const toolCalls = await planTools(event.messages);
  for (const call of toolCalls) {
    await runTool(call);
  }
  // hope nothing double-fires, hope DLQ exists, hope CloudWatch catches it
  await lambda.send(
    new InvokeCommand({
      FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME!,
      InvocationType: "Event",
      Payload: Buffer.from(JSON.stringify({ ...event, depth: event.depth + 1 })),
    })
  );
}

✅ Move the graph into ASL. The Lambda becomes a worker, not a scheduler. Recursive Loop Protection still matters for accidental self-invokes — see Lambda Recursive Loop Protection — but it should not be your primary orchestrator.

Map and Parallel for tool fan-out

When the model returns three independent tool calls (read_file × 2 + grep), do not serialize them in a single Lambda. Use Map (dynamic cardinality) or Parallel (fixed branches).

{
  "Comment": "Coding agent tool graph — Express-friendly fan-out",
  "StartAt": "PlanTools",
  "States": {
    "PlanTools": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:agent-planner",
        "Payload.$": "$"
      },
      "ResultSelector": { "toolCalls.$": "$.Payload.toolCalls" },
      "Next": "FanOutTools"
    },
    "FanOutTools": {
      "Type": "Map",
      "ItemsPath": "$.toolCalls",
      "MaxConcurrency": 5,
      "Iterator": {
        "StartAt": "InvokeTool",
        "States": {
          "InvokeTool": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
              "FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:agent-tool-runner",
              "Payload.$": "$"
            },
            "Retry": [
              {
                "ErrorEquals": ["States.TaskFailed"],
                "IntervalSeconds": 1,
                "MaxAttempts": 2,
                "BackoffRate": 2.0
              }
            ],
            "End": true
          }
        }
      },
      "ResultPath": "$.toolResults",
      "Next": "Summarize"
    },
    "Summarize": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:agent-summarizer",
        "Payload.$": "$"
      },
      "End": true
    }
  }
}
// ✅ Tool runner stays dumb — schema + AppConfig gate only
import { z } from "zod";

const ToolCall = z.object({
  name: z.enum(["read_file", "grep", "apply_patch", "shell_exec"]),
  args: z.record(z.unknown()),
  idempotencyKey: z.string().min(8),
});

export async function handler(event: unknown) {
  const call = ToolCall.parse(event);
  // gate with AppConfig kill switches before side effects
  if (!(await isToolEnabled(call.name))) {
    return { ok: false, error: "tool_disabled", name: call.name };
  }
  return runToolIdempotent(call);
}

Cap MaxConcurrency so a buggy planner cannot open 200 shell tools at once. Combine with tool contracts from AI Coding Agent Tool Schemas.

waitForTaskToken: human-in-the-loop without busy-wait

For git_push, deploy_prod, or any irreversible tool, pause the graph until a human (or a secondary policy service) calls SendTaskSuccess / SendTaskFailure.

// ✅ Standard workflow task with callback token
// ASL snippet concept: Resource = arn:aws:states:::lambda:invoke.waitForTaskToken
import {
  SFNClient,
  SendTaskSuccessCommand,
  SendTaskFailureCommand,
} from "@aws-sdk/client-sfn";

const sfn = new SFNClient({});

// Approval API (API Gateway → Lambda) called by your review UI
export async function approvePush(req: {
  taskToken: string;
  approved: boolean;
  reviewer: string;
}) {
  if (req.approved) {
    await sfn.send(
      new SendTaskSuccessCommand({
        taskToken: req.taskToken,
        output: JSON.stringify({ approvedBy: req.reviewer, at: new Date().toISOString() }),
      })
    );
  } else {
    await sfn.send(
      new SendTaskFailureCommand({
        taskToken: req.taskToken,
        error: "HumanRejected",
        cause: `rejected_by=${req.reviewer}`,
      })
    );
  }
}

Store the taskToken in DynamoDB keyed by session/PR id with a TTL. Do not embed the raw token in a public URL without signing. Heartbeat with SendTaskHeartbeat if the approval window is long — otherwise Standard workflows time out the task.

❌ Polling a approvals DynamoDB table from a Lambda every 5 seconds while the state machine sits in a Wait state wastes money and races. Prefer callback tokens.

Contrast: recursive Lambda vs Step Functions

Concern Recursive Lambda Step Functions
Visibility CloudWatch logs per hop Execution history / Map runs
Retries DIY Declarative Retry/Catch
Human pause Homemade waitForTaskToken
Cost at high QPS Lambda duration × hops Express transitions cheaper
Loop protection Required (post) Native graph; still guard workers

Keep Recursive Loop Protection on for tool Lambdas that might still self-invoke by bug. Kill switches in AppConfig still gate which tools the Map iterator may call.

Error handling and Catch paths

Agent graphs fail in two ways: model/planner errors (bad JSON, empty tool list) and tool errors (patch conflict, sandbox timeout). Model ASL Catch separately:

{
  "Catch": [
    {
      "ErrorEquals": ["ToolSchemaError"],
      "ResultPath": "$.error",
      "Next": "RespondValidationError"
    },
    {
      "ErrorEquals": ["States.ALL"],
      "ResultPath": "$.error",
      "Next": "RespondTransientError"
    }
  ]
}

Surface validation errors to the user immediately; route transient tool failures through retries then a friendly “partial results” summarize state. Do not recurse the whole agent Lambda to “try again” — that is how you rediscover why Recursive Loop Protection exists.

Operational checklist

  • [ ] Choose Express vs Standard from duration + human-in-the-loop needs
  • [ ] Use Map with MaxConcurrency for fan-out tool calls
  • [ ] Put irreversible tools behind waitForTaskToken + signed approval API
  • [ ] Keep tool Lambdas idempotent (idempotencyKey in payload)
  • [ ] Leave Recursive Loop Protection enabled on workers; do not orchestrate via self-invoke
  • [ ] Wire AppConfig kill switches into the tool runner, not only the planner
  • [ ] Alert on ExecutionsFailed and Map item failures separately from agent “model errors”

Related reading

Last updated on September 20, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.