Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Users enter a product idea in plain English, and the system coordinates multiple

Currently, this repository contains the **Core Backend & Orchestration Layer** (v1), which features a clean provider abstraction, strict Zod validation, and real-time Server-Sent Events (SSE) streaming for agent progress.

The backend now runs on **real OpenRouter provider calls** with per-agent token optimization (input compression, output caps, and budget guardrails).

---

## 🚀 Tech Stack & Tools Needed
Expand Down Expand Up @@ -92,10 +94,73 @@ apps/
web/ # Frontend Web App (React/Next.js stub)

packages/
agents/ # Core Orchestration, 6 Subagents, and LLM Provider mock
agents/ # Core Orchestration, 6 Subagents, OpenRouter provider, token optimizer
shared/ # Zod Schemas, Constant Enums, and TS Contract Types
ui/ # Reusable UI primitives stub
config/ # ESLint/TSConfig stubs
```

---

## ⚙️ OpenRouter Runtime Configuration

Set these variables in `apps/api/.env`:

```bash
OPENROUTER_API_KEY=your_key_here
OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions
OPENROUTER_APP_NAME=stackforge-api
OPENROUTER_APP_URL=http://localhost:3001
```

---

## 📉 Token Tuning Guide

Per-agent tuning lives in `packages/agents/src/config/agent.configs.ts`.

- `maxInputTokens`: hard input cap used by optimizer compression.
- `maxOutputTokens`: maximum completion tokens requested from provider.
- `minOutputTokens`: minimum output budget required after compression.
- `tokenBudget`: total budget target used to derive dynamic output caps.
- `compressionLevel`: default compression aggressiveness (`low` / `medium` / `high`).
- `budgetOverflowRetries`: number of extra compression passes before fail-fast.

**Suggested workflow:**
1. Run 3–5 representative prompts.
2. Inspect per-agent SSE `agent_completed` telemetry.
3. Lower `maxInputTokens` or raise `compressionLevel` for agents with high `inputTokens`.
4. Lower `maxOutputTokens` for agents with consistently low `outputTokens`.
5. Raise `minOutputTokens` only if quality drops from over-compression.

---

## 📡 SSE Agent Telemetry

Each `agent_completed` event includes token and optimizer metrics:

```json
{
"type": "agent_completed",
"agent": "schema",
"payload": {
"durationMs": 842,
"cached": false,
"inputTokens": 612,
"outputTokens": 431,
"totalTokens": 1043,
"tokensUsed": 1043,
"estimatedInputTokens": 590,
"compressionPasses": 2,
"providerInputTokens": 612,
"providerOutputTokens": 431,
"model": "openai/gpt-4o-mini"
}
}
```

Per-job aggregates are available in REST responses:
- `GET /api/jobs` returns all jobs with `tokenUsage` summaries.
- `GET /api/jobs/:jobId` includes the same `tokenUsage` object for a single run.


4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
PORT=3001
NODE_ENV=development
OPENROUTER_API_KEY=
OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions
OPENROUTER_APP_NAME=stackforge-api
OPENROUTER_APP_URL=http://localhost:3001
19 changes: 18 additions & 1 deletion apps/api/src/controllers/jobs.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import type { Request, Response, NextFunction } from "express";
import { JobIdParamSchema, JOB_STATUS } from "@stackforge/shared";
import { getJob } from "../store/job.store.js";
import { getJob, listJobs, summarizeJobTokenUsage } from "../store/job.store.js";
import { subscribe, unsubscribe } from "../services/sse.service.js";

export function listJobsController(_req: Request, res: Response): void {
const jobs = listJobs().map((job) => ({
id: job.id,
status: job.status,
projectName: job.projectName,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
completedAt: job.completedAt,
agentsCompleted: job.agentsCompleted,
error: job.error,
tokenUsage: summarizeJobTokenUsage(job),
}));

res.json({ jobs });
}

export function getJobController(req: Request, res: Response, next: NextFunction): void {
const parsed = JobIdParamSchema.safeParse(req.params);
if (!parsed.success) {
Expand All @@ -26,6 +42,7 @@ export function getJobController(req: Request, res: Response, next: NextFunction
agentsCompleted: job.agentsCompleted,
error: job.error,
blueprint: job.blueprint,
tokenUsage: summarizeJobTokenUsage(job),
});
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Router, type IRouter } from "express";
import { generateController } from "../controllers/generate.controller.js";
import { getJobController, streamController } from "../controllers/jobs.controller.js";
import { listJobsController, getJobController, streamController } from "../controllers/jobs.controller.js";

const router: IRouter = Router();

router.post("/generate", generateController);
router.get("/jobs", listJobsController);
router.get("/jobs/:jobId", getJobController);
router.get("/stream/:jobId", streamController);

Expand Down
42 changes: 39 additions & 3 deletions apps/api/src/services/generate.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SSEEvent, AgentName } from "@stackforge/shared";

import { JOB_STATUS } from "@stackforge/shared";
import { MockProvider, AgentCache, runOrchestrator } from "@stackforge/agents";
import { OpenRouterProvider, AgentCache, runOrchestrator } from "@stackforge/agents";
import {
createJob,
getJob,
Expand All @@ -11,7 +11,36 @@ import {
} from "../store/job.store.js";
import { broadcast, closeJobClients } from "./sse.service.js";

const provider = new MockProvider();
function readEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.trim().length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}

function buildProvider(): OpenRouterProvider {
const endpoint = process.env["OPENROUTER_ENDPOINT"];
const options = {
apiKey: readEnv("OPENROUTER_API_KEY"),
appName: process.env["OPENROUTER_APP_NAME"] ?? "stackforge-api",
appUrl: process.env["OPENROUTER_APP_URL"] ?? "http://localhost",
...(endpoint !== undefined && endpoint.trim().length > 0 ? { endpoint } : {}),
};

return new OpenRouterProvider(options);
}

let provider: OpenRouterProvider | undefined;

function getProvider(): OpenRouterProvider {
if (provider === undefined) {
provider = buildProvider();
}

return provider;
}

const cache = new AgentCache();

function buildEmitter(jobId: string): (event: SSEEvent) => void {
Expand Down Expand Up @@ -41,7 +70,14 @@ async function startOrchestration(
try {
updateJob(jobId, { status: JOB_STATUS.RUNNING });

const blueprint = await runOrchestrator({ jobId, prompt, projectName, emit, provider, cache });
const blueprint = await runOrchestrator({
jobId,
prompt,
projectName,
emit,
provider: getProvider(),
cache,
});

const now = new Date().toISOString();
updateJob(jobId, { status: JOB_STATUS.COMPLETED, blueprint, completedAt: now });
Expand Down
67 changes: 66 additions & 1 deletion apps/api/src/store/job.store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { randomUUID } from "node:crypto";
import type { Blueprint, SSEEvent, AgentName } from "@stackforge/shared";
import type {
Blueprint,
SSEEvent,
AgentName,
AgentCompletedEvent,
} from "@stackforge/shared";
import { JOB_STATUS } from "@stackforge/shared";

export type StoredJob = {
Expand All @@ -18,6 +23,62 @@ export type StoredJob = {

const store = new Map<string, StoredJob>();

export type JobTokenUsage = {
totalTokens: number;
inputTokens: number;
outputTokens: number;
completionEvents: number;
byAgent: Partial<Record<AgentName, {
totalTokens: number;
inputTokens: number;
outputTokens: number;
count: number;
}>>;
};

function isAgentCompletedEvent(event: SSEEvent): event is AgentCompletedEvent {
return event.type === "agent_completed";
}

export function summarizeJobTokenUsage(job: StoredJob): JobTokenUsage {
const initial: JobTokenUsage = {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
completionEvents: 0,
byAgent: {},
};

for (const event of job.events) {
if (!isAgentCompletedEvent(event)) {
continue;
}

initial.totalTokens += event.payload.totalTokens;
initial.inputTokens += event.payload.inputTokens;
initial.outputTokens += event.payload.outputTokens;
initial.completionEvents += 1;

const agent = event.agent as AgentName;

const existing = initial.byAgent[agent] ?? {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
count: 0,
};

initial.byAgent[agent] = {
totalTokens: existing.totalTokens + event.payload.totalTokens,
inputTokens: existing.inputTokens + event.payload.inputTokens,
outputTokens: existing.outputTokens + event.payload.outputTokens,
count: existing.count + 1,
};
}

return initial;
}

export function createJob(prompt: string, projectName: string): StoredJob {
const now = new Date().toISOString();
const job: StoredJob = {
Expand All @@ -38,6 +99,10 @@ export function getJob(id: string): StoredJob | undefined {
return store.get(id);
}

export function listJobs(): StoredJob[] {
return [...store.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

export function updateJob(
id: string,
patch: Partial<Omit<StoredJob, "id" | "createdAt" | "events">>,
Expand Down
13 changes: 12 additions & 1 deletion apps/api/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ describe("StackForge API Integration", () => {
expect(jobRes.status).toBe(200);
const jobData = await jobRes.json();
expect(jobData.id).toBe(data.jobId);
expect(["queued", "running", "completed"]).toContain(jobData.status);
expect(["queued", "running", "completed", "failed"]).toContain(jobData.status);
expect(jobData.tokenUsage).toBeDefined();
expect(typeof jobData.tokenUsage.totalTokens).toBe("number");

const jobsRes = await fetch(`${baseUrl}/api/jobs`);
expect(jobsRes.status).toBe(200);
const jobsData = await jobsRes.json();
expect(Array.isArray(jobsData.jobs)).toBe(true);
const createdJob = jobsData.jobs.find((job: { id: string }) => job.id === data.jobId);
expect(createdJob).toBeDefined();
expect(createdJob.tokenUsage).toBeDefined();
expect(typeof createdJob.tokenUsage.totalTokens).toBe("number");
});
});
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@stackforge/shared": "workspace:*"
},
"devDependencies": {
"@types/bun": "^1.3.11",
"@types/node": "^22.13.10",
"typescript": "^5.7.3"
}
Expand Down
Loading
Loading