Control flow
In shortReference for Command, interrupt(), Interrupt, GraphInterrupt, pending_interrupt, create_handoff_tool and is_handoff_tool in 10xGraph.
- 8 min read
- 9 sections
- Updated
- v0.10.0
- Markdown
These APIs control graph execution from inside nodes and tools. Use Command to update state and choose the next node in one return value, interrupt() to pause for human input and resume later, and handoff tools to let a model transfer control to another agent.
For concepts and walkthroughs, see Interrupts, Routing, Command and callbacks, Add human approval and Hand off between agents.
Import paths
from tenxgraph.utils import Command, END
from tenxgraph.utils import interrupt, Interrupt, GraphInterrupt, pending_interrupt
from tenxgraph.prebuilt.tools import create_handoff_tool, is_handoff_toolCommand
A return value from a node function that combines a state update with an explicit routing decision. Returning Command(goto=...) sends execution to the named node, or to END to stop.
from tenxgraph.core.state import AgentState
from tenxgraph.utils import Command, END
# Route on the last message; the matching nodes must exist in the graph
def router_node(state: AgentState, config: dict) -> Command:
last = state.context[-1].text() if state.context else ""
if "research" in last.lower():
return Command(goto="RESEARCHER")
elif "write" in last.lower():
return Command(goto="WRITER")
else:
return Command(goto=END)Signature
Command(
update=None,
goto=None,
graph=None,
state=None,
)Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
update |
StateT | Message | str | BaseConverter | None |
None |
Applied before navigating. An AgentState replaces the current state (its new messages are recorded); a Message, str, converter or list of these is added as new message(s). |
goto |
str | None |
None |
Name of the next node to execute, or END to terminate. |
graph |
str | None |
None |
Target graph (None for the current one). Stored on the command; the current runtime does not use it for routing. |
state |
StateT | None |
None |
Optional state to attach. When streaming, its new context messages are emitted. |
Constants
| Constant | Value | Description |
|---|---|---|
Command.PARENT |
"PARENT" |
Value meant for graph= to name the parent graph. Not acted on by the current runtime. |
Example
Add a message and navigate in one step:
from tenxgraph.core.state import AgentState
from tenxgraph.utils import Command
# Record a note as an assistant message, then jump to BILLING_AGENT
def classify_node(state: AgentState, config: dict) -> Command:
return Command(update="Routing to billing.", goto="BILLING_AGENT")interrupt()
Pause the graph and wait for a resume value. Call interrupt() when a node or tool needs outside input: approval, a choice, or a correction. The first time it runs, it stops the graph. The graph saves its state and reports the interrupt. Resume by running the same thread again with a resume value.
from tenxgraph.utils import interrupt
# Tool that pauses for approval before acting; issue_refund is your own function
async def refund(amount: float) -> str:
decision = interrupt(
{"amount": amount},
message=f"Approve ${amount} refund?",
response_schema={
"type": "object",
"properties": {"approved": {"type": "boolean"}}
},
)
if not decision or not decision.get("approved"):
return "Refund declined"
return issue_refund(amount)
# First run: pauses at interrupt() (app and config come from your graph)
await app.ainvoke({"messages": [...]}, config)
# Resume on the same thread: interrupt() returns the value
await app.ainvoke({"resume": {"approved": True}}, config)The interrupted node or tool runs again from the start on resume, so keep side effects after the interrupt() call. The thread needs a checkpointer and a thread_id in config to be resumable.
Signature
interrupt(
value=None,
*,
message=None,
reason="input_required",
response_schema=None,
)Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
value |
Any |
None |
Data for the client (what to approve, what to choose from). |
message |
str | None |
None |
Human-readable prompt, shown by UIs such as CopilotKit. |
reason |
str |
"input_required" |
Machine-readable reason, e.g., "tool_approval" or "human_verification". |
response_schema |
dict[str, Any] | None |
None |
JSON Schema describing the expected resume value. |
Returns
The resume value on the run that resumes this interrupt. None if the client cancelled.
Raises
| Exception | When |
|---|---|
GraphInterrupt |
On the first run, to stop the graph. Do not catch it. |
RuntimeError |
When called outside a running graph node or tool. |
Example
Multiple interrupts in one node, answered one per resume:
from tenxgraph.core.state import AgentState
from tenxgraph.utils import interrupt
async def approval_step(state: AgentState, config: dict):
step1 = interrupt({"step": 1}, message="Continue to step 1?")
if not step1:
return state
step2 = interrupt({"step": 2}, message="Continue to step 2?")
if not step2:
return state
return "All approved"Interrupt
A pause requested by interrupt(), waiting for a resume value.
Signature
class Interrupt(BaseModel):
id: str
key: str
node: str
value: Any
message: str | None
reason: str
response_schema: dict[str, Any] | None
tool_call_id: str | NoneFields
| Field | Type | Description |
|---|---|---|
id |
str |
Unique id of this pause; clients echo it when resuming. |
key |
str |
Stable position of the call within its node or tool call. |
node |
str |
Node that was running when the graph paused. |
value |
Any |
Payload passed to interrupt(). |
message |
str | None |
Human-readable prompt. |
reason |
str |
Why the graph paused (default "input_required"). |
response_schema |
dict[str, Any] | None |
JSON Schema the resume value should follow. |
tool_call_id |
str | None |
Tool call that paused (if interrupt() ran inside a tool). |
Example
Inspect a pending interrupt after invoke. The state is only returned with ResponseGranularity.FULL:
from tenxgraph.utils import ResponseGranularity, pending_interrupt
result = app.invoke(
{"messages": [...]}, config, response_granularity=ResponseGranularity.FULL
)
paused = pending_interrupt(result["state"])
if paused:
print(f"Waiting at {paused.node}: {paused.message}")
result = app.invoke({"resume": {"approved": True}}, config)GraphInterrupt
Exception raised by interrupt() to stop the graph. Derives from BaseException, so standard error handlers in tools and nodes do not catch it.
Signature
class GraphInterrupt(BaseException):
def __init__(self, interrupt: Interrupt) -> None: ...
interrupt: Interrupt # The pause detailsThe graph runtime catches GraphInterrupt. Do not catch it in nodes or tools, and note that a bare except BaseException would swallow it.
pending_interrupt()
Return the interrupt a paused thread is waiting on, or None.
Pass the AgentState from a run made with ResponseGranularity.FULL (result["state"]).
Signature
pending_interrupt(state: AgentState) -> Interrupt | NoneParameters
| Parameter | Type | Description |
|---|---|---|
state |
AgentState |
The state from result["state"] after invoke() or ainvoke() with ResponseGranularity.FULL. |
Returns
The pending Interrupt if the thread is paused at an interrupt() call, or None.
Example
from tenxgraph.utils import ResponseGranularity, pending_interrupt
result = app.invoke(
{"messages": [...]}, config, response_granularity=ResponseGranularity.FULL
)
pause = pending_interrupt(result["state"])
if pause:
# Paused: resume with a value
result = app.invoke({"resume": user_input}, config)
else:
# Finished
print(result["messages"][-1].text())create_handoff_tool()
Factory that creates an LLM-callable tool. When the model calls it, the ToolNode detects the transfer_to_<agent> name and navigates directly to that node without executing the function body.
from tenxgraph.core import ToolNode
from tenxgraph.prebuilt.tools import create_handoff_tool
transfer_to_researcher = create_handoff_tool(
agent_name="RESEARCHER",
description="Transfer to the research agent for detailed investigation.",
)
tools = ToolNode([transfer_to_researcher])Signature
create_handoff_tool(
agent_name: str,
description: str | None = None,
) -> CallableParameters
| Parameter | Type | Default | Description |
|---|---|---|---|
agent_name |
str |
required | Must match an existing node name in the graph exactly. |
description |
str | None |
None |
Description shown to the LLM. Defaults to "Transfer control to <agent_name> agent". |
An agent_name containing an underscore logs a warning, because it can complicate name matching. Prefer names such as RESEARCHER.
Returns
A callable with special attributes:
__name__=f"transfer_to_{agent_name}"__doc__= the description string__handoff_tool__=True__target_agent__=agent_name
Interception flow
When the last assistant message contains a tool call named transfer_to_<agent>, the ToolNode handler (invoke and stream) intercepts it before running any tool:
Agent calls transfer_to_RESEARCHER
-> ToolNode handler: is_handoff_tool("transfer_to_RESEARCHER") returns (True, "RESEARCHER")
-> handler returns Command(goto="RESEARCHER")
-> the tool function body is not calledExample
A coordinator that can hand off to a researcher. Set OPENAI_API_KEY first and install with pip install "10xgraph[openai]":
from tenxgraph.core import Agent, StateGraph, ToolNode
from tenxgraph.core.state import AgentState
from tenxgraph.prebuilt.tools import create_handoff_tool
from tenxgraph.utils import END
handoff = create_handoff_tool("RESEARCH", "Transfer to the researcher for deep investigation.")
coordinator = Agent(model="gpt-4o", provider="openai", tool_node="COORD_TOOLS")
researcher = Agent(model="gpt-4o", provider="openai")
# Go to the tool node when the model asked for tools, otherwise finish
def after_coord(state: AgentState) -> str:
last = state.context[-1]
return "COORD_TOOLS" if last.role == "assistant" and last.tools_calls else END
graph = StateGraph()
graph.add_node("COORD", coordinator)
graph.add_node("COORD_TOOLS", ToolNode([handoff]))
graph.add_node("RESEARCH", researcher)
graph.set_entry_point("COORD")
graph.add_conditional_edges("COORD", after_coord, {"COORD_TOOLS": "COORD_TOOLS", END: END})
graph.add_edge("RESEARCH", END)
app = graph.compile()For a fuller multi-agent walkthrough, see Hand off between agents.
is_handoff_tool()
Check whether a tool name follows the handoff convention.
from tenxgraph.prebuilt.tools import is_handoff_tool
print(is_handoff_tool("transfer_to_researcher")) # (True, 'researcher')
print(is_handoff_tool("get_weather")) # (False, None)Signature
is_handoff_tool(tool_name: str) -> tuple[bool, str | None]Parameters
| Parameter | Type | Description |
|---|---|---|
tool_name |
str |
The name to check. |
Returns
A tuple (is_handoff, target_agent):
is_handoff(bool):Trueif the name matches"transfer_to_<agent>"pattern.target_agent(str | None): The text after thetransfer_to_prefix, unchanged (case is preserved), orNoneif not a handoff tool or the prefix has no target.
Example
is_hoff, target = is_handoff_tool("transfer_to_support_team")
assert is_hoff is True
assert target == "support_team"Common errors
| Error | Cause | Fix |
|---|---|---|
"should have been intercepted" in logs |
Handoff tool body executed. | Run the tool through a standard ToolNode, not a custom tool handler. |
| LLM doesn’t choose handoff tools | Description is unclear. | Make description= action-oriented and specific. |
Command.graph has no effect |
The runtime does not route on graph. |
Route with goto within the current graph. |
pending_interrupt() fails on a dict |
Passed the invoke() result instead of the state. |
Use ResponseGranularity.FULL and pass result["state"]. |
RuntimeError from interrupt() |
Called outside a graph node/tool. | Call interrupt() only during graph execution. |
ValueError or TypeError from create_handoff_tool |
agent_name is empty (ValueError) or not a string (TypeError). |
Pass a non-empty string. |