moa agent
Some checks failed
Build and Push Docker Image / build (push) Failing after 22s

This commit is contained in:
2026-03-11 23:21:57 +01:00
parent 88899a11c4
commit f03c96f964
7 changed files with 511 additions and 11 deletions

71
.env.example Normal file
View 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

View File

@@ -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"]

View File

@@ -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"

View File

@@ -1 +1,2 @@
data/**
data/**
.env

21
package.json Normal file
View 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
View 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
View 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"]
}