0. Series Loop (Read Along Without Public Source Code)

End-to-End Pipeline: Vue frontend → api/routes/chat.py → Guide multi-turn SSE → run_analysis_pipeline (parse → analyze → match → report) → tools/pdf_exporter PDF.
This Article: 5/17 · Control Loop · Conditional Edges

Stage User Visible Code Entry Article
Create Session Welcome message POST /api/sessions 09
Multi-turn Chat SSE streaming chat/stream → run_guide_single_turn 06, 14
Info Sufficient Start Analysis _run_analysis_background 05, 07
Resume Parsing Progress 30% run_resume_parser 12
Profile/RIASEC Progress 50% run_profile_analyzer 03, 13
Career Matching Progress 70% run_career_matcher 02
Report Progress 90% run_reporter 11
Download PDF File GET …/report/pdf 11, 15
Description
Before reading Article 04 state fields, Article 06 Guide subgraph
After reading Can write the exit conditions for route_after_guide and should_continue
Next loop Article 07: Error tolerance when routing fails (Article 6)

Full series loop index: SERIES-LOOP.md

1. What Problem Does It Solve

iCan’s dialogue guidance stage needs to “continue asking if info is insufficient, enter analysis pipeline when enough.” Using plain Python if-else to chain functions makes it hard to express the loop of “go back to the previous step and run another round,” and lacks LangGraph’s built-in execution tracing and recursion_limit fallback.

The project uses add_conditional_edges in a two-layer StateGraph:

  1. Inner layer (create_guide_graph() in agents/guide.py): The Guide subgraph, after check_sufficiency, decides whether to continue with dig_deeper or END.
  2. Outer layer (create_workflow() in workflow.py): The top-level graph, after guide_node, decides based on needs_more_info whether to return to guide_node or proceed to resume_parser_node.

Additionally, the online HTTP dialogue uses the run_guide_chat single-turn mode, which does not run the full top-level graph on every request — this differs from the CLI’s run_workflow and will be explained separately below.

2. Implementation Locations

Layer File Key Symbols
Inner subgraph agents/guide.py should_continue, create_guide_graph, run_guide_agent
Outer orchestration workflow.py guide_node, route_after_guide, create_workflow
API single-turn workflow.py + api/routes/chat.py run_guide_chatrun_guide_single_turn
State fields core/state.py needs_more_info, conversation_history

Guide Conditional Routing

3. LangGraph Conditional Edge API

The core implementation is in create_workflow() in workflow.py:

1
2
3
4
5
6
7
8
graph.add_conditional_edges(
"guide_node",
route_after_guide,
{
"guide_node": "guide_node",
"resume_parser_node": "resume_parser_node",
},
)

The routing function only reads state and returns a string key, without initiating side effects — this is LangGraph’s recommended pattern for easier debugging and visualization.

4. Inner Layer: should_continue and Guide Subgraph

The Guide subgraph structure in agents/guide.py:

1
2
3
4
5
welcome → assess_need → collect_basic_info → dig_deeper → check_sufficiency

should_continue()
↙ ↘
dig_deeper END

The logic of should_continue:

1
2
3
4
5
6
7
8
9
10
def should_continue(state: GuideState) -> str:
is_sufficient = state.get("is_info_sufficient", False)
messages_list = state.get("messages", [])
loop_count = len([m for m in messages_list if m])

if is_sufficient:
return "handoff"
if loop_count >= 8:
return "handoff"
return "dig_deeper"

Registration method:

1
2
3
4
5
graph.add_conditional_edges(
"check_sufficiency",
should_continue,
{"dig_deeper": "dig_deeper", "handoff": END},
)

When compiling the subgraph, run_guide_agent sets recursion_limit=15, which is tighter than the outer layer’s 50, preventing the subgraph from spinning without user input.

5. Outer Layer: guide_node and route_after_guide

guide_node: Writing the subgraph result back to the top-level state

guide_node in workflow.py does three things:

  1. Appends raw_input to conversation_history;
  2. Calls run_guide_agent(guide_state);
  3. Writes back needs_more_info = not is_sufficient to the top-level state.
1
2
3
4
5
6
7
8
9
async def guide_node(state: iCanWorkflowState) -> dict:
# ...
guide_result = await run_guide_agent(guide_state)
is_sufficient = guide_result.get("is_info_sufficient", False)
return {
"conversation_history": updated_history,
"current_agent": "guide",
"needs_more_info": not is_sufficient,
}

If the subgraph throws an error, the node catches it and still sets needs_more_info: True, preventing accidental entry into the analysis phase.

route_after_guide: Info sufficient or forced advancement

1
2
3
4
5
6
7
8
9
10
def route_after_guide(state: iCanWorkflowState) -> str:
needs_more_info = state.get("needs_more_info", True)
conversation_history = state.get("conversation_history", [])
user_msg_count = len([m for m in conversation_history if m.get("role") == "user"])

if not needs_more_info:
return "resume_parser_node"
if user_msg_count >= 3:
return "resume_parser_node"
return "guide_node"

Decision order: First trust the LLM/subgraph’s sufficiency judgment, then count user turns, only then continue the loop. The routing itself also has try/except, defaulting to resume_parser_node on exception to avoid getting stuck in the guide loop.

Full top-level path (from create_workflow comment):

1
2
3
4
start → guide_node → route_after_guide
→ (insufficient info & turns<3) → guide_node [loop]
→ (sufficient info or turns≥3) → resume_parser_node → profile_analyzer_node
→ career_matcher_node → reporter_node → END

run_workflow (CLI entry) does a single ainvoke with recursion_limit=50:

1
result = await workflow.ainvoke(initial_state, config={"recursion_limit": 50})

6. run_guide_chat vs Full Workflow

These are the two most easily confused paths in iCan:

Path Entry Uses route_after_guide? Typical Caller
Single-turn Chat run_guide_chat No /chat, /sessions in api/routes/chat.py
Full Graph run_workflowcreate_workflow Yes cli.py

run_guide_chat internally calls run_guide_single_turn from agents/guide.py — it calls the LLM directly without running the Guide subgraph loop. Each HTTP message from the user advances one turn; when info is sufficient, the API layer calls run_analysis_pipeline via asyncio.create_task, no longer passing through the top-level LangGraph.

1
2
3
4
async def run_guide_chat(conversation_history: list, user_message: str) -> dict:
from ican.agents.guide import run_guide_single_turn
result = await run_guide_single_turn(conversation_history, user_message)
# returns reply, is_info_sufficient, conversation_history

Design rationale: The Web scenario requires “wait for user input before proceeding,” so multi-turn cannot be stuffed into one ainvoke and idle; the user_msg_count >= 3 forced exit in route_after_guide is mainly to prevent infinite loops when the CLI runs the full graph in one shot.

7. Triple Insurance Against Infinite Loops

Insurance Layer Location Mechanism
Layer 1 agents/guide.py should_continue loop_count >= 8 forces handoff
Layer 2 workflow.py route_after_guide user_msg_count >= 3 forces resume_parser_node
Layer 3 LangGraph framework Outer recursion_limit=50, subgraph recursion_limit=15

The three layers have different meanings: the inner layer limits the number of messages in the subgraph, the outer layer limits the number of user-role messages in the top-level conversation_history, and the framework limits the total number of graph steps.

8. Pitfalls and Edge Cases

  1. Comment says “max 2 times”, but code does 3 user messages
    The docstrings for route_after_guide and should_continue both say “max 2 loops,” but the actual check is user_msg_count >= 3 / loop_count >= 8. When writing documentation and adjusting thresholds, refer to the source code, not the comments.

  2. loop_count counts non-empty messages entries, not user turns
    should_continue uses GuideState.messages (accumulated via Annotated reducer), which is inconsistent with the counting method of the top-level conversation_history. When adjusting loop limits, verify both layers of state separately.

  3. The API path lacks the outer route_after_guide
    If you only test /chat in a browser, you won’t see the effect of user_msg_count >= 3; you need to run run_workflow in the CLI or test the top-level graph with unit tests to verify the forced exit.

  4. needs_more_info defaults to True
    In core/state.py, the initial workflow state has needs_more_info set to False, but route_after_guide uses state.get("needs_more_info", True) — if the field is missing, it tends to continue the guide, which is a conservative design.

9. Summary

  • Conditional routing uses add_conditional_edges(source, router_fn, edge_map); the router only reads state and returns a string.
  • Guide’s inner layer in agents/guide.py uses should_continue to control dig_deeperEND; the outer layer in workflow.py uses route_after_guide to control the guide loop ↔ analysis chain.
  • Production API uses run_guide_chat single-turn + user-driven loop; CLI uses run_workflow to run the full graph, which triggers the turn limit in route_after_guide.
  • Loops must have independent counters + framework recursion_limit to prevent LangGraph from spinning without user input.
  • The next article (Article 6) will discuss how two-layer StateGraph nesting decouples the Guide subgraph from top-level orchestration.

Appendix: Key Source Code (Annotated Line by Line)

The following code is taken from the iCan implementation, with Chinese comments above each line, so you can follow along even without the public repository.
Generation command: python3 bin/build-ican-annotated-snippets.py

Inner should_continue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# ========== Inner should_continue ==========
# Source file: agents/guide.py Lines 336-372

# L336: Synchronous function should_continue: routing decision or factory method
def should_continue(state: GuideState) -> str:
# L338: [Doc] Conditional routing function: decides whether to continue the dialogue or hand off to the next Agent.
# L340: [Doc] Function description:
# L341: [Doc] Based on the result of the info sufficiency check, determines the workflow direction:
# L342: [Doc] - If info is sufficient (is_info_sufficient=True), end the process and hand off to the next Agent
# L343: [Doc] - If info is insufficient (is_info_sufficient=False), return dig_deeper to continue the dialogue
# L344: [Doc] - Loops at most 2 times to avoid infinite loops without real-time user interaction
# L346: [Doc] Parameter description:
# L347: [Doc] state (GuideState): The Guide dialogue state object, must include the is_info_sufficient field.
# L349: [Doc] Return description:
# L350: [Doc] str: Node name string, "dig_deeper" means continue dialogue, "handoff" means hand off to the next Agent.
# (L337-351 are function/module docstrings, converted to comments for readability)
# L352: Start try block, except handles fallback
try:
# L353: Log for online troubleshooting of node input/output
logger.info("[should_continue] Starting execution, input: state=%s", {k: str(v)[:100] for k, v in state.items()})
# L354: Guide determines if user info is sufficient to enter analysis phase
is_sufficient = state.get("is_info_sufficient", False)

# L356: Assignment: update local variable or state field
messages_list = state.get("messages", [])
# L357: Assignment: update local variable or state field
loop_count = len([m for m in messages_list if m])

# L359: Conditional branch
if is_sufficient:
# L360: Log for online troubleshooting of node input/output
logger.info("[should_continue] Info sufficient, routing to handoff (hand over to next Agent)")
# L361: Returns fields to merge into state (LangGraph will merge)
return "handoff"

# L363: Conditional branch
if loop_count >= 8:
# L364: Log for online troubleshooting of node input/output
logger.info("[should_continue] Max loop count reached (%d), forcing route to handoff", loop_count)
# L365: Returns fields to merge into state (LangGraph will merge)
return "handoff"

# L367: Log for online troubleshooting of node input/output
logger.info("[should_continue] Info insufficient, routing to dig_deeper (continue deep dive), current loop=%d", loop_count)
# L368: Returns fields to merge into state (LangGraph will merge)
return "dig_deeper"

# L370: Catch exceptions to prevent full graph/request crash
except Exception as e:
# L371: Log for online troubleshooting of node input/output
logger.error("[should_continue] Condition routing exception: %s", e, exc_info=True)
# L372: Returns fields to merge into state (LangGraph will merge)
return "dig_deeper"

Outer route_after_guide

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# ========== Outer route_after_guide ==========
# Source file: workflow.py Lines 393-429

# L393: Synchronous function route_after_guide: routing decision or factory method
def route_after_guide(state: iCanWorkflowState) -> str:
# L395: [Doc] Routing decision after GuideAgent: continue if info sufficient, else loop (max 2 times)
# L397: [Doc] Function description:
# L398: [Doc] Based on the info sufficiency result from the Guide phase, determines the workflow direction:
# L399: [Doc] - If needs_more_info is True (info insufficient), return guide_node to continue dialogue
# L400: [Doc] - If needs_more_info is False (info sufficient), return resume_parser_node to enter next phase
# L401: [Doc] - Loops at most 2 times to avoid infinite loops without real-time user interaction
# L403: [Doc] Parameters:
# L404: [Doc] state (iCanWorkflowState): Top-level workflow state, must include the needs_more_info field
# L406: [Doc] Returns:
# L407: [Doc] str: Next node name, either "guide_node" or "resume_parser_node"
# (L394-408 are function/module docstrings, converted to comments for readability)
# L409: Start try block, except handles fallback
try:
# L410: Whether to continue Guide loop; False means can proceed to resume_parser
logger.info("[route_after_guide] Starting execution, input: needs_more_info=%s", state.get("needs_more_info"))

# L412: Whether to continue Guide loop; False means can proceed to resume_parser
needs_more_info = state.get("needs_more_info", True)
# L413: List of multi-turn dialogues, elements are {role, content}
conversation_history = state.get("conversation_history", [])
# L414: List of multi-turn dialogues, elements are {role, content}
user_msg_count = len([m for m in conversation_history if m.get("role") == "user"])

# L416: Whether to continue Guide loop; False means can proceed to resume_parser
if not needs_more_info:
# L417: Log for online troubleshooting of node input/output
logger.info("[route_after_guide] Route decision: info sufficient, entering resume_parser_node")
# L418: Returns fields to merge into state (LangGraph will merge)
return "resume_parser_node"

# L420: Conditional branch
if user_msg_count >= 3:
# L421: Log for online troubleshooting of node input/output
logger.info("[route_after_guide] Route decision: already have %d user messages, forcing to resume_parser_node", user_msg_count)
# L422: Returns fields to merge into state (LangGraph will merge)
return "resume_parser_node"

# L424: Log for online troubleshooting of node input/output
logger.info("[route_after_guide] Route decision: info insufficient, returning to guide_node to continue dialogue")
# L425: Returns fields to merge into state (LangGraph will merge)
return "guide_node"

# L427: Catch exceptions to prevent full graph/request crash
except Exception as e:
# L428: Log for online troubleshooting of node input/output
logger.error("[route_after_guide] Route decision exception: %s", e, exc_info=True)
# L429: Returns fields to merge into state (LangGraph will merge)
return "resume_parser_node"

Conditional Edge Registration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# ========== Conditional Edge Registration ==========
# Source file: workflow.py Lines 432-474

# L432: Synchronous function create_workflow: routing decision or factory method
def create_workflow() -> StateGraph:
# L434: [Doc] Create the top-level workflow StateGraph
# L436: [Doc] Function description:
# L437: [Doc] Build the top-level LangGraph workflow graph for the iCan system, connecting 5 Agent nodes,
# L438: [Doc] and implement the GuideAgent's loop dialogue mechanism via conditional routing.
# L440: [Doc] Input: None
# L442: [Doc] Output:
# L443: [Doc] StateGraph: Compiled LangGraph StateGraph instance
# L445: [Doc] Workflow:
# L446: [Doc] start -> guide_node -> route_after_guide
# L447: [Doc] -> (info insufficient) -> guide_node [loop]
# L448: [Doc] -> (info sufficient) -> resume_parser_node -> profile_analyzer_node
# L449: [Doc] -> career_matcher_node -> reporter_node -> END
# (L433-450 are function/module docstrings, converted to comments for readability)
# L451: Start try block, except handles fallback
try:
# L452: Log for online troubleshooting of node input/output
logger.info("[create_workflow] Starting to create top-level workflow StateGraph")

# L454: Create LangGraph state graph; the TypedDict inside defines fields shared/passed between nodes
graph = StateGraph(iCanWorkflowState)

# L456: Add nodes
# L457: Register graph node "guide_node", value is async node function
graph.add_node("guide_node", guide_node)
# L458: Register graph node "resume_parser_node", value is async node function
graph.add_node("resume_parser_node", resume_parser_node)
# L459: Register graph node "profile_analyzer_node", value is async node function
graph.add_node("profile_analyzer_node", profile_analyzer_node)
# L460: Register graph node "career_matcher_node", value is async node function
graph.add_node("career_matcher_node", career_matcher_node)
# L461: Register graph node "reporter_node", value is async node function
graph.add_node("reporter_node", reporter_node)

# L463: Set entry point
# L464: Set graph entry: the first node executed when ainvoke is called
graph.set_entry_point("guide_node")

# L466: Define conditional edge: after guide_node, route based on info sufficiency
# L467: Add conditional edge: the routing function's return value determines the next node name
graph.add_conditional_edges(
# L468: Execute this statement (details in the business description above)
"guide_node",
# L469: Execute this statement (details in the business description above)
route_after_guide,
# L470: Execute this statement (details in the business description above)
{
# L471: Execute this statement (details in the business description above)
"guide_node": "guide_node",
# L472: Execute this statement (details in the business description above)
"resume_parser_node": "resume_parser_node",
# L473: Execute this statement (details in the business description above)
},
# L474: Execute this statement (details in the business description above)
)

Series Navigation

Article Topic
1 System Panorama
2 Five Agent Collaboration
3 Holland RIASEC
4–7 State · 5 Routing (This) · Nesting · Fault Tolerance
8–11 LLM Layer · SSE/WS · DB Migration · PDF
12–14 JSON Prompt · RIASEC Prompt · Guide Prompt
15–17 Docker · Middleware · Configuration

← Back to iCan Topic