06 · Mixed Workflow — Agents *and* Executors in One Graph

This lesson teaches how deterministic function executors and agent-backed executors compose in one graph with the same `AddEdge` wiring.

What this lesson demonstrates

Earlier lessons kept the two node kinds apart: pure functions (streaming, subworkflow) or all agents (translation pipeline, multi-model service). This one mixes them in a single graph that classifies an input for jailbreak / prompt-injection and then answers it:

UserInput → Inverter1 → Inverter2 → StringToChat
          → JailbreakDetector (agent) → JailbreakSync
          → ResponseAgent (agent) → FinalOutput

Most nodes are deterministic — UserInput, the two inverters (which reverse the text and reverse it back, cancelling out so the agents see the original), StringToChat, JailbreakSync, FinalOutput. Two nodes are agents hosted via agentworkflow.New: a JailbreakDetector that classifies, and a ResponseAgent that answers or refuses. The graph does not care which kind a node is.

One builder, both node kinds

inverter1 := workflow.NewExecutor("Inverter1", textInverted).Bind()
stringToChat := workflow.NewExecutor("StringToChat", stringToChatMessageExecutor{}).Bind()

hostCfg := agentworkflow.Config{DisableForwardIncomingMessages: true}
detector := agentworkflow.New(
    newFoundryAgent(endpoint, model, cred, "JailbreakDetector", detectorInstructions, mw),
    hostCfg,
)

return workflow.NewBuilder(userInput).
    AddEdge(userInput, inverter1).
    // …
    AddEdge(stringToChat, detector).
    AddEdge(detector, jailbreakSync).
    AddEdge(jailbreakSync, responder).
    AddEdge(responder, finalOutput).
    WithOutputFrom(finalOutput).
    Build()

workflow.NewExecutor(...).Bind() produces the deterministic nodes; agentworkflow.New(agent, cfg) produces the agent nodes; plain AddEdge joins them all.

What to notice — agents need a TurnToken, and message contracts are typed

A hosted agent executor waits for a workflow.TurnToken before it takes its turn. That is why the intermediary executors send two things — their payload message and a turn token:

func (stringToChatMessageExecutor) Handle(ctx *workflow.Context, text string) error {
    if err := ctx.SendMessage("", message.NewText(text)); err != nil {
        return err
    }
    emitEvents := true
    return ctx.SendMessage("", workflow.TurnToken{EmitEvents: &emitEvents})
}

The struct executors also carry workflow.AttrSendsMessage[...] marker fields (e.g. AttrSendsMessage[*message.Message] and AttrSendsMessage[workflow.TurnToken]). These declare, at the type level, which message types the executor emits, so the builder can type-check the graph at build time. Forget the token and the downstream agent simply never fires.

JailbreakSync sits between the two agents: it parses the detector’s JAILBREAK: DETECTED/SAFE line, reformats it into a SAFE: or JAILBREAK_DETECTED: directive that matches the responder’s instructions, then forwards it plus a token.

How it maps to Azure AI Foundry

The two agent nodes call Azure AI Foundry (needs az login + FOUNDRY_PROJECT_ENDPOINT), while the guard rails around them stay deterministic and cheap. That mix — put your validation, parsing, and formatting in plain Go executors and reserve model calls for the two nodes that need judgment — is a practical shape for real pipelines, and it composes with DisableForwardIncomingMessages: true for strict per-node forwarding.

Run it: go run ./tutorial/03-workflows/01-start-here/06_mixed_workflow_agents_and_executors. The offline test builds the full graph with a fake credential and walks it edge by edge; the classification-parsing helpers are unit-tested directly. The live run is gated behind AF_LIVE=1.


Next: 07 · Writer ⇄ Critic Workflow (iterative refinement)

Sources & References

The Go SDK this lesson exercises
Mixing deterministic executors and agent executors in one graph