diff --git a/AgentCompany-New/src/main/services/auth-broker-service.ts b/AgentCompany-New/src/main/services/auth-broker-service.ts index 52a9e4dc..f3c73ccd 100644 --- a/AgentCompany-New/src/main/services/auth-broker-service.ts +++ b/AgentCompany-New/src/main/services/auth-broker-service.ts @@ -125,6 +125,25 @@ export class AuthBrokerService { return this.vault.decryptSecret(Buffer.from(row.ciphertext)); } + /** + * Re-encrypts a refreshed OAuth credential into its existing credential row, + * keeping the same credential id (and active auth profile) so request-time + * token refresh persists without re-binding the provider. + */ + updateProviderSecret(input: { orgId: string; providerKey: string; credentialId: string; secret: string }): void { + const secret = input.secret.trim(); + if (!secret) throw new Error("Credential is required."); + const displayHint = credentialDisplayHint(secret); + const result = this.db.prepare(` + update credentials + set ciphertext = ?, display_hint = ? + where id = ? and org_id = ? and provider_key = ? + `).run(this.vault.encryptSecret(secret), displayHint, input.credentialId, input.orgId, input.providerKey); + if (result.changes === 0) throw new Error("Provider credential was not found."); + this.db.prepare("update provider_auth_profiles set display_hint = ? where credential_id = ? and org_id = ?") + .run(displayHint, input.credentialId, input.orgId); + } + revokeProviderProfile(input: RevokeProviderAuthProfileInput): void { const row = this.db.prepare(` select id, credential_id diff --git a/AgentCompany-New/src/main/services/oauth/anthropic-claude-flow.ts b/AgentCompany-New/src/main/services/oauth/anthropic-claude-flow.ts index 442c45c0..498be9f3 100644 --- a/AgentCompany-New/src/main/services/oauth/anthropic-claude-flow.ts +++ b/AgentCompany-New/src/main/services/oauth/anthropic-claude-flow.ts @@ -53,6 +53,22 @@ async function exchangeCode(code: string, state: string, verifier: string): Prom return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 }; } +/** Refresh an expired Claude Pro/Max access token. */ +export async function refreshClaudePro(refreshToken: string): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken }), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`Claude token refresh 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("Claude token refresh response was missing required fields."); + } + return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 }; +} + export const anthropicClaudeFlow: OAuthFlow = { providerKey: "claude-pro", async login(callbacks: OAuthLoginCallbacks): Promise { diff --git a/AgentCompany-New/src/main/services/oauth/github-copilot-flow.ts b/AgentCompany-New/src/main/services/oauth/github-copilot-flow.ts index ab7bbe81..8d2a64d6 100644 --- a/AgentCompany-New/src/main/services/oauth/github-copilot-flow.ts +++ b/AgentCompany-New/src/main/services/oauth/github-copilot-flow.ts @@ -9,7 +9,7 @@ import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-t // Decoded from the published GitHub Copilot client id used by the official CLI. const CLIENT_ID = "Iv1.b507a08c87ecfe98"; -const COPILOT_HEADERS = { +export const COPILOT_HEADERS = { "User-Agent": "GitHubCopilotChat/0.35.0", "Editor-Version": "vscode/1.107.0", "Editor-Plugin-Version": "copilot-chat/0.35.0", @@ -177,7 +177,7 @@ export async function refreshGitHubCopilotToken(githubToken: string, enterpriseD }; } -function copilotBaseUrl(token: string, enterpriseDomain?: string | null): string { +export function copilotBaseUrl(token: string, enterpriseDomain?: string | null): string { const match = token.match(/proxy-ep=([^;]+)/); if (match) return `https://${match[1].replace(/^proxy\./, "api.")}`; if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`; diff --git a/AgentCompany-New/src/main/services/oauth/google-antigravity-flow.ts b/AgentCompany-New/src/main/services/oauth/google-antigravity-flow.ts index 49e86d2a..eaa45a86 100644 --- a/AgentCompany-New/src/main/services/oauth/google-antigravity-flow.ts +++ b/AgentCompany-New/src/main/services/oauth/google-antigravity-flow.ts @@ -99,6 +99,23 @@ async function discoverProject(accessToken: string): Promise { return DEFAULT_PROJECT_ID; } +/** Refresh an expired Antigravity access token, retaining the project id. */ +export async function refreshAntigravity(refreshToken: string, projectId: string): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, refresh_token: refreshToken, grant_type: "refresh_token" }), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`Antigravity token refresh 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.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 { diff --git a/AgentCompany-New/src/main/services/oauth/openai-codex-flow.ts b/AgentCompany-New/src/main/services/oauth/openai-codex-flow.ts index 99baafac..a26caba5 100644 --- a/AgentCompany-New/src/main/services/oauth/openai-codex-flow.ts +++ b/AgentCompany-New/src/main/services/oauth/openai-codex-flow.ts @@ -76,6 +76,24 @@ async function exchangeCode(code: string, verifier: string): Promise<{ access: s return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 }; } +/** Refresh an expired Codex access token using the long-lived refresh token. */ +export async function refreshCodex(refreshToken: string): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`Codex token refresh 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("Codex token refresh response was missing required fields."); + } + const accountId = extractAccountId(json.access_token); + if (!accountId) throw new Error("Could not read the ChatGPT account id from the refreshed token."); + return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000, accountId }; +} + export const openaiCodexFlow: OAuthFlow = { providerKey: "gpt-account", async login(callbacks: OAuthLoginCallbacks): Promise { diff --git a/AgentCompany-New/src/main/services/provider-response-runner.ts b/AgentCompany-New/src/main/services/provider-response-runner.ts index 865e7611..22e81771 100644 --- a/AgentCompany-New/src/main/services/provider-response-runner.ts +++ b/AgentCompany-New/src/main/services/provider-response-runner.ts @@ -12,8 +12,9 @@ import type { ProviderRateLimitRegistry } from "./provider-rate-limit-registry"; import type { ProviderRequestStore } from "./provider-request-store"; import type { ProviderResponseObserver } from "./provider-response-observer"; import { hashPrompt, readProviderResponseId, requireStreamText } from "./provider-response-utils"; -import { requireApiKeyProfile } from "./provider-auth-profiles"; -import { selectToolTurn, type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn"; +import { type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn"; +import { selectToolTurn, supportsToolTurn } from "./provider-tool-turns/router"; +import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver"; import { HEALTH_STATE_HEALTHY, WORKSPACE_SCOPES, @@ -106,14 +107,14 @@ export class ProviderResponseRunner { const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId); const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null); this.enforcePolicy(input.orgId, provider, PROVIDER_EXECUTION_MODE.FOREGROUND, workspaceId); - if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) throw new Error("Agent tool execution requires an API-key provider."); + 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."); + } const model = provider.model; if (!model) throw new Error("Provider model is required."); const owner: ProviderRow & { model: string } = { ...provider, model }; - const adapter = requireAdapter(this.deps.adapters, provider.provider_key, provider.auth_type); const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key); - const credential = loadCredential(this.deps.authBroker, input.orgId, provider); - const apiKey = requireApiKeyProfile(adapter.parseAuthProfile(credential.secret), provider.provider_key).apiKey; + const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, orgId: input.orgId, provider }); const promptChars = JSON.stringify(input.messages).length; const requestId = this.deps.requestStore.create({ orgId: input.orgId, @@ -127,7 +128,7 @@ export class ProviderResponseRunner { }); const startedAt = Date.now(); try { - const result = await turn({ apiKey, model, instructions: input.instructions, messages: input.messages, tools: input.tools }); + const result = await turn({ credential, model, instructions: input.instructions, messages: input.messages, tools: input.tools }); this.deps.requestStore.markSucceeded({ orgId: input.orgId, requestId, diff --git a/AgentCompany-New/src/main/services/provider-tool-turn.ts b/AgentCompany-New/src/main/services/provider-tool-turn.ts index bae2b41c..a1233a35 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turn.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turn.ts @@ -1,13 +1,12 @@ /** - * Provider-agnostic single turn of a tool-use ("function calling") conversation. - * - * The agent loop maintains a normalized conversation ({@link AgentTurnMessage}[]) - * and the requested {@link ToolDefinition}s; these helpers translate that into the - * provider's wire format (OpenAI Responses API or Anthropic Messages API), call - * the live endpoint, and parse the assistant's reply plus any tool calls back into - * the normalized shape. + * Shared contract for a single tool-use ("function calling") turn against any + * provider backend. The agent loop maintains a normalized conversation + * ({@link AgentTurnMessage}[]) plus the requested {@link ToolDefinition}s; each + * provider's turn function (under `provider-tool-turns/`) translates that into + * the backend's wire format, calls the live endpoint, and parses the reply plus + * any tool calls back into {@link AgentTurnResult}. */ -import { isRecord, postJson, type JsonRecord } from "./provider-http"; +import { isRecord, type JsonRecord } from "./provider-http"; export interface ToolDefinition { name: string; @@ -33,15 +32,27 @@ export interface AgentTurnResult { rawResponseJson: string; } +/** Resolved per-request credential, already refreshed if it was an OAuth token. */ +export interface TurnCredential { + apiKey?: string; + accessToken?: string; + accountId?: string; + projectId?: string; + enterpriseUrl?: string | null; +} + export interface ProviderToolTurnInput { - apiKey: string; + credential: TurnCredential; model: string; instructions: string | null; messages: AgentTurnMessage[]; tools: ToolDefinition[]; } -function parseArguments(value: unknown): JsonRecord { +export type ToolTurnFn = (input: ProviderToolTurnInput) => Promise; + +/** Coerce a tool-call argument payload (string or object) into a JSON object. */ +export function parseArguments(value: unknown): JsonRecord { if (isRecord(value)) return value; if (typeof value === "string" && value.trim()) { try { @@ -53,112 +64,3 @@ function parseArguments(value: unknown): JsonRecord { } return {}; } - -// ---- OpenAI Responses API ---- - -function toOpenAiInput(messages: AgentTurnMessage[]): unknown[] { - const items: unknown[] = []; - for (const message of messages) { - if (message.role === "user") { - items.push({ role: "user", content: message.text }); - } else if (message.role === "assistant") { - for (const call of message.toolCalls) { - items.push({ type: "function_call", call_id: call.id, name: call.name, arguments: JSON.stringify(call.arguments) }); - } - } else { - for (const result of message.results) { - items.push({ type: "function_call_output", call_id: result.toolCallId, output: result.output }); - } - } - } - return items; -} - -function parseOpenAiResult(data: JsonRecord): AgentTurnResult { - const output = Array.isArray(data.output) ? data.output : []; - const textParts: string[] = []; - const toolCalls: ToolCall[] = []; - for (const item of output) { - if (!isRecord(item)) continue; - if (item.type === "function_call") { - const callId = typeof item.call_id === "string" ? item.call_id : typeof item.id === "string" ? item.id : ""; - const name = typeof item.name === "string" ? item.name : ""; - if (callId && name) toolCalls.push({ id: callId, name, arguments: parseArguments(item.arguments) }); - } else if (item.type === "message" && Array.isArray(item.content)) { - for (const block of item.content) { - if (isRecord(block) && block.type === "output_text" && typeof block.text === "string") textParts.push(block.text); - } - } - } - return { text: textParts.join("\n").trim(), toolCalls, rawResponseJson: JSON.stringify(data) }; -} - -export async function runOpenAiToolTurn(input: ProviderToolTurnInput): Promise { - const body: JsonRecord = { - model: input.model, - input: toOpenAiInput(input.messages), - tools: input.tools.map((tool) => ({ type: "function", name: tool.name, description: tool.description, parameters: tool.parameters })), - tool_choice: "auto", - ...(input.instructions ? { instructions: input.instructions } : {}), - }; - const response = await postJson("https://api.openai.com/v1/responses", { authorization: `Bearer ${input.apiKey}` }, body); - return parseOpenAiResult(response.data); -} - -// ---- Anthropic Messages API ---- - -function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] { - const out: unknown[] = []; - for (const message of messages) { - if (message.role === "user") { - out.push({ role: "user", content: [{ type: "text", text: message.text }] }); - } else if (message.role === "assistant") { - const content: unknown[] = []; - if (message.text) content.push({ type: "text", text: message.text }); - for (const call of message.toolCalls) content.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments }); - out.push({ role: "assistant", content }); - } else { - out.push({ role: "user", content: message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output })) }); - } - } - return out; -} - -function parseAnthropicResult(data: JsonRecord): AgentTurnResult { - const content = Array.isArray(data.content) ? data.content : []; - const textParts: string[] = []; - const toolCalls: ToolCall[] = []; - for (const block of content) { - if (!isRecord(block)) continue; - if (block.type === "text" && typeof block.text === "string") textParts.push(block.text); - else if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") { - toolCalls.push({ id: block.id, name: block.name, arguments: isRecord(block.input) ? block.input : {} }); - } - } - return { text: textParts.join("\n").trim(), toolCalls, rawResponseJson: JSON.stringify(data) }; -} - -export async function runAnthropicToolTurn(input: ProviderToolTurnInput): Promise { - const body: JsonRecord = { - model: input.model, - max_tokens: 4096, - tools: input.tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters })), - messages: toAnthropicMessages(input.messages), - ...(input.instructions ? { system: input.instructions } : {}), - }; - const response = await postJson("https://api.anthropic.com/v1/messages", { "x-api-key": input.apiKey, "anthropic-version": "2023-06-01" }, body); - return parseAnthropicResult(response.data); -} - -export type ToolTurnFn = (input: ProviderToolTurnInput) => Promise; - -/** Provider keys that can drive a native tool-use loop, by wire protocol. */ -export function selectToolTurn(providerKey: string): ToolTurnFn { - if (providerKey === "openai") return runOpenAiToolTurn; - if (providerKey === "anthropic") return runAnthropicToolTurn; - throw new Error(`${providerKey} does not support agent tool execution; assign the agent an OpenAI or Anthropic provider.`); -} - -export function supportsToolTurn(providerKey: string): boolean { - return providerKey === "openai" || providerKey === "anthropic"; -} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/anthropic-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/anthropic-format.ts new file mode 100644 index 00000000..ed98ba72 --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/anthropic-format.ts @@ -0,0 +1,38 @@ +/** Anthropic Messages API encoding/decoding, shared by anthropic + claude-pro. */ +import { isRecord, type JsonRecord } from "../provider-http"; +import { type AgentTurnMessage, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; + +export function anthropicToolDefs(tools: ToolDefinition[]): unknown[] { + return tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters })); +} + +export function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] { + const out: unknown[] = []; + for (const message of messages) { + if (message.role === "user") { + out.push({ role: "user", content: [{ type: "text", text: message.text }] }); + } else if (message.role === "assistant") { + const content: unknown[] = []; + if (message.text) content.push({ type: "text", text: message.text }); + for (const call of message.toolCalls) content.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments }); + out.push({ role: "assistant", content }); + } else { + out.push({ role: "user", content: message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output })) }); + } + } + return out; +} + +export function parseAnthropicContent(data: JsonRecord): { text: string; toolCalls: ToolCall[] } { + const content = Array.isArray(data.content) ? data.content : []; + const textParts: string[] = []; + const toolCalls: ToolCall[] = []; + for (const block of content) { + if (!isRecord(block)) continue; + if (block.type === "text" && typeof block.text === "string") textParts.push(block.text); + else if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") { + toolCalls.push({ id: block.id, name: block.name, arguments: isRecord(block.input) ? block.input : {} }); + } + } + return { text: textParts.join("\n").trim(), toolCalls }; +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/chat-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/chat-format.ts new file mode 100644 index 00000000..fef93611 --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/chat-format.ts @@ -0,0 +1,46 @@ +/** OpenAI Chat Completions encoding/decoding (xAI, Groq, Mistral, Cerebras, OpenRouter). */ +import { isRecord, type JsonRecord } from "../provider-http"; +import { parseArguments, type AgentTurnMessage, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; + +export function chatToolDefs(tools: ToolDefinition[]): unknown[] { + return tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } })); +} + +export function toChatMessages(instructions: string | null, messages: AgentTurnMessage[]): unknown[] { + const out: unknown[] = []; + if (instructions) out.push({ role: "system", content: instructions }); + for (const message of messages) { + if (message.role === "user") { + out.push({ role: "user", content: message.text }); + } else if (message.role === "assistant") { + const entry: JsonRecord = { role: "assistant", content: message.text || null }; + if (message.toolCalls.length > 0) { + entry.tool_calls = message.toolCalls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: JSON.stringify(call.arguments) } })); + } + out.push(entry); + } else { + for (const result of message.results) { + out.push({ role: "tool", tool_call_id: result.toolCallId, content: result.output }); + } + } + } + return out; +} + +export function parseChatChoice(data: JsonRecord): { text: string; toolCalls: ToolCall[] } { + const choices = Array.isArray(data.choices) ? data.choices : []; + const first = choices.length > 0 && isRecord(choices[0]) ? choices[0] : null; + const message = first && isRecord(first.message) ? first.message : null; + if (!message) return { text: "", toolCalls: [] }; + const text = typeof message.content === "string" ? message.content : ""; + const rawCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + const toolCalls: ToolCall[] = []; + for (const call of rawCalls) { + if (!isRecord(call)) continue; + const fn = isRecord(call.function) ? call.function : null; + const id = typeof call.id === "string" ? call.id : ""; + const name = fn && typeof fn.name === "string" ? fn.name : ""; + if (id && name) toolCalls.push({ id, name, arguments: parseArguments(fn?.arguments) }); + } + return { text: text.trim(), toolCalls }; +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts b/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts new file mode 100644 index 00000000..10cdb5e0 --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts @@ -0,0 +1,88 @@ +/** + * Resolves the per-request credential for a tool-use turn. API-key providers + * 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. + */ +import { PROVIDER_AUTH_TYPE } from "../../../shared/domain"; +import type { AuthBrokerService } from "../auth-broker-service"; +import { loadCredential, type ProviderRow } from "../provider-internal"; +import { parseApiKey, requireApiKeyProfile } from "../provider-auth-profiles"; +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 } from "../oauth/google-antigravity-flow"; +import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow"; + +interface StoredOAuth { + access: string; + refresh: string; + expires: number; + accountId?: string; + projectId?: string; + enterpriseUrl?: string | null; +} + +function parseStoredOAuth(secret: string): StoredOAuth { + let parsed: unknown; + try { + parsed = JSON.parse(secret); + } catch { + throw new Error("Stored OAuth credential is not valid JSON; reconnect the provider."); + } + const creds = parsed as StoredOAuth; + if (typeof creds.access !== "string" || typeof creds.refresh !== "string" || typeof creds.expires !== "number") { + throw new Error("Stored OAuth credential is incomplete; reconnect the provider."); + } + return creds; +} + +function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise { + switch (providerKey) { + case "gpt-account": + return refreshCodex(creds.refresh); + case "claude-pro": + return refreshClaudePro(creds.refresh); + case "google-antigravity": + if (!creds.projectId) throw new Error("Antigravity credential is missing its project id; reconnect the provider."); + return refreshAntigravity(creds.refresh, creds.projectId); + case "github-copilot": + return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined); + default: + throw new Error(`${providerKey} cannot refresh its credential.`); + } +} + +function toTurnCredential(creds: StoredOAuth | OAuthCredentials): TurnCredential { + const enterpriseUrl = creds.enterpriseUrl; + 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; + orgId: string; + provider: ProviderRow; +}): Promise { + const secret = loadCredential(deps.authBroker, deps.orgId, deps.provider).secret; + if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.API_KEY) { + return { apiKey: requireApiKeyProfile(parseApiKey(secret), deps.provider.provider_key).apiKey }; + } + const creds = parseStoredOAuth(secret); + if (Date.now() < creds.expires) return toTurnCredential(creds); + if (!deps.provider.credential_id) throw new Error("Provider credential id is missing; reconnect the provider."); + const refreshed = await refreshOAuth(deps.provider.provider_key, creds); + deps.authBroker.updateProviderSecret({ + orgId: deps.orgId, + providerKey: deps.provider.provider_key, + credentialId: deps.provider.credential_id, + secret: JSON.stringify(refreshed), + }); + return toTurnCredential(refreshed); +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts new file mode 100644 index 00000000..032ae91d --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts @@ -0,0 +1,117 @@ +/** + * 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. + */ +import { isRecord, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http"; +import { type AgentTurnMessage, type AgentTurnResult, 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-"); + return [ + { + functionDeclarations: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + ...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }), + })), + }, + ]; +} + +function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonRecord[] { + const withId = usesToolCallId(model); + const contents: JsonRecord[] = []; + for (const message of messages) { + if (message.role === "user") { + contents.push({ role: "user", parts: [{ text: message.text }] }); + } else if (message.role === "assistant") { + 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 } : {}) } }); + } + if (parts.length > 0) contents.push({ role: "model", parts }); + } else { + const responses = message.results.map((result) => ({ + functionResponse: { name: result.toolName, response: { output: result.output }, ...(withId ? { id: result.toolCallId } : {}) }, + })); + const last = contents[contents.length - 1]; + if (last && last.role === "user" && Array.isArray(last.parts) && last.parts.some((part) => isRecord(part) && "functionResponse" in part)) { + (last.parts as unknown[]).push(...responses); + } else { + contents.push({ role: "user", parts: responses }); + } + } + } + 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), + 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 }; +} + +export async function accumulateGeminiStream(messages: AsyncIterable): Promise { + const raw: string[] = []; + const textParts: string[] = []; + const toolCalls: ToolCall[] = []; + 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 Error(typeof error.message === "string" ? error.message : "Antigravity stream failed."); + } + const response = isRecord(message.data.response) ? message.data.response : null; + if (!response) continue; + const candidates = Array.isArray(response.candidates) ? response.candidates : []; + 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; + 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({ id, name, arguments: isRecord(fc.args) ? fc.args : {} }); + } + } + } + return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw) }; +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/responses-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/responses-format.ts new file mode 100644 index 00000000..4e1344eb --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/responses-format.ts @@ -0,0 +1,106 @@ +/** + * OpenAI Responses API encoding/decoding, shared by the openai, gpt-account + * (Codex) and github-copilot turns. Matches Pi's `convertResponsesMessages` / + * `convertResponsesTools` and the Responses streaming event shapes. + */ +import { isRecord, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http"; +import { parseArguments, type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; + +export function responsesToolDefs(tools: ToolDefinition[], strict: boolean | null = false): unknown[] { + return tools.map((tool) => ({ type: "function", name: tool.name, description: tool.description, parameters: tool.parameters, strict })); +} + +export function toResponsesInput(messages: AgentTurnMessage[]): unknown[] { + const items: unknown[] = []; + for (const message of messages) { + if (message.role === "user") { + items.push({ role: "user", content: [{ type: "input_text", text: message.text }] }); + } else if (message.role === "assistant") { + if (message.text) { + items.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: message.text, annotations: [] }] }); + } + for (const call of message.toolCalls) { + items.push({ type: "function_call", call_id: call.id, name: call.name, arguments: JSON.stringify(call.arguments) }); + } + } else { + for (const result of message.results) { + items.push({ type: "function_call_output", call_id: result.toolCallId, output: result.output }); + } + } + } + return items; +} + +/** Parse a non-streaming Responses payload (`data.output[]`). */ +export function parseResponsesOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[] } { + const output = Array.isArray(data.output) ? data.output : []; + const textParts: string[] = []; + const toolCalls: ToolCall[] = []; + for (const item of output) { + if (!isRecord(item)) continue; + if (item.type === "function_call") { + const id = typeof item.call_id === "string" ? item.call_id : typeof item.id === "string" ? item.id : ""; + const name = typeof item.name === "string" ? item.name : ""; + if (id && name) toolCalls.push({ id, name, arguments: parseArguments(item.arguments) }); + } else if (item.type === "message" && Array.isArray(item.content)) { + for (const block of item.content) { + if (isRecord(block) && block.type === "output_text" && typeof block.text === "string") textParts.push(block.text); + } + } + } + return { text: textParts.join("\n").trim(), toolCalls }; +} + +function eventType(message: ProviderEventStreamMessage): string | null { + const type = message.data.type; + return typeof type === "string" ? type : message.eventName; +} + +function errorText(data: JsonRecord): string { + const error = data.error; + if (typeof error === "string") return error; + if (isRecord(error) && typeof error.message === "string") return error.message; + if (typeof data.message === "string") return data.message; + return "Provider stream failed."; +} + +/** + * Accumulate a Responses SSE stream (Codex/Copilot) into the final result. The + * authoritative output arrives on the completion event's `response.output`; the + * incremental events are accumulated only as a fallback. + */ +export async function accumulateResponsesStream(messages: AsyncIterable): Promise { + const raw: string[] = []; + const textParts: string[] = []; + const calls = new Map(); + for await (const message of messages) { + raw.push(message.rawData); + const type = eventType(message); + if (!type) continue; + if (type === "error" || type === "response.failed") throw new Error(errorText(message.data)); + if (type === "response.completed" || type === "response.done" || type === "response.incomplete") { + const response = isRecord(message.data.response) ? message.data.response : null; + if (response && Array.isArray(response.output)) { + const parsed = parseResponsesOutput(response); + return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) }; + } + break; + } + if (type === "response.output_text.delta" && typeof message.data.delta === "string") { + textParts.push(message.data.delta); + } else if (type === "response.output_item.added" && isRecord(message.data.item) && message.data.item.type === "function_call") { + const item = message.data.item; + const key = typeof item.id === "string" ? item.id : typeof item.call_id === "string" ? item.call_id : ""; + const id = typeof item.call_id === "string" ? item.call_id : key; + if (key) calls.set(key, { id, name: typeof item.name === "string" ? item.name : "", args: typeof item.arguments === "string" ? item.arguments : "" }); + } else if (type === "response.function_call_arguments.delta" && typeof message.data.item_id === "string" && typeof message.data.delta === "string") { + const entry = calls.get(message.data.item_id); + if (entry) entry.args += message.data.delta; + } else if (type === "response.function_call_arguments.done" && typeof message.data.item_id === "string" && typeof message.data.arguments === "string") { + const entry = calls.get(message.data.item_id); + if (entry) entry.args = message.data.arguments; + } + } + const toolCalls = [...calls.values()].filter((entry) => entry.name).map((entry) => ({ id: entry.id, name: entry.name, arguments: parseArguments(entry.args) })); + return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw) }; +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/router.ts b/AgentCompany-New/src/main/services/provider-tool-turns/router.ts new file mode 100644 index 00000000..cbee94fa --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/router.ts @@ -0,0 +1,35 @@ +import type { ToolTurnFn } from "../provider-tool-turn"; +import { + makeChatCompletionsTurn, + runAnthropicTurn, + runAntigravityTurn, + runClaudeProTurn, + runCodexTurn, + runCopilotTurn, + runOpenAiResponsesTurn, +} from "./turns"; + +/** Provider key -> the tool-use turn function that drives the agent loop. */ +const TURNS: Record = { + openai: runOpenAiResponsesTurn, + anthropic: runAnthropicTurn, + xai: makeChatCompletionsTurn("https://api.x.ai/v1/chat/completions", "xAI"), + groq: makeChatCompletionsTurn("https://api.groq.com/openai/v1/chat/completions", "Groq"), + mistral: makeChatCompletionsTurn("https://api.mistral.ai/v1/chat/completions", "Mistral"), + cerebras: makeChatCompletionsTurn("https://api.cerebras.ai/v1/chat/completions", "Cerebras"), + openrouter: makeChatCompletionsTurn("https://openrouter.ai/api/v1/chat/completions", "OpenRouter"), + "gpt-account": runCodexTurn, + "github-copilot": runCopilotTurn, + "google-antigravity": runAntigravityTurn, + "claude-pro": runClaudeProTurn, +}; + +export function supportsToolTurn(providerKey: string): boolean { + return providerKey in TURNS; +} + +export function selectToolTurn(providerKey: string): ToolTurnFn { + const turn = TURNS[providerKey]; + if (!turn) throw new Error(`${providerKey} does not support agent execution.`); + return turn; +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts b/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts new file mode 100644 index 00000000..518701a6 --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts @@ -0,0 +1,176 @@ +/** + * Per-backend tool-use turn functions. Each takes the normalized + * {@link ProviderToolTurnInput} (with a resolved credential) and returns an + * {@link AgentTurnResult}. Faithful ports of Pi's provider request handlers. + */ +import { release as osRelease, arch as osArch, platform as osPlatform } from "node:os"; +import { postEventStream, postJson, type JsonRecord } from "../provider-http"; +import type { AgentTurnMessage, AgentTurnResult, ProviderToolTurnInput, TurnCredential } from "../provider-tool-turn"; +import { accumulateResponsesStream, parseResponsesOutput, responsesToolDefs, toResponsesInput } from "./responses-format"; +import { anthropicToolDefs, parseAnthropicContent, toAnthropicMessages } from "./anthropic-format"; +import { chatToolDefs, parseChatChoice, toChatMessages } from "./chat-format"; +import { accumulateGeminiStream, antigravityRequestBody } from "./gemini-format"; +import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow"; + +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 { + if (!credential.apiKey) throw new Error(`${label} requires an API key.`); + return credential.apiKey; +} + +function requireAccessToken(credential: TurnCredential, label: string): string { + if (!credential.accessToken) throw new Error(`${label} requires a signed-in access token.`); + return credential.accessToken; +} + +function lastMessageIsUser(messages: AgentTurnMessage[]): boolean { + const last = messages[messages.length - 1]; + return last?.role === "user"; +} + +// ---- OpenAI Responses (api key) ---- + +export async function runOpenAiResponsesTurn(input: ProviderToolTurnInput): Promise { + const body: JsonRecord = { + model: input.model, + input: toResponsesInput(input.messages), + tools: responsesToolDefs(input.tools), + tool_choice: "auto", + store: false, + ...(input.instructions ? { instructions: input.instructions } : {}), + }; + const response = await postJson("https://api.openai.com/v1/responses", { authorization: `Bearer ${requireApiKey(input.credential, "OpenAI")}` }, body); + const parsed = parseResponsesOutput(response.data); + return { ...parsed, rawResponseJson: JSON.stringify(response.data) }; +} + +// ---- OpenAI Chat Completions (xAI, Groq, Mistral, Cerebras, OpenRouter) ---- + +export function makeChatCompletionsTurn(endpoint: string, label: string): (input: ProviderToolTurnInput) => Promise { + return async (input) => { + const body: JsonRecord = { + model: input.model, + messages: toChatMessages(input.instructions, input.messages), + tools: chatToolDefs(input.tools), + tool_choice: "auto", + stream: false, + }; + const response = await postJson(endpoint, { authorization: `Bearer ${requireApiKey(input.credential, label)}` }, body); + const parsed = parseChatChoice(response.data); + return { ...parsed, rawResponseJson: JSON.stringify(response.data) }; + }; +} + +// ---- Anthropic Messages (api key) ---- + +export async function runAnthropicTurn(input: ProviderToolTurnInput): Promise { + const body: JsonRecord = { + model: input.model, + max_tokens: 4096, + tools: anthropicToolDefs(input.tools), + messages: toAnthropicMessages(input.messages), + ...(input.instructions ? { system: input.instructions } : {}), + }; + const response = await postJson("https://api.anthropic.com/v1/messages", { "x-api-key": requireApiKey(input.credential, "Anthropic"), "anthropic-version": "2023-06-01" }, body); + const parsed = parseAnthropicContent(response.data); + return { ...parsed, rawResponseJson: JSON.stringify(response.data) }; +} + +// ---- Claude Pro/Max (OAuth subscription) ---- + +export async function runClaudeProTurn(input: ProviderToolTurnInput): Promise { + const system: unknown[] = [ + { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + ...(input.instructions ? [{ type: "text", text: input.instructions }] : []), + ]; + const body: JsonRecord = { + model: input.model, + max_tokens: 4096, + tools: anthropicToolDefs(input.tools), + messages: toAnthropicMessages(input.messages), + system, + }; + const response = await postJson("https://api.anthropic.com/v1/messages", { + authorization: `Bearer ${requireAccessToken(input.credential, "Claude Pro")}`, + "anthropic-version": "2023-06-01", + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14", + "anthropic-dangerous-direct-browser-access": "true", + "user-agent": "claude-cli/2.1.75", + "x-app": "cli", + }, body); + const parsed = parseAnthropicContent(response.data); + return { ...parsed, rawResponseJson: JSON.stringify(response.data) }; +} + +// ---- GPT account / OpenAI Codex (OAuth, SSE) ---- + +export async function runCodexTurn(input: ProviderToolTurnInput): Promise { + const access = requireAccessToken(input.credential, "GPT account"); + if (!input.credential.accountId) throw new Error("GPT account credential is missing its account id."); + const body: JsonRecord = { + model: input.model, + store: false, + stream: true, + ...(input.instructions ? { instructions: input.instructions } : {}), + input: toResponsesInput(input.messages), + text: { verbosity: "medium" }, + include: ["reasoning.encrypted_content"], + tool_choice: "auto", + parallel_tool_calls: true, + tools: responsesToolDefs(input.tools, null), + }; + const response = await postEventStream(`${CODEX_BASE_URL}/codex/responses`, { + Authorization: `Bearer ${access}`, + "chatgpt-account-id": input.credential.accountId, + originator: "agentcompany", + "User-Agent": `agentcompany (${osPlatform()} ${osRelease()}; ${osArch()})`, + "OpenAI-Beta": "responses=experimental", + }, body); + return accumulateResponsesStream(response.messages); +} + +// ---- GitHub Copilot (OAuth, openai-responses at the proxy) ---- + +export async function runCopilotTurn(input: ProviderToolTurnInput): Promise { + const access = requireAccessToken(input.credential, "GitHub Copilot"); + const baseUrl = copilotBaseUrl(access, input.credential.enterpriseUrl ?? undefined); + const body: JsonRecord = { + model: input.model, + input: toResponsesInput(input.messages), + tools: responsesToolDefs(input.tools), + tool_choice: "auto", + store: false, + }; + const response = await postJson(`${baseUrl}/responses`, { + Authorization: `Bearer ${access}`, + ...COPILOT_HEADERS, + "X-Initiator": lastMessageIsUser(input.messages) ? "user" : "agent", + "Openai-Intent": "conversation-edits", + }, body); + const parsed = parseResponsesOutput(response.data); + return { ...parsed, rawResponseJson: JSON.stringify(response.data) }; +} + +// ---- Google Antigravity (OAuth, Gemini Code Assist, SSE) ---- + +export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise { + 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 accumulateGeminiStream(response.messages); +} diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index 36d3bdef..79024136 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -332,7 +332,8 @@ export class TaskExecutionService { and a.heartbeat_enabled = 1 and a.lifecycle = 'active' and p.enabled = 1 - and not exists (select 1 from task_runs r where r.task_id = t.id) + and not exists (select 1 from task_runs r where r.task_id = t.id and r.state in ('queued', 'running', 'completed')) + and (select count(*) from task_runs r where r.task_id = t.id) < 3 `).all() as Array<{ task_id: string; org_id: string }>; const created: string[] = []; for (const row of rows) { diff --git a/AgentCompany-New/tests/unit/provider-tool-turns.test.ts b/AgentCompany-New/tests/unit/provider-tool-turns.test.ts new file mode 100644 index 00000000..3e0e07ac --- /dev/null +++ b/AgentCompany-New/tests/unit/provider-tool-turns.test.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import type { JsonRecord, ProviderEventStreamMessage } from "../../src/main/services/provider-http"; +import type { AgentTurnMessage } from "../../src/main/services/provider-tool-turn"; +import { accumulateResponsesStream, parseResponsesOutput, toResponsesInput } from "../../src/main/services/provider-tool-turns/responses-format"; +import { parseChatChoice, toChatMessages } from "../../src/main/services/provider-tool-turns/chat-format"; +import { parseAnthropicContent, toAnthropicMessages } from "../../src/main/services/provider-tool-turns/anthropic-format"; +import { accumulateGeminiStream, antigravityRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format"; +import { selectToolTurn, supportsToolTurn } from "../../src/main/services/provider-tool-turns/router"; +import { resolveTurnCredential } from "../../src/main/services/provider-tool-turns/credential-resolver"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { AuthBrokerService } from "../../src/main/services/auth-broker-service"; +import { loadConfiguredProvider } from "../../src/main/services/provider-internal"; + +const CONVO: AgentTurnMessage[] = [ + { role: "user", text: "do it" }, + { role: "assistant", text: "calling", toolCalls: [{ id: "c1", name: "hire_agent", arguments: { name: "Sam" } }] }, + { role: "tool", results: [{ toolCallId: "c1", toolName: "hire_agent", output: "ok" }] }, +]; + +async function* stream(items: JsonRecord[]): AsyncIterable { + for (const data of items) yield { eventName: null, data, rawData: JSON.stringify(data) }; +} + +const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +describe("openai responses encoding", () => { + test("encodes the conversation and tool calls", () => { + expect(toResponsesInput(CONVO)).toEqual([ + { role: "user", content: [{ type: "input_text", text: "do it" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "calling", annotations: [] }] }, + { type: "function_call", call_id: "c1", name: "hire_agent", arguments: JSON.stringify({ name: "Sam" }) }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + ]); + }); + + test("parses output text and function calls", () => { + const data = { output: [ + { type: "message", content: [{ type: "output_text", text: "done" }] }, + { type: "function_call", call_id: "c2", name: "create_task", arguments: '{"title":"x"}' }, + ] }; + expect(parseResponsesOutput(data)).toEqual({ text: "done", toolCalls: [{ id: "c2", name: "create_task", arguments: { title: "x" } }] }); + }); + + test("accumulates the SSE completion event", async () => { + const result = await accumulateResponsesStream(stream([ + { type: "response.output_text.delta", delta: "partial" }, + { type: "response.completed", response: { output: [{ type: "function_call", call_id: "c3", name: "write_deliverable", arguments: '{"path":"a"}' }] } }, + ])); + expect(result.toolCalls).toEqual([{ id: "c3", name: "write_deliverable", arguments: { path: "a" } }]); + }); +}); + +describe("openai chat completions encoding", () => { + test("encodes system, tool calls and tool results", () => { + expect(toChatMessages("sys", CONVO)).toEqual([ + { role: "system", content: "sys" }, + { role: "user", content: "do it" }, + { role: "assistant", content: "calling", tool_calls: [{ id: "c1", type: "function", function: { name: "hire_agent", arguments: JSON.stringify({ name: "Sam" }) } }] }, + { role: "tool", tool_call_id: "c1", content: "ok" }, + ]); + }); + + test("parses a tool call choice", () => { + const data = { choices: [{ message: { content: "hi", tool_calls: [{ id: "c9", function: { name: "f", arguments: '{"a":1}' } }] } }] }; + expect(parseChatChoice(data)).toEqual({ text: "hi", toolCalls: [{ id: "c9", name: "f", arguments: { a: 1 } }] }); + }); +}); + +describe("anthropic encoding", () => { + test("encodes tool_use and tool_result", () => { + expect(toAnthropicMessages(CONVO)).toEqual([ + { role: "user", content: [{ type: "text", text: "do it" }] }, + { role: "assistant", content: [{ type: "text", text: "calling" }, { type: "tool_use", id: "c1", name: "hire_agent", input: { name: "Sam" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "c1", content: "ok" }] }, + ]); + }); + + test("parses text and tool_use blocks", () => { + const data = { content: [{ type: "text", text: "ok" }, { type: "tool_use", id: "tu", name: "f", input: { x: 1 } }] }; + expect(parseAnthropicContent(data)).toEqual({ text: "ok", toolCalls: [{ id: "tu", name: "f", arguments: { x: 1 } }] }); + }); +}); + +describe("antigravity (gemini) encoding", () => { + test("wraps the request with the Antigravity system instruction and tool declarations", () => { + const body = antigravityRequestBody({ model: "claude-sonnet-4-5", projectId: "proj", instructions: "be helpful", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }], requestId: "agent-1" }); + expect(body.project).toBe("proj"); + expect(body.requestType).toBe("agent"); + expect(body.userAgent).toBe("antigravity"); + const request = body.request as JsonRecord; + const system = request.systemInstruction as { role: string; parts: Array<{ text: string }> }; + expect(system.role).toBe("user"); + expect(system.parts[0].text).toContain("You are Antigravity"); + expect(system.parts[system.parts.length - 1].text).toBe("be helpful"); + // claude-* models use the legacy `parameters` field + expect(request.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parameters: { type: "object" } }] }]); + }); + + test("gemini models use parametersJsonSchema and omit the tool-call id", () => { + const body = antigravityRequestBody({ model: "gemini-3.1-pro-high", projectId: "proj", instructions: null, messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }], requestId: "agent-2" }); + const request = body.request as JsonRecord; + expect(request.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]); + const contents = request.contents as Array<{ role: string; parts: Array }>; + const assistant = contents.find((c) => c.role === "model"); + const fnCall = assistant?.parts.find((p) => "functionCall" in p)?.functionCall as JsonRecord; + expect(fnCall.id).toBeUndefined(); + }); + + test("accumulates streamed candidates", async () => { + const result = await accumulateGeminiStream(stream([ + { response: { candidates: [{ content: { parts: [{ text: "hello" }, { functionCall: { name: "f", args: { x: 1 }, id: "g1" } }] } }] } }, + ])); + expect(result.text).toBe("hello"); + expect(result.toolCalls).toEqual([{ id: "g1", name: "f", arguments: { x: 1 } }]); + }); +}); + +describe("tool-turn router", () => { + test("supports every agent-capable provider and rejects cloud-only ones", () => { + for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "gpt-account", "github-copilot", "google-antigravity", "claude-pro"]) { + expect(supportsToolTurn(key)).toBe(true); + expect(typeof selectToolTurn(key)).toBe("function"); + } + for (const key of ["azure-openai", "aws-bedrock", "vertex-ai"]) { + expect(supportsToolTurn(key)).toBe(false); + expect(() => selectToolTurn(key)).toThrow(); + } + }); +}); + +describe("credential resolver", () => { + test("returns the api key for api-key providers and the access token for fresh OAuth", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-cred-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const providers = new ProviderService(connection.db, providerAdapters, testVault); + const authBroker = new AuthBrokerService(connection.db, testVault); + let orgId = ""; + let openaiId = ""; + let gptAccountId = ""; + const future = Date.now() + 60 * 60 * 1000; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + openaiId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + gptAccountId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'gpt-account'").get(orgId) as { id: string }).id; + providers.saveCredential({ orgId, providerId: openaiId, secret: "sk-live", model: "gpt-5.4" }); + providers.saveCredential({ orgId, providerId: gptAccountId, secret: JSON.stringify({ access: "tok", refresh: "r", expires: future, accountId: "acct_1" }), model: "gpt-5.4" }); + }); + + return (async () => { + const apiKeyCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, openaiId) }); + expect(apiKeyCred).toEqual({ apiKey: "sk-live" }); + const oauthCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, gptAccountId) }); + expect(oauthCred.accessToken).toBe("tok"); + expect(oauthCred.accountId).toBe("acct_1"); + connection.close(); + })(); + }); +});