add support for multi-agent Grok models (#197)

This commit is contained in:
Ismail Pelaseyed authored and GitHub committed 2026-03-23 09:57:07 +01:00
1 parent fc37434a2a
commit 994a307324
15 files changed
+578 -119

No files matched your search

+2 -2
View File
@@ -4,8 +4,8 @@ GROK_API_KEY=your_grok_api_key_here
# Optional: Custom API base URL (default: https://api.x.ai/v1)
# GROK_BASE_URL=https://api.x.ai/v1
# Optional: Default model (default: grok-4-1-fast)
# GROK_MODEL=grok-4-1-fast
# Optional: Default model (default: grok-4-1-fast-reasoning)
# GROK_MODEL=grok-4-1-fast-reasoning
# Optional: Max tokens per response (default: 16384)
# GROK_MAX_TOKENS=16384
+1 -1
View File
@@ -86,7 +86,7 @@ grok fix the flaky test in src/foo.test.ts
| Thing | What it means |
|--------|----------------|
| **Grok-native** | Defaults tuned for Grok; models like **`grok-code-fast-1`**, **`grok-4-1-fast`**, flagship and fast variants—run `grok models` for the full menu. |
| **Grok-native** | Defaults tuned for Grok; models like **`grok-code-fast-1`**, **`grok-4-1-fast-reasoning`**, **`grok-4.20-multi-agent-0309`**, plus flagship and fast variants—run `grok models` for the full menu. |
| **X + web search** | **`search_x`** and **`search_web`** tools—live posts and docs without pretending the internet stopped in 2023. |
| **Sub-agents (default behavior)** | Foreground **`task`** delegation (e.g. explore vs general) plus background **`delegate`** for read-only deep dives—parallelize like you mean it. |
| **Custom sub-agents** | Define named agents with **`subAgents`** in **`~/.grok/user-settings.json`** and manage them from the TUI with **`/agents`**. |
+61 -21
View File
@@ -1,6 +1,6 @@
import { type ModelMessage, stepCountIs, streamText, type ToolSet } from "ai";
import { createProvider, generateTitle as genTitle, type XaiProvider } from "../grok/client";
import { getModelInfo } from "../grok/models";
import { createProvider, generateTitle as genTitle, resolveModelRuntime, type XaiProvider } from "../grok/client";
import { DEFAULT_MODEL, getModelInfo, normalizeModelId } from "../grok/models";
import { createTools } from "../grok/tools";
import { buildMcpToolSet } from "../mcp/runtime";
import {
@@ -45,6 +45,7 @@ import {
shouldCompactContext,
} from "./compaction";
import { DelegationManager } from "./delegations";
import { containsEncryptedReasoning, sanitizeModelMessages } from "./reasoning";
const MAX_TOOL_ROUNDS = 400;
@@ -287,6 +288,22 @@ function buildSubagentPrompt(
].join("\n");
}
function applyModelConstraints(system: string, modelId: string): string {
const modelInfo = getModelInfo(modelId);
if (modelInfo?.supportsClientTools !== false) {
return system;
}
return [
system,
"",
"MODEL CONSTRAINTS:",
"- The selected model does not support client-side CLI tool calls in this environment.",
"- Do not call bash, read_file, write_file, edit_file, task, delegate, delegation, or MCP tools.",
"- Answer directly using only the conversation context already provided.",
].join("\n");
}
export class Agent {
private provider: XaiProvider | null = null;
private apiKey: string | null = null;
@@ -319,7 +336,7 @@ export class Agent {
}
this.bash = new BashTool();
this.delegations = new DelegationManager(() => this.bash.getCwd());
this.modelId = model || "grok-4-1-fast";
this.modelId = normalizeModelId(model || DEFAULT_MODEL);
this.maxToolRounds = maxToolRounds || MAX_TOOL_ROUNDS;
const envMax = Number(process.env.GROK_MAX_TOKENS);
this.maxTokens = Number.isFinite(envMax) && envMax > 0 ? envMax : 16_384;
@@ -341,9 +358,9 @@ export class Agent {
}
setModel(model: string): void {
this.modelId = model;
this.modelId = normalizeModelId(model);
if (this.sessionStore && this.session) {
this.sessionStore.setModel(this.session.id, model);
this.sessionStore.setModel(this.session.id, this.modelId);
this.session = this.sessionStore.getRequiredSession(this.session.id);
}
}
@@ -410,7 +427,7 @@ export class Agent {
}
const generated = await genTitle(provider, userMessage);
this.recordUsage(generated.usage, "title", "grok-3-mini-fast");
this.recordUsage(generated.usage, "title", generated.modelId);
if (this.sessionStore && this.session && !this.session.title && generated.title) {
this.sessionStore.setTitle(this.session.id, generated.title);
this.session = this.sessionStore.getRequiredSession(this.session.id);
@@ -553,12 +570,17 @@ export class Agent {
let lastActivity = initialDetail;
let childTools: ToolSet = childBaseTools;
let closeMcp: (() => Promise<void>) | undefined;
const childModelId = isExplore ? "grok-4-1-fast" : custom ? custom.model : this.modelId;
const childModelId = normalizeModelId(isExplore ? DEFAULT_MODEL : custom ? custom.model : this.modelId);
const childRuntime = resolveModelRuntime(provider, childModelId);
const childSystem = applyModelConstraints(
buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, subagents),
childRuntime.modelId,
);
onActivity?.(initialDetail);
try {
if (childMode === "agent") {
if (childMode === "agent" && childRuntime.modelInfo?.supportsClientTools !== false) {
const mcpBundle = await buildMcpToolSet(loadMcpServers());
closeMcp = mcpBundle.close;
childTools = { ...childBaseTools, ...mcpBundle.tools };
@@ -569,17 +591,20 @@ export class Agent {
}
const result = streamText({
model: provider(childModelId),
system: buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, subagents),
model: childRuntime.model,
system: childSystem,
messages: [{ role: "user", content: request.prompt }],
tools: childTools,
tools: childRuntime.modelInfo?.supportsClientTools === false ? {} : childTools,
stopWhen: stepCountIs(Math.min(this.maxToolRounds, isExplore ? 60 : 120)),
maxRetries: 0,
abortSignal: signal,
temperature: isExplore ? 0.2 : 0.5,
maxOutputTokens: Math.min(this.maxTokens, 8_192),
...(childRuntime.modelInfo?.supportsMaxOutputTokens === false
? {}
: { maxOutputTokens: Math.min(this.maxTokens, 8_192) }),
...(childRuntime.providerOptions ? { providerOptions: childRuntime.providerOptions } : {}),
onFinish: ({ totalUsage }) => {
this.recordUsage(totalUsage, "task", childModelId);
this.recordUsage(totalUsage, "task", childRuntime.modelId);
},
});
@@ -789,14 +814,20 @@ export class Agent {
const provider = this.requireProvider();
const subagents = loadValidSubAgents();
const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext, subagents);
const modelInfo = getModelInfo(this.modelId);
const system = applyModelConstraints(
buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext, subagents),
this.modelId,
);
const runtime = resolveModelRuntime(provider, this.modelId);
const modelInfo = runtime.modelInfo;
this.planContext = null;
let attemptedOverflowRecovery = false;
try {
while (true) {
let assistantText = "";
let reasoningPreview = "";
let encryptedReasoningHidden = false;
let streamOk = false;
let closeMcp: (() => Promise<void>) | undefined;
let stepNumber = -1;
@@ -824,8 +855,8 @@ export class Agent {
listDelegations: () => this.listDelegations(),
subagents,
});
let tools: ToolSet = baseTools;
if (this.mode === "agent") {
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 };
@@ -835,7 +866,7 @@ export class Agent {
}
const result = streamText({
model: provider(this.modelId),
model: runtime.model,
system,
messages: this.messages,
tools,
@@ -843,7 +874,8 @@ export class Agent {
maxRetries: 0,
abortSignal: signal,
temperature: 0.7,
maxOutputTokens: this.maxTokens,
...(runtime.modelInfo?.supportsMaxOutputTokens === false ? {} : { maxOutputTokens: this.maxTokens }),
...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}),
experimental_onStepStart: (event: unknown) => {
stepNumber = getStepNumber(event, stepNumber + 1);
notifyObserver(observer?.onStepStart, {
@@ -862,7 +894,7 @@ export class Agent {
});
},
onFinish: ({ totalUsage }) => {
this.recordUsage(totalUsage, "message");
this.recordUsage(totalUsage, "message", runtime.modelId);
},
});
@@ -879,6 +911,14 @@ export class Agent {
break;
case "reasoning-delta":
reasoningPreview = `${reasoningPreview}${part.text}`.slice(-256);
if (containsEncryptedReasoning(reasoningPreview)) {
if (!encryptedReasoningHidden) {
encryptedReasoningHidden = true;
yield { type: "reasoning", content: "[Encrypted reasoning hidden]" };
}
break;
}
yield { type: "reasoning", content: part.text };
break;
@@ -933,7 +973,7 @@ export class Agent {
try {
const response = await result.response;
if (!signal.aborted) {
this.appendCompletedTurn(userModelMessage, response.messages);
this.appendCompletedTurn(userModelMessage, sanitizeModelMessages(response.messages));
streamOk = true;
}
} catch (responseError: unknown) {
+14 -5
View File
@@ -1,5 +1,6 @@
import { generateText, type ModelMessage } from "ai";
import type { XaiProvider } from "../grok/client";
import { resolveModelRuntime, type XaiProvider } from "../grok/client";
import { containsEncryptedReasoning } from "./reasoning";
export interface CompactionSettings {
reserveTokens: number;
@@ -110,11 +111,15 @@ function getTextParts(content: unknown): string[] {
continue;
}
if (part.type === "reasoning" && typeof part.text === "string") {
parts.push(part.text);
if (!containsEncryptedReasoning(part.text)) {
parts.push(part.text);
}
continue;
}
if (part.type === "reasoning" && typeof part.reasoning === "string") {
parts.push(part.reasoning);
if (!containsEncryptedReasoning(part.reasoning)) {
parts.push(part.reasoning);
}
}
}
return parts;
@@ -409,14 +414,18 @@ async function summarizeConversation(
promptParts.push(`Additional focus: ${customInstructions.trim()}`);
}
const runtime = resolveModelRuntime(provider, modelId);
const { text } = await generateText({
model: provider(modelId),
model: runtime.model,
system: SUMMARIZATION_SYSTEM_PROMPT,
prompt: promptParts.filter(Boolean).join("\n\n"),
abortSignal: signal,
maxRetries: 0,
temperature: 0.2,
maxOutputTokens: Math.max(512, Math.floor(reserveTokens * 0.8)),
...(runtime.modelInfo?.supportsMaxOutputTokens === false
? {}
: { maxOutputTokens: Math.max(512, Math.floor(reserveTokens * 0.8)) }),
...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}),
});
return text.trim();
+29
View File
@@ -0,0 +1,29 @@
import type { ModelMessage } from "ai";
import { describe, expect, it } from "vitest";
import { containsEncryptedReasoning, sanitizeModelMessages } from "./reasoning";
describe("reasoning helpers", () => {
it("detects encrypted reasoning markers", () => {
expect(containsEncryptedReasoning("-----BEGIN PGP MESSAGE-----\nabc")).toBe(true);
expect(containsEncryptedReasoning("normal reasoning text")).toBe(false);
});
it("removes encrypted reasoning from assistant messages", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "-----BEGIN PGP MESSAGE-----\nabc" },
{ type: "text", text: "Final answer" },
],
},
] as ModelMessage[];
expect(sanitizeModelMessages(messages)).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "Final answer" }],
},
]);
});
});
+64
View File
@@ -0,0 +1,64 @@
import type { ModelMessage } from "ai";
const ENCRYPTED_REASONING_MARKERS = [
"-----BEGIN PGP MESSAGE-----",
"-----BEGIN PGP ARMORED FILE-----",
"-----BEGIN AGE ENCRYPTED FILE-----",
"encrypted_content",
] as const;
type AssistantMessage = Extract<ModelMessage, { role: "assistant" }>;
type AssistantContent = AssistantMessage["content"];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getReasoningText(part: unknown): string | null {
if (!isRecord(part) || part.type !== "reasoning") return null;
if (typeof part.text === "string") return part.text;
if (typeof part.reasoning === "string") return part.reasoning;
return null;
}
export function containsEncryptedReasoning(text: string): boolean {
const lower = text.toLowerCase();
return ENCRYPTED_REASONING_MARKERS.some((marker) => lower.includes(marker.toLowerCase()));
}
function sanitizeAssistantContent(content: AssistantContent): AssistantContent {
if (!Array.isArray(content)) return content;
const filtered = content.filter((part) => {
const reasoningText = getReasoningText(part);
return !reasoningText || !containsEncryptedReasoning(reasoningText);
});
return filtered.length === content.length ? content : (filtered as typeof content);
}
export function sanitizeModelMessages(messages: ModelMessage[]): ModelMessage[] {
let changed = false;
const sanitized: ModelMessage[] = [];
for (const message of messages) {
if (message.role !== "assistant") {
sanitized.push(message);
continue;
}
const content = sanitizeAssistantContent(message.content);
if (content !== message.content) {
changed = true;
}
if (Array.isArray(content) && content.length === 0) {
changed = true;
continue;
}
const nextMessage: ModelMessage = content === message.content ? message : { ...message, content };
sanitized.push(nextMessage);
}
return changed ? sanitized : messages;
}
+46 -3
View File
@@ -1,9 +1,19 @@
import { createXai } from "@ai-sdk/xai";
import { generateText } from "ai";
import type { ModelInfo, ReasoningEffort } from "../types/index";
import { getReasoningEffortForModel } from "../utils/settings";
import { getEffectiveReasoningEffort, getModelInfo, normalizeModelId } from "./models";
export type XaiProvider = ReturnType<typeof createXai>;
export type XaiChatModel = ReturnType<XaiProvider>;
export type XaiResponsesModel = ReturnType<XaiProvider["responses"]>;
export type GrokRuntimeModel = XaiChatModel | XaiResponsesModel;
const DEFAULT_TITLE_MODEL = "grok-4-1-fast-non-reasoning";
export interface GeneratedTitle {
title: string;
modelId: string;
usage?: {
totalTokens?: number;
inputTokens?: number;
@@ -11,6 +21,17 @@ export interface GeneratedTitle {
};
}
export interface ResolvedModelRuntime {
model: GrokRuntimeModel;
modelId: string;
modelInfo?: ModelInfo;
providerOptions?: {
xai: {
reasoningEffort: ReasoningEffort;
};
};
}
export function createProvider(apiKey: string, baseURL?: string): XaiProvider {
return createXai({
apiKey,
@@ -18,12 +39,33 @@ export function createProvider(apiKey: string, baseURL?: string): XaiProvider {
});
}
export function resolveModelRuntime(provider: XaiProvider, requestedModelId: string): ResolvedModelRuntime {
const modelId = normalizeModelId(requestedModelId);
const modelInfo = getModelInfo(modelId);
const reasoningEffort = getEffectiveReasoningEffort(modelId, getReasoningEffortForModel(modelId));
return {
model: modelInfo?.responsesOnly ? provider.responses(modelId) : provider(modelId),
modelId,
modelInfo,
providerOptions: reasoningEffort
? {
xai: {
reasoningEffort,
},
}
: undefined,
};
}
export async function generateTitle(provider: XaiProvider, userMessage: string): Promise<GeneratedTitle> {
const runtime = resolveModelRuntime(provider, DEFAULT_TITLE_MODEL);
try {
const { text, usage } = await generateText({
model: provider("grok-3-mini-fast"),
model: runtime.model,
temperature: 0.5,
maxOutputTokens: 60,
...(runtime.modelInfo?.supportsMaxOutputTokens === false ? {} : { maxOutputTokens: 60 }),
...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}),
system: [
"You are a title generator. Output ONLY a short title. Nothing else.",
"Rules:",
@@ -39,9 +81,10 @@ export async function generateTitle(provider: XaiProvider, userMessage: string):
});
return {
title: text?.trim().replace(/^["']|["']$/g, "") || "New session",
modelId: runtime.modelId,
usage,
};
} catch {
return { title: "New session" };
return { title: "New session", modelId: runtime.modelId };
}
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_MODEL,
getEffectiveReasoningEffort,
getModelInfo,
getSupportedReasoningEfforts,
normalizeModelId,
} from "./models";
describe("models", () => {
it("keeps the default model on a canonical reasoning id", () => {
expect(DEFAULT_MODEL).toBe("grok-4-1-fast-reasoning");
});
it("normalizes current aliases to canonical ids", () => {
expect(normalizeModelId("grok-4-1-fast")).toBe("grok-4-1-fast-reasoning");
expect(normalizeModelId("grok-4.20-multi-agent")).toBe("grok-4.20-multi-agent-0309");
expect(normalizeModelId("x-ai/grok-4.20-multi-agent-beta")).toBe("grok-4.20-multi-agent-0309");
expect(normalizeModelId("xai/grok-code-fast-1")).toBe("grok-code-fast-1");
});
it("returns model metadata for aliased ids", () => {
expect(getModelInfo("grok-4-1-fast")?.id).toBe("grok-4-1-fast-reasoning");
expect(getModelInfo("grok-4.20-multi-agent")?.responsesOnly).toBe(true);
expect(getModelInfo("grok-4.20-multi-agent")?.supportsClientTools).toBe(false);
});
it("reports supported reasoning-effort levels", () => {
expect(getSupportedReasoningEfforts("grok-4.20-multi-agent-0309")).toEqual(["low", "medium", "high", "xhigh"]);
expect(getSupportedReasoningEfforts("grok-4-1-fast-reasoning")).toEqual(["low", "high"]);
expect(getSupportedReasoningEfforts("grok-4-1-fast-non-reasoning")).toEqual([]);
});
it("resolves effective reasoning effort with defaults and overrides", () => {
expect(getEffectiveReasoningEffort("grok-4.20-multi-agent-0309")).toBe("low");
expect(getEffectiveReasoningEffort("grok-4.20-multi-agent-0309", "high")).toBe("high");
expect(getEffectiveReasoningEffort("grok-4-1-fast-reasoning")).toBeUndefined();
});
});
+122 -35
View File
@@ -1,6 +1,78 @@
import type { ModelInfo } from "../types/index";
import type { ModelInfo, ReasoningEffort } from "../types/index";
export const MODELS: ModelInfo[] = [
{
id: "grok-4.20-multi-agent-0309",
name: "Grok 4.20 Multi-Agent",
contextWindow: 2_000_000,
inputPrice: 2.0,
outputPrice: 6.0,
reasoning: true,
description: "Realtime multi-agent research model",
aliases: ["grok-4.20-multi-agent", "grok-4.20-multi-agent-beta"],
responsesOnly: true,
multiAgent: true,
supportsClientTools: false,
supportsMaxOutputTokens: false,
defaultReasoningEffort: "low",
},
{
id: "grok-4.20-0309-reasoning",
name: "Grok 4.20 Reasoning",
contextWindow: 2_000_000,
inputPrice: 2.0,
outputPrice: 6.0,
reasoning: true,
description: "Grok 4.20 reasoning release",
aliases: ["grok-4.20-beta-0309", "grok-4.20-beta", "grok-beta"],
},
{
id: "grok-4.20-0309-non-reasoning",
name: "Grok 4.20 Non-Reasoning",
contextWindow: 2_000_000,
inputPrice: 2.0,
outputPrice: 6.0,
reasoning: false,
description: "Grok 4.20 non-reasoning release",
},
{
id: "grok-4-1-fast-reasoning",
name: "Grok 4.1 Fast Reasoning",
contextWindow: 2_000_000,
inputPrice: 0.2,
outputPrice: 0.5,
reasoning: true,
description: "Fast reasoning model with 2M context",
aliases: ["grok-4-1-fast"],
},
{
id: "grok-4-1-fast-non-reasoning",
name: "Grok 4.1 Fast Non-Reasoning",
contextWindow: 2_000_000,
inputPrice: 0.2,
outputPrice: 0.5,
reasoning: false,
description: "Fast non-reasoning model with 2M context",
},
{
id: "grok-4-fast-reasoning",
name: "Grok 4 Fast Reasoning",
contextWindow: 2_000_000,
inputPrice: 0.2,
outputPrice: 0.5,
reasoning: true,
description: "Legacy fast reasoning model",
aliases: ["grok-4-fast"],
},
{
id: "grok-4-fast-non-reasoning",
name: "Grok 4 Fast Non-Reasoning",
contextWindow: 2_000_000,
inputPrice: 0.2,
outputPrice: 0.5,
reasoning: false,
description: "Legacy fast non-reasoning model",
},
{
id: "grok-4-0709",
name: "Grok 4",
@@ -10,46 +82,20 @@ export const MODELS: ModelInfo[] = [
reasoning: true,
description: "Flagship reasoning model",
},
{
id: "grok-4.20-beta-0309",
name: "Grok 4.20 Beta",
contextWindow: 2_000_000,
inputPrice: 2.0,
outputPrice: 6.0,
reasoning: true,
description: "Multi-agent reasoning beta with 2M context",
},
{
id: "grok-4-fast",
name: "Grok 4 Fast",
contextWindow: 2_000_000,
inputPrice: 0.2,
outputPrice: 0.5,
reasoning: true,
description: "Fast reasoning with 2M context",
},
{
id: "grok-4-1-fast",
name: "Grok 4.1 Fast",
contextWindow: 2_000_000,
inputPrice: 0.2,
outputPrice: 0.5,
reasoning: true,
description: "Latest fast model with 2M context",
},
{
id: "grok-code-fast-1",
name: "Grok Code Fast",
name: "Grok Code Fast 1",
contextWindow: 256_000,
inputPrice: 0.2,
outputPrice: 1.5,
reasoning: false,
description: "Optimized for code generation",
reasoning: true,
description: "Fast coding model with reasoning",
aliases: ["grok-code-fast"],
},
{
id: "grok-3",
name: "Grok 3",
contextWindow: 131_000,
contextWindow: 131_072,
inputPrice: 3.0,
outputPrice: 15.0,
reasoning: false,
@@ -58,20 +104,61 @@ export const MODELS: ModelInfo[] = [
{
id: "grok-3-mini",
name: "Grok 3 Mini",
contextWindow: 131_000,
contextWindow: 131_072,
inputPrice: 0.3,
outputPrice: 0.5,
reasoning: false,
description: "Budget-friendly compact model",
aliases: ["grok-3-mini-fast"],
},
];
export const DEFAULT_MODEL = "grok-4-1-fast";
const PROVIDER_PREFIX_RE = /^(x-ai|xai)\//i;
const aliasMap = new Map<string, string>();
for (const model of MODELS) {
aliasMap.set(model.id.toLowerCase(), model.id);
for (const alias of model.aliases ?? []) {
aliasMap.set(alias.toLowerCase(), model.id);
}
}
export const DEFAULT_MODEL =
MODELS.find((model) => model.id === "grok-4-1-fast-reasoning")?.id ?? MODELS[0]?.id ?? "grok-4-1-fast-reasoning";
export function normalizeModelId(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed) return trimmed;
const withoutProviderPrefix = trimmed.replace(PROVIDER_PREFIX_RE, "");
return aliasMap.get(withoutProviderPrefix.toLowerCase()) ?? withoutProviderPrefix;
}
export function getModelInfo(modelId: string): ModelInfo | undefined {
return MODELS.find((m) => m.id === modelId);
const normalized = normalizeModelId(modelId);
return MODELS.find((m) => m.id === normalized);
}
export function getModelIds(): string[] {
return MODELS.map((m) => m.id);
}
export function isKnownModelId(modelId: string): boolean {
return !!getModelInfo(modelId);
}
export function getSupportedReasoningEfforts(modelId: string): ReasoningEffort[] {
const modelInfo = getModelInfo(modelId);
if (!modelInfo?.reasoning) return [];
if (modelInfo.multiAgent) return ["low", "medium", "high", "xhigh"];
if (modelInfo.responsesOnly) return ["low", "medium", "high"];
return ["low", "high"];
}
export function getEffectiveReasoningEffort(modelId: string, override?: ReasoningEffort): ReasoningEffort | undefined {
const supported = getSupportedReasoningEfforts(modelId);
if (supported.length === 0) return undefined;
if (override && supported.includes(override)) return override;
const defaultEffort = getModelInfo(modelId)?.defaultReasoningEffort;
return defaultEffort && supported.includes(defaultEffort) ? defaultEffort : undefined;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import type { AgentMode, TaskRequest, ToolResult } from "../types/index";
import { type CustomSubagentConfig, loadValidSubAgents } from "../utils/settings";
import type { XaiProvider } from "./client";
const RESPONSES_SEARCH_MODEL = "grok-4-fast-non-reasoning";
const RESPONSES_SEARCH_MODEL = "grok-4-1-fast-non-reasoning";
interface CreateToolsOptions {
runTask?: (request: TaskRequest, abortSignal?: AbortSignal) => Promise<ToolResult>;
+14 -6
View File
@@ -4,7 +4,7 @@ import * as dotenv from "dotenv";
import packageJson from "../package.json";
import { Agent } from "./agent/agent";
import { completeDelegation, failDelegation, loadDelegation } from "./agent/delegations";
import { MODELS } from "./grok/models";
import { MODELS, normalizeModelId } from "./grok/models";
import {
createHeadlessJsonlEmitter,
type HeadlessOutputFormat,
@@ -118,7 +118,7 @@ async function runBackgroundDelegation(jobPath: string, options: Record<string,
}
const baseURL = options.baseUrl || getBaseURL();
const model = options.model || delegation.model || getCurrentModel();
const model = normalizeModelId(options.model || delegation.model || getCurrentModel());
const maxToolRounds =
parseInt(options.maxToolRounds || String(delegation.maxToolRounds), 10) || delegation.maxToolRounds;
const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { persistSession: false });
@@ -150,11 +150,11 @@ async function runBackgroundDelegation(jobPath: string, options: Record<string,
function resolveConfig(options: Record<string, string | undefined>) {
const apiKey = options.apiKey || getApiKey();
const baseURL = options.baseUrl || getBaseURL();
const model = options.model || getCurrentModel();
const model = normalizeModelId(options.model || getCurrentModel());
const maxToolRounds = parseInt(options.maxToolRounds || "400", 10) || 400;
if (options.apiKey) saveUserSettings({ apiKey: options.apiKey });
if (options.model) saveUserSettings({ defaultModel: options.model });
if (options.model) saveUserSettings({ defaultModel: normalizeModelId(options.model) });
return { apiKey, baseURL, model, maxToolRounds };
}
@@ -240,11 +240,19 @@ program
.action(() => {
console.log("\nAvailable Grok Models:\n");
for (const m of MODELS) {
const reasoning = m.reasoning ? " (reasoning)" : "";
console.log(` \x1b[36m${m.id}\x1b[0m — ${m.name}${reasoning}`);
const tags = [
m.reasoning ? "reasoning" : "non-reasoning",
m.multiAgent ? "multi-agent" : null,
m.responsesOnly ? "responses-only" : null,
].filter(Boolean);
const suffix = tags.length > 0 ? ` (${tags.join(", ")})` : "";
console.log(` \x1b[36m${m.id}\x1b[0m — ${m.name}${suffix}`);
console.log(
` ${m.description} | ${formatContext(m.contextWindow)} context | $${m.inputPrice}/$${m.outputPrice} per 1M tokens`,
);
if ((m.aliases?.length ?? 0) > 0) {
console.log(` aliases: ${(m.aliases ?? []).join(", ")}`);
}
}
console.log();
});
+8
View File
@@ -107,6 +107,8 @@ export interface StreamChunk {
toolResult?: ToolResult;
}
export type ReasoningEffort = "low" | "medium" | "high" | "xhigh";
export interface ModelInfo {
id: string;
name: string;
@@ -115,6 +117,12 @@ export interface ModelInfo {
outputPrice: number;
reasoning: boolean;
description: string;
aliases?: string[];
responsesOnly?: boolean;
multiAgent?: boolean;
supportsClientTools?: boolean;
supportsMaxOutputTokens?: boolean;
defaultReasoningEffort?: ReasoningEffort;
}
export type AgentMode = "agent" | "plan" | "ask";
+120 -30
View File
@@ -4,7 +4,15 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
import os from "os";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Agent } from "../agent/agent";
import { getModelIds, getModelInfo, MODELS } from "../grok/models";
import {
DEFAULT_MODEL,
getEffectiveReasoningEffort,
getModelIds,
getModelInfo,
getSupportedReasoningEfforts,
MODELS,
normalizeModelId,
} from "../grok/models";
import { POPULAR_MCP_CATALOG } from "../mcp/catalog";
import { parseEnvLines, parseHeaderLines } from "../mcp/parse-headers";
import { toMcpServerId, validateMcpServerConfig } from "../mcp/validate";
@@ -18,6 +26,7 @@ import type {
ModelInfo,
Plan,
PlanQuestion,
ReasoningEffort,
SubagentStatus,
ToolCall,
ToolResult,
@@ -77,6 +86,14 @@ type ContextStats = {
ratioUsed: number;
ratioRemaining: number;
};
type PasteBlock = { id: number; content: string; lines: number; isImage?: boolean };
function getPasteBlockToken(block: Pick<PasteBlock, "id" | "lines" | "isImage">): string {
if (block.isImage) {
return `[Image #${block.id}]`;
}
return `[Pasted #${block.id} ${block.lines}+ lines]`;
}
const HERO_ROWS: Row[] = [
{
@@ -325,16 +342,22 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const [showSlashMenu, setShowSlashMenu] = useState(false);
const [slashMenuIndex, setSlashMenuIndex] = useState(0);
const [slashSearchQuery, setSlashSearchQuery] = useState("");
const [pasteBlocks, setPasteBlocks] = useState<{ id: number; content: string; lines: number; isImage?: boolean }[]>(
[],
const [reasoningEffortByModel, setReasoningEffortByModel] = useState<Record<string, ReasoningEffort>>(() =>
Object.fromEntries(
Object.entries(loadUserSettings().reasoningEffortByModel ?? {}).map(([modelId, effort]) => [
normalizeModelId(modelId),
effort,
]),
),
);
const [pasteBlocks, setPasteBlocks] = useState<PasteBlock[]>([]);
const [activePlan, setActivePlan] = useState<Plan | null>(null);
/** Incremented on each successful TUI copy; drives a brief "Copied" banner. */
const [copyFlashId, setCopyFlashId] = useState(0);
const [activeSubagent, setActiveSubagent] = useState<SubagentStatus | null>(null);
const [pqs, setPqs] = useState<PlanQuestionsState>(initialPlanQuestionsState());
const imageCounterRef = useRef(0);
const pasteCounterRef = useRef(0);
const pasteBlocksRef = useRef<PasteBlock[]>([]);
const apiKeyInputRef = useRef<TextareaRenderable>(null);
const inputRef = useRef<TextareaRenderable>(null);
const scrollRef = useRef<ScrollBoxRenderable>(null);
@@ -394,7 +417,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const [agentsEditorModelIndex, setAgentsEditorModelIndex] = useState(() =>
Math.max(
0,
MODELS.findIndex((model) => model.id === "grok-4-1-fast"),
MODELS.findIndex((model) => model.id === DEFAULT_MODEL),
),
);
const [agentsEditorSyncKey, setAgentsEditorSyncKey] = useState(0);
@@ -460,6 +483,51 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
saveMcpServers(servers);
}, []);
const setReasoningEfforts = useCallback((next: Record<string, ReasoningEffort>) => {
setReasoningEffortByModel(next);
saveUserSettings({ reasoningEffortByModel: next });
}, []);
const replacePasteBlocks = useCallback((next: PasteBlock[]) => {
pasteBlocksRef.current = next;
setPasteBlocks(next);
}, []);
const getModelReasoningEffort = useCallback(
(modelId: string): ReasoningEffort | undefined => {
const normalizedModelId = normalizeModelId(modelId);
return getEffectiveReasoningEffort(normalizedModelId, reasoningEffortByModel[normalizedModelId]);
},
[reasoningEffortByModel],
);
const adjustModelReasoningEffort = useCallback(
(modelId: string, direction: -1 | 1) => {
const normalizedModelId = normalizeModelId(modelId);
const supported = getSupportedReasoningEfforts(normalizedModelId);
if (supported.length === 0) return;
const current = getModelReasoningEffort(normalizedModelId);
if (!current) {
if (direction > 0) {
setReasoningEfforts({ ...reasoningEffortByModel, [normalizedModelId]: supported[0] });
}
return;
}
const currentIndex = supported.indexOf(current);
if (direction < 0 && currentIndex <= 0) {
const { [normalizedModelId]: _, ...rest } = reasoningEffortByModel;
setReasoningEfforts(rest);
} else {
const nextIndex = direction < 0 ? currentIndex - 1 : Math.min(supported.length - 1, currentIndex + 1);
setReasoningEfforts({ ...reasoningEffortByModel, [normalizedModelId]: supported[nextIndex] });
}
},
[getModelReasoningEffort, reasoningEffortByModel, setReasoningEfforts],
);
const snapshotMcpEditorDraft = useCallback((): McpEditorDraft => {
return {
...mcpEditorDraft,
@@ -583,7 +651,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
setAgentsEditorModelIndex(
Math.max(
0,
MODELS.findIndex((model) => model.id === agent.model),
MODELS.findIndex((model) => model.id === normalizeModelId(agent.model)),
),
);
} else {
@@ -591,7 +659,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
setAgentsEditorModelIndex(
Math.max(
0,
MODELS.findIndex((model) => model.id === "grok-4-1-fast"),
MODELS.findIndex((model) => model.id === DEFAULT_MODEL),
),
);
}
@@ -1245,11 +1313,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
setActiveSubagent(null);
setActivePlan(null);
setPqs(initialPlanQuestionsState());
setPasteBlocks([]);
replacePasteBlocks([]);
queuedMessagesRef.current = [];
setQueuedMessages([]);
imageCounterRef.current = 0;
}, [agent]);
}, [agent, replacePasteBlocks]);
const processMessage = useCallback(
async (text: string) => {
@@ -1972,6 +2039,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
setModelPickerIndex((i) => Math.min(filteredModelIds.length - 1, i + 1));
return;
}
if (key.name === "left" || key.name === "right") {
const sel = filteredModelIds[modelPickerIndex];
if (sel) {
adjustModelReasoningEffort(sel, key.name === "left" ? -1 : 1);
}
return;
}
if (key.name === "return") {
const sel = filteredModelIds[modelPickerIndex];
if (sel) {
@@ -2044,7 +2118,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const text = inputRef.current?.plainText || "";
if (text.trim()) {
inputRef.current?.clear();
setPasteBlocks([]);
replacePasteBlocks([]);
} else {
handleExit();
}
@@ -2069,6 +2143,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
dismissPlan,
editingSubagent,
editSavedMcp,
adjustModelReasoningEffort,
filteredModelIds,
filteredSlashItems,
handleExit,
@@ -2086,6 +2161,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
openApiKeyModal,
openCatalogMcp,
openMcpEditor,
replacePasteBlocks,
openSubagentEditor,
submitTelegramPair,
submitTelegramToken,
@@ -2121,24 +2197,25 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
if (imageExts.test(trimmed) && !trimmed.includes("\n")) {
event.preventDefault();
const id = ++pasteCounterRef.current;
const imgNum = ++imageCounterRef.current;
setPasteBlocks((prev) => [...prev, { id, content: trimmed, lines: 1, isImage: true }]);
inputRef.current?.insertText(`[Image ${imgNum}]`);
const block = { id, content: trimmed, lines: 1, isImage: true } satisfies PasteBlock;
replacePasteBlocks([...pasteBlocksRef.current, block]);
inputRef.current?.insertText(getPasteBlockToken(block));
return;
}
const lineCount = text.split("\n").length;
if (lineCount < 2) return;
event.preventDefault();
const id = ++pasteCounterRef.current;
setPasteBlocks((prev) => [...prev, { id, content: text, lines: lineCount }]);
inputRef.current?.insertText(`[Pasted ~${lineCount} lines]`);
const block = { id, content: text, lines: lineCount } satisfies PasteBlock;
replacePasteBlocks([...pasteBlocksRef.current, block]);
inputRef.current?.insertText(getPasteBlockToken(block));
},
[openApiKeyModal],
[openApiKeyModal, replacePasteBlocks],
);
const handleSubmit = useCallback(() => {
const raw = inputRef.current?.plainText || "";
if (!raw.trim() && pasteBlocks.length === 0) {
if (!raw.trim() && pasteBlocksRef.current.length === 0) {
if (queuedMessagesRef.current.length > 0 && isProcessingRef.current) {
interruptedRunIdRef.current = activeRunIdRef.current;
setStreamContent("");
@@ -2151,16 +2228,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
inputRef.current?.clear();
let message = raw;
const blocks = [...pasteBlocks];
let imgIdx = 0;
setPasteBlocks([]);
const blocks = [...pasteBlocksRef.current];
replacePasteBlocks([]);
for (const block of blocks) {
if (block.isImage) {
imgIdx++;
message = message.replace(`[Image ${imgIdx}]`, block.content);
} else {
message = message.replace(`[Pasted ~${block.lines} lines]`, block.content);
}
message = message.replace(getPasteBlockToken(block), block.content);
}
if (!message.trim()) return;
if (!hasApiKeyRef.current) {
@@ -2175,7 +2246,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
processMessage(message);
}, [agent, handleCommand, openApiKeyModal, processMessage, pasteBlocks, scrollToBottom]);
}, [agent, handleCommand, openApiKeyModal, processMessage, replacePasteBlocks, scrollToBottom]);
const hasMessages = messages.length > 0 || streamContent || isProcessing;
@@ -2387,6 +2458,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
height={height}
searchQuery={modelSearchQuery}
filteredModels={filteredModels}
reasoningEffortByModel={reasoningEffortByModel}
/>
)}
{showConnectModal && (
@@ -3652,6 +3724,7 @@ function ModelPickerModal({
height,
searchQuery,
filteredModels,
reasoningEffortByModel,
}: {
t: Theme;
currentModel: string;
@@ -3660,6 +3733,7 @@ function ModelPickerModal({
height: number;
searchQuery: string;
filteredModels: ModelInfo[];
reasoningEffortByModel: Record<string, ReasoningEffort>;
}) {
const listRef = useRef<ScrollBoxRenderable>(null);
useEffect(() => {
@@ -3668,7 +3742,9 @@ function ModelPickerModal({
}, [selectedIndex, filteredModels]);
const itemCount = Math.max(filteredModels.length, 1);
const contentHeight = itemCount + 5;
const selectedModel = filteredModels[selectedIndex];
const selectedSupportsReasoning = !!selectedModel && getSupportedReasoningEfforts(selectedModel.id).length > 0;
const contentHeight = itemCount + 6;
const maxH = Math.floor(height * 0.6);
const panelHeight = Math.min(contentHeight, maxH);
const top = bottomAlignedModalTop(height, panelHeight);
@@ -3705,6 +3781,9 @@ function ModelPickerModal({
{filteredModels.map((m, idx) => {
const selected = idx === selectedIndex;
const current = m.id === currentModel;
const supportedReasoningEfforts = getSupportedReasoningEfforts(m.id);
const reasoningEffort =
getEffectiveReasoningEffort(m.id, reasoningEffortByModel[normalizeModelId(m.id)]) ?? "auto";
return (
<box
key={m.id}
@@ -3712,8 +3791,14 @@ function ModelPickerModal({
backgroundColor={selected ? t.selectedBg : undefined}
paddingLeft={2}
paddingRight={2}
width="100%"
>
<text fg={current ? t.accent : selected ? t.selected : t.text}>{m.name}</text>
<box width="100%" flexDirection="row" justifyContent="space-between">
<text fg={current ? t.accent : selected ? t.selected : t.text}>{m.name}</text>
{supportedReasoningEfforts.length > 0 ? (
<text fg={selected ? t.primary : t.textMuted}>{`[${reasoningEffort}]`}</text>
) : null}
</box>
</box>
);
})}
@@ -3723,6 +3808,11 @@ function ModelPickerModal({
</box>
)}
</scrollbox>
<box flexShrink={0} paddingLeft={2} paddingRight={2} paddingTop={1}>
<text fg={t.textMuted}>
{selectedSupportsReasoning ? "left/right reasoning enter select esc close" : "enter select esc close"}
</text>
</box>
</box>
</box>
);
+40 -8
View File
@@ -1,7 +1,8 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { DEFAULT_MODEL, getModelIds } from "../grok/models";
import { DEFAULT_MODEL, getEffectiveReasoningEffort, getModelIds, normalizeModelId } from "../grok/models";
import type { ReasoningEffort } from "../types/index";
export type TelegramStreamingMode = "off" | "partial";
@@ -60,7 +61,7 @@ export function parseSubAgentsRawList(raw: unknown): CustomSubagentConfig[] {
const entry = item as Record<string, unknown>;
const name = typeof entry.name === "string" ? entry.name.trim() : "";
const model = typeof entry.model === "string" ? entry.model.trim() : "";
const model = typeof entry.model === "string" ? normalizeModelId(entry.model) : "";
const instruction = typeof entry.instruction === "string" ? entry.instruction : "";
if (!name || isReservedSubagentName(name) || !validModels.has(model)) {
@@ -84,6 +85,7 @@ export function loadValidSubAgents(): CustomSubagentConfig[] {
export interface UserSettings {
apiKey?: string;
defaultModel?: string;
reasoningEffortByModel?: Record<string, ReasoningEffort>;
telegram?: TelegramSettings;
mcp?: McpSettings;
subAgents?: CustomSubagentConfig[];
@@ -126,7 +128,17 @@ export function saveUserSettings(partial: Partial<UserSettings>): void {
...current,
...partial,
...(partial.apiKey !== undefined ? { apiKey: partial.apiKey } : {}),
...(partial.defaultModel !== undefined ? { defaultModel: partial.defaultModel } : {}),
...(partial.defaultModel !== undefined ? { defaultModel: normalizeModelId(partial.defaultModel) } : {}),
...(partial.reasoningEffortByModel !== undefined
? {
reasoningEffortByModel: Object.fromEntries(
Object.entries(partial.reasoningEffortByModel).map(([modelId, effort]) => [
normalizeModelId(modelId),
effort,
]),
),
}
: {}),
...(partial.telegram !== undefined
? {
telegram: {
@@ -148,7 +160,14 @@ export function saveUserSettings(partial: Partial<UserSettings>): void {
},
}
: {}),
...(partial.subAgents !== undefined ? { subAgents: partial.subAgents } : {}),
...(partial.subAgents !== undefined
? {
subAgents: partial.subAgents.map((agent) => ({
...agent,
model: normalizeModelId(agent.model),
})),
}
: {}),
};
writeJson(USER_SETTINGS_PATH, next);
@@ -162,7 +181,11 @@ export function loadProjectSettings(): ProjectSettings {
export function saveProjectSettings(partial: Partial<ProjectSettings>): void {
const projectPath = path.join(process.cwd(), ".grok", "settings.json");
const current = loadProjectSettings();
writeJson(projectPath, { ...current, ...partial });
writeJson(projectPath, {
...current,
...partial,
...(partial.model !== undefined ? { model: normalizeModelId(partial.model) } : {}),
});
}
export function getApiKey(): string | undefined {
@@ -174,11 +197,20 @@ export function getBaseURL(): string {
}
export function getCurrentModel(): string {
if (process.env.GROK_MODEL) return process.env.GROK_MODEL;
if (process.env.GROK_MODEL) return normalizeModelId(process.env.GROK_MODEL);
const project = loadProjectSettings();
if (project.model) return project.model;
if (project.model) return normalizeModelId(project.model);
const user = loadUserSettings();
return user.defaultModel || DEFAULT_MODEL;
return user.defaultModel ? normalizeModelId(user.defaultModel) : DEFAULT_MODEL;
}
export function getReasoningEffortForModel(modelId: string): ReasoningEffort | undefined {
const normalizedModelId = normalizeModelId(modelId);
const savedEfforts = loadUserSettings().reasoningEffortByModel ?? {};
const effort =
savedEfforts[normalizedModelId] ??
Object.entries(savedEfforts).find(([savedModelId]) => normalizeModelId(savedModelId) === normalizedModelId)?.[1];
return getEffectiveReasoningEffort(normalizedModelId, effort);
}
export function getTelegramBotToken(): string | undefined {
+17 -7
View File
@@ -10,8 +10,18 @@ describe("parseSubAgentsRawList", () => {
it("keeps valid entries with known model ids", () => {
expect(
parseSubAgentsRawList([{ name: "docs", model: "grok-4-1-fast", instruction: "Focus on documentation." }]),
).toEqual([{ name: "docs", model: "grok-4-1-fast", instruction: "Focus on documentation." }]);
parseSubAgentsRawList([
{ name: "docs", model: "grok-4-1-fast-reasoning", instruction: "Focus on documentation." },
]),
).toEqual([{ name: "docs", model: "grok-4-1-fast-reasoning", instruction: "Focus on documentation." }]);
});
it("normalizes legacy aliases to canonical ids", () => {
expect(
parseSubAgentsRawList([
{ name: "research", model: "x-ai/grok-4.20-multi-agent-beta", instruction: "Focus on research." },
]),
).toEqual([{ name: "research", model: "grok-4.20-multi-agent-0309", instruction: "Focus on research." }]);
});
it("skips unknown models", () => {
@@ -21,10 +31,10 @@ describe("parseSubAgentsRawList", () => {
it("skips reserved and empty names", () => {
expect(
parseSubAgentsRawList([
{ name: "general", model: "grok-4-1-fast", instruction: "x" },
{ name: "Explore", model: "grok-4-1-fast", instruction: "x" },
{ name: "", model: "grok-4-1-fast", instruction: "x" },
{ name: " ", model: "grok-4-1-fast", instruction: "x" },
{ name: "general", model: "grok-4-1-fast-reasoning", instruction: "x" },
{ name: "Explore", model: "grok-4-1-fast-reasoning", instruction: "x" },
{ name: "", model: "grok-4-1-fast-reasoning", instruction: "x" },
{ name: " ", model: "grok-4-1-fast-reasoning", instruction: "x" },
]),
).toEqual([]);
});
@@ -35,7 +45,7 @@ describe("parseSubAgentsRawList", () => {
{ name: "Docs", model: "grok-4-1-fast", instruction: "first" },
{ name: "docs", model: "grok-code-fast-1", instruction: "second" },
]),
).toEqual([{ name: "Docs", model: "grok-4-1-fast", instruction: "first" }]);
).toEqual([{ name: "Docs", model: "grok-4-1-fast-reasoning", instruction: "first" }]);
});
it("ignores non-object rows", () => {