import { randomUUID } from "node:crypto"; import { CloudAgentBase, type AgentCredentials, type CreateTaskParams, type GetStatusResult, } from "../baseAgent.ts"; import { buildJulesApiUrl, JULES_API_BASE_URL } from "../julesApi.ts"; import type { CloudAgentTask, CloudAgentActivity, CloudAgentStatus, CloudAgentResult, } from "../types.ts"; import { CLOUD_AGENT_STATUS } from "../types.ts"; function julesHeaders(apiKey: string, json = false): Record { const headers: Record = { "X-Goog-Api-Key": apiKey, }; if (json) { headers["Content-Type"] = "application/json"; } return headers; } function parseGithubOwnerRepo(repoUrl: string, repoName: string): { owner: string; repo: string } { const normalized = repoUrl.includes("://") ? repoUrl : `https://${repoUrl}`; try { const url = new URL(normalized); const parts = url.pathname.split("/").filter(Boolean); if (parts.length >= 2) { return { owner: parts[0], repo: parts[1].replace(/\.git$/i, ""), }; } } catch { // fall through to string split for non-URL inputs } const parts = repoUrl.split("/").filter(Boolean); const owner = parts.length >= 2 ? parts[parts.length - 2] : ""; const repo = parts.length >= 2 ? parts[parts.length - 1].replace(/\.git$/i, "") : repoName.trim(); return { owner, repo: repo || repoName.trim() }; } function buildJulesSourceResourceName(owner: string, repo: string): string { return `sources/github/${owner}/${repo}`; } function normalizeJulesSessionId(externalId: string): string { const trimmed = externalId.trim(); return trimmed.startsWith("sessions/") ? trimmed.slice("sessions/".length) : trimmed; } function mapJulesActivity(act: Record): CloudAgentActivity { const progress = act.progressUpdated as Record | undefined; const planGenerated = act.planGenerated as Record | undefined; let type: CloudAgentActivity["type"] = "command"; let content = ""; if (act.planGenerated) { type = "plan"; const plan = planGenerated?.plan as Record | undefined; const steps = Array.isArray(plan?.steps) ? plan.steps : []; content = steps .map((step) => { const row = step as Record; return typeof row.title === "string" ? row.title : ""; }) .filter(Boolean) .join("\n"); } else if (act.sessionCompleted) { type = "completion"; content = "Session completed"; } else if (act.planApproved) { type = "message"; content = "Plan approved"; } else if (progress) { content = [progress.title, progress.description].filter(Boolean).join(": "); } return { id: (act.id as string) || randomUUID(), type, content, timestamp: (act.createTime as string) || new Date().toISOString(), }; } function extractJulesResult(outputs: unknown): CloudAgentResult | undefined { if (!Array.isArray(outputs)) return undefined; for (const item of outputs) { const output = item as Record; const pullRequest = output.pullRequest as Record | undefined; if (pullRequest?.url) { return { prUrl: String(pullRequest.url), commitMessage: typeof pullRequest.description === "string" ? pullRequest.description : undefined, summary: typeof pullRequest.title === "string" ? pullRequest.title : undefined, }; } } return undefined; } function readJulesErrorMessage(data: Record): string { if (typeof data.error === "string" && data.error.trim()) { return data.error.trim(); } if (data.error && typeof data.error === "object") { const record = data.error as Record; const message = record.message; if (typeof message === "string" && message.trim()) { return message.trim(); } } return ""; } function inferJulesStatus( data: Record, activities: Record[] ): CloudAgentStatus { if (extractJulesResult(data.outputs)) { return CLOUD_AGENT_STATUS.COMPLETED; } if (activities.some((act) => act.sessionCompleted)) { return CLOUD_AGENT_STATUS.COMPLETED; } if (activities.some((act) => act.planGenerated) && !activities.some((act) => act.planApproved)) { return CLOUD_AGENT_STATUS.AWAITING_APPROVAL; } if (readJulesErrorMessage(data)) { return CLOUD_AGENT_STATUS.FAILED; } const state = typeof data.state === "string" ? data.state.toLowerCase() : ""; if (state.includes("failed") || state.includes("error")) { return CLOUD_AGENT_STATUS.FAILED; } if (state.includes("cancelled") || state.includes("canceled")) { return CLOUD_AGENT_STATUS.CANCELLED; } if (state.includes("completed") || state.includes("done")) { return CLOUD_AGENT_STATUS.COMPLETED; } if (state.includes("pending") || state.includes("queued")) { return CLOUD_AGENT_STATUS.QUEUED; } if (state.includes("running") || state.includes("active")) { return CLOUD_AGENT_STATUS.RUNNING; } if (activities.some((act) => act.progressUpdated)) { return CLOUD_AGENT_STATUS.RUNNING; } return CLOUD_AGENT_STATUS.QUEUED; } function readJulesSourceBranch(source: Record): string | undefined { const githubRepo = source.githubRepo as Record | undefined; const githubRepoContext = source.githubRepoContext as Record | undefined; const candidates = [ githubRepoContext?.startingBranch, githubRepoContext?.defaultBranch, githubRepo?.defaultBranch, source.defaultBranch, ]; for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim()) { return candidate.trim(); } } return undefined; } export class JulesAgent extends CloudAgentBase { readonly providerId = "jules"; readonly baseUrl = JULES_API_BASE_URL; async createTask( params: CreateTaskParams, credentials: AgentCredentials ): Promise { const taskId = this.generateTaskId(); const { owner, repo } = parseGithubOwnerRepo(params.source.repoUrl, params.source.repoName); const sourceResource = buildJulesSourceResourceName(owner, repo); const body: Record = { prompt: params.prompt, title: params.source.repoName || repo, sourceContext: { source: sourceResource, githubRepoContext: { startingBranch: params.source.branch || "main", }, }, }; if (params.options.autoCreatePr) { body.automationMode = "AUTO_CREATE_PR"; } if (params.options.planApprovalRequired) { body.requirePlanApproval = true; } const response = await fetch(buildJulesApiUrl("/sessions"), { method: "POST", headers: julesHeaders(credentials.apiKey, true), body: JSON.stringify(body), }); if (!response.ok) { const error = await response.text(); throw new Error(`Jules create task failed: ${response.status} ${error}`); } const data = (await response.json()) as Record; const sessionId = (typeof data.id === "string" && data.id) || (typeof data.name === "string" ? normalizeJulesSessionId(data.name) : "") || taskId; return { id: taskId, providerId: this.providerId, externalId: sessionId, status: CLOUD_AGENT_STATUS.QUEUED, prompt: params.prompt, source: params.source, options: params.options, activities: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } async getStatus(externalId: string, credentials: AgentCredentials): Promise { const sessionId = normalizeJulesSessionId(externalId); const [sessionRes, activitiesRes] = await Promise.all([ fetch(buildJulesApiUrl(`/sessions/${sessionId}`), { headers: julesHeaders(credentials.apiKey), }), fetch(buildJulesApiUrl(`/sessions/${sessionId}/activities?pageSize=30`), { headers: julesHeaders(credentials.apiKey), }), ]); if (!sessionRes.ok) { const error = await sessionRes.text(); throw new Error(`Jules get status failed: ${sessionRes.status} ${error}`); } const data = (await sessionRes.json()) as Record; let rawActivities: Record[] = []; if (activitiesRes.ok) { const activitiesPayload = (await activitiesRes.json()) as Record; rawActivities = Array.isArray(activitiesPayload.activities) ? (activitiesPayload.activities as Record[]) : []; } const activities = rawActivities.map(mapJulesActivity); const status = inferJulesStatus(data, rawActivities); const result = extractJulesResult(data.outputs); const errorMessage = readJulesErrorMessage(data); return { status, externalId: sessionId, result, activities, error: errorMessage || undefined, }; } async approvePlan(externalId: string, credentials: AgentCredentials): Promise { const sessionId = normalizeJulesSessionId(externalId); const response = await fetch(buildJulesApiUrl(`/sessions/${sessionId}:approvePlan`), { method: "POST", headers: julesHeaders(credentials.apiKey, true), body: "{}", }); if (!response.ok) { const error = await response.text(); throw new Error(`Jules approve plan failed: ${response.status} ${error}`); } } async sendMessage( externalId: string, message: string, credentials: AgentCredentials ): Promise { const sessionId = normalizeJulesSessionId(externalId); const response = await fetch(buildJulesApiUrl(`/sessions/${sessionId}:sendMessage`), { method: "POST", headers: julesHeaders(credentials.apiKey, true), body: JSON.stringify({ prompt: message }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Jules send message failed: ${response.status} ${error}`); } return { id: this.generateActivityId(), type: "message", content: message, timestamp: new Date().toISOString(), }; } async listSources( credentials: AgentCredentials ): Promise<{ name: string; url: string; branch?: string }[]> { const response = await fetch(buildJulesApiUrl("/sources"), { headers: julesHeaders(credentials.apiKey), }); if (!response.ok) { const error = await response.text(); throw new Error(`Jules list sources failed: ${response.status} ${error}`); } const data = (await response.json()) as Record; return (Array.isArray(data.sources) ? data.sources : []).map( (source: Record) => { const githubRepo = source.githubRepo as Record | undefined; const owner = typeof githubRepo?.owner === "string" ? githubRepo.owner : ""; const repo = typeof githubRepo?.repo === "string" ? githubRepo.repo : ""; const branch = readJulesSourceBranch(source); return { name: typeof source.name === "string" ? source.name : `${owner}/${repo}`, url: owner && repo ? `https://github.com/${owner}/${repo}` : "", ...(branch ? { branch } : {}), }; } ); } }