Skip to content

Commit 25f94a0

Browse files
committed
feat(adapter): add LangGraph export and run adapter
Adds `langgraph` as a new export format and run adapter. Closes #1. - src/adapters/langgraph.ts: exports gitagent definition as a LangGraph StateGraph (ReAct pattern) with ToolNode and conditional routing - src/runners/langgraph.ts: runs the exported graph script via python3 - Multi-agent topology: sub-agents from agent.yaml are annotated with guidance for supervisor-node patterns - Compliance constraints injected into system prompt including SOD conflicts - Model routing: gpt-4o (default), claude-* → ChatAnthropic, gemini-* → ChatGoogleGenerativeAI
1 parent 0ba5985 commit 25f94a0

File tree

2 files changed

+227
-0
lines changed

2 files changed

+227
-0
lines changed

src/adapters/langgraph.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { resolve, join } from 'node:path';
2+
import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js';
3+
import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js';
4+
5+
/**
6+
* Export a gitagent directory to a LangGraph-compatible Python agent graph.
7+
*
8+
* Generates a ready-to-run Python file implementing a stateful ReAct agent
9+
* using LangGraph's StateGraph with ToolNode. Supports multi-agent topologies
10+
* via the gitagent `agents` manifest field. Compliance constraints are
11+
* injected as system prompt rules.
12+
*/
13+
export function exportToLangGraph(dir: string): string {
14+
const agentDir = resolve(dir);
15+
const manifest = loadAgentManifest(agentDir);
16+
17+
const systemPrompt = buildSystemPrompt(agentDir, manifest);
18+
const tools = buildToolDefinitions(agentDir);
19+
const hasSubAgents = manifest.agents && Object.keys(manifest.agents).length > 0;
20+
const modelImport = resolveModelImport(manifest.model?.preferred);
21+
22+
const lines: string[] = [];
23+
24+
lines.push('"""');
25+
lines.push(`LangGraph agent graph for ${manifest.name} v${manifest.version}`);
26+
lines.push('Generated by gitagent export --format langgraph');
27+
lines.push('"""');
28+
lines.push('');
29+
lines.push('from typing import Annotated, TypedDict, Sequence');
30+
lines.push('from langchain_core.messages import BaseMessage, HumanMessage, AIMessage');
31+
lines.push('from langchain_core.tools import tool');
32+
lines.push(`${modelImport}`);
33+
lines.push('from langgraph.graph import StateGraph, END');
34+
lines.push('from langgraph.prebuilt import ToolNode');
35+
lines.push('import operator');
36+
lines.push('');
37+
38+
lines.push('# --- Agent State ---');
39+
lines.push('class AgentState(TypedDict):');
40+
lines.push(' messages: Annotated[Sequence[BaseMessage], operator.add]');
41+
lines.push('');
42+
43+
if (tools.length > 0) {
44+
lines.push('# --- Tools ---');
45+
for (const t of tools) {
46+
const funcName = t.name.replace(/[^a-zA-Z0-9]/g, '_');
47+
lines.push('');
48+
lines.push('@tool');
49+
lines.push(`def ${funcName}(input: str) -> str:`);
50+
lines.push(` """${t.description}"""`);
51+
lines.push(` # TODO: implement tool logic`);
52+
lines.push(` raise NotImplementedError("${funcName} not yet implemented")`);
53+
}
54+
lines.push('');
55+
lines.push(`tools = [${tools.map(t => t.name.replace(/[^a-zA-Z0-9]/g, '_')).join(', ')}]`);
56+
} else {
57+
lines.push('tools = []');
58+
}
59+
60+
lines.push('');
61+
lines.push('# --- Model with tool binding ---');
62+
lines.push(`llm = ${resolveModelInstantiation(manifest.model?.preferred)}`);
63+
lines.push('llm_with_tools = llm.bind_tools(tools)');
64+
65+
lines.push('');
66+
lines.push('SYSTEM_PROMPT = """' + systemPrompt.replace(/"""/g, '\\"\\"\\"') + '"""');
67+
68+
lines.push('');
69+
lines.push('# --- Graph Nodes ---');
70+
lines.push('def agent_node(state: AgentState) -> dict:');
71+
lines.push(' """Main reasoning node."""');
72+
lines.push(' messages = state["messages"]');
73+
lines.push(' from langchain_core.messages import SystemMessage');
74+
lines.push(' if not any(isinstance(m, SystemMessage) for m in messages):');
75+
lines.push(' messages = [SystemMessage(content=SYSTEM_PROMPT)] + list(messages)');
76+
lines.push(' response = llm_with_tools.invoke(messages)');
77+
lines.push(' return {"messages": [response]}');
78+
lines.push('');
79+
lines.push('tool_node = ToolNode(tools)');
80+
lines.push('');
81+
lines.push('def should_continue(state: AgentState) -> str:');
82+
lines.push(' last = state["messages"][-1]');
83+
lines.push(' if hasattr(last, "tool_calls") and last.tool_calls:');
84+
lines.push(' return "tools"');
85+
lines.push(' return END');
86+
87+
lines.push('');
88+
lines.push('# --- Build Graph ---');
89+
lines.push('workflow = StateGraph(AgentState)');
90+
lines.push('workflow.add_node("agent", agent_node)');
91+
lines.push('workflow.add_node("tools", tool_node)');
92+
lines.push('workflow.set_entry_point("agent")');
93+
lines.push('workflow.add_conditional_edges("agent", should_continue)');
94+
lines.push('workflow.add_edge("tools", "agent")');
95+
lines.push('graph = workflow.compile()');
96+
97+
if (hasSubAgents) {
98+
lines.push('');
99+
lines.push('# --- Sub-Agents ---');
100+
lines.push('# Sub-agents defined in agent.yaml can be modelled as additional');
101+
lines.push('# StateGraph nodes with supervisor routing.');
102+
lines.push('# See: https://langchain-ai.github.io/langgraph/tutorials/multi_agent/multi-agent-collaboration/');
103+
for (const [name] of Object.entries(manifest.agents ?? {})) {
104+
lines.push(`# Sub-agent: ${name}`);
105+
}
106+
}
107+
108+
lines.push('');
109+
lines.push('if __name__ == "__main__":');
110+
lines.push(` print("Agent: ${manifest.name} v${manifest.version}")`);
111+
lines.push(' while True:');
112+
lines.push(' user_input = input("You: ").strip()');
113+
lines.push(' if not user_input or user_input.lower() in ("exit", "quit"):');
114+
lines.push(' break');
115+
lines.push(' result = graph.invoke({"messages": [HumanMessage(content=user_input)]})');
116+
lines.push(' last_ai = next((m for m in reversed(result["messages"]) if isinstance(m, AIMessage)), None)');
117+
lines.push(' if last_ai:');
118+
lines.push(' print(f"Agent: {last_ai.content}")');
119+
120+
return lines.join('\n');
121+
}
122+
123+
function buildSystemPrompt(agentDir: string, manifest: ReturnType<typeof loadAgentManifest>): string {
124+
const parts: string[] = [];
125+
126+
const soul = loadFileIfExists(join(agentDir, 'SOUL.md'));
127+
if (soul) parts.push(soul);
128+
129+
const rules = loadFileIfExists(join(agentDir, 'RULES.md'));
130+
if (rules) parts.push(`## Rules\n${rules}`);
131+
132+
const skillsDir = join(agentDir, 'skills');
133+
const skills = loadAllSkills(skillsDir);
134+
for (const skill of skills) {
135+
const allowedTools = getAllowedTools(skill.frontmatter);
136+
const toolsNote = allowedTools.length > 0 ? `\nAllowed tools: ${allowedTools.join(', ')}` : '';
137+
parts.push(`## Skill: ${skill.frontmatter.name}\n${skill.frontmatter.description}${toolsNote}\n\n${skill.instructions}`);
138+
}
139+
140+
if (manifest.compliance) {
141+
const c = manifest.compliance;
142+
const constraints: string[] = ['## Compliance Constraints'];
143+
if (c.communications?.fair_balanced) constraints.push('- All outputs must be fair and balanced (FINRA 2210)');
144+
if (c.communications?.no_misleading) constraints.push('- Never make misleading or promissory statements');
145+
if (c.data_governance?.pii_handling === 'redact') constraints.push('- Redact all PII from outputs');
146+
if (c.supervision?.human_in_the_loop === 'always') constraints.push('- All decisions require human approval');
147+
if (manifest.compliance.segregation_of_duties) {
148+
const sod = manifest.compliance.segregation_of_duties;
149+
if (sod.conflicts) {
150+
constraints.push('- Segregation of duties conflicts:');
151+
for (const [a, b] of sod.conflicts) {
152+
constraints.push(` - "${a}" and "${b}" may not be held by the same agent`);
153+
}
154+
}
155+
}
156+
if (constraints.length > 1) parts.push(constraints.join('\n'));
157+
}
158+
159+
return parts.join('\n\n');
160+
}
161+
162+
interface ToolDef {
163+
name: string;
164+
description: string;
165+
}
166+
167+
function buildToolDefinitions(agentDir: string): ToolDef[] {
168+
const skills = loadAllSkills(join(agentDir, 'skills'));
169+
return skills.map(s => ({
170+
name: s.frontmatter.name,
171+
description: s.frontmatter.description ?? s.frontmatter.name,
172+
}));
173+
}
174+
175+
function resolveModelImport(model?: string): string {
176+
if (!model) return 'from langchain_openai import ChatOpenAI';
177+
if (model.startsWith('claude')) return 'from langchain_anthropic import ChatAnthropic';
178+
if (model.startsWith('gemini')) return 'from langchain_google_genai import ChatGoogleGenerativeAI';
179+
return 'from langchain_openai import ChatOpenAI';
180+
}
181+
182+
function resolveModelInstantiation(model?: string): string {
183+
if (!model) return 'ChatOpenAI(model="gpt-4o", temperature=0.3)';
184+
if (model.startsWith('claude')) return `ChatAnthropic(model="${model}", temperature=0.3)`;
185+
if (model.startsWith('gemini')) return `ChatGoogleGenerativeAI(model="${model}", temperature=0.3)`;
186+
return `ChatOpenAI(model="${model}", temperature=0.3)`;
187+
}

src/runners/langgraph.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { writeFileSync, unlinkSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { tmpdir } from 'node:os';
4+
import { spawnSync } from 'node:child_process';
5+
import { randomBytes } from 'node:crypto';
6+
import { exportToLangGraph } from '../adapters/langgraph.js';
7+
import { AgentManifest } from '../utils/loader.js';
8+
import { error, info } from '../utils/format.js';
9+
10+
interface RunOptions {
11+
prompt?: string;
12+
}
13+
14+
export function runWithLangGraph(agentDir: string, _manifest: AgentManifest, _options: RunOptions = {}): void {
15+
const script = exportToLangGraph(agentDir);
16+
const tmpFile = join(tmpdir(), `gitagent-langgraph-${randomBytes(4).toString('hex')}.py`);
17+
18+
writeFileSync(tmpFile, script, 'utf-8');
19+
20+
info(`Running LangGraph agent from "${agentDir}"...`);
21+
info('Make sure langgraph, langchain-core, and a model package are installed.');
22+
23+
try {
24+
const result = spawnSync('python3', [tmpFile], {
25+
stdio: 'inherit',
26+
cwd: agentDir,
27+
env: { ...process.env },
28+
});
29+
30+
if (result.error) {
31+
error(`Failed to run Python: ${result.error.message}`);
32+
info('Install: pip install langgraph langchain-core langchain-openai');
33+
process.exit(1);
34+
}
35+
36+
process.exit(result.status ?? 0);
37+
} finally {
38+
try { unlinkSync(tmpFile); } catch { /* ignore */ }
39+
}
40+
}

0 commit comments

Comments
 (0)