feat: Add Antigravity provider integration + fix native mode startup
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
- Added Antigravity AI provider with Google OAuth authentication - New integration client (antigravity.ts) with automatic endpoint fallback - API routes for /api/antigravity/* (models, auth-status, test, chat) - AntigravitySettings.tsx for Advanced Settings panel - Updated session-api.ts and session-actions.ts for provider routing - Updated opencode.jsonc with Antigravity plugin and 11 models: - Gemini 3 Pro Low/High, Gemini 3 Flash - Claude Sonnet 4.5 (+ thinking variants) - Claude Opus 4.5 (+ thinking variants) - GPT-OSS 120B Medium - Fixed native mode startup error (was trying to launch __nomadarch_native__ as binary) - Native mode workspaces now skip binary launch and are immediately ready
This commit is contained in:
495
packages/server/src/integrations/antigravity.ts
Normal file
495
packages/server/src/integrations/antigravity.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* Antigravity API Integration for Binary-Free Mode
|
||||
* Provides direct access to Antigravity models (Gemini, Claude, GPT-OSS) via Google OAuth
|
||||
* Based on the opencode-antigravity-auth plugin: https://github.com/NoeFabris/opencode-antigravity-auth
|
||||
*
|
||||
* This integration enables access to:
|
||||
* - Gemini 3 Pro/Flash models
|
||||
* - Claude Sonnet 4.5 / Opus 4.5 (with thinking variants)
|
||||
* - GPT-OSS 120B
|
||||
*
|
||||
* Uses Google OAuth credentials stored via the Antigravity OAuth flow
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
// Configuration schema for Antigravity
|
||||
export const AntigravityConfigSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
// Multiple endpoints for automatic fallback (daily → autopush → prod)
|
||||
endpoints: z.array(z.string()).default([
|
||||
"https://daily.antigravity.dev/v1beta",
|
||||
"https://autopush.antigravity.dev/v1beta",
|
||||
"https://antigravity.dev/v1beta"
|
||||
]),
|
||||
apiKey: z.string().optional()
|
||||
})
|
||||
|
||||
export type AntigravityConfig = z.infer<typeof AntigravityConfigSchema>
|
||||
|
||||
// Antigravity Model schema
|
||||
export const AntigravityModelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
family: z.enum(["gemini", "claude", "gpt-oss"]).optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
tool_call: z.boolean().optional(),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
output: z.number()
|
||||
}).optional()
|
||||
})
|
||||
|
||||
export type AntigravityModel = z.infer<typeof AntigravityModelSchema>
|
||||
|
||||
// Chat message schema (OpenAI-compatible)
|
||||
export const ChatMessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant", "system", "tool"]),
|
||||
content: z.string().optional(),
|
||||
tool_calls: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z.string()
|
||||
})
|
||||
})).optional(),
|
||||
tool_call_id: z.string().optional()
|
||||
})
|
||||
|
||||
export type ChatMessage = z.infer<typeof ChatMessageSchema>
|
||||
|
||||
// Tool Definition Schema
|
||||
export const ToolDefinitionSchema = z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.any()),
|
||||
required: z.array(z.string()).optional()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>
|
||||
|
||||
// Chat request schema
|
||||
export const ChatRequestSchema = z.object({
|
||||
model: z.string(),
|
||||
messages: z.array(ChatMessageSchema),
|
||||
stream: z.boolean().default(true),
|
||||
temperature: z.number().optional(),
|
||||
max_tokens: z.number().optional(),
|
||||
tools: z.array(ToolDefinitionSchema).optional(),
|
||||
tool_choice: z.union([
|
||||
z.literal("auto"),
|
||||
z.literal("none"),
|
||||
z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({ name: z.string() })
|
||||
})
|
||||
]).optional(),
|
||||
workspacePath: z.string().optional(),
|
||||
enableTools: z.boolean().optional()
|
||||
})
|
||||
|
||||
export type ChatRequest = z.infer<typeof ChatRequestSchema>
|
||||
|
||||
// Chat response chunk schema
|
||||
export const ChatChunkSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
object: z.string().optional(),
|
||||
created: z.number().optional(),
|
||||
model: z.string().optional(),
|
||||
choices: z.array(z.object({
|
||||
index: z.number(),
|
||||
delta: z.object({
|
||||
role: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
reasoning_content: z.string().optional()
|
||||
}).optional(),
|
||||
message: z.object({
|
||||
role: z.string(),
|
||||
content: z.string()
|
||||
}).optional(),
|
||||
finish_reason: z.string().nullable().optional()
|
||||
}))
|
||||
})
|
||||
|
||||
export type ChatChunk = z.infer<typeof ChatChunkSchema>
|
||||
|
||||
// Available Antigravity models with their specifications
|
||||
export const ANTIGRAVITY_MODELS: AntigravityModel[] = [
|
||||
// Gemini Models
|
||||
{
|
||||
id: "gemini-3-pro-low",
|
||||
name: "Gemini 3 Pro Low (Antigravity)",
|
||||
family: "gemini",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 1048576, output: 65535 }
|
||||
},
|
||||
{
|
||||
id: "gemini-3-pro-high",
|
||||
name: "Gemini 3 Pro High (Antigravity)",
|
||||
family: "gemini",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 1048576, output: 65535 }
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash",
|
||||
name: "Gemini 3 Flash (Antigravity)",
|
||||
family: "gemini",
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
limit: { context: 1048576, output: 65536 }
|
||||
},
|
||||
// Claude Models
|
||||
{
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5 (Antigravity)",
|
||||
family: "claude",
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
limit: { context: 200000, output: 64000 }
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-5-thinking-low",
|
||||
name: "Claude Sonnet 4.5 Thinking Low (Antigravity)",
|
||||
family: "claude",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 200000, output: 64000 }
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-5-thinking-medium",
|
||||
name: "Claude Sonnet 4.5 Thinking Medium (Antigravity)",
|
||||
family: "claude",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 200000, output: 64000 }
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-5-thinking-high",
|
||||
name: "Claude Sonnet 4.5 Thinking High (Antigravity)",
|
||||
family: "claude",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 200000, output: 64000 }
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-5-thinking-low",
|
||||
name: "Claude Opus 4.5 Thinking Low (Antigravity)",
|
||||
family: "claude",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 200000, output: 64000 }
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-5-thinking-medium",
|
||||
name: "Claude Opus 4.5 Thinking Medium (Antigravity)",
|
||||
family: "claude",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 200000, output: 64000 }
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-5-thinking-high",
|
||||
name: "Claude Opus 4.5 Thinking High (Antigravity)",
|
||||
family: "claude",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 200000, output: 64000 }
|
||||
},
|
||||
// Other Models
|
||||
{
|
||||
id: "gpt-oss-120b-medium",
|
||||
name: "GPT-OSS 120B Medium (Antigravity)",
|
||||
family: "gpt-oss",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
limit: { context: 131072, output: 32768 }
|
||||
}
|
||||
]
|
||||
|
||||
// Token storage key for Antigravity OAuth
|
||||
const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token"
|
||||
|
||||
export interface AntigravityToken {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in: number
|
||||
created_at: number
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
export class AntigravityClient {
|
||||
private config: AntigravityConfig
|
||||
private currentEndpointIndex: number = 0
|
||||
private modelsCache: AntigravityModel[] | null = null
|
||||
private modelsCacheTime: number = 0
|
||||
private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
constructor(config?: Partial<AntigravityConfig>) {
|
||||
this.config = AntigravityConfigSchema.parse(config || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current active endpoint with automatic fallback
|
||||
*/
|
||||
private getEndpoint(): string {
|
||||
const endpoints = this.config.endpoints
|
||||
return endpoints[this.currentEndpointIndex] || endpoints[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate to next endpoint on failure
|
||||
*/
|
||||
private rotateEndpoint(): void {
|
||||
this.currentEndpointIndex = (this.currentEndpointIndex + 1) % this.config.endpoints.length
|
||||
console.log(`Antigravity: Rotating to endpoint ${this.getEndpoint()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OAuth token from localStorage (browser context)
|
||||
*/
|
||||
getStoredToken(): AntigravityToken | null {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ANTIGRAVITY_TOKEN_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the stored token is still valid
|
||||
*/
|
||||
isTokenValid(token: AntigravityToken | null): boolean {
|
||||
if (!token) return false
|
||||
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
||||
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 // 5 min buffer
|
||||
return Date.now() < expiresAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization headers for API requests
|
||||
*/
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "NomadArch/1.0"
|
||||
}
|
||||
|
||||
// Try OAuth token first
|
||||
const token = this.getStoredToken()
|
||||
if (token && this.isTokenValid(token)) {
|
||||
headers["Authorization"] = `Bearer ${token.access_token}`
|
||||
} else if (this.config.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
const token = this.getStoredToken()
|
||||
return this.isTokenValid(token) || Boolean(this.config.apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Antigravity models
|
||||
*/
|
||||
async getModels(): Promise<AntigravityModel[]> {
|
||||
// Return cached models if still valid
|
||||
const now = Date.now()
|
||||
if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) {
|
||||
return this.modelsCache
|
||||
}
|
||||
|
||||
// If authenticated, return full model list
|
||||
if (this.isAuthenticated()) {
|
||||
this.modelsCache = ANTIGRAVITY_MODELS
|
||||
this.modelsCacheTime = now
|
||||
return ANTIGRAVITY_MODELS
|
||||
}
|
||||
|
||||
// Not authenticated - return empty list
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to Antigravity API
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
if (!this.isAuthenticated()) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Try a simple models list request to verify connectivity
|
||||
const response = await fetch(`${this.getEndpoint()}/models`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.warn("Antigravity connection test failed:", error)
|
||||
// Try next endpoint
|
||||
this.rotateEndpoint()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat completion (streaming) with automatic endpoint fallback
|
||||
*/
|
||||
async *chatStream(request: ChatRequest): AsyncGenerator<ChatChunk> {
|
||||
if (!this.isAuthenticated()) {
|
||||
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
|
||||
}
|
||||
|
||||
let lastError: Error | null = null
|
||||
const maxRetries = this.config.endpoints.length
|
||||
|
||||
for (let retry = 0; retry < maxRetries; retry++) {
|
||||
try {
|
||||
const endpoint = this.getEndpoint()
|
||||
const response = await fetch(`${endpoint}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
stream: true
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(`Antigravity authentication failed: ${errorText}`)
|
||||
}
|
||||
// Try next endpoint for other errors
|
||||
this.rotateEndpoint()
|
||||
lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is missing")
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith("data: ")) {
|
||||
const data = trimmed.slice(6)
|
||||
if (data === "[DONE]") return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
yield parsed as ChatChunk
|
||||
|
||||
// Check for finish
|
||||
if (parsed.choices?.[0]?.finish_reason) {
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
return // Success, exit retry loop
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
if (error instanceof Error && error.message.includes("authentication")) {
|
||||
throw error // Don't retry auth errors
|
||||
}
|
||||
this.rotateEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Antigravity: All endpoints failed")
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat completion (non-streaming)
|
||||
*/
|
||||
async chat(request: ChatRequest): Promise<ChatChunk> {
|
||||
if (!this.isAuthenticated()) {
|
||||
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
|
||||
}
|
||||
|
||||
let lastError: Error | null = null
|
||||
const maxRetries = this.config.endpoints.length
|
||||
|
||||
for (let retry = 0; retry < maxRetries; retry++) {
|
||||
try {
|
||||
const endpoint = this.getEndpoint()
|
||||
const response = await fetch(`${endpoint}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(`Antigravity authentication failed: ${errorText}`)
|
||||
}
|
||||
this.rotateEndpoint()
|
||||
lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`)
|
||||
continue
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
if (error instanceof Error && error.message.includes("authentication")) {
|
||||
throw error
|
||||
}
|
||||
this.rotateEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Antigravity: All endpoints failed")
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultAntigravityConfig(): AntigravityConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
endpoints: [
|
||||
"https://daily.antigravity.dev/v1beta",
|
||||
"https://autopush.antigravity.dev/v1beta",
|
||||
"https://antigravity.dev/v1beta"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { registerOllamaRoutes } from "./routes/ollama"
|
||||
import { registerQwenRoutes } from "./routes/qwen"
|
||||
import { registerZAIRoutes } from "./routes/zai"
|
||||
import { registerOpenCodeZenRoutes } from "./routes/opencode-zen"
|
||||
import { registerAntigravityRoutes } from "./routes/antigravity"
|
||||
import { registerSkillsRoutes } from "./routes/skills"
|
||||
import { registerContextEngineRoutes } from "./routes/context-engine"
|
||||
import { registerNativeSessionsRoutes } from "./routes/native-sessions"
|
||||
@@ -131,6 +132,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerQwenRoutes(app, { logger: deps.logger })
|
||||
registerZAIRoutes(app, { logger: deps.logger })
|
||||
registerOpenCodeZenRoutes(app, { logger: deps.logger })
|
||||
registerAntigravityRoutes(app, { logger: deps.logger })
|
||||
registerSkillsRoutes(app)
|
||||
registerContextEngineRoutes(app)
|
||||
|
||||
|
||||
336
packages/server/src/server/routes/antigravity.ts
Normal file
336
packages/server/src/server/routes/antigravity.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { AntigravityClient, type ChatRequest, getDefaultAntigravityConfig, type ChatMessage } from "../../integrations/antigravity"
|
||||
import { Logger } from "../../logger"
|
||||
import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor"
|
||||
import { getMcpManager } from "../../mcp/client"
|
||||
|
||||
interface AntigravityRouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
// Maximum number of tool execution loops
|
||||
const MAX_TOOL_LOOPS = 10
|
||||
|
||||
export async function registerAntigravityRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: AntigravityRouteDeps
|
||||
) {
|
||||
const logger = deps.logger.child({ component: "antigravity-routes" })
|
||||
|
||||
// Create shared client
|
||||
const client = new AntigravityClient(getDefaultAntigravityConfig())
|
||||
|
||||
// List available Antigravity models
|
||||
app.get('/api/antigravity/models', async (request, reply) => {
|
||||
try {
|
||||
const models = await client.getModels()
|
||||
|
||||
return {
|
||||
models: models.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
family: m.family,
|
||||
provider: "antigravity",
|
||||
free: false, // These require Google OAuth
|
||||
reasoning: m.reasoning,
|
||||
tool_call: m.tool_call,
|
||||
limit: m.limit
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to list Antigravity models")
|
||||
return reply.status(500).send({ error: "Failed to list models" })
|
||||
}
|
||||
})
|
||||
|
||||
// Check authentication status
|
||||
app.get('/api/antigravity/auth-status', async (request, reply) => {
|
||||
try {
|
||||
const authenticated = client.isAuthenticated()
|
||||
return { authenticated }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Antigravity auth status check failed")
|
||||
return reply.status(500).send({ error: "Auth status check failed" })
|
||||
}
|
||||
})
|
||||
|
||||
// Test connection
|
||||
app.get('/api/antigravity/test', async (request, reply) => {
|
||||
try {
|
||||
const connected = await client.testConnection()
|
||||
return { connected }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Antigravity connection test failed")
|
||||
return reply.status(500).send({ error: "Connection test failed" })
|
||||
}
|
||||
})
|
||||
|
||||
// Chat completion endpoint WITH MCP TOOL SUPPORT
|
||||
app.post('/api/antigravity/chat', async (request, reply) => {
|
||||
try {
|
||||
const chatRequest = request.body as ChatRequest & {
|
||||
workspacePath?: string
|
||||
enableTools?: boolean
|
||||
}
|
||||
|
||||
// Extract workspace path for tool execution
|
||||
const workspacePath = chatRequest.workspacePath || process.cwd()
|
||||
const enableTools = chatRequest.enableTools !== false
|
||||
|
||||
logger.info({
|
||||
workspacePath,
|
||||
receivedWorkspacePath: chatRequest.workspacePath,
|
||||
enableTools,
|
||||
model: chatRequest.model
|
||||
}, "Antigravity chat request received")
|
||||
|
||||
// Handle streaming with tool loop
|
||||
if (chatRequest.stream) {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
await streamWithToolLoop(
|
||||
client,
|
||||
chatRequest,
|
||||
workspacePath,
|
||||
enableTools,
|
||||
reply.raw,
|
||||
logger
|
||||
)
|
||||
reply.raw.end()
|
||||
} catch (streamError) {
|
||||
logger.error({ error: streamError }, "Antigravity streaming failed")
|
||||
reply.raw.write(`data: ${JSON.stringify({ error: String(streamError) })}\n\n`)
|
||||
reply.raw.end()
|
||||
}
|
||||
} else {
|
||||
// Non-streaming with tool loop
|
||||
const response = await chatWithToolLoop(
|
||||
client,
|
||||
chatRequest,
|
||||
workspacePath,
|
||||
enableTools,
|
||||
logger
|
||||
)
|
||||
return response
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Antigravity chat request failed")
|
||||
return reply.status(500).send({ error: "Chat request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("Antigravity routes registered with MCP tool support - Google OAuth required!")
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat with tool execution loop
|
||||
*/
|
||||
async function streamWithToolLoop(
|
||||
client: AntigravityClient,
|
||||
request: ChatRequest,
|
||||
workspacePath: string,
|
||||
enableTools: boolean,
|
||||
rawResponse: any,
|
||||
logger: Logger
|
||||
): Promise<void> {
|
||||
let messages = [...request.messages]
|
||||
let loopCount = 0
|
||||
|
||||
// Load MCP tools from workspace config
|
||||
let allTools = [...CORE_TOOLS]
|
||||
if (enableTools && workspacePath) {
|
||||
try {
|
||||
const mcpManager = getMcpManager()
|
||||
await mcpManager.loadConfig(workspacePath)
|
||||
const mcpTools = await mcpManager.getToolsAsOpenAIFormat()
|
||||
allTools = [...CORE_TOOLS, ...mcpTools]
|
||||
if (mcpTools.length > 0) {
|
||||
logger.info({ mcpToolCount: mcpTools.length }, "Loaded MCP tools for Antigravity")
|
||||
}
|
||||
} catch (mcpError) {
|
||||
logger.warn({ error: mcpError }, "Failed to load MCP tools")
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tools if enabled
|
||||
const requestWithTools: ChatRequest = {
|
||||
...request,
|
||||
tools: enableTools ? allTools : undefined,
|
||||
tool_choice: enableTools ? "auto" : undefined
|
||||
}
|
||||
|
||||
while (loopCount < MAX_TOOL_LOOPS) {
|
||||
loopCount++
|
||||
|
||||
// Accumulate tool calls from stream
|
||||
let accumulatedToolCalls: { [index: number]: { id: string; name: string; arguments: string } } = {}
|
||||
let hasToolCalls = false
|
||||
let textContent = ""
|
||||
|
||||
// Stream response
|
||||
for await (const chunk of client.chatStream({ ...requestWithTools, messages })) {
|
||||
// Write chunk to client
|
||||
rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
if (!choice) continue
|
||||
|
||||
// Accumulate text content
|
||||
if (choice.delta?.content) {
|
||||
textContent += choice.delta.content
|
||||
}
|
||||
|
||||
// Accumulate tool calls from delta (if API supports it)
|
||||
const deltaToolCalls = (choice.delta as any)?.tool_calls
|
||||
if (deltaToolCalls) {
|
||||
hasToolCalls = true
|
||||
for (const tc of deltaToolCalls) {
|
||||
const idx = tc.index ?? 0
|
||||
if (!accumulatedToolCalls[idx]) {
|
||||
accumulatedToolCalls[idx] = { id: tc.id || "", name: "", arguments: "" }
|
||||
}
|
||||
if (tc.id) accumulatedToolCalls[idx].id = tc.id
|
||||
if (tc.function?.name) accumulatedToolCalls[idx].name += tc.function.name
|
||||
if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should stop
|
||||
if (choice.finish_reason === "stop") {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool calls, we're done
|
||||
if (!hasToolCalls || !enableTools) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
|
||||
// Convert accumulated tool calls
|
||||
const toolCalls: ToolCall[] = Object.values(accumulatedToolCalls).map(tc => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments
|
||||
}
|
||||
}))
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info({ toolCalls: toolCalls.map(tc => tc.function.name) }, "Executing tool calls")
|
||||
|
||||
// Add assistant message with tool calls
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: textContent || undefined,
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
messages.push(assistantMessage)
|
||||
|
||||
// Execute tools
|
||||
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||
|
||||
// Notify client about tool execution via special event
|
||||
for (const result of toolResults) {
|
||||
const toolEvent = {
|
||||
type: "tool_result",
|
||||
tool_call_id: result.tool_call_id,
|
||||
content: result.content
|
||||
}
|
||||
rawResponse.write(`data: ${JSON.stringify(toolEvent)}\n\n`)
|
||||
}
|
||||
|
||||
// Add tool results to messages
|
||||
for (const result of toolResults) {
|
||||
const toolMessage: ChatMessage = {
|
||||
role: "tool",
|
||||
content: result.content,
|
||||
tool_call_id: result.tool_call_id
|
||||
}
|
||||
messages.push(toolMessage)
|
||||
}
|
||||
|
||||
logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete")
|
||||
}
|
||||
|
||||
logger.warn({ loopCount }, "Max tool loops reached")
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming chat with tool execution loop
|
||||
*/
|
||||
async function chatWithToolLoop(
|
||||
client: AntigravityClient,
|
||||
request: ChatRequest,
|
||||
workspacePath: string,
|
||||
enableTools: boolean,
|
||||
logger: Logger
|
||||
): Promise<any> {
|
||||
let messages = [...request.messages]
|
||||
let loopCount = 0
|
||||
let lastResponse: any = null
|
||||
|
||||
// Inject tools if enabled
|
||||
const requestWithTools: ChatRequest = {
|
||||
...request,
|
||||
tools: enableTools ? CORE_TOOLS : undefined,
|
||||
tool_choice: enableTools ? "auto" : undefined
|
||||
}
|
||||
|
||||
while (loopCount < MAX_TOOL_LOOPS) {
|
||||
loopCount++
|
||||
|
||||
const response = await client.chat({ ...requestWithTools, messages, stream: false })
|
||||
lastResponse = response
|
||||
|
||||
const choice = response.choices[0]
|
||||
if (!choice) break
|
||||
|
||||
const toolCalls = (choice.message as any)?.tool_calls
|
||||
|
||||
// If no tool calls, return
|
||||
if (!toolCalls || toolCalls.length === 0 || !enableTools) {
|
||||
return response
|
||||
}
|
||||
|
||||
logger.info({ toolCalls: toolCalls.map((tc: any) => tc.function.name) }, "Executing tool calls")
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: (choice.message as any).content || undefined,
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
messages.push(assistantMessage)
|
||||
|
||||
// Execute tools
|
||||
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||
|
||||
// Add tool results
|
||||
for (const result of toolResults) {
|
||||
const toolMessage: ChatMessage = {
|
||||
role: "tool",
|
||||
content: result.content,
|
||||
tool_call_id: result.tool_call_id
|
||||
}
|
||||
messages.push(toolMessage)
|
||||
}
|
||||
|
||||
logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete")
|
||||
}
|
||||
|
||||
logger.warn({ loopCount }, "Max tool loops reached")
|
||||
return lastResponse
|
||||
}
|
||||
@@ -68,6 +68,8 @@ export class WorkspaceManager {
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
// Special constant for Native mode (no OpenCode binary)
|
||||
const NATIVE_MODE_PATH = "__nomadarch_native__"
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
@@ -75,7 +77,15 @@ export class WorkspaceManager {
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
// Check if we're in native mode
|
||||
const isNativeMode = resolvedBinaryPath === NATIVE_MODE_PATH || binary.path === NATIVE_MODE_PATH
|
||||
|
||||
this.options.logger.info({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binary: resolvedBinaryPath,
|
||||
isNativeMode
|
||||
}, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
|
||||
@@ -87,13 +97,14 @@ export class WorkspaceManager {
|
||||
status: "starting",
|
||||
proxyPath,
|
||||
binaryId: resolvedBinaryPath,
|
||||
binaryLabel: binary.label,
|
||||
binaryVersion: binary.version,
|
||||
binaryLabel: isNativeMode ? "NomadArch Native" : binary.label,
|
||||
binaryVersion: isNativeMode ? "Native" : binary.version,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
// Native mode doesn't need binary version detection
|
||||
if (!isNativeMode && !descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
@@ -102,6 +113,31 @@ export class WorkspaceManager {
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
// In native mode, we don't launch a binary - the workspace is immediately ready
|
||||
// and uses native session management APIs instead
|
||||
if (isNativeMode) {
|
||||
this.options.logger.info({ workspaceId: id }, "Starting native mode workspace (no binary)")
|
||||
|
||||
// Native mode is immediately ready - no process to launch
|
||||
descriptor.status = "ready"
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
// No pid or port for native mode - it uses the server's own APIs
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
||||
this.options.logger.info({ workspaceId: id }, "Native mode workspace ready")
|
||||
|
||||
// Trigger Context-Engine indexing (non-blocking)
|
||||
const contextEngine = getContextEngineService()
|
||||
if (contextEngine) {
|
||||
contextEngine.indexPath(workspacePath).catch((error) => {
|
||||
this.options.logger.warn({ workspaceId: id, error }, "Context-Engine indexing failed")
|
||||
})
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// SDK/binary mode - launch the OpenCode process
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id)
|
||||
|
||||
@@ -33,6 +33,12 @@ export class WorkspaceRuntime {
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
// Native mode should not use the runtime - it uses native session management instead
|
||||
const NATIVE_MODE_PATH = "__nomadarch_native__"
|
||||
if (options.binaryPath === NATIVE_MODE_PATH) {
|
||||
throw new Error(`Native mode does not use binary launches. Use native session management APIs instead.`)
|
||||
}
|
||||
|
||||
// Check if binary exists before attempting to launch
|
||||
try {
|
||||
accessSync(options.binaryPath, constants.F_OK)
|
||||
|
||||
Reference in New Issue
Block a user