diff --git a/README.md b/README.md index 6f42123..730ae9f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. + diff --git a/apps/api/.env.example b/apps/api/.env.example index 9e6ca71..36fdf67 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/src/controllers/jobs.controller.ts b/apps/api/src/controllers/jobs.controller.ts index 4767780..dd326ea 100644 --- a/apps/api/src/controllers/jobs.controller.ts +++ b/apps/api/src/controllers/jobs.controller.ts @@ -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) { @@ -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), }); } diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 8bed2a8..04661ac 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -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); diff --git a/apps/api/src/services/generate.service.ts b/apps/api/src/services/generate.service.ts index e172ad9..e95c08a 100644 --- a/apps/api/src/services/generate.service.ts +++ b/apps/api/src/services/generate.service.ts @@ -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, @@ -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 { @@ -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 }); diff --git a/apps/api/src/store/job.store.ts b/apps/api/src/store/job.store.ts index 196b495..2966786 100644 --- a/apps/api/src/store/job.store.ts +++ b/apps/api/src/store/job.store.ts @@ -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 = { @@ -18,6 +23,62 @@ export type StoredJob = { const store = new Map(); +export type JobTokenUsage = { + totalTokens: number; + inputTokens: number; + outputTokens: number; + completionEvents: number; + byAgent: Partial>; +}; + +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 = { @@ -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>, diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts index ecdca6f..d96ff26 100644 --- a/apps/api/test/integration.test.ts +++ b/apps/api/test/integration.test.ts @@ -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"); }); }); diff --git a/bun.lock b/bun.lock index ecf6e38..28b0d63 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "@stackforge/shared": "workspace:*", }, "devDependencies": { + "@types/bun": "^1.3.11", "@types/node": "^22.13.10", "typescript": "^5.7.3", }, diff --git a/packages/agents/package.json b/packages/agents/package.json index 368d8b6..4397514 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -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" } diff --git a/packages/agents/src/agents/base.agent.ts b/packages/agents/src/agents/base.agent.ts index bed1fe5..c05ebce 100644 --- a/packages/agents/src/agents/base.agent.ts +++ b/packages/agents/src/agents/base.agent.ts @@ -1,6 +1,8 @@ import type { AgentName } from "@stackforge/shared"; import type { LLMProvider } from "../provider/provider.interface.js"; import type { AgentCache } from "../cache/agent.cache.js"; +import { optimizeAgentPayload } from "../optimizer/token.optimizer.js"; +import { AgentOutputSchemas } from "./output.schemas.js"; export type AgentRunResult = { agentName: AgentName; @@ -8,6 +10,14 @@ export type AgentRunResult = { cached: boolean; durationMs: number; tokensUsed: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; + estimatedInputTokens: number; + compressionPasses: number; + providerInputTokens: number; + providerOutputTokens: number; + model: string; }; export async function runAgent( @@ -26,20 +36,50 @@ export async function runAgent( cached: true, durationMs: 0, tokensUsed: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + estimatedInputTokens: 0, + compressionPasses: 0, + providerInputTokens: 0, + providerOutputTokens: 0, + model: "cache", }; } const start = Date.now(); - const response = await provider.call({ agentName, input }); + const optimized = optimizeAgentPayload(agentName, input); + const response = await provider.call({ + agentName, + input: optimized.optimizedInput, + options: { + systemPrompt: optimized.systemPrompt, + userPrompt: optimized.userPrompt, + model: optimized.model, + maxInputTokens: optimized.maxInputTokens, + maxOutputTokens: optimized.maxOutputTokens, + temperature: optimized.temperature, + }, + }); const durationMs = Date.now() - start; + const schema = AgentOutputSchemas[agentName]; + const validatedOutput = schema.parse(response.output); - cache.set(cacheKey, response.output); + cache.set(cacheKey, validatedOutput); return { agentName, - output: response.output as TOutput, + output: validatedOutput as TOutput, cached: false, durationMs, tokensUsed: response.tokensUsed, + inputTokens: response.inputTokens ?? optimized.estimatedInputTokens, + outputTokens: response.outputTokens ?? optimized.maxOutputTokens, + totalTokens: response.tokensUsed, + estimatedInputTokens: optimized.estimatedInputTokens, + compressionPasses: optimized.compressionPasses, + providerInputTokens: response.inputTokens ?? optimized.estimatedInputTokens, + providerOutputTokens: response.outputTokens ?? optimized.maxOutputTokens, + model: response.model ?? optimized.model, }; } diff --git a/packages/agents/src/agents/output.schemas.ts b/packages/agents/src/agents/output.schemas.ts new file mode 100644 index 0000000..50db99b --- /dev/null +++ b/packages/agents/src/agents/output.schemas.ts @@ -0,0 +1,38 @@ +import { BlueprintSchema } from "@stackforge/shared"; + +export const PlannerOutputSchema = BlueprintSchema.pick({ + projectName: true, + stack: true, + folderStructure: true, +}); + +export const SchemaOutputSchema = BlueprintSchema.pick({ + entities: true, + relationships: true, +}); + +export const ApiOutputSchema = BlueprintSchema.pick({ + routePlan: true, +}); + +export const FrontendOutputSchema = BlueprintSchema.pick({ + frontendPages: true, +}); + +export const DevopsOutputSchema = BlueprintSchema.pick({ + infraPlan: true, + generatedFilesPlan: true, +}); + +export const ReviewerOutputSchema = BlueprintSchema.pick({ + reviewerNotes: true, +}); + +export const AgentOutputSchemas = { + planner: PlannerOutputSchema, + schema: SchemaOutputSchema, + api: ApiOutputSchema, + frontend: FrontendOutputSchema, + devops: DevopsOutputSchema, + reviewer: ReviewerOutputSchema, +} as const; diff --git a/packages/agents/src/agents/prompts/index.ts b/packages/agents/src/agents/prompts/index.ts new file mode 100644 index 0000000..58c5e57 --- /dev/null +++ b/packages/agents/src/agents/prompts/index.ts @@ -0,0 +1,70 @@ +import type { AgentName } from "@stackforge/shared"; + +export type AgentPrompt = { + systemPrompt: string; + userPrompt: string; +}; + +function stringify(value: unknown): string { + return JSON.stringify(value); +} + +function pick(input: unknown, keys: string[]): Record { + if (input === null || typeof input !== "object") { + return { input }; + } + + const source = input as Record; + const out: Record = {}; + for (const key of keys) { + if (key in source) { + out[key] = source[key]; + } + } + + return out; +} + +const SYSTEM_INSTRUCTIONS: Record = { + planner: + "You are the planner agent. Create a concise project foundation. Respond with strict JSON only: { projectName, stack, folderStructure }.", + schema: + "You are the schema agent. Design normalized entities and relationships. Respond with strict JSON only: { entities, relationships }.", + api: + "You are the api agent. Plan minimal complete REST routes with auth metadata. Respond with strict JSON only: { routePlan }.", + frontend: + "You are the frontend agent. Plan pages and component groupings mapped to routes. Respond with strict JSON only: { frontendPages }.", + devops: + "You are the devops agent. Plan deployable infra, env vars, and generated files. Respond with strict JSON only: { infraPlan, generatedFilesPlan }.", + reviewer: + "You are the reviewer agent. Identify consistency, reliability, and security issues. Respond with strict JSON only: { reviewerNotes }.", +}; + +const CONTEXT_KEYS: Record = { + planner: ["prompt", "projectName"], + schema: ["prompt", "projectName", "stack"], + api: ["prompt", "entities", "stack"], + frontend: ["prompt", "entities", "routePlan", "stack"], + devops: ["prompt", "stack", "entities"], + reviewer: [ + "prompt", + "projectName", + "stack", + "entities", + "relationships", + "routePlan", + "frontendPages", + "infraPlan", + "generatedFilesPlan", + ], +}; + +export function buildAgentPrompt(agentName: AgentName, input: unknown): AgentPrompt { + const context = pick(input, CONTEXT_KEYS[agentName]); + + return { + systemPrompt: + `${SYSTEM_INSTRUCTIONS[agentName]} Never return markdown, code fences, comments, or extra keys.`, + userPrompt: stringify(context), + }; +} diff --git a/packages/agents/src/config/agent.configs.ts b/packages/agents/src/config/agent.configs.ts index 3939c87..cdab868 100644 --- a/packages/agents/src/config/agent.configs.ts +++ b/packages/agents/src/config/agent.configs.ts @@ -5,36 +5,78 @@ export const AGENT_CONFIGS: Record = { name: "planner", description: "Resolves stack, project name, and top-level folder structure", tokenBudget: 1000, + model: "openai/gpt-4o-mini", + maxInputTokens: 900, + maxOutputTokens: 700, + minOutputTokens: 220, + temperature: 0.2, + compressionLevel: "high", + budgetOverflowRetries: 2, retries: 1, }, schema: { name: "schema", description: "Designs entities, fields, indexes, and relationships", tokenBudget: 1500, + model: "openai/gpt-4o-mini", + maxInputTokens: 1200, + maxOutputTokens: 1100, + minOutputTokens: 320, + temperature: 0.1, + compressionLevel: "medium", + budgetOverflowRetries: 2, retries: 1, }, api: { name: "api", description: "Plans REST routes, request/response shapes, and auth requirements", tokenBudget: 1200, + model: "openai/gpt-4o-mini", + maxInputTokens: 950, + maxOutputTokens: 900, + minOutputTokens: 280, + temperature: 0.1, + compressionLevel: "medium", + budgetOverflowRetries: 2, retries: 1, }, frontend: { name: "frontend", description: "Plans pages, React component trees, and routing", tokenBudget: 1200, + model: "openai/gpt-4o-mini", + maxInputTokens: 900, + maxOutputTokens: 900, + minOutputTokens: 250, + temperature: 0.2, + compressionLevel: "high", + budgetOverflowRetries: 2, retries: 1, }, devops: { name: "devops", description: "Plans CI/CD pipelines, deployment targets, Docker, and env vars", tokenBudget: 800, + model: "openai/gpt-4o-mini", + maxInputTokens: 700, + maxOutputTokens: 650, + minOutputTokens: 180, + temperature: 0.2, + compressionLevel: "high", + budgetOverflowRetries: 3, retries: 1, }, reviewer: { name: "reviewer", description: "Performs consistency checks and flags issues across the full blueprint", tokenBudget: 1000, + model: "openai/gpt-4o-mini", + maxInputTokens: 850, + maxOutputTokens: 700, + minOutputTokens: 220, + temperature: 0, + compressionLevel: "high", + budgetOverflowRetries: 2, retries: 1, }, }; diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 0ec2db4..b56a94c 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -1,6 +1,8 @@ // provider -export type { LLMProvider, ProviderCallInput, ProviderCallOutput } from "./provider/provider.interface.js"; +export type { LLMProvider, ProviderCallInput, ProviderCallOutput, ProviderCallOptions } from "./provider/provider.interface.js"; export { MockProvider } from "./provider/mock.provider.js"; +export { OpenRouterProvider } from "./provider/openrouter.provider.js"; +export type { OpenRouterProviderOptions } from "./provider/openrouter.provider.js"; // cache export { AgentCache } from "./cache/agent.cache.js"; @@ -8,6 +10,9 @@ export { AgentCache } from "./cache/agent.cache.js"; // config export { AGENT_CONFIGS } from "./config/agent.configs.js"; +// optimizer +export { optimizeAgentPayload } from "./optimizer/token.optimizer.js"; + // base export type { AgentRunResult } from "./agents/base.agent.js"; diff --git a/packages/agents/src/optimizer/token.optimizer.ts b/packages/agents/src/optimizer/token.optimizer.ts new file mode 100644 index 0000000..eee5b09 --- /dev/null +++ b/packages/agents/src/optimizer/token.optimizer.ts @@ -0,0 +1,184 @@ +import type { AgentName } from "@stackforge/shared"; +import { AGENT_CONFIGS } from "../config/agent.configs.js"; +import { buildAgentPrompt } from "../agents/prompts/index.js"; + +export type OptimizedAgentPayload = { + optimizedInput: unknown; + systemPrompt: string; + userPrompt: string; + model: string; + maxInputTokens: number; + maxOutputTokens: number; + temperature: number; + estimatedInputTokens: number; + compressionPasses: number; +}; + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function trimPromptValue(value: unknown, maxChars: number): unknown { + if (typeof value !== "string") { + return value; + } + + if (value.length <= maxChars) { + return value; + } + + return value.slice(0, maxChars); +} + +type CompressionPlan = { + promptRatio: number; + genericStringRatio: number; + arrayLimit: number; +}; + +function buildCompressionPlan( + level: "low" | "medium" | "high", + pass: number, +): CompressionPlan { + const presets: Record<"low" | "medium" | "high", CompressionPlan> = { + low: { promptRatio: 0.9, genericStringRatio: 0.8, arrayLimit: 24 }, + medium: { promptRatio: 0.75, genericStringRatio: 0.6, arrayLimit: 16 }, + high: { promptRatio: 0.6, genericStringRatio: 0.4, arrayLimit: 10 }, + }; + + const base = presets[level]; + const adjustment = pass * 0.12; + return { + promptRatio: Math.max(0.2, base.promptRatio - adjustment), + genericStringRatio: Math.max(0.15, base.genericStringRatio - adjustment), + arrayLimit: Math.max(3, base.arrayLimit - pass * 4), + }; +} + +function clampJsonSize(serialized: string, maxInputTokens: number): string { + const maxChars = maxInputTokens * 4; + return serialized.length <= maxChars ? serialized : serialized.slice(0, maxChars); +} + +function compressLargeCollections( + value: Record, + arrayLimit: number, +): Record { + const keys = [ + "entities", + "relationships", + "routePlan", + "frontendPages", + "generatedFilesPlan", + "folderStructure", + "reviewerNotes", + "ci", + "deployment", + "envVars", + ]; + + const copy = { ...value }; + for (const key of keys) { + const current = copy[key]; + if (Array.isArray(current) && current.length > arrayLimit) { + copy[key] = current.slice(0, arrayLimit); + } + } + + return copy; +} + +function compressInput(input: unknown, maxInputTokens: number, plan: CompressionPlan): unknown { + const maxChars = maxInputTokens * 4; + + if (input === null || typeof input !== "object") { + return trimPromptValue(input, maxChars); + } + + let shallow = { ...(input as Record) }; + shallow = compressLargeCollections(shallow, plan.arrayLimit); + + if ("prompt" in shallow) { + shallow["prompt"] = trimPromptValue(shallow["prompt"], Math.floor(maxChars * plan.promptRatio)); + } + + for (const [key, value] of Object.entries(shallow)) { + if (key !== "prompt") { + shallow[key] = trimPromptValue(value, Math.floor(maxChars * plan.genericStringRatio)); + } + } + + let serialized = JSON.stringify(shallow); + if (serialized.length <= maxChars) { + return shallow; + } + + if ("prompt" in shallow) { + const prompt = shallow["prompt"]; + if (typeof prompt === "string") { + shallow["prompt"] = trimPromptValue(prompt, Math.floor(maxChars * Math.max(0.2, plan.promptRatio - 0.2))); + } + } + + serialized = JSON.stringify(shallow); + if (serialized.length <= maxChars) { + return shallow; + } + + return { prompt: clampJsonSize(serialized, maxInputTokens) }; +} + +export function optimizeAgentPayload(agentName: AgentName, input: unknown): OptimizedAgentPayload { + const config = AGENT_CONFIGS[agentName]; + const attempts = Math.max(1, config.budgetOverflowRetries + 1); + + let selectedInput: unknown = input; + let selectedPrompt = JSON.stringify(input); + let estimatedInputTokens = estimateTokens(selectedPrompt); + let selectedPass = 1; + + for (let pass = 0; pass < attempts; pass++) { + const plan = buildCompressionPlan(config.compressionLevel, pass); + const compressedInput = compressInput(input, config.maxInputTokens, plan); + const compactInput = clampJsonSize(JSON.stringify(compressedInput), config.maxInputTokens); + const tokenEstimate = estimateTokens(compactInput); + + selectedInput = compressedInput; + selectedPrompt = compactInput; + estimatedInputTokens = tokenEstimate; + selectedPass = pass + 1; + + if (tokenEstimate <= config.maxInputTokens) { + break; + } + } + + if (estimatedInputTokens > config.maxInputTokens) { + throw new Error( + `Token budget exceeded for agent '${agentName}' after ${attempts} compression attempts`, + ); + } + + const remainingBudget = config.tokenBudget - estimatedInputTokens; + if (remainingBudget < config.minOutputTokens) { + throw new Error( + `Insufficient output token budget for agent '${agentName}' after compression`, + ); + } + + const cappedOutputTokens = Math.min(config.maxOutputTokens, remainingBudget); + + const prompt = buildAgentPrompt(agentName, selectedInput); + + return { + optimizedInput: selectedInput, + systemPrompt: prompt.systemPrompt, + userPrompt: prompt.userPrompt, + model: config.model, + maxInputTokens: config.maxInputTokens, + maxOutputTokens: cappedOutputTokens, + temperature: config.temperature, + estimatedInputTokens, + compressionPasses: selectedPass, + }; +} diff --git a/packages/agents/src/orchestrator/orchestrator.service.ts b/packages/agents/src/orchestrator/orchestrator.service.ts index 69fdf59..f15df3d 100644 --- a/packages/agents/src/orchestrator/orchestrator.service.ts +++ b/packages/agents/src/orchestrator/orchestrator.service.ts @@ -30,8 +30,40 @@ function agentStarted(jobId: string, agent: AgentName): SSEEvent { return { type: "agent_started", jobId, agent, timestamp: now(), payload: {} }; } -function agentCompleted(jobId: string, agent: AgentName, durationMs: number, cached: boolean): SSEEvent { - return { type: "agent_completed", jobId, agent, timestamp: now(), payload: { durationMs, cached } }; +function agentCompleted( + jobId: string, + agent: AgentName, + durationMs: number, + cached: boolean, + inputTokens: number, + outputTokens: number, + totalTokens: number, + tokensUsed: number, + estimatedInputTokens: number, + compressionPasses: number, + providerInputTokens: number, + providerOutputTokens: number, + model: string, +): SSEEvent { + return { + type: "agent_completed", + jobId, + agent, + timestamp: now(), + payload: { + durationMs, + cached, + inputTokens, + outputTokens, + totalTokens, + tokensUsed, + estimatedInputTokens, + compressionPasses, + providerInputTokens, + providerOutputTokens, + model, + }, + }; } function agentFailed(jobId: string, agent: AgentName, error: string): SSEEvent { @@ -42,12 +74,41 @@ async function runWithEmit( jobId: string, agentName: AgentName, emit: (event: SSEEvent) => void, - fn: () => Promise<{ output: T; cached: boolean; durationMs: number }>, + fn: () => Promise<{ + output: T; + cached: boolean; + durationMs: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; + tokensUsed: number; + estimatedInputTokens: number; + compressionPasses: number; + providerInputTokens: number; + providerOutputTokens: number; + model: string; + }>, ): Promise { emit(agentStarted(jobId, agentName)); try { const result = await fn(); - emit(agentCompleted(jobId, agentName, result.durationMs, result.cached)); + emit( + agentCompleted( + jobId, + agentName, + result.durationMs, + result.cached, + result.inputTokens, + result.outputTokens, + result.totalTokens, + result.tokensUsed, + result.estimatedInputTokens, + result.compressionPasses, + result.providerInputTokens, + result.providerOutputTokens, + result.model, + ), + ); return result.output; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/packages/agents/src/provider/mock.provider.ts b/packages/agents/src/provider/mock.provider.ts index da7b735..1445647 100644 --- a/packages/agents/src/provider/mock.provider.ts +++ b/packages/agents/src/provider/mock.provider.ts @@ -59,16 +59,22 @@ function dispatch(agentName: AgentName, input: unknown): unknown { export class MockProvider implements LLMProvider { readonly name = "mock"; - async call({ agentName, input }: ProviderCallInput): Promise { + async call({ agentName, input, options }: ProviderCallInput): Promise { const [min, max] = SIMULATED_DELAY_MS[agentName]; const start = Date.now(); await simulate(min, max); const durationMs = Date.now() - start; + const inputTokens = Math.ceil(options.userPrompt.length / 4); + const outputTokens = SIMULATED_TOKENS[agentName]; + return { output: dispatch(agentName, input), - tokensUsed: SIMULATED_TOKENS[agentName], + tokensUsed: inputTokens + outputTokens, durationMs, + inputTokens, + outputTokens, + model: options.model, }; } } diff --git a/packages/agents/src/provider/openrouter.provider.ts b/packages/agents/src/provider/openrouter.provider.ts new file mode 100644 index 0000000..dc1008f --- /dev/null +++ b/packages/agents/src/provider/openrouter.provider.ts @@ -0,0 +1,119 @@ +import type { LLMProvider, ProviderCallInput, ProviderCallOutput } from "./provider.interface.js"; + +export type OpenRouterProviderOptions = { + apiKey: string; + endpoint?: string; + appName?: string; + appUrl?: string; +}; + +type OpenRouterResponse = { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + model?: string; + error?: { + message?: string; + }; +}; + +function stripCodeFences(raw: string): string { + const trimmed = raw.trim(); + + if (!trimmed.startsWith("```")) { + return trimmed; + } + + const lines = trimmed.split("\n"); + const body = lines.slice(1, -1).join("\n"); + return body.trim(); +} + +function parseJsonContent(content: string): unknown { + const cleaned = stripCodeFences(content); + return JSON.parse(cleaned); +} + +export class OpenRouterProvider implements LLMProvider { + readonly name = "openrouter"; + private readonly apiKey: string; + private readonly endpoint: string; + private readonly appName: string; + private readonly appUrl: string; + + constructor(options: OpenRouterProviderOptions) { + this.apiKey = options.apiKey; + this.endpoint = options.endpoint ?? "https://openrouter.ai/api/v1/chat/completions"; + this.appName = options.appName ?? "stackforge"; + this.appUrl = options.appUrl ?? "http://localhost"; + } + + async call({ options }: ProviderCallInput): Promise { + const startedAt = Date.now(); + + const response = await fetch(this.endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": this.appUrl, + "X-Title": this.appName, + }, + body: JSON.stringify({ + model: options.model, + messages: [ + { role: "system", content: options.systemPrompt }, + { role: "user", content: options.userPrompt }, + ], + max_tokens: options.maxOutputTokens, + temperature: options.temperature, + response_format: { type: "json_object" }, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`OpenRouter request failed (${response.status}): ${errorBody}`); + } + + const data = (await response.json()) as OpenRouterResponse; + + if (data.error?.message !== undefined) { + throw new Error(`OpenRouter error: ${data.error.message}`); + } + + const content = data.choices?.[0]?.message?.content; + if (content === undefined || content.length === 0) { + throw new Error("OpenRouter response did not include message content"); + } + + let output: unknown; + try { + output = parseJsonContent(content); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`OpenRouter returned non-JSON content: ${msg}`); + } + + const inputTokens = data.usage?.prompt_tokens; + const outputTokens = data.usage?.completion_tokens; + const totalTokens = data.usage?.total_tokens + ?? (inputTokens ?? 0) + (outputTokens ?? 0); + + return { + output, + tokensUsed: totalTokens, + durationMs: Date.now() - startedAt, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + model: data.model ?? options.model, + }; + } +} diff --git a/packages/agents/src/provider/provider.interface.ts b/packages/agents/src/provider/provider.interface.ts index e73f58b..ae53402 100644 --- a/packages/agents/src/provider/provider.interface.ts +++ b/packages/agents/src/provider/provider.interface.ts @@ -1,14 +1,27 @@ import type { AgentName } from "@stackforge/shared"; +export type ProviderCallOptions = { + systemPrompt: string; + userPrompt: string; + model: string; + maxInputTokens: number; + maxOutputTokens: number; + temperature: number; +}; + export type ProviderCallInput = { agentName: AgentName; input: unknown; + options: ProviderCallOptions; }; export type ProviderCallOutput = { output: unknown; tokensUsed: number; durationMs: number; + inputTokens?: number; + outputTokens?: number; + model?: string; }; export interface LLMProvider { diff --git a/packages/agents/test/openrouter-provider.test.ts b/packages/agents/test/openrouter-provider.test.ts new file mode 100644 index 0000000..9148ce3 --- /dev/null +++ b/packages/agents/test/openrouter-provider.test.ts @@ -0,0 +1,71 @@ +/// +import { afterEach, describe, expect, it } from "bun:test"; +import { OpenRouterProvider } from "../src/provider/openrouter.provider.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("OpenRouterProvider", () => { + it("parses JSON response and exposes usage metrics", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + choices: [{ message: { content: '{"projectName":"acme"}' } }], + usage: { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200 }, + model: "openai/gpt-4o-mini", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + + const provider = new OpenRouterProvider({ apiKey: "test-key" }); + + const result = await provider.call({ + agentName: "planner", + input: { prompt: "build app", projectName: "acme" }, + options: { + systemPrompt: "Return JSON", + userPrompt: "{\"prompt\":\"build app\"}", + model: "openai/gpt-4o-mini", + maxInputTokens: 300, + maxOutputTokens: 300, + temperature: 0.1, + }, + }); + + expect(result.output).toEqual({ projectName: "acme" }); + expect(result.inputTokens).toBe(120); + expect(result.outputTokens).toBe(80); + expect(result.tokensUsed).toBe(200); + expect(result.model).toBe("openai/gpt-4o-mini"); + }); + + it("throws on non-JSON model content", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + choices: [{ message: { content: "not-json" } }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + + const provider = new OpenRouterProvider({ apiKey: "test-key" }); + + await expect( + provider.call({ + agentName: "planner", + input: { prompt: "build app", projectName: "acme" }, + options: { + systemPrompt: "Return JSON", + userPrompt: "{}", + model: "openai/gpt-4o-mini", + maxInputTokens: 300, + maxOutputTokens: 300, + temperature: 0.1, + }, + }), + ).rejects.toThrow("OpenRouter returned non-JSON content"); + }); +}); diff --git a/packages/agents/test/orchestrator-failure.test.ts b/packages/agents/test/orchestrator-failure.test.ts new file mode 100644 index 0000000..4cab71a --- /dev/null +++ b/packages/agents/test/orchestrator-failure.test.ts @@ -0,0 +1,60 @@ +/// +import { describe, expect, it } from "bun:test"; +import type { ProviderCallInput, ProviderCallOutput } from "../src/provider/provider.interface.js"; +import type { LLMProvider } from "../src/provider/provider.interface.js"; +import { runOrchestrator } from "../src/orchestrator/orchestrator.service.js"; +import { AgentCache } from "../src/cache/agent.cache.js"; +import type { SSEEvent } from "@stackforge/shared"; + +class InvalidPlannerProvider implements LLMProvider { + readonly name = "invalid-planner"; + + async call({ agentName }: ProviderCallInput): Promise { + if (agentName === "planner") { + return { + output: { bad: true }, + tokensUsed: 42, + durationMs: 5, + inputTokens: 20, + outputTokens: 22, + model: "test-model", + }; + } + + return { + output: {}, + tokensUsed: 1, + durationMs: 1, + inputTokens: 1, + outputTokens: 0, + model: "test-model", + }; + } +} + +describe("Orchestrator failure propagation", () => { + it("emits agent_failed and rejects when agent output schema validation fails", async () => { + const events: SSEEvent[] = []; + const provider = new InvalidPlannerProvider(); + + await expect( + runOrchestrator({ + jobId: crypto.randomUUID(), + prompt: "Build a task management platform", + projectName: "taskflow", + emit: (event) => events.push(event), + provider, + cache: new AgentCache(), + }), + ).rejects.toBeDefined(); + + const started = events.find((event) => event.type === "agent_started" && event.agent === "planner"); + const failed = events.find((event) => event.type === "agent_failed" && event.agent === "planner"); + + expect(started).toBeDefined(); + expect(failed).toBeDefined(); + if (failed?.type === "agent_failed") { + expect(failed.payload.error.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/agents/test/token-optimizer.test.ts b/packages/agents/test/token-optimizer.test.ts new file mode 100644 index 0000000..045eb6a --- /dev/null +++ b/packages/agents/test/token-optimizer.test.ts @@ -0,0 +1,37 @@ +/// +import { describe, it, expect } from "bun:test"; +import { optimizeAgentPayload } from "../src/optimizer/token.optimizer.js"; +import { AGENT_CONFIGS } from "../src/config/agent.configs.js"; + +describe("Token optimizer", () => { + it("compresses oversized prompt input within configured input token limit", () => { + const veryLongPrompt = "Build enterprise app. ".repeat(3000); + + const result = optimizeAgentPayload("planner", { + prompt: veryLongPrompt, + projectName: "planner-test", + }); + + expect(result.maxInputTokens).toBe(900); + expect(result.estimatedInputTokens).toBeLessThanOrEqual(result.maxInputTokens); + expect(result.compressionPasses).toBeGreaterThanOrEqual(1); + expect(result.userPrompt.length).toBeLessThanOrEqual(result.maxInputTokens * 4); + }); + + it("fails fast when remaining budget cannot satisfy minimum output tokens", () => { + const original = AGENT_CONFIGS.devops.minOutputTokens; + AGENT_CONFIGS.devops.minOutputTokens = AGENT_CONFIGS.devops.tokenBudget; + + try { + expect(() => + optimizeAgentPayload("devops", { + prompt: "x".repeat(40000), + stack: { backend: "express", database: "postgres" }, + entities: Array.from({ length: 200 }, (_, index) => ({ name: `Entity${index}` })), + }), + ).toThrow("Insufficient output token budget"); + } finally { + AGENT_CONFIGS.devops.minOutputTokens = original; + } + }); +}); diff --git a/packages/shared/src/schemas/sse.schema.ts b/packages/shared/src/schemas/sse.schema.ts index 70b90b7..bbb40ac 100644 --- a/packages/shared/src/schemas/sse.schema.ts +++ b/packages/shared/src/schemas/sse.schema.ts @@ -26,6 +26,15 @@ export const AgentCompletedEventSchema = SSEBaseSchema.extend({ payload: z.object({ durationMs: z.number(), cached: z.boolean(), + inputTokens: z.number(), + outputTokens: z.number(), + totalTokens: z.number(), + tokensUsed: z.number(), + estimatedInputTokens: z.number(), + compressionPasses: z.number(), + providerInputTokens: z.number(), + providerOutputTokens: z.number(), + model: z.string(), }), }); diff --git a/packages/shared/src/types/agent.types.ts b/packages/shared/src/types/agent.types.ts index bee0187..8170e96 100644 --- a/packages/shared/src/types/agent.types.ts +++ b/packages/shared/src/types/agent.types.ts @@ -17,6 +17,13 @@ export type AgentConfig = { name: AgentName; description: string; tokenBudget: number; + model: string; + maxInputTokens: number; + maxOutputTokens: number; + minOutputTokens: number; + temperature: number; + compressionLevel: "low" | "medium" | "high"; + budgetOverflowRetries: number; retries: number; };