feat: add terminal node back in graph
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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=[],
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user