This commit is contained in:
71
.env.example
Normal file
71
.env.example
Normal file
@@ -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
|
||||
38
Dockerfile
38
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"]
|
||||
# /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"]
|
||||
65
compose.yaml
65
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=<model-name>, 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"]
|
||||
|
||||
# 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"
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
305
src/index.ts
Normal file
305
src/index.ts
Normal file
@@ -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=<model-name> (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<string> {
|
||||
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<any> | 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<typeof getModel>[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<string, (typeof readTool)> = {
|
||||
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);
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user