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
24 changes: 24 additions & 0 deletions .changeset/g5-codegen-provider.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<AICompletionResult> {
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/);
});
Comment thread
laofahai marked this conversation as resolved.

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<AICompletionResult> {
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/);
});
});
Original file line number Diff line number Diff line change
@@ -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<string> {
if (!ai.configured) {
throw new Error(
"CodeGenerationProvider: AIService is not configured — cannot generate code.",
);
}
Comment thread
laofahai marked this conversation as resolved.
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<string>).
if (typeof result?.content !== "string") {
throw new Error("CodeGenerationProvider: AI returned no text content.");
}
return result.content;
Comment thread
laofahai marked this conversation as resolved.
},
};
}
6 changes: 5 additions & 1 deletion addons/ai-provider/cap-ai-provider/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/exports/server/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading