diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fbd6e49 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +TINKER_API_KEY= + +# A supported Tinker base model or tinker:// sampler checkpoint. +# TINKER_MODEL=openai/gpt-oss-120b diff --git a/README.md b/README.md index e52198a..ff80c27 100644 --- a/README.md +++ b/README.md @@ -22,34 +22,48 @@ Pi runs with all tools disabled. Two branches that can both write to the same ch The branch sessions also use an isolated Pi resource directory under `.harness/`; global Pi extensions, skills, and prompt files do not silently contaminate the constitutional target. Authentication still comes from Pi's normal auth store or the explicit Tinker environment key. -## Install and run the mock UI +## Install and run with Tinker ```bash pnpm install -pnpm demo +export TINKER_API_KEY=... +pnpm start ``` -The mock backend exercises streaming, branch selection, correction, squashing, and JSONL persistence without model credits. +`pnpm start` uses Thinking Machines' Tinker inference endpoint and forks two independent samples from `openai/gpt-oss-120b` by default. There is no automatic mock fallback. The active backend and model are printed at the top of the terminal so a fake run cannot quietly masquerade as an experiment. -## Run with an existing Pi model +You can keep the key in a gitignored `.env` file instead: -Pi uses its normal credentials and model registry: +```bash +cp .env.example .env +# edit .env, then: +pnpm start +``` + +Choose another currently supported Tinker base model or a sampler checkpoint with `TINKER_MODEL`: ```bash -pnpm start -- --provider anthropic --model claude-sonnet-4-6 +TINKER_MODEL='openai/gpt-oss-20b' pnpm start +TINKER_MODEL='tinker://:train:0/sampler_weights/' pnpm start ``` -## Run a Tinker checkpoint +The mock backend remains available for tests and UI work without model credits: -Tinker's OpenAI-compatible endpoint requires a sampler checkpoint path, not just a base-model name: +```bash +pnpm demo +``` + +## Run with an existing Pi model + +Pi uses its normal credentials and model registry: ```bash -export TINKER_API_KEY=... -export TINKER_MODEL='tinker://:train:0/sampler_weights/' -pnpm start +pnpm start -- --provider anthropic --model claude-sonnet-4-6 ``` -The key is read from the environment and never written to the event store. Tinker's OpenAI-compatible inference is currently beta and intended for low-traffic evaluation/internal use. +The key is read from the environment and never written to the event store. Tinker's OpenAI-compatible inference is currently beta and intended for low-traffic evaluation/internal use. Branch's preference-collection traffic is exactly that shape; this is not a production serving claim. + +Branch preserves the separate reasoning stream emitted by the default model and by compatible Tinker checkpoints. It does not currently send `reasoning_effort`: Tinker's endpoint rejects that field for several otherwise-reasoning-capable base models. Model-specific renderer controls belong in a later native SamplingClient adapter rather than a hopeful generic flag. ## Context Constitution target diff --git a/src/backends/mock.ts b/src/backends/mock.ts index 69b398f..49cbd0c 100644 --- a/src/backends/mock.ts +++ b/src/backends/mock.ts @@ -2,6 +2,7 @@ import type { BranchBackend, RunRequest, RunResult, SerializedMessage } from ".. export class MockBackend implements BranchBackend { readonly name = "mock"; + readonly displayName = "mock · deterministic test backend"; readonly systemPrompt: string; private runCount = 0; diff --git a/src/backends/pi.ts b/src/backends/pi.ts index 93de69a..ce85cc1 100644 --- a/src/backends/pi.ts +++ b/src/backends/pi.ts @@ -10,6 +10,8 @@ import { join } from "node:path"; import { lastAssistantMessage, reasoningFromMessage, textFromMessage } from "../text.js"; import type { BranchBackend, RunRequest, RunResult, SerializedMessage } from "../types.js"; +export type BranchThinkingLevel = "off" | "minimal" | "low" | "medium" | "high"; + export interface PiBackendOptions { systemPrompt: string; cwd: string; @@ -18,16 +20,21 @@ export interface PiBackendOptions { baseUrl?: string; apiKey?: string; tinkerCheckpoint?: string; + reasoning?: boolean; + supportsReasoningEffort?: boolean; + thinkingLevel?: BranchThinkingLevel; contextWindow?: number; maxTokens?: number; } export class PiBackend implements BranchBackend { readonly name: string; + readonly displayName: string; readonly systemPrompt: string; constructor(private readonly options: PiBackendOptions) { this.name = `pi:${options.provider}`; + this.displayName = `${options.provider} · ${options.modelId}`; this.systemPrompt = options.systemPrompt; } @@ -54,7 +61,7 @@ export class PiBackend implements BranchBackend { resourceLoader, sessionManager: SessionManager.inMemory(this.options.cwd), noTools: "all", - thinkingLevel: "off", + thinkingLevel: this.options.thinkingLevel ?? "off", }); const events: Array> = []; let response = ""; @@ -71,6 +78,13 @@ export class PiBackend implements BranchBackend { reasoning += event.assistantMessageEvent.delta; request.onUpdate?.({ reasoning: event.assistantMessageEvent.delta, status: "reasoning" }); } + } else if (event.type === "message_end" && event.message.role === "assistant") { + events.push({ + type: event.type, + at: new Date().toISOString(), + stopReason: event.message.stopReason, + ...(event.message.errorMessage ? { errorMessage: event.message.errorMessage } : {}), + }); } else if (event.type === "agent_start" || event.type === "agent_end") { events.push({ type: event.type, at: new Date().toISOString() }); } @@ -79,8 +93,17 @@ export class PiBackend implements BranchBackend { await session.prompt(request.prompt, { expandPromptTemplates: false }); const messages = structuredClone(session.state.messages) as unknown as SerializedMessage[]; const finalMessage = lastAssistantMessage(messages); + if (!finalMessage) { + const failedMessage = events.findLast((event) => event.type === "message_end"); + const detail = + failedMessage && typeof failedMessage.errorMessage === "string" ? `: ${failedMessage.errorMessage}` : ""; + throw new Error(`Model run ended without an assistant response${detail}`); + } response ||= textFromMessage(finalMessage); reasoning ||= reasoningFromMessage(finalMessage); + if (!response && !reasoning) { + throw new Error("Model returned an empty assistant response."); + } request.onUpdate?.({ status: "complete" }); return { @@ -110,16 +133,18 @@ export class PiBackend implements BranchBackend { api: "openai-completions", provider: this.options.provider, baseUrl: this.options.baseUrl, - reasoning: false, + reasoning: this.options.reasoning ?? false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: this.options.contextWindow ?? 131_072, maxTokens: this.options.maxTokens ?? 8_192, compat: { supportsDeveloperRole: false, - supportsReasoningEffort: false, + supportsReasoningEffort: this.options.supportsReasoningEffort ?? false, supportsStore: false, supportsUsageInStreaming: false, + maxTokensField: "max_tokens", + thinkingFormat: "openai", }, }; } diff --git a/src/config.ts b/src/config.ts index 2834d88..5a6b5e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,14 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { loadEnvFile } from "node:process"; import type { BranchBackend } from "./types.js"; import { MockBackend } from "./backends/mock.js"; import { PiBackend } from "./backends/pi.js"; const TINKER_BASE_URL = "https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1"; +export const DEFAULT_TINKER_MODEL = "openai/gpt-oss-120b"; +const DEFAULT_TINKER_CONTEXT_WINDOW = 32_768; +const DEFAULT_TINKER_MAX_TOKENS = 4_096; export interface AppConfig { backend: BranchBackend; @@ -12,6 +16,7 @@ export interface AppConfig { } export async function loadConfig(argv: string[]): Promise { + loadLocalEnv(); const mock = argv.includes("--mock"); const constitutionPath = valueAfter(argv, "--constitution") ?? process.env.CONSTITUTION_PATH; const systemPrompt = constitutionPath @@ -23,38 +28,59 @@ export async function loadConfig(argv: string[]): Promise { if (mock) return { backend: new MockBackend(systemPrompt), dataPath }; - const tinkerCheckpoint = valueAfter(argv, "--tinker-checkpoint") ?? process.env.TINKER_MODEL; - if (tinkerCheckpoint) { - const apiKey = process.env.TINKER_API_KEY; - if (!apiKey) throw new Error("TINKER_API_KEY is required when a Tinker checkpoint is configured."); + const provider = valueAfter(argv, "--provider") ?? process.env.PI_PROVIDER; + const modelId = valueAfter(argv, "--model") ?? process.env.PI_MODEL; + if (provider || modelId) { + if (!provider || !modelId) { + throw new Error("Both --provider and --model are required when selecting a non-Tinker Pi model."); + } return { - backend: new PiBackend({ - systemPrompt, - cwd: process.cwd(), - provider: "tinker", - modelId: tinkerCheckpoint, - tinkerCheckpoint, - baseUrl: TINKER_BASE_URL, - apiKey, - }), + backend: new PiBackend({ systemPrompt, cwd: process.cwd(), provider, modelId }), dataPath, }; } - const provider = valueAfter(argv, "--provider") ?? process.env.PI_PROVIDER; - const modelId = valueAfter(argv, "--model") ?? process.env.PI_MODEL; - if (!provider || !modelId) { + const tinkerModel = + valueAfter(argv, "--tinker-model") ?? + valueAfter(argv, "--tinker-checkpoint") ?? + process.env.TINKER_MODEL ?? + DEFAULT_TINKER_MODEL; + const apiKey = process.env.TINKER_API_KEY; + if (!apiKey) { throw new Error( - "No model configured. Use --mock, set TINKER_MODEL, or provide --provider and --model for a Pi model.", + "TINKER_API_KEY is required. Export the key from the Tinker console or put it in .env. " + + "Branch does not fall back to fake responses; use `pnpm demo` only when you deliberately want the mock backend.", ); } + const tinkerCheckpoint = tinkerModel.startsWith("tinker://") ? tinkerModel : undefined; return { - backend: new PiBackend({ systemPrompt, cwd: process.cwd(), provider, modelId }), + backend: new PiBackend({ + systemPrompt, + cwd: process.cwd(), + provider: "tinker", + modelId: tinkerModel, + ...(tinkerCheckpoint ? { tinkerCheckpoint } : {}), + baseUrl: TINKER_BASE_URL, + apiKey, + reasoning: true, + supportsReasoningEffort: false, + thinkingLevel: "off", + contextWindow: DEFAULT_TINKER_CONTEXT_WINDOW, + maxTokens: DEFAULT_TINKER_MAX_TOKENS, + }), dataPath, }; } +function loadLocalEnv(): void { + try { + loadEnvFile(resolve(".env")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + function valueAfter(argv: string[], flag: string): string | undefined { const index = argv.indexOf(flag); return index >= 0 ? argv[index + 1] : undefined; diff --git a/src/types.ts b/src/types.ts index b825a90..d250208 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ export interface RunResult { export interface BranchBackend { readonly name: string; + readonly displayName: string; readonly systemPrompt: string; run(request: RunRequest): Promise; } diff --git a/src/ui.ts b/src/ui.ts index 2834da6..d3e9ce3 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -15,7 +15,7 @@ export async function runTerminal(engine: BranchEngine): Promise { try { while (true) { clear(); - stdout.write("BRANCH · WEIGHT-BASED LEARNING\n\n"); + stdout.write(`BRANCH · ${engine.backend.displayName}\n\n`); const prompt = (await rl.question("You › ")).trim(); if (!prompt) continue; if (prompt === "/quit" || prompt === "/q") break; diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..875c319 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_TINKER_MODEL, loadConfig } from "../src/config.js"; +import { MockBackend } from "../src/backends/mock.js"; +import { PiBackend } from "../src/backends/pi.js"; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("loadConfig", () => { + it("uses the mock only when explicitly requested", async () => { + delete process.env.TINKER_API_KEY; + + const config = await loadConfig(["--mock"]); + + expect(config.backend).toBeInstanceOf(MockBackend); + expect(config.backend.displayName).toContain("mock"); + }); + + it("defaults to a real Tinker base model", async () => { + process.env.TINKER_API_KEY = "test-key"; + delete process.env.TINKER_MODEL; + delete process.env.PI_PROVIDER; + delete process.env.PI_MODEL; + + const config = await loadConfig([]); + + expect(config.backend).toBeInstanceOf(PiBackend); + expect(config.backend.name).toBe("pi:tinker"); + expect(config.backend.displayName).toBe(`tinker · ${DEFAULT_TINKER_MODEL}`); + }); + + it("accepts either a Tinker base model or sampler checkpoint", async () => { + process.env.TINKER_API_KEY = "test-key"; + process.env.TINKER_MODEL = "tinker://run:train:0/sampler_weights/000001"; + delete process.env.PI_PROVIDER; + delete process.env.PI_MODEL; + + const config = await loadConfig([]); + + expect(config.backend.displayName).toContain(process.env.TINKER_MODEL); + }); + + it("refuses to silently fall back to mock responses", async () => { + delete process.env.TINKER_API_KEY; + delete process.env.TINKER_MODEL; + delete process.env.PI_PROVIDER; + delete process.env.PI_MODEL; + + await expect(loadConfig([])).rejects.toThrow("Branch does not fall back to fake responses"); + }); + + it("preserves explicit non-Tinker Pi model selection", async () => { + delete process.env.TINKER_API_KEY; + const config = await loadConfig(["--provider", "anthropic", "--model", "claude-sonnet-4-6"]); + + expect(config.backend).toBeInstanceOf(PiBackend); + expect(config.backend.displayName).toBe("anthropic · claude-sonnet-4-6"); + }); +});