1.5.1: refined office, chat in the app language, current OpenAI models, own connector keys
Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s

- Home: the goal title box is two line boxes tall (2.8em), so a third line no longer peeks out.
- Chat messages, reply fallbacks and assignment DMs follow the app display language; tasks and documents keep the working language.
- OpenAI and GPT-account model lists match OpenAI's current line-up; default gpt-6.1-sol.
- Onboarding connectors step takes the operator's Composio and logo.dev keys, then lists the toolkits the Composio project enables; without a key Connectors leaves the sidebar.
- Google Antigravity removed end to end (migration 111), with the remote-revocation path only it used.
- Office: 2x pixel people with outlines, rim light, walk, typing and blink; dithered real-clock lighting; detailed furniture and floors.
- Per-agent model picker lists the provider's whole model list, live when connected.
- Chat wake context breaks same-millisecond ties by insertion order; the mid-run inbox dedupes by message id.
- The stored "clock" theme reads back instead of falling to "system".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 21:12:19 -04:00
1 parent 48dfddffef
commit 245ff66336
79 files changed
+1301 -1035

No files matched your search

+2 -12
View File
@@ -65,7 +65,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const chat = new ChatService(deps.connection.db);
const providers = new ProviderService(deps.connection.db);
const integrationKeys = new IntegrationKeyStore(deps.connection.db);
const providerOAuth = new ProviderOAuthService(integrationKeys);
const providerOAuth = new ProviderOAuthService();
const skills = new SkillService(deps.connection.db, deps.artifactRoot);
const announceChange = coalesce(() => {
try {
@@ -261,17 +261,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
});
handle("setProviderModel", (input) => mutate(() => setProviderModel(deps.connection.db, input)));
handle("revokeProviderCredential", async (input) => {
try {
await providers.revokeCredential(input);
notifyDataChanged();
return ok(snapshot.load());
} catch (error) {
// The local disconnect has happened even when the provider refused the revocation.
notifyDataChanged();
return fail("provider_revoke_failed", error instanceof Error ? error.message : String(error));
}
});
handle("revokeProviderCredential", (input) => mutate(() => providers.revokeCredential(input)));
handle("updateProviderPolicy", (input) => mutate(() => providers.updatePolicy(input)));
handle("setProviderPrices", (input) => mutate(() => providers.setPrices(input)));
handle("setGoalAuditor", (input) => mutate(() => setGoalAuditor(deps.connection.db, input)));
@@ -0,0 +1,13 @@
// Google Antigravity is no longer offered; its rows, secrets and key go.
export const dropGoogleAntigravitySchema = `
update agents set model_provider_id = null, model_id = null
where model_provider_id in (select id from providers where provider_key = 'google-antigravity');
update organizations set audit_provider_id = null
where audit_provider_id in (select id from providers where provider_key = 'google-antigravity');
delete from providers where provider_key = 'google-antigravity';
update credentials set ciphertext = x'' where provider_key = 'google-antigravity';
delete from integration_keys where name = 'google_antigravity_client_secret';
update app_state
set onboarding_draft_json = json_set(onboarding_draft_json, '$.step', 'provider', '$.providerKey', 'openai', '$.providerModel', 'gpt-6.1-sol', '$.providerBaseUrl', '')
where json_extract(onboarding_draft_json, '$.providerKey') = 'google-antigravity';
`;
+2
View File
@@ -109,6 +109,7 @@ import { searchPendingSchema } from "./migrations/107-search-pending";
import { dropAutomationRulesSchema } from "./migrations/108-drop-automation-rules";
import { dropMeetingsSchema } from "./migrations/109-drop-meetings";
import { dropSprintsSchema } from "./migrations/110-drop-sprints";
import { dropGoogleAntigravitySchema } from "./migrations/111-drop-google-antigravity";
interface Migration {
version: number;
@@ -227,6 +228,7 @@ const MIGRATIONS: Migration[] = [
{ version: 108, name: "drop_automation_rules", sql: dropAutomationRulesSchema },
{ version: 109, name: "drop_meetings", sql: dropMeetingsSchema },
{ version: 110, name: "drop_sprints", sql: dropSprintsSchema },
{ version: 111, name: "drop_google_antigravity", sql: dropGoogleAntigravitySchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+2 -7
View File
@@ -1,7 +1,6 @@
import { app } from "electron";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { DatabaseSync } from "node:sqlite";
import { DatabaseConnection } from "./database/connection";
import { appPageUrl, createMainWindow } from "./app/create-window";
import { registerIpcHandlers } from "./app/ipc-handlers";
@@ -13,6 +12,7 @@ import { recoverInterruptedWork } from "./app/startup-recovery";
import { startTaskQueueWorker } from "./app/task-queue-worker";
import { confirmDiscardOnClose } from "./app/unsaved-guard";
import { stopAllRunsAndWait } from "./services/run-control";
import { appLanguage } from "./services/work-language";
const QUIT_GRACE_MS = 10_000;
@@ -28,7 +28,7 @@ if (!app.requestSingleInstanceLock()) {
// strings, date formatting) is only settable pre-ready, and it follows the
// stored language preference instead of the OS locale.
const connection = new DatabaseConnection(join(profileDir, "agentcompany.db"));
const language = storedLanguage(connection.db);
const language = appLanguage(connection.db);
app.commandLine.appendSwitch("lang", language === "zh" ? "zh-CN" : "en-US");
app.whenReady().then(() => {
@@ -98,11 +98,6 @@ if (!app.requestSingleInstanceLock()) {
});
}
function storedLanguage(db: DatabaseSync): string {
const row = db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined;
return row?.language ?? "en";
}
/**
* The bundled skill catalogue: the repo folder during development, the
* extraResources copy in a packaged build, or an explicit override for
+5 -1
View File
@@ -18,6 +18,7 @@ import { sendMessageTool } from "./agent-send-message";
import { withSavepoint } from "../database/connection";
import { optionalEnum, optionalString, optionalStringArray, optionalText, requireString } from "./agent-tool-args";
import { CEO_FIRST_HIRES } from "./org-building";
import { appLanguage } from "./work-language";
import type { JsonRecord } from "./provider-http";
import type { ToolCall, ToolDefinition } from "./provider-tool-turn";
@@ -435,8 +436,11 @@ function postAssignmentMessage(
{ type: "agent", id: ctx.agent.id },
{ type: "agent", id: input.assignee.id },
);
const mention = `@${canonicalMentionHandle(input.assignee.name)}`;
const body = [
`@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`,
appLanguage(deps.db) === "zh"
? `${mention} 请接手 task_id=${input.taskId}:${input.title}。`
: `${mention} take task_id=${input.taskId}: ${input.title}.`,
input.description,
].join(" ");
const messageId = deps.chat.sendMessage({
+18 -14
View File
@@ -30,16 +30,18 @@ export function loadChatWakeContext(db: DatabaseSync, orgId: string, agentId: st
const thread = db.prepare("select space_id from chat_threads where id = ? and org_id = ?")
.get(scope.sourceThreadId, orgId) as { space_id: string } | undefined;
if (!thread) throw new Error(`Chat wake thread ${scope.sourceThreadId} no longer exists.`);
const lastReplyAt = (db.prepare(`
select coalesce(max(created_at), '') as at from chat_messages
const lastReply = db.prepare(`
select created_at as at, rowid as seq from chat_messages
where thread_id = ? and org_id = ? and sender_type = 'agent' and sender_id = ? and deleted_at is null
`).get(scope.sourceThreadId, orgId, agentId) as { at: string }).at;
const isFresh = `not (m.sender_type = 'agent' and m.sender_id = ?) and (m.created_at > ? or coalesce(m.edited_at, '') > ?)`;
order by created_at desc, rowid desc limit 1
`).get(scope.sourceThreadId, orgId, agentId) as { at: string; seq: number } | undefined;
const at = lastReply?.at ?? "";
const isFresh = `not (m.sender_type = 'agent' and m.sender_id = ?) and ((m.created_at, m.rowid) > (?, ?) or coalesce(m.edited_at, '') >= ?)`;
const inThread = "m.thread_id = ? and m.org_id = ? and m.deleted_at is null";
const args = [scope.sourceThreadId, orgId, agentId, lastReplyAt, lastReplyAt];
const fresh = db.prepare(`${MESSAGE_SELECT} where ${inThread} and ${isFresh} order by m.created_at asc`)
const args = [scope.sourceThreadId, orgId, agentId, at, lastReply?.seq ?? 0, at];
const fresh = db.prepare(`${MESSAGE_SELECT} where ${inThread} and ${isFresh} order by m.created_at asc, m.rowid asc`)
.all(...args) as unknown as ThreadMessageRow[];
const history = (db.prepare(`${MESSAGE_SELECT} where ${inThread} and not (${isFresh}) order by m.created_at desc limit ?`)
const history = (db.prepare(`${MESSAGE_SELECT} where ${inThread} and not (${isFresh}) order by m.created_at desc, m.rowid desc limit ?`)
.all(...args, HISTORY_LIMIT) as unknown as ThreadMessageRow[]).reverse();
const historyTotal = (db.prepare(`select count(*) as n from chat_messages m where ${inThread} and not (${isFresh})`)
.get(...args) as { n: number }).n;
@@ -98,24 +100,26 @@ function utcMinute(iso: string): string {
return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
}
/** Polls the threads this run is part of for messages from others. */
/** Polls the threads this run is part of for messages from others that it has not seen; ids, not timestamps, mark what was seen. */
export function createRunMessagePoller(
db: DatabaseSync,
input: { orgId: string; agentId: string; sourceThreadId: string | null; runStartedAt: string; onInjected: (entries: ReadEntry[]) => void },
): () => string | null {
let watermark = input.runStartedAt;
const since = db.prepare("select id from chat_messages where org_id = ? and created_at >= ?");
const seen = new Set((since.all(input.orgId, input.runStartedAt) as Array<{ id: string }>).map((row) => row.id));
return () => {
const rows = db.prepare(`${MESSAGE_SELECT}
where m.org_id = ? and m.created_at > ? and m.deleted_at is null
const rows = (db.prepare(`${MESSAGE_SELECT}
where m.org_id = ? and m.created_at >= ? and m.deleted_at is null
and not (m.sender_type = 'agent' and m.sender_id = ?)
and (m.thread_id = ? or m.thread_id in (
select own.thread_id from chat_messages own
where own.org_id = ? and own.sender_type = 'agent' and own.sender_id = ? and own.created_at >= ?
))
order by m.created_at asc
`).all(input.orgId, watermark, input.agentId, input.sourceThreadId, input.orgId, input.agentId, input.runStartedAt) as unknown as ThreadMessageRow[];
order by m.created_at asc, m.rowid asc
`).all(input.orgId, input.runStartedAt, input.agentId, input.sourceThreadId, input.orgId, input.agentId, input.runStartedAt) as unknown as ThreadMessageRow[])
.filter((row) => !seen.has(row.id));
for (const row of rows) seen.add(row.id);
if (rows.length === 0) return null;
watermark = rows[rows.length - 1].created_at;
input.onInjected(rows.map(readEntry));
console.info(`[run-inbox] agent ${input.agentId}: ${rows.length} new message(s) injected mid-run`);
return ["Messages that arrived while you were working (they may change what you should do):", ...rows.map((m) => formatMessage(db, m))].join("\n");
@@ -1,154 +0,0 @@
/**
* Google Antigravity (Gemini 3 / Claude / GPT-OSS via Google Cloud) —
* authorization-code + PKCE flow with project discovery.
*
* Ported from the reference implementation in @mariozechner/pi-ai.
*/
import { startOAuthCallbackServer } from "./oauth-callback-server";
import { generatePkce } from "./pkce";
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
import { httpFailure } from "../provider-http";
import { oauthFetch } from "./oauth-fetch";
const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
const CALLBACK_PORT = 51121;
const CALLBACK_PATH = "/oauth-callback";
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const CODE_ASSIST_ENDPOINTS = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
export function requireAntigravityClientSecret(secret: string | null | undefined): string {
if (!secret) throw new Error("Google Antigravity sign-in needs its OAuth client secret. Add it in Settings > Integrations.");
return secret;
}
function buildAuthorizeUrl(challenge: string, state: string): string {
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state,
access_type: "offline",
prompt: "consent",
});
return `${AUTH_URL}?${params.toString()}`;
}
async function exchangeCode(code: string, verifier: string, clientSecret: string, cancel?: AbortSignal): Promise<{ access: string; refresh: string; expires: number }> {
const { response, text } = await oauthFetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
}, cancel);
if (!response.ok) throw new Error(`Token exchange failed: ${response.status} ${text}`);
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
throw new Error("Token response was missing required fields.");
}
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 };
}
/** Extract a Code Assist project id from a loadCodeAssist response (unit-tested). */
export function readProjectId(payload: Record<string, unknown>): string | null {
const project = payload.cloudaicompanionProject;
if (typeof project === "string" && project) return project;
if (project && typeof project === "object" && "id" in project) {
const id = (project as { id?: unknown }).id;
if (typeof id === "string" && id) return id;
}
return null;
}
async function discoverProject(accessToken: string, cancel?: AbortSignal): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
};
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
try {
const { response, text } = await oauthFetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } }),
}, cancel);
if (response.ok) {
const projectId = readProjectId(JSON.parse(text) as Record<string, unknown>);
if (projectId) return projectId;
}
} catch (error) {
// A cancelled sign-in ends here; any other failure moves on to the next endpoint.
if (cancel?.aborted) throw error;
}
}
throw new Error("Antigravity project discovery failed.");
}
/** Revokes the sign-in at Google; revoking the refresh token also ends its access tokens. */
export async function revokeAntigravity(refreshToken: string): Promise<void> {
const { response, text } = await oauthFetch("https://oauth2.googleapis.com/revoke", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: refreshToken }),
});
if (!response.ok) throw httpFailure(`Google revocation failed: ${response.status} ${text}`, response, text);
}
/** Refresh an expired Antigravity access token, retaining the project id. */
export async function refreshAntigravity(refreshToken: string, projectId: string, clientSecret: string): Promise<OAuthCredentials> {
const { response, text } = await oauthFetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!response.ok) throw httpFailure(`Antigravity token refresh failed: ${response.status} ${text}`, response, text);
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
throw new Error("Antigravity token refresh response was missing required fields.");
}
const refresh = typeof json.refresh_token === "string" && json.refresh_token ? json.refresh_token : refreshToken;
return { access: json.access_token, refresh, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000, projectId };
}
export const antigravityFlow: OAuthFlow = {
providerKey: "google-antigravity",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const clientSecret = requireAntigravityClientSecret(callbacks.clientSecret);
const { verifier, challenge } = generatePkce();
const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal });
try {
callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), userCode: null });
const result = await server.waitForCode();
if (result.state !== verifier) throw new Error("OAuth state mismatch.");
const tokens = await exchangeCode(result.code, verifier, clientSecret, callbacks.signal);
callbacks.onProgress?.("Resolving project");
const projectId = await discoverProject(tokens.access, callbacks.signal);
return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, projectId };
} finally {
server.close();
}
},
};
-2
View File
@@ -1,6 +1,5 @@
import { githubCopilotFlow } from "./github-copilot-flow";
import { openaiCodexFlow } from "./openai-codex-flow";
import { antigravityFlow } from "./google-antigravity-flow";
import { anthropicClaudeFlow } from "./anthropic-claude-flow";
import { kimiCodeFlow } from "./kimi-code-flow";
import type { OAuthFlow } from "./oauth-types";
@@ -9,7 +8,6 @@ import type { OAuthFlow } from "./oauth-types";
const OAUTH_FLOWS: Record<string, OAuthFlow> = {
"github-copilot": githubCopilotFlow,
"gpt-account": openaiCodexFlow,
"google-antigravity": antigravityFlow,
"claude-pro": anthropicClaudeFlow,
"kimi-code": kimiCodeFlow,
};
-2
View File
@@ -21,8 +21,6 @@ export interface OAuthLoginCallbacks {
signal?: AbortSignal;
/** GitHub Enterprise domain for Copilot; null/empty means github.com. */
enterpriseDomain?: string | null;
/** Client secret for flows that need one. */
clientSecret?: string | null;
}
export interface OAuthFlow {
+1 -3
View File
@@ -6,14 +6,12 @@ export interface StoredOAuth {
refresh: string;
expires: number;
accountId?: string;
projectId?: string;
enterpriseUrl?: string | null;
}
/** Extras a provider's turns cannot run without, with the words used when one is missing. */
const REQUIRED_EXTRAS: Record<string, { field: "accountId" | "projectId"; label: string }> = {
const REQUIRED_EXTRAS: Record<string, { field: "accountId"; label: string }> = {
"gpt-account": { field: "accountId", label: "ChatGPT account id" },
"google-antigravity": { field: "projectId", label: "Google Cloud project id" },
};
/** The one check of a stored sign-in, used both when it is saved and when a run uses it. */
@@ -3,7 +3,7 @@ import type { ProviderModelEntry } from "../../shared/provider-models";
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
import type { ProviderAuthProfile } from "./provider-auth-profiles";
import type { ProviderBackgroundCreateResult, ProviderBackgroundRetrieveResult } from "./provider-background";
import type { ProviderAuthorizeHandler, ProviderRefreshHandler, ProviderRevokeHandler } from "./provider-auth-lifecycle";
import type { ProviderAuthorizeHandler, ProviderRefreshHandler } from "./provider-auth-lifecycle";
import type { ProviderCancelResult, ProviderStreamResult } from "./provider-streaming";
export interface ProviderExecutionInput {
@@ -50,7 +50,6 @@ export interface ProviderAdapter {
authType: ProviderAuthType;
authorize: ProviderAuthorizeHandler;
refresh: ProviderRefreshHandler;
revoke: ProviderRevokeHandler;
parseAuthProfile(secret: string): ProviderAuthProfile;
listModels(ctx: ProviderContext): Promise<ProviderModelEntry[]>;
getRateLimitState(ctx: ProviderContext): Promise<ProviderRateLimitState | null>;
+1 -23
View File
@@ -9,16 +9,10 @@ import {
parseOpenAiCompatProfile,
parseVertexProfile,
requireAccessTokenProfile,
requireAccountTokenProfile,
requireAnthropicCompatProfile,
requireApiKeyProfile,
requireAzureProfile,
requireBedrockProfile,
requireOpenAiCompatProfile,
requireVertexProfile,
} from "./provider-auth-profiles";
import { backgroundUnsupported, retrieveUnsupported } from "./provider-background";
import { authorizeWithParser, refreshUnsupported, revokeLocalProfile } from "./provider-auth-lifecycle";
import { authorizeWithParser, refreshUnsupported } from "./provider-auth-lifecycle";
import { getJson, postEventStream, postJson, WHOLE_RESPONSE_WAIT, type JsonRecord } from "./provider-http";
import { OUTPUT_RESERVE_TOKENS } from "./context-budget";
import { chatMaxTokensField } from "./provider-tool-turns/chat-format";
@@ -26,8 +20,6 @@ import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch
import { KIMI_CODE_BASE_URL } from "./oauth/kimi-code-flow";
import { FINISHED_STOP_REASONS, incompleteTurn, requireFinished } from "./provider-tool-turn";
import { incompleteReason } from "./provider-tool-turns/responses-format";
import { revokeAntigravity } from "./oauth/google-antigravity-flow";
import { parseStoredOAuth } from "./oauth/stored-oauth";
const ANTHROPIC_VERSION = "2023-06-01";
const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14";
@@ -55,7 +47,6 @@ export const providerAdapters: ProviderAdapterRegistry = {
authType: PROVIDER_AUTH_TYPE.API_KEY,
authorize: authorizeWithParser(parseApiKey),
refresh: refreshUnsupported("OpenAI API key"),
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "OpenAI"); }),
parseAuthProfile: parseApiKey,
listModels: (ctx) => fetchOpenAiModels("https://api.openai.com/v1/models", {
authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, "OpenAI").apiKey}`,
@@ -138,7 +129,6 @@ export const providerAdapters: ProviderAdapterRegistry = {
authType: PROVIDER_AUTH_TYPE.API_KEY,
authorize: authorizeWithParser(parseApiKey),
refresh: refreshUnsupported("Anthropic API key"),
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "Anthropic"); }),
parseAuthProfile: parseApiKey,
listModels: (ctx) => fetchAnthropicModels("https://api.anthropic.com/v1/models", {
"x-api-key": requireApiKeyProfile(ctx.authProfile, "Anthropic").apiKey,
@@ -224,10 +214,6 @@ export const providerAdapters: ProviderAdapterRegistry = {
healthUrl: "https://api.moonshot.cn/v1/models",
}),
"github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
"google-antigravity": {
...browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
revoke: async (input) => revokeAntigravity(parseStoredOAuth("google-antigravity", input.secret).refresh),
},
"gpt-account": browserTokenAdapter("gpt-account", PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION),
"claude-pro": {
...browserTokenAdapter("claude-pro", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
@@ -248,7 +234,6 @@ export const providerAdapters: ProviderAdapterRegistry = {
authType: PROVIDER_AUTH_TYPE.CLOUD,
authorize: authorizeWithParser(parseAzureProfile),
refresh: refreshUnsupported("Azure OpenAI cloud credential"),
revoke: revokeLocalProfile((profile) => { requireAzureProfile(profile); }),
parseAuthProfile: parseAzureProfile,
listModels: () => listStaticModels("azure-openai"),
getRateLimitState: providerRateLimitUnknown,
@@ -262,7 +247,6 @@ export const providerAdapters: ProviderAdapterRegistry = {
authType: PROVIDER_AUTH_TYPE.CLOUD,
authorize: authorizeWithParser(parseBedrockProfile),
refresh: refreshUnsupported("AWS Bedrock cloud credential"),
revoke: revokeLocalProfile((profile) => { requireBedrockProfile(profile); }),
parseAuthProfile: parseBedrockProfile,
listModels: () => listStaticModels("aws-bedrock"),
getRateLimitState: providerRateLimitUnknown,
@@ -276,7 +260,6 @@ export const providerAdapters: ProviderAdapterRegistry = {
authType: PROVIDER_AUTH_TYPE.CLOUD,
authorize: authorizeWithParser(parseVertexProfile),
refresh: refreshUnsupported("Vertex AI cloud credential"),
revoke: revokeLocalProfile((profile) => { requireVertexProfile(profile); }),
parseAuthProfile: parseVertexProfile,
listModels: () => listStaticModels("vertex-ai"),
getRateLimitState: providerRateLimitUnknown,
@@ -290,7 +273,6 @@ export const providerAdapters: ProviderAdapterRegistry = {
authType: PROVIDER_AUTH_TYPE.CLOUD,
authorize: authorizeWithParser(parseOpenAiCompatProfile),
refresh: refreshUnsupported("Custom OpenAI-compatible endpoint"),
revoke: revokeLocalProfile((profile) => { requireOpenAiCompatProfile(profile); }),
parseAuthProfile: parseOpenAiCompatProfile,
listModels: () => listStaticModels("custom-openai"),
getRateLimitState: providerRateLimitUnknown,
@@ -304,7 +286,6 @@ export const providerAdapters: ProviderAdapterRegistry = {
authType: PROVIDER_AUTH_TYPE.CLOUD,
authorize: authorizeWithParser(parseAnthropicCompatProfile),
refresh: refreshUnsupported("Custom Anthropic-compatible endpoint"),
revoke: revokeLocalProfile((profile) => { requireAnthropicCompatProfile(profile); }),
parseAuthProfile: parseAnthropicCompatProfile,
listModels: () => listStaticModels("custom-anthropic"),
getRateLimitState: providerRateLimitUnknown,
@@ -363,7 +344,6 @@ function openAiCompatibleAdapter({
authType: PROVIDER_AUTH_TYPE.API_KEY,
authorize: authorizeWithParser(parseApiKey),
refresh: refreshUnsupported(`${label} API key`),
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, label); }),
parseAuthProfile: parseApiKey,
listModels: (ctx) => fetchOpenAiModels(healthUrl, {
authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, label).apiKey}`,
@@ -413,13 +393,11 @@ function responseMessages(instructions: string | null, prompt: string): Array<{
function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): ProviderAdapter {
const parseAuthProfile = oauthProfileParser(providerKey, authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? "account_token" : "access_token");
const assertProfile = authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? requireAccountTokenProfile : requireAccessTokenProfile;
return {
providerKey,
authType,
authorize: authorizeWithParser(parseAuthProfile),
refresh: refreshUnsupported(`${providerKey} token`),
revoke: revokeLocalProfile((profile) => { assertProfile(profile); }),
parseAuthProfile,
listModels: () => listStaticModels(providerKey),
getRateLimitState: providerRateLimitUnknown,
@@ -18,15 +18,8 @@ export interface ProviderRefreshResult {
refreshedAt: string;
}
export interface ProviderRevokeInput {
authProfile: ProviderAuthProfile;
/** The stored credential, for providers that revoke with the refresh token. */
secret: string;
}
export type ProviderAuthorizeHandler = (input: ProviderAuthorizeInput) => Promise<ProviderAuthorizeResult>;
export type ProviderRefreshHandler = (input: ProviderRefreshInput) => Promise<ProviderRefreshResult>;
export type ProviderRevokeHandler = (input: ProviderRevokeInput) => Promise<void>;
export function authorizeWithParser(parseAuthProfile: (secret: string) => ProviderAuthProfile): ProviderAuthorizeHandler {
return async (input) => ({ authProfile: parseAuthProfile(input.secret) });
@@ -37,9 +30,3 @@ export function refreshUnsupported(label: string): ProviderRefreshHandler {
throw new Error(`${label} does not support token refresh.`);
};
}
export function revokeLocalProfile(assertProfile: (profile: ProviderAuthProfile) => void): ProviderRevokeHandler {
return async (input) => {
assertProfile(input.authProfile);
};
}
@@ -82,11 +82,6 @@ export function requireAccessTokenProfile(profile: ProviderAuthProfile): Extract
return profile;
}
export function requireAccountTokenProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "account_token" }> {
if (profile.type !== "account_token") throw new Error("Account token profile is required.");
return profile;
}
export function requireAzureProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "azure_openai" }> {
if (profile.type !== "azure_openai") throw new Error("Azure OpenAI profile is required.");
return profile;
@@ -1,5 +1,4 @@
import { getOAuthFlow } from "./oauth/oauth-flows";
import type { IntegrationKeyStore } from "./integration-key-store";
export interface ProviderOAuthHooks {
onAuth: (info: { url: string; userCode: string | null }) => void;
@@ -15,8 +14,6 @@ export interface ProviderOAuthHooks {
export class ProviderOAuthService {
private readonly controllers = new Map<string, AbortController>();
constructor(private readonly keys: IntegrationKeyStore) {}
async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise<string> {
const flow = getOAuthFlow(input.providerKey);
if (this.controllers.has(input.providerKey)) {
@@ -30,7 +27,6 @@ export class ProviderOAuthService {
onProgress: hooks.onProgress,
signal: controller.signal,
enterpriseDomain: input.enterpriseDomain ?? null,
clientSecret: input.providerKey === "google-antigravity" ? this.keys.read("google_antigravity_client_secret") : null,
});
return JSON.stringify(credentials);
} finally {
@@ -1,4 +1,3 @@
import type { IntegrationKeyStore } from "./integration-key-store";
import type { DatabaseSync } from "node:sqlite";
import type { CancelProviderResponseInput, CreateProviderResponseInput, StreamProviderResponseInput } from "../../shared/commands";
import { FEATURE_FLAG_KEY, PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../shared/domain";
@@ -41,7 +40,6 @@ export interface ProviderResponseRunnerDeps {
adapters: ProviderAdapterRegistry;
audit: AuditService;
authBroker: AuthBrokerService;
integrationKeys: IntegrationKeyStore;
operations: OperationsService;
rateLimitRegistry: ProviderRateLimitRegistry;
responseObserver: ProviderResponseObserver;
@@ -122,7 +120,7 @@ export class ProviderResponseRunner {
const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId);
const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null);
if (!supportsToolTurn(provider.provider_key)) {
throw new Error("Agent execution is not available for this provider; assign the agent an OpenAI, Anthropic, xAI, Groq, Mistral, Cerebras, OpenRouter, GPT account, GitHub Copilot, Google Antigravity, or Claude Pro provider.");
throw new Error("Agent execution is not available for this provider; assign the agent an OpenAI, Anthropic, xAI, Groq, Mistral, Cerebras, OpenRouter, GPT account, GitHub Copilot, or Claude Pro provider.");
}
// A call made for a run works for its agent: the agent's model, its budget, its attribution.
const taskRunId = currentRunId();
@@ -198,7 +196,7 @@ export class ProviderResponseRunner {
});
const startedAt = Date.now();
try {
const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, integrationKeys: this.deps.integrationKeys, orgId, provider: owner });
const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, orgId, provider: owner });
const result = await turn({ credential, model, instructions: request.instructions, messages: request.messages, tools: request.tools });
recordObservedRateLimit(this.deps.rateLimitRegistry, orgId, owner, model, result.rateLimitState ?? null);
// Tool calls are output the model wrote, as much as its text.
+17 -36
View File
@@ -68,7 +68,6 @@ export class ProviderService {
adapters,
audit: this.audit,
authBroker: this.authBroker,
integrationKeys: this.integrationKeys,
operations: this.operations,
rateLimitRegistry: this.rateLimitRegistry,
responseObserver: this.responseObserver,
@@ -136,40 +135,27 @@ export class ProviderService {
return providerEndpoint(this.db, this.authBroker, orgId, providerId);
}
/** Revokes at the provider where it can, then always disconnects and erases the credential here; a remote failure is reported, not hidden. */
async revokeCredential(input: RevokeProviderCredentialInput): Promise<void> {
/** Disconnects the provider and erases its credential on this computer. */
revokeCredential(input: RevokeProviderCredentialInput): void {
const provider = this.db.prepare(`
select id, provider_key, auth_type, label, credential_id
select provider_key, label, credential_id
from providers
where id = ? and org_id = ?
`).get(input.providerId, input.orgId) as { id: string; provider_key: string; auth_type: ProviderAuthType; label: string; credential_id: string | null } | undefined;
`).get(input.providerId, input.orgId) as { provider_key: string; label: string; credential_id: string | null } | undefined;
if (!provider) throw new Error("Provider was not found.");
if (!provider.credential_id) throw new Error(`${provider.label} is not connected.`);
const credentialId = provider.credential_id;
const entry = requireCatalogEntry(provider.provider_key);
let remoteError: string | null = null;
if (entry.revokesRemotely) {
try {
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
const secret = this.authBroker.loadProviderSecret({ orgId: input.orgId, providerId: provider.id, providerKey: provider.provider_key, credentialId });
await adapter.revoke({ authProfile: adapter.parseAuthProfile(secret), secret });
} catch (error) {
remoteError = error instanceof Error ? error.message : String(error);
}
if (!this.authBroker.revokeProviderProfile({ orgId: input.orgId, providerId: input.providerId, providerKey: provider.provider_key, credentialId })) {
throw new Error(`${provider.label} has no active sign-in to disconnect.`);
}
// The credential read at the start is the one disconnected; a connection made while Google answered stays.
const disconnected = this.authBroker.revokeProviderProfile({ orgId: input.orgId, providerId: input.providerId, providerKey: provider.provider_key, credentialId });
if (disconnected) {
this.db.prepare(`
update providers
set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
where id = ? and org_id = ? and credential_id = ?
`).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId, credentialId);
// Sessions mid-turn on this provider stop now instead of finishing on a revoked account.
const running = this.db.prepare("select id from task_runs where provider_id = ? and state = 'running'").all(input.providerId) as Array<{ id: string }>;
for (const run of running) stopRun(run.id, `${provider.label} was disconnected.`);
}
const remote = !entry.revokesRemotely ? "not_supported" : remoteError ? "failed" : "revoked";
this.db.prepare(`
update providers
set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
where id = ? and org_id = ? and credential_id = ?
`).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId, credentialId);
// Sessions mid-turn on this provider stop now instead of finishing on a revoked account.
const running = this.db.prepare("select id from task_runs where provider_id = ? and state = 'running'").all(input.providerId) as Array<{ id: string }>;
for (const run of running) stopRun(run.id, `${provider.label} was disconnected.`);
this.audit.record({
orgId: input.orgId,
entityType: "provider",
@@ -177,14 +163,9 @@ export class ProviderService {
actorType: "system",
actorId: null,
action: "credential_revoked",
payload: { providerKey: provider.provider_key, remote, remoteError, replacedMeanwhile: !disconnected },
payload: { providerKey: provider.provider_key },
});
console.info(`[providers] ${provider.provider_key} disconnected; remote revocation ${remote}`);
const problems = [
...(disconnected ? [] : [`${provider.label} was reconnected while it was being disconnected; the old sign-in is erased and the new connection stays.`]),
...(remoteError ? [`${disconnected ? "Disconnected on this computer, but " : "The old sign-in was not confirmed revoked: "}${provider.label} did not confirm revoking the sign-in (${remoteError}); revoke it in your account.`] : []),
];
if (problems.length > 0) throw new Error(problems.join(" "));
console.info(`[providers] ${provider.provider_key} disconnected`);
}
/**
@@ -204,7 +185,7 @@ export class ProviderService {
const provider = loadConfiguredProvider(this.db, input.orgId, input.providerId);
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
// Renew an expired sign-in the way a run does, so the list and the runs agree on whether the account works.
await resolveTurnCredential({ authBroker: this.authBroker, integrationKeys: this.integrationKeys, orgId: input.orgId, provider });
await resolveTurnCredential({ authBroker: this.authBroker, orgId: input.orgId, provider });
const credential = loadCredential(this.authBroker, input.orgId, provider);
const authProfile = adapter.parseAuthProfile(credential.secret);
return adapter.listModels({ authProfile, model: provider.model });
@@ -3,7 +3,7 @@
* pass the key straight through; OAuth/subscription providers parse the stored
* credential JSON, refresh the access token when it has expired (persisting the
* rotated token), and hand the turn a usable access token plus the extras
* (accountId / projectId / enterpriseUrl) its backend needs.
* (accountId / enterpriseUrl) its backend needs.
*/
import { PROVIDER_AUTH_TYPE } from "../../../shared/domain";
import type { AuthBrokerService } from "../auth-broker-service";
@@ -26,21 +26,17 @@ import type { TurnCredential } from "../provider-tool-turn";
import type { OAuthCredentials } from "../oauth/oauth-types";
import { refreshCodex } from "../oauth/openai-codex-flow";
import { refreshClaudePro } from "../oauth/anthropic-claude-flow";
import { refreshAntigravity, requireAntigravityClientSecret } from "../oauth/google-antigravity-flow";
import type { IntegrationKeyStore } from "../integration-key-store";
import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow";
import { refreshKimiCode } from "../oauth/kimi-code-flow";
import { parseStoredOAuth, type StoredOAuth } from "../oauth/stored-oauth";
import { untilStopped } from "../run-control";
function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: IntegrationKeyStore): Promise<OAuthCredentials> {
function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise<OAuthCredentials> {
switch (providerKey) {
case "gpt-account":
return refreshCodex(creds.refresh);
case "claude-pro":
return refreshClaudePro(creds.refresh);
case "google-antigravity":
return refreshAntigravity(creds.refresh, requireProjectId(creds), requireAntigravityClientSecret(keys.read("google_antigravity_client_secret")));
case "github-copilot":
return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined);
case "kimi-code":
@@ -82,14 +78,12 @@ function toTurnCredential(creds: StoredOAuth | OAuthCredentials): TurnCredential
return {
accessToken: creds.access,
accountId: typeof creds.accountId === "string" ? creds.accountId : undefined,
projectId: typeof creds.projectId === "string" ? creds.projectId : undefined,
enterpriseUrl: typeof enterpriseUrl === "string" ? enterpriseUrl : enterpriseUrl === null ? null : undefined,
};
}
export async function resolveTurnCredential(deps: {
authBroker: AuthBrokerService;
integrationKeys: IntegrationKeyStore;
orgId: string;
provider: ProviderRow;
}): Promise<TurnCredential> {
@@ -109,13 +103,13 @@ export async function resolveTurnCredential(deps: {
/** Refreshes in flight, by stored credential: a rotating provider honours a refresh token once, so callers at once share one. */
const refreshing = new Map<string, Promise<OAuthCredentials>>();
function refreshOnce(deps: { authBroker: AuthBrokerService; integrationKeys: IntegrationKeyStore; orgId: string; provider: ProviderRow }, creds: StoredOAuth): Promise<OAuthCredentials> {
function refreshOnce(deps: { authBroker: AuthBrokerService; orgId: string; provider: ProviderRow }, creds: StoredOAuth): Promise<OAuthCredentials> {
const credentialId = deps.provider.credential_id;
if (!credentialId) throw new Error("Provider credential id is missing; reconnect the provider.");
const running = refreshing.get(credentialId);
if (running) return running;
console.info(`[credentials] refreshing the ${deps.provider.provider_key} sign-in`);
const refresh = refreshOAuth(deps.provider.provider_key, creds, deps.integrationKeys)
const refresh = refreshOAuth(deps.provider.provider_key, creds)
.then((refreshed) => {
deps.authBroker.updateProviderSecret({ orgId: deps.orgId, providerKey: deps.provider.provider_key, credentialId, secret: JSON.stringify(refreshed) });
return refreshed;
@@ -124,8 +118,3 @@ function refreshOnce(deps: { authBroker: AuthBrokerService; integrationKeys: Int
refreshing.set(credentialId, refresh);
return refresh;
}
function requireProjectId(creds: StoredOAuth): string {
if (!creds.projectId) throw new Error("The Google Antigravity sign-in is missing its Google Cloud project id; sign in again.");
return creds.projectId;
}
@@ -1,37 +1,21 @@
/**
* Google Cloud Code Assist (Gemini) encoding for the Antigravity backend.
* Ported from Pi's google-shared `convertMessages` / `convertTools` and the
* Antigravity request wrapper in google-gemini-cli.
*/
/** Gemini generateContent encoding for Vertex AI. */
import { OUTPUT_RESERVE_TOKENS } from "../context-budget";
import { isRecord, ProviderHttpError, readProviderErrorCode, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
import { FINISHED_STOP_REASONS, replayIn, requireFinished, toolCallFromObject, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
import { isRecord, type JsonRecord } from "../provider-http";
import { FINISHED_STOP_REASONS, replayIn, requireFinished, toolCallFromObject, type AgentTurnMessage, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
const ANTIGRAVITY_SYSTEM_INSTRUCTION =
"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." +
"You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question." +
"**Absolute paths only**" +
"**Proactiveness**";
function usesToolCallId(model: string): boolean {
return model.startsWith("claude-") || model.startsWith("gpt-oss-");
}
function geminiToolDefs(model: string, tools: ToolDefinition[]): unknown[] {
const useParameters = model.startsWith("claude-");
function geminiToolDefs(tools: ToolDefinition[]): unknown[] {
return [
{
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }),
parametersJsonSchema: tool.parameters,
})),
},
];
}
function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonRecord[] {
const withId = usesToolCallId(model);
function toGeminiContents(messages: AgentTurnMessage[]): JsonRecord[] {
const contents: JsonRecord[] = [];
for (const message of messages) {
if (message.role === "user") {
@@ -45,12 +29,12 @@ function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonReco
const parts: unknown[] = [];
if (message.text) parts.push({ text: message.text });
for (const call of message.toolCalls) {
parts.push({ functionCall: { name: call.name, args: call.arguments, ...(withId ? { id: call.id } : {}) } });
parts.push({ functionCall: { name: call.name, args: call.arguments } });
}
if (parts.length > 0) contents.push({ role: "model", parts });
} else {
const responses: unknown[] = message.results.map((result) => ({
functionResponse: { name: result.toolName, response: { output: result.output }, ...(withId ? { id: result.toolCallId } : {}) },
functionResponse: { name: result.toolName, response: { output: result.output } },
}));
if (message.followUp) responses.push({ text: message.followUp });
const last = contents[contents.length - 1];
@@ -64,45 +48,16 @@ function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonReco
return contents;
}
/** The full Antigravity request envelope ({project, model, request, ...}). */
export function antigravityRequestBody(input: {
model: string;
projectId: string;
instructions: string | null;
messages: AgentTurnMessage[];
tools: ToolDefinition[];
requestId: string;
}): JsonRecord {
const request: JsonRecord = {
contents: toGeminiContents(input.model, input.messages),
generationConfig: { maxOutputTokens: OUTPUT_RESERVE_TOKENS },
systemInstruction: {
role: "user",
parts: [
{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
{ text: `Please ignore following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` },
...(input.instructions ? [{ text: input.instructions }] : []),
],
},
};
if (input.tools.length > 0) {
request.tools = geminiToolDefs(input.model, input.tools);
request.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
}
return { project: input.projectId, model: input.model, request, requestType: "agent", userAgent: "antigravity", requestId: input.requestId };
}
/** The Vertex AI generateContent body (Gemini, no Antigravity wrapper). */
/** The Vertex AI generateContent body. */
export function vertexRequestBody(input: {
model: string;
instructions: string | null;
messages: AgentTurnMessage[];
tools: ToolDefinition[];
}): JsonRecord {
const body: JsonRecord = { contents: toGeminiContents(input.model, input.messages), generationConfig: { maxOutputTokens: OUTPUT_RESERVE_TOKENS } };
const body: JsonRecord = { contents: toGeminiContents(input.messages), generationConfig: { maxOutputTokens: OUTPUT_RESERVE_TOKENS } };
if (input.instructions) body.systemInstruction = { parts: [{ text: input.instructions }] };
if (input.tools.length > 0) {
body.tools = geminiToolDefs(input.model, input.tools);
body.tools = geminiToolDefs(input.tools);
body.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
}
return body;
@@ -135,40 +90,3 @@ export function parseGeminiResponse(data: JsonRecord): { text: string; toolCalls
const result = partsToResult(parts);
return { text: result.text.join("").trim(), toolCalls: result.toolCalls, replay: { format: "gemini", content: parts } };
}
export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
const raw: string[] = [];
const textParts: string[] = [];
const toolCalls: ToolCall[] = [];
const replayParts: unknown[] = [];
let finishReason: unknown = null;
let counter = 0;
for await (const message of messages) {
raw.push(message.rawData);
if (isRecord(message.data.error)) {
const error = message.data.error;
throw new ProviderHttpError(typeof error.message === "string" ? error.message : "Antigravity stream failed.", null, typeof error.code === "number" ? error.code : null, false, readProviderErrorCode({ error }));
}
const response = isRecord(message.data.response) ? message.data.response : null;
if (!response) continue;
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
if (isRecord(candidates[0]) && typeof candidates[0].finishReason === "string") finishReason = candidates[0].finishReason;
const content = candidates.length > 0 && isRecord(candidates[0]) && isRecord(candidates[0].content) ? candidates[0].content : null;
const parts = content && Array.isArray(content.parts) ? content.parts : [];
for (const part of parts) {
if (!isRecord(part)) continue;
replayParts.push(part);
if (typeof part.text === "string" && part.thought !== true) {
textParts.push(part.text);
} else if (isRecord(part.functionCall)) {
const fc = part.functionCall;
const name = typeof fc.name === "string" ? fc.name : "";
if (!name) continue;
const id = typeof fc.id === "string" && fc.id ? fc.id : `${name}_${(counter += 1)}`;
toolCalls.push(toolCallFromObject(id, name, fc.args));
}
}
}
requireFinished(finishReason, FINISHED_STOP_REASONS.gemini);
return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw), ...(replayParts.length > 0 ? { replay: { format: "gemini", content: replayParts } } : {}) };
}
@@ -2,7 +2,6 @@ import type { ToolTurnFn } from "../provider-tool-turn";
import {
makeChatCompletionsTurn,
runAnthropicTurn,
runAntigravityTurn,
runAzureTurn,
runBedrockTurn,
runClaudeProTurn,
@@ -28,7 +27,6 @@ const TURNS: Record<string, ToolTurnFn> = {
"kimi-cn": makeChatCompletionsTurn("kimi-cn", "https://api.moonshot.cn/v1/chat/completions", "Kimi CN"),
"gpt-account": runCodexTurn,
"github-copilot": runCopilotTurn,
"google-antigravity": runAntigravityTurn,
"claude-pro": runClaudeProTurn,
"kimi-code": runKimiCodeTurn,
"custom-openai": runCustomOpenAiTurn,
+2 -25
View File
@@ -9,7 +9,7 @@ import type { AgentTurnMessage, AgentTurnResult, ProviderToolTurnInput, TurnCred
import { accumulateResponsesStream, parseResponsesOutput, responsesToolDefs, toResponsesInput } from "./responses-format";
import { anthropicToolDefs, parseAnthropicContent, toAnthropicMessages } from "./anthropic-format";
import { accumulateChatStream, chatMaxTokensField, chatToolDefs, parseChatChoice, toChatMessages } from "./chat-format";
import { accumulateGeminiStream, antigravityRequestBody, parseGeminiResponse, vertexRequestBody } from "./gemini-format";
import { parseGeminiResponse, vertexRequestBody } from "./gemini-format";
import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "./bedrock-format";
import { signAwsRequest } from "./aws-sigv4";
import { getGcpAccessToken } from "./gcp-auth";
@@ -17,8 +17,6 @@ import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow";
import { KIMI_CODE_BASE_URL } from "../oauth/kimi-code-flow";
import { OUTPUT_RESERVE_TOKENS } from "../context-budget";
const ANTIGRAVITY_VERSION = "1.21.9";
const ANTIGRAVITY_ENDPOINT = "https://cloudcode-pa.googleapis.com";
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
function requireApiKey(credential: TurnCredential, label: string): string {
@@ -211,27 +209,6 @@ export async function runCustomAnthropicTurn(input: ProviderToolTurnInput): Prom
return { ...parsed, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState };
}
// ---- Google Antigravity (OAuth, Gemini Code Assist, SSE) ----
export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "Google Antigravity");
if (!input.credential.projectId) throw new Error("Antigravity credential is missing its project id.");
const requestId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const body = antigravityRequestBody({
model: input.model,
projectId: input.credential.projectId,
instructions: input.instructions,
messages: input.messages,
tools: input.tools,
requestId,
});
const response = await postEventStream(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
Authorization: `Bearer ${access}`,
"User-Agent": `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`,
}, body);
return { ...(await accumulateGeminiStream(response.messages)), rateLimitState: response.rateLimitState };
}
// ---- Azure OpenAI (Chat Completions at the resource deployment) ----
export async function runAzureTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
@@ -283,7 +260,7 @@ export async function runVertexTurn(input: ProviderToolTurnInput): Promise<Agent
const { projectId, region, serviceAccountJson } = input.credential;
if (!projectId || !region || !serviceAccountJson) throw new Error("Vertex AI credential is incomplete.");
const token = await getGcpAccessToken(serviceAccountJson);
const body = vertexRequestBody({ model: input.model, instructions: input.instructions, messages: input.messages, tools: input.tools });
const body = vertexRequestBody({ instructions: input.instructions, messages: input.messages, tools: input.tools });
const url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${input.model}:generateContent`;
const response = await postJson(url, { authorization: `Bearer ${token}` }, body, WHOLE_RESPONSE_WAIT);
const parsed = parseGeminiResponse(response.data);
+6 -3
View File
@@ -2,7 +2,9 @@ import { currentDataVersion } from "./data-version";
import { loadViewState } from "./view-state";
import { loadOnboardingDraft } from "./onboarding-progress";
import type { DatabaseSync, StatementSync, SQLInputValue } from "node:sqlite";
import type { AppSnapshot } from "../../shared/domain";
import type { AppSnapshot, UiTheme } from "../../shared/domain";
const UI_THEMES: UiTheme[] = ["system", "light", "dark", "clock"];
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
import { IntegrationKeyStore } from "./integration-key-store";
import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain";
@@ -173,9 +175,10 @@ export class SnapshotService {
private resolveUiPreferences(): AppSnapshot["uiPreferences"] {
const row = this.db.prepare("select theme, language from app_state where id = 1")
.get() as { theme: string; language: string } | undefined;
const theme = row?.theme === "light" || row?.theme === "dark" ? row.theme : "system";
const theme = row?.theme ?? "system";
if (!UI_THEMES.includes(theme as UiTheme)) throw new Error(`Stored theme "${theme}" is not a known theme.`);
const language = row?.language === "zh" ? "zh" : "en";
return { theme, language };
return { theme: theme as UiTheme, language };
}
}
+5 -4
View File
@@ -1,4 +1,4 @@
import { taskWorkLanguage } from "./work-language";
import { appLanguage, taskWorkLanguage } from "./work-language";
import { closeChatWakeTask } from "./chat-wake-close";
import type { DatabaseSync } from "node:sqlite";
import type { RunTaskInput } from "../../shared/commands";
@@ -187,7 +187,9 @@ export class TaskExecutionService {
.run(effectiveModel(agent.model_id, provider.model), provider.credential_id, runId);
}
const runModel = pinned ?? effectiveModel(agent.model_id, provider.model);
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: runModel }, task.writing_mode === WRITING_MODE.CONVERSATIONAL, taskWorkLanguage(this.db, orgId, task.id));
const language = taskWorkLanguage(this.db, orgId, task.id);
const chatLanguage = appLanguage(this.db);
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: runModel }, task.writing_mode === WRITING_MODE.CONVERSATIONAL, task.origin === "chat_wake" ? chatLanguage : language);
// Watermark for reply delivery and mid-run message polling.
const runStartedAt = new Date().toISOString();
const chatContext = loadChatWakeContext(this.db, orgId, agent.id, isolation.secretScopeJson);
@@ -208,7 +210,6 @@ export class TaskExecutionService {
const connectorNames = new ConnectorGrantsService(this.db)
.toolkitsForAgent(orgId, agent.id)
.map(connectorDisplayName);
const language = taskWorkLanguage(this.db, orgId, task.id);
// Baseline taken before any await, so a goal closed while tools load still reaches the session.
const goalChanges = createGoalChangePoller(this.db, task.goal_id);
const externalTools = loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id);
@@ -222,7 +223,7 @@ export class TaskExecutionService {
orgId,
providerId: provider.id,
workspaceId: task.workspace_id,
instructions: buildAgentInstructions({ agent, companyName: this.orgName(orgId), isCeo, hasReports, connectorNames, language }),
instructions: buildAgentInstructions({ agent, companyName: this.orgName(orgId), isCeo, hasReports, connectorNames, language, chatLanguage }),
initialUserTurn: buildTaskPrompt({
task,
agent,
+7 -2
View File
@@ -97,6 +97,8 @@ export interface AgentInstructionContext {
connectorNames: string[];
/** The task's working language (its own, else the company's). */
language: "en" | "zh";
/** The app's display language: every chat message uses it. */
chatLanguage: "en" | "zh";
}
/**
@@ -113,8 +115,11 @@ export function buildAgentInstructions(input: AgentInstructionContext): string {
"Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.",
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes company documents (PRDs, specs, memos, reports) and list_documents, read_document, update_document find and revise them, search_knowledge and record_knowledge read and grow what the company has learned, read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`,
input.language === "zh"
? "公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"
: "The working language is English: write every message, task title and description, document and deliverable in English (code, commands and API names stay as they are), even when the material you read is in another language.",
? "公司的工作语言是简体中文:任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"
: "The working language is English: write every task title and description, document and deliverable in English (code, commands and API names stay as they are), even when the material you read is in another language.",
input.chatLanguage === "zh"
? "聊天语言是简体中文:你发出的每一条聊天消息(send_message、回复、交接说明)都用中文,即使对方或任务材料用的是其他语言。"
: "The chat language is English: write every chat message you send (send_message, replies, handoff notes) in English, even when the other person or the task material uses another language.",
capabilityBriefing(input),
"Talk like a person on a team, not a status bot: address teammates by @name, say what you genuinely think, ask when you are unsure, and reply when someone asks you something. Before starting work, check your messages with read_messages to see if anyone needs you. Post where the work lives — 'engineering', 'product', or otherwise 'general' — and keep a back-and-forth in one thread by passing the thread_id a previous send_message returned. Hand work to a teammate with /handoff @Name and the file_id write_deliverable gives you.",
].join("\n");
+6
View File
@@ -11,3 +11,9 @@ export function taskWorkLanguage(db: DatabaseSync, orgId: string, taskId: string
if (!row) throw new Error("Task was not found.");
return row.language;
}
/** The app's display language; agents chat in it. */
export function appLanguage(db: DatabaseSync): WorkLanguage {
const row = db.prepare("select language from app_state where id = 1").get() as { language: WorkLanguage } | undefined;
return row?.language ?? "en";
}