diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..333699c --- /dev/null +++ b/.env.example @@ -0,0 +1,71 @@ +# ── Ollama (local / self-hosted) ──────────────────────────────────────────────── +# To use a local Ollama instance, override PROVIDER and MODEL: +# PROVIDER=ollama +# MODEL=llama3.2 # or qwen2.5-coder, mistral, deepseek-r1:14b … +# No API key is needed. +# +# Docker: Ollama must listen on 0.0.0.0 (not just 127.0.0.1). +# OLLAMA_HOST=0.0.0.0 ollama serve +# The default OLLAMA_BASE_URL uses host.docker.internal which maps to your +# host machine from inside a Docker container. +# On Linux: enable extra_hosts in compose.yaml (already included). +# Locally (no Docker): change to http://localhost:11434/v1 +# +# OLLAMA_BASE_URL=http://host.docker.internal:11434/v1 +# OLLAMA_CONTEXT_WINDOW=32768 # tokens passed as num_ctx +# OLLAMA_MAX_TOKENS=8192 + +# ── Model ──────────────────────────────────────────────────────────────────── +# Provider and model to use. Both must be set together. +# Available providers: anthropic, openai, google, mistral, groq, cerebras, +# xai, openrouter, azure-openai, amazon-bedrock, … +PROVIDER=anthropic +MODEL=claude-sonnet-4-20250514 + +# ── Authentication ──────────────────────────────────────────────────────────── +# Generic API key for the selected PROVIDER (injected at runtime, not stored). +API_KEY=sk-ant-... + +# Provider-specific keys are also honoured (pick one approach): +# ANTHROPIC_API_KEY=sk-ant-... +# OPENAI_API_KEY=sk-... +# GEMINI_API_KEY=... +# MISTRAL_API_KEY=... +# GROQ_API_KEY=... +# CEREBRAS_API_KEY=... +# XAI_API_KEY=... +# OPENROUTER_API_KEY=... + +# ── Prompt ──────────────────────────────────────────────────────────────────── +# The user message to send. Alternatively pipe via stdin. +PROMPT=List all .ts files in the current directory. + +# ── System prompt ───────────────────────────────────────────────────────────── +# Completely replaces the default system prompt (optional). +# SYSTEM_PROMPT=You are a helpful assistant. Work inside /app only. + +# Appended to the (possibly overridden) system prompt (optional). +# APPEND_SYSTEM_PROMPT=Always answer in English. + +# ── Thinking ────────────────────────────────────────────────────────────────── +# off | minimal | low | medium | high | xhigh (default: off) +THINKING_LEVEL=off + +# ── Tools ───────────────────────────────────────────────────────────────────── +# all → read, bash, edit, write (default) +# readonly → read only +# none → no built-in tools +# Or a comma-separated list: read,bash +TOOLS=all + +# ── Working directory ───────────────────────────────────────────────────────── +# Directory the agent reads/writes. Maps to the Docker volume mount point. +CWD=/app + +# ── Session persistence ─────────────────────────────────────────────────────── +# true → persist session files under CWD; false/unset → in-memory (default) +SESSION_PERSIST=false + +# ── Verbose tool logging ────────────────────────────────────────────────────── +# true → log tool start/end events to stderr +VERBOSE_TOOLS=false diff --git a/Dockerfile b/Dockerfile index 0578208..b4c8e88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,38 @@ -# FROM ubuntu:24.04 -FROM node:24 +# ─── build stage ───────────────────────────────────────────────────────────── +FROM node:24-slim AS builder +WORKDIR /agent + +# Install all deps (including dev deps required by tsc) +COPY package*.json tsconfig.json ./ +RUN npm ci + +COPY src/ ./src/ + +# Compile TypeScript → /agent/dist/ +RUN npm run build + +# Prune dev deps for the final image +RUN npm prune --omit=dev + +# ─── runtime stage ──────────────────────────────────────────────────────────── +FROM node:24-slim + +# Install pi globally so it is available as a shell tool (e.g. inside bash tool) RUN npm install -g @mariozechner/pi-coding-agent -WORKDIR /app +WORKDIR /agent -VOLUME [ "/app" ] +# Copy compiled app + production deps +COPY --from=builder /agent/dist ./dist +COPY --from=builder /agent/node_modules ./node_modules +COPY package.json ./ -CMD ["pi", "--version"] \ No newline at end of file +# /app is the project directory mounted at runtime +VOLUME ["/app"] + +ENV NODE_ENV=production +# Default working directory for the agent (overridable via CWD env var) +ENV CWD=/app + +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 9fd7c6b..4edcddc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,10 +2,65 @@ services: agent: # image: agent:latest build: . - # ports: - # - "8080:8080" - # environment: - # - ENV_VAR=value + environment: + # ── Model ────────────────────────────────────────────────────────────── + # Both PROVIDER and MODEL must be set together to select a specific model. + # If omitted, the agent uses the first available / settings model. + - PROVIDER=${PROVIDER:-anthropic} + - MODEL=${MODEL:-claude-sonnet-4-20250514} + + # ── API keys ─────────────────────────────────────────────────────────── + # Generic key injected at runtime for the chosen PROVIDER: + - API_KEY=${API_KEY:-} + # Provider-specific keys (pi-ai reads these automatically): + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + - MISTRAL_API_KEY=${MISTRAL_API_KEY:-} + - GROQ_API_KEY=${GROQ_API_KEY:-} + - CEREBRAS_API_KEY=${CEREBRAS_API_KEY:-} + - XAI_API_KEY=${XAI_API_KEY:-} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - AI_GATEWAY_API_KEY=${AI_GATEWAY_API_KEY:-} + + # ── Ollama (local / self-hosted) ─────────────────────────────────────── + # To use Ollama set PROVIDER=ollama and MODEL=, e.g.: + # PROVIDER=ollama MODEL=llama3.2 docker compose up + # host.docker.internal resolves to the Docker host on Mac/Windows. + # On Linux add the extra_hosts entry below (already included). + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434/v1} + - OLLAMA_CONTEXT_WINDOW=${OLLAMA_CONTEXT_WINDOW:-32768} + - OLLAMA_MAX_TOKENS=${OLLAMA_MAX_TOKENS:-8192} + + # ── Prompt ───────────────────────────────────────────────────────────── + - PROMPT=${PROMPT:-} + + # ── System prompt ────────────────────────────────────────────────────── + - SYSTEM_PROMPT=${SYSTEM_PROMPT:-} + - APPEND_SYSTEM_PROMPT=${APPEND_SYSTEM_PROMPT:-} + + # ── Thinking ─────────────────────────────────────────────────────────── + # off | minimal | low | medium | high | xhigh + - THINKING_LEVEL=${THINKING_LEVEL:-off} + + # ── Tools ────────────────────────────────────────────────────────────── + # all | readonly | none | read,bash,edit,write + - TOOLS=${TOOLS:-all} + + # ── Working directory (Docker volume mount target) ────────────────────── + - CWD=/app + + # ── Session persistence ──────────────────────────────────────────────── + - SESSION_PERSIST=${SESSION_PERSIST:-false} + + # ── Verbose tool logging ─────────────────────────────────────────────── + - VERBOSE_TOOLS=${VERBOSE_TOOLS:-false} + volumes: - ./data:/app - command: ["pi", "--version"] \ No newline at end of file + + # Allows the container to reach Ollama (or any service) on the Docker host. + # On Mac/Windows host.docker.internal works without this; on Linux it needs + # this mapping to be resolved correctly. + extra_hosts: + - "host.docker.internal:host-gateway" \ No newline at end of file diff --git a/gitignore b/gitignore index b786662..a464491 100644 --- a/gitignore +++ b/gitignore @@ -1 +1,2 @@ -data/** \ No newline at end of file +data/** +.env \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e9ff33 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "moa-agent", + "version": "1.0.0", + "description": "MoA agent powered by pi coding agent SDK", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@mariozechner/pi-ai": "^0.57.1", + "@mariozechner/pi-coding-agent": "^0.57.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.0.0", + "typescript": "^5.8.0" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f4c5a0b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,305 @@ +/** + * MoA Agent – pi coding agent SDK wrapper + * + * All behaviour is driven by environment variables so the container + * works as a drop-in Docker service without any code changes. + * + * Environment variables + * ───────────────────── + * PROVIDER Provider name (anthropic | openai | google | mistral | + * groq | cerebras | xai | openrouter | ollama | …) + * When set together with MODEL, the exact model is resolved. + * If omitted the session uses whatever model is already in + * settings / the first available one. + * + * ── Ollama (local) ────────────────────────────────────── + * Set PROVIDER=ollama and MODEL= (e.g. llama3.2, + * qwen2.5-coder, mistral, deepseek-r1:14b …). + * No API key is needed. + * OLLAMA_BASE_URL URL of the Ollama HTTP API. + * Default in Docker: + * http://host.docker.internal:11434/v1 + * Default locally: + * http://localhost:11434/v1 + * OLLAMA_CONTEXT_WINDOW Context size in tokens (default: 32768) + * OLLAMA_MAX_TOKENS Max output tokens (default: 8192) + * + * MODEL Model ID as understood by getModel(), e.g. + * "claude-sonnet-4-20250514" or "gpt-4o". + * + * API_KEY Generic API key for the selected PROVIDER. + * Stored as a runtime (non-persisted) key. + * Provider-specific env vars (ANTHROPIC_API_KEY, + * OPENAI_API_KEY, GEMINI_API_KEY, …) are also honoured + * automatically by the underlying pi-ai library. + * Not required when PROVIDER=ollama. + * + * THINKING_LEVEL off | minimal | low | medium | high | xhigh (default: off) + * + * TOOLS all – read, bash, edit, write (default) + * readonly – read only + * none – no built-in tools + * Or a comma-separated list: read,bash,edit,write + * + * SYSTEM_PROMPT Completely replaces the default system prompt. + * APPEND_SYSTEM_PROMPT Appended to the (possibly overridden) system prompt. + * + * PROMPT The user prompt to send. If omitted the agent reads from + * stdin. Exactly one of the two must be provided. + * + * CWD Working directory the agent operates in. + * Defaults to /app (the Docker volume mount point). + * + * SESSION_PERSIST Set to "true" to persist the session under CWD. + * Defaults to in-memory (ephemeral). + * + * VERBOSE_TOOLS Set to "true" to log tool start/end events to stderr. + */ + +import { getModel, type Model } from "@mariozechner/pi-ai"; +import { + AuthStorage, + createAgentSession, + DefaultResourceLoader, + ModelRegistry, + SessionManager, + codingTools, + readOnlyTools, + readTool, + bashTool, + editTool, + writeTool, +} from "@mariozechner/pi-coding-agent"; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function env(name: string): string | undefined { + const v = process.env[name]; + return v === "" ? undefined : v; +} + +function requiredEnv(name: string): string { + const v = env(name); + if (!v) { + console.error(`[agent] Required environment variable ${name} is not set.`); + process.exit(1); + } + return v; +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8").trim())); + process.stdin.on("error", reject); + }); +} + +// ─── configuration ────────────────────────────────────────────────────────── + +const providerName = env("PROVIDER"); +const modelId = env("MODEL"); +const apiKey = env("API_KEY"); +const ollamaBaseUrl = env("OLLAMA_BASE_URL") ?? "http://host.docker.internal:11434/v1"; +const ollamaCtxWin = parseInt(env("OLLAMA_CONTEXT_WINDOW") ?? "32768", 10); +const ollamaMaxTok = parseInt(env("OLLAMA_MAX_TOKENS") ?? "8192", 10); +const thinkingLevel = (env("THINKING_LEVEL") ?? "off") as + "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; +const toolsEnv = env("TOOLS") ?? "all"; +const systemPrompt = env("SYSTEM_PROMPT"); +const appendPrompt = env("APPEND_SYSTEM_PROMPT"); +const promptText = env("PROMPT"); +const cwd = env("CWD") ?? "/app"; +const sessionPersist = env("SESSION_PERSIST") === "true"; +const verboseTools = env("VERBOSE_TOOLS") === "true"; + +// ─── auth & model registry ────────────────────────────────────────────────── + +const authStorage = AuthStorage.create(); + +// Inject the generic API_KEY for the chosen provider at runtime so it is +// never written to disk inside the container. +if (providerName && apiKey) { + authStorage.setRuntimeApiKey(providerName, apiKey); +} + +// Ollama doesn't require a real key; set a dummy to satisfy the auth layer. +if (providerName?.toLowerCase() === "ollama" && !apiKey) { + authStorage.setRuntimeApiKey("ollama", "ollama"); +} + +const modelRegistry = new ModelRegistry(authStorage); + +// ─── model resolution ─────────────────────────────────────────────────────── + +let model: Model | undefined; + +if (providerName?.toLowerCase() === "ollama") { + // Ollama is OpenAI-compatible; build a custom model object. + if (!modelId) { + console.error("[agent] PROVIDER=ollama requires MODEL to be set (e.g. MODEL=llama3.2)."); + process.exit(1); + } + model = { + id: modelId, + name: modelId, + api: "openai-completions", + provider: "ollama", + baseUrl: ollamaBaseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: ollamaCtxWin, + maxTokens: ollamaMaxTok, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStrictMode: false, + maxTokensField: "max_tokens", + }, + } satisfies Model<"openai-completions">; + console.error(`[agent] Using Ollama model "${modelId}" at ${ollamaBaseUrl}`); + +} else if (providerName && modelId) { + model = getModel(providerName as Parameters[0], modelId as any); + if (!model) { + console.error( + `[agent] Model "${providerName}/${modelId}" not found. ` + + `Run "pi --list-models" to check available models.` + ); + process.exit(1); + } + console.error(`[agent] Using model: ${model.provider}/${model.id}`); + +} else if (providerName || modelId) { + console.error( + "[agent] Both PROVIDER and MODEL must be set together. " + + "Falling back to default model." + ); +} + +// ─── tools ────────────────────────────────────────────────────────────────── + +const toolMap: Record = { + read: readTool, + bash: bashTool, + edit: editTool, + write: writeTool, +}; + +let tools: typeof codingTools; + +switch (toolsEnv.toLowerCase()) { + case "all": + tools = codingTools; + break; + case "readonly": + case "read-only": + tools = readOnlyTools; + break; + case "none": + tools = []; + break; + default: + tools = toolsEnv + .split(",") + .map((t) => t.trim().toLowerCase()) + .filter(Boolean) + .map((name) => { + const tool = toolMap[name]; + if (!tool) { + console.error(`[agent] Unknown tool "${name}", ignoring.`); + return null; + } + return tool; + }) + .filter((t): t is typeof readTool => t !== null); +} + +// ─── resource loader (system prompt) ──────────────────────────────────────── + +let resourceLoader: DefaultResourceLoader | undefined; + +if (systemPrompt !== undefined || appendPrompt !== undefined) { + resourceLoader = new DefaultResourceLoader({ + systemPromptOverride: (base: string) => { + let result = systemPrompt !== undefined ? systemPrompt : base; + if (appendPrompt) { + result = result ? `${result}\n\n${appendPrompt}` : appendPrompt; + } + return result; + }, + }); + await resourceLoader.reload(); +} + +// ─── session manager ──────────────────────────────────────────────────────── + +const sessionManager = sessionPersist + ? SessionManager.create(cwd) + : SessionManager.inMemory(); + +// ─── create session ────────────────────────────────────────────────────────── + +const { session } = await createAgentSession({ + ...(model ? { model } : {}), + ...(resourceLoader ? { resourceLoader } : {}), + thinkingLevel, + tools, + sessionManager, + authStorage, + modelRegistry, + cwd, +}); + +// ─── event handling ────────────────────────────────────────────────────────── + +session.subscribe((event) => { + switch (event.type) { + case "message_update": + if (event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + break; + + case "tool_execution_start": + if (verboseTools) { + process.stderr.write(`\n[tool:start] ${event.toolName}\n`); + } + break; + + case "tool_execution_end": + if (verboseTools) { + process.stderr.write(`[tool:end] ${event.toolName}\n`); + } + break; + + case "agent_end": + process.stdout.write("\n"); + break; + } +}); + +// ─── run prompt ────────────────────────────────────────────────────────────── + +let userPrompt: string; + +if (promptText) { + userPrompt = promptText; +} else if (!process.stdin.isTTY) { + userPrompt = await readStdin(); + if (!userPrompt) { + console.error("[agent] Stdin was empty. Set PROMPT or pipe a non-empty message."); + process.exit(1); + } +} else { + console.error( + "[agent] No prompt provided.\n" + + " Set the PROMPT environment variable, or pipe input via stdin." + ); + process.exit(1); +} + +await session.prompt(userPrompt); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e81d88c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}