feat: add batch mode for headless Grok CLI runs (#231)

* Add batch API support for unattended headless runs.

* Handle empty child batch responses as errors.

* remove redundant markdown, bump package version

---------

Co-authored-by: Ismail Pelaseyed <homanp@gmail.com>
This commit is contained in:
Alan ZabihiandIsmail Pelaseyed authored and GitHub committed 2026-04-01 08:07:02 +02:00
1 parent 19154d22c6
commit e2ca4e3621
14 files changed
+1615 -31

No files matched your search

+11 -11
View File
@@ -8,23 +8,23 @@ Grok CLI (`@vibe-kit/grok-cli`) is a single-package TypeScript CLI tool — no d
### Quick reference
| Action | Command |
|--------|---------|
| Install deps | `bun install` (installs Husky; pre-commit runs Biome on staged files) |
| Typecheck | `bun run typecheck` |
| Build | `bun run build` |
| Run built CLI | `node dist/index.js` |
| Headless mode | `node dist/index.js --prompt "..." --max-tool-rounds N` |
| CLI help | `node dist/index.js --help` |
| Action | Command |
| ------------- | --------------------------------------------------------------------- |
| Install deps | `bun install` (installs Husky; pre-commit runs Biome on staged files) |
| Typecheck | `bun run typecheck` |
| Build | `bun run build` |
| Run built CLI | `node dist/index.js` |
| Headless mode | `node dist/index.js --prompt "..." --max-tool-rounds N` |
| CLI help | `node dist/index.js --help` |
### Known issues
- **ESLint config is broken**: The repo has `.eslintrc.js` (legacy format) but uses ESLint 9 (`^9.31.0`) + `@typescript-eslint` v8, which require flat config (`eslint.config.js`). Additionally, `.eslintrc.js` uses `module.exports` (CJS) but `package.json` has `"type": "module"` (ESM). Running `bun run lint` will fail. Use `bun run typecheck` as the primary code quality check (this is also what CI enforces).
- **Dev mode (`bun run dev` / `bun run dev:node`) fails at runtime**: `src/utils/model-config.ts` imports TypeScript interfaces (`UserSettings`, `ProjectSettings`) as value imports from `settings-manager.ts`. These type-only exports are erased at runtime by Bun and tsx, causing `SyntaxError: export '...' not found`. The fix is to use `import type` syntax, but this is a pre-existing repo issue. **Workaround**: build first (`bun run build`), then run the compiled version (`node dist/index.js`).
- **Grok API 410 error**: The client sends `search_parameters` in every API request, but xAI has deprecated "Live search". This causes a 410 error on headless prompts. This is an API compatibility issue in the codebase, not an environment problem.
### Environment
- **Bun** must be installed (not pre-installed on Cloud VMs). The update script handles this.
- `GROK_API_KEY` environment variable is required for API calls. Set it as a secret.
- `MORPH_API_KEY` is optional (enables Morph Fast Apply editing).
- `GROK_API_KEY` environment variable is required for API calls. Set it as a secret.
+5
View File
@@ -60,9 +60,14 @@ grok --prompt "run the test suite and summarize failures"
grok -p "show me package.json" --directory /path/to/project
grok --prompt "refactor X" --max-tool-rounds 30
grok --prompt "summarize the repo state" --format json
grok --prompt "review the repo overnight" --batch-api
grok --verify
```
`--batch-api` uses xAI's Batch API for lower-cost unattended runs. It is a good
fit for scripts, CI, schedules, and other non-interactive workflows where a
delayed result is fine.
**Continue a saved session:**
```bash
+12 -1
View File
@@ -6,6 +6,7 @@
"name": "@vibe-kit/grok-cli",
"dependencies": {
"@ai-sdk/mcp": "^1.0.25",
"@ai-sdk/provider-utils": "^4.0.21",
"@ai-sdk/xai": "^3.0.67",
"@modelcontextprotocol/sdk": "^1.27.1",
"@opentui/core": "^0.1.88",
@@ -41,7 +42,7 @@
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.67", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KQQIDc91dUA5IGFMnXBuvPBeraYNTdpDC1qUS+JG8vE+/299//5sZFafI1kKYUu3f3p7LaZrKXYgZ1Ni7QIRbw=="],
@@ -679,6 +680,14 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -713,6 +722,8 @@
"@opentui/core/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
"lint-staged/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "grok-dev",
"version": "1.1.0",
"version": "1.1.1",
"description": "An open-source AI coding agent powered by Grok, built with Bun and OpenTUI.",
"type": "module",
"main": "dist/index.js",
@@ -43,6 +43,7 @@
"license": "MIT",
"dependencies": {
"@ai-sdk/mcp": "^1.0.25",
"@ai-sdk/provider-utils": "^4.0.21",
"@ai-sdk/xai": "^3.0.67",
"@modelcontextprotocol/sdk": "^1.27.1",
"@opentui/core": "^0.1.88",
+776
View File
@@ -1,6 +1,20 @@
import { convertToBase64 } from "@ai-sdk/provider-utils";
import { type ModelMessage, stepCountIs, streamText, type ToolSet } from "ai";
import {
addBatchRequests,
type BatchChatCompletionRequest,
type BatchChatCompletionResponse,
type BatchChatMessage,
type BatchClientOptions,
type BatchFunctionTool,
type BatchToolCall,
createBatch,
getBatchChatCompletion,
pollBatchRequestResult,
} from "../grok/batch";
import { createProvider, generateTitle as genTitle, resolveModelRuntime, type XaiProvider } from "../grok/client";
import { DEFAULT_MODEL, getModelInfo, normalizeModelId } from "../grok/models";
import { toolSetToBatchTools } from "../grok/tool-schemas";
import { createTools } from "../grok/tools";
import { buildMcpToolSet } from "../mcp/runtime";
import {
@@ -65,6 +79,7 @@ interface AgentOptions {
session?: string;
sandboxMode?: SandboxMode;
sandboxSettings?: SandboxSettings;
batchApi?: boolean;
}
type ProcessMessageFinishReason = "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
@@ -73,6 +88,7 @@ export interface ProcessMessageUsage {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
costUsdTicks?: number;
}
export interface ProcessMessageStepStart {
@@ -418,6 +434,7 @@ export class Agent {
private planContext: string | null = null;
private subagentStatusListeners = new Set<(status: SubagentStatus | null) => void>();
private sendTelegramFile: ((filePath: string) => Promise<ToolResult>) | null = null;
private batchApi = false;
constructor(
apiKey: string | undefined,
@@ -443,6 +460,7 @@ export class Agent {
this.maxToolRounds = maxToolRounds || MAX_TOOL_ROUNDS;
const envMax = Number(process.env.GROK_MAX_TOKENS);
this.maxTokens = Number.isFinite(envMax) && envMax > 0 ? envMax : 16_384;
this.batchApi = options.batchApi ?? false;
if (options.persistSession !== false) {
this.sessionStore = new SessionStore(this.bash.getCwd());
@@ -679,6 +697,219 @@ export class Agent {
}
}
private getBatchClientOptions(signal?: AbortSignal): BatchClientOptions {
if (!this.apiKey) {
throw new Error("API key required. Add an API key to continue.");
}
return {
apiKey: this.apiKey,
baseURL: this.baseURL ?? undefined,
signal,
};
}
private async executeBatchToolCall(
tools: ToolSet,
toolCall: ToolCall,
messages: ModelMessage[],
signal?: AbortSignal,
): Promise<{ input: unknown; result: ToolResult }> {
const tool = tools[toolCall.function.name];
if (!tool || tool.type === "provider" || typeof tool.execute !== "function") {
return {
input: parseToolArgumentsOrRaw(toolCall.function.arguments),
result: {
success: false,
output: `Tool "${toolCall.function.name}" is unavailable in batch mode.`,
},
};
}
let parsedInput: unknown;
try {
parsedInput = toolCall.function.arguments.trim() ? JSON.parse(toolCall.function.arguments) : {};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
input: toolCall.function.arguments,
result: {
success: false,
output: `Tool "${toolCall.function.name}" received invalid JSON arguments: ${message}`,
},
};
}
try {
const output = await tool.execute(parsedInput as never, {
toolCallId: toolCall.id,
messages,
abortSignal: signal,
});
return {
input: parsedInput,
result: toToolResult(output),
};
} catch (error) {
if (signal?.aborted) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
return {
input: parsedInput,
result: {
success: false,
output: `Tool "${toolCall.function.name}" failed: ${message}`,
},
};
}
}
private async runTaskRequestBatch(args: {
request: TaskRequest;
childMessages: ModelMessage[];
childSystem: string;
childRuntime: ReturnType<typeof resolveModelRuntime>;
childTools: ToolSet;
maxSteps: number;
initialDetail: string;
onActivity?: (detail: string) => void;
signal?: AbortSignal;
}): Promise<ToolResult> {
const {
request,
childMessages,
childSystem,
childRuntime,
childTools,
maxSteps,
initialDetail,
onActivity,
signal,
} = args;
if (childRuntime.modelInfo?.responsesOnly) {
throw new Error("Batch mode currently supports chat-completions models only.");
}
const batchTools =
childRuntime.modelInfo?.supportsClientTools === false ? [] : await toolSetToBatchTools(childTools);
const batch = await createBatch({
...this.getBatchClientOptions(signal),
name: buildBatchName(`task-${request.agent}`, request.description),
});
const turnMessages: ModelMessage[] = [];
const totalUsage: ProcessMessageUsage = {};
let assistantText = "";
let lastActivity = initialDetail;
for (let round = 0; round < maxSteps; round++) {
const batchRequestId = `task-${Date.now()}-${round + 1}`;
await addBatchRequests({
...this.getBatchClientOptions(signal),
batchId: batch.batch_id,
batchRequests: [
{
batch_request_id: batchRequestId,
batch_request: {
chat_get_completion: buildBatchChatCompletionRequest({
modelId: childRuntime.modelId,
system: childSystem,
messages: [...childMessages, ...turnMessages],
temperature: request.agent === "explore" ? 0.2 : 0.5,
maxOutputTokens:
childRuntime.modelInfo?.supportsMaxOutputTokens === false
? undefined
: Math.min(this.maxTokens, 8_192),
reasoningEffort: childRuntime.providerOptions?.xai.reasoningEffort,
tools: batchTools,
}),
},
},
],
});
const result = await pollBatchRequestResult({
...this.getBatchClientOptions(signal),
batchId: batch.batch_id,
batchRequestId,
});
const response = getBatchChatCompletion(result);
accumulateUsage(totalUsage, getBatchUsage(response));
const choice = response.choices[0];
if (!choice) {
throw new Error("Batch response did not contain any choices.");
}
const content = choice?.message.content ?? "";
if (content) {
assistantText += content;
}
const requestMessages = [...childMessages, ...turnMessages];
const toolCalls = (choice?.message.tool_calls ?? []).map(toLocalToolCall);
const assistantMessage = buildAssistantBatchMessage(content, toolCalls);
if (assistantMessage) {
turnMessages.push(assistantMessage);
}
if (toolCalls.length === 0) {
if (hasUsage(totalUsage)) {
this.recordUsage(totalUsage, "task", childRuntime.modelId);
}
const output = assistantText.trim() || `Task completed. Last action: ${lastActivity}`;
return {
success: true,
output,
task: {
agent: request.agent,
description: request.description,
summary: firstLine(output),
activity: lastActivity,
},
};
}
const toolParts: ExecutedBatchTool[] = [];
for (const toolCall of toolCalls) {
const nextActivity = formatSubagentActivity(
toolCall.function.name,
parseToolArgumentsOrRaw(toolCall.function.arguments),
);
lastActivity = nextActivity;
onActivity?.(nextActivity);
const executed = await this.executeBatchToolCall(childTools, toolCall, requestMessages, signal);
toolParts.push({
toolCall,
input: executed.input,
toolResult: executed.result,
});
}
const toolMessage = buildToolBatchMessage(toolParts);
if (toolMessage) {
turnMessages.push(toolMessage);
}
}
if (hasUsage(totalUsage)) {
this.recordUsage(totalUsage, "task", childRuntime.modelId);
}
const output = assistantText.trim() || `Task stopped after ${maxSteps} batch rounds. Last action: ${lastActivity}`;
return {
success: false,
output,
task: {
agent: request.agent,
description: request.description,
summary: output,
activity: lastActivity,
},
};
}
async runTaskRequest(
request: TaskRequest,
onActivity?: (detail: string) => void,
@@ -768,6 +999,20 @@ export class Agent {
? await buildVisionUserMessages(request.prompt, childBash.getCwd(), signal)
: [{ role: "user" as const, content: request.prompt }];
if (this.batchApi) {
return await this.runTaskRequestBatch({
request,
childMessages,
childSystem,
childRuntime,
childTools,
maxSteps: Math.min(this.maxToolRounds, isExplore ? 60 : 120),
initialDetail,
onActivity,
signal,
});
}
const result = streamText({
model: childRuntime.model,
system: childSystem,
@@ -869,6 +1114,7 @@ export class Agent {
sandboxSettings: this.bash.getSandboxSettings(),
maxToolRounds: this.maxToolRounds,
maxTokens: this.maxTokens,
batchApi: this.batchApi,
});
} catch (err: unknown) {
if (abortSignal?.aborted) throw err;
@@ -956,6 +1202,218 @@ export class Agent {
return true;
}
private async *processMessageBatchTurn(args: {
userModelMessage: ModelMessage;
observer?: ProcessMessageObserver;
provider: XaiProvider;
subagents: CustomSubagentConfig[];
system: string;
runtime: ReturnType<typeof resolveModelRuntime>;
modelInfo: ReturnType<typeof getModelInfo>;
signal: AbortSignal;
}): AsyncGenerator<StreamChunk, void, unknown> {
const { userModelMessage, observer, provider, subagents, system, runtime, modelInfo, signal } = args;
let attemptedOverflowRecovery = false;
while (true) {
let closeMcp: (() => Promise<void>) | undefined;
const turnMessages: ModelMessage[] = [];
const totalUsage: ProcessMessageUsage = {};
try {
const settings = attemptedOverflowRecovery
? relaxCompactionSettings(this.getCompactionSettings())
: this.getCompactionSettings();
if (modelInfo) {
await this.compactForContext(
provider,
system,
modelInfo.contextWindow,
signal,
settings,
attemptedOverflowRecovery,
);
}
if (runtime.modelInfo?.responsesOnly) {
throw new Error("Batch mode currently supports chat-completions models only.");
}
const baseTools = createTools(this.bash, provider, this.mode, {
runTask: (request, abortSignal) => this.runTask(request, combineAbortSignals(signal, abortSignal)),
runDelegation: (request, abortSignal) =>
this.runDelegation(request, combineAbortSignals(signal, abortSignal)),
readDelegation: (id) => this.readDelegation(id),
listDelegations: () => this.listDelegations(),
scheduleManager: this.schedules,
subagents,
sendTelegramFile: this.sendTelegramFile ?? undefined,
});
let tools: ToolSet = runtime.modelInfo?.supportsClientTools === false ? {} : baseTools;
if (this.mode === "agent" && runtime.modelInfo?.supportsClientTools !== false) {
const mcpBundle = await buildMcpToolSet(loadMcpServers());
closeMcp = mcpBundle.close;
tools = { ...baseTools, ...mcpBundle.tools };
if (mcpBundle.errors.length > 0) {
yield { type: "content", content: `MCP unavailable: ${mcpBundle.errors.join(" | ")}\n\n` };
}
}
const batchTools = runtime.modelInfo?.supportsClientTools === false ? [] : await toolSetToBatchTools(tools);
const batch = await createBatch({
...this.getBatchClientOptions(signal),
name: buildBatchName("session", this.getSessionId() || runtime.modelId),
});
for (let round = 0; round < this.maxToolRounds; round++) {
const stepNumber = round + 1;
notifyObserver(observer?.onStepStart, {
stepNumber,
timestamp: Date.now(),
});
const batchRequestId = `turn-${Date.now()}-${stepNumber}`;
await addBatchRequests({
...this.getBatchClientOptions(signal),
batchId: batch.batch_id,
batchRequests: [
{
batch_request_id: batchRequestId,
batch_request: {
chat_get_completion: buildBatchChatCompletionRequest({
modelId: runtime.modelId,
system,
messages: [...this.messages, ...turnMessages],
temperature: 0.7,
maxOutputTokens: runtime.modelInfo?.supportsMaxOutputTokens === false ? undefined : this.maxTokens,
reasoningEffort: runtime.providerOptions?.xai.reasoningEffort,
tools: batchTools,
}),
},
},
],
});
const result = await pollBatchRequestResult({
...this.getBatchClientOptions(signal),
batchId: batch.batch_id,
batchRequestId,
});
const response = getBatchChatCompletion(result);
const choice = response.choices[0];
if (!choice) {
throw new Error("Batch response did not contain any choices.");
}
const usage = getBatchUsage(response);
accumulateUsage(totalUsage, usage);
const finishReason = getBatchFinishReason(choice.finish_reason);
const content = choice.message.content ?? "";
if (content) {
yield { type: "content", content };
}
const requestMessages = [...this.messages, ...turnMessages];
const toolCalls = (choice.message.tool_calls ?? []).map(toLocalToolCall);
const assistantMessage = buildAssistantBatchMessage(content, toolCalls);
if (assistantMessage) {
turnMessages.push(assistantMessage);
}
if (toolCalls.length === 0) {
notifyObserver(observer?.onStepFinish, {
stepNumber,
timestamp: Date.now(),
finishReason,
usage,
});
if (hasUsage(totalUsage)) {
this.recordUsage(totalUsage, "message", runtime.modelId);
}
this.appendCompletedTurn(userModelMessage, turnMessages);
yield { type: "done" };
return;
}
yield { type: "tool_calls", toolCalls };
const toolParts: ExecutedBatchTool[] = [];
for (const toolCall of toolCalls) {
notifyObserver(observer?.onToolStart, {
toolCall,
timestamp: Date.now(),
});
const executed = await this.executeBatchToolCall(tools, toolCall, requestMessages, signal);
notifyObserver(observer?.onToolFinish, {
toolCall,
toolResult: executed.result,
timestamp: Date.now(),
});
yield { type: "tool_result", toolCall, toolResult: executed.result };
toolParts.push({
toolCall,
input: executed.input,
toolResult: executed.result,
});
}
const toolMessage = buildToolBatchMessage(toolParts);
if (toolMessage) {
turnMessages.push(toolMessage);
}
notifyObserver(observer?.onStepFinish, {
stepNumber,
timestamp: Date.now(),
finishReason,
usage,
});
}
const message = `Error: Reached max tool rounds (${this.maxToolRounds}) in batch mode.`;
notifyObserver(observer?.onError, {
message,
timestamp: Date.now(),
});
if (hasUsage(totalUsage)) {
this.recordUsage(totalUsage, "message", runtime.modelId);
}
this.appendCompletedTurn(userModelMessage, turnMessages);
yield { type: "error", content: message };
yield { type: "done" };
return;
} catch (err: unknown) {
if (signal.aborted) {
this.discardAbortedTurn(userModelMessage);
yield { type: "content", content: "\n\n[Cancelled]" };
yield { type: "done" };
return;
}
if (!attemptedOverflowRecovery && turnMessages.length === 0 && modelInfo && isContextLimitError(err)) {
attemptedOverflowRecovery = true;
continue;
}
const msg = err instanceof Error ? err.message : String(err);
notifyObserver(observer?.onError, {
message: `Error: ${msg}`,
timestamp: Date.now(),
});
if (hasUsage(totalUsage)) {
this.recordUsage(totalUsage, "message", runtime.modelId);
}
this.appendCompletedTurn(userModelMessage, turnMessages);
yield { type: "error", content: `Error: ${msg}` };
yield { type: "done" };
return;
} finally {
await closeMcp?.().catch(() => {});
}
}
}
private appendCompletedTurn(userMessage: ModelMessage, newMessages: ModelMessage[]): void {
if (newMessages.length === 0) return;
@@ -1010,6 +1468,26 @@ export class Agent {
this.planContext = null;
let attemptedOverflowRecovery = false;
if (this.batchApi) {
try {
yield* this.processMessageBatchTurn({
userModelMessage,
observer,
provider,
subagents,
system,
runtime,
modelInfo,
signal,
});
} finally {
if (this.abortController?.signal === signal) {
this.abortController = null;
}
}
return;
}
try {
while (true) {
let assistantText = "";
@@ -1253,6 +1731,14 @@ export class Agent {
}
}
interface ExecutedBatchTool {
toolCall: ToolCall;
input: unknown;
toolResult: ToolResult;
}
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
function extractJsonObject(text: string): string | null {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
@@ -1260,6 +1746,296 @@ function extractJsonObject(text: string): string | null {
return text.slice(start, end + 1);
}
function buildBatchName(prefix: string, label: string): string {
const compact =
label
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9._-]+/g, "")
.slice(0, 48) || "run";
return `grok-cli-${prefix}-${compact}`;
}
function buildBatchChatCompletionRequest(args: {
modelId: string;
system: string;
messages: ModelMessage[];
temperature: number;
maxOutputTokens?: number;
reasoningEffort?: BatchChatCompletionRequest["reasoning_effort"];
tools: BatchFunctionTool[];
}): BatchChatCompletionRequest {
return {
model: args.modelId,
messages: toBatchChatMessages(args.system, args.messages),
temperature: args.temperature,
...(args.maxOutputTokens != null ? { max_completion_tokens: args.maxOutputTokens } : {}),
...(args.reasoningEffort ? { reasoning_effort: args.reasoningEffort } : {}),
...(args.tools.length > 0 ? { tools: args.tools } : {}),
};
}
function toBatchChatMessages(system: string, messages: ModelMessage[]): BatchChatMessage[] {
const batchMessages: BatchChatMessage[] = [{ role: "system", content: system }];
for (const message of messages) {
const { role, content } = message;
switch (role) {
case "system":
batchMessages.push({ role: "system", content });
break;
case "user": {
if (typeof content === "string") {
batchMessages.push({ role: "user", content });
break;
}
if (!Array.isArray(content)) {
break;
}
if (content.length === 1 && content[0]?.type === "text") {
batchMessages.push({ role: "user", content: content[0].text });
break;
}
const userContent: Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> =
[];
for (const part of content) {
switch (part.type) {
case "text":
userContent.push({ type: "text", text: part.text });
break;
case "image": {
const mediaType = part.mediaType === "image/*" || !part.mediaType ? "image/jpeg" : part.mediaType;
const data =
part.image instanceof URL
? part.image.toString()
: `data:${mediaType};base64,${toBase64DataContent(part.image)}`;
userContent.push({ type: "image_url", image_url: { url: data } });
break;
}
case "file": {
if (!part.mediaType.startsWith("image/")) {
break;
}
const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType;
const data =
part.data instanceof URL
? part.data.toString()
: `data:${mediaType};base64,${toBase64DataContent(part.data)}`;
userContent.push({ type: "image_url", image_url: { url: data } });
break;
}
}
}
batchMessages.push({
role: "user",
content: userContent,
});
break;
}
case "assistant": {
if (typeof content === "string") {
batchMessages.push({ role: "assistant", content });
break;
}
if (!Array.isArray(content)) {
break;
}
let assistantText = "";
const toolCalls: BatchToolCall[] = [];
for (const part of content) {
if (part.type === "text") {
assistantText += part.text;
} else if (part.type === "tool-call") {
toolCalls.push({
id: part.toolCallId,
type: "function",
function: {
name: part.toolName,
arguments: JSON.stringify(part.input),
},
});
}
}
if (assistantText || toolCalls.length > 0) {
batchMessages.push({
role: "assistant",
content: assistantText,
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
});
}
break;
}
case "tool":
for (const part of content) {
if (part.type === "tool-approval-response") {
continue;
}
batchMessages.push({
role: "tool",
tool_call_id: part.toolCallId,
content: toolOutputToText(part.output),
});
}
break;
}
}
return batchMessages;
}
function toBase64DataContent(value: string | Uint8Array | ArrayBuffer): string {
return convertToBase64(value instanceof ArrayBuffer ? new Uint8Array(value) : value);
}
function toolOutputToText(output: {
type: "text" | "json" | "execution-denied" | "error-text" | "error-json" | "content";
value?: unknown;
reason?: string;
}): string {
switch (output.type) {
case "text":
case "error-text":
return String(output.value ?? "");
case "execution-denied":
return output.reason ?? "Tool execution denied.";
case "json":
case "error-json":
case "content":
return JSON.stringify(output.value ?? null);
}
}
function getBatchUsage(response: BatchChatCompletionResponse): ProcessMessageUsage {
const usage = response.usage ?? {};
const inputTokens = asNumber(usage.input_tokens) ?? asNumber(usage.prompt_tokens);
const outputTokens = asNumber(usage.output_tokens) ?? asNumber(usage.completion_tokens);
const totalTokens = asNumber(usage.total_tokens) ?? sumDefined(inputTokens, outputTokens);
return {
inputTokens,
outputTokens,
totalTokens,
costUsdTicks: asNumber(usage.cost_in_usd_ticks),
};
}
function accumulateUsage(target: ProcessMessageUsage, usage: ProcessMessageUsage): void {
target.inputTokens = (target.inputTokens ?? 0) + (usage.inputTokens ?? 0);
target.outputTokens = (target.outputTokens ?? 0) + (usage.outputTokens ?? 0);
target.totalTokens = (target.totalTokens ?? 0) + (usage.totalTokens ?? 0);
target.costUsdTicks = (target.costUsdTicks ?? 0) + (usage.costUsdTicks ?? 0);
}
function hasUsage(usage: ProcessMessageUsage): boolean {
return Boolean(
(usage.inputTokens ?? 0) || (usage.outputTokens ?? 0) || (usage.totalTokens ?? 0) || (usage.costUsdTicks ?? 0),
);
}
function getBatchFinishReason(finishReason: string | null | undefined): ProcessMessageFinishReason {
switch (finishReason) {
case "stop":
case "length":
case "content-filter":
case "tool-calls":
case "error":
case "other":
return finishReason;
case "tool_calls":
return "tool-calls";
default:
return "other";
}
}
function toLocalToolCall(toolCall: BatchToolCall): ToolCall {
return {
id: toolCall.id,
type: "function",
function: {
name: toolCall.function.name,
arguments: toolCall.function.arguments,
},
};
}
function buildAssistantBatchMessage(content: string, toolCalls: ToolCall[]): ModelMessage | null {
if (toolCalls.length === 0) {
return content ? { role: "assistant", content } : null;
}
const parts: Array<
{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
> = [];
if (content) {
parts.push({ type: "text", text: content });
}
for (const toolCall of toolCalls) {
parts.push({
type: "tool-call",
toolCallId: toolCall.id,
toolName: toolCall.function.name,
input: parseToolArgumentsOrRaw(toolCall.function.arguments),
});
}
return { role: "assistant", content: parts };
}
function buildToolBatchMessage(toolParts: ExecutedBatchTool[]): ModelMessage | null {
if (toolParts.length === 0) {
return null;
}
return {
role: "tool",
content: toolParts.map((part) => ({
type: "tool-result" as const,
toolCallId: part.toolCall.id,
toolName: part.toolCall.function.name,
output: part.toolResult.success
? ({ type: "json", value: toSerializableValue(part.toolResult) } as const)
: ({ type: "error-json", value: toSerializableValue(part.toolResult) } as const),
})),
};
}
function parseToolArgumentsOrRaw(raw: string): unknown {
try {
return raw.trim() ? JSON.parse(raw) : {};
} catch {
return raw;
}
}
function toSerializableValue(value: unknown): JsonValue {
try {
return JSON.parse(JSON.stringify(value ?? null)) as JsonValue;
} catch {
return String(value);
}
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function sumDefined(left?: number, right?: number): number | undefined {
if (left == null && right == null) {
return undefined;
}
return (left ?? 0) + (right ?? 0);
}
function toToolCall(part: { toolCallId: string; toolName: string; args?: unknown; input?: unknown }): ToolCall {
return {
id: part.toolCallId,
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it, vi } from "vitest";
async function importAgentModuleWithBatchMocks() {
vi.resetModules();
vi.doMock("../storage/index", () => ({
appendCompaction: vi.fn(),
appendMessages: vi.fn(() => []),
appendSystemMessage: vi.fn(() => 0),
buildChatEntries: vi.fn(() => []),
getNextMessageSequence: vi.fn(() => 0),
getSessionTotalTokens: vi.fn(() => 0),
loadTranscript: vi.fn(() => []),
loadTranscriptState: vi.fn(() => ({ messages: [], seqs: [] })),
recordUsageEvent: vi.fn(),
SessionStore: class {
getWorkspace() {
return null;
}
openSession() {
return null;
}
createSession() {
return null;
}
setModel() {}
getRequiredSession() {
return null;
}
setMode() {}
touchSession() {}
},
}));
const createBatch = vi.fn(async () => ({
batch_id: "batch-1",
state: {
num_requests: 0,
num_pending: 0,
num_success: 0,
num_error: 0,
num_cancelled: 0,
},
}));
const addBatchRequests = vi.fn(async () => {});
const pollBatchRequestResult = vi.fn(async () => ({ batch_request_id: "req-1" }));
const getBatchChatCompletion = vi.fn(() => ({
choices: [],
usage: {},
}));
vi.doMock("../grok/batch", async () => {
const actual = await vi.importActual<typeof import("../grok/batch")>("../grok/batch");
return {
...actual,
createBatch,
addBatchRequests,
pollBatchRequestResult,
getBatchChatCompletion,
};
});
const mod = await import("./agent");
return {
...mod,
mocks: {
createBatch,
addBatchRequests,
pollBatchRequestResult,
getBatchChatCompletion,
},
};
}
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock("../storage/index");
vi.doUnmock("../grok/batch");
});
describe("Agent batch mode", () => {
it("throws when a child batch response has no choices", async () => {
const { Agent, mocks } = await importAgentModuleWithBatchMocks();
const agent = new Agent("test-key", "https://api.x.ai/v1", undefined, undefined, {
persistSession: false,
});
const runTaskRequestBatch = (
agent as unknown as {
runTaskRequestBatch: (args: {
request: { agent: string; description: string; prompt: string };
childMessages: Array<{ role: "user"; content: string }>;
childSystem: string;
childRuntime: unknown;
childTools: Record<string, never>;
maxSteps: number;
initialDetail: string;
}) => Promise<unknown>;
}
).runTaskRequestBatch.bind(agent);
await expect(
runTaskRequestBatch({
request: {
agent: "general",
description: "Child task",
prompt: "Do the thing",
},
childMessages: [{ role: "user", content: "Do the thing" }],
childSystem: "system",
childRuntime: {
modelId: "grok-4-1-fast-reasoning",
modelInfo: {
supportsClientTools: false,
supportsMaxOutputTokens: true,
},
providerOptions: undefined,
},
childTools: {},
maxSteps: 1,
initialDetail: "Starting child task",
}),
).rejects.toThrow("Batch response did not contain any choices.");
expect(mocks.createBatch).toHaveBeenCalled();
expect(mocks.addBatchRequests).toHaveBeenCalled();
expect(mocks.pollBatchRequestResult).toHaveBeenCalled();
expect(mocks.getBatchChatCompletion).toHaveBeenCalled();
});
});
+15 -15
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const tempDirs: string[] = [];
const originalHome = process.env.HOME;
function makeTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -11,19 +12,22 @@ function makeTempDir(prefix: string): string {
return dir;
}
function readStoredDelegationRecord(home: string): Record<string, unknown> {
const delegationsRoot = path.join(home, ".grok", "delegations");
const projectDirs = fs.readdirSync(delegationsRoot);
expect(projectDirs).toHaveLength(1);
const projectDir = path.join(delegationsRoot, projectDirs[0] as string);
const files = fs.readdirSync(projectDir).filter((file) => file.endsWith(".json"));
expect(files).toHaveLength(1);
return JSON.parse(fs.readFileSync(path.join(projectDir, files[0] as string), "utf8")) as Record<string, unknown>;
}
async function importDelegationsModule(options: { home?: string; spawnMock?: ReturnType<typeof vi.fn> } = {}) {
vi.resetModules();
vi.doUnmock("os");
vi.doUnmock("child_process");
if (options.home) {
vi.doMock("os", async () => {
const actual = await vi.importActual<typeof import("os")>("os");
return {
...actual,
homedir: () => options.home!,
};
});
process.env.HOME = options.home;
}
if (options.spawnMock) {
@@ -43,9 +47,9 @@ afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
process.env.HOME = originalHome;
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock("os");
vi.doUnmock("child_process");
});
@@ -81,9 +85,7 @@ describe("DelegationManager sandbox propagation", () => {
expect.objectContaining({ cwd }),
);
const cliArgs = (spawnMock.mock.calls[0] as unknown[])?.[1] as string[];
const jobPath = cliArgs[cliArgs.indexOf("--background-task-file") + 1] as string;
const record = JSON.parse(fs.readFileSync(jobPath, "utf8")) as {
const record = readStoredDelegationRecord(home) as {
sandboxMode: string;
pid: number;
};
@@ -124,9 +126,7 @@ describe("DelegationManager sandbox propagation", () => {
},
);
const cliArgs = (spawnMock.mock.calls[0] as unknown[])?.[1] as string[];
const jobPath = cliArgs[cliArgs.indexOf("--background-task-file") + 1] as string;
const record = JSON.parse(fs.readFileSync(jobPath, "utf8")) as {
const record = readStoredDelegationRecord(home) as {
sandboxMode: string;
sandboxSettings?: {
allowNet: boolean;
+11 -1
View File
@@ -23,6 +23,7 @@ export interface StoredDelegation {
sandboxSettings?: SandboxSettings;
maxToolRounds: number;
maxTokens: number;
batchApi?: boolean;
status: DelegationStatus;
startedAt: string;
completedAt?: string;
@@ -45,6 +46,7 @@ interface StartDelegationOptions {
sandboxSettings?: SandboxSettings;
maxToolRounds: number;
maxTokens: number;
batchApi?: boolean;
}
export class DelegationManager {
@@ -83,6 +85,7 @@ export class DelegationManager {
sandboxSettings: options.sandboxSettings,
maxToolRounds: options.maxToolRounds,
maxTokens: options.maxTokens,
batchApi: options.batchApi,
status: "running",
startedAt: new Date().toISOString(),
outputPath,
@@ -92,7 +95,14 @@ export class DelegationManager {
const child = spawn(
process.execPath,
[...resolveCliArgs(), "--directory", cwd, "--background-task-file", jobPath],
[
...resolveCliArgs(),
"--directory",
cwd,
"--background-task-file",
jobPath,
...(options.batchApi ? ["--batch-api"] : []),
],
{
cwd,
detached: true,
+166
View File
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getBatchChatCompletion, pollBatchRequestResult } from "./batch";
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
describe("getBatchChatCompletion", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("extracts the chat completion payload from a batch result", () => {
const response = getBatchChatCompletion({
batch_request_id: "req-1",
batch_result: {
response: {
chat_get_completion: {
id: "chatcmpl-1",
choices: [
{
finish_reason: "stop",
message: {
content: "done",
},
},
],
usage: {
total_tokens: 12,
},
},
},
},
});
expect(response.choices[0]?.message.content).toBe("done");
expect(response.usage?.total_tokens).toBe(12);
});
it("throws when the batch result is an error", () => {
expect(() =>
getBatchChatCompletion({
batch_request_id: "req-2",
error_message: "boom",
}),
).toThrow('Batch request "req-2" failed: boom');
});
it("waits through transient ClickHouse visibility errors before returning a result", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
results: [
{
batch_request_id: "req-3",
batch_result: {
error: "Batch request not yet visible in ClickHouse",
},
},
],
pagination_token: null,
}),
)
.mockResolvedValueOnce(
jsonResponse({
batch_id: "batch-1",
state: {
num_requests: 1,
num_pending: 1,
num_success: 0,
num_error: 0,
num_cancelled: 0,
},
}),
)
.mockResolvedValueOnce(
jsonResponse({
results: [
{
batch_request_id: "req-3",
batch_result: {
response: {
chat_get_completion: {
choices: [{ message: { content: "done" } }],
},
},
},
},
],
pagination_token: null,
}),
);
vi.stubGlobal("fetch", fetchMock);
const result = await pollBatchRequestResult({
apiKey: "test-key",
batchId: "batch-1",
batchRequestId: "req-3",
initialPollMs: 0,
maxPollMs: 0,
timeoutMs: 1000,
});
expect(result.batch_result?.response?.chat_get_completion?.choices[0]?.message.content).toBe("done");
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("keeps polling after batch completion until the result row becomes visible", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
results: [],
pagination_token: null,
}),
)
.mockResolvedValueOnce(
jsonResponse({
batch_id: "batch-2",
state: {
num_requests: 1,
num_pending: 0,
num_success: 1,
num_error: 0,
num_cancelled: 0,
},
}),
)
.mockResolvedValueOnce(
jsonResponse({
results: [
{
batch_request_id: "req-4",
batch_result: {
response: {
chat_get_completion: {
choices: [{ message: { content: "visible now" } }],
},
},
},
},
],
pagination_token: null,
}),
);
vi.stubGlobal("fetch", fetchMock);
const result = await pollBatchRequestResult({
apiKey: "test-key",
batchId: "batch-2",
batchRequestId: "req-4",
initialPollMs: 0,
maxPollMs: 0,
timeoutMs: 1000,
});
expect(result.batch_result?.response?.chat_get_completion?.choices[0]?.message.content).toBe("visible now");
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
+405
View File
@@ -0,0 +1,405 @@
import process from "node:process";
const DEFAULT_BASE_URL = "https://api.x.ai/v1";
const DEFAULT_INITIAL_POLL_MS = 2_000;
const DEFAULT_MAX_POLL_MS = 30_000;
const DEFAULT_TIMEOUT_MS = 10 * 60_000;
const DEFAULT_BATCH_PAGE_SIZE = 100;
const DEFAULT_RATE_LIMIT_RETRIES = 5;
export interface BatchClientOptions {
apiKey: string;
baseURL?: string;
signal?: AbortSignal;
}
export interface BatchState {
num_requests: number;
num_pending: number;
num_success: number;
num_error: number;
num_cancelled: number;
}
export interface BatchCostBreakdown {
total_cost_usd_ticks?: number;
}
export interface BatchInfo {
batch_id: string;
name?: string;
expires_at?: string;
state: BatchState;
cost_breakdown?: BatchCostBreakdown;
}
export interface BatchFunctionTool {
type: "function";
function: {
name: string;
description?: string;
parameters: unknown;
strict?: boolean;
};
}
export interface BatchToolCall {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}
export interface BatchChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
tool_calls?: BatchToolCall[];
tool_call_id?: string;
}
export interface BatchChatCompletionRequest {
model: string;
messages: BatchChatMessage[];
temperature?: number;
max_completion_tokens?: number;
reasoning_effort?: "low" | "medium" | "high" | "xhigh";
tools?: BatchFunctionTool[];
tool_choice?:
| "auto"
| "none"
| "required"
| {
type: "function";
function: {
name: string;
};
};
}
export interface BatchRequestEntry {
batch_request_id: string;
batch_request: {
chat_get_completion: BatchChatCompletionRequest;
};
}
export interface BatchChatCompletionChoice {
index?: number;
finish_reason?: string | null;
message: {
role?: "assistant";
content?: string | null;
tool_calls?: BatchToolCall[];
};
}
export interface BatchChatCompletionUsage {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
cost_in_usd_ticks?: number;
}
export interface BatchChatCompletionResponse {
id?: string;
model?: string;
choices: BatchChatCompletionChoice[];
usage?: BatchChatCompletionUsage;
}
export interface BatchResultEntry {
batch_request_id: string;
error_message?: string;
batch_result?: {
error?: string;
response?: {
chat_get_completion?: BatchChatCompletionResponse;
};
};
}
export interface BatchResultsPage {
results: BatchResultEntry[];
pagination_token?: string | null;
}
export interface PollBatchRequestOptions extends BatchClientOptions {
batchId: string;
batchRequestId: string;
timeoutMs?: number;
initialPollMs?: number;
maxPollMs?: number;
pageSize?: number;
}
export async function createBatch(
options: BatchClientOptions & {
name: string;
inputFileId?: string;
},
): Promise<BatchInfo> {
return requestJson<BatchInfo>({
...options,
method: "POST",
path: "/batches",
body: {
name: options.name,
...(options.inputFileId ? { input_file_id: options.inputFileId } : {}),
},
retryRateLimit: true,
});
}
export async function addBatchRequests(
options: BatchClientOptions & {
batchId: string;
batchRequests: BatchRequestEntry[];
},
): Promise<void> {
await requestJson<unknown>({
...options,
method: "POST",
path: `/batches/${encodeURIComponent(options.batchId)}/requests`,
body: {
batch_requests: options.batchRequests,
},
});
}
export async function getBatchStatus(
options: BatchClientOptions & {
batchId: string;
},
): Promise<BatchInfo> {
return requestJson<BatchInfo>({
...options,
method: "GET",
path: `/batches/${encodeURIComponent(options.batchId)}`,
});
}
export async function listBatchResults(
options: BatchClientOptions & {
batchId: string;
pageSize?: number;
paginationToken?: string;
},
): Promise<BatchResultsPage> {
const search = new URLSearchParams();
search.set("page_size", String(options.pageSize ?? DEFAULT_BATCH_PAGE_SIZE));
if (options.paginationToken) {
search.set("pagination_token", options.paginationToken);
}
return requestJson<BatchResultsPage>({
...options,
method: "GET",
path: `/batches/${encodeURIComponent(options.batchId)}/results?${search.toString()}`,
});
}
export async function pollBatchRequestResult(options: PollBatchRequestOptions): Promise<BatchResultEntry> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const initialPollMs = options.initialPollMs ?? DEFAULT_INITIAL_POLL_MS;
const maxPollMs = options.maxPollMs ?? DEFAULT_MAX_POLL_MS;
const pageSize = options.pageSize ?? DEFAULT_BATCH_PAGE_SIZE;
const startedAt = Date.now();
let delayMs = initialPollMs;
while (true) {
throwIfAborted(options.signal);
const match = await findBatchResult({
...options,
pageSize,
});
if (match && isBatchResultReady(match)) {
return match;
}
await getBatchStatus(options);
if (Date.now() - startedAt >= timeoutMs) {
throw new Error(
`Timed out waiting for batch request "${options.batchRequestId}" in batch "${options.batchId}" after ${Math.round(timeoutMs / 1000)}s.`,
);
}
await sleep(delayMs, options.signal);
delayMs = Math.min(Math.round(delayMs * 1.5), maxPollMs);
}
}
export function getBatchChatCompletion(result: BatchResultEntry): BatchChatCompletionResponse {
if (result.error_message) {
throw new Error(`Batch request "${result.batch_request_id}" failed: ${result.error_message}`);
}
if (result.batch_result?.error) {
throw new Error(`Batch request "${result.batch_request_id}" failed: ${result.batch_result.error}`);
}
const response = result.batch_result?.response?.chat_get_completion;
if (!response) {
throw new Error(`Batch request "${result.batch_request_id}" did not return a chat completion result.`);
}
return response;
}
async function findBatchResult(
options: BatchClientOptions & {
batchId: string;
batchRequestId: string;
pageSize: number;
},
): Promise<BatchResultEntry | null> {
let paginationToken: string | undefined;
while (true) {
const page = await listBatchResults({
...options,
pageSize: options.pageSize,
paginationToken,
});
const match = page.results.find((entry) => entry.batch_request_id === options.batchRequestId);
if (match) {
return match;
}
paginationToken = page.pagination_token ?? undefined;
if (!paginationToken) {
return null;
}
}
}
function isBatchResultReady(result: BatchResultEntry): boolean {
if (result.error_message) {
return true;
}
if (result.batch_result?.response?.chat_get_completion) {
return true;
}
if (result.batch_result?.error && !isTransientBatchResultError(result.batch_result.error)) {
return true;
}
return false;
}
function isTransientBatchResultError(message: string): boolean {
return message.includes("not yet visible in ClickHouse");
}
async function requestJson<T>(
options: BatchClientOptions & {
method: "GET" | "POST";
path: string;
body?: unknown;
retryRateLimit?: boolean;
},
): Promise<T> {
const url = `${normalizeBaseUrl(options.baseURL)}${options.path.startsWith("/") ? options.path : `/${options.path}`}`;
let attempt = 0;
let delayMs = 500;
while (true) {
throwIfAborted(options.signal);
const response = await fetch(url, {
method: options.method,
headers: {
Authorization: `Bearer ${options.apiKey}`,
...(options.body ? { "Content-Type": "application/json" } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal,
});
if (response.ok) {
return readJson<T>(response);
}
if (options.retryRateLimit && response.status === 429 && attempt < DEFAULT_RATE_LIMIT_RETRIES) {
const retryAfterMs = getRetryAfterMs(response) ?? delayMs;
await sleep(retryAfterMs, options.signal);
attempt += 1;
delayMs = Math.min(delayMs * 2, 5_000);
continue;
}
const errorText = await response.text();
const suffix = errorText.trim() ? `: ${errorText.trim()}` : "";
throw new Error(`Batch API ${options.method} ${options.path} failed (${response.status})${suffix}`);
}
}
async function readJson<T>(response: Response): Promise<T> {
const text = await response.text();
if (!text.trim()) {
return {} as T;
}
try {
return JSON.parse(text) as T;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Batch API returned invalid JSON: ${message}`);
}
}
function normalizeBaseUrl(baseURL?: string): string {
return (baseURL || process.env.GROK_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
}
function getRetryAfterMs(response: Response): number | undefined {
const retryAfter = response.headers.get("retry-after");
if (!retryAfter) return undefined;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) {
return seconds * 1000;
}
const dateMs = Date.parse(retryAfter);
if (!Number.isNaN(dateMs)) {
return Math.max(0, dateMs - Date.now());
}
return undefined;
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error("Request aborted");
}
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timeout);
cleanup();
reject(new Error("Request aborted"));
};
const cleanup = () => {
signal?.removeEventListener("abort", onAbort);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
+38
View File
@@ -0,0 +1,38 @@
import { tool } from "ai";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { toolSetToBatchTools } from "./tool-schemas";
describe("toolSetToBatchTools", () => {
it("converts AI SDK tools into batch function tools", async () => {
const tools = {
greet: tool({
description: "Say hello",
inputSchema: z.object({
name: z.string().describe("Who to greet"),
}),
execute: async () => ({ success: true }),
}),
};
const batchTools = await toolSetToBatchTools(tools);
expect(batchTools).toHaveLength(1);
expect(batchTools[0]).toMatchObject({
type: "function",
function: {
name: "greet",
description: "Say hello",
},
});
expect(batchTools[0]?.function.parameters).toMatchObject({
type: "object",
properties: {
name: {
type: "string",
description: "Who to greet",
},
},
required: ["name"],
});
});
});
+29
View File
@@ -0,0 +1,29 @@
import { asSchema } from "@ai-sdk/provider-utils";
import type { ToolSet } from "ai";
import type { BatchFunctionTool } from "./batch";
export async function toolSetToBatchTools(tools: ToolSet): Promise<BatchFunctionTool[]> {
const entries = Object.entries(tools);
if (entries.length === 0) {
return [];
}
const batchTools: BatchFunctionTool[] = [];
for (const [name, tool] of entries) {
if (tool.type === "provider") {
throw new Error(`Batch mode does not support provider-defined tool "${name}".`);
}
batchTools.push({
type: "function",
function: {
name,
description: tool.description,
parameters: await asSchema(tool.inputSchema as never).jsonSchema,
...(tool.strict != null ? { strict: tool.strict } : {}),
},
});
}
return batchTools;
}
+1
View File
@@ -47,6 +47,7 @@ export type HeadlessJsonEvent =
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
costUsdTicks?: number;
};
}
| {
+14 -2
View File
@@ -51,12 +51,13 @@ async function startInteractive(
baseURL: string,
model: string,
maxToolRounds: number,
batchApi: boolean,
sandboxMode: SandboxMode,
sandboxSettings: SandboxSettings,
session?: string,
initialMessage?: string,
) {
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session, sandboxMode, sandboxSettings });
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session, sandboxMode, sandboxSettings, batchApi });
const { createCliRenderer } = await import("@opentui/core");
const { createRoot } = await import("@opentui/react");
const { createElement } = await import("react");
@@ -100,12 +101,18 @@ async function runHeadless(
baseURL: string,
model: string,
maxToolRounds: number,
batchApi: boolean,
sandboxMode: SandboxMode,
sandboxSettings: SandboxSettings,
format: HeadlessOutputFormat,
session?: string,
) {
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session, sandboxMode, sandboxSettings });
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, {
session,
sandboxMode,
sandboxSettings,
batchApi,
});
const prelude = renderHeadlessPrelude(format, agent.getSessionId() || undefined);
if (prelude.stdout) process.stdout.write(prelude.stdout);
if (prelude.stderr) process.stderr.write(prelude.stderr);
@@ -180,6 +187,7 @@ async function runBackgroundDelegation(jobPath: string, options: CliOptions) {
persistSession: false,
sandboxMode,
sandboxSettings,
batchApi: Boolean(delegation.batchApi ?? options.batchApi === true),
});
const result = await agent.runTaskRequest({
agent: delegation.agent,
@@ -271,6 +279,7 @@ program
.option("-s, --session <id>", "Continue a saved session by id, or use 'latest'")
.option("--background-task-file <path>", "Run a persisted background delegation")
.option("--max-tool-rounds <n>", "Max tool execution rounds", "400")
.option("--batch-api", "Use xAI Batch API for model calls (async, lower cost)")
.option("--update", "Update grok to the latest version and exit")
.action(async (message: string[], options) => {
if (options.update) {
@@ -302,6 +311,7 @@ program
config.baseURL,
config.model,
config.maxToolRounds,
options.batchApi === true,
config.sandboxMode,
config.sandboxSettings,
options.format,
@@ -317,6 +327,7 @@ program
config.baseURL,
config.model,
config.maxToolRounds,
options.batchApi === true,
config.sandboxMode,
config.sandboxSettings,
options.format,
@@ -331,6 +342,7 @@ program
config.baseURL,
config.model,
config.maxToolRounds,
options.batchApi === true,
config.sandboxMode,
config.sandboxSettings,
options.session,