Implementation Status
The FeatureAgent LangGraph graph is implemented at
packages/core/src/infrastructure/services/agents/feature-agent/. This guide describes how to extend the agent system with new nodes. The code examples below use import paths referencing the feature-agent directory.See AGENTS.md for the full current implementation details.
Before adding any new node or agent capability, understand this rule: The agent executor used by any node, graph, or worker is ALWAYS resolved from
getSettings().agent.typeviaAgentExecutorFactory.createExecutor(). Nodes receive the executor as a dependency — they never create or choose an executor themselves. See AGENTS.md — Settings-Driven Agent Resolution.
Guide to extending Shep’s LangGraph-based agent system with new nodes.
Shep’s agent system will be built on LangGraph StateGraphs. Adding new capabilities will involve:
Before adding a new node, understand:
First, determine what state your node needs to read and write.
// src/infrastructure/agents/langgraph/state.ts
import { Annotation } from '@langchain/langgraph';
// Add new fields to the state schema
export const FeatureState = Annotation.Root({
// Existing fields...
repoPath: Annotation<string>,
repoAnalysis: Annotation<RepoAnalysis | null>,
// NEW: Add your node's output field
myNodeOutput: Annotation<MyOutputType | null>,
// For arrays, use reducers
myItems: Annotation<MyItem[]>({
reducer: (prev, next) => [...prev, ...next],
default: () => [],
}),
});
export type FeatureStateType = typeof FeatureState.State;
State Field Guidelines:
| Pattern | Use Case | Example |
|---|---|---|
| Simple field | Single value output | analysis: Annotation<Analysis \| null> |
| Array with reducer | Accumulating items | tasks: Annotation<Task[]>({ reducer: ... }) |
| Status/phase field | Tracking progress | currentPhase: Annotation<SdlcLifecycle> |
| Error field | Error handling | error: Annotation<string \| null> |
Create a new file for your node:
// src/infrastructure/agents/langgraph/nodes/my-node.node.ts
import { ChatAnthropic } from '@langchain/anthropic';
import { FeatureStateType } from '../state';
const model = new ChatAnthropic({
modelName: 'claude-sonnet-4-20250514',
});
/**
* MyNode - Brief description of what this node does.
*
* Inputs (from state):
* - repoAnalysis: Repository context
* - requirements: Gathered requirements
*
* Outputs (state updates):
* - myNodeOutput: The processed result
* - currentPhase: Updated lifecycle phase
*/
export async function myNode(state: FeatureStateType): Promise<Partial<FeatureStateType>> {
// 1. Extract needed state
const { repoAnalysis, requirements } = state;
// 2. Validate inputs
if (!repoAnalysis) {
return {
error: 'Repository analysis required before this step',
};
}
// 3. Do the work
const result = await processData(repoAnalysis, requirements);
// 4. Return partial state update
return {
myNodeOutput: result,
currentPhase: SdlcLifecycle.NextPhase,
};
}
// Helper functions (keep node function clean)
async function processData(
analysis: RepoAnalysis,
requirements: Requirement[]
): Promise<MyOutputType> {
// Implementation
}
Node Function Rules:
state parameterRegister your node in the appropriate graph:
// src/infrastructure/agents/langgraph/graphs/feature.graph.ts
import { StateGraph, START, END } from '@langchain/langgraph';
import { FeatureState } from '../state';
import { analyzeNode, requirementsNode, planNode, implementNode } from '../nodes';
import { myNode } from '../nodes/my-node.node'; // NEW
export function createFeatureGraph() {
return (
new StateGraph(FeatureState)
.addNode('analyze', analyzeNode)
.addNode('requirements', requirementsNode)
.addNode('myNode', myNode) // NEW
.addNode('plan', planNode)
.addNode('implement', implementNode)
// Define flow (see Step 4)
.addEdge(START, 'analyze')
// ...
.compile()
);
}
Choose the appropriate edge type:
// After analyze, always go to myNode
graph.addEdge('analyze', 'myNode');
graph.addEdge('myNode', 'requirements');
// Choose next node based on state
graph.addConditionalEdges('myNode', (state) => {
if (state.error) {
return 'error_handler';
}
if (needsMoreWork(state)) {
return 'myNode'; // Loop back
}
return 'next_step';
});
graph.addConditionalEdges('decision_point', (state) => {
switch (state.decisionType) {
case 'typeA':
return 'handleA';
case 'typeB':
return 'handleB';
default:
return 'handleDefault';
}
});
Follow Red-Green-Refactor:
// tests/unit/agents/nodes/my-node.node.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { myNode } from '@/infrastructure/services/agents/feature-agent/nodes/my-node.node';
describe('myNode', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should process analysis and return output', async () => {
// Arrange
const state = {
repoPath: '/test/repo',
repoAnalysis: {
/* mock data */
},
requirements: [{ id: '1', text: 'Requirement' }],
myNodeOutput: null,
currentPhase: SdlcLifecycle.Requirements,
};
// Act
const result = await myNode(state);
// Assert
expect(result.myNodeOutput).toBeDefined();
expect(result.currentPhase).toBe(SdlcLifecycle.NextPhase);
});
it('should return error when analysis missing', async () => {
const state = {
repoPath: '/test/repo',
repoAnalysis: null, // Missing!
requirements: [],
myNodeOutput: null,
currentPhase: SdlcLifecycle.Requirements,
};
const result = await myNode(state);
expect(result.error).toContain('Repository analysis required');
});
it('should handle empty requirements gracefully', async () => {
const state = {
repoPath: '/test/repo',
repoAnalysis: {
/* mock data */
},
requirements: [], // Empty
myNodeOutput: null,
currentPhase: SdlcLifecycle.Requirements,
};
const result = await myNode(state);
expect(result.myNodeOutput).toBeDefined();
// Verify appropriate handling of empty input
});
});
// tests/integration/agents/graphs/feature.graph.test.ts
import { describe, it, expect } from 'vitest';
import { createFeatureGraph } from '@/infrastructure/services/agents/feature-agent/graphs/feature.graph';
describe('FeatureGraph with myNode', () => {
it('should execute myNode in correct order', async () => {
const graph = createFeatureGraph();
const executedNodes: string[] = [];
// Stream to track node execution
const stream = await graph.stream({
repoPath: './test-fixtures/sample-repo',
featureDescription: 'Test feature',
});
for await (const event of stream) {
executedNodes.push(event.node);
}
// Verify myNode runs after analyze
const analyzeIndex = executedNodes.indexOf('analyze');
const myNodeIndex = executedNodes.indexOf('myNode');
expect(myNodeIndex).toBeGreaterThan(analyzeIndex);
});
});
If your node needs external capabilities, create or use tools:
import { contextQueryTool, fileSystemTool } from '../tools';
const modelWithTools = model.bindTools([contextQueryTool, fileSystemTool]);
export async function myNode(state: FeatureStateType) {
const response = await modelWithTools.invoke([
{ role: 'system', content: MY_NODE_PROMPT },
{ role: 'user', content: state.featureDescription },
]);
// Handle tool calls if any
const toolCalls = response.tool_calls || [];
// Process results...
}
// src/infrastructure/agents/langgraph/tools/my-tool.tool.ts
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
export const myTool = tool(
async ({ param1, param2 }) => {
// Tool implementation
const result = await doSomething(param1, param2);
return JSON.stringify(result);
},
{
name: 'my_tool',
description: 'Clear description of what this tool does and when to use it',
schema: z.object({
param1: z.string().describe('Description of param1'),
param2: z.number().optional().describe('Optional numeric parameter'),
}),
}
);
Tool Guidelines:
export async function gatheringNode(state: FeatureStateType) {
const newItems = await gatherMore(state);
return {
items: newItems, // Reducer appends to existing
gatheringComplete: newItems.length === 0,
};
}
// In graph:
graph.addConditionalEdges('gathering', (state) => (state.gatheringComplete ? 'next' : 'gathering'));
import { interrupt } from '@langchain/langgraph';
export async function approvalNode(state: FeatureStateType) {
// Pause for human approval
const approved = await interrupt({
type: 'approval_required',
message: 'Please review the plan',
data: state.plan,
});
if (!approved) {
return { error: 'Plan rejected by user' };
}
return { approved: true };
}
export async function safeNode(state: FeatureStateType) {
try {
const result = await riskyOperation(state);
return { result, error: null };
} catch (error) {
// Don't throw - return error in state
return {
result: null,
error: error instanceof Error ? error.message : 'Unknown error',
currentPhase: SdlcLifecycle.Error,
};
}
}
// Add error handler node
graph.addNode('error_handler', errorHandlerNode);
graph.addConditionalEdges('safeNode', (state) => (state.error ? 'error_handler' : 'next'));
export async function parallelNode(state: FeatureStateType) {
// Run multiple tasks concurrently
const [result1, result2, result3] = await Promise.all([
processTypeA(state),
processTypeB(state),
processTypeC(state),
]);
return {
resultsA: result1,
resultsB: result2,
resultsC: result3,
};
}
src/infrastructure/services/agents/feature-agent/
├── state.ts # State schema (modify for new fields)
├── nodes/
│ ├── node-helpers.ts # Shared node utilities
│ ├── analyze.node.ts
│ ├── requirements.node.ts
│ ├── research.node.ts
│ ├── plan.node.ts
│ ├── implement.node.ts
│ ├── schemas/ # Validation schemas for nodes
│ └── my-node.node.ts # NEW: Your node
├── feature-agent-graph.ts # Graph factory (wires nodes)
├── feature-agent-process.service.ts # Background process management
├── feature-agent-worker.ts # Detached worker entry point
└── heartbeat.ts # Node heartbeat reporting
Before submitting your new node:
Update when:
Related docs: