From 15ab74e4c893a5b2b9b47d614f0124b55b6005e4 Mon Sep 17 00:00:00 2001 From: dahlinomine <132731075+dahlinomine@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:19:47 +0000 Subject: [PATCH] feat(adapter): add LangChain export and run adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `langchain` as a new export format and run adapter. Closes #2. - src/adapters/langchain.ts: exports gitagent definition as a LangChain `create_tool_calling_agent` Python script with `AgentExecutor` - src/runners/langchain.ts: runs the exported script via python3 - Wires langchain into adapters/index.ts, commands/export.ts, commands/run.ts Skills become @tool stubs. Compliance constraints are injected into the system prompt. Model routing: gpt-4o (default), claude-* → ChatAnthropic, gemini-* → ChatGoogleGenerativeAI. --- src/adapters/index.ts | 2 + src/adapters/langchain.ts | 135 ++++++++++++++++++++++++++++++++++++++ src/commands/export.ts | 12 +++- src/commands/run.ts | 17 +++-- src/runners/langchain.ts | 40 +++++++++++ 5 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 src/adapters/langchain.ts create mode 100644 src/runners/langchain.ts diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 487c132..515b369 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -5,3 +5,5 @@ export { exportToCrewAI } from './crewai.js'; export { exportToOpenClawString, exportToOpenClaw } from './openclaw.js'; export { exportToNanobotString, exportToNanobot } from './nanobot.js'; export { exportToCopilotString, exportToCopilot } from './copilot.js'; +export { exportToLangChain } from './langchain.js'; +export { exportToLangGraph } from './langgraph.js'; diff --git a/src/adapters/langchain.ts b/src/adapters/langchain.ts new file mode 100644 index 0000000..fa63626 --- /dev/null +++ b/src/adapters/langchain.ts @@ -0,0 +1,135 @@ +import { resolve, join } from 'node:path'; +import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; +import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; + +/** + * Export a gitagent directory to a LangChain-compatible Python agent script. + * + * Generates a ready-to-run Python file using the LangChain `create_tool_calling_agent` + * pattern with a `ChatOpenAI` / `ChatAnthropic` backbone and an `AgentExecutor`. + * Skills are surfaced as placeholder `@tool` functions; compliance constraints are + * injected into the system prompt. + */ +export function exportToLangChain(dir: string): string { + const agentDir = resolve(dir); + const manifest = loadAgentManifest(agentDir); + + const systemPrompt = buildSystemPrompt(agentDir, manifest); + const tools = buildToolDefinitions(agentDir); + const modelImport = resolveModelImport(manifest.model?.preferred); + + const lines: string[] = []; + + lines.push('"""'); + lines.push(`LangChain agent for ${manifest.name} v${manifest.version}`); + lines.push('Generated by gitagent export --format langchain'); + lines.push('"""'); + lines.push(''); + lines.push('from langchain.agents import create_tool_calling_agent, AgentExecutor'); + lines.push('from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder'); + lines.push('from langchain_core.tools import tool'); + lines.push(`${modelImport}`); + lines.push(''); + + // Tool stubs + if (tools.length > 0) { + lines.push('# --- Tools (implement or replace with real LangChain tools) ---'); + for (const t of tools) { + const funcName = t.name.replace(/[^a-zA-Z0-9]/g, '_'); + lines.push(''); + lines.push('@tool'); + lines.push(`def ${funcName}(input: str) -> str:`); + lines.push(` """${t.description}"""`); + lines.push(` # TODO: implement tool logic`); + lines.push(` raise NotImplementedError("${funcName} not yet implemented")`); + } + lines.push(''); + lines.push(`tools = [${tools.map(t => t.name.replace(/[^a-zA-Z0-9]/g, '_')).join(', ')}]`); + } else { + lines.push('tools = []'); + } + + lines.push(''); + lines.push('# --- Model ---'); + lines.push(`llm = ${resolveModelInstantiation(manifest.model?.preferred)}`); + lines.push(''); + lines.push('# --- Prompt ---'); + lines.push('prompt = ChatPromptTemplate.from_messages(['); + lines.push(' ("system", """' + systemPrompt.replace(/"""/g, '\\"\\"\\"') + '"""),'); + lines.push(' MessagesPlaceholder(variable_name="chat_history", optional=True),'); + lines.push(' ("human", "{input}"),'); + lines.push(' MessagesPlaceholder(variable_name="agent_scratchpad"),'); + lines.push('])'); + lines.push(''); + lines.push('# --- Agent ---'); + lines.push(`agent = create_tool_calling_agent(llm, tools, prompt)`); + lines.push(`agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)`); + lines.push(''); + lines.push('if __name__ == "__main__":'); + lines.push(` print("Agent: ${manifest.name} v${manifest.version}")`); + lines.push(' while True:'); + lines.push(' user_input = input("You: ").strip()'); + lines.push(' if not user_input or user_input.lower() in ("exit", "quit"):'); + lines.push(' break'); + lines.push(' result = agent_executor.invoke({"input": user_input})'); + lines.push(' print(f"Agent: {result[\'output\']}")') ; + + return lines.join('\n'); +} + +function buildSystemPrompt(agentDir: string, manifest: ReturnType): string { + const parts: string[] = []; + + const soul = loadFileIfExists(join(agentDir, 'SOUL.md')); + if (soul) parts.push(soul); + + const rules = loadFileIfExists(join(agentDir, 'RULES.md')); + if (rules) parts.push(`## Rules\n${rules}`); + + const skillsDir = join(agentDir, 'skills'); + const skills = loadAllSkills(skillsDir); + for (const skill of skills) { + const allowedTools = getAllowedTools(skill.frontmatter); + const toolsNote = allowedTools.length > 0 ? `\nAllowed tools: ${allowedTools.join(', ')}` : ''; + parts.push(`## Skill: ${skill.frontmatter.name}\n${skill.frontmatter.description}${toolsNote}\n\n${skill.instructions}`); + } + + if (manifest.compliance) { + const c = manifest.compliance; + const constraints: string[] = ['## Compliance Constraints']; + if (c.communications?.fair_balanced) constraints.push('- All outputs must be fair and balanced (FINRA 2210)'); + if (c.communications?.no_misleading) constraints.push('- Never make misleading or promissory statements'); + if (c.data_governance?.pii_handling === 'redact') constraints.push('- Redact all PII from outputs'); + if (c.supervision?.human_in_the_loop === 'always') constraints.push('- All decisions require human approval'); + if (constraints.length > 1) parts.push(constraints.join('\n')); + } + + return parts.join('\n\n'); +} + +interface ToolDef { + name: string; + description: string; +} + +function buildToolDefinitions(agentDir: string): ToolDef[] { + const skills = loadAllSkills(join(agentDir, 'skills')); + return skills.map(s => ({ + name: s.frontmatter.name, + description: s.frontmatter.description ?? s.frontmatter.name, + })); +} + +function resolveModelImport(model?: string): string { + if (!model) return 'from langchain_openai import ChatOpenAI'; + if (model.startsWith('claude')) return 'from langchain_anthropic import ChatAnthropic'; + if (model.startsWith('gemini')) return 'from langchain_google_genai import ChatGoogleGenerativeAI'; + return 'from langchain_openai import ChatOpenAI'; +} + +function resolveModelInstantiation(model?: string): string { + if (!model) return 'ChatOpenAI(model="gpt-4o", temperature=0.3)'; + if (model.startsWith('claude')) return `ChatAnthropic(model="${model}", temperature=0.3)`; + if (model.startsWith('gemini')) return `ChatGoogleGenerativeAI(model="${model}", temperature=0.3)`; + return `ChatOpenAI(model="${model}", temperature=0.3)`; +} \ No newline at end of file diff --git a/src/commands/export.ts b/src/commands/export.ts index 465b040..03508ba 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -9,6 +9,8 @@ import { exportToOpenClawString, exportToNanobotString, exportToCopilotString, + exportToLangChain, + exportToLangGraph, } from '../adapters/index.js'; import { exportToLyzrString } from '../adapters/lyzr.js'; import { exportToGitHubString } from '../adapters/github.js'; @@ -21,7 +23,7 @@ interface ExportOptions { export const exportCommand = new Command('export') .description('Export agent to other formats') - .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot)') + .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, langchain, langgraph)') .option('-d, --dir ', 'Agent directory', '.') .option('-o, --output ', 'Output file path') .action(async (options: ExportOptions) => { @@ -61,9 +63,15 @@ export const exportCommand = new Command('export') case 'copilot': result = exportToCopilotString(dir); break; + case 'langchain': + result = exportToLangChain(dir); + break; + case 'langgraph': + result = exportToLangGraph(dir); + break; default: error(`Unknown format: ${options.format}`); - info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot'); + info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, langchain, langgraph'); process.exit(1); } diff --git a/src/commands/run.ts b/src/commands/run.ts index 4773dd5..3e7d74b 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -12,6 +12,8 @@ import { runWithNanobot } from '../runners/nanobot.js'; import { runWithLyzr } from '../runners/lyzr.js'; import { runWithGitHub } from '../runners/github.js'; import { runWithGit } from '../runners/git.js'; +import { runWithLangChain } from '../runners/langchain.js'; +import { runWithLangGraph } from '../runners/langgraph.js'; interface RunOptions { repo?: string; @@ -26,7 +28,7 @@ interface RunOptions { export const runCommand = new Command('run') .description('Run an agent from a git repository or local directory') .option('-r, --repo ', 'Git repository URL') - .option('-a, --adapter ', 'Adapter: claude, openai, crewai, openclaw, nanobot, lyzr, github, git, prompt', 'claude') + .option('-a, --adapter ', 'Adapter: claude, openai, crewai, openclaw, nanobot, lyzr, github, git, prompt, langchain, langgraph', 'claude') .option('-b, --branch ', 'Git branch/tag to clone', 'main') .option('--refresh', 'Force re-clone (pull latest)', false) .option('--no-cache', 'Clone to temp dir, delete on exit') @@ -36,7 +38,6 @@ export const runCommand = new Command('run') let agentDir: string; let cleanup: (() => void) | undefined; - // Resolve agent directory if (options.dir) { agentDir = resolve(options.dir); } else if (options.repo) { @@ -61,14 +62,12 @@ export const runCommand = new Command('run') process.exit(1); } - // Validate agent directory if (!agentDirExists(agentDir)) { error(`No agent.yaml found in ${agentDir}`); if (cleanup) cleanup(); process.exit(1); } - // Load manifest let manifest; try { manifest = loadAgentManifest(agentDir); @@ -78,7 +77,6 @@ export const runCommand = new Command('run') process.exit(1); } - // Print agent info heading(`Running agent: ${manifest.name}`); label('Version', manifest.version); label('Description', manifest.description); @@ -88,7 +86,6 @@ export const runCommand = new Command('run') label('Adapter', options.adapter); divider(); - // Run with selected adapter try { switch (options.adapter) { case 'claude': @@ -125,12 +122,18 @@ export const runCommand = new Command('run') prompt: options.prompt, }); break; + case 'langchain': + runWithLangChain(agentDir, manifest, { prompt: options.prompt }); + break; + case 'langgraph': + runWithLangGraph(agentDir, manifest, { prompt: options.prompt }); + break; case 'prompt': console.log(exportToSystemPrompt(agentDir)); break; default: error(`Unknown adapter: ${options.adapter}`); - info('Supported adapters: claude, openai, crewai, openclaw, nanobot, lyzr, github, git, prompt'); + info('Supported adapters: claude, openai, crewai, openclaw, nanobot, lyzr, github, git, prompt, langchain, langgraph'); process.exit(1); } } catch (e) { diff --git a/src/runners/langchain.ts b/src/runners/langchain.ts new file mode 100644 index 0000000..36180ab --- /dev/null +++ b/src/runners/langchain.ts @@ -0,0 +1,40 @@ +import { writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { exportToLangChain } from '../adapters/langchain.js'; +import { AgentManifest } from '../utils/loader.js'; +import { error, info } from '../utils/format.js'; + +interface RunOptions { + prompt?: string; +} + +export function runWithLangChain(agentDir: string, _manifest: AgentManifest, _options: RunOptions = {}): void { + const script = exportToLangChain(agentDir); + const tmpFile = join(tmpdir(), `gitagent-langchain-${randomBytes(4).toString('hex')}.py`); + + writeFileSync(tmpFile, script, 'utf-8'); + + info(`Running LangChain agent from "${agentDir}"...`); + info('Make sure langchain, langchain-core, and a model package (langchain-openai / langchain-anthropic) are installed.'); + + try { + const result = spawnSync('python3', [tmpFile], { + stdio: 'inherit', + cwd: agentDir, + env: { ...process.env }, + }); + + if (result.error) { + error(`Failed to run Python: ${result.error.message}`); + info('Install: pip install langchain langchain-core langchain-openai'); + process.exit(1); + } + + process.exit(result.status ?? 0); + } finally { + try { unlinkSync(tmpFile); } catch { /* ignore */ } + } +} \ No newline at end of file