Skip to content
Closed
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
254 changes: 190 additions & 64 deletions bun.lock

Large diffs are not rendered by default.

14 changes: 6 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,24 @@
"zero": "src/index.ts"
},
"scripts": {
"dev": "bun run src/index.ts",
"dev": "bun --preload @opentui/solid/preload src/index.ts",
"test": "bun test ./tests --timeout 15000",
"build": "bun build src/index.ts --compile --outfile zero"
"build": "bun run scripts/build.ts"
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19.2.15"
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@opentui/core": "^0.2.16",
"@opentui/solid": "^0.2.16",
"commander": "^15.0.0",
"execa": "^9.6.1",
"ink": "^7.0.4",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"openai": "^6.39.1",
"react": "^19.2.6",
"shiki": "^4.1.0",
"solid-js": "1.9.12",
"zod": "^4.4.3"
}
}
16 changes: 16 additions & 0 deletions scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bun
import solidPlugin from '@opentui/solid/bun-plugin';

const result = await Bun.build({
entrypoints: ['./src/index.ts'],
target: 'bun',
plugins: [solidPlugin],
outdir: './dist',
});

if (!result.success) {
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
81 changes: 41 additions & 40 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import type { Provider } from '../providers/types';
import type { ToolCall, ToolResult } from '../tools/types';
import { toolRegistry } from '../tools';
import { DEFAULT_SYSTEM_PROMPT, PLAN_MODE_SYSTEM_PROMPT } from './prompts';
import { clearPlan } from '../tools/plan';
import { clearPlan, getCurrentPlan, type PlanItem } from '../tools/plan';
import { z } from 'zod';

export interface AgentOptions {
maxTurns?: number;
onText?: (text: string) => void;
onToolCall?: (toolCall: ToolCall) => void;
onToolResult?: (result: ToolResult) => void;
toolsEnabled?: boolean; // allows temporarily disabling tool calling for debugging
debug?: boolean; // when true, logs the exact payload sent to the provider
planMode?: boolean; // when true, the agent plans without modifying the codebase
onUsage?: (usage: { promptTokens: number; completionTokens: number }) => void;
onPlanUpdate?: (plan: PlanItem[]) => void;
toolsEnabled?: boolean;
debug?: boolean;
planMode?: boolean;
}

interface PendingToolCall {
Expand All @@ -26,18 +28,21 @@ export async function runAgent(
provider: Provider,
options: AgentOptions = {}
): Promise<string> {
const {
maxTurns = 12,
onText,
onToolCall,
const {
maxTurns = 12,
onText,
onToolCall,
onToolResult,
onUsage,
onPlanUpdate,
toolsEnabled = true,
debug = false,
planMode = false
planMode = false,
} = options;

// Clear any previous plan when starting a new task
clearPlan();
onPlanUpdate?.(getCurrentPlan());

const systemPrompt = planMode ? PLAN_MODE_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT;

Expand All @@ -52,16 +57,9 @@ export async function runAgent(
for (let turn = 0; turn < maxTurns; turn++) {
const toolDefinitions = (toolsEnabled && tools.length > 0)
? tools.map(t => {
// Convert Zod schema to proper JSON Schema (critical for many providers).
// zod v4 ships this natively — no external package needed.
const jsonSchema = z.toJSONSchema(t.parameters, {
target: 'draft-7',
}) as any;

// Remove $schema if present (some providers dislike it)
const jsonSchema = z.toJSONSchema(t.parameters, { target: 'draft-7' }) as any;
delete jsonSchema.$schema;

// Make it strict by default (good practice)
if (jsonSchema.type === 'object' && !('additionalProperties' in jsonSchema)) {
jsonSchema.additionalProperties = false;
}
Expand All @@ -80,30 +78,21 @@ export async function runAgent(
if (debug) {
const red = '\x1b[31m';
const reset = '\x1b[0m';
const border = '─'.repeat(50);

console.log(`\n${red}┌${border}┐`);
console.log(`│ SENDING TO PROVIDER${' '.repeat(31)}│`);
console.log(`├${border}┤`);
console.log(`│ Messages: ${messages.length}${' '.repeat(40 - String(messages.length).length)}│`);
console.log(`│ Tools enabled: ${toolDefinitions.length > 0}${' '.repeat(33)}│`);
console.log(`│ Tool count: ${toolDefinitions.length}${' '.repeat(38 - String(toolDefinitions.length).length)}│`);

if (toolDefinitions.length > 0) {
const toolsList = toolDefinitions.map(t => t.name).join(', ');
console.log(`│ Tools: ${toolsList.slice(0, 42)}${' '.repeat(Math.max(0, 43 - toolsList.length))}│`);

// Show a sample of the schema for the first tool (very useful for debugging)
const firstTool = toolDefinitions[0];
if (firstTool.parameters) {
const schemaPreview = JSON.stringify(firstTool.parameters, null, 2).slice(0, 300);
console.log(`│ First tool schema sample:\n${schemaPreview}...`);
}
const border = '-'.repeat(50);
const toolsList = toolDefinitions.map(t => t.name).join(', ');
const preview = String(messages[messages.length - 1]?.content || '').slice(0, 45);

console.log(`\n${red}+${border}+`);
console.log(`| SENDING TO PROVIDER${' '.repeat(31)}|`);
console.log(`+${border}+`);
console.log(`| Messages: ${messages.length}${' '.repeat(Math.max(0, 40 - String(messages.length).length))}|`);
console.log(`| Tools enabled: ${toolDefinitions.length > 0}${' '.repeat(33)}|`);
console.log(`| Tool count: ${toolDefinitions.length}${' '.repeat(Math.max(0, 38 - String(toolDefinitions.length).length))}|`);
if (toolsList) {
console.log(`| Tools: ${toolsList.slice(0, 42)}${' '.repeat(Math.max(0, 43 - toolsList.length))}|`);
}

const preview = String(messages[messages.length-1]?.content || '').slice(0, 45);
console.log(`│ Last message: ${preview}${' '.repeat(Math.max(0, 36 - preview.length))}│`);
console.log(`└${border}┘${reset}\n`);
console.log(`| Last message: ${preview}${' '.repeat(Math.max(0, 36 - preview.length))}|`);
console.log(`+${border}+${reset}\n`);
}

// Stream the response
Expand All @@ -113,6 +102,13 @@ export async function runAgent(
if (onText) onText(event.content);
}

if (event.type === 'usage') {
onUsage?.({
promptTokens: event.promptTokens,
completionTokens: event.completionTokens,
});
}

if (event.type === 'tool-call-start') {
toolCallMap.set(event.id, {
id: event.id,
Expand Down Expand Up @@ -189,6 +185,11 @@ export async function runAgent(
onToolResult({ toolCallId: tc.id, result });
}

const effects = tool?.onAfterExecute?.(result);
if (effects?.planUpdated) {
onPlanUpdate?.(getCurrentPlan());
}

return { toolCallId: tc.id, result };
});

Expand Down
13 changes: 10 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { Command } from 'commander';
import { ensureSolidTransformPlugin } from '@opentui/solid/bun-plugin';
import packageJson from '../package.json';
import { runHeadless } from './cli';
import { configManager } from './config/manager';
import { startTUI } from './tui';

const program = new Command();

async function startInteractiveTUI() {
ensureSolidTransformPlugin();
const { startTUI } = await import('./tui');
startTUI();
}

program
.name('zero')
.description('A clean terminal AI coding agent')
.version('0.1.0');
.version(packageJson.version);

program
.option('-p, --prompt <prompt>', 'Run in headless mode with the given prompt')
Expand All @@ -17,7 +24,7 @@ program
await runHeadless(options.prompt);
} else {
// Launch the interactive TUI (Grok Build style)
startTUI();
await startInteractiveTUI();
}
});

Expand Down
118 changes: 81 additions & 37 deletions src/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ interface OpenAIProviderOptions {
model: string;
}

function isUnsupportedStreamOptionsError(error: unknown): boolean {
const candidate = error as {
status?: number;
code?: string;
message?: string;
error?: {
code?: string;
message?: string;
};
};

const statusAllowsRetry = candidate.status === 400 || candidate.status === 422;
const errorText = [
candidate.code,
candidate.message,
candidate.error?.code,
candidate.error?.message,
].filter(Boolean).join(' ').toLowerCase();

return statusAllowsRetry && /stream[ _-]?options?/.test(errorText);
}

export class OpenAIProvider implements Provider {
private client: OpenAI;
private model: string;
Expand Down Expand Up @@ -48,23 +70,31 @@ export class OpenAIProvider implements Provider {
}))
: undefined;

const createStreamRequest = (includeUsage: boolean) => ({
model: this.model,
messages: openaiMessages as any,
tools: openaiTools,
stream: true as const,
...(includeUsage ? {
stream_options: {
include_usage: true,
},
} : {}),
});

let stream;
try {
stream = await this.client.chat.completions.create({
model: this.model,
messages: openaiMessages as any,
tools: openaiTools,
stream: true,
});
} catch (err: any) {
const message = getDetailedErrorMessage(err);
if (message.includes('401') || message.toLowerCase().includes('invalid') || message.toLowerCase().includes('unauthorized')) {
throw new Error(`Provider authentication error (check your API key): ${message}`);
}
if (message.toLowerCase().includes('rate') || message.toLowerCase().includes('quota')) {
throw new Error(`Provider rate limit error: ${message}`);
stream = await this.client.chat.completions.create(createStreamRequest(true));
} catch (error) {
if (isUnsupportedStreamOptionsError(error)) {
try {
stream = await this.client.chat.completions.create(createStreamRequest(false));
} catch (retryError) {
throw formatProviderCreateError(retryError);
}
} else {
throw formatProviderCreateError(error);
}
throw new Error(`Provider returned error: ${message}`);
}

const toolCallAccumulators = new Map<number, {
Expand All @@ -76,11 +106,9 @@ export class OpenAIProvider implements Provider {

try {
for await (const chunk of stream) {
// Some OpenAI-compatible servers send errors as special chunks
if ((chunk as any).error) {
const errData = (chunk as any).error;
const msg = errData.message || JSON.stringify(errData);
throw new Error(`Provider returned error: ${msg}`);
const errorData = (chunk as any).error;
throw formatProviderCreateError(errorData);
}

const delta = chunk.choices[0]?.delta;
Expand All @@ -100,7 +128,7 @@ export class OpenAIProvider implements Provider {
toolCallAccumulators.set(tc.index, acc);
}

// If we already had data at this index and now get a new id,
// If we already had data at this index and now get a new id,
// the previous tool call is complete.
if (tc.id && acc.id && acc.id !== tc.id) {
if (acc.id) {
Expand Down Expand Up @@ -155,36 +183,52 @@ export class OpenAIProvider implements Provider {
}

yield { type: 'done' };
} catch (err: any) {
const message = getDetailedErrorMessage(err);
throw new Error(`Provider returned error during streaming: ${message}`);
} catch (error) {
throw formatProviderCreateError(error);
}
}
}

function getDetailedErrorMessage(err: any): string {
if (!err) return 'Unknown error';
function formatProviderCreateError(error: unknown): Error {
const message = getDetailedErrorMessage(error);
const lower = message.toLowerCase();

// Try common places where real error lives (especially for custom gateways)
if (err.message && !err.message.includes('Provider returned error')) {
return err.message;
if (message.includes('401') || lower.includes('invalid') || lower.includes('unauthorized') || lower.includes('api key')) {
return new Error(`Provider authentication error (check your API key): ${message}`);
}

if (err.error) {
if (typeof err.error === 'string') return err.error;
if (err.error.message) return err.error.message;
try { return JSON.stringify(err.error); } catch {}
if (lower.includes('rate') || lower.includes('quota')) {
return new Error(`Provider rate limit error: ${message}`);
}

if (err.response?.data) {
const data = err.response.data;
return new Error(`Provider returned error: ${message}`);
}

function getDetailedErrorMessage(error: any): string {
if (!error) return 'Unknown error';

if (error.message && !error.message.includes('Provider returned error')) {
return error.message;
}

if (error.error) {
if (typeof error.error === 'string') return error.error;
if (error.error.message) return error.error.message;
try {
return JSON.stringify(error.error);
} catch {}
}

if (error.response?.data) {
const data = error.response.data;
if (data.error?.message) return data.error.message;
if (typeof data.error === 'string') return data.error;
try { return JSON.stringify(data); } catch {}
try {
return JSON.stringify(data);
} catch {}
}

if (err.cause?.message) return err.cause.message;
if (error.cause?.message) return error.cause.message;

return err.message || String(err);
return error.message || String(error);
}

Loading