diff --git a/docs/adapters/langgraph.md b/docs/adapters/langgraph.md new file mode 100644 index 0000000..21437b3 --- /dev/null +++ b/docs/adapters/langgraph.md @@ -0,0 +1,186 @@ +# LangGraph Adapter + +Complete mapping guide for converting between gitagent and LangGraph formats. + +## Overview + +[LangGraph](https://langchain-ai.github.io/langgraph/) is LangChain's graph-based agent orchestration framework for Python. It models agents as `StateGraph` objects where: + +- **Nodes** are Python functions that receive and return state +- **Edges** are routing decisions (conditional or fixed) +- **State** is a typed dictionary (`TypedDict`) carrying messages between nodes +- **Tools** are Python functions decorated with `@tool` + +The gitagent LangGraph adapter enables: +1. **Export**: Convert gitagent → LangGraph Python code (`agent.py`) +2. **Run**: Execute gitagent agents using the generated Python agent + +## Installation + +```bash +# LangGraph requires Python 3.11+ +pip install langgraph langchain-core + +# Provider-specific: +pip install langchain-openai # for GPT models +pip install langchain-anthropic # for Claude models +pip install langchain-google-genai # for Gemini models +``` + +## Field Mapping + +### Export: gitagent → LangGraph + +| gitagent | LangGraph | Notes | +|----------|-----------|-------| +| `SOUL.md` | `SYSTEM_PROMPT` (identity section) | Embedded as Python string constant | +| `RULES.md` | `SYSTEM_PROMPT` (constraints section) | Appended to system prompt | +| `DUTIES.md` | `SYSTEM_PROMPT` (SOD section) | Appended to system prompt | +| `skills/*/SKILL.md` | `SYSTEM_PROMPT` (skills section) | Progressive disclosure, full instructions | +| `tools/*.yaml` | `@tool` decorated functions | Stub implementations — fill in logic | +| `knowledge/` (always_load) | `SYSTEM_PROMPT` (knowledge section) | Reference documents embedded | +| `manifest.model.preferred` | LLM constructor (`ChatOpenAI`, `ChatAnthropic`, `ChatGoogleGenerativeAI`) | Auto-detected from model name prefix | +| `compliance.supervision.human_in_the_loop: always` | `human_review_node` (blocks on every tool call) | Inserted between `agent` and `tools` nodes | +| `compliance.supervision.human_in_the_loop: conditional` | `human_review_node` (blocks on risky tools) | Heuristic based on tool name keywords | +| `compliance.supervision.human_in_the_loop: none` | No HITL node | Fully autonomous | +| `manifest.version` | Docstring | Recorded for traceability | + +### Graph Structure + +**Without HITL:** +``` +START → agent → (should_continue) → tools → agent (loop) + ↓ + END +``` + +**With HITL (`always` or `conditional`):** +``` +START → agent → human_review → (should_continue) → tools → agent (loop) + ↓ + END +``` + +## Model Resolution + +| gitagent `model.preferred` | LangGraph import | Class | +|----------------------------|------------------|-------| +| `claude-*` or `anthropic/*` | `langchain_anthropic` | `ChatAnthropic` | +| `gpt-*`, `o1*`, `o3*`, `openai/*` | `langchain_openai` | `ChatOpenAI` | +| `gemini-*` or `google/*` | `langchain_google_genai` | `ChatGoogleGenerativeAI` | +| *(other)* | `langchain_openai` | `ChatOpenAI` | + +## Usage Examples + +### Export to LangGraph + +```bash +# Export to stdout +gitagent export --format langgraph -d ./my-agent + +# Save to file +gitagent export --format langgraph -d ./my-agent -o agent.py +``` + +**Output Structure:** +``` +# === agent.py === +"""LangGraph agent generated from gitagent manifest…""" +from langchain_openai import ChatOpenAI +from langgraph.graph import StateGraph, END +… + +# === requirements.txt === +langgraph>=0.2.0 +langchain-core>=0.3.0 +langchain-openai>=0.3.0 +python-dotenv>=1.0.0 + +# === .env.example === +OPENAI_API_KEY=your-openai-api-key +``` + +### Run with LangGraph + +```bash +# Interactive mode +gitagent run --adapter langgraph -d ./my-agent + +# Single-shot mode +gitagent run --adapter langgraph -d ./my-agent --prompt "Summarise the latest SEC filings" +``` + +The runner: +1. Generates `agent.py`, `requirements.txt`, and `.env.example` in a temp workspace +2. Creates a Python virtual environment +3. Installs dependencies via `pip install -r requirements.txt` +4. Executes `python agent.py [--prompt "…"]` +5. Cleans up the temp workspace on exit + +### Running the generated agent directly + +```bash +# After export: +cp .env.example .env && vim .env # add your API key +pip install -r requirements.txt +python agent.py # interactive REPL +python agent.py --prompt "Hello" # single-shot +``` + +## Compliance Mapping + +| gitagent `human_in_the_loop` | LangGraph behaviour | +|------------------------------|---------------------| +| `always` | `human_review_node` inserted — user must type `y` to approve **every** tool call | +| `conditional` | `human_review_node` inserted — approval required only for write/delete/send/post operations | +| `advisory` | No HITL node — advisory note added in system prompt only | +| `none` | No HITL node — fully autonomous | + +## Implementing Tools + +The adapter generates stub `@tool` functions from `tools/*.yaml`. You need to fill in the implementation: + +```python +# Before (generated stub) +@tool +def search_regulations(query: str) -> str: + """Search regulatory database.""" + raise NotImplementedError("Implement search_regulations tool") + +# After (your implementation) +@tool +def search_regulations(query: str) -> str: + """Search regulatory database.""" + results = my_db.search(query) + return "\n".join(r.text for r in results) +``` + +## Generated File Reference + +### `agent.py` + +| Section | Description | +|---------|-------------| +| `SYSTEM_PROMPT` | Full agent identity + rules + skills as string constant | +| `TOOLS` | List of `@tool`-decorated functions (stubs from `tools/*.yaml`) | +| `AgentState` | `TypedDict` with `messages: Annotated[list[BaseMessage], add_messages]` | +| `agent_node` | Calls LLM with tools bound; prepends system prompt | +| `should_continue` | Routes to `"tools"` if last message has tool calls, else `END` | +| `human_review_node` | (HITL only) Prompts user for approval before tool execution | +| `build_graph` | Wires nodes and edges into a compiled `StateGraph` | +| `main` | CLI entry point: `--prompt` for single-shot, interactive REPL otherwise | + +### `requirements.txt` + +Minimal set of pip packages needed to run the agent. + +### `.env.example` + +Template for environment variables (API keys). Copy to `.env` and fill in. + +## Notes + +- Tools are generated as **stubs** — the adapter cannot infer implementation from YAML declarations alone. Fill in the function body before use. +- The runner requires **Python 3.11+** and **pip** on PATH. +- For production use, replace the `tmpdir`-based approach with a persistent project directory. +- LangGraph does not natively support gitagent's agent versioning or branch deployment patterns — those remain in the git layer. diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 439d65d..bfb263a 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -9,3 +9,4 @@ export { exportToOpenCodeString, exportToOpenCode } from './opencode.js'; export { exportToCursorString, exportToCursor } from './cursor.js'; export { exportToGeminiString, exportToGemini } from './gemini.js'; export { exportToCodexString, exportToCodex } from './codex.js'; +export { exportToLangGraphString, exportToLangGraph } from './langgraph.js'; diff --git a/src/adapters/langgraph.test.ts b/src/adapters/langgraph.test.ts new file mode 100644 index 0000000..50a087b --- /dev/null +++ b/src/adapters/langgraph.test.ts @@ -0,0 +1,239 @@ +/** + * Tests for the LangGraph adapter (export). + * + * Uses Node.js built-in test runner (node --test). + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { exportToLangGraph, exportToLangGraphString } from './langgraph.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAgentDir(opts: { + name?: string; + description?: string; + soul?: string; + rules?: string; + model?: string; + compliance?: string; + skills?: Array<{ name: string; description: string; instructions: string }>; + tools?: Array<{ name: string; description: string; params?: Record }>; +}): string { + const dir = mkdtempSync(join(tmpdir(), 'gitagent-langgraph-test-')); + + const modelBlock = opts.model + ? `model:\n preferred: ${opts.model}\n` + : ''; + + const complianceBlock = opts.compliance ?? ''; + + writeFileSync( + join(dir, 'agent.yaml'), + [ + `spec_version: '0.1.0'`, + `name: ${opts.name ?? 'test-agent'}`, + `version: '0.1.0'`, + `description: '${opts.description ?? 'A test agent'}'`, + modelBlock, + complianceBlock, + ].join('\n'), + 'utf-8', + ); + + if (opts.soul !== undefined) { + writeFileSync(join(dir, 'SOUL.md'), opts.soul, 'utf-8'); + } + + if (opts.rules !== undefined) { + writeFileSync(join(dir, 'RULES.md'), opts.rules, 'utf-8'); + } + + if (opts.skills) { + for (const skill of opts.skills) { + const skillDir = join(dir, 'skills', skill.name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\nname: ${skill.name}\ndescription: '${skill.description}'\n---\n\n${skill.instructions}\n`, + 'utf-8', + ); + } + } + + if (opts.tools) { + const toolsDir = join(dir, 'tools'); + mkdirSync(toolsDir, { recursive: true }); + for (const t of opts.tools) { + const propLines = t.params + ? Object.entries(t.params).map(([k, v]) => ` ${k}:\n type: ${v}`).join('\n') + : ''; + const schemaBlock = propLines + ? `input_schema:\n properties:\n${propLines}\n required: [${Object.keys(t.params ?? {}).join(', ')}]` + : ''; + writeFileSync( + join(toolsDir, `${t.name}.yaml`), + `name: ${t.name}\ndescription: '${t.description}'\n${schemaBlock}\n`, + 'utf-8', + ); + } + } + + return dir; +} + +// --------------------------------------------------------------------------- +// exportToLangGraph +// --------------------------------------------------------------------------- + +describe('exportToLangGraph', () => { + test('returns agentPy, requirements, and envExample', () => { + const dir = makeAgentDir({ name: 'my-agent', description: 'My test agent' }); + const result = exportToLangGraph(dir); + assert.ok(typeof result.agentPy === 'string'); + assert.ok(typeof result.requirements === 'string'); + assert.ok(typeof result.envExample === 'string'); + }); + + test('agentPy contains agent name and description', () => { + const dir = makeAgentDir({ name: 'demo-agent', description: 'Demo description' }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /demo-agent/); + assert.match(agentPy, /Demo description/); + }); + + test('agentPy includes SOUL.md content in system prompt', () => { + const dir = makeAgentDir({ soul: 'Be helpful and precise.' }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /Be helpful and precise/); + }); + + test('agentPy includes RULES.md content in system prompt', () => { + const dir = makeAgentDir({ rules: 'Never share credentials.' }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /Never share credentials/); + }); + + test('agentPy includes skill content in system prompt', () => { + const dir = makeAgentDir({ + skills: [ + { name: 'web-search', description: 'Search the web', instructions: 'Use the search tool.' }, + ], + }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /web-search/); + assert.match(agentPy, /Use the search tool/); + }); + + test('agentPy imports ChatAnthropic for claude models', () => { + const dir = makeAgentDir({ model: 'claude-opus-4-5' }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /ChatAnthropic/); + assert.match(agentPy, /claude-opus-4-5/); + }); + + test('agentPy imports ChatOpenAI for gpt models', () => { + const dir = makeAgentDir({ model: 'gpt-4o' }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /ChatOpenAI/); + assert.match(agentPy, /gpt-4o/); + }); + + test('agentPy imports ChatGoogleGenerativeAI for gemini models', () => { + const dir = makeAgentDir({ model: 'gemini-2.0-flash' }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /ChatGoogleGenerativeAI/); + assert.match(agentPy, /gemini-2.0-flash/); + }); + + test('agentPy wires StateGraph with agent node and END', () => { + const dir = makeAgentDir({}); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /StateGraph/); + assert.match(agentPy, /agent_node/); + assert.match(agentPy, /should_continue/); + assert.match(agentPy, /build_graph/); + }); + + test('agentPy includes ToolNode when tools are defined', () => { + const dir = makeAgentDir({ + tools: [{ name: 'my-tool', description: 'Does something', params: { query: 'string' } }], + }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /ToolNode/); + assert.match(agentPy, /my_tool/); + }); + + test('agentPy includes HITL node for human_in_the_loop: always', () => { + const dir = makeAgentDir({ + compliance: 'compliance:\n supervision:\n human_in_the_loop: always', + }); + const { agentPy } = exportToLangGraph(dir); + assert.match(agentPy, /human_review_node/); + assert.match(agentPy, /Approve\?/); + }); + + test('agentPy does NOT include HITL node when human_in_the_loop is none', () => { + const dir = makeAgentDir({ + compliance: 'compliance:\n supervision:\n human_in_the_loop: none', + }); + const { agentPy } = exportToLangGraph(dir); + assert.doesNotMatch(agentPy, /human_review_node/); + }); + + test('requirements includes langgraph', () => { + const dir = makeAgentDir({}); + const { requirements } = exportToLangGraph(dir); + assert.match(requirements, /langgraph/); + assert.match(requirements, /langchain-core/); + }); + + test('requirements includes langchain-anthropic for claude models', () => { + const dir = makeAgentDir({ model: 'claude-opus-4-5' }); + const { requirements } = exportToLangGraph(dir); + assert.match(requirements, /langchain-anthropic/); + }); + + test('requirements includes langchain-openai for gpt models', () => { + const dir = makeAgentDir({ model: 'gpt-4o' }); + const { requirements } = exportToLangGraph(dir); + assert.match(requirements, /langchain-openai/); + }); + + test('envExample includes ANTHROPIC_API_KEY for claude models', () => { + const dir = makeAgentDir({ model: 'claude-opus-4-5' }); + const { envExample } = exportToLangGraph(dir); + assert.match(envExample, /ANTHROPIC_API_KEY/); + }); + + test('envExample includes OPENAI_API_KEY for gpt models', () => { + const dir = makeAgentDir({ model: 'gpt-4o' }); + const { envExample } = exportToLangGraph(dir); + assert.match(envExample, /OPENAI_API_KEY/); + }); +}); + +// --------------------------------------------------------------------------- +// exportToLangGraphString +// --------------------------------------------------------------------------- + +describe('exportToLangGraphString', () => { + test('contains agent.py, requirements.txt, and .env.example section headers', () => { + const dir = makeAgentDir({ name: 'str-agent', description: 'String export test' }); + const result = exportToLangGraphString(dir); + assert.match(result, /=== agent\.py ===/); + assert.match(result, /=== requirements\.txt ===/); + assert.match(result, /=== \.env\.example ===/); + }); + + test('contains agent name in output', () => { + const dir = makeAgentDir({ name: 'string-agent', description: 'desc' }); + const result = exportToLangGraphString(dir); + assert.match(result, /string-agent/); + }); +}); diff --git a/src/adapters/langgraph.ts b/src/adapters/langgraph.ts new file mode 100644 index 0000000..709c9d1 --- /dev/null +++ b/src/adapters/langgraph.ts @@ -0,0 +1,507 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import yaml from 'js-yaml'; +import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; +import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { buildComplianceSection } from './shared.js'; + +/** + * Export a gitagent to LangGraph (Python) format. + * + * LangGraph uses: + * - agent.py (StateGraph definition with nodes + edges) + * - requirements.txt (Python dependencies) + * - .env.example (required environment variables) + * + * Returns structured output with all files that should be written. + * + * @see https://langchain-ai.github.io/langgraph/ + */ +export interface LangGraphExport { + /** Python source for the LangGraph agent */ + agentPy: string; + /** pip requirements */ + requirements: string; + /** .env.example content */ + envExample: string; +} + +export function exportToLangGraph(dir: string): LangGraphExport { + const agentDir = resolve(dir); + const manifest = loadAgentManifest(agentDir); + + const agentPy = buildAgentPy(agentDir, manifest); + const requirements = buildRequirements(manifest); + const envExample = buildEnvExample(manifest); + + return { agentPy, requirements, envExample }; +} + +/** + * Export as a single string (for `gitagent export -f langgraph`). + */ +export function exportToLangGraphString(dir: string): string { + const exp = exportToLangGraph(dir); + const parts: string[] = []; + + parts.push('# === agent.py ==='); + parts.push(exp.agentPy); + parts.push('\n# === requirements.txt ==='); + parts.push(exp.requirements); + parts.push('\n# === .env.example ==='); + parts.push(exp.envExample); + + return parts.join('\n'); +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +function buildAgentPy( + agentDir: string, + manifest: ReturnType, +): string { + const agentName = manifest.name ?? 'gitagent'; + const agentSlug = agentName.toLowerCase().replace(/[^a-z0-9]+/g, '_'); + const description = manifest.description ?? ''; + const modelId = resolveModel(manifest.model?.preferred); + + // Collect system prompt sections + const systemParts: string[] = []; + systemParts.push(`# ${agentName}`); + systemParts.push(description); + systemParts.push(''); + + const soul = loadFileIfExists(join(agentDir, 'SOUL.md')); + if (soul) { systemParts.push(soul); systemParts.push(''); } + + const rules = loadFileIfExists(join(agentDir, 'RULES.md')); + if (rules) { systemParts.push(rules); systemParts.push(''); } + + const duties = loadFileIfExists(join(agentDir, 'DUTIES.md')); + if (duties) { systemParts.push(duties); systemParts.push(''); } + + // Skills + const skillsDir = join(agentDir, 'skills'); + const skills = loadAllSkills(skillsDir); + if (skills.length > 0) { + systemParts.push('## Skills'); + systemParts.push(''); + for (const skill of skills) { + const toolsList = getAllowedTools(skill.frontmatter); + const toolsNote = toolsList.length > 0 ? `\nAllowed tools: ${toolsList.join(', ')}` : ''; + systemParts.push(`### ${skill.frontmatter.name}`); + systemParts.push(`${skill.frontmatter.description}${toolsNote}`); + systemParts.push(''); + systemParts.push(skill.instructions); + systemParts.push(''); + } + } + + // Knowledge (always_load) + const knowledgeDir = join(agentDir, 'knowledge'); + const indexPath = join(knowledgeDir, 'index.yaml'); + if (existsSync(indexPath)) { + const index = yaml.load(readFileSync(indexPath, 'utf-8')) as { + documents?: Array<{ path: string; always_load?: boolean }>; + }; + if (index?.documents) { + const alwaysLoad = index.documents.filter(d => d.always_load); + if (alwaysLoad.length > 0) { + systemParts.push('## Knowledge'); + systemParts.push(''); + for (const doc of alwaysLoad) { + const content = loadFileIfExists(join(knowledgeDir, doc.path)); + if (content) { + systemParts.push(`### ${doc.path}`); + systemParts.push(content); + systemParts.push(''); + } + } + } + } + } + + // Compliance + if (manifest.compliance) { + const section = buildComplianceSection(manifest.compliance); + if (section) { systemParts.push(section); systemParts.push(''); } + } + + const systemPrompt = systemParts.join('\n').trimEnd(); + + // Build tool stubs from tools/*.yaml + const toolStubs = buildToolStubs(agentDir); + + // Build LangGraph Python code + const hitl = manifest.compliance?.supervision?.human_in_the_loop; + const hitlComment = hitlComment_(hitl); + + const lines: string[] = []; + + lines.push(`""" +LangGraph agent generated from gitagent manifest. + +Agent : ${agentName} +Version: ${manifest.version ?? '0.1.0'} +Source : ${agentDir} + +Usage: + python agent.py # interactive REPL + python agent.py --prompt "..." # single-shot +""" +from __future__ import annotations + +import argparse +import os +from typing import Annotated, TypedDict + +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage +from langchain_core.tools import tool +from langgraph.graph import END, StateGraph +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode`); + + // Model import + const { importLine, modelConstructor } = resolveModelImport(modelId); + lines.push(importLine); + lines.push(''); + + // ------------------------------------------------------------------------- + // System prompt (embedded) + // ------------------------------------------------------------------------- + lines.push('# ---------------------------------------------------------------------------'); + lines.push('# System prompt — generated from SOUL.md / RULES.md / skills'); + lines.push('# ---------------------------------------------------------------------------'); + lines.push(''); + lines.push('SYSTEM_PROMPT = """\\'); + // Escape triple-quote inside the string + lines.push(systemPrompt.replace(/"""/g, '\\"\\"\\"')); + lines.push('"""'); + lines.push(''); + + // ------------------------------------------------------------------------- + // Tool definitions + // ------------------------------------------------------------------------- + lines.push('# ---------------------------------------------------------------------------'); + lines.push('# Tools — generated from tools/*.yaml'); + lines.push('# ---------------------------------------------------------------------------'); + lines.push(''); + if (toolStubs.length > 0) { + for (const stub of toolStubs) { + lines.push(stub); + lines.push(''); + } + lines.push(`TOOLS = [${toolStubs.map(s => extractFunctionName(s)).join(', ')}]`); + } else { + lines.push('# No tools defined in this agent — add tools/*.yaml to declare tools.'); + lines.push('TOOLS: list = []'); + } + lines.push(''); + + // ------------------------------------------------------------------------- + // LangGraph state + nodes + // ------------------------------------------------------------------------- + lines.push('# ---------------------------------------------------------------------------'); + lines.push('# LangGraph state'); + lines.push('# ---------------------------------------------------------------------------'); + lines.push(''); + lines.push('class AgentState(TypedDict):'); + lines.push(' messages: Annotated[list[BaseMessage], add_messages]'); + lines.push(''); + lines.push(''); + lines.push('# ---------------------------------------------------------------------------'); + lines.push('# Graph nodes'); + lines.push('# ---------------------------------------------------------------------------'); + lines.push(''); + lines.push(`def _make_llm() -> ${modelConstructor.split('(')[0]}: # type: ignore[return]`); + lines.push(` return ${modelConstructor}.bind_tools(TOOLS) if TOOLS else ${modelConstructor}`); + lines.push(''); + lines.push(''); + lines.push('def agent_node(state: AgentState) -> AgentState:'); + lines.push(' """Core reasoning node — calls the LLM with tool bindings."""'); + lines.push(' llm = _make_llm()'); + lines.push(' messages = state["messages"]'); + lines.push(' # Prepend system prompt if not already present'); + lines.push(' if not messages or not isinstance(messages[0], SystemMessage):'); + lines.push(' messages = [SystemMessage(content=SYSTEM_PROMPT), *messages]'); + lines.push(' response = llm.invoke(messages)'); + lines.push(' return {"messages": [response]}'); + lines.push(''); + lines.push(''); + lines.push('def should_continue(state: AgentState) -> str:'); + lines.push(' """Edge condition: route to tools or END."""'); + lines.push(' last = state["messages"][-1]'); + lines.push(' if isinstance(last, AIMessage) and last.tool_calls:'); + lines.push(' return "tools"'); + lines.push(' return END'); + lines.push(''); + lines.push(''); + + // ------------------------------------------------------------------------- + // HITL node (if required) + // ------------------------------------------------------------------------- + if (hitl === 'always' || hitl === 'conditional') { + lines.push('# ---------------------------------------------------------------------------'); + lines.push(`# Human-in-the-loop — compliance.supervision.human_in_the_loop: "${hitl}"`); + lines.push('# ---------------------------------------------------------------------------'); + lines.push(''); + lines.push('def human_review_node(state: AgentState) -> AgentState:'); + if (hitl === 'always') { + lines.push(' """Blocks execution until the user explicitly approves every tool call."""'); + lines.push(' last = state["messages"][-1]'); + lines.push(' if isinstance(last, AIMessage) and last.tool_calls:'); + lines.push(' print("\\n[HITL] Agent wants to call tools:")'); + lines.push(' for tc in last.tool_calls:'); + lines.push(' print(f" {tc[\'name\']}({tc[\'args\']})")'); + lines.push(' approval = input("Approve? [y/N]: ").strip().lower()'); + lines.push(' if approval != "y":'); + lines.push(' # Strip tool calls — agent will respond without executing'); + lines.push(' blocked = AIMessage(content=last.content or "(tool calls blocked by reviewer)")'); + lines.push(' return {"messages": [blocked]}'); + lines.push(' return state'); + } else { + lines.push(' """Prompts the user for approval before high-risk tool calls."""'); + lines.push(' last = state["messages"][-1]'); + lines.push(' if isinstance(last, AIMessage) and last.tool_calls:'); + lines.push(' risky = [tc for tc in last.tool_calls if _is_risky_tool(tc["name"])]'); + lines.push(' if risky:'); + lines.push(' print("\\n[HITL] Approval needed for:")'); + lines.push(' for tc in risky:'); + lines.push(' print(f" {tc[\'name\']}({tc[\'args\']})")'); + lines.push(' if input("Approve? [y/N]: ").strip().lower() != "y":'); + lines.push(' blocked = AIMessage(content="(blocked by reviewer)")'); + lines.push(' return {"messages": [blocked]}'); + lines.push(' return state'); + lines.push(''); + lines.push(''); + lines.push('def _is_risky_tool(name: str) -> bool:'); + lines.push(' """Heuristic: flag write/delete/send operations for approval."""'); + lines.push(' risky_keywords = {"write", "delete", "send", "post", "update", "create", "remove"}'); + lines.push(' return any(kw in name.lower() for kw in risky_keywords)'); + } + lines.push(''); + lines.push(''); + } + + // ------------------------------------------------------------------------- + // Graph wiring + // ------------------------------------------------------------------------- + lines.push('# ---------------------------------------------------------------------------'); + lines.push('# Graph wiring'); + lines.push('# ---------------------------------------------------------------------------'); + lines.push(''); + lines.push('def build_graph() -> StateGraph:'); + lines.push(` graph = StateGraph(AgentState)`); + lines.push(' graph.add_node("agent", agent_node)'); + if (hitl === 'always' || hitl === 'conditional') { + lines.push(' graph.add_node("human_review", human_review_node)'); + } + if (toolStubs.length > 0) { + lines.push(' graph.add_node("tools", ToolNode(TOOLS))'); + } + lines.push(''); + lines.push(' graph.set_entry_point("agent")'); + if (hitl === 'always' || hitl === 'conditional') { + lines.push(' graph.add_edge("agent", "human_review")'); + lines.push(' graph.add_conditional_edges("human_review", should_continue, {"tools": "tools", END: END})'); + } else { + lines.push(' graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})'); + } + if (toolStubs.length > 0) { + lines.push(' graph.add_edge("tools", "agent")'); + } + lines.push(''); + lines.push(' return graph.compile()'); + lines.push(''); + lines.push(''); + + // ------------------------------------------------------------------------- + // Entry point + // ------------------------------------------------------------------------- + lines.push('# ---------------------------------------------------------------------------'); + lines.push('# Entry point'); + lines.push('# ---------------------------------------------------------------------------'); + lines.push(''); + lines.push('def main() -> None:'); + lines.push(' parser = argparse.ArgumentParser(description="Run the LangGraph agent")'); + lines.push(' parser.add_argument("--prompt", "-p", type=str, default=None,'); + lines.push(' help="Single-shot prompt (omit for interactive mode)")'); + lines.push(' args = parser.parse_args()'); + lines.push(''); + lines.push(' app = build_graph()'); + lines.push(''); + lines.push(' if args.prompt:'); + lines.push(' # Single-shot mode'); + lines.push(' result = app.invoke({"messages": [HumanMessage(content=args.prompt)]})'); + lines.push(' last = result["messages"][-1]'); + lines.push(' print(last.content)'); + lines.push(' else:'); + lines.push(` # Interactive REPL`); + lines.push(` print(f"${agentName} — LangGraph agent (type 'exit' to quit)")`); + lines.push(' conversation: list[BaseMessage] = []'); + lines.push(' while True:'); + lines.push(' try:'); + lines.push(' user_input = input("You: ").strip()'); + lines.push(' except (EOFError, KeyboardInterrupt):'); + lines.push(' break'); + lines.push(' if user_input.lower() in {"exit", "quit", "q"}:'); + lines.push(' break'); + lines.push(' if not user_input:'); + lines.push(' continue'); + lines.push(' conversation.append(HumanMessage(content=user_input))'); + lines.push(' result = app.invoke({"messages": conversation})'); + lines.push(' conversation = result["messages"]'); + lines.push(' last = conversation[-1]'); + lines.push(` print(f"Agent: {last.content}")`); + lines.push(''); + lines.push(''); + lines.push('if __name__ == "__main__":'); + lines.push(' main()'); + + return lines.join('\n') + '\n'; +} + +function buildRequirements(manifest: ReturnType): string { + const model = manifest.model?.preferred ?? ''; + const deps = [ + 'langgraph>=0.2.0', + 'langchain-core>=0.3.0', + ]; + + if (model.startsWith('claude') || model.startsWith('anthropic/')) { + deps.push('langchain-anthropic>=0.3.0'); + } else if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('openai/')) { + deps.push('langchain-openai>=0.3.0'); + } else if (model.startsWith('gemini') || model.startsWith('google/')) { + deps.push('langchain-google-genai>=2.0.0'); + } else { + // Default: include all common providers + deps.push('langchain-openai>=0.3.0'); + deps.push('# Uncomment to use Anthropic: langchain-anthropic>=0.3.0'); + deps.push('# Uncomment to use Google: langchain-google-genai>=2.0.0'); + } + + deps.push('python-dotenv>=1.0.0'); + return deps.join('\n') + '\n'; +} + +function buildEnvExample(manifest: ReturnType): string { + const model = manifest.model?.preferred ?? ''; + const lines = ['# Copy to .env and fill in your credentials']; + + if (model.startsWith('claude') || model.startsWith('anthropic/')) { + lines.push('ANTHROPIC_API_KEY=your-anthropic-api-key'); + } else if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('openai/')) { + lines.push('OPENAI_API_KEY=your-openai-api-key'); + } else if (model.startsWith('gemini') || model.startsWith('google/')) { + lines.push('GOOGLE_API_KEY=your-google-api-key'); + } else { + lines.push('OPENAI_API_KEY=your-openai-api-key'); + lines.push('# ANTHROPIC_API_KEY=your-anthropic-api-key'); + lines.push('# GOOGLE_API_KEY=your-google-api-key'); + } + + return lines.join('\n') + '\n'; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resolveModel(preferred?: string): string { + if (!preferred) return 'gpt-4o'; + // Strip provider prefix if present + if (preferred.startsWith('anthropic/')) return preferred.slice('anthropic/'.length); + if (preferred.startsWith('openai/')) return preferred.slice('openai/'.length); + if (preferred.startsWith('google/')) return preferred.slice('google/'.length); + return preferred; +} + +function resolveModelImport(modelId: string): { importLine: string; modelConstructor: string } { + if (modelId.startsWith('claude-')) { + return { + importLine: 'from langchain_anthropic import ChatAnthropic', + modelConstructor: `ChatAnthropic(model="${modelId}", temperature=0)`, + }; + } + if (modelId.startsWith('gemini-')) { + return { + importLine: 'from langchain_google_genai import ChatGoogleGenerativeAI', + modelConstructor: `ChatGoogleGenerativeAI(model="${modelId}", temperature=0)`, + }; + } + // Default: OpenAI-compatible + return { + importLine: 'from langchain_openai import ChatOpenAI', + modelConstructor: `ChatOpenAI(model="${modelId}", temperature=0)`, + }; +} + +function buildToolStubs(agentDir: string): string[] { + const toolsDir = join(agentDir, 'tools'); + if (!existsSync(toolsDir)) return []; + + const files = readdirSync(toolsDir).filter(f => f.endsWith('.yaml')); + const stubs: string[] = []; + + for (const file of files) { + try { + const content = readFileSync(join(toolsDir, file), 'utf-8'); + const toolConfig = yaml.load(content) as { + name?: string; + description?: string; + input_schema?: { properties?: Record; required?: string[] }; + }; + + if (!toolConfig?.name) continue; + + const fnName = toolConfig.name.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(); + const description = toolConfig.description ?? toolConfig.name; + + // Build function signature from input_schema + const props = toolConfig.input_schema?.properties ?? {}; + const required = new Set(toolConfig.input_schema?.required ?? []); + const params = Object.entries(props) + .map(([k, v]) => { + const pyType = jsonTypeToPython(v.type); + return required.has(k) ? `${k}: ${pyType}` : `${k}: ${pyType} | None = None`; + }); + + const sig = params.length > 0 ? params.join(', ') : ''; + const body = toolConfig.input_schema?.properties + ? Object.keys(props).map(k => ` # TODO: implement ${k} handling`).join('\n') + : ' # TODO: implement'; + + stubs.push(`@tool\ndef ${fnName}(${sig}) -> str:\n """${description}\"\"\"\n${body}\n raise NotImplementedError("Implement ${fnName} tool")`); + } catch { /* skip malformed tools */ } + } + + return stubs; +} + +function extractFunctionName(stub: string): string { + const m = stub.match(/^def (\w+)\(/m); + return m ? m[1] : 'unknown'; +} + +function jsonTypeToPython(jsonType: string): string { + const map: Record = { + string: 'str', + integer: 'int', + number: 'float', + boolean: 'bool', + array: 'list', + object: 'dict', + }; + return map[jsonType] ?? 'str'; +} + +function hitlComment_(hitl: string | undefined): string { + if (!hitl || hitl === 'none') return '# No human-in-the-loop required'; + if (hitl === 'always') return '# human_in_the_loop: always — all tool calls require approval'; + if (hitl === 'conditional') return '# human_in_the_loop: conditional — risky tool calls require approval'; + if (hitl === 'advisory') return '# human_in_the_loop: advisory — agent may proceed; log for review'; + return ''; +} diff --git a/src/commands/export.ts b/src/commands/export.ts index b952d7e..1c35882 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -11,8 +11,9 @@ import { exportToCopilotString, exportToOpenCodeString, exportToCursorString, -exportToGeminiString, -exportToCodexString, + exportToGeminiString, + exportToCodexString, + exportToLangGraphString, } from '../adapters/index.js'; import { exportToLyzrString } from '../adapters/lyzr.js'; import { exportToGitHubString } from '../adapters/github.js'; @@ -25,8 +26,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, opencode, cursor, gemini)') -.requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, codex)') + .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini, codex, langgraph)') .option('-d, --dir ', 'Agent directory', '.') .option('-o, --output ', 'Output file path') .action(async (options: ExportOptions) => { @@ -72,15 +72,18 @@ export const exportCommand = new Command('export') case 'cursor': result = exportToCursorString(dir); break; -case 'gemini': + case 'gemini': result = exportToGeminiString(dir); break; + case 'codex': + result = exportToCodexString(dir); + break; + case 'langgraph': + result = exportToLangGraphString(dir); + break; default: error(`Unknown format: ${options.format}`); - info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini'); -case 'codex': - result = exportToCodexString(dir); - info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, codex'); + info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini, codex, langgraph'); process.exit(1); } diff --git a/src/commands/run.ts b/src/commands/run.ts index 657e0e4..d31d5e2 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -14,6 +14,7 @@ import { runWithGitHub } from '../runners/github.js'; import { runWithGit } from '../runners/git.js'; import { runWithOpenCode } from '../runners/opencode.js'; import { runWithGemini } from '../runners/gemini.js'; +import { runWithLangGraph } from '../runners/langgraph.js'; interface RunOptions { repo?: string; @@ -28,7 +29,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, opencode, gemini, git, prompt', 'claude') + .option('-a, --adapter ', 'Adapter: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, gemini, codex, langgraph, git, prompt', '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') @@ -120,6 +121,9 @@ export const runCommand = new Command('run') case 'gemini': runWithGemini(agentDir, manifest, { prompt: options.prompt }); break; + case 'langgraph': + runWithLangGraph(agentDir, manifest, { prompt: options.prompt }); + break; case 'git': if (!options.repo) { error('The git adapter requires --repo (-r)'); @@ -138,7 +142,7 @@ export const runCommand = new Command('run') break; default: error(`Unknown adapter: ${options.adapter}`); - info('Supported adapters: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, gemini, git, prompt'); + info('Supported adapters: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, gemini, codex, langgraph, git, prompt'); process.exit(1); } } catch (e) { diff --git a/src/runners/langgraph.ts b/src/runners/langgraph.ts new file mode 100644 index 0000000..6c65738 --- /dev/null +++ b/src/runners/langgraph.ts @@ -0,0 +1,137 @@ +import { writeFileSync, mkdirSync, rmSync } 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 { exportToLangGraph } from '../adapters/langgraph.js'; +import { AgentManifest } from '../utils/loader.js'; +import { error, info } from '../utils/format.js'; + +export interface LangGraphRunOptions { + prompt?: string; +} + +/** + * Run a gitagent agent using LangGraph. + * + * Creates a temporary Python workspace with: + * - agent.py (StateGraph definition) + * - requirements.txt (pip dependencies) + * - .env.example (credential template) + * + * Requires Python 3.11+ and pip to be available on PATH. + * Dependencies are installed into an isolated venv in the workspace. + * + * Supports both interactive mode (no prompt) and single-shot mode (`--prompt`). + */ +export function runWithLangGraph( + agentDir: string, + manifest: AgentManifest, + options: LangGraphRunOptions = {}, +): void { + const exp = exportToLangGraph(agentDir); + + // Create a temporary workspace + const workspaceDir = join( + tmpdir(), + `gitagent-langgraph-${randomBytes(4).toString('hex')}`, + ); + mkdirSync(workspaceDir, { recursive: true }); + + // Write generated files + writeFileSync(join(workspaceDir, 'agent.py'), exp.agentPy, 'utf-8'); + writeFileSync(join(workspaceDir, 'requirements.txt'), exp.requirements, 'utf-8'); + writeFileSync(join(workspaceDir, '.env.example'), exp.envExample, 'utf-8'); + + info(`Workspace prepared at ${workspaceDir}`); + info(' agent.py, requirements.txt, .env.example'); + if (manifest.model?.preferred) { + info(` Model: ${manifest.model.preferred}`); + } + + // Detect python executable (python3 first, then python) + const pythonBin = detectPython(); + if (!pythonBin) { + error('Python 3.11+ is required to run LangGraph agents.'); + info('Install from https://python.org or via your package manager.'); + process.exit(1); + } + + // Create venv and install dependencies + info('Creating Python virtual environment…'); + const venvDir = join(workspaceDir, '.venv'); + + const venvResult = spawnSync(pythonBin, ['-m', 'venv', venvDir], { + stdio: 'inherit', + cwd: workspaceDir, + }); + if (venvResult.error || venvResult.status !== 0) { + error('Failed to create virtual environment.'); + process.exit(1); + } + + // pip install + const pipBin = process.platform === 'win32' + ? join(venvDir, 'Scripts', 'pip') + : join(venvDir, 'bin', 'pip'); + + info('Installing dependencies (langgraph, langchain-core, …)…'); + const pipResult = spawnSync(pipBin, ['install', '-r', 'requirements.txt', '-q'], { + stdio: 'inherit', + cwd: workspaceDir, + }); + if (pipResult.error || pipResult.status !== 0) { + error('pip install failed — check your network connection and try again.'); + process.exit(1); + } + + // Resolve venv python + const venvPython = process.platform === 'win32' + ? join(venvDir, 'Scripts', 'python') + : join(venvDir, 'bin', 'python'); + + // Build args + const args: string[] = ['agent.py']; + if (options.prompt) { + args.push('--prompt', options.prompt); + } + + info(`Launching LangGraph agent "${manifest.name}"…`); + if (!options.prompt) { + info("Starting interactive mode. Type 'exit' to quit."); + } + + const result = spawnSync(venvPython, args, { + stdio: 'inherit', + cwd: workspaceDir, + env: { ...process.env }, + }); + + // Cleanup + try { rmSync(workspaceDir, { recursive: true, force: true }); } catch { /* ignore */ } + + if (result.error) { + error(`Failed to launch LangGraph agent: ${result.error.message}`); + info('Ensure Python 3.11+ and pip are available on PATH.'); + process.exit(1); + } + + process.exit(result.status ?? 0); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function detectPython(): string | null { + for (const candidate of ['python3', 'python']) { + const result = spawnSync(candidate, ['--version'], { encoding: 'utf-8' }); + if (result.status === 0 && result.stdout) { + const match = result.stdout.match(/Python (\d+)\.(\d+)/); + if (match && parseInt(match[1], 10) >= 3 && parseInt(match[2], 10) >= 11) { + return candidate; + } + } + } + return null; +}