Skip to main content

ag2-network-workflow

Build an AG2 network `workflow` channel — the orchestrated N-party adapter driven by a declarative `TransitionGraph`. Use when the user needs conditional handoffs, multi-step pipelines, context-aware routing, feedback loops, or is migrating from the classic `GroupChat` + `Agent.handoffs` pattern. Covers `TransitionGraph`; factories `TransitionGraph.sequence(...)` / `.round_robin(...)`; built-in targets (`AgentTarget`, `RoundRobinTarget`, `StayTarget`, `RevertToInitiatorTarget`, `TerminateTarget`); built-in conditions (`Always`, `FromSpeaker`, `ToolCalled`, `ContextEquals`); the typed `Handoff` return for dynamic routing; channel-scoped context via `set_context`; `register_target` / `register_condition` for custom subclasses; the packet execution model and idempotent-tool requirement; eight cookbook patterns (pipeline, hierarchical, escalation, feedback loop, triage, and more); and side-by-side migration from classic `GroupChat`. Load this after `ag2-network-quickstart`.

Source facts

Repository
ag2ai/ag2-skills
Last source activity
July 1, 2026 at 01:38
Detected SKILL.md language
English
Stars
11
Forks
5

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
ag2-network-workflow
description
Build an AG2 network `workflow` channel — the orchestrated N-party adapter driven by a declarative `TransitionGraph`. Use when the user needs conditional handoffs, multi-step pipelines, context-aware routing, feedback loops, or is migrating from the classic `GroupChat` + `Agent.handoffs` pattern. Covers `TransitionGraph`; factories `TransitionGraph.sequence(...)` / `.round_robin(...)`; built-in targets (`AgentTarget`, `RoundRobinTarget`, `StayTarget`, `RevertToInitiatorTarget`, `TerminateTarget`); built-in conditions (`Always`, `FromSpeaker`, `ToolCalled`, `ContextEquals`); the typed `Handoff` return for dynamic routing; channel-scoped context via `set_context`; `register_target` / `register_condition` for custom subclasses; the packet execution model and idempotent-tool requirement; eight cookbook patterns (pipeline, hierarchical, escalation, feedback loop, triage, and more); and side-by-side migration from classic `GroupChat`. Load this after `ag2-network-quickstart`.
license
Apache-2.0
# AG2 Network — Workflow Adapter `workflow` is the orchestrated multi-party adapter. A declarative `TransitionGraph` describes who speaks first, what conditions fire on each turn, and when the channel terminates. It's the modern replacement for the classic `GroupChat + Agent.handoffs` pattern — turn-taking lives in the hub, not in an in-process speaker selector. > Prerequisite: read `ag2-network-quickstart` first. This skill assumes you know `Hub.open`, `HubClient.register`, the channel lifecycle, and the basic `agent_client.open(...)` / `channel.send(...)` flow. ## When to use Load this skill when the user asks for any of: - "Two/three/N agents with conditional handoffs" - "Pipeline / sequence of agents (researcher → writer → editor)" - "Triage agent routes to specialists" - "Drafter / reviewer feedback loop" - "Coordinator + specialists / hierarchical orchestration" - "Migrate from `GroupChat` / `ConversableAgent.handoffs` / `ReplyResult(target=...)`" - "I want context-driven routing (`OnContextCondition`, `StringContextCondition`)" **Don't use `workflow` for:** - Strict 1Q1R between two agents → `consulting` (see `ag2-network-quickstart`). - Free-form 2-party chat with no turn order → `conversation` (see `ag2-network-quickstart`). - Fixed round-robin with no conditions → `discussion` (see `ag2-network-discussion`). If the user is unsure, the rule of thumb: **need any condition more complex than "next speaker in a fixed list"? Use `workflow`.** ## Shape | | | |---|---| | Participants | 2+ | | Turn order | Whatever `TransitionGraph` says | | Auto-close | Yes — when graph emits `TerminateTarget` or `max_turns` is hit | | Default view | `NamedWindowedSummary(recent_n=N*2)` | | Default expectations | `turn_within(120s, warn)`, `turn_within(600s, auto_close)` | | Required knob | `{"graph": <TransitionGraph.to_dict()>}` | The expectation is strict: a stuck speaker auto-closes the channel after 10 minutes. Tune via `ag2-network-governance`. ## Workflow agents and the `NetworkPlugin` `HubClient.register(...)` attaches `NetworkPlugin` by default. The plugin adds only the *identity-level cross-cutting* tools — `peers` / `channels` / `tasks` / `context` / `delegate`. Channel-specific tools (notably `say`, plus your workflow handoff `@tool`s) come from `adapter.tools_for(client, metadata, state, participant_id)`, resolved per turn by the default handler and merged into `agent.ask(tools=...)` — and **the `workflow` adapter offers none**: `WorkflowAdapter.tools_for(...)` returns `[]`. So a workflow agent never sees `say`; routing is entirely your `@tool`-decorated handoff functions (returning `Handoff`, mutating context vars, or calling `channel.close()`). Consequence: the old "mid-turn `say` races the round-end `EV_PACKET`" hazard **can't happen** on a workflow channel — you do **not** need `attach_plugin=False` for correctness. Choose by capability instead: - **Keep the plugin** (default) if the agent should also `delegate` to a peer, look up `peers` / `channels` / `tasks`, or read/write channel `context` from outside the graph. (`delegate` / `channels(action="open")` spin up *separate* channels — they never emit on the workflow channel, so they don't disturb its turn machinery.) - **`attach_plugin=False`** for a genuinely bare agent — only the domain `@tool`s you wrote. Tidy for pure pipeline workers and most tests. ```python # Bare pipeline worker — no cross-cutting network tools: worker = await wc.register(agent, Passport(name="worker"), Resume(), attach_plugin=False) ``` The default handler wraps the LLM's plain-text reply into the workflow `EV_PACKET` either way. The examples below pass `attach_plugin=False` because they're pure pipeline workers; drop it if your agents need `delegate` / `peers` / etc. Full detail: `ag2-network-tools-and-views` → "Adapter-owned tools (`tools_for`)". ## Smallest example — pipeline via `sequence` ```python import asyncio from ag2 import Agent from ag2.config import AnthropicConfig from ag2.knowledge import MemoryKnowledgeStore from ag2.network import ( EV_CHANNEL_CLOSED, Hub, HubClient, LocalLink, Passport, Resume, TransitionGraph, ) async def main() -> None: config = AnthropicConfig(model="claude-sonnet-4-6") hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0) link = LocalLink(hub) rhc, whc, ehc = (HubClient(link, hub=hub) for _ in range(3)) researcher = await rhc.register( Agent("researcher", prompt="Bullet-point three facts on the topic.", config=config), Passport(name="researcher"), Resume(), attach_plugin=False, ) writer = await whc.register( Agent("writer", prompt="Turn the bullets into one paragraph.", config=config), Passport(name="writer"), Resume(), attach_plugin=False, ) editor = await ehc.register( Agent("editor", prompt="Tighten the paragraph. Reply with the final text.", config=config), Passport(name="editor"), Resume(), attach_plugin=False, ) graph = TransitionGraph.sequence([ researcher.agent_id, writer.agent_id, editor.agent_id, ]) channel = await researcher.open( type="workflow", target=[writer.agent_id, editor.agent_id], knobs={"graph": graph.to_dict()}, ) await channel.send("Topic: how does HTTPS work?") close_env = await researcher.wait_for_channel_event( channel_id=channel.channel_id, predicate=lambda e: e.event_type == EV_CHANNEL_CLOSED, timeout=180.0, ) print(f"closed: {close_env.event_data.get('reason')!r}") # 'sequence_complete' for hc in (rhc, whc, ehc): await hc.close() await hub.close() asyncio.run(main()) ``` `TransitionGraph.sequence([a, b, c])` builds three `FromSpeaker(x) → AgentTarget(next)` transitions plus a `TerminateTarget("sequence_complete")` default. After `c` speaks no transition matches, the default fires, and the channel auto-closes. ## `TransitionGraph` anatomy ```python @dataclass(slots=True) class TransitionGraph: initial_speaker: str # agent_id of the first speaker (must be a participant) transitions: list[Transition] # ordered list, walked in priority order on every fold default_target: TransitionTarget # consulted if no transition matches max_turns: int | None = None # hard turn cap; force-terminates on reaching @dataclass(slots=True) class Transition: when: TransitionCondition # evaluated against the just-accepted envelope + state then: TransitionTarget # if when() is True, this resolves the next speaker priority: int = 0 # LOWER runs first; ties break by insertion order ``` On every accepted substantive envelope (text or packet), the adapter walks the `transitions` list. The **first** matching condition's target resolves the next speaker. If none match, `default_target` is consulted. A `TerminateTarget` resolves with `next_speaker=None`, which makes the adapter return `AdapterResult(next_state=CLOSING, ...)` and the hub posts `EV_CHANNEL_CLOSED` with that target's `reason`. `max_turns` is a separate, harder cap: once `turn_count` reaches it the adapter closes the channel immediately with reason **`"max_turns"`** — it does *not* fall through to `default_target` (so a `default_target=TerminateTarget("…")` reason only appears when an actual turn produced no matching transition). **`priority` sorts ascending — a *lower* number is checked first.** The adapter does `sorted(transitions, key=lambda t: t.priority)` then walks the result, so `priority=0` (the default) beats `priority=100`. If you want a rule consulted before the others, either put it at the top of the list (the sort is stable, so equal priorities keep list order) or give it a *negative* priority. A common mistake: writing `priority=100` on a `ContextEquals(...) → TerminateTarget(...)` rule expecting it to win — it gets pushed to the *end* and a `FromSpeaker(...)` fallback fires first instead, so the channel never terminates. ## Gotcha — the kickoff `channel.send()` is the `initial_speaker`'s first turn When you do `agent.open(type="workflow", ...)` then `await channel.send(text)`, that first send is folded as `initial_speaker`'s turn — it's an envelope from that agent, and `expected_next_speaker` starts equal to `initial_speaker`, so `validate_send` accepts it and the graph immediately routes onward. **The `initial_speaker`'s `Agent.ask` never runs for that turn** — the text you sent *is* its contribution. So if your design wants the *first agent that should actually think* (the drafter, the researcher, the planner) to respond to the kickoff prompt, that agent must **not** be `initial_speaker` and must **not** be the one calling `channel.send()`. The clean answer is a **`HumanClient`** as the seeder — a non-LLM participant (no `Agent`, no plugin) whose only job is to open the channel and post the brief. Make it `initial_speaker` and route `FromSpeaker(user) → AgentTarget(drafter)`: ```python from ag2.network import HumanClient, Passport # plus the usual imports user_hc = HubClient(link, hub=hub) user = await user_hc.register_human(Passport(name="user", kind="human")) # ← register_human, not register drafter = await drf_hc.register(drafter_agent, Passport(name="drafter"), Resume(), attach_plugin=False) reviewer = await rev_hc.register(reviewer_agent, Passport(name="reviewer"), Resume(), attach_plugin=False) graph = TransitionGraph( initial_speaker=user.agent_id, transitions=[ Transition(when=ContextEquals("approved", value=True), then=TerminateTarget("approved")), Transition(when=FromSpeaker(user.agent_id), then=AgentTarget(drafter.agent_id)), # brief → drafter Transition(when=FromSpeaker(drafter.agent_id), then=AgentTarget(reviewer.agent_id)), Transition(when=FromSpeaker(reviewer.agent_id), then=AgentTarget(drafter.agent_id)), # revise loop ], default_target=TerminateTarget("max_revisions"), max_turns=12, ) channel = await user.open(type="workflow", target=[drafter.agent_id, reviewer.agent_id], knobs={"graph": graph.to_dict()}) await channel.send("Brief: …") # consumed as `user`'s turn → routes to `drafter`, who drafts # Later, when the channel closes: await user.next_envelope(predicate=lambda e: e.event_type == EV_CHANNEL_CLOSED, timeout=300) ``` `register_human(passport, *, resume=None, rule=None, auto_ack_invites=True)` returns a `HumanClient` (`HubClient.register(...)` now *rejects* `kind="human"` and points you here). It auto-acks invites so the quorum handshake completes without UI round-trips; pass `auto_ack_invites=False` to gate joins by hand. Drive it with `on_envelope(callback)` (push) or `next_envelope(...)` / `envelopes()` (pull) — see `ag2-network-quickstart` → "`HumanClient`". If you'd rather not introduce a `HumanClient`, the older workaround still works: a throwaway "intake" `Agent` as `initial_speaker` whose `Agent.ask` is never invoked (no transition routes back to it) — but a `HumanClient` is cheaper (no model config, no plugin) and is what the framework's own smoke tests use. Either way, the same applies to `TransitionGraph.sequence([a, b, c])`: `channel.send(...)` is `a`'s turn, so if `a` is supposed to *produce* the first artifact (not just relay a prompt), prepend a seeder — `sequence([user, a, b, c])`. (Symptom when you get this wrong: the second agent in the chain receives the bare kickoff prompt as if it were the first agent's output, "responds" to something that isn't there, and the pipeline produces nonsense — often hallucinating that an artifact exists.) ## Built-in targets | Target | Decision | |---|---| | `AgentTarget(agent_id)` | Hand off to a specific named agent. | | `RoundRobinTarget()` | Advance through the participant order. | | `StayTarget()` | Same speaker continues (rare; "let me elaborate" patterns). | | `RevertToInitiatorTarget()` | Hand back to whoever opened the channel. | | `TerminateTarget(reason="…")` | End the channel; reason flows on `EV_CHANNEL_CLOSED.event_data["reason"]`. | ## Built-in conditions | Condition | Fires when | |---|---| | `Always()` | Every accepted envelope. | | `FromSpeaker(agent_id)` | The just-accepted envelope was sent by this agent. | | `ToolCalled(tool_name)` | The previous turn called this tool by name (matched via `routing.tool` on the packet). | | `ContextEquals(key, value)` | Channel-scoped `context_vars[key]` equals `value`. Missing keys compare as `None`. | For combinations beyond what these compose to, see "Custom targets / conditions" below. ## Convenience factories ```python # Pipeline: a → b → c → terminate. graph = TransitionGraph.sequence([alice.agent_id, bob.agent_id, carol.agent_id]) # Round-robin with a cap: a → b → c → a → b → c → terminate after 6 turns. graph = TransitionGraph.round_robin( participants=[alice.agent_id, bob.agent_id, carol.agent_id], max_turns=6, ) ``` `round_robin` uses `Always() → RoundRobinTarget()` and a `TerminateTarget("round_robin_complete")` default — but since `Always()` matches every turn, the default is unreachable and the channel actually closes with reason `"max_turns"` once the cap is hit (set `max_turns` or it cycles forever). `sequence` uses `FromSpeaker(steps[i]) → AgentTarget(steps[i+1])` chains with a `TerminateTarget("sequence_complete")` default — *that* one is reachable: after the last step speaks, no `FromSpeaker` rule matches, so the default fires and you get `"sequence_complete"`. ## Conditional handoff (the most common manual graph) A triage agent inspects each request and routes to a specialist via a tool call: ```python from ag2.network import ( AgentTarget, FromSpeaker, RevertToInitiatorTarget, TerminateTarget, ToolCalled, Transition, TransitionGraph, ) graph = TransitionGraph( initial_speaker=triage.agent_id, transitions=[ # Triage's `escalate` tool fired → route to security. Transition(when=ToolCalled("escalate"), then=AgentTarget(security.agent_id)), # Once security has spoken, hand back to whoever opened the channel. Transition(when=FromSpeaker(security.agent_id), then=RevertToInitiatorTarget()), # Otherwise triage's reply goes to the generic responder.
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub