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
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,53 @@ const result = await rlm.completion(

The LLM will know it can access `context.users`, `context.settings`, etc. with full type awareness.

### Structured Output with Zod (`generateObject`)

If you want schema-validated JSON output directly (without REPL/code execution), use `generateObject`.
RLLM will retry when output is invalid JSON or fails Zod validation.

```typescript
import { z } from 'zod';
import { createRLLM } from 'rllm';

const rlm = createRLLM({ model: 'gpt-4o-mini' });

const OutputSchema = z.object({
summary: z.string(),
keyPoints: z.array(z.string()),
confidence: z.number().min(0).max(1),
});
const InputSchema = z.object({
reportText: z.string(),
locale: z.string(),
});

const result = await rlm.generateObject(
"Summarize this report and provide key points with confidence",
{
input: {
reportText: hugeDocument,
locale: "en-US",
},
inputSchema: InputSchema,
outputSchema: OutputSchema,
},
{
maxRetries: 2, // total attempts = 3
onRetry: (event) => {
console.log(`Retry ${event.attempt}/${event.maxRetries + 1}: ${event.errorType}`);
},
}
);

console.log(result.object.summary);
console.log(result.attempts, result.usage.tokenUsage.totalTokens);
```

`generateObject` differs from `completion()`:
- `generateObject` asks for one JSON object and validates it against your schema.
- `completion()` runs the full recursive REPL workflow where the model writes and executes JS code.

The LLM will write code like:
```javascript
// LLM-generated code runs in V8 isolate
Expand Down Expand Up @@ -153,6 +200,7 @@ Defaults:
| Method | Description |
|--------|-------------|
| `rlm.completion(prompt, options)` | Full RLM completion with code execution |
| `rlm.generateObject(prompt, { input?, inputSchema?, outputSchema }, options?)` | Structured output with Zod validation + retries |
| `rlm.chat(messages)` | Direct LLM chat |
| `rlm.getClient()` | Get underlying LLM client |

Expand All @@ -164,6 +212,23 @@ Defaults:
| `context` | `string \| T` | The context data available to LLM-generated code |
| `contextSchema` | `ZodType<T>` | Optional Zod schema describing context structure |

### `GenerateObjectOptions`

| Option | Type | Description |
|--------|------|-------------|
| `maxRetries` | `number` | Retries after first attempt (default `2`) |
| `temperature` | `number` | Optional generation temperature |
| `maxTokens` | `number` | Optional max completion tokens |
| `onRetry` | `(event) => void` | Called when parse/validation fails and a retry is scheduled |

### `GenerateObject` schema config

| Field | Type | Description |
|-------|------|-------------|
| `input` | `TInput` | Optional structured input value |
| `inputSchema` | `ZodType<TInput>` | Optional input schema used for pre-validation + prompt typing |
| `outputSchema` | `ZodType<TOutput>` | Required output schema used for retry validation |

### Sandbox Bindings

The V8 isolate provides these bindings to LLM-generated code:
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,10 @@ export type {
RLMEventType,
RLMEvent,
RLMEventCallback,
GenerateObjectErrorType,
GenerateObjectRetryEvent,
GenerateObjectOptions,
GenerateObjectSchemas,
GenerateObjectUsage,
GenerateObjectResult,
} from "./types.js";
63 changes: 63 additions & 0 deletions src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type { ZodType } from "zod";
import type { GenerateObjectRetryEvent } from "./types.js";

/**
* Main RLM system prompt - instructs the LLM on how to use the REPL environment
Expand Down Expand Up @@ -347,3 +348,65 @@ export function buildUserPrompt(

return { role: "user", content };
}

/**
* Build messages for schema-constrained JSON generation.
*/
export function buildGenerateObjectMessages(
prompt: string,
outputSchema: ZodType,
input?: unknown,
inputSchema?: ZodType
): Array<{ role: "system" | "user"; content: string }> {
const outputSchemaDescription = zodSchemaToTypeDescription(outputSchema);
const inputSchemaDescription = inputSchema ? zodSchemaToTypeDescription(inputSchema) : null;
const inputSection = input !== undefined
? (
"Input value (JSON):\n" +
`\`\`\`json\n${JSON.stringify(input, null, 2)}\n\`\`\`\n\n`
)
: "";
const inputTypeSection = inputSchemaDescription
? (
"Input TypeScript type:\n" +
`\`\`\`typescript\ntype Input = ${inputSchemaDescription}\n\`\`\`\n\n`
)
: "";

return [
{
role: "system",
content:
"You generate structured data. Return exactly one valid JSON object that matches the provided schema. " +
"Do not include markdown, code fences, comments, or any extra text before/after the JSON.",
},
{
role: "user",
content:
`Task:\n${prompt}\n\n` +
inputTypeSection +
inputSection +
"Target TypeScript type:\n" +
`\`\`\`typescript\ntype Output = ${outputSchemaDescription}\n\`\`\`\n\n` +
"Return only the JSON object.",
},
];
}

/**
* Build retry feedback after a failed parse/validation attempt.
*/
export function buildGenerateObjectRetryPrompt(
event: GenerateObjectRetryEvent
): { role: "user"; content: string } {
const issues = event.validationIssues?.length
? `\nValidation issues:\n- ${event.validationIssues.join("\n- ")}`
: "";

return {
role: "user",
content:
`Previous attempt ${event.attempt} failed (${event.errorType}): ${event.errorMessage}.${issues}\n\n` +
"Please try again and return only one corrected JSON object with no surrounding text.",
};
}
190 changes: 190 additions & 0 deletions src/rlm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { RLLM } from "./rlm.js";
import type { ChatMessage, TokenUsage } from "./types.js";

interface MockCompletionResponse {
content: string;
usage: TokenUsage;
}

function createTestRLLMWithMock(responses: MockCompletionResponse[]): {
rllm: RLLM;
completeMock: ReturnType<typeof vi.fn>;
} {
const rllm = new RLLM({
client: {
provider: "openai",
model: "gpt-4o-mini",
apiKey: "test-key",
},
});

const completeMock = vi.fn().mockImplementation(async () => {
const next = responses.shift();
if (!next) {
throw new Error("No mock response configured");
}
return {
message: { role: "assistant", content: next.content },
usage: next.usage,
finishReason: "stop",
};
});

(
rllm as unknown as {
client: {
complete: (options: { messages: ChatMessage[] }) => Promise<unknown>;
};
}
).client = { complete: completeMock };

return { rllm, completeMock };
}

describe("RLLM.generateObject", () => {
it("returns typed object on first valid attempt", async () => {
const outputSchema = z.object({
name: z.string(),
count: z.number(),
});
const inputSchema = z.object({
report: z.string(),
});

const { rllm } = createTestRLLMWithMock([
{
content: '{"name":"ok","count":3}',
usage: { promptTokens: 11, completionTokens: 7, totalTokens: 18 },
},
]);

const result = await rllm.generateObject(
"Generate object",
{
input: { report: "hello" },
inputSchema,
outputSchema,
}
);

expect(result.object).toEqual({ name: "ok", count: 3 });
expect(result.attempts).toBe(1);
expect(result.rawResponse).toBe('{"name":"ok","count":3}');
expect(result.usage.totalCalls).toBe(1);
expect(result.usage.tokenUsage).toEqual({
promptTokens: 11,
completionTokens: 7,
totalTokens: 18,
});
});

it("retries after invalid JSON and succeeds", async () => {
const outputSchema = z.object({
city: z.string(),
});
const onRetry = vi.fn();

const { rllm, completeMock } = createTestRLLMWithMock([
{
content: '{"city":"Tel Aviv"',
usage: { promptTokens: 5, completionTokens: 4, totalTokens: 9 },
},
{
content: '{"city":"Tel Aviv"}',
usage: { promptTokens: 6, completionTokens: 4, totalTokens: 10 },
},
]);

const result = await rllm.generateObject("Return city", { outputSchema }, {
maxRetries: 2,
onRetry,
});

expect(result.object).toEqual({ city: "Tel Aviv" });
expect(result.attempts).toBe(2);
expect(result.usage.totalCalls).toBe(2);
expect(result.usage.tokenUsage).toEqual({
promptTokens: 11,
completionTokens: 8,
totalTokens: 19,
});

expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0]?.[0].errorType).toBe("json_parse");
expect(completeMock).toHaveBeenCalledTimes(2);
});

it("retries after schema mismatch and succeeds", async () => {
const outputSchema = z.object({
status: z.enum(["ok", "error"]),
count: z.number(),
});
const onRetry = vi.fn();

const { rllm } = createTestRLLMWithMock([
{
content: '{"status":"ok","count":"3"}',
usage: { promptTokens: 8, completionTokens: 5, totalTokens: 13 },
},
{
content: '{"status":"ok","count":3}',
usage: { promptTokens: 9, completionTokens: 5, totalTokens: 14 },
},
]);

const result = await rllm.generateObject("Return status and count", { outputSchema }, {
maxRetries: 2,
onRetry,
});

expect(result.object).toEqual({ status: "ok", count: 3 });
expect(result.attempts).toBe(2);
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0]?.[0].errorType).toBe("schema_validation");
expect(onRetry.mock.calls[0]?.[0].validationIssues?.length).toBeGreaterThan(0);
});

it("throws actionable error after exhausting retries", async () => {
const outputSchema = z.object({
id: z.string(),
});

const { rllm } = createTestRLLMWithMock([
{
content: '{"id":123}',
usage: { promptTokens: 3, completionTokens: 2, totalTokens: 5 },
},
{
content: '{"id":456}',
usage: { promptTokens: 3, completionTokens: 2, totalTokens: 5 },
},
]);

await expect(
rllm.generateObject("Return id", { outputSchema }, { maxRetries: 1 })
).rejects.toThrow(/generateObject failed after 2 attempt/);
});

it("fails fast when input does not satisfy inputSchema", async () => {
const outputSchema = z.object({ answer: z.string() });
const inputSchema = z.object({ age: z.number() });
const { rllm, completeMock } = createTestRLLMWithMock([
{
content: '{"answer":"ok"}',
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
},
]);

await expect(
rllm.generateObject("Use input", {
input: { age: "not-a-number" } as unknown as { age: number },
inputSchema,
outputSchema,
})
).rejects.toThrow(/input failed inputSchema validation/);

expect(completeMock).toHaveBeenCalledTimes(0);
});
});
Loading