feat: add terminal node back in graph

This commit is contained in:
Richard Tang
2026-03-05 19:02:41 -08:00
parent 94d0038e03
commit f23d5a3ff5
9 changed files with 47 additions and 33 deletions
@@ -406,7 +406,8 @@ nodes = [
client_facing=True,
max_node_visits=0,
input_keys=[],
output_keys=[],
output_keys=["test_result"],
nullable_output_keys=["test_result"],
tools=["get_account_info"],
system_prompt="""\
You are a credential tester. Your job is to help the user verify that their \
@@ -444,7 +445,7 @@ edges = []
entry_node = "tester"
entry_points = {"start": "tester"}
pause_nodes = []
terminal_nodes = [] # Forever-alive: loops until user exits
terminal_nodes = ["tester"] # Tester node can terminate
conversation_mode = "continuous"
identity_prompt = (
@@ -531,7 +532,7 @@ class CredentialTesterAgent:
version="1.0.0",
entry_node="tester",
entry_points={"start": "tester"},
terminal_nodes=[],
terminal_nodes=["tester"], # Tester node can terminate
pause_nodes=[],
nodes=[tester_node],
edges=[],
@@ -51,7 +51,8 @@ The key is pre-injected into the session environment and tools read it automatic
client_facing=True,
max_node_visits=0,
input_keys=[],
output_keys=[],
output_keys=["test_result"],
nullable_output_keys=["test_result"],
tools=tools,
system_prompt=f"""\
You are a credential tester for the {account_label}: {provider}/{alias}{detail}
+3 -3
View File
@@ -99,14 +99,14 @@ goal = Goal(
# GraphExecutor with queen_node — not as part of this graph.
nodes = [coder_node]
# No edges needed — single forever-alive event_loop node
# No edges needed — single event_loop node
edges = []
# Graph configuration
entry_node = "coder"
entry_points = {"start": "coder"}
pause_nodes = []
terminal_nodes = [] # Forever-alive: loops until user exits
terminal_nodes = ["coder"] # Coder node has output_keys and can terminate
# No async entry points needed — the queen is now an independent executor,
# not a secondary graph receiving events via add_graph().
@@ -154,7 +154,7 @@ queen_graph = GraphSpec(
version="1.0.0",
entry_node="queen",
entry_points={"start": "queen"},
terminal_nodes=[],
terminal_nodes=["queen"], # Queen node can terminate
pause_nodes=[],
nodes=[queen_node],
edges=[],
@@ -363,9 +363,9 @@ Design the agent architecture:
- Goal: id, name, description, 3-5 success criteria, 2-4 constraints
- Nodes: **2-5 nodes** (warn if <2 or >5)
- Edges: on_success for linear, conditional for routing
- Lifecycle: ALWAYS forever-alive (`terminal_nodes=[]`) unless the user \
explicitly requests a one-shot/batch agent. Forever-alive agents loop \
continuously the user exits by closing the TUI. This is the standard \
- Lifecycle: ALWAYS mark the primary event_loop node as terminal \
(`terminal_nodes=["process"]`). The node has `output_keys` and can \
complete when the agent finishes its work. This is the standard \
pattern for all interactive agents.
### Node Design Rules
@@ -498,15 +498,15 @@ run_agent_tests("{name}")
If anything fails: read error, fix with edit_file, re-validate. Up to 3x.
**CRITICAL: Testing forever-alive agents**
Most agents use `terminal_nodes=[]` (forever-alive). This means \
`runner.run()` NEVER returns it hangs forever waiting for a \
terminal node that doesn't exist. Agent tests MUST be structural:
**CRITICAL: Testing continuous-loop agents**
Most agents mark the primary event_loop node as terminal \
(`terminal_nodes=["process"]`). This means the agent can complete \
when it finishes its work. Agent tests MUST be structural:
- Validate graph, node specs, edges, tools, prompts
- Check goal/constraints/success criteria definitions
- Test `AgentRunner.load()` succeeds (structural, no API key needed)
- NEVER call `runner.run()` or `trigger_and_wait()` in tests for \
forever-alive agents they will hang and time out.
interactive agents they run indefinitely waiting for user input.
When you restructure an agent (change nodes/edges), always update \
the tests to match. Stale tests referencing old node names will fail.
@@ -965,8 +965,8 @@ queen_node = NodeSpec(
client_facing=True,
max_node_visits=0,
input_keys=["greeting"],
output_keys=[],
nullable_output_keys=[],
output_keys=["session_status"],
nullable_output_keys=["session_status"],
success_criteria=(
"User's intent is understood, coding tasks are completed correctly, "
"and the worker is managed effectively when delegated to."
@@ -26,7 +26,7 @@ module-level variables via `getattr()`:
| `edges` | YES | `None` | **FATAL** — same error |
| `entry_node` | no | `nodes[0].id` | Probably wrong node |
| `entry_points` | no | `{}` | **Nodes unreachable** — validation fails |
| `terminal_nodes` | no | `[]` | OK for forever-alive |
| `terminal_nodes` | **YES** | `[]` | **FATAL** — graph must have at least one terminal node |
| `pause_nodes` | no | `[]` | OK |
| `conversation_mode` | no | not passed | Isolated mode (no context carryover) |
| `identity_prompt` | no | not passed | No agent-level identity |
@@ -165,8 +165,9 @@ review_node = NodeSpec(
)
```
### Forever-Alive Pattern
`terminal_nodes=[]` — every node has outgoing edges, graph loops until user exits.
### Continuous Loop Pattern
Mark the primary event_loop node as terminal: `terminal_nodes=["process"]`.
The node has `output_keys` and can complete when the agent finishes its work.
Use `conversation_mode="continuous"` to preserve context across transitions.
### set_output
@@ -192,16 +193,16 @@ condition_expr examples:
| Pattern | terminal_nodes | When |
|---------|---------------|------|
| **Forever-alive** | `[]` | **DEFAULT for all agents** |
| Linear | `["last-node"]` | Only if user explicitly requests one-shot/batch |
| **Continuous loop** | `["node-with-output-keys"]` | **DEFAULT for all agents** |
| Linear | `["last-node"]` | One-shot/batch agents |
**Forever-alive is the default.** Always use `terminal_nodes=[]`.
The framework default for `max_node_visits` is 0 (unbounded), so
nodes work correctly in forever-alive loops without explicit override.
Only set `max_node_visits > 0` in one-shot agents with feedback loops.
Every node must have at least one outgoing edge — no dead ends. The
user exits by closing the TUI. Only use terminal nodes if the user
explicitly asks for a batch/one-shot agent that runs once and exits.
**Every graph must have at least one terminal node.** Terminal nodes
define where execution ends. For interactive agents that loop continuously,
mark the primary event_loop node as terminal (it has `output_keys` and can
complete at any point). The framework default for `max_node_visits` is 0
(unbounded), so nodes work correctly in continuous loops without explicit
override. Only set `max_node_visits > 0` in one-shot agents with feedback loops.
Every node must have at least one outgoing edge — no dead ends.
## Continuous Conversation Mode
+7
View File
@@ -618,6 +618,13 @@ class GraphSpec(BaseModel):
if not self.get_node(term):
errors.append(f"Terminal node '{term}' not found")
# Require at least one terminal node (graphs must have termination points)
if not self.terminal_nodes:
errors.append(
"Graph must have at least one terminal node in 'terminal_nodes'. "
"Every graph needs a termination point where execution ends."
)
# Check edge references
for edge in self.edges:
if not self.get_node(edge.source):
+1 -1
View File
@@ -244,7 +244,7 @@ judge_graph = GraphSpec(
version="1.0.0",
entry_node="judge",
entry_points={"health_check": "judge"},
terminal_nodes=[], # Forever-alive: fires on every timer tick
terminal_nodes=["judge"], # Judge node can terminate after each check
pause_nodes=[],
nodes=[judge_node],
edges=[],
+3 -3
View File
@@ -126,7 +126,7 @@ async def test_visit_limit_skips_node(runtime, goal):
EdgeSpec(id="a_to_b", source="a", target="b", condition=EdgeCondition.ON_SUCCESS),
EdgeSpec(id="b_to_a", source="b", target="a", condition=EdgeCondition.ON_SUCCESS),
],
terminal_nodes=[], # No terminal — max_steps is the guard
terminal_nodes=["a", "b"], # Both nodes can terminate; max_steps is the guard
max_steps=10,
)
@@ -184,7 +184,7 @@ async def test_visit_limit_allows_multiple(runtime, goal):
EdgeSpec(id="a_to_b", source="a", target="b", condition=EdgeCondition.ON_SUCCESS),
EdgeSpec(id="b_to_a", source="b", target="a", condition=EdgeCondition.ON_SUCCESS),
],
terminal_nodes=[],
terminal_nodes=["a", "b"], # Both nodes can terminate; max_steps is the guard
max_steps=10,
)
@@ -240,7 +240,7 @@ async def test_visit_limit_zero_unlimited(runtime, goal):
EdgeSpec(id="a_to_b", source="a", target="b", condition=EdgeCondition.ON_SUCCESS),
EdgeSpec(id="b_to_a", source="b", target="a", condition=EdgeCondition.ON_SUCCESS),
],
terminal_nodes=[],
terminal_nodes=["a", "b"], # Both nodes can terminate; max_steps is the guard
max_steps=6, # A,B,A,B,A,B
)
+4
View File
@@ -59,6 +59,7 @@ async def test_executor_single_node_success():
],
edges=[],
entry_node="n1",
terminal_nodes=["n1"],
)
executor = GraphExecutor(
@@ -114,6 +115,7 @@ async def test_executor_single_node_failure():
],
edges=[],
entry_node="n1",
terminal_nodes=["n1"],
)
executor = GraphExecutor(
@@ -191,6 +193,7 @@ async def test_executor_skips_events_for_event_loop_nodes():
],
edges=[],
entry_node="el1",
terminal_nodes=["el1"],
)
executor = GraphExecutor(
@@ -229,6 +232,7 @@ async def test_executor_no_events_without_event_bus():
],
edges=[],
entry_node="n1",
terminal_nodes=["n1"],
)
# No event_bus passed — should not crash