diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 96be83d160..66a55a6b4a 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -34,6 +34,7 @@ import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; +import { GrokACPAgentClient } from "./providers/grok-acp-agent.js"; import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; import { OmpAgentClient } from "./providers/omp/agent.js"; @@ -680,6 +681,9 @@ function addDerivedProviders( if (providerId === "kiro") { return new KiroACPAgentClient(acpOptions); } + if (providerId === "grok") { + return new GrokACPAgentClient(acpOptions); + } if (providerId === "traecli") { return new TraeACPAgentClient(acpOptions); } diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 399ed89427..c455fde02f 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -366,6 +366,49 @@ export type ACPExtensionCommandsParser = ( params: Record, ) => AgentSlashCommand[] | null; +export type ACPModelDefinitionTransformer = ( + model: AvailableACPModel, + definition: AgentModelDefinition, +) => AgentModelDefinition; + +export interface ACPContextUsageResolverInput { + sessionId: string; + cwd: string; + modelId: string | null; + usage: AgentUsage | undefined; +} + +export type ACPContextUsageResolver = ( + input: ACPContextUsageResolverInput, +) => AgentUsage | undefined; + +export interface ACPLaunchOnlyConfig { + mode?: boolean; + model?: boolean; + thinkingOption?: boolean; +} + +export type ACPSessionLaunchArgsPlacement = "append" | "before-default-args"; + +export function buildACPSessionProcessArgs({ + prefixArgs, + defaultArgs, + sessionArgs, + placement, +}: { + prefixArgs: string[]; + defaultArgs: string[]; + sessionArgs: string[]; + placement: ACPSessionLaunchArgsPlacement; +}): string[] { + return [ + ...prefixArgs, + ...(placement === "before-default-args" ? sessionArgs : []), + ...defaultArgs, + ...(placement === "append" ? sessionArgs : []), + ]; +} + interface ACPAgentClientOptions { provider: string; logger: Logger; @@ -373,6 +416,13 @@ interface ACPAgentClientOptions { defaultCommand: [string, ...string[]]; defaultModes?: AgentMode[]; modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; + modelDefinitionTransformer?: ACPModelDefinitionTransformer; + contextUsageResolver?: ACPContextUsageResolver; + sessionLaunchArgs?: (config: AgentSessionConfig) => string[]; + sessionLaunchArgsPlacement?: ACPSessionLaunchArgsPlacement; + autoApprovePermissionModes?: readonly string[]; + launchOnlyConfig?: ACPLaunchOnlyConfig; + persistenceMetadata?: (config: AgentSessionConfig) => AgentMetadata; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configFeatureOptions?: ACPConfigFeatureOption[]; @@ -403,6 +453,12 @@ interface ACPAgentSessionOptions { defaultCommand: [string, ...string[]]; defaultModes: AgentMode[]; modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; + contextUsageResolver?: ACPContextUsageResolver; + sessionLaunchArgs?: (config: AgentSessionConfig) => string[]; + sessionLaunchArgsPlacement?: ACPSessionLaunchArgsPlacement; + autoApprovePermissionModes?: readonly string[]; + launchOnlyConfig?: ACPLaunchOnlyConfig; + persistenceMetadata?: (config: AgentSessionConfig) => AgentMetadata; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configFeatureOptions?: ACPConfigFeatureOption[]; @@ -517,7 +573,7 @@ interface SelectConfigChoice { description?: string | null; group?: string; } -type AvailableACPModel = NonNullable[number]; +export type AvailableACPModel = NonNullable[number]; interface ACPModeSelection { availableMode: AgentMode | null; @@ -640,20 +696,26 @@ export function deriveModelDefinitionsFromACP( provider: string, models: SessionModelState | null | undefined, configOptions?: SessionConfigOption[] | null, + modelDefinitionTransformer?: ACPModelDefinitionTransformer, ): AgentModelDefinition[] { const thinkingOptions = deriveSelectorOptions(configOptions, "thought_level"); const defaultThinkingOptionId = thinkingOptions.find((option) => option.isDefault)?.id ?? null; if (models?.availableModels?.length) { - return models.availableModels.map((model) => ({ - provider, - id: model.modelId, - label: model.name, - description: model.description ?? undefined, - isDefault: model.modelId === models.currentModelId, - thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, - defaultThinkingOptionId: defaultThinkingOptionId ?? undefined, - })); + return models.availableModels.map((model) => { + const definition: AgentModelDefinition = { + provider, + id: model.modelId, + label: model.name, + description: model.description ?? undefined, + isDefault: model.modelId === models.currentModelId, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId: defaultThinkingOptionId ?? undefined, + }; + return modelDefinitionTransformer + ? modelDefinitionTransformer(model, definition) + : definition; + }); } const modelOptions = deriveSelectorOptions(configOptions, "model"); @@ -703,6 +765,13 @@ export class ACPAgentClient implements AgentClient { protected readonly defaultCommand: [string, ...string[]]; protected readonly defaultModes: AgentMode[]; private readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; + private readonly modelDefinitionTransformer?: ACPModelDefinitionTransformer; + private readonly contextUsageResolver?: ACPContextUsageResolver; + private readonly sessionLaunchArgs?: (config: AgentSessionConfig) => string[]; + private readonly sessionLaunchArgsPlacement: ACPSessionLaunchArgsPlacement; + private readonly autoApprovePermissionModes: readonly string[]; + private readonly launchOnlyConfig: ACPLaunchOnlyConfig; + private readonly persistenceMetadata?: (config: AgentSessionConfig) => AgentMetadata; private readonly sessionResponseTransformer?: ( response: SessionStateResponse, ) => SessionStateResponse; @@ -742,6 +811,13 @@ export class ACPAgentClient implements AgentClient { this.defaultCommand = options.defaultCommand; this.defaultModes = options.defaultModes ?? []; this.modelTransformer = options.modelTransformer; + this.modelDefinitionTransformer = options.modelDefinitionTransformer; + this.contextUsageResolver = options.contextUsageResolver; + this.sessionLaunchArgs = options.sessionLaunchArgs; + this.sessionLaunchArgsPlacement = options.sessionLaunchArgsPlacement ?? "append"; + this.autoApprovePermissionModes = options.autoApprovePermissionModes ?? []; + this.launchOnlyConfig = options.launchOnlyConfig ?? {}; + this.persistenceMetadata = options.persistenceMetadata; this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; @@ -771,6 +847,12 @@ export class ACPAgentClient implements AgentClient { defaultCommand: this.defaultCommand, defaultModes: this.defaultModes, modelTransformer: this.modelTransformer, + contextUsageResolver: this.contextUsageResolver, + sessionLaunchArgs: this.sessionLaunchArgs, + sessionLaunchArgsPlacement: this.sessionLaunchArgsPlacement, + autoApprovePermissionModes: this.autoApprovePermissionModes, + launchOnlyConfig: this.launchOnlyConfig, + persistenceMetadata: this.persistenceMetadata, sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, @@ -821,6 +903,12 @@ export class ACPAgentClient implements AgentClient { defaultCommand: this.defaultCommand, defaultModes: this.defaultModes, modelTransformer: this.modelTransformer, + contextUsageResolver: this.contextUsageResolver, + sessionLaunchArgs: this.sessionLaunchArgs, + sessionLaunchArgsPlacement: this.sessionLaunchArgsPlacement, + autoApprovePermissionModes: this.autoApprovePermissionModes, + launchOnlyConfig: this.launchOnlyConfig, + persistenceMetadata: this.persistenceMetadata, sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, @@ -867,6 +955,7 @@ export class ACPAgentClient implements AgentClient { this.provider, transformed.models, transformed.configOptions, + this.modelDefinitionTransformer, ); const modeInfo = deriveModesFromACP( this.defaultModes, @@ -1185,6 +1274,7 @@ export class ACPAgentClient implements AgentClient { this.provider, transformed.models, transformed.configOptions, + this.modelDefinitionTransformer, ); const modeInfo = deriveModesFromACP( this.defaultModes, @@ -1271,6 +1361,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly defaultCommand: [string, ...string[]]; private readonly defaultModes: AgentMode[]; protected readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; + private readonly contextUsageResolver?: ACPContextUsageResolver; + private readonly sessionLaunchArgs?: (config: AgentSessionConfig) => string[]; + private readonly sessionLaunchArgsPlacement: ACPSessionLaunchArgsPlacement; + private readonly autoApprovePermissionModes: Set; + private readonly launchOnlyConfig: ACPLaunchOnlyConfig; + private readonly persistenceMetadata?: (config: AgentSessionConfig) => AgentMetadata; private readonly sessionResponseTransformer?: ( response: SessionStateResponse, ) => SessionStateResponse; @@ -1341,6 +1437,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.defaultCommand = options.defaultCommand; this.defaultModes = options.defaultModes; this.modelTransformer = options.modelTransformer; + this.contextUsageResolver = options.contextUsageResolver; + this.sessionLaunchArgs = options.sessionLaunchArgs; + this.sessionLaunchArgsPlacement = options.sessionLaunchArgsPlacement ?? "append"; + this.autoApprovePermissionModes = new Set(options.autoApprovePermissionModes ?? []); + this.launchOnlyConfig = options.launchOnlyConfig ?? {}; + this.persistenceMetadata = options.persistenceMetadata; this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; @@ -1386,6 +1488,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.bootstrapThreadEventPending = true; this.applySessionState(response); await this.applyConfiguredOverrides(); + this.refreshContextUsage(); } catch (error) { await this.closeAfterInitializationFailure(error); } @@ -1440,6 +1543,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { } await this.applyConfiguredOverrides(); + this.refreshContextUsage(); } catch (error) { await this.closeAfterInitializationFailure(error); } @@ -1532,6 +1636,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { sessionId: this.sessionId, }); } + if (this.currentTurnUsage) { + callback({ + type: "usage_updated", + provider: this.provider, + usage: this.currentTurnUsage, + }); + } return () => { this.subscribers.delete(callback); }; @@ -1625,6 +1736,18 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!this.connection || !this.sessionId) { throw new Error("ACP session not initialized"); } + if (this.launchOnlyConfig.mode) { + if (modeId === this.currentMode) return; + this.currentMode = modeId; + this.config.modeId = modeId; + this.pushEvent({ + type: "mode_changed", + provider: this.provider, + currentModeId: this.currentMode, + availableModes: [...this.availableModes], + }); + return; + } const selection = resolveACPModeSelection({ modeId, @@ -1760,6 +1883,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!this.connection || !this.sessionId) { throw new Error("ACP session not initialized"); } + if (this.launchOnlyConfig.model) { + if (modelId === this.currentModel) return; + this.currentModel = modelId; + this.config.model = modelId ?? undefined; + this.pushEvent({ + type: "model_changed", + provider: this.provider, + runtimeInfo: this.runtimeInfo(), + }); + return; + } if (!modelId) { this.currentModel = null; return; @@ -1855,6 +1989,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!this.connection || !this.sessionId) { throw new Error("ACP session not initialized"); } + if (this.launchOnlyConfig.thinkingOption) { + if (thinkingOptionId === this.thinkingOptionId) return; + this.thinkingOptionId = thinkingOptionId; + this.config.thinkingOptionId = thinkingOptionId ?? undefined; + this.pushEvent({ + type: "thinking_option_changed", + provider: this.provider, + thinkingOptionId: this.thinkingOptionId, + }); + return; + } if (!thinkingOptionId) { this.thinkingOptionId = null; return; @@ -2014,6 +2159,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { metadata: { ...this.config, title: this.currentTitle, + ...this.persistenceMetadata?.(this.config), }, }; } @@ -2083,6 +2229,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { } async requestPermission(params: RequestPermissionRequest): Promise { + if (this.currentMode && this.autoApprovePermissionModes.has(this.currentMode)) { + const selectedOption = selectPermissionOption(params.options, { behavior: "allow" }); + if (selectedOption) { + return { outcome: { outcome: "selected", optionId: selectedOption.optionId } }; + } + } // Match Zed acp.rs:3189-3220: generic ACP permission requests stay pure pass-through. const requestId = randomUUID(); let toolSnapshot = @@ -2319,7 +2471,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { } const command = prefix.command; - const args = [...prefix.args, ...this.defaultCommand.slice(1)]; + const args = buildACPSessionProcessArgs({ + prefixArgs: prefix.args, + defaultArgs: this.defaultCommand.slice(1), + sessionArgs: this.sessionLaunchArgs?.(this.config) ?? [], + placement: this.sessionLaunchArgsPlacement, + }); const child = spawnProcess(command, args, { cwd: this.config.cwd, ...createProviderEnvSpec({ @@ -2416,7 +2573,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { private async applyConfiguredOverrides(): Promise { const configuredModeId = this.config.modeId; - if (configuredModeId && configuredModeId !== this.currentMode) { + if (configuredModeId && this.launchOnlyConfig.mode) { + this.currentMode = configuredModeId; + } else if (configuredModeId && configuredModeId !== this.currentMode) { const selection = resolveACPModeSelection({ modeId: configuredModeId, availableModes: this.availableModes, @@ -2425,7 +2584,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { await this.setModeWithSelection({ modeId: configuredModeId, selection }); } const configuredModelId = this.config.model; - if (configuredModelId && configuredModelId !== this.currentModel) { + if (configuredModelId && this.launchOnlyConfig.model) { + this.currentModel = configuredModelId; + } else if (configuredModelId && configuredModelId !== this.currentModel) { const selection = resolveACPModelSelection({ modelId: configuredModelId, availableModels: this.availableModels, @@ -2443,7 +2604,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { ); } } - if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) { + if (this.config.thinkingOptionId && this.launchOnlyConfig.thinkingOption) { + this.thinkingOptionId = this.config.thinkingOptionId; + } else if ( + this.config.thinkingOptionId && + this.config.thinkingOptionId !== this.thinkingOptionId + ) { await this.setThinkingOption(this.config.thinkingOptionId); } const configuredFeatureValues = this.config.featureValues ?? {}; @@ -2629,6 +2795,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { } private handleCurrentModeUpdate(update: CurrentModeUpdate): void { + if (this.launchOnlyConfig.mode && this.config.modeId) { + this.currentMode = this.config.modeId; + return; + } this.currentMode = this.transformModeId(update.currentModeId); } @@ -2680,11 +2850,31 @@ export class ACPAgentSession implements AgentSession, ACPClient { } private handleUsageUpdate(update: UsageUpdate): void { - void update; + const totalCostUsd = + update.cost?.currency.toUpperCase() === "USD" && Number.isFinite(update.cost.amount) + ? update.cost.amount + : undefined; + this.currentTurnUsage = { + ...this.currentTurnUsage, + contextWindowMaxTokens: update.size, + contextWindowUsedTokens: update.used, + ...(totalCostUsd === undefined ? {} : { totalCostUsd }), + }; + this.refreshContextUsage(); + this.pushEvent({ + type: "usage_updated", + provider: this.provider, + usage: this.currentTurnUsage, + turnId: this.activeForegroundTurnId ?? undefined, + }); } private handlePromptResponse(response: PromptResponse, turnId: string): void { - this.currentTurnUsage = mapACPUsage(response.usage) ?? this.currentTurnUsage; + const responseUsage = mapACPUsage(response.usage); + this.currentTurnUsage = responseUsage + ? { ...this.currentTurnUsage, ...responseUsage } + : this.currentTurnUsage; + this.refreshContextUsage(); switch (response.stopReason) { case "cancelled": @@ -2711,6 +2901,18 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + private refreshContextUsage(): void { + if (!this.contextUsageResolver || !this.sessionId) { + return; + } + this.currentTurnUsage = this.contextUsageResolver({ + sessionId: this.sessionId, + cwd: this.config.cwd, + modelId: this.currentModel, + usage: this.currentTurnUsage, + }); + } + private wrapTimeline(item: AgentTimelineItem): AgentStreamEvent { return { type: "timeline", diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 72f29faa13..be6da25eb6 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,12 +1,21 @@ import type { Logger } from "pino"; import { z } from "zod"; -import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; +import type { + AgentCapabilityFlags, + AgentMetadata, + AgentMode, + AgentSessionConfig, +} from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, type ACPClientCapabilityMeta, type ACPConfigFeatureOption, + type ACPContextUsageResolver, + type ACPLaunchOnlyConfig, + type ACPModelDefinitionTransformer, + type ACPSessionLaunchArgsPlacement, DEFAULT_ACP_CAPABILITIES, type ACPExtensionCommandsParser, } from "./acp-agent.js"; @@ -49,6 +58,14 @@ interface GenericACPAgentClientOptions { clientCapabilityMeta?: ACPClientCapabilityMeta; configFeatureOptions?: ACPConfigFeatureOption[]; extensionCommandsParser?: ACPExtensionCommandsParser; + defaultModes?: AgentMode[]; + modelDefinitionTransformer?: ACPModelDefinitionTransformer; + contextUsageResolver?: ACPContextUsageResolver; + sessionLaunchArgs?: (config: AgentSessionConfig) => string[]; + sessionLaunchArgsPlacement?: ACPSessionLaunchArgsPlacement; + autoApprovePermissionModes?: readonly string[]; + launchOnlyConfig?: ACPLaunchOnlyConfig; + persistenceMetadata?: (config: AgentSessionConfig) => AgentMetadata; } export class GenericACPAgentClient extends ACPAgentClient { @@ -66,6 +83,14 @@ export class GenericACPAgentClient extends ACPAgentClient { env: options.env, }, defaultCommand: options.command, + defaultModes: options.defaultModes, + modelDefinitionTransformer: options.modelDefinitionTransformer, + contextUsageResolver: options.contextUsageResolver, + sessionLaunchArgs: options.sessionLaunchArgs, + sessionLaunchArgsPlacement: options.sessionLaunchArgsPlacement, + autoApprovePermissionModes: options.autoApprovePermissionModes, + launchOnlyConfig: options.launchOnlyConfig, + persistenceMetadata: options.persistenceMetadata, capabilities: buildGenericACPCapabilities(providerParams), waitForInitialCommands: options.waitForInitialCommands, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, diff --git a/packages/server/src/server/agent/providers/grok-acp-agent.test.ts b/packages/server/src/server/agent/providers/grok-acp-agent.test.ts new file mode 100644 index 0000000000..f214cc388d --- /dev/null +++ b/packages/server/src/server/agent/providers/grok-acp-agent.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; + +import { buildACPSessionProcessArgs } from "./acp-agent.js"; +import { buildGrokSessionLaunchArgs, transformGrokModelDefinition } from "./grok-acp-agent.js"; + +describe("GrokACPAgentClient", () => { + test("maps Grok model metadata into context and thinking controls", () => { + const model = transformGrokModelDefinition( + { + modelId: "grok-4.5", + name: "Grok 4.5", + _meta: { + totalContextTokens: 500_000, + reasoningEffort: "high", + reasoningEfforts: [ + { id: "high", label: "High" }, + { id: "medium", label: "Medium" }, + ], + }, + }, + { provider: "acp", id: "grok-4.5", label: "Grok 4.5", isDefault: true }, + ); + + expect(model).toMatchObject({ + contextWindowMaxTokens: 500_000, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "high", isDefault: true }, + { id: "medium", isDefault: false }, + ], + }); + }); + + test("launches Grok with the selected model, effort, and unattended mode", () => { + const sessionArgs = buildGrokSessionLaunchArgs({ + provider: "acp", + cwd: "/workspace/paseo", + model: "grok-4.5", + thinkingOptionId: "high", + modeId: "full-access", + }); + + expect(sessionArgs).toEqual([ + "--model", + "grok-4.5", + "--reasoning-effort", + "high", + "--sandbox", + "devbox", + "--permission-mode", + "bypassPermissions", + "--rules", + expect.stringContaining("without asking follow-up questions"), + ]); + expect( + buildACPSessionProcessArgs({ + prefixArgs: [], + defaultArgs: ["agent", "stdio"], + sessionArgs, + placement: "before-default-args", + }), + ).toEqual([...sessionArgs, "agent", "stdio"]); + }); + + test("uses Grok's native plan mode", () => { + expect( + buildGrokSessionLaunchArgs({ + provider: "acp", + cwd: "/workspace/paseo", + modeId: "plan", + }), + ).toEqual(["--permission-mode", "plan"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/grok-acp-agent.ts b/packages/server/src/server/agent/providers/grok-acp-agent.ts new file mode 100644 index 0000000000..77ed9bbfdf --- /dev/null +++ b/packages/server/src/server/agent/providers/grok-acp-agent.ts @@ -0,0 +1,142 @@ +import type { Logger } from "pino"; + +import type { + AgentMode, + AgentModelDefinition, + AgentSelectOption, + AgentSessionConfig, +} from "../agent-sdk-types.js"; +import type { AvailableACPModel } from "./acp-agent.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; +import { createGrokContextUsageResolver, resolveGrokHome } from "./grok-context-usage.js"; + +const GROK_CONTEXT_WINDOW_FALLBACKS: Record = { + "grok-4.5": 500_000, +}; +const GROK_UNATTENDED_RULES = + "Operate autonomously and complete the task without asking follow-up questions or requesting confirmation. Make reasonable assumptions, use tools as needed, and report decisions in the final response."; + +export const GROK_ALWAYS_APPROVE_MODE_ID = "always-approve"; +export const GROK_FULL_ACCESS_MODE_ID = "full-access"; +export const GROK_PLAN_MODE_ID = "plan"; + +const GROK_MODES: AgentMode[] = [ + { + id: "default", + label: "Ask before tools", + description: "Request approval before Grok runs tools.", + }, + { + id: GROK_ALWAYS_APPROVE_MODE_ID, + label: "Always approve", + description: "Allow Grok to run tools without approval prompts.", + isUnattended: true, + }, + { + id: GROK_PLAN_MODE_ID, + label: "Plan", + description: "Inspect the workspace and prepare a plan before making changes.", + }, + { + id: GROK_FULL_ACCESS_MODE_ID, + label: "Full access", + description: "Run without approval prompts using Grok's host-wide devbox sandbox.", + isUnattended: true, + }, +]; + +interface GrokACPAgentClientOptions { + logger: Logger; + command: [string, ...string[]]; + env?: Record; + providerId?: string; + label?: string; + providerParams?: unknown; +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function readPositiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function deriveGrokThinkingOptions(metadata: Record): AgentSelectOption[] { + const defaultId = readNonEmptyString(metadata.reasoningEffort); + const efforts = Array.isArray(metadata.reasoningEfforts) ? metadata.reasoningEfforts : []; + return efforts.flatMap((effort) => { + if (!isRecord(effort)) return []; + const id = readNonEmptyString(effort.id) ?? readNonEmptyString(effort.value); + const label = readNonEmptyString(effort.label); + if (!id || !label) return []; + return [ + { + id, + label, + description: readNonEmptyString(effort.description), + isDefault: id === defaultId, + }, + ]; + }); +} + +export function transformGrokModelDefinition( + model: AvailableACPModel, + definition: AgentModelDefinition, +): AgentModelDefinition { + const metadata = isRecord(model._meta) ? model._meta : null; + const contextWindowMaxTokens = + (metadata ? readPositiveNumber(metadata.totalContextTokens) : undefined) ?? + GROK_CONTEXT_WINDOW_FALLBACKS[model.modelId]; + if (!metadata) { + return contextWindowMaxTokens ? { ...definition, contextWindowMaxTokens } : definition; + } + + const thinkingOptions = deriveGrokThinkingOptions(metadata); + const defaultThinkingOptionId = thinkingOptions.find((option) => option.isDefault)?.id; + return { + ...definition, + contextWindowMaxTokens, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : definition.thinkingOptions, + defaultThinkingOptionId: defaultThinkingOptionId ?? definition.defaultThinkingOptionId, + }; +} + +export function buildGrokSessionLaunchArgs(config: AgentSessionConfig): string[] { + const isUnattended = + config.modeId === GROK_ALWAYS_APPROVE_MODE_ID || config.modeId === GROK_FULL_ACCESS_MODE_ID; + return [ + ...(config.model ? ["--model", config.model] : []), + ...(config.thinkingOptionId ? ["--reasoning-effort", config.thinkingOptionId] : []), + ...(config.modeId === GROK_FULL_ACCESS_MODE_ID ? ["--sandbox", "devbox"] : []), + ...(config.modeId === GROK_PLAN_MODE_ID ? ["--permission-mode", "plan"] : []), + ...(isUnattended + ? ["--permission-mode", "bypassPermissions", "--rules", GROK_UNATTENDED_RULES] + : []), + ]; +} + +export class GrokACPAgentClient extends GenericACPAgentClient { + constructor(options: GrokACPAgentClientOptions) { + super({ + ...options, + defaultModes: GROK_MODES, + modelDefinitionTransformer: transformGrokModelDefinition, + contextUsageResolver: createGrokContextUsageResolver({ env: options.env }), + sessionLaunchArgs: buildGrokSessionLaunchArgs, + sessionLaunchArgsPlacement: "before-default-args", + autoApprovePermissionModes: [GROK_ALWAYS_APPROVE_MODE_ID, GROK_FULL_ACCESS_MODE_ID], + launchOnlyConfig: { + mode: true, + model: true, + thinkingOption: true, + }, + persistenceMetadata: () => ({ grokHome: resolveGrokHome(options.env) }), + }); + } +} diff --git a/packages/server/src/server/agent/providers/grok-context-usage.test.ts b/packages/server/src/server/agent/providers/grok-context-usage.test.ts new file mode 100644 index 0000000000..0b4fad935e --- /dev/null +++ b/packages/server/src/server/agent/providers/grok-context-usage.test.ts @@ -0,0 +1,55 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createGrokContextUsageResolver, resolveGrokHome } from "./grok-context-usage.js"; + +const SESSION_ID = "019f808f-7cd7-7b80-89b5-36f2b0f71edd"; +const CWD = "/workspace/paseo"; + +describe("createGrokContextUsageResolver", () => { + test("reads the Grok CLI's local session counters", async () => { + const grokHome = await mkdtemp(join(tmpdir(), "paseo-grok-context-")); + const signalsPath = join( + grokHome, + "sessions", + encodeURIComponent(CWD), + SESSION_ID, + "signals.json", + ); + try { + await mkdir(dirname(signalsPath), { recursive: true }); + await writeFile( + signalsPath, + JSON.stringify({ contextTokensUsed: 16_027, contextWindowTokens: 500_000 }), + ); + expect(existsSync(signalsPath)).toBe(true); + + const resolver = createGrokContextUsageResolver({ env: { GROK_HOME: grokHome } }); + expect(resolveGrokHome({ GROK_HOME: grokHome })).toBe(grokHome); + expect(resolver({ sessionId: SESSION_ID, cwd: CWD, modelId: "grok-4.5" })).toEqual({ + contextWindowMaxTokens: 500_000, + contextWindowUsedTokens: 16_027, + }); + } finally { + await rm(grokHome, { recursive: true, force: true }); + } + }); + + test("preserves exact ACP context data when it is available", () => { + const resolver = createGrokContextUsageResolver({ + readSessionSignals: () => { + throw new Error("local lookup should not run"); + }, + }); + const usage = { + contextWindowMaxTokens: 500_000, + contextWindowUsedTokens: 42_000, + }; + + expect(resolver({ sessionId: SESSION_ID, cwd: CWD, modelId: "grok-4.5", usage })).toBe(usage); + }); +}); diff --git a/packages/server/src/server/agent/providers/grok-context-usage.ts b/packages/server/src/server/agent/providers/grok-context-usage.ts new file mode 100644 index 0000000000..fc20a656cf --- /dev/null +++ b/packages/server/src/server/agent/providers/grok-context-usage.ts @@ -0,0 +1,87 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +import { expandTilde } from "../../../utils/path.js"; +import type { ACPContextUsageResolver } from "./acp-agent.js"; + +const GROK_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface GrokSessionSignals { + contextTokensUsed: number; + contextWindowTokens: number; +} + +interface GrokContextUsageResolverOptions { + env?: Record; + readSessionSignals?: (sessionId: string, cwd: string) => GrokSessionSignals | null; +} + +function parseNonNegativeNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function parsePositiveNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; +} + +export function resolveGrokHome(env: Record | undefined): string { + const configuredHome = env?.GROK_HOME ?? process.env.GROK_HOME; + return configuredHome ? resolve(expandTilde(configuredHome)) : join(homedir(), ".grok"); +} + +function createGrokSessionSignalsReader( + env: Record | undefined, +): (sessionId: string, cwd: string) => GrokSessionSignals | null { + const grokHome = resolveGrokHome(env); + return (sessionId, cwd) => { + if (!GROK_SESSION_ID_PATTERN.test(sessionId)) return null; + + const signalsPath = join( + grokHome, + "sessions", + encodeURIComponent(cwd), + sessionId, + "signals.json", + ); + if (!existsSync(signalsPath)) return null; + + const parsed = JSON.parse(readFileSync(signalsPath, "utf8")) as Record; + const contextTokensUsed = parseNonNegativeNumber(parsed.contextTokensUsed); + const contextWindowTokens = parsePositiveNumber(parsed.contextWindowTokens); + if (contextTokensUsed === null || contextWindowTokens === null) return null; + return { contextTokensUsed, contextWindowTokens }; + }; +} + +/** + * Grok's ACP transport may omit usage updates. Its local session signals are + * the CLI's own context counters, so use them before falling back to unknown. + */ +export function createGrokContextUsageResolver( + options: GrokContextUsageResolverOptions = {}, +): ACPContextUsageResolver { + const readSessionSignals = + options.readSessionSignals ?? createGrokSessionSignalsReader(options.env); + + return ({ sessionId, cwd, usage }) => { + if ( + usage?.contextWindowMaxTokens !== undefined && + usage.contextWindowUsedTokens !== undefined + ) { + return usage; + } + + try { + const signals = readSessionSignals(sessionId, cwd); + if (!signals) return usage; + return { + ...usage, + contextWindowMaxTokens: signals.contextWindowTokens, + contextWindowUsedTokens: signals.contextTokensUsed, + }; + } catch { + return usage; + } + }; +}