Data Model: StateGraph Agent Node Separation
Feature: 011-stategraph-node-separation Date: 2026-01-09 Status: Design
Overview
This document defines the data model extensions required to support separate agent nodes in the LangGraph workflow. The primary change is adding three optional state fields to collect per-agent feedback before merging in the aggregator node.
Entity: ReviewState (Extended)
File: packages/resume-review/src/workflow/state.py
Purpose: LangGraph state schema that flows through the workflow, extended with per-agent feedback fields.
New Fields
| Field Name | Type | Required | Default | Reducer | Description |
|---|---|---|---|---|---|
recruiter_feedback | Optional[Feedback] | No | None | None | Feedback from recruiter agent node (overwritten each iteration) |
tech_writer_feedback | Optional[Feedback] | No | None | None | Feedback from technical writer agent node (overwritten each iteration) |
copywriter_feedback | Optional[Feedback] | No | None | None | Feedback from copywriter agent node (overwritten each iteration) |
Field Semantics
Write Pattern:
- Each agent node writes to its dedicated field:
return {"recruiter_feedback": feedback} - Fields are overwritten on each iteration (no accumulation across iterations)
- No reducer needed - simple state update
Read Pattern:
- Aggregator node reads all three fields after parallel execution completes
- Checks for presence:
if "recruiter_feedback" in state: ... - Merges into canonical
current_feedbacklist for downstream nodes
Lifecycle:
- Initialization: Fields are unset (
Noneor not present in state dict) - Agent Execution: Each agent writes to its field (parallel, no conflicts)
- Aggregation: Aggregator reads and merges into
current_feedback - Next Iteration: Fields are overwritten with new feedback (no accumulation)
Backward Compatibility
✅ Fully Backward Compatible
TypedDictwithtotal=Falseallows optional fields- Existing code that doesn’t reference these fields continues to work
- The
current_feedbackfield remains the canonical source for downstream nodes - No breaking changes to CLI, ReviewSession, or output formats
Example:
class ReviewState(TypedDict, total=False):
# Existing fields (unchanged)
resume: Resume
target_role: str
current_feedback: list[Feedback]
feedback_history: Annotated[list[list[Feedback]], add_feedback]
# ... other existing fields ...
# NEW: Per-agent feedback fields
recruiter_feedback: Optional[Feedback]
tech_writer_feedback: Optional[Feedback]
copywriter_feedback: Optional[Feedback]
State Transitions
┌─────────────┐
│ router_node │ (no state changes)
└──────┬──────┘
│ Fan-out (parallel execution)
├────────────────┬────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│recruiter_node│ │tech_writer_ │ │copywriter_ │
│ │ │node │ │node │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ Write │ Write │ Write
│ recruiter_ │ tech_writer_ │ copywriter_
│ feedback │ feedback │ feedback
│ │ │
└────────────────┴────────────────┘
│ Fan-in (wait for all)
▼
┌─────────────┐
│ aggregator_ │
│ node │
└──────┬──────┘
│
│ Merge into current_feedback
│ = [recruiter, tech_writer, copywriter]
▼
Validation Rules
- Type Safety: Each field must be
Optional[Feedback]or absent - Mutual Exclusivity: Each agent writes only to its own field (enforced by node isolation)
- Completeness: Aggregator tolerates partial feedback (e.g., if one agent failed)
- Order Preservation: Aggregator merges in consistent order: recruiter, tech_writer, copywriter
Example State Flow
After Router Node:
{
"resume": Resume(...),
"target_role": "LLM Engineer",
"current_iteration": 1,
# Per-agent fields not yet set
}
After Parallel Agent Execution:
{
"resume": Resume(...),
"target_role": "LLM Engineer",
"current_iteration": 1,
"recruiter_feedback": Feedback(agent_name="recruiter", score=8.5, ...),
"tech_writer_feedback": Feedback(agent_name="technical_writer", score=7.0, ...),
"copywriter_feedback": Feedback(agent_name="copywriter", score=8.0, ...),
}
After Aggregator Node:
{
"resume": Resume(...),
"target_role": "LLM Engineer",
"current_iteration": 1,
# Per-agent fields still present but no longer used
"recruiter_feedback": Feedback(...),
"tech_writer_feedback": Feedback(...),
"copywriter_feedback": Feedback(...),
# Canonical merged list
"current_feedback": [
Feedback(agent_name="recruiter", score=8.5, ...),
Feedback(agent_name="technical_writer", score=7.0, ...),
Feedback(agent_name="copywriter", score=8.0, ...),
],
"integrated_score": 7.8,
"threshold_met": False,
}
Entity: Feedback (Unchanged)
File: packages/resume-review/src/models/feedback.py
No changes required - existing Feedback model is used as-is.
Relevant Fields:
class Feedback(BaseModel):
agent_name: str # "recruiter", "technical_writer", "copywriter"
score: float # 0.0 - 10.0
strengths: list[str]
issues: list[Issue]
suggestions: list[str]
Entity: Node Function Contract
Purpose: Standard interface for all node functions in the workflow.
Input Type
state: ReviewState # Full state dictionary
Key Fields Used by Agent Nodes:
state["resume"]: Resume object to evaluatestate["target_role"]: Target role string (e.g., “LLM Engineer”)state["gemini_api_key"]: Optional Gemini API keystate["openai_api_key"]: Optional OpenAI API keystate["anthropic_api_key"]: Optional Anthropic API keystate["override_model"]: Optional model override for testing
Output Type
dict[str, Any] # Partial state update
Agent Node Output Example:
{
"recruiter_feedback": Feedback(
agent_name="recruiter",
score=8.5,
strengths=["Strong technical background"],
issues=[Issue(...)],
suggestions=["Add quantitative metrics"]
)
}
Aggregator Node Output Example:
{
"current_feedback": [recruiter_fb, tech_writer_fb, copywriter_fb],
"integrated_score": 7.8,
"threshold_met": False,
"feedback_history": [[recruiter_fb, tech_writer_fb, copywriter_fb]],
"final_score": 7.8
}
Node Function Signature
All agent nodes follow this pattern:
async def <agent>_node(state: ReviewState) -> dict[str, Any]:
"""
Execute <agent> agent evaluation.
Args:
state: Full workflow state
Returns:
Partial state update with <agent>_feedback field
"""
start_time = time.perf_counter()
logger.info("Starting <agent> node")
try:
# Create LLM client
client = LLMClientFactory.create_client(
agent_name=AgentName.<AGENT>,
gemini_api_key=state.get("gemini_api_key"),
openai_api_key=state.get("openai_api_key"),
anthropic_api_key=state.get("anthropic_api_key"),
override_model=state.get("override_model"),
)
# Initialize agent
agent = <Agent>Agent(llm_client=client)
# Evaluate
feedback = await agent.evaluate_async(
state["resume"],
state["target_role"]
)
# Log success
duration = time.perf_counter() - start_time
logger.debug(f"<Agent> score: {feedback.score}/10.0")
logger.info(f"<Agent> node completed in {duration:.2f}s")
return {"<agent>_feedback": feedback}
except Exception as e:
duration = time.perf_counter() - start_time
logger.error(f"Error in <agent> node after {duration:.2f}s: {e}")
# Return minimal feedback (neutral score)
return {
"<agent>_feedback": Feedback(
agent_name="<agent>",
score=5.0,
strengths=["Evaluation failed"],
issues=[],
suggestions=[f"Error: {str(e)}"],
)
}
Entity: Graph Structure (Updated)
File: packages/resume-review/src/workflow/graph.py
Node Inventory
Before (3 nodes):
supervisor(contains 3 agents internally)aggregatorrevisor,portfolio,design(unchanged)
After (6 nodes):
router(fan-out coordinator)recruiter(agent evaluation)tech_writer(agent evaluation)copywriter(agent evaluation)aggregator(modified to collect from separate fields)revisor,portfolio,design(unchanged)
Edge Definitions
Before:
workflow.set_entry_point("supervisor")
workflow.add_edge("supervisor", "aggregator")
workflow.add_edge("revisor", "supervisor") # Loop back
After:
# Entry point
workflow.set_entry_point("router")
# Fan-out (parallel execution)
workflow.add_edge("router", "recruiter")
workflow.add_edge("router", "tech_writer")
workflow.add_edge("router", "copywriter")
# Fan-in (wait for all)
workflow.add_edge("recruiter", "aggregator")
workflow.add_edge("tech_writer", "aggregator")
workflow.add_edge("copywriter", "aggregator")
# Loop back to router (not supervisor)
workflow.add_edge("revisor", "router")
# Remainder unchanged
workflow.add_conditional_edges("aggregator", should_continue_review, {...})
workflow.add_conditional_edges("portfolio", should_do_design_review, {...})
workflow.add_edge("design", END)
Execution Flow
__start__
↓
router (logs "Starting iteration N")
↓ (fan-out, parallel)
├── recruiter (writes recruiter_feedback)
├── tech_writer (writes tech_writer_feedback)
└── copywriter (writes copywriter_feedback)
↓ (fan-in, wait for all)
aggregator (merges into current_feedback)
↓ (conditional)
├── revisor → router (loop)
└── portfolio → design/END
Data Validation & Error Handling
Valid State Scenarios
-
All agents succeed:
- All three feedback fields populated
- Aggregator merges all three into
current_feedback
-
One agent fails:
- Failed agent writes minimal feedback (score 5.0)
- Aggregator merges all three (including failed one)
-
Multiple agents fail:
- Each writes minimal feedback with error message
- Workflow continues with degraded feedback
Invalid State Scenarios (Should Not Occur)
- Missing feedback fields: Aggregator handles gracefully by skipping None fields
- Type mismatch: Pydantic validation catches at runtime
- Reducer conflict: Impossible - separate fields prevent conflicts
Error Recovery Pattern
# In agent node
try:
feedback = await agent.evaluate_async(resume, target_role)
return {"<agent>_feedback": feedback}
except Exception as e:
logger.error(f"Error in <agent> node: {e}")
return {
"<agent>_feedback": Feedback(
agent_name="<agent>",
score=5.0, # Neutral score - doesn't bias final score
strengths=["Evaluation failed"],
issues=[],
suggestions=[f"Error: {str(e)}"],
)
}
Rationale: Graceful degradation allows workflow to complete even if one agent fails, providing partial feedback instead of cascading failure.
Performance Characteristics
State Size Impact: Minimal
- Three additional optional fields per iteration
- Each
Feedbackobject is ~1-2 KB - Total overhead: ~3-6 KB per iteration
- Negligible compared to resume content (~10-50 KB)
Serialization: Standard TypedDict → dict conversion (no custom serializers needed)
Memory Footprint: O(1) per iteration (fields are overwritten, not accumulated)
Migration Path
Phase 1: Add Optional Fields
# In state.py - ADD these fields (no breaking changes)
class ReviewState(TypedDict, total=False):
# ... existing fields ...
# NEW: Per-agent feedback
recruiter_feedback: Optional[Feedback]
tech_writer_feedback: Optional[Feedback]
copywriter_feedback: Optional[Feedback]
Impact: Zero - TypedDict total=False allows optional fields
Phase 2: Update Graph & Nodes
- Create router, recruiter, tech_writer, copywriter node functions
- Update graph.py with new edges
- Modify aggregator to read from separate fields
- Remove supervisor_node
Impact: Existing tests may fail (node name assertions) - covered in RT-006
Phase 3: Update Tests
- Update test assertions to check for new node names
- Verify fan-out/fan-in behavior
- Confirm output format unchanged
Impact: Test suite passes with new implementation
Summary
Key Changes:
- ✅ Three new optional state fields (recruiter_feedback, tech_writer_feedback, copywriter_feedback)
- ✅ No custom reducers needed (simple overwrites)
- ✅ Fully backward compatible (TypedDict total=False)
- ✅ Clear state transitions (router → agents → aggregator)
- ✅ Graceful error handling (minimal feedback on failure)
Validation:
- ✅ Matches research findings (RT-002: no reducers needed)
- ✅ Supports fan-out/fan-in pattern (RT-001)
- ✅ Enables per-agent error attribution (RT-004)
Ready for: Contract definition (Phase 1.2)