diff --git a/.env.example b/.env.example index a4813f3..b6e1e08 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,122 @@ -# hanarang-rails environment variables -# Copy to .env and fill in values. +# ──────────────────────────────────────────────────────────────── +# hanarang-rails — environment configuration +# Copy this file to `.env` and fill in the values you need. +# +# The file is grouped into: +# 1. required (must set to run any pipeline) +# 2. LLM provider (pick one) +# 3. transport / deployment topology +# 4. optional — Gitea push +# 5. optional — Discord bridge +# ──────────────────────────────────────────────────────────────── -# ── Database (MariaDB / MySQL) ── +# ==== 1. REQUIRED ===================================================== + +# MariaDB / MySQL connection string used by Prisma. +# For docker-compose, use: +# mysql://rails:rails@mariadb:3306/hanarang_rails DATABASE_URL="mysql://rails:CHANGE_ME@localhost:3306/hanarang_rails" -# ── Discord ── -DISCORD_TOKEN="" -DISCORD_GUILD_ID="" - -# ── Gitea Webhook ── -GITEA_WEBHOOK_SECRET="" - -# ── Rails ── +# Rails HTTP server port RAILS_PORT=18800 RAILS_LOG_LEVEL=info NODE_ENV=production + + +# ==== 2. LLM PROVIDER ================================================= +# +# Pick exactly one provider for LLM_PROVIDER. Supported values: +# mock — deterministic fake responses. No network, no money. +# openai — OpenAI / OpenRouter / Azure OpenAI / any OpenAI-compatible API +# anthropic — Anthropic Messages API +# ollama — local Ollama server (https://ollama.com) +# openclaw — hanarang-internal OpenClaw runtime (most external users won't have this) + +LLM_PROVIDER=mock + +# Per-role model override. Leave empty to use the defaults baked into roles.ts +# (which are OpenClaw-flavored names — you probably need to set these for +# openai / anthropic / ollama). +# +# Good starting points: +# OpenAI: gpt-4o / gpt-4o-mini +# Anthropic: claude-opus-4-6 / claude-haiku-4-5 +# Ollama: qwen2.5-coder:32b / qwen2.5-coder:7b +# +# LLM_MODEL_MANAGER=gpt-4o +# LLM_MODEL_PRINCIPAL=gpt-4o +# LLM_MODEL_LEAD=gpt-4o-mini +# LLM_MODEL_JUNIOR=gpt-4o-mini +# LLM_MODEL_FALLBACK=gpt-4o-mini + +# ── OpenAI (and OpenAI-compatible) ──────────────────────────────────── +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com/v1 +# (also works for OpenRouter, Azure OpenAI, local llama.cpp servers, etc.) + +# ── Anthropic ───────────────────────────────────────────────────────── +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=https://api.anthropic.com + +# ── Ollama (local) ──────────────────────────────────────────────────── +# OLLAMA_BASE_URL=http://localhost:11434 + +# ── OpenClaw (internal) ─────────────────────────────────────────────── +# OPENCLAW_BIN=/home/you/.npm-global/bin/openclaw + + +# ==== 3. TRANSPORT / TOPOLOGY ========================================= +# +# rails supports three deployment topologies: +# +# 1. in-process — everything in one Node process. The simplest. The 4 +# sister agents are just function calls inside rails. +# Requires sister-agent to be built under +# ./sister-agent/dist/. +# +# 2. http — rails calls each sister-agent over HTTP. The sister +# agents run as separate daemons (potentially on separate +# machines/containers). Production topology. +# +# 3. mock — no LLM, no files, no push. Just exercises the FSM. +# +# Set via RAILS_TRANSPORT globally, or per-stage via RAILS_TRANSPORT_PLAN etc. + +RAILS_TRANSPORT=in-process + +# For http mode — each sister-agent daemon's HTTP endpoint: +# SISTER_ENDPOINT_PLAN=http://harang-lxc:18801 +# SISTER_ENDPOINT_IMPLEMENT=http://narang-lxc:18801 +# SISTER_ENDPOINT_REVIEW=http://darang-lxc:18801 +# SISTER_ENDPOINT_DEPLOY=http://erang-lxc:18801 + +# Loopback URL that sister-agent uses to report sub-task events back to rails. +# Usually the same as your rails HTTP URL as seen from the sister. +RAILS_API_URL=http://127.0.0.1:18800 + +# For in-process mode, optional override of where to load the compiled +# sister-agent core module from. Defaults to ./sister-agent/dist/core.js +# SISTER_AGENT_CORE_PATH=/app/sister-agent/dist/core.js + +# Where sister-agent writes per-pipeline workspaces on disk. +# SISTER_WORKSPACE_DIR=/home/you/rails-projects + + +# ==== 4. OPTIONAL — Gitea auto-push =================================== +# +# When enabled, each pipeline run auto-creates a public repo and pushes its +# workspace to Gitea, giving you a shareable URL for the generated files. +# Leave GITEA_TOKEN empty to skip the push step entirely. + +# GITEA_BASE_URL=https://git.example.com +# GITEA_ORG=my-org +# GITEA_TOKEN= +# GIT_USER_NAME=rails-agent +# GIT_USER_EMAIL=rails@example.com +# GIT_PUSH_ENABLED=true # force on/off; default is auto (on iff GITEA_TOKEN set) + + +# ==== 5. OPTIONAL — Discord bridge ==================================== +# DISCORD_TOKEN= +# DISCORD_GUILD_ID= +# GITEA_WEBHOOK_SECRET= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7c79e31 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# ──────────────────────────────────────────────────────────────── +# Dockerfile — builds rails + sister-agent + CLI into one image +# +# The image starts `rails serve` in single-process (in-process) mode. +# For distributed mode, see docker-compose.full.yml which uses the same +# image but overrides CMD / env vars per container. +# ──────────────────────────────────────────────────────────────── + +FROM node:22-bookworm-slim AS builder + +RUN corepack enable \ + && apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates openssl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy manifests first for better layer caching +COPY package.json pnpm-lock.yaml tsconfig.json ./ +COPY prisma ./prisma +COPY sister-agent/package.json ./sister-agent/ +COPY sister-agent/tsconfig.json ./sister-agent/ + +# Install deps (root + sister-agent workspace — sister-agent has its own lockfile) +RUN pnpm install --frozen-lockfile + +WORKDIR /app/sister-agent +RUN pnpm install --frozen-lockfile || pnpm install + +WORKDIR /app + +# Copy sources +COPY src ./src +COPY sister-agent/src ./sister-agent/src + +# Generate prisma client + build both +RUN npx prisma generate \ + && pnpm build \ + && cd sister-agent && pnpm build + +# ────────────── runtime image ────────────── +FROM node:22-bookworm-slim + +RUN corepack enable \ + && apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates openssl wget \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/sister-agent/package.json ./sister-agent/ +COPY --from=builder /app/sister-agent/node_modules ./sister-agent/node_modules +COPY --from=builder /app/sister-agent/dist ./sister-agent/dist + +ENV NODE_ENV=production +ENV RAILS_PORT=18800 +ENV RAILS_TRANSPORT=in-process +ENV SISTER_AGENT_CORE_PATH=/app/sister-agent/dist/core.js +ENV SISTER_WORKSPACE_DIR=/app/rails-projects + +EXPOSE 18800 + +# Default command: run migrations then start the HTTP server +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/cli/index.js serve"] diff --git a/README.md b/README.md index c4404da..8be1ac0 100644 --- a/README.md +++ b/README.md @@ -93,43 +93,46 @@ - **v0.1.0** — Sprint 000~007 완료. FSM / Contract / QA / Migration 코어. 105 테스트 통과. - **v0.1.1** — 실 LLM 통합 (OpenClaw infer), 4 계층 재귀 스폰, 파일 추출, Gitea auto-push, deploy URL. - **v0.1.2** — 대시보드 아티팩트 뷰, FileViewerModal (MD 파일 클릭 → 모달). +- **v0.1.3** — LLM 제공자 어댑터 (OpenAI / Anthropic / Ollama / OpenClaw / mock), in-process 단일 프로세스 모드, docker-compose, Gitea 호스트 완전 외부화, 외부 배포 친화 .env.example. -## 빠른 시작 +## 빠른 시작 (Docker, 5 분) + +가장 짧은 경로. 로컬에 `docker` 와 `docker compose` 만 있으면 된다. ```bash -# 1. 클론 + 설치 git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git cd hanarang-rails -pnpm install - -# 2. 환경 설정 cp .env.example .env -# DATABASE_URL, GITEA_TOKEN 등 채우기 +# .env 에서 LLM_PROVIDER=mock 으로 시작 (또는 openai/anthropic/ollama) -# 3. DB 마이그레이션 -pnpm prisma migrate deploy -pnpm prisma generate +docker compose up --build +# → http://localhost:18800/health 확인 -# 4. 빌드 -pnpm build - -# 5. Mock 모드로 스모크 테스트 -pnpm rails run hello-world --mock -r "Try a pipeline" -pnpm rails status - -# 6. 서버 기동 (18800 포트) -pnpm rails serve +# 다른 터미널 +curl -X POST http://localhost:18800/pipelines/start \ + -H 'content-type: application/json' \ + -d '{"project":"hello","requirements":"Say hi"}' ``` -Sister-agent 는 각 LXC 에서 별도 기동: +이게 끝. MariaDB + rails + sister-agent 4 개가 한 컨테이너 안에서 **in-process 모드** 로 돈다. 자세한 설정 옵션 (실제 LLM 키 연결, 네이티브 설치, 분산 토폴로지, 대시보드) 은 [`docs/LOCAL-SETUP.md`](docs/LOCAL-SETUP.md) 참조. -```bash -cd sister-agent -pnpm install && pnpm build -AGENT_NAME=harang RAILS_API_URL=http://dev-vm:18800 node dist/server.js -``` +### 배포 토폴로지 -대시보드는 별도 repo `hanarang-dashboard` 참조. +| 모드 | 설명 | 파일 | +|---|---|---| +| **in-process** | 모든 것을 하나의 Node 프로세스에서. 로컬 개발 기본값 | `docker-compose.yml` · `rails.config.local.yaml` | +| **http 분산** | rails + 4 개 독립 sister-agent 컨테이너. 운영 토폴로지 | `docker-compose.full.yml` · `rails.config.distributed.yaml` | +| **mock** | FSM 만 검증 (LLM/파일/push 없음) | `RAILS_TRANSPORT=mock` 또는 `rails run --mock` | + +### LLM 제공자 + +어댑터가 있어 다음 중 하나를 선택할 수 있다. `.env` 의 `LLM_PROVIDER` 로 지정: + +- `mock` — API 키 없이 결정론 스켈레톤만 확인 (기본값) +- `openai` — OpenAI / OpenRouter / Azure OpenAI / OpenAI-호환 로컬 서버 +- `anthropic` — Anthropic Messages API +- `ollama` — 로컬 Ollama 서버 +- `openclaw` — hanarang 내부 전용 런타임 ## CLI 서브커맨드 @@ -165,7 +168,8 @@ AGENT_NAME=harang RAILS_API_URL=http://dev-vm:18800 node dist/server.js ## 문서 -- [`docs/GUIDE.md`](docs/GUIDE.md) — **처음 보는 사람을 위한 완전 가이드 (전 구간 해설)** +- [`docs/LOCAL-SETUP.md`](docs/LOCAL-SETUP.md) — **30 분 퀵스타트** (본인 환경에서 처음 돌려 보기) +- [`docs/GUIDE.md`](docs/GUIDE.md) — **완전 가이드** (전 구간 해설, 처음 보는 사람용) - [`docs/GUIDE.pdf`](docs/GUIDE.pdf) — 위 문서의 PDF 버전 - [`docs/migration-guide.md`](docs/migration-guide.md) — 레거시 → rails 이전 가이드 - [`docs/operations.md`](docs/operations.md) — 운영 가이드 (PM2, 로그, DB) diff --git a/docker-compose.full.yml b/docker-compose.full.yml new file mode 100644 index 0000000..c4f8ea0 --- /dev/null +++ b/docker-compose.full.yml @@ -0,0 +1,141 @@ +# ──────────────────────────────────────────────────────────────── +# docker-compose.full.yml — production-style distributed topology +# +# Brings up: +# - mariadb +# - rails (orchestrator only) +# - sister-harang (plan) +# - sister-narang (implement) +# - sister-darang (review) +# - sister-erang (deploy) +# +# All 6 services share the same image but each sister container runs the +# sister-agent HTTP daemon instead of the rails orchestrator, and rails +# is configured to talk to them over HTTP. +# +# Usage: +# cp .env.example .env +# docker compose -f docker-compose.full.yml up --build +# ──────────────────────────────────────────────────────────────── + +x-sister-env: &sister-env + NODE_ENV: production + RAILS_API_URL: http://rails:18800 + SISTER_WORKSPACE_DIR: /app/rails-projects + LLM_PROVIDER: ${LLM_PROVIDER:-mock} + LLM_MODEL_MANAGER: ${LLM_MODEL_MANAGER:-} + LLM_MODEL_PRINCIPAL: ${LLM_MODEL_PRINCIPAL:-} + LLM_MODEL_LEAD: ${LLM_MODEL_LEAD:-} + LLM_MODEL_JUNIOR: ${LLM_MODEL_JUNIOR:-} + LLM_MODEL_FALLBACK: ${LLM_MODEL_FALLBACK:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-} + GITEA_BASE_URL: ${GITEA_BASE_URL:-} + GITEA_ORG: ${GITEA_ORG:-} + GITEA_TOKEN: ${GITEA_TOKEN:-} + +x-sister-service: &sister-service + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + mariadb: + condition: service_healthy + command: + ["node", "sister-agent/dist/server.js"] + networks: + - rails-net + +services: + mariadb: + image: mariadb:10.11 + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: rootpw + MARIADB_DATABASE: hanarang_rails + MARIADB_USER: rails + MARIADB_PASSWORD: rails + healthcheck: + test: + - "CMD" + - "healthcheck.sh" + - "--connect" + - "--innodb_initialized" + interval: 5s + timeout: 3s + retries: 20 + volumes: + - rails-db:/var/lib/mysql + networks: + - rails-net + + rails: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + mariadb: + condition: service_healthy + sister-harang: { condition: service_started } + sister-narang: { condition: service_started } + sister-darang: { condition: service_started } + sister-erang: { condition: service_started } + environment: + DATABASE_URL: mysql://rails:rails@mariadb:3306/hanarang_rails + RAILS_PORT: "18800" + RAILS_LOG_LEVEL: info + NODE_ENV: production + RAILS_TRANSPORT: http + RAILS_API_URL: http://rails:18800 + SISTER_ENDPOINT_PLAN: http://sister-harang:18801 + SISTER_ENDPOINT_IMPLEMENT: http://sister-narang:18801 + SISTER_ENDPOINT_REVIEW: http://sister-darang:18801 + SISTER_ENDPOINT_DEPLOY: http://sister-erang:18801 + SISTER_NAME_PLAN: harang + SISTER_NAME_IMPLEMENT: narang + SISTER_NAME_REVIEW: darang + SISTER_NAME_DEPLOY: erang + ports: + - "18800:18800" + networks: + - rails-net + + sister-harang: + <<: *sister-service + environment: + <<: *sister-env + SISTER_AGENT_NAME: harang + SISTER_AGENT_PORT: "18801" + + sister-narang: + <<: *sister-service + environment: + <<: *sister-env + SISTER_AGENT_NAME: narang + SISTER_AGENT_PORT: "18801" + + sister-darang: + <<: *sister-service + environment: + <<: *sister-env + SISTER_AGENT_NAME: darang + SISTER_AGENT_PORT: "18801" + + sister-erang: + <<: *sister-service + environment: + <<: *sister-env + SISTER_AGENT_NAME: erang + SISTER_AGENT_PORT: "18801" + +volumes: + rails-db: + +networks: + rails-net: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9710350 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,96 @@ +# ──────────────────────────────────────────────────────────────── +# docker-compose.yml — single-host / in-process mode +# +# Spins up: +# - mariadb (10.11) +# - rails (orchestrator + 4 sister agents all in one process) +# +# Everything runs in one container so the 4 sisters are just function +# calls instead of 4 separate daemons. Pick this file when you want +# "docker compose up and try it". +# +# Usage: +# cp .env.example .env # set LLM_PROVIDER and API keys +# docker compose up --build +# +# Then hit http://localhost:18800/health to confirm. +# +# For the production topology with 4 separate sister daemons, see +# docker-compose.full.yml instead. +# ──────────────────────────────────────────────────────────────── + +services: + mariadb: + image: mariadb:10.11 + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: rootpw + MARIADB_DATABASE: hanarang_rails + MARIADB_USER: rails + MARIADB_PASSWORD: rails + healthcheck: + test: + - "CMD" + - "healthcheck.sh" + - "--connect" + - "--innodb_initialized" + interval: 5s + timeout: 3s + retries: 20 + volumes: + - rails-db:/var/lib/mysql + networks: + - rails-net + + rails: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + mariadb: + condition: service_healthy + environment: + DATABASE_URL: mysql://rails:rails@mariadb:3306/hanarang_rails + RAILS_PORT: "18800" + RAILS_LOG_LEVEL: info + NODE_ENV: production + RAILS_TRANSPORT: in-process + RAILS_API_URL: http://127.0.0.1:18800 + SISTER_AGENT_CORE_PATH: /app/sister-agent/dist/core.js + SISTER_WORKSPACE_DIR: /app/rails-projects + # LLM — read from .env + LLM_PROVIDER: ${LLM_PROVIDER:-mock} + LLM_MODEL_MANAGER: ${LLM_MODEL_MANAGER:-} + LLM_MODEL_PRINCIPAL: ${LLM_MODEL_PRINCIPAL:-} + LLM_MODEL_LEAD: ${LLM_MODEL_LEAD:-} + LLM_MODEL_JUNIOR: ${LLM_MODEL_JUNIOR:-} + LLM_MODEL_FALLBACK: ${LLM_MODEL_FALLBACK:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-} + # Gitea — leave empty to disable auto-push + GITEA_BASE_URL: ${GITEA_BASE_URL:-} + GITEA_ORG: ${GITEA_ORG:-} + GITEA_TOKEN: ${GITEA_TOKEN:-} + ports: + - "18800:18800" + volumes: + - rails-workspace:/app/rails-projects + networks: + - rails-net + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:18800/health"] + interval: 10s + timeout: 3s + retries: 10 + +volumes: + rails-db: + rails-workspace: + +networks: + rails-net: + driver: bridge diff --git a/docs/LOCAL-SETUP.md b/docs/LOCAL-SETUP.md new file mode 100644 index 0000000..3912eaf --- /dev/null +++ b/docs/LOCAL-SETUP.md @@ -0,0 +1,218 @@ +# Local Setup — 30 분 퀵스타트 + +이 문서는 **본인 환경에서 hanarang-rails 를 처음부터 돌려 보는** 가장 짧은 경로다. 외부 인프라 (Gitea, OpenClaw, 4 개 LXC, MariaDB 전용 서버) 전혀 없어도 로컬에서 E2E 파이프라인을 한 번 돌리는 게 목표. + +대상 독자: 이 리포를 처음 클론한 사람. Node 와 docker 를 쓸 줄 아는 사람. + +--- + +## 0. 사전 요구 + +하나만 선택: + +- **Option A — Docker 경로** (권장): `docker` + `docker compose` 만 있으면 끝. MariaDB 까지 컨테이너로 뜬다. +- **Option B — 네이티브 경로**: Node 22, pnpm 9, MariaDB 10.11+ 로컬 설치. + +추가로 **LLM 제공자 하나**를 정해 둬야 한다. + +| 제공자 | 필요한 것 | 비용 | +|---|---|---| +| `mock` | (없음) | 무료, 진짜 LLM 호출 없음 — FSM 만 확인 | +| `openai` | OpenAI API 키 | 사용량 기반 | +| `anthropic` | Anthropic API 키 | 사용량 기반 | +| `ollama` | 로컬 Ollama + 모델 pull | 무료, 로컬 GPU/CPU | +| `openclaw` | hanarang 내부 런타임 | 외부인 접근 불가 | + +**처음이면 `mock` 으로 시작**하는 걸 권장한다. 실제 LLM 없이 파이프라인 전 구간이 동작하는지 먼저 확인하고, 그 다음 원하는 제공자로 바꿔도 늦지 않다. + +--- + +## 1. Docker 경로 (권장) + +### 1-1. 클론 + 환경 설정 + +```bash +git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git +cd hanarang-rails +cp .env.example .env +``` + +`.env` 에서 최소 이 두 줄만 만져 주면 된다: + +```bash +# 모크 모드로 시작 (진짜 LLM 호출 안 함) +LLM_PROVIDER=mock + +# Docker compose 가 쓸 DB URL +DATABASE_URL="mysql://rails:rails@mariadb:3306/hanarang_rails" +``` + +### 1-2. 기동 + +```bash +docker compose up --build +``` + +처음 빌드는 몇 분 걸린다. 완료되면 rails 컨테이너가 마이그레이션을 돌리고 HTTP 서버가 18800 포트에서 리스닝한다. + +```bash +curl http://localhost:18800/health +# → {"ok":true,"service":"hanarang-rails"} +``` + +### 1-3. 파이프라인 첫 실행 + +다른 터미널에서: + +```bash +curl -X POST http://localhost:18800/pipelines/start \ + -H 'content-type: application/json' \ + -d '{"project":"todo-app","requirements":"간단한 todo 웹앱"}' +``` + +응답으로 `pipelineId`, `finalState: done`, `transitions` 숫자가 돌아오면 성공. 파이프라인 상태는: + +```bash +curl http://localhost:18800/pipelines/ +``` + +### 1-4. 실제 LLM 로 갈아타기 + +`.env` 에서: + +```bash +LLM_PROVIDER=openai +OPENAI_API_KEY=sk-... +LLM_MODEL_MANAGER=gpt-4o +LLM_MODEL_PRINCIPAL=gpt-4o +LLM_MODEL_LEAD=gpt-4o-mini +LLM_MODEL_JUNIOR=gpt-4o-mini +``` + +`docker compose up -d --build` 로 재시작. 같은 `curl` 명령을 또 날리면 이번에는 실제 LLM 이 호출되고, 각 junior 가 만든 코드 블록이 `rails-workspace` 볼륨 안으로 저장된다. + +> **Anthropic / Ollama / OpenAI 호환 서버** 도 같은 패턴이다. `LLM_PROVIDER` 만 바꾸고 해당 API 키/URL 를 `.env` 에 채워 주면 된다. `.env.example` 파일 주석에 각 제공자별 키 이름이 정리돼 있다. + +--- + +## 2. 네이티브 경로 + +Docker 없이 로컬 프로세스로 돌리는 경로. + +### 2-1. MariaDB 준비 + +```bash +# brew / apt / 도커 중 편한 방법으로 MariaDB 10.11+ 기동 +# 그 다음 DB/사용자 생성: +mysql -u root -p <//files/` 에 저장되고, `rails-workspace` named volume 에 영속화된다. 컨테이너 밖에서 보려면 `docker compose run --rm rails ls /app/rails-projects/` 또는 볼륨 mount 변경. +- 네이티브 경로: `$HOME/rails-projects///files/`. + +Gitea auto-push 는 기본적으로 꺼져 있다. 켜고 싶으면 `.env` 에 `GITEA_TOKEN`, `GITEA_BASE_URL`, `GITEA_ORG` 를 채우면 자동으로 켜진다. + +--- + +## 4. 대시보드도 띄우려면 + +대시보드 (`hanarang-dashboard`) 는 별도 리포다. rails 가 돌아가고 있는 상태에서 같은 MariaDB 를 바라보도록 설정하면 `/rails` 페이지에서 파이프라인 트리가 시각화된다. + +```bash +git clone https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard.git +cd hanarang-dashboard/backend +cp .env.example .env +# DATABASE_URL 을 rails 와 같게 +# RAILS_API_URL=http://localhost:18800 +# GIT_RAW_ALLOWED_HOSTS=git.example.com (optional, for MD viewer) +pnpm install && pnpm build && pnpm start:prod +``` + +프론트엔드는 별도 프로세스: + +```bash +cd ../frontend +pnpm install && pnpm dev +# → http://localhost:3000/rails +``` + +--- + +## 5. 자주 막히는 부분 + +**Q. `pnpm rails run` 이 "DATABASE_URL not set" 에러.** +`.env` 가 rails 의 작업 디렉토리에 있어야 한다. `loadEnv()` 는 `process.cwd()` 기준으로 찾는다. + +**Q. Mock 모드인데 LLM 응답이 텅 비어 있다.** +정상이다. Mock 은 결정론 스켈레톤만 확인하려고 있는 거라 파일도 안 만들고 내용도 거의 없다. 실제 LLM 로 바꿔야 의미 있는 산출물이 나온다. + +**Q. In-process 모드인데 `sister-agent core module not found`.** +`sister-agent/dist/core.js` 가 빌드되지 않은 상태다. `cd sister-agent && pnpm build`. 또는 `SISTER_AGENT_CORE_PATH` 로 절대 경로 명시. + +**Q. OpenAI 대신 OpenRouter / Azure OpenAI / 로컬 llama.cpp 서버를 쓸 수 있나?** +된다. `LLM_PROVIDER=openai` 로 두고 `OPENAI_BASE_URL` 을 바꿔 주면 OpenAI Chat Completions 프로토콜을 말하는 모든 서버에 붙는다. + +**Q. 4 개 자매를 진짜 분리된 컨테이너로 돌리고 싶다.** +`docker-compose.full.yml` 을 써라. rails 1 개 + 각 자매 1 개씩 총 6 개 서비스가 뜨고, rails 가 HTTP 로 각 자매에게 invoke 를 보낸다. + +--- + +## 6. 다음 단계 + +- **구조 전체를 이해하고 싶다면**: [`docs/GUIDE.md`](GUIDE.md) 또는 PDF 버전 +- **실제로 코드를 건드리고 싶다면**: [`.plans/design/`](../.plans/design/) 의 설계 문서 +- **프롬프트/역할을 본인 도메인에 맞추고 싶다면**: `sister-agent/src/prompts.ts`, `sister-agent/src/roles.ts`, `rails.config.local.yaml` 순으로 읽기 diff --git a/rails.config.distributed.yaml b/rails.config.distributed.yaml new file mode 100644 index 0000000..4407540 --- /dev/null +++ b/rails.config.distributed.yaml @@ -0,0 +1,58 @@ +# ──────────────────────────────────────────────────────────────── +# rails.config.distributed.yaml +# +# Production-style topology. Each sister-agent runs as its own daemon +# (typically on its own host/container/LXC) and rails calls them over +# HTTP. This is what the hanarang internal deployment uses. +# +# Usage: +# cp rails.config.distributed.yaml rails.config.yaml +# # Start 4 sister-agent daemons (see docs/LOCAL-SETUP.md) +# pnpm rails serve -c rails.config.yaml +# +# Endpoints can also be overridden via env: +# SISTER_ENDPOINT_PLAN=http://host:18801 etc. +# ──────────────────────────────────────────────────────────────── + +pipeline: + stages: + - plan + - implement + - review + - deploy + +agents: + plan: + role: plan + displayName: Planner + agentName: harang + transport: http + endpoint: http://harang.local:18801 + timeoutMs: 600000 + + implement: + role: implement + displayName: Generator + agentName: narang + transport: http + endpoint: http://narang.local:18801 + timeoutMs: 600000 + + review: + role: review + displayName: Evaluator + agentName: darang + transport: http + endpoint: http://darang.local:18801 + timeoutMs: 600000 + + deploy: + role: deploy + displayName: Deploy + agentName: erang + transport: http + endpoint: http://erang.local:18801 + timeoutMs: 600000 + +discord: + enabled: false diff --git a/rails.config.local.yaml b/rails.config.local.yaml new file mode 100644 index 0000000..02cabda --- /dev/null +++ b/rails.config.local.yaml @@ -0,0 +1,56 @@ +# ──────────────────────────────────────────────────────────────── +# rails.config.local.yaml +# +# Single-host / single-process configuration. Every stage runs the +# sister-agent core directly inside the rails Node process — no separate +# daemons, no networking between agents, just one binary. +# +# This is the fastest way to try rails on your laptop. +# +# Usage: +# cp rails.config.local.yaml rails.config.yaml +# cp .env.example .env # then set LLM_PROVIDER + any API keys +# pnpm rails serve -c rails.config.yaml +# +# Env vars (RAILS_TRANSPORT, SISTER_ENDPOINT_*, etc.) always override +# whatever is in this file. +# ──────────────────────────────────────────────────────────────── + +pipeline: + stages: + - plan + - implement + - review + - deploy + +agents: + plan: + role: plan + displayName: Planner + agentName: harang + transport: in-process + timeoutMs: 600000 + + implement: + role: implement + displayName: Generator + agentName: narang + transport: in-process + timeoutMs: 600000 + + review: + role: review + displayName: Evaluator + agentName: darang + transport: in-process + timeoutMs: 600000 + + deploy: + role: deploy + displayName: Deploy + agentName: erang + transport: in-process + timeoutMs: 600000 + +discord: + enabled: false diff --git a/sister-agent/src/core.ts b/sister-agent/src/core.ts new file mode 100644 index 0000000..c08c5db --- /dev/null +++ b/sister-agent/src/core.ts @@ -0,0 +1,26 @@ +/** + * Sister-agent library entry — exposes the core execution functions so + * another process (e.g. rails running in single-process mode) can + * invoke them directly without going through HTTP. + * + * This lives in addition to ./server.ts (which wraps the same logic as + * an HTTP daemon). Both code paths share ./spawn.ts under the hood. + */ + +export { executeInvocation } from "./spawn.js"; +export { RailsClient } from "./rails-client.js"; +export { createLlmAdapter, getLlmAdapter } from "./llm/index.js"; +export type { + LlmAdapter, + LlmRequest, + LlmResult, + ProviderName, +} from "./llm/index.js"; +export { ROLES, ROLE_KOREAN } from "./roles.js"; +export type { RoleConfig } from "./roles.js"; +export { + InvokeRequest, + HandoffMessage, + Role, + type SubTaskRecord, +} from "./types.js"; diff --git a/sister-agent/src/llm.ts b/sister-agent/src/llm.ts index 38d652f..a5a6b8d 100644 --- a/sister-agent/src/llm.ts +++ b/sister-agent/src/llm.ts @@ -1,108 +1,11 @@ -import { spawn } from "node:child_process"; - -export interface LlmResult { - ok: boolean; - text: string; - provider: string; - model: string; - errorMessage?: string; -} - -const OPENCLAW_BIN = - process.env["OPENCLAW_BIN"] ?? - `${process.env["HOME"]}/.npm-global/bin/openclaw`; - -/** - * Call openclaw infer model run via subprocess. - * Returns the structured JSON the CLI emits with --json. - * - * Note: openclaw enforces an allowlist per agent. We pass through to the - * default model unless an explicit override is requested AND it's allowed. - */ -export async function callLlm(opts: { - prompt: string; - modelOverride?: string; - timeoutMs?: number; -}): Promise { - const args = ["infer", "model", "run", "--prompt", opts.prompt, "--json"]; - if (opts.modelOverride) { - args.push("--model", opts.modelOverride); - } - - return new Promise((resolveFn) => { - const child = spawn(OPENCLAW_BIN, args, { - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - child.kill("SIGKILL"); - resolveFn({ - ok: false, - text: "", - provider: "", - model: opts.modelOverride ?? "default", - errorMessage: `LLM timeout after ${opts.timeoutMs ?? 120_000}ms`, - }); - }, opts.timeoutMs ?? 120_000); - - child.stdout.on("data", (b: Buffer) => (stdout += b.toString())); - child.stderr.on("data", (b: Buffer) => (stderr += b.toString())); - child.on("error", (err) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolveFn({ - ok: false, - text: "", - provider: "", - model: opts.modelOverride ?? "default", - errorMessage: `LLM spawn error: ${err.message}`, - }); - }); - child.on("exit", (code) => { - if (settled) return; - settled = true; - clearTimeout(timer); - - if (code !== 0) { - resolveFn({ - ok: false, - text: "", - provider: "", - model: opts.modelOverride ?? "default", - errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`, - }); - return; - } - - try { - const parsed = JSON.parse(stdout) as { - ok: boolean; - provider: string; - model: string; - outputs: Array<{ text: string }>; - }; - const text = parsed.outputs?.[0]?.text ?? ""; - resolveFn({ - ok: parsed.ok, - text, - provider: parsed.provider, - model: parsed.model, - }); - } catch (err) { - resolveFn({ - ok: false, - text: "", - provider: "", - model: opts.modelOverride ?? "default", - errorMessage: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}\nstdout: ${stdout.slice(0, 500)}`, - }); - } - }); - }); -} +// Shim for backwards compatibility — the adapter implementations now live +// in ./llm/. Import from ./llm/index.js for new code. +export { + callLlm, + createLlmAdapter, + getLlmAdapter, + type LlmAdapter, + type LlmRequest, + type LlmResult, + type ProviderName, +} from "./llm/index.js"; diff --git a/sister-agent/src/llm/adapter.ts b/sister-agent/src/llm/adapter.ts new file mode 100644 index 0000000..c4c9c9c --- /dev/null +++ b/sister-agent/src/llm/adapter.ts @@ -0,0 +1,18 @@ +export interface LlmRequest { + prompt: string; + model: string; + timeoutMs: number; +} + +export interface LlmResult { + ok: boolean; + text: string; + provider: string; + model: string; + errorMessage?: string; +} + +export interface LlmAdapter { + readonly name: string; + infer(req: LlmRequest): Promise; +} diff --git a/sister-agent/src/llm/anthropic.ts b/sister-agent/src/llm/anthropic.ts new file mode 100644 index 0000000..723e470 --- /dev/null +++ b/sister-agent/src/llm/anthropic.ts @@ -0,0 +1,97 @@ +import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js"; + +/** + * Anthropic Messages API adapter. + * + * Required env: + * ANTHROPIC_API_KEY — your API key + * Optional env: + * ANTHROPIC_BASE_URL — override endpoint (defaults to api.anthropic.com) + * ANTHROPIC_VERSION — API version header (defaults to 2023-06-01) + */ +export class AnthropicAdapter implements LlmAdapter { + readonly name = "anthropic"; + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly version: string; + + constructor(opts: { apiKey?: string; baseUrl?: string; version?: string } = {}) { + this.apiKey = opts.apiKey ?? process.env["ANTHROPIC_API_KEY"] ?? ""; + this.baseUrl = + opts.baseUrl ?? + process.env["ANTHROPIC_BASE_URL"] ?? + "https://api.anthropic.com"; + this.version = + opts.version ?? process.env["ANTHROPIC_VERSION"] ?? "2023-06-01"; + } + + async infer(req: LlmRequest): Promise { + if (!this.apiKey) { + return { + ok: false, + text: "", + provider: this.name, + model: req.model, + errorMessage: "ANTHROPIC_API_KEY not configured", + }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), req.timeoutMs); + + try { + const res = await fetch(`${this.baseUrl}/v1/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": this.apiKey, + "anthropic-version": this.version, + }, + body: JSON.stringify({ + model: req.model, + max_tokens: 4096, + messages: [{ role: "user", content: req.prompt }], + }), + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) { + const body = await res.text(); + return { + ok: false, + text: "", + provider: this.name, + model: req.model, + errorMessage: `anthropic HTTP ${res.status}: ${body.slice(0, 400)}`, + }; + } + + const data = (await res.json()) as { + content?: Array<{ type: string; text?: string }>; + model?: string; + }; + const text = + data.content + ?.filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join("") ?? ""; + return { + ok: true, + text, + provider: this.name, + model: data.model ?? req.model, + }; + } catch (err) { + clearTimeout(timer); + return { + ok: false, + text: "", + provider: this.name, + model: req.model, + errorMessage: + err instanceof Error ? err.message : String(err), + }; + } + } +} diff --git a/sister-agent/src/llm/index.ts b/sister-agent/src/llm/index.ts new file mode 100644 index 0000000..6125c7f --- /dev/null +++ b/sister-agent/src/llm/index.ts @@ -0,0 +1,75 @@ +import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js"; +import { OpenClawAdapter } from "./openclaw.js"; +import { OpenAiAdapter } from "./openai.js"; +import { AnthropicAdapter } from "./anthropic.js"; +import { OllamaAdapter } from "./ollama.js"; +import { MockAdapter } from "./mock.js"; + +export type { LlmAdapter, LlmRequest, LlmResult }; + +export type ProviderName = + | "openclaw" + | "openai" + | "anthropic" + | "ollama" + | "mock"; + +/** + * Build an adapter from env / explicit override. + * + * Provider selection order: + * 1. explicit `opts.provider` + * 2. env LLM_PROVIDER + * 3. default: "mock" (safe fallback — won't accidentally spend money) + */ +export function createLlmAdapter( + opts: { provider?: ProviderName } = {}, +): LlmAdapter { + const raw = + opts.provider ?? + (process.env["LLM_PROVIDER"] as ProviderName | undefined) ?? + "mock"; + + switch (raw) { + case "openclaw": + return new OpenClawAdapter(); + case "openai": + return new OpenAiAdapter(); + case "anthropic": + return new AnthropicAdapter(); + case "ollama": + return new OllamaAdapter(); + case "mock": + return new MockAdapter(); + default: { + const exhaustive: never = raw; + throw new Error( + `Unknown LLM_PROVIDER: ${exhaustive as string}. ` + + `Supported: openclaw, openai, anthropic, ollama, mock`, + ); + } + } +} + +// Module-level singleton so we don't rebuild the adapter on every LLM call. +let cached: LlmAdapter | null = null; + +export function getLlmAdapter(): LlmAdapter { + if (!cached) cached = createLlmAdapter(); + return cached; +} + +/** Convenience wrapper — kept signature-compatible with the old callLlm(). */ +export async function callLlm(opts: { + prompt: string; + model?: string; + modelOverride?: string; + timeoutMs?: number; +}): Promise { + const adapter = getLlmAdapter(); + return adapter.infer({ + prompt: opts.prompt, + model: opts.modelOverride ?? opts.model ?? "", + timeoutMs: opts.timeoutMs ?? 120_000, + }); +} diff --git a/sister-agent/src/llm/mock.ts b/sister-agent/src/llm/mock.ts new file mode 100644 index 0000000..158a4cf --- /dev/null +++ b/sister-agent/src/llm/mock.ts @@ -0,0 +1,42 @@ +import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js"; + +/** + * Mock adapter — returns a deterministic fake response based on the role + * hint in the prompt. Useful for smoke tests and offline demos where no + * real LLM credentials are available. + */ +export class MockAdapter implements LlmAdapter { + readonly name = "mock"; + + async infer(req: LlmRequest): Promise { + // tiny delay so upstream concurrency code behaves as if it's async + await new Promise((r) => setTimeout(r, 40)); + + const isImpl = /implement|구현/.test(req.prompt); + const isJunior = /junior|신입/.test(req.prompt); + + let text: string; + if (isImpl && isJunior) { + text = [ + "간단한 샘플 산출물입니다.", + "", + "```html:frontend/index.html", + "", + "", + "mock", + "

Hello from mock adapter

", + "", + "```", + ].join("\n"); + } else { + text = `# Mock ${req.model}\n\n이 응답은 MockAdapter 가 생성한 결정론적 더미입니다. 실제 LLM 응답이 아닙니다.`; + } + + return { + ok: true, + text, + provider: this.name, + model: req.model, + }; + } +} diff --git a/sister-agent/src/llm/ollama.ts b/sister-agent/src/llm/ollama.ts new file mode 100644 index 0000000..3b079f6 --- /dev/null +++ b/sister-agent/src/llm/ollama.ts @@ -0,0 +1,72 @@ +import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js"; + +/** + * Ollama adapter — for local LLM inference via https://ollama.com + * + * Optional env: + * OLLAMA_BASE_URL — defaults to http://localhost:11434 + * + * Example model names: llama3.1, qwen2.5-coder, mistral, deepseek-coder + */ +export class OllamaAdapter implements LlmAdapter { + readonly name = "ollama"; + private readonly baseUrl: string; + + constructor(opts: { baseUrl?: string } = {}) { + this.baseUrl = + opts.baseUrl ?? + process.env["OLLAMA_BASE_URL"] ?? + "http://localhost:11434"; + } + + async infer(req: LlmRequest): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), req.timeoutMs); + + try { + const res = await fetch(`${this.baseUrl}/api/generate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: req.model, + prompt: req.prompt, + stream: false, + options: { temperature: 0.3 }, + }), + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) { + const body = await res.text(); + return { + ok: false, + text: "", + provider: this.name, + model: req.model, + errorMessage: `ollama HTTP ${res.status}: ${body.slice(0, 400)}`, + }; + } + const data = (await res.json()) as { + response?: string; + model?: string; + }; + return { + ok: true, + text: data.response ?? "", + provider: this.name, + model: data.model ?? req.model, + }; + } catch (err) { + clearTimeout(timer); + return { + ok: false, + text: "", + provider: this.name, + model: req.model, + errorMessage: + err instanceof Error ? err.message : String(err), + }; + } + } +} diff --git a/sister-agent/src/llm/openai.ts b/sister-agent/src/llm/openai.ts new file mode 100644 index 0000000..de58dfb --- /dev/null +++ b/sister-agent/src/llm/openai.ts @@ -0,0 +1,91 @@ +import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js"; + +/** + * OpenAI Chat Completions adapter. + * + * Required env: + * OPENAI_API_KEY — your API key + * Optional env: + * OPENAI_BASE_URL — override endpoint (defaults to api.openai.com/v1) + * Use this for Azure OpenAI, OpenRouter, local + * llama.cpp servers that speak the OpenAI protocol, + * etc. + */ +export class OpenAiAdapter implements LlmAdapter { + readonly name = "openai"; + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor(opts: { apiKey?: string; baseUrl?: string } = {}) { + this.apiKey = opts.apiKey ?? process.env["OPENAI_API_KEY"] ?? ""; + this.baseUrl = + opts.baseUrl ?? + process.env["OPENAI_BASE_URL"] ?? + "https://api.openai.com/v1"; + } + + async infer(req: LlmRequest): Promise { + if (!this.apiKey) { + return { + ok: false, + text: "", + provider: this.name, + model: req.model, + errorMessage: "OPENAI_API_KEY not configured", + }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), req.timeoutMs); + + try { + const res = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: req.model, + messages: [{ role: "user", content: req.prompt }], + temperature: 0.3, + }), + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) { + const body = await res.text(); + return { + ok: false, + text: "", + provider: this.name, + model: req.model, + errorMessage: `openai HTTP ${res.status}: ${body.slice(0, 400)}`, + }; + } + + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + model?: string; + }; + const text = data.choices?.[0]?.message?.content ?? ""; + return { + ok: true, + text, + provider: this.name, + model: data.model ?? req.model, + }; + } catch (err) { + clearTimeout(timer); + return { + ok: false, + text: "", + provider: this.name, + model: req.model, + errorMessage: + err instanceof Error ? err.message : String(err), + }; + } + } +} diff --git a/sister-agent/src/llm/openclaw.ts b/sister-agent/src/llm/openclaw.ts new file mode 100644 index 0000000..81487e4 --- /dev/null +++ b/sister-agent/src/llm/openclaw.ts @@ -0,0 +1,103 @@ +import { spawn } from "node:child_process"; +import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js"; + +/** + * OpenClaw adapter — invokes the nabomhalang internal OpenClaw runtime via its + * `openclaw infer model run --json` subprocess. This is the original adapter + * used by the hanarang 4-sister deployment. + * + * External users will most likely NOT have OpenClaw installed. They should + * use the `openai`, `anthropic`, `ollama`, or `mock` adapters instead. + */ +export class OpenClawAdapter implements LlmAdapter { + readonly name = "openclaw"; + private readonly bin: string; + + constructor(opts: { bin?: string } = {}) { + this.bin = + opts.bin ?? + process.env["OPENCLAW_BIN"] ?? + `${process.env["HOME"]}/.npm-global/bin/openclaw`; + } + + async infer(req: LlmRequest): Promise { + const args = ["infer", "model", "run", "--prompt", req.prompt, "--json"]; + if (req.model) args.push("--model", req.model); + + return new Promise((resolveFn) => { + const child = spawn(this.bin, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + resolveFn({ + ok: false, + text: "", + provider: this.name, + model: req.model || "default", + errorMessage: `openclaw timeout after ${req.timeoutMs}ms`, + }); + }, req.timeoutMs); + + child.stdout.on("data", (b: Buffer) => (stdout += b.toString())); + child.stderr.on("data", (b: Buffer) => (stderr += b.toString())); + child.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveFn({ + ok: false, + text: "", + provider: this.name, + model: req.model || "default", + errorMessage: `openclaw spawn error: ${err.message}`, + }); + }); + child.on("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code !== 0) { + resolveFn({ + ok: false, + text: "", + provider: this.name, + model: req.model || "default", + errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`, + }); + return; + } + try { + const parsed = JSON.parse(stdout) as { + ok: boolean; + provider: string; + model: string; + outputs: Array<{ text: string }>; + }; + resolveFn({ + ok: parsed.ok, + text: parsed.outputs?.[0]?.text ?? "", + provider: parsed.provider || this.name, + model: parsed.model || req.model, + }); + } catch (err) { + resolveFn({ + ok: false, + text: "", + provider: this.name, + model: req.model || "default", + errorMessage: `openclaw JSON parse failed: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + } + }); + }); + } +} diff --git a/sister-agent/src/roles.ts b/sister-agent/src/roles.ts index aec7ed9..6288d68 100644 --- a/sister-agent/src/roles.ts +++ b/sister-agent/src/roles.ts @@ -7,28 +7,71 @@ export interface RoleConfig { maxSpawnPerCall: number; } +/** + * Per-role model defaults. For external users this is almost certainly the + * first thing you'll want to change — the `gpt-5.4` / `glm-*` / `gpt-codex-*` + * names are OpenClaw-internal labels that won't resolve against OpenAI, + * Anthropic, or Ollama directly. + * + * Override priority (highest first): + * 1. env vars: + * LLM_MODEL_MANAGER, LLM_MODEL_PRINCIPAL, LLM_MODEL_LEAD, LLM_MODEL_JUNIOR + * LLM_MODEL_FALLBACK (used for every role's fallback unless you set + * LLM_MODEL_FALLBACK_) + * 2. these hardcoded defaults (OpenClaw-flavored) + * + * Good starting points for a real deployment: + * OpenAI: gpt-4o / gpt-4o-mini + * Anthropic: claude-opus-4-6 / claude-haiku-4-5 + * Ollama: qwen2.5-coder:32b / qwen2.5-coder:7b + */ + +const OPENCLAW_DEFAULTS: Record = { + manager: { primary: "gpt-5.4", fallback: "glm-5.1" }, + principal: { primary: "gpt-5.4", fallback: "glm-5.1" }, + lead: { primary: "gpt-codex-5.3", fallback: "glm-5" }, + junior: { primary: "glm-5-turbo", fallback: "gpt-5" }, +}; + +function envModel(role: Role, kind: "primary" | "fallback"): string | undefined { + const up = role.toUpperCase(); + if (kind === "primary") { + return process.env[`LLM_MODEL_${up}`]; + } + return ( + process.env[`LLM_MODEL_FALLBACK_${up}`] ?? + process.env["LLM_MODEL_FALLBACK"] + ); +} + +function modelFor(role: Role, kind: "primary" | "fallback"): string { + const override = envModel(role, kind); + if (override && override.length > 0) return override; + return OPENCLAW_DEFAULTS[role][kind]; +} + export const ROLES: Record = { manager: { - primaryModel: "gpt-5.4", - fallbackModel: "glm-5.1", + primaryModel: modelFor("manager", "primary"), + fallbackModel: modelFor("manager", "fallback"), canSpawn: ["principal", "lead", "junior"], maxSpawnPerCall: 4, }, principal: { - primaryModel: "gpt-5.4", - fallbackModel: "glm-5.1", + primaryModel: modelFor("principal", "primary"), + fallbackModel: modelFor("principal", "fallback"), canSpawn: ["lead", "junior"], maxSpawnPerCall: 3, }, lead: { - primaryModel: "gpt-codex-5.3", - fallbackModel: "glm-5", + primaryModel: modelFor("lead", "primary"), + fallbackModel: modelFor("lead", "fallback"), canSpawn: ["junior"], maxSpawnPerCall: 4, }, junior: { - primaryModel: "glm-5-turbo", - fallbackModel: "gpt-5", + primaryModel: modelFor("junior", "primary"), + fallbackModel: modelFor("junior", "fallback"), canSpawn: [], maxSpawnPerCall: 0, }, diff --git a/sister-agent/src/spawn.ts b/sister-agent/src/spawn.ts index 37effe1..28e0894 100644 --- a/sister-agent/src/spawn.ts +++ b/sister-agent/src/spawn.ts @@ -20,8 +20,24 @@ import { buildPrompt } from "./prompts.js"; import { extractCodeBlocks, saveExtractedFiles } from "./code-extractor.js"; import { commitAndPush } from "./git-ops.js"; -const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false"; -const ENABLE_GIT_PUSH = process.env["RAILS_ENABLE_GIT_PUSH"] !== "false"; +// Real LLM call is the default. Set USE_REAL_LLM=false (or the legacy +// RAILS_USE_REAL_LLM=false) to short-circuit every LLM call — useful when +// the operator wants determinism-only smoke tests. +const USE_REAL_LLM = + process.env["USE_REAL_LLM"] !== "false" && + process.env["RAILS_USE_REAL_LLM"] !== "false"; + +// Git push auto-activates when a Gitea token is present. Operators can +// force it off (useful for local dry-runs) by setting GIT_PUSH_ENABLED=false. +// The legacy RAILS_ENABLE_GIT_PUSH env var is still honored. +const ENABLE_GIT_PUSH = (() => { + if (process.env["GIT_PUSH_ENABLED"] === "false") return false; + if (process.env["RAILS_ENABLE_GIT_PUSH"] === "false") return false; + if (process.env["GIT_PUSH_ENABLED"] === "true") return true; + if (process.env["RAILS_ENABLE_GIT_PUSH"] === "true") return true; + // Auto: enabled iff we actually have a token to push with + return Boolean(process.env["GITEA_TOKEN"]); +})(); const WORKSPACE_ROOT = process.env["SISTER_WORKSPACE_DIR"] ?? join(homedir(), "rails-projects"); @@ -328,6 +344,7 @@ async function doWork(args: { const result = await callLlm({ prompt, + model: ROLES[args.role].primaryModel, timeoutMs: 120_000, }); diff --git a/src/cli/run.ts b/src/cli/run.ts index 27be5df..e4c5894 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -4,6 +4,7 @@ import { loadConfig } from "../config/loader.js"; import { runPipeline } from "../orchestrator/runner.js"; import { MockTransport } from "../handoff/mock-transport.js"; import type { SisterTransport } from "../handoff/transport.js"; +import { buildTransports } from "../handoff/build.js"; import { disconnectPrisma } from "../orchestrator/persist.js"; export default defineCommand({ @@ -39,31 +40,16 @@ export default defineCommand({ loadEnv(); try { const config = await loadConfig(args.config || undefined); - const transports = new Map(); + let transports: Map; if (args.mock) { + // Hard override: force mock across all stages const mock = new MockTransport(); - for (const stage of config.pipeline.stages) { - transports.set(stage, mock); - } + transports = new Map(); + for (const stage of config.pipeline.stages) transports.set(stage, mock); } else { - // For now, default to mock when no real transport wiring is provided. - // Sprint 004 ships DiscordTransport as a class; wiring a live discord - // client is an operator task (see docs/discord-setup.md). - const mock = new MockTransport(); - for (const stage of config.pipeline.stages) { - const t = config.agents[stage]?.transport; - if (t === "mock" || !t) { - transports.set(stage, mock); - } else if (t === "discord") { - console.warn( - `[rails] Discord transport for stage '${stage}' requires a bot wiring — falling back to mock.`, - ); - transports.set(stage, mock); - } else { - transports.set(stage, mock); - } - } + // Config + env-based transport wiring + transports = buildTransports(config); } const result = await runPipeline({ diff --git a/src/config/schema.ts b/src/config/schema.ts index 2786d2a..a36c522 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,13 +1,25 @@ import { z } from "zod"; -export const TransportMode = z.enum(["discord", "mock", "local"]); +export const TransportMode = z.enum([ + "discord", + "mock", + "local", + "http", + "in-process", +]); export type TransportMode = z.infer; export const AgentConfig = z.object({ role: z.string().min(1), displayName: z.string().default(""), + /** Sister identity — harang / narang / darang / erang. Required for http / in-process. */ + agentName: z.string().default(""), transport: TransportMode.default("mock"), channelId: z.string().default(""), + /** http transport: the sister-agent daemon endpoint, e.g. http://10.10.10.112:18801 */ + endpoint: z.string().default(""), + /** in-process: optional override for the sister-agent core module path */ + coreModulePath: z.string().default(""), timeoutMs: z.number().int().positive().default(600_000), }); export type AgentConfig = z.infer; diff --git a/src/handoff/build.ts b/src/handoff/build.ts index 72c8686..639f1a5 100644 --- a/src/handoff/build.ts +++ b/src/handoff/build.ts @@ -1,23 +1,42 @@ import type { SisterTransport } from "./transport.js"; import { MockTransport } from "./mock-transport.js"; import { HttpTransport } from "./http-transport.js"; -import type { RailsConfig } from "../config/schema.js"; +import { InProcessTransport } from "./in-process-transport.js"; +import type { RailsConfig, TransportMode } from "../config/schema.js"; import { childLogger } from "../logger.js"; const log = childLogger({ module: "transport-builder" }); +const DEFAULT_SISTER_NAMES: Record = { + plan: "harang", + implement: "narang", + review: "darang", + deploy: "erang", +}; + /** * Build a stage → transport map from rails config + environment. * - * Environment overrides (convenient for testing): - * RAILS_TRANSPORT_MODE=mock|http|auto (default auto — use config) - * RAILS_API_URL=http://10.10.10.169:18800 (used as rails callback URL for sub-tasks) - * RAILS_AGENT_{STAGE}_HOST=10.10.10.112 (override agent host) - * RAILS_AGENT_{STAGE}_PORT=18801 (override agent port) + * Environment overrides (convenient for docker-compose / smoke tests): + * + * RAILS_TRANSPORT=mock|http|in-process (applies to every stage) + * RAILS_TRANSPORT_{STAGE}=… (per-stage override) + * RAILS_API_URL=http://127.0.0.1:18800 (callback URL for sub-tasks) + * + * For http transport: + * SISTER_ENDPOINT_{STAGE}=http://host:18801 + * or legacy RAILS_AGENT_{STAGE}_HOST / _PORT + * + * For in-process transport: + * SISTER_AGENT_CORE_PATH=/abs/path/to/sister-agent/dist/core.js + * + * Sister identity: + * SISTER_NAME_{STAGE}=harang|narang|darang|erang|custom */ -export function buildTransports(config: RailsConfig): Map { +export function buildTransports( + config: RailsConfig, +): Map { const transports = new Map(); - const mode = process.env["RAILS_TRANSPORT_MODE"] ?? "auto"; const railsApiUrl = process.env["RAILS_API_URL"] ?? "http://127.0.0.1:18800"; @@ -25,59 +44,106 @@ export function buildTransports(config: RailsConfig): Map { + const parsed = CreateSubTaskInput.parse(record); + await createSubTask(parsed); + } + + async recordEvent( + subTaskId: string, + eventType: string, + payload: Record, + ): Promise { + const parsed = SubTaskEventInput.parse({ + subTaskId, + eventType, + payload, + }); + await recordSubTaskEvent(parsed); + } + + async patchSubTask( + id: string, + patch: Record, + ): Promise { + const parsed = UpdateSubTaskInput.parse(patch); + await updateSubTask(id, parsed); + } +} diff --git a/src/handoff/in-process-transport.ts b/src/handoff/in-process-transport.ts new file mode 100644 index 0000000..5d2850c --- /dev/null +++ b/src/handoff/in-process-transport.ts @@ -0,0 +1,122 @@ +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; +import type { SisterTransport, HealthStatus } from "./transport.js"; +import { HandoffMessage, type InvokeRequest } from "./message.js"; +import { DirectRailsClient } from "./direct-rails-client.js"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "in-process-transport" }); + +export interface InProcessTransportOptions { + /** Agent identity — harang / narang / darang / erang (or any custom name). */ + agentName: string; + /** + * Rails API URL (loopback). The embedded sister-agent core uses this to + * report sub-task events back via HTTP. Typically "http://127.0.0.1:". + */ + railsApiUrl: string; + /** + * Absolute path to sister-agent's compiled core.js. Defaults to + * env SISTER_AGENT_CORE_PATH + * or /sister-agent/dist/core.js + */ + coreModulePath?: string; + timeoutMs?: number; +} + +interface SisterCore { + executeInvocation: (req: unknown, rails: unknown) => Promise; +} + +let cachedCore: Promise | null = null; + +function loadCore(modulePath: string): Promise { + if (!cachedCore) { + const url = pathToFileURL(resolve(modulePath)).href; + cachedCore = import(url).then((mod: unknown) => { + const m = mod as Partial; + if (typeof m.executeInvocation !== "function") { + throw new Error( + `sister-agent core module at ${modulePath} is missing executeInvocation export`, + ); + } + return m as SisterCore; + }); + } + return cachedCore; +} + +/** + * InProcessTransport — runs sister-agent logic inside the same Node process + * as rails. Used for single-host, zero-config deployments where spinning up + * 4 separate LXCs is overkill. + * + * Under the hood it dynamically imports sister-agent/dist/core.js and calls + * executeInvocation() directly. Sub-task events still flow through the rails + * HTTP API (loopback) so the observability surface is identical to the + * distributed HTTP transport. + */ +export class InProcessTransport implements SisterTransport { + readonly name: string; + private readonly opts: Required; + + constructor(opts: InProcessTransportOptions) { + this.name = `in-process:${opts.agentName}`; + this.opts = { + agentName: opts.agentName, + railsApiUrl: opts.railsApiUrl, + coreModulePath: + opts.coreModulePath ?? + process.env["SISTER_AGENT_CORE_PATH"] ?? + resolve(process.cwd(), "sister-agent/dist/core.js"), + timeoutMs: opts.timeoutMs ?? 600_000, + }; + } + + async invoke( + req: InvokeRequest, + signal?: AbortSignal, + ): Promise { + const core = await loadCore(this.opts.coreModulePath); + // Use a direct-DB client — skipping HTTP loopback entirely. + const rails = new DirectRailsClient(); + + const payload = { + ...req, + agentName: this.opts.agentName, + railsApiUrl: this.opts.railsApiUrl, + timeoutMs: req.timeoutMs || this.opts.timeoutMs, + }; + + log.info( + { agent: this.opts.agentName, stage: req.stage, pipelineId: req.pipelineId }, + "in-process invoke start", + ); + + // The caller's AbortSignal is honored indirectly — executeInvocation + // itself does not take a signal today, but if it hangs the outer pipeline + // timeout will bubble up through the FSM. + void signal; + + const raw = await core.executeInvocation(payload, rails); + return HandoffMessage.parse(raw); + } + + async health(): Promise { + try { + await loadCore(this.opts.coreModulePath); + return { alive: true, latencyMs: 0, message: "ok" }; + } catch (err) { + return { + alive: false, + latencyMs: 0, + message: err instanceof Error ? err.message : String(err), + }; + } + } + + async close(): Promise { + // Nothing to clean up — the module import is cached for the lifetime + // of the process. + } +} diff --git a/tests/transport-build.test.ts b/tests/transport-build.test.ts new file mode 100644 index 0000000..a2c0014 --- /dev/null +++ b/tests/transport-build.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { buildTransports } from "../src/handoff/build.js"; +import { RailsConfig } from "../src/config/schema.js"; + +const baseConfig = (overrides: Partial<{ + transport: string; + endpoint: string; + agentName: string; +}>) => + RailsConfig.parse({ + pipeline: { stages: ["plan", "implement", "review", "deploy"] }, + agents: { + plan: { + role: "plan", + agentName: overrides.agentName ?? "harang", + transport: overrides.transport ?? "mock", + endpoint: overrides.endpoint ?? "", + }, + implement: { + role: "implement", + agentName: "narang", + transport: overrides.transport ?? "mock", + endpoint: overrides.endpoint ?? "", + }, + review: { + role: "review", + agentName: "darang", + transport: overrides.transport ?? "mock", + endpoint: overrides.endpoint ?? "", + }, + deploy: { + role: "deploy", + agentName: "erang", + transport: overrides.transport ?? "mock", + endpoint: overrides.endpoint ?? "", + }, + }, + }); + +const envKeys = [ + "RAILS_TRANSPORT", + "RAILS_TRANSPORT_MODE", + "RAILS_TRANSPORT_PLAN", + "RAILS_TRANSPORT_IMPLEMENT", + "RAILS_TRANSPORT_REVIEW", + "RAILS_TRANSPORT_DEPLOY", + "SISTER_ENDPOINT_PLAN", + "SISTER_ENDPOINT_IMPLEMENT", + "SISTER_ENDPOINT_REVIEW", + "SISTER_ENDPOINT_DEPLOY", + "RAILS_AGENT_PLAN_HOST", + "RAILS_AGENT_IMPLEMENT_HOST", +]; + +describe("transport builder", () => { + const saved: Record = {}; + + beforeEach(() => { + for (const k of envKeys) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("defaults to mock when nothing is set", () => { + const config = baseConfig({ transport: "mock" }); + const map = buildTransports(config); + expect(map.size).toBe(4); + for (const [, t] of map) expect(t.name).toBe("mock"); + }); + + it("RAILS_TRANSPORT=in-process wires in-process transport everywhere", () => { + process.env["RAILS_TRANSPORT"] = "in-process"; + const config = baseConfig({ transport: "mock" }); + const map = buildTransports(config); + expect(map.get("plan")?.name).toBe("in-process:harang"); + expect(map.get("implement")?.name).toBe("in-process:narang"); + expect(map.get("review")?.name).toBe("in-process:darang"); + expect(map.get("deploy")?.name).toBe("in-process:erang"); + }); + + it("per-stage RAILS_TRANSPORT_REVIEW overrides global", () => { + process.env["RAILS_TRANSPORT"] = "in-process"; + process.env["RAILS_TRANSPORT_REVIEW"] = "mock"; + const config = baseConfig({ transport: "mock" }); + const map = buildTransports(config); + expect(map.get("plan")?.name).toBe("in-process:harang"); + expect(map.get("review")?.name).toBe("mock"); + }); + + it("http transport falls back to mock when no endpoint is configured", () => { + process.env["RAILS_TRANSPORT"] = "http"; + const config = baseConfig({ transport: "mock" }); + const map = buildTransports(config); + // Without endpoints, everything falls back to mock + for (const [, t] of map) expect(t.name).toBe("mock"); + }); + + it("http transport honors SISTER_ENDPOINT_* env", () => { + process.env["RAILS_TRANSPORT"] = "http"; + process.env["SISTER_ENDPOINT_PLAN"] = "http://plan.local:18801"; + process.env["SISTER_ENDPOINT_IMPLEMENT"] = "http://impl.local:18801"; + process.env["SISTER_ENDPOINT_REVIEW"] = "http://rev.local:18801"; + process.env["SISTER_ENDPOINT_DEPLOY"] = "http://dep.local:18801"; + const config = baseConfig({ transport: "mock" }); + const map = buildTransports(config); + expect(map.get("plan")?.name).toBe("http:harang"); + expect(map.get("deploy")?.name).toBe("http:erang"); + }); + + it("legacy RAILS_TRANSPORT_MODE=http + RAILS_AGENT_*_HOST still works", () => { + process.env["RAILS_TRANSPORT_MODE"] = "http"; + process.env["RAILS_AGENT_PLAN_HOST"] = "10.0.0.1"; + process.env["RAILS_AGENT_IMPLEMENT_HOST"] = "10.0.0.2"; + const config = baseConfig({ transport: "mock" }); + const map = buildTransports(config); + expect(map.get("plan")?.name).toBe("http:harang"); + expect(map.get("implement")?.name).toBe("http:narang"); + // review/deploy have no host → fall back to mock + expect(map.get("review")?.name).toBe("mock"); + }); +});