diff --git a/.changeset/g5-codegen-provider.md b/.changeset/g5-codegen-provider.md new file mode 100644 index 000000000..150035956 --- /dev/null +++ b/.changeset/g5-codegen-provider.md @@ -0,0 +1,24 @@ +--- +"@linchkit/core": minor +"@linchkit/cap-ai-provider": minor +--- + +G5 Phase 1 — implement the `CodeGenerationProvider` seam. + +`@linchkit/core` previously declared a `CodeGenerationProvider` interface +("implemented by cap-ai-provider") but no implementation existed and the type was +not exported from any public barrel. This adds: + +- `@linchkit/core/server` now exports the `CodeGenerationProvider`, + `CodeGenerationResult`, `ProjectContext`, and `QualityGateRunner` types so a + capability can implement the seam. +- `@linchkit/cap-ai-provider` exports `createCodeGenerationProvider(ai, options)` + — a thin adapter over the configured `AIService` (GLM/zhipu/etc. per + `linchkit.config`) that turns a prompt (+ optional context) into generated + TypeScript source. + +This is the foundation for AI code generation of the irreducibly-code parts of a +proposal (action / event-handler / flow logic bodies). It only PRODUCES candidate +source as a string — it never writes files, runs code, or touches the approval / +graduation path. Generated source still flows through validation and double human +review (draft + graduation PR) before it can land. diff --git a/addons/ai-provider/cap-ai-provider/__tests__/code-generation-provider.test.ts b/addons/ai-provider/cap-ai-provider/__tests__/code-generation-provider.test.ts new file mode 100644 index 000000000..4b6d216a4 --- /dev/null +++ b/addons/ai-provider/cap-ai-provider/__tests__/code-generation-provider.test.ts @@ -0,0 +1,128 @@ +/** + * CodeGenerationProvider (G5 Phase 1) — unit tests. + * + * Verifies the thin adapter over AIService: message shaping (context→system, + * prompt→user), defaults (temperature 0, taskType "code"), option forwarding, + * and the fail-loud behaviour when the AIService is not configured. A fake + * AIService is injected — no real model is called. + */ + +import { describe, expect, test } from "bun:test"; +import type { AICompletionOptions, AICompletionResult, AIService } from "@linchkit/core"; +import { createCodeGenerationProvider } from "../src/code-generation-provider"; + +function makeAI(opts: { configured?: boolean; content?: string } = {}): { + ai: AIService; + calls: AICompletionOptions[]; +} { + const calls: AICompletionOptions[] = []; + const ai: AIService = { + configured: opts.configured ?? true, + defaultProvider: "glm", + providerNames: ["glm"], + async complete(options: AICompletionOptions): Promise { + calls.push(options); + return { + content: opts.content ?? "export const x = 1;", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + model: "glm-4", + provider: "glm", + duration: 1, + }; + }, + }; + return { ai, calls }; +} + +describe("createCodeGenerationProvider", () => { + test("returns the completion content verbatim", async () => { + const { ai } = makeAI({ content: "export const late_fee = defineRule({});" }); + const provider = createCodeGenerationProvider(ai); + const code = await provider.generateCode("Generate a late_fee rule"); + expect(code).toBe("export const late_fee = defineRule({});"); + }); + + test("sends context as a system message and prompt as the user message", async () => { + const { ai, calls } = makeAI(); + const provider = createCodeGenerationProvider(ai); + await provider.generateCode("PROMPT", "CONTEXT"); + expect(calls).toHaveLength(1); + expect(calls[0]?.messages).toEqual([ + { role: "system", content: "CONTEXT" }, + { role: "user", content: "PROMPT" }, + ]); + expect(calls[0]?.temperature).toBe(0); + expect(calls[0]?.taskType).toBe("code"); + }); + + test("omits the system message when no context is given", async () => { + const { ai, calls } = makeAI(); + const provider = createCodeGenerationProvider(ai); + await provider.generateCode("PROMPT"); + expect(calls[0]?.messages).toEqual([{ role: "user", content: "PROMPT" }]); + }); + + test("omits the system message for whitespace-only context", async () => { + const { ai, calls } = makeAI(); + const provider = createCodeGenerationProvider(ai); + await provider.generateCode("PROMPT", " "); + expect(calls[0]?.messages).toEqual([{ role: "user", content: "PROMPT" }]); + }); + + test("forwards model / maxTokens / temperature / tenantId / taskType overrides", async () => { + const { ai, calls } = makeAI(); + const provider = createCodeGenerationProvider(ai, { + model: "advanced", + maxTokens: 2048, + temperature: 0.2, + tenantId: "t-1", + taskType: "generation", + }); + await provider.generateCode("PROMPT"); + expect(calls[0]?.model).toBe("advanced"); + expect(calls[0]?.maxTokens).toBe(2048); + expect(calls[0]?.temperature).toBe(0.2); + expect(calls[0]?.tenantId).toBe("t-1"); + expect(calls[0]?.taskType).toBe("generation"); + }); + + test("throws when the AIService is not configured", async () => { + const { ai } = makeAI({ configured: false }); + const provider = createCodeGenerationProvider(ai); + await expect(provider.generateCode("PROMPT")).rejects.toThrow(/not configured/); + }); + + test("throws on an empty prompt; the model is never called", async () => { + const { ai, calls } = makeAI(); + const provider = createCodeGenerationProvider(ai); + await expect(provider.generateCode("")).rejects.toThrow(/non-empty string/); + expect(calls).toHaveLength(0); + }); + + test("throws on a whitespace-only prompt; the model is never called", async () => { + const { ai, calls } = makeAI(); + const provider = createCodeGenerationProvider(ai); + await expect(provider.generateCode(" \n\t ")).rejects.toThrow(/non-empty string/); + expect(calls).toHaveLength(0); + }); + + test("throws when the completion has no text content", async () => { + const ai: AIService = { + configured: true, + defaultProvider: "glm", + providerNames: ["glm"], + // Simulate a provider returning a result without usable text content. + async complete(): Promise { + return { + content: undefined as unknown as string, + usage: { inputTokens: 1, outputTokens: 0, totalTokens: 1 }, + model: "glm-4", + provider: "glm", + duration: 1, + }; + }, + }; + const provider = createCodeGenerationProvider(ai); + await expect(provider.generateCode("PROMPT")).rejects.toThrow(/no text content/); + }); +}); diff --git a/addons/ai-provider/cap-ai-provider/src/code-generation-provider.ts b/addons/ai-provider/cap-ai-provider/src/code-generation-provider.ts new file mode 100644 index 000000000..48ce1d9b7 --- /dev/null +++ b/addons/ai-provider/cap-ai-provider/src/code-generation-provider.ts @@ -0,0 +1,87 @@ +/** + * CodeGenerationProvider implementation (G5 Phase 1). + * + * Implements core's `CodeGenerationProvider` seam. The interface in + * `@linchkit/core` is documented as "implemented by cap-ai-provider", but no + * implementation existed. This is a thin adapter over the configured + * {@link AIService} (GLM / zhipu / etc. per `linchkit.config`), intended for the + * proposal code-generation pipeline that materializes the irreducibly-code parts + * of a proposal — action / event-handler / flow logic bodies that a declarative + * `ChangeDefinition` cannot express — into TypeScript source. + * + * SAFETY BOUNDARY ("AI never modifies production directly"): this only PRODUCES + * candidate source as a string. It never writes files, runs code, or touches the + * approval / graduation path. Generated source must flow through validation + * (build + quality gates) and double human review (draft review + graduation PR) + * before it can land. + */ + +import type { AICompletionOptions, AIMessage, AIService, AITaskType } from "@linchkit/core"; +import type { CodeGenerationProvider } from "@linchkit/core/server"; + +export interface CodeGenerationProviderOptions { + /** + * Model alias (`fast` / `standard` / `advanced`) or a full model id. Omit to + * let the AIService pick its configured default provider/model. + */ + model?: string; + /** Sampling temperature. Defaults to 0 — code generation should be deterministic. */ + temperature?: number; + /** Max output tokens for a single generation. */ + maxTokens?: number; + /** Task-type hint for model routing. Defaults to `"code"`. */ + taskType?: AITaskType; + /** Tenant id for BYOK (bring-your-own-key) config resolution. */ + tenantId?: string; +} + +/** + * Build a {@link CodeGenerationProvider} backed by a configured {@link AIService}. + * + * `generateCode(prompt, context?)` sends `context` as a system message (when + * non-empty) and `prompt` as the user message, returning the model's raw text + * content. It throws when the AIService is not configured, so the caller's retry + * / quality-gate loop surfaces a clear failure instead of silently treating an + * empty completion as generated code. + */ +export function createCodeGenerationProvider( + ai: AIService, + options: CodeGenerationProviderOptions = {}, +): CodeGenerationProvider { + return { + async generateCode(prompt: string, context?: string): Promise { + if (!ai.configured) { + throw new Error( + "CodeGenerationProvider: AIService is not configured — cannot generate code.", + ); + } + if (typeof prompt !== "string" || prompt.trim().length === 0) { + throw new Error("CodeGenerationProvider: prompt must be a non-empty string."); + } + + const messages: AIMessage[] = []; + if (context && context.trim().length > 0) { + messages.push({ role: "system", content: context }); + } + messages.push({ role: "user", content: prompt }); + + const completion: AICompletionOptions = { + messages, + temperature: options.temperature ?? 0, + taskType: options.taskType ?? "code", + }; + if (options.model) completion.model = options.model; + if (options.maxTokens !== undefined) completion.maxTokens = options.maxTokens; + if (options.tenantId) completion.tenantId = options.tenantId; + + const result = await ai.complete(completion); + // A completion with no text content is a generation failure, not empty + // code — throw so the caller's retry / quality gate surfaces it rather than + // silently returning a non-string (the contract is Promise). + if (typeof result?.content !== "string") { + throw new Error("CodeGenerationProvider: AI returned no text content."); + } + return result.content; + }, + }; +} diff --git a/addons/ai-provider/cap-ai-provider/src/index.ts b/addons/ai-provider/cap-ai-provider/src/index.ts index e41e06458..2d03b7b4e 100644 --- a/addons/ai-provider/cap-ai-provider/src/index.ts +++ b/addons/ai-provider/cap-ai-provider/src/index.ts @@ -45,7 +45,11 @@ export type { UsageEvent, } from "./anomaly-detector"; export { AnomalyDetector } from "./anomaly-detector"; - +// Code generation provider (G5 Phase 1) — implements core's CodeGenerationProvider +// seam over the configured AIService. Produces candidate source only; never +// writes/runs code (gated by validation + double human review). +export type { CodeGenerationProviderOptions } from "./code-generation-provider"; +export { createCodeGenerationProvider } from "./code-generation-provider"; // Cost Estimator export type { ModelPricing } from "./cost-estimator"; export { CostEstimator, defaultCostEstimator } from "./cost-estimator"; diff --git a/packages/core/src/exports/server/ai.ts b/packages/core/src/exports/server/ai.ts index 622cb4961..d9643389b 100644 --- a/packages/core/src/exports/server/ai.ts +++ b/packages/core/src/exports/server/ai.ts @@ -16,7 +16,16 @@ */ // AI Boundary -export type { PatternEvidence, PatternInsight, PatternType } from "../../ai"; +// Code-generation seam (G5) — type-only; the impl lives in cap-ai-provider. +export type { + CodeGenerationProvider, + CodeGenerationResult, + PatternEvidence, + PatternInsight, + PatternType, + ProjectContext, + QualityGateRunner, +} from "../../ai"; // AI Audit // AI Prompt Sanitizer // AI Intent Resolver (Spec 52 §2.2 / §2.5)