← vibesdk
import { Think } from '@cloudflare/think';
import type { PrepareStepContext, StepConfig, Session, TurnContext, TurnConfig } from '@cloudflare/think';
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModel, ToolSet } from 'ai';
import {
createReadTool,
createWriteTool,
createEditTool,
createListTool,
createFindTool,
createGrepTool,
createDeleteTool,
} from '@cloudflare/think/tools/workspace';
import type { SkillSource } from 'agents/skills';
import { createSpaceWorkspaceOps, type SpaceWorkspaceStub } from './space-workspace-ops';
import { selectSystemPrompt, PROMPT_MAX_STEPS } from './prompts';
import { createThinkSkillSource } from './skills';
import { createAskQuestionsTool } from './ask-questions-tool';
import { createBrowserConsoleLogsTool } from './browser-logs-tool';
import { createDeploySpaceTool } from './deploy-tool';
import { createCommitTool } from './commit-tool';
import { createSetTitleTool } from './set-title-tool';
import { selectThinkContextMessages } from './context-selector';
import { getUserConfigurableSettings } from '../../config';
import { RateLimitService } from '../../services/rate-limit/rateLimits';
import { hasCloudflareConfigured } from '../../services/rate-limit/usageChecker';
import type { RateLimitSettings } from '../../services/rate-limit/config';
import { THINK_MODEL_CONFIG } from './model-config';
/**
* Per-instance configuration pushed into a {@link ThinkAgent} by the host
* `ThinkCodingBehavior` (which runs in the main worker and therefore has access
* to VibeSDK's model-config + AI Gateway plumbing). Plain JSON only — it crosses
* the DO RPC boundary and is persisted via Think's `configure()`/`getConfig()`.
*/
export interface ThinkAgentConfig {
/** Owner — used for usage attribution / logging. */
userId: string;
/**
* Fully-resolved model coordinates. The behavior computes these from the
* user's `ModelConfig` + AGENT_CONFIG via `getConfigurationForModel`, so
* `getModel()` here is a thin `@ai-sdk/openai` provider over the same
* AI Gateway `/compat` endpoint the rest of the platform uses.
*/
model: {
baseURL: string;
apiKey: string;
modelName: string;
contextSize?: number;
headers?: Record<string, string>;
/**
* When true, the AI Gateway holds the provider keys (BYOK / stored
* keys). In that mode the request must carry only `cf-aig-authorization`
* and must NOT send a provider `Authorization` header — otherwise the
* forwarded value overrides the gateway's stored key and the provider
* rejects it. `getModel()` strips `Authorization` when this is set.
*/
useStoredKeys?: boolean;
};
/** Builder system prompt (assembled host-side; falls back to a default). */
systemPrompt?: string;
/**
* Current preview URL (computed host-side via `getBrowserPreviewURL`), used
* as the default target for the `get_browser_console_logs` tool.
*/
previewUrl?: string;
}
const DEFAULT_SYSTEM_PROMPT =
'You are an expert Cloudflare full-stack engineer building deployable web apps. ' +
'Use the workspace tools to read, write and edit files in the project. Keep changes ' +
'minimal and runnable.';
/** Extracts the Gemini thought signature from a `tool_calls[]` entry, if any. */
function getThoughtSignature(call: unknown): string | undefined {
const sig = (call as { extra_content?: { google?: { thought_signature?: unknown } } })
?.extra_content?.google?.thought_signature;
return typeof sig === 'string' ? sig : undefined;
}
/**
* Google's OpenAI-compatible streaming omits the `index` field on each
* `tool_calls` delta, but the OpenAI provider's chunk schema requires it
* (`choices[].delta.tool_calls[].index` must be a number). Patch each parsed
* SSE `data:` line so the AI SDK's zod validation passes, assigning the array
* position when `index` is absent. Also harvest each tool call's Gemini
* `thought_signature` (keyed by tool-call id) via `onSignature`, so it can be
* replayed on later turns (Gemini 3 rejects function-call history that drops
* it — `400 INVALID_ARGUMENT`).
*/
function patchToolCallChunk(
json: Record<string, unknown>,
onSignature: (id: string, signature: string) => void,
): Record<string, unknown> {
const choices = json.choices;
if (!Array.isArray(choices)) return json;
for (const choice of choices) {
const delta = (choice as { delta?: { tool_calls?: unknown } }).delta;
const toolCalls = delta?.tool_calls;
if (!Array.isArray(toolCalls)) continue;
toolCalls.forEach((call, i) => {
if (call && typeof call === 'object' && (call as { index?: unknown }).index == null) {
(call as { index: number }).index = i;
}
const id = (call as { id?: unknown })?.id;
const sig = getThoughtSignature(call);
if (typeof id === 'string' && sig) onSignature(id, sig);
});
}
return json;
}
/**
* Rewrites an SSE response body, applying {@link patchToolCallChunk} to each
* `data:` event. Buffers across network chunks so split JSON lines parse
* correctly. Non-JSON lines (e.g. `data: [DONE]`) pass through untouched.
*/
function fixToolCallStream(
body: ReadableStream<Uint8Array>,
onSignature: (id: string, signature: string) => void,
): ReadableStream<Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = '';
const transformLine = (line: string): string => {
const trimmed = line.trimStart();
if (!trimmed.startsWith('data:')) return line;
const payload = trimmed.slice(5).trim();
if (!payload || payload === '[DONE]') return line;
try {
const json = JSON.parse(payload) as Record<string, unknown>;
return `data: ${JSON.stringify(patchToolCallChunk(json, onSignature))}`;
} catch {
return line;
}
};
return body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
controller.enqueue(encoder.encode(`${transformLine(line)}\n`));
}
},
flush(controller) {
if (buffer) controller.enqueue(encoder.encode(transformLine(buffer)));
},
}),
);
}
/**
* ThinkAgent — the `@cloudflare/think` harness. It owns the agentic loop
* (model ↔ tools), message persistence and streaming; its file tools operate
* on the companion SpaceDO (the preview source of truth) rather than Think's
* default DO-SQLite workspace.
*/
export class ThinkAgent extends Think<Env> {
/** Step budget per turn (Think's default is 10). */
override maxSteps = 25;
/** SpaceDO has no shell; expose only the explicit file tools. */
override workspaceBash = false;
/**
* Gemini thought signatures harvested from streamed tool calls, keyed by
* tool-call id. Re-injected into outgoing request history (the AI SDK drops
* the `extra_content` Google requires for multi-step function calling).
*/
private readonly thoughtSignatures = new Map<string, string>();
private turnUsage: { config: RateLimitSettings; hasCloudflareConfigured: boolean } | null = null;
/**
* Re-attach harvested Gemini `thought_signature`s to the `tool_calls` in an
* outgoing request body, keyed by tool-call id. No-op if we have none or the
* body isn't the expected chat-completions JSON.
*/
private injectThoughtSignatures(bodyText: string): string {
if (this.thoughtSignatures.size === 0) return bodyText;
let json: { messages?: unknown };
try {
json = JSON.parse(bodyText);
} catch {
return bodyText;
}
const messages = json.messages;
if (!Array.isArray(messages)) return bodyText;
let changed = false;
for (const message of messages) {
const toolCalls = (message as { tool_calls?: unknown })?.tool_calls;
if (!Array.isArray(toolCalls)) continue;
for (const call of toolCalls) {
const id = (call as { id?: unknown })?.id;
if (typeof id !== 'string') continue;
if (getThoughtSignature(call)) continue;
const sig = this.thoughtSignatures.get(id);
if (!sig) continue;
const c = call as { extra_content?: { google?: Record<string, unknown> } };
c.extra_content = {
...(c.extra_content ?? {}),
google: { ...(c.extra_content?.google ?? {}), thought_signature: sig },
};
changed = true;
}
}
return changed ? JSON.stringify(json) : bodyText;
}
private requireConfig(): ThinkAgentConfig {
const cfg = this.getConfig<ThinkAgentConfig>();
if (!cfg) {
throw new Error('ThinkAgent has not been configured yet (call configureVibe first)');
}
return cfg;
}
private getSpaceStub(): SpaceWorkspaceStub {
// One space per session: the SpaceDO is always keyed by this session's
// (agent) id, so a session can never resolve to a different space.
const ns = (this.env as unknown as { SPACE_DO: DurableObjectNamespace }).SPACE_DO;
return ns.get(ns.idFromName(this.name)) as unknown as SpaceWorkspaceStub;
}
override getModel(): LanguageModel {
const { model } = this.requireConfig();
// Wrap fetch to (1) strip the provider `Authorization` header in BYOK /
// stored-keys mode (the gateway injects the stored key; a forwarded
// header would override it) while preserving `cf-aig-authorization`;
// (2) patch the response SSE stream so Google's compat tool-call deltas
// (which omit `index`) satisfy the OpenAI provider's chunk schema; and
// (3) round-trip Gemini `thought_signature`s — harvest them from the
// response and re-inject them into outgoing request history (the AI SDK
// drops them, which Gemini 3 rejects with `400 INVALID_ARGUMENT`).
const gatewayFetch: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers);
if (model.useStoredKeys) headers.delete('authorization');
let body = init?.body;
if (typeof body === 'string') body = this.injectThoughtSignatures(body);
const res = await fetch(input as RequestInfo, { ...(init ?? {}), headers, body });
if (!res.body) return res;
const outHeaders = new Headers(res.headers);
// Body length/encoding change after transforming the stream.
outHeaders.delete('content-length');
outHeaders.delete('content-encoding');
return new Response(
fixToolCallStream(res.body, (id, sig) => this.thoughtSignatures.set(id, sig)),
{ status: res.status, statusText: res.statusText, headers: outHeaders },
);
};
const provider = createOpenAI({
baseURL: model.baseURL,
apiKey: model.apiKey,
headers: model.headers,
fetch: gatewayFetch,
});
// Use the Chat Completions API (`/compat/chat/completions`), NOT the
// default Responses API (`/compat/responses`). VibeSDK's AI Gateway is
// exercised via chat-completions everywhere else; the responses path on
// the gateway rejects auth (401 AiGatewayError) for these models.
return provider.chat(model.modelName);
}
/**
* Register the base prompt as a context block.
*
* Think only falls back to `getSystemPrompt()` when the session has NO
* context blocks. Because `getSkills()` adds a skills block, that fallback
* never fires — so without this, the model would receive only the skill
* catalog and none of the file-based persona prompt or project context.
* Adding it as a (lazy, readonly) block makes Think render it ahead of the
* skills block in the assembled system prompt. The `get` closure is invoked
* at prompt-freeze time, so it picks up config pushed via `configureVibe`.
*/
override configureSession(session: Session): Session {
return session.withContext('system', {
description: 'Base system prompt and project context',
provider: { get: async () => this.getSystemPrompt() },
});
}
override getSystemPrompt(): string {
const cfg = this.getConfig<ThinkAgentConfig>();
// Base prompt is the file-based prompt selected by model
// family; the host behavior's project/query context (`systemPrompt`) is
// appended. Think additionally injects the skill catalog (see getSkills).
const base = selectSystemPrompt(cfg?.model.modelName ?? '');
const projectContext = cfg?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
return `${base}\n\n${projectContext}`;
}
override async beforeTurn(ctx: TurnContext): Promise<TurnConfig> {
const messages = selectThinkContextMessages(ctx.messages);
const config = this.getConfig<ThinkAgentConfig>();
this.turnUsage = null;
if (config) {
try {
const userConfig = await getUserConfigurableSettings(this.env, config.userId);
this.turnUsage = {
config: userConfig.security.rateLimit,
hasCloudflareConfigured: await hasCloudflareConfigured(this.env, config.userId),
};
} catch (error) {
console.warn('Failed to resolve Think credit metering configuration', error);
}
}
console.info('Think context selected', {
model: config?.model.modelName,
contextSize: config?.model.contextSize,
originalMessageCount: ctx.messages.length,
selectedMessageCount: messages.length,
});
return { messages };
}
override getSkills(): SkillSource[] {
// SKILL.md catalog. Think registers the skill-loading tool
// and injects the catalog into the system prompt automatically.
return [createThinkSkillSource()];
}
override getTools(): ToolSet {
const ops = createSpaceWorkspaceOps(() => this.getSpaceStub());
const previewUrl = this.getConfig<ThinkAgentConfig>()?.previewUrl;
// Same names as Think's built-in workspace tools, so these SpaceDO-backed
// versions win the tool-merge. Bash is disabled via `workspaceBash`.
return {
read: createReadTool({ ops }),
write: createWriteTool({ ops }),
edit: createEditTool({ ops }),
list: createListTool({ ops }),
find: createFindTool({ ops }),
grep: createGrepTool({ ops }),
delete: createDeleteTool({ ops }),
// Save a restore point without deploying. The model decides when.
commit: createCommitTool({ getStub: () => this.getSpaceStub() }),
// Commit + deploy the SpaceDO branch so the preview rebuilds.
deploy_space: createDeploySpaceTool({ getStub: () => this.getSpaceStub() }),
// Set the project's short display title (host observes the output).
set_title: createSetTitleTool(),
// Ask the user clarifying questions via a frontend popup.
ask_questions: createAskQuestionsTool(),
// Client-side debugging via a real headless browser.
get_browser_console_logs: createBrowserConsoleLogsTool({
env: this.env,
defaultUrl: previewUrl,
}),
} as unknown as ToolSet;
}
/**
* On the final allowed step, disable tools and prime a text-only wrap-up so
* the turn ends with a summary instead of a truncated tool call (see
* `prompts/max-steps.txt`).
*
* The primer is injected as a `user` turn, not an `assistant` one: Gemini's
* OpenAI-compat endpoint rejects any request whose final message is a model
* turn (`400 INVALID_ARGUMENT: Requests ending with a model turn are not
* supported`), and the prompt is a control directive that reads naturally as
* user input.
*/
override async beforeStep(ctx: PrepareStepContext): Promise<StepConfig | void> {
const config = this.getConfig<ThinkAgentConfig>();
if (this.turnUsage && config) {
await RateLimitService.enforceLLMCallsRateLimit(
this.env,
this.turnUsage.config,
config.userId,
'Think',
'',
false,
this.turnUsage.hasCloudflareConfigured,
{ creditCost: THINK_MODEL_CONFIG.creditCost, throwOnExceeded: false },
);
}
if (ctx.stepNumber >= this.maxSteps - 1) {
return {
activeTools: [],
toolChoice: 'none',
messages: [...ctx.messages, { role: 'user', content: PROMPT_MAX_STEPS }],
};
}
}
/**
* RPC entrypoint used by `ThinkCodingBehavior` to push model/space/prompt
* configuration. Called once per app, when the host behavior initializes a
* fresh agent (each app maps to its own ThinkAgent DO — and therefore its
* own session — keyed by `agentId`). Persisted in Think's `think_config`
* table (survives hibernation), so subsequent turns resolve
* `getModel()`/`getTools()`.
*
* The system prompt is frozen + persisted on its first render and served
* from cache afterwards (see the Sessions lifecycle). Re-render it here so
* THIS session's prompt is (re)loaded from the freshly pushed model/project
* config — the `system` context block (`configureSession`) reads config
* lazily, so a refresh is what actually pulls it in for a new app instead of
* any stale cached value.
*/
async configureVibe(config: ThinkAgentConfig): Promise<void> {
this.configure<ThinkAgentConfig>(config);
try {
await this.session?.refreshSystemPrompt();
} catch {
// Session may not be wired yet on the very first configure; in that
// case the initial freezeSystemPrompt() during chat() renders with
// this config anyway.
}
}
}