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 - z A - Z 0 - 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 - z A - Z 0 - 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+ }
0 commit comments