+
+ expect(mockRunCloudReview).toHaveBeenCalledTimes(1)
+ expect(mockRunCloud).not.toHaveBeenCalled()
+ const params = mockRunCloudReview.mock.calls[0][0]
+ expect(params.mode).toBe('cloud_review')
+ expect(params.pullNumber).toBe(7)
+ expect(params.reviewEvent).toBe('REQUEST_CHANGES')
+ expect(params).not.toHaveProperty('skills')
+ expect(params).not.toHaveProperty('initialMessages')
+ expect(mockResolveSkills).not.toHaveBeenCalled()
+ expect(mockLoadMemory).not.toHaveBeenCalled()
+ expect(mockAppendMemory).not.toHaveBeenCalled()
+ expect(output.reviewUrl).toBe('https://github.com/o/r/pull/7#pullrequestreview-1')
+ expect(output.commentsPosted).toBe(2)
+ expect(output.content).toBe('looks good')
+ })
+
+ it('requires SSH fields in Local Dev', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' })
- ).rejects.toThrow(/Local mode requires/)
+ ).rejects.toThrow(/Local Dev requires/)
})
- it('requires repo + token in cloud mode', async () => {
+ it('requires repo + token in Create PR', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'cloud', task: 'x', model: 'claude', owner: 'o' })
- ).rejects.toThrow(/Cloud mode requires/)
+ ).rejects.toThrow(/Create PR requires/)
+ })
+
+ it('requires pullNumber in cloud_review mode', async () => {
+ await expect(
+ handler.execute(ctx(), block, {
+ mode: 'cloud_review',
+ task: 'x',
+ model: 'claude',
+ owner: 'o',
+ repo: 'r',
+ githubToken: 'ghp',
+ })
+ ).rejects.toThrow(/Review Code requires/)
+ })
+
+ it.each(['0', '-1', '1.5'])('rejects invalid pull request number %s', async (pullNumber) => {
+ await expect(
+ handler.execute(ctx(), block, {
+ mode: 'cloud_review',
+ task: 'x',
+ model: 'claude',
+ owner: 'o',
+ repo: 'r',
+ githubToken: 'ghp',
+ pullNumber,
+ })
+ ).rejects.toThrow(/pullNumber/)
+ })
+
+ it('rejects autonomous approval reviews', async () => {
+ await expect(
+ handler.execute(ctx(), block, {
+ mode: 'cloud_review',
+ task: 'x',
+ model: 'claude',
+ owner: 'o',
+ repo: 'r',
+ githubToken: 'ghp',
+ pullNumber: '7',
+ reviewEvent: 'APPROVE',
+ })
+ ).rejects.toThrow(/COMMENT or REQUEST_CHANGES/)
+ expect(mockRunCloudReview).not.toHaveBeenCalled()
})
it('streams text when the block is selected for streaming output', async () => {
diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts
index 986ab4a211c..a46637c70bd 100644
--- a/apps/sim/executor/handlers/pi/pi-handler.ts
+++ b/apps/sim/executor/handlers/pi/pi-handler.ts
@@ -12,12 +12,14 @@ import { parseOptionalNumberInput } from '@/blocks/utils'
import { BlockType } from '@/executor/constants'
import type {
PiBackendRun,
+ PiCloudReviewRunParams,
PiCloudRunParams,
PiLocalRunParams,
PiRunParams,
PiRunResult,
} from '@/executor/handlers/pi/backend'
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
+import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend'
import {
appendPiMemory,
loadPiMemory,
@@ -34,10 +36,13 @@ import type {
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
+import { isPiSupportedProvider, resolvePiModelId } from '@/providers/pi-providers'
+import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('PiBlockHandler')
-const DEFAULT_MODEL = 'claude-sonnet-5'
+const DEFAULT_MODEL = 'claude-sonnet-4-6'
+const REVIEW_EVENTS = ['COMMENT', 'REQUEST_CHANGES'] as const
function asOptString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
@@ -49,6 +54,15 @@ function asRawString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
}
+function isReviewEvent(value: string): value is PiCloudReviewRunParams['reviewEvent'] {
+ return REVIEW_EVENTS.some((event) => event === value)
+}
+
+function parsePiMode(value: unknown): PiRunParams['mode'] {
+ if (value === 'cloud' || value === 'cloud_review' || value === 'local') return value
+ throw new Error(`Invalid Pi mode: ${String(value)}`)
+}
+
export class PiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.PI
@@ -62,42 +76,77 @@ export class PiBlockHandler implements BlockHandler {
const task = asOptString(inputs.task)
if (!task) throw new Error('Task is required')
const model = asOptString(inputs.model) ?? DEFAULT_MODEL
+ const mode = parsePiMode(inputs.mode)
- // Validate the mode up front so an invalid value reports a mode error rather
- // than a misattributed credential error from key resolution below.
- if (inputs.mode !== 'cloud' && inputs.mode !== 'local') {
- throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
+ const providerId = getProviderFromModel(model)
+ if (!isPiSupportedProvider(providerId)) {
+ throw new Error(`Pi provider "${providerId}" is not supported`)
+ }
+ const piModel = resolvePiModelId(providerId, model)
+ if (!piModel) {
+ throw new Error(
+ `Pi model "${model}" is not available for provider "${providerId}" in the installed Pi catalog`
+ )
}
- const mode: 'cloud' | 'local' = inputs.mode
- const { providerId, apiKey, isBYOK } = await resolvePiModelKey({
+ const { apiKey, isBYOK } = await resolvePiModelKey({
+ providerId,
model,
mode,
workspaceId: ctx.workspaceId,
- userId: ctx.userId,
apiKey: asRawString(inputs.apiKey),
- vertexCredential: asOptString(inputs.vertexCredential),
})
- const skills = await resolvePiSkills(inputs.skills, ctx.workspaceId)
- const memoryConfig: PiMemoryConfig = {
- memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'],
- conversationId: asOptString(inputs.conversationId),
- slidingWindowSize: asOptString(inputs.slidingWindowSize),
- slidingWindowTokens: asOptString(inputs.slidingWindowTokens),
- model,
- }
- const initialMessages = await loadPiMemory(ctx, memoryConfig)
-
const base = {
model,
+ piModel,
providerId,
apiKey,
isBYOK,
task,
thinkingLevel: asOptString(inputs.thinkingLevel),
- skills,
- initialMessages,
+ }
+
+ if (mode === 'cloud_review') {
+ const owner = asOptString(inputs.owner)
+ const repo = asOptString(inputs.repo)
+ const githubToken = asRawString(inputs.githubToken)
+ const pullNumber = parseOptionalNumberInput(inputs.pullNumber, 'pullNumber', {
+ integer: true,
+ min: 1,
+ })
+ if (!owner || !repo || !githubToken || pullNumber === undefined) {
+ throw new Error(
+ 'Review Code requires repository owner, name, a GitHub token, and a pull request number'
+ )
+ }
+ const reviewEventRaw = asOptString(inputs.reviewEvent) ?? 'COMMENT'
+ if (!isReviewEvent(reviewEventRaw)) {
+ throw new Error(`Invalid review event: ${reviewEventRaw}. Use COMMENT or REQUEST_CHANGES.`)
+ }
+ const params: PiCloudReviewRunParams = {
+ ...base,
+ mode: 'cloud_review',
+ owner,
+ repo,
+ githubToken,
+ pullNumber,
+ reviewEvent: reviewEventRaw,
+ }
+ return this.runPi(ctx, block, runCloudReviewPi, params)
+ }
+
+ const memoryConfig: PiMemoryConfig = {
+ memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'],
+ conversationId: asOptString(inputs.conversationId),
+ slidingWindowSize: asOptString(inputs.slidingWindowSize),
+ slidingWindowTokens: asOptString(inputs.slidingWindowTokens),
+ model,
+ }
+ const contextualBase = {
+ ...base,
+ skills: await resolvePiSkills(inputs.skills, ctx.workspaceId),
+ initialMessages: await loadPiMemory(ctx, memoryConfig),
}
if (mode === 'local') {
@@ -105,13 +154,13 @@ export class PiBlockHandler implements BlockHandler {
const username = asOptString(inputs.username)
const repoPath = asOptString(inputs.repoPath)
if (!host || !username || !repoPath) {
- throw new Error('Local mode requires host, username, and repository path')
+ throw new Error('Local Dev requires host, username, and repository path')
}
const usePrivateKey = inputs.authMethod === 'privateKey'
const port = parseOptionalNumberInput(inputs.port, 'port', { integer: true, min: 1 }) ?? 22
const tools = await buildSimToolSpecs(ctx, inputs.tools)
const params: PiLocalRunParams = {
- ...base,
+ ...contextualBase,
mode: 'local',
repoPath,
tools,
@@ -127,29 +176,25 @@ export class PiBlockHandler implements BlockHandler {
return this.runPi(ctx, block, runLocalPi, params, memoryConfig)
}
- if (mode === 'cloud') {
- const owner = asOptString(inputs.owner)
- const repo = asOptString(inputs.repo)
- const githubToken = asRawString(inputs.githubToken)
- if (!owner || !repo || !githubToken) {
- throw new Error('Cloud mode requires repository owner, name, and a GitHub token')
- }
- const params: PiCloudRunParams = {
- ...base,
- mode: 'cloud',
- owner,
- repo,
- githubToken,
- baseBranch: asOptString(inputs.baseBranch),
- branchName: asOptString(inputs.branchName),
- draft: inputs.draft !== false,
- prTitle: asOptString(inputs.prTitle),
- prBody: asOptString(inputs.prBody),
- }
- return this.runPi(ctx, block, runCloudPi, params, memoryConfig)
+ const owner = asOptString(inputs.owner)
+ const repo = asOptString(inputs.repo)
+ const githubToken = asRawString(inputs.githubToken)
+ if (!owner || !repo || !githubToken) {
+ throw new Error('Create PR requires repository owner, name, and a GitHub token')
}
-
- throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
+ const params: PiCloudRunParams = {
+ ...contextualBase,
+ mode: 'cloud',
+ owner,
+ repo,
+ githubToken,
+ baseBranch: asOptString(inputs.baseBranch),
+ branchName: asOptString(inputs.branchName),
+ draft: inputs.draft !== false,
+ prTitle: asOptString(inputs.prTitle),
+ prBody: asOptString(inputs.prBody),
+ }
+ return this.runPi(ctx, block, runCloudPi, params, memoryConfig)
}
private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
@@ -178,6 +223,10 @@ export class PiBlockHandler implements BlockHandler {
diff: result.diff ?? '',
...(result.prUrl ? { prUrl: result.prUrl } : {}),
...(result.branch ? { branch: result.branch } : {}),
+ ...(result.reviewUrl ? { reviewUrl: result.reviewUrl } : {}),
+ ...(typeof result.commentsPosted === 'number'
+ ? { commentsPosted: result.commentsPosted }
+ : {}),
tokens: {
input: totals.inputTokens,
output: totals.outputTokens,
@@ -197,7 +246,7 @@ export class PiBlockHandler implements BlockHandler {
block: SerializedBlock,
backend: PiBackendRun,
params: P,
- memoryConfig: PiMemoryConfig
+ memoryConfig?: PiMemoryConfig
): Promise {
const startTime = Date.now()
const startTimeISO = new Date(startTime).toISOString()
@@ -231,7 +280,9 @@ export class PiBlockHandler implements BlockHandler {
output,
this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
)
- await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
+ if (memoryConfig) {
+ await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
+ }
controller.close()
} catch (error) {
controller.error(error)
@@ -256,7 +307,9 @@ export class PiBlockHandler implements BlockHandler {
if (result.totals.errorMessage) {
throw new Error(result.totals.errorMessage)
}
- await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
+ if (memoryConfig) {
+ await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
+ }
return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
}
}
diff --git a/apps/sim/executor/handlers/pi/pi-sdk.ts b/apps/sim/executor/handlers/pi/pi-sdk.ts
new file mode 100644
index 00000000000..7f6c0146f50
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/pi-sdk.ts
@@ -0,0 +1,56 @@
+import { InMemoryCredentialStore } from '@earendil-works/pi-ai'
+import type { ModelRuntime, ResourceLoader } from '@earendil-works/pi-coding-agent'
+
+/** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */
+export type PiSdk = typeof import('@earendil-works/pi-coding-agent')
+
+let sdkPromise: Promise | undefined
+
+/** Loads the Pi SDK while preserving Next.js standalone dependency tracing. */
+export function loadPiSdk(): Promise {
+ if (!sdkPromise) {
+ sdkPromise = import('@earendil-works/pi-coding-agent').catch((error) => {
+ sdkPromise = undefined
+ throw error
+ })
+ }
+ return sdkPromise
+}
+
+/** Creates a host-only Pi model runtime without reading credentials or models from disk. */
+export function createPiModelRuntime(sdk: PiSdk): Promise {
+ return sdk.ModelRuntime.create({
+ credentials: new InMemoryCredentialStore(),
+ modelsPath: null,
+ allowModelNetwork: false,
+ })
+}
+
+/** Resolves only model definitions that the installed Pi SDK declares exactly. */
+export function resolvePiSdkModel(modelRuntime: ModelRuntime, provider: string, modelId: string) {
+ return modelRuntime.getModel(provider, modelId)
+}
+
+/**
+ * Creates an isolated resource-discovery boundary for untrusted repositories. No project
+ * files, extensions, skills, prompt templates, themes, or settings are loaded.
+ */
+export function createSealedPiResourceLoader(sdk: PiSdk, systemPrompt: string): ResourceLoader {
+ const extensions = {
+ extensions: [],
+ errors: [],
+ runtime: sdk.createExtensionRuntime(),
+ }
+
+ return {
+ getExtensions: () => extensions,
+ getSkills: () => ({ skills: [], diagnostics: [] }),
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
+ getThemes: () => ({ themes: [], diagnostics: [] }),
+ getAgentsFiles: () => ({ agentsFiles: [] }),
+ getSystemPrompt: () => systemPrompt,
+ getAppendSystemPrompt: () => [],
+ extendResources: () => {},
+ reload: async () => {},
+ }
+}
diff --git a/apps/sim/executor/handlers/pi/redaction.test.ts b/apps/sim/executor/handlers/pi/redaction.test.ts
new file mode 100644
index 00000000000..529deaf9b2e
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/redaction.test.ts
@@ -0,0 +1,46 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ createScrubbedPiError,
+ getScrubbedPiErrorMessage,
+ scrubPiEvent,
+ scrubPiSecrets,
+} from '@/executor/handlers/pi/redaction'
+
+describe('Pi secret redaction', () => {
+ it('redacts literal and URL-encoded secret representations', () => {
+ expect(
+ scrubPiSecrets('literal sk-hosted/secret encoded sk-hosted%2Fsecret', ['sk-hosted/secret'])
+ ).toBe('literal *** encoded ***')
+ })
+
+ it('redacts longer overlapping secrets before their prefixes', () => {
+ expect(scrubPiSecrets('ghp_secret and ghp_', ['ghp_', 'ghp_secret'])).toBe('*** and ***')
+ })
+
+ it('redacts all string-bearing Pi event variants', () => {
+ expect(scrubPiEvent({ type: 'thinking', text: 'saw sk-hosted' }, ['sk-hosted'])).toEqual({
+ type: 'thinking',
+ text: 'saw ***',
+ })
+ expect(
+ scrubPiEvent({ type: 'tool_end', toolName: 'sk-hosted', isError: true }, ['sk-hosted'])
+ ).toEqual({ type: 'tool_end', toolName: '***', isError: true })
+ expect(scrubPiEvent({ type: 'error', message: 'failed sk-hosted' }, ['sk-hosted'])).toEqual({
+ type: 'error',
+ message: 'failed ***',
+ })
+ })
+
+ it('creates sanitized errors without retaining the raw cause', () => {
+ const raw = new Error('provider exposed sk-hosted')
+ const scrubbed = createScrubbedPiError(raw, ['sk-hosted'])
+
+ expect(getScrubbedPiErrorMessage(raw, ['sk-hosted'])).toBe('provider exposed ***')
+ expect(scrubbed.message).toBe('provider exposed ***')
+ expect(scrubbed.cause).toBeUndefined()
+ expect(String(scrubbed.stack)).not.toContain('sk-hosted')
+ })
+})
diff --git a/apps/sim/executor/handlers/pi/redaction.ts b/apps/sim/executor/handlers/pi/redaction.ts
new file mode 100644
index 00000000000..904d9ae9f8d
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/redaction.ts
@@ -0,0 +1,51 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import type { PiEvent } from '@/executor/handlers/pi/events'
+
+/** Redacts exact secret values and their URL-encoded forms from surfaced text. */
+export function scrubPiSecrets(text: string, secrets: readonly string[]): string {
+ let scrubbed = text
+ const representations = new Set(
+ secrets.flatMap((secret) => (secret ? [secret, encodeURIComponent(secret)] : []))
+ )
+ for (const representation of [...representations].sort(
+ (left, right) => right.length - left.length
+ )) {
+ scrubbed = scrubbed.split(representation).join('***')
+ }
+ return scrubbed
+}
+
+/** Redacts secrets from every string-bearing normalized Pi event. */
+export function scrubPiEvent(event: PiEvent | null, secrets: readonly string[]): PiEvent | null {
+ if (!event) return event
+ switch (event.type) {
+ case 'text':
+ case 'thinking':
+ return { ...event, text: scrubPiSecrets(event.text, secrets) }
+ case 'tool_start':
+ case 'tool_end':
+ return { ...event, toolName: scrubPiSecrets(event.toolName, secrets) }
+ case 'error':
+ return { ...event, message: scrubPiSecrets(event.message, secrets) }
+ default:
+ return event
+ }
+}
+
+/** Extracts an unknown error message without allowing exact secrets to escape. */
+export function getScrubbedPiErrorMessage(
+ error: unknown,
+ secrets: readonly string[],
+ fallback = 'Pi run failed'
+): string {
+ return scrubPiSecrets(getErrorMessage(error, fallback), secrets)
+}
+
+/** Creates a boundary-safe error without retaining a potentially secret-bearing cause. */
+export function createScrubbedPiError(
+ error: unknown,
+ secrets: readonly string[],
+ fallback?: string
+): Error {
+ return new Error(getScrubbedPiErrorMessage(error, secrets, fallback))
+}
diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/sim-tools.ts
index 0fb6a3b632e..8956b4a7dcb 100644
--- a/apps/sim/executor/handlers/pi/sim-tools.ts
+++ b/apps/sim/executor/handlers/pi/sim-tools.ts
@@ -1,6 +1,6 @@
/**
* Adapts user-selected Sim tools into backend-neutral {@link PiToolSpec}s that
- * Pi can call in local mode. Each spec carries the tool's JSON-schema parameters
+ * Pi can call in Local Dev. Each spec carries the tool's JSON-schema parameters
* and an `execute` that runs the real Sim tool through `executeTool`, so the
* agent's calls go through the same credential-access checks as any block.
*
diff --git a/apps/sim/executor/handlers/pi/ssh-tools.ts b/apps/sim/executor/handlers/pi/ssh-tools.ts
index c625ba7bcb3..0fa471e2190 100644
--- a/apps/sim/executor/handlers/pi/ssh-tools.ts
+++ b/apps/sim/executor/handlers/pi/ssh-tools.ts
@@ -98,7 +98,7 @@ async function guard(run: () => Promise): Promise {
/**
* Best-effort working-tree snapshot of the repo over the run's SSH session, for
- * the block's `changedFiles`/`diff` outputs — Local mode edits in place rather
+ * the block's `changedFiles`/`diff` outputs — Local Dev edits in place rather
* than opening a PR. `changedFiles` covers both tracked modifications and untracked
* (newly created) files so files the agent created are reported; `diff` reflects
* tracked changes against HEAD. Returns empty on any failure (not a git repo, git
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index c86eb9e7ff4..27aa7019f63 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -450,7 +450,7 @@ export const env = createEnv({
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Custom E2B template with pre-installed CLI tools for shell execution
MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path
- E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Pi Coding Agent cloud mode)
+ E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Create PR and Review Code)
// Access Control (Permission Groups) - for self-hosted deployments
ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements)
diff --git a/apps/sim/lib/uploads/core/storage-client.ts b/apps/sim/lib/uploads/core/storage-client.ts
index 72a0683ebf9..cd5d7c9f8a1 100644
--- a/apps/sim/lib/uploads/core/storage-client.ts
+++ b/apps/sim/lib/uploads/core/storage-client.ts
@@ -96,7 +96,10 @@ export async function getFileMetadata(
if (USE_GCS_STORAGE) {
const { getGcsObjectMetadata } = await import('@/lib/uploads/providers/gcs/client')
- return getGcsObjectMetadata(key, customConfig?.bucket ? { bucket: customConfig.bucket } : undefined)
+ return getGcsObjectMetadata(
+ key,
+ customConfig?.bucket ? { bucket: customConfig.bucket } : undefined
+ )
}
return {}
diff --git a/apps/sim/lib/uploads/providers/gcs/client.test.ts b/apps/sim/lib/uploads/providers/gcs/client.test.ts
index 58cebc3187a..dc0b01d2093 100644
--- a/apps/sim/lib/uploads/providers/gcs/client.test.ts
+++ b/apps/sim/lib/uploads/providers/gcs/client.test.ts
@@ -308,7 +308,9 @@ describe('GCS Client', () => {
})
it('should return null when the object is missing', async () => {
- mockFile.getMetadata.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { code: 404 }))
+ mockFile.getMetadata.mockRejectedValueOnce(
+ Object.assign(new Error('Not Found'), { code: 404 })
+ )
const result = await headGcsObject('missing.txt')
@@ -316,7 +318,9 @@ describe('GCS Client', () => {
})
it('should rethrow non-404 errors', async () => {
- mockFile.getMetadata.mockRejectedValueOnce(Object.assign(new Error('Forbidden'), { code: 403 }))
+ mockFile.getMetadata.mockRejectedValueOnce(
+ Object.assign(new Error('Forbidden'), { code: 403 })
+ )
await expect(headGcsObject('secret.txt')).rejects.toThrow('Forbidden')
})
@@ -359,7 +363,9 @@ describe('GCS Client', () => {
expect(result).toEqual({ uploadId: 'upload-123', key: 'workspace/ws-1/large.csv' })
const [url, init] = mockFetch.mock.calls[0]
- expect(url).toBe('https://storage.googleapis.com/test-bucket/workspace/ws-1/large.csv?uploads')
+ expect(url).toBe(
+ 'https://storage.googleapis.com/test-bucket/workspace/ws-1/large.csv?uploads'
+ )
expect(init.method).toBe('POST')
expect(init.headers).toEqual(
expect.objectContaining({
@@ -400,7 +406,9 @@ describe('GCS Client', () => {
expect(part).toEqual({ PartNumber: 1, ETag: '"etag-1"' })
const [url, init] = mockFetch.mock.calls[0]
- expect(url).toBe('https://storage.googleapis.com/test-bucket/key.csv?partNumber=1&uploadId=upload-123')
+ expect(url).toBe(
+ 'https://storage.googleapis.com/test-bucket/key.csv?partNumber=1&uploadId=upload-123'
+ )
expect(init.method).toBe('PUT')
})
@@ -440,7 +448,9 @@ describe('GCS Client', () => {
])
const [url, init] = mockFetch.mock.calls[0]
- expect(url).toBe('https://storage.googleapis.com/test-bucket/kb/uuid-file.txt?uploadId=upload-123')
+ expect(url).toBe(
+ 'https://storage.googleapis.com/test-bucket/kb/uuid-file.txt?uploadId=upload-123'
+ )
expect(init.method).toBe('POST')
expect(init.body).toBe(
'1"etag-1"2"etag-2"'
@@ -455,9 +465,7 @@ describe('GCS Client', () => {
it('should restore quotes on ETags stripped by the browser upload client', async () => {
mockFetch.mockResolvedValueOnce(new Response('', { status: 200 }))
- await completeGcsMultipartUpload('key.csv', 'upload-123', [
- { PartNumber: 1, ETag: 'etag-1' },
- ])
+ await completeGcsMultipartUpload('key.csv', 'upload-123', [{ PartNumber: 1, ETag: 'etag-1' }])
const [, init] = mockFetch.mock.calls[0]
expect(init.body).toBe(
diff --git a/apps/sim/lib/uploads/providers/gcs/client.ts b/apps/sim/lib/uploads/providers/gcs/client.ts
index 27b248e01fd..b4d5194a0d9 100644
--- a/apps/sim/lib/uploads/providers/gcs/client.ts
+++ b/apps/sim/lib/uploads/providers/gcs/client.ts
@@ -75,7 +75,12 @@ export async function getGcsClient(): Promise {
? { projectId: env.GCS_PROJECT_ID || credentials?.project_id }
: {}),
...(credentials
- ? { credentials: { client_email: credentials.client_email, private_key: credentials.private_key } }
+ ? {
+ credentials: {
+ client_email: credentials.client_email,
+ private_key: credentials.private_key,
+ },
+ }
: {}),
})
@@ -158,11 +163,14 @@ export async function uploadToGcs(
Object.assign(gcsMetadata, sanitizeStorageMetadata(metadata, 8000))
}
- await storage.bucket(config.bucket).file(uniqueKey).save(file, {
- contentType,
- resumable: false,
- metadata: { metadata: gcsMetadata },
- })
+ await storage
+ .bucket(config.bucket)
+ .file(uniqueKey)
+ .save(file, {
+ contentType,
+ resumable: false,
+ metadata: { metadata: gcsMetadata },
+ })
const servePath = `/api/files/serve/${encodeURIComponent(uniqueKey)}`
@@ -574,12 +582,7 @@ export async function abortGcsMultipartUpload(
): Promise {
const config = customConfig || { bucket: GCS_CONFIG.bucket }
try {
- await gcsXmlApiRequest(
- 'DELETE',
- config.bucket,
- key,
- `uploadId=${encodeURIComponent(uploadId)}`
- )
+ await gcsXmlApiRequest('DELETE', config.bucket, key, `uploadId=${encodeURIComponent(uploadId)}`)
} catch (error) {
logger.warn('Error cleaning up GCS multipart upload:', error)
}
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index 4d1ce2b9104..5cf26fbaeea 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -130,6 +130,7 @@ const nextConfig: NextConfig = {
'isolated-vm',
'@e2b/code-interpreter',
'e2b',
+ '@earendil-works/pi-ai',
'@earendil-works/pi-coding-agent',
],
outputFileTracingIncludes: {
diff --git a/apps/sim/package.json b/apps/sim/package.json
index a7800a9f904..1a41e053ea3 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -5,7 +5,7 @@
"license": "Apache-2.0",
"engines": {
"bun": ">=1.2.13",
- "node": ">=20.0.0"
+ "node": ">=22.19.0"
},
"scripts": {
"dev": "next dev --port 3000",
@@ -30,6 +30,7 @@
"lint:check": "biome check .",
"format": "biome format --write .",
"format:check": "biome format .",
+ "generate:pi-model-catalog": "bun run scripts/generate-pi-model-catalog.ts",
"generate-docs": "bun run ../../scripts/generate-docs.ts"
},
"dependencies": {
@@ -65,7 +66,8 @@
"@calcom/embed-react": "1.5.3",
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
"@e2b/code-interpreter": "^2.0.0",
- "@earendil-works/pi-coding-agent": "0.79.4",
+ "@earendil-works/pi-ai": "0.80.10",
+ "@earendil-works/pi-coding-agent": "0.80.10",
"@floating-ui/dom": "1.7.6",
"@google-cloud/storage": "7.21.0",
"@google/genai": "1.34.0",
@@ -213,6 +215,7 @@
"three": "0.177.0",
"tldts": "7.0.30",
"twilio": "5.9.0",
+ "typebox": "1.1.38",
"undici": "7.28.0",
"unpdf": "1.4.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
diff --git a/apps/sim/providers/pi-model-catalog.generated.ts b/apps/sim/providers/pi-model-catalog.generated.ts
new file mode 100644
index 00000000000..e08acce2e65
--- /dev/null
+++ b/apps/sim/providers/pi-model-catalog.generated.ts
@@ -0,0 +1,480 @@
+/**
+ * Generated from the installed Pi model catalog by
+ * `bun run generate:pi-model-catalog`. Do not edit manually.
+ */
+export const PI_MODEL_IDS_BY_PROVIDER = {
+ anthropic: [
+ 'claude-fable-5',
+ 'claude-haiku-4-5',
+ 'claude-haiku-4-5-20251001',
+ 'claude-opus-4-1',
+ 'claude-opus-4-1-20250805',
+ 'claude-opus-4-5',
+ 'claude-opus-4-5-20251101',
+ 'claude-opus-4-6',
+ 'claude-opus-4-7',
+ 'claude-opus-4-8',
+ 'claude-sonnet-4-5',
+ 'claude-sonnet-4-5-20250929',
+ 'claude-sonnet-4-6',
+ 'claude-sonnet-5',
+ ],
+ openai: [
+ 'gpt-4',
+ 'gpt-4-turbo',
+ 'gpt-4.1',
+ 'gpt-4.1-mini',
+ 'gpt-4.1-nano',
+ 'gpt-4o',
+ 'gpt-4o-2024-05-13',
+ 'gpt-4o-2024-08-06',
+ 'gpt-4o-2024-11-20',
+ 'gpt-4o-mini',
+ 'gpt-5',
+ 'gpt-5-chat-latest',
+ 'gpt-5-codex',
+ 'gpt-5-mini',
+ 'gpt-5-nano',
+ 'gpt-5-pro',
+ 'gpt-5.1',
+ 'gpt-5.1-chat-latest',
+ 'gpt-5.1-codex',
+ 'gpt-5.1-codex-max',
+ 'gpt-5.1-codex-mini',
+ 'gpt-5.2',
+ 'gpt-5.2-chat-latest',
+ 'gpt-5.2-codex',
+ 'gpt-5.2-pro',
+ 'gpt-5.3-chat-latest',
+ 'gpt-5.3-codex',
+ 'gpt-5.3-codex-spark',
+ 'gpt-5.4',
+ 'gpt-5.4-mini',
+ 'gpt-5.4-nano',
+ 'gpt-5.4-pro',
+ 'gpt-5.5',
+ 'gpt-5.5-pro',
+ 'gpt-5.6-luna',
+ 'gpt-5.6-sol',
+ 'gpt-5.6-terra',
+ 'gpt-realtime-2.1',
+ 'o1',
+ 'o1-pro',
+ 'o3',
+ 'o3-deep-research',
+ 'o3-mini',
+ 'o3-pro',
+ 'o4-mini',
+ 'o4-mini-deep-research',
+ ],
+ google: [
+ 'gemini-2.0-flash',
+ 'gemini-2.0-flash-lite',
+ 'gemini-2.5-flash',
+ 'gemini-2.5-flash-lite',
+ 'gemini-2.5-pro',
+ 'gemini-3-flash-preview',
+ 'gemini-3-pro-preview',
+ 'gemini-3.1-flash-lite',
+ 'gemini-3.1-flash-lite-preview',
+ 'gemini-3.1-pro-preview',
+ 'gemini-3.1-pro-preview-customtools',
+ 'gemini-3.5-flash',
+ 'gemini-flash-latest',
+ 'gemini-flash-lite-latest',
+ 'gemma-4-26b-a4b-it',
+ 'gemma-4-31b-it',
+ ],
+ xai: ['grok-4.3', 'grok-4.5', 'grok-build-0.1'],
+ deepseek: ['deepseek-v4-flash', 'deepseek-v4-pro'],
+ mistral: [
+ 'codestral-latest',
+ 'devstral-2512',
+ 'devstral-latest',
+ 'devstral-medium-2507',
+ 'devstral-medium-latest',
+ 'devstral-small-2505',
+ 'devstral-small-2507',
+ 'labs-devstral-small-2512',
+ 'magistral-medium-latest',
+ 'magistral-small',
+ 'ministral-3b-latest',
+ 'ministral-8b-latest',
+ 'mistral-large-2411',
+ 'mistral-large-2512',
+ 'mistral-large-latest',
+ 'mistral-medium-2505',
+ 'mistral-medium-2508',
+ 'mistral-medium-2604',
+ 'mistral-medium-3.5',
+ 'mistral-medium-latest',
+ 'mistral-nemo',
+ 'mistral-small-2506',
+ 'mistral-small-2603',
+ 'mistral-small-latest',
+ 'open-mistral-7b',
+ 'open-mistral-nemo',
+ 'open-mixtral-8x22b',
+ 'open-mixtral-8x7b',
+ 'pixtral-12b',
+ 'pixtral-large-latest',
+ ],
+ groq: [
+ 'llama-3.1-8b-instant',
+ 'llama-3.3-70b-versatile',
+ 'meta-llama/llama-4-scout-17b-16e-instruct',
+ 'openai/gpt-oss-120b',
+ 'openai/gpt-oss-20b',
+ 'openai/gpt-oss-safeguard-20b',
+ 'qwen/qwen3-32b',
+ ],
+ cerebras: ['gemma-4-31b', 'gpt-oss-120b', 'zai-glm-4.7'],
+ openrouter: [
+ 'ai21/jamba-large-1.7',
+ 'aion-labs/aion-2.0',
+ 'aion-labs/aion-3.0',
+ 'aion-labs/aion-3.0-mini',
+ 'amazon/nova-2-lite-v1',
+ 'amazon/nova-lite-v1',
+ 'amazon/nova-micro-v1',
+ 'amazon/nova-premier-v1',
+ 'amazon/nova-pro-v1',
+ 'anthropic/claude-3-haiku',
+ 'anthropic/claude-fable-5',
+ 'anthropic/claude-haiku-4.5',
+ 'anthropic/claude-opus-4',
+ 'anthropic/claude-opus-4.1',
+ 'anthropic/claude-opus-4.5',
+ 'anthropic/claude-opus-4.6',
+ 'anthropic/claude-opus-4.7',
+ 'anthropic/claude-opus-4.7-fast',
+ 'anthropic/claude-opus-4.8',
+ 'anthropic/claude-opus-4.8-fast',
+ 'anthropic/claude-sonnet-4',
+ 'anthropic/claude-sonnet-4.5',
+ 'anthropic/claude-sonnet-4.6',
+ 'anthropic/claude-sonnet-5',
+ 'arcee-ai/trinity-large-thinking',
+ 'arcee-ai/virtuoso-large',
+ 'auto',
+ 'bytedance-seed/seed-1.6',
+ 'bytedance-seed/seed-1.6-flash',
+ 'bytedance-seed/seed-2.0-lite',
+ 'bytedance-seed/seed-2.0-mini',
+ 'cohere/command-r-08-2024',
+ 'cohere/command-r-plus-08-2024',
+ 'cohere/north-mini-code:free',
+ 'deepseek/deepseek-chat',
+ 'deepseek/deepseek-chat-v3-0324',
+ 'deepseek/deepseek-chat-v3.1',
+ 'deepseek/deepseek-r1',
+ 'deepseek/deepseek-r1-0528',
+ 'deepseek/deepseek-v3.1-terminus',
+ 'deepseek/deepseek-v3.2',
+ 'deepseek/deepseek-v3.2-exp',
+ 'deepseek/deepseek-v4-flash',
+ 'deepseek/deepseek-v4-pro',
+ 'google/gemini-2.5-flash',
+ 'google/gemini-2.5-flash-lite',
+ 'google/gemini-2.5-pro',
+ 'google/gemini-2.5-pro-preview',
+ 'google/gemini-2.5-pro-preview-05-06',
+ 'google/gemini-3-flash-preview',
+ 'google/gemini-3-pro-image',
+ 'google/gemini-3.1-flash-lite',
+ 'google/gemini-3.1-flash-lite-preview',
+ 'google/gemini-3.1-pro-preview',
+ 'google/gemini-3.1-pro-preview-customtools',
+ 'google/gemini-3.5-flash',
+ 'google/gemma-3-12b-it',
+ 'google/gemma-3-27b-it',
+ 'google/gemma-4-26b-a4b-it',
+ 'google/gemma-4-26b-a4b-it:free',
+ 'google/gemma-4-31b-it',
+ 'google/gemma-4-31b-it:free',
+ 'ibm-granite/granite-4.1-8b',
+ 'inception/mercury-2',
+ 'inclusionai/ling-2.6-1t',
+ 'inclusionai/ling-2.6-flash',
+ 'inclusionai/ring-2.6-1t',
+ 'kwaipilot/kat-coder-air-v2.5',
+ 'kwaipilot/kat-coder-pro-v2',
+ 'kwaipilot/kat-coder-pro-v2.5',
+ 'meta-llama/llama-3.1-70b-instruct',
+ 'meta-llama/llama-3.1-8b-instruct',
+ 'meta-llama/llama-3.3-70b-instruct',
+ 'meta-llama/llama-3.3-70b-instruct:free',
+ 'meta-llama/llama-4-maverick',
+ 'meta-llama/llama-4-scout',
+ 'meta/muse-spark-1.1',
+ 'minimax/minimax-m1',
+ 'minimax/minimax-m2',
+ 'minimax/minimax-m2.1',
+ 'minimax/minimax-m2.5',
+ 'minimax/minimax-m2.7',
+ 'minimax/minimax-m3',
+ 'mistralai/codestral-2508',
+ 'mistralai/devstral-2512',
+ 'mistralai/ministral-14b-2512',
+ 'mistralai/ministral-3b-2512',
+ 'mistralai/ministral-8b-2512',
+ 'mistralai/mistral-large',
+ 'mistralai/mistral-large-2407',
+ 'mistralai/mistral-large-2512',
+ 'mistralai/mistral-medium-3',
+ 'mistralai/mistral-medium-3-5',
+ 'mistralai/mistral-medium-3.1',
+ 'mistralai/mistral-nemo',
+ 'mistralai/mistral-saba',
+ 'mistralai/mistral-small-2603',
+ 'mistralai/mistral-small-3.2-24b-instruct',
+ 'mistralai/mixtral-8x22b-instruct',
+ 'mistralai/voxtral-small-24b-2507',
+ 'moonshotai/kimi-k2',
+ 'moonshotai/kimi-k2-0905',
+ 'moonshotai/kimi-k2-thinking',
+ 'moonshotai/kimi-k2.5',
+ 'moonshotai/kimi-k2.6',
+ 'moonshotai/kimi-k2.7-code',
+ 'moonshotai/kimi-k3',
+ 'nex-agi/nex-n2-mini',
+ 'nex-agi/nex-n2-pro',
+ 'nvidia/llama-3.3-nemotron-super-49b-v1.5',
+ 'nvidia/nemotron-3-nano-30b-a3b',
+ 'nvidia/nemotron-3-nano-30b-a3b:free',
+ 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free',
+ 'nvidia/nemotron-3-super-120b-a12b',
+ 'nvidia/nemotron-3-super-120b-a12b:free',
+ 'nvidia/nemotron-3-ultra-550b-a55b',
+ 'nvidia/nemotron-3-ultra-550b-a55b:free',
+ 'nvidia/nemotron-nano-12b-v2-vl:free',
+ 'nvidia/nemotron-nano-9b-v2:free',
+ 'openai/gpt-3.5-turbo',
+ 'openai/gpt-3.5-turbo-0613',
+ 'openai/gpt-3.5-turbo-16k',
+ 'openai/gpt-4',
+ 'openai/gpt-4-turbo',
+ 'openai/gpt-4-turbo-preview',
+ 'openai/gpt-4.1',
+ 'openai/gpt-4.1-mini',
+ 'openai/gpt-4.1-nano',
+ 'openai/gpt-4o',
+ 'openai/gpt-4o-2024-05-13',
+ 'openai/gpt-4o-2024-08-06',
+ 'openai/gpt-4o-2024-11-20',
+ 'openai/gpt-4o-mini',
+ 'openai/gpt-4o-mini-2024-07-18',
+ 'openai/gpt-5',
+ 'openai/gpt-5-codex',
+ 'openai/gpt-5-mini',
+ 'openai/gpt-5-nano',
+ 'openai/gpt-5-pro',
+ 'openai/gpt-5.1',
+ 'openai/gpt-5.1-chat',
+ 'openai/gpt-5.1-codex',
+ 'openai/gpt-5.1-codex-max',
+ 'openai/gpt-5.1-codex-mini',
+ 'openai/gpt-5.2',
+ 'openai/gpt-5.2-chat',
+ 'openai/gpt-5.2-codex',
+ 'openai/gpt-5.2-pro',
+ 'openai/gpt-5.3-chat',
+ 'openai/gpt-5.3-codex',
+ 'openai/gpt-5.4',
+ 'openai/gpt-5.4-mini',
+ 'openai/gpt-5.4-nano',
+ 'openai/gpt-5.4-pro',
+ 'openai/gpt-5.5',
+ 'openai/gpt-5.5-pro',
+ 'openai/gpt-5.6-luna',
+ 'openai/gpt-5.6-luna-pro',
+ 'openai/gpt-5.6-sol',
+ 'openai/gpt-5.6-sol-pro',
+ 'openai/gpt-5.6-terra',
+ 'openai/gpt-5.6-terra-pro',
+ 'openai/gpt-audio',
+ 'openai/gpt-audio-mini',
+ 'openai/gpt-chat-latest',
+ 'openai/gpt-oss-120b',
+ 'openai/gpt-oss-20b',
+ 'openai/gpt-oss-20b:free',
+ 'openai/gpt-oss-safeguard-20b',
+ 'openai/o1',
+ 'openai/o3',
+ 'openai/o3-deep-research',
+ 'openai/o3-mini',
+ 'openai/o3-mini-high',
+ 'openai/o3-pro',
+ 'openai/o4-mini',
+ 'openai/o4-mini-deep-research',
+ 'openai/o4-mini-high',
+ 'openrouter/auto',
+ 'openrouter/free',
+ 'openrouter/fusion',
+ 'poolside/laguna-m.1',
+ 'poolside/laguna-m.1:free',
+ 'poolside/laguna-xs-2.1',
+ 'poolside/laguna-xs-2.1:free',
+ 'qwen/qwen-2.5-72b-instruct',
+ 'qwen/qwen-2.5-7b-instruct',
+ 'qwen/qwen-plus',
+ 'qwen/qwen-plus-2025-07-28',
+ 'qwen/qwen-plus-2025-07-28:thinking',
+ 'qwen/qwen3-14b',
+ 'qwen/qwen3-235b-a22b',
+ 'qwen/qwen3-235b-a22b-2507',
+ 'qwen/qwen3-235b-a22b-thinking-2507',
+ 'qwen/qwen3-30b-a3b',
+ 'qwen/qwen3-30b-a3b-instruct-2507',
+ 'qwen/qwen3-30b-a3b-thinking-2507',
+ 'qwen/qwen3-32b',
+ 'qwen/qwen3-8b',
+ 'qwen/qwen3-coder',
+ 'qwen/qwen3-coder-30b-a3b-instruct',
+ 'qwen/qwen3-coder-flash',
+ 'qwen/qwen3-coder-next',
+ 'qwen/qwen3-coder-plus',
+ 'qwen/qwen3-coder:free',
+ 'qwen/qwen3-max',
+ 'qwen/qwen3-max-thinking',
+ 'qwen/qwen3-next-80b-a3b-instruct',
+ 'qwen/qwen3-next-80b-a3b-instruct:free',
+ 'qwen/qwen3-next-80b-a3b-thinking',
+ 'qwen/qwen3-vl-235b-a22b-instruct',
+ 'qwen/qwen3-vl-235b-a22b-thinking',
+ 'qwen/qwen3-vl-30b-a3b-instruct',
+ 'qwen/qwen3-vl-30b-a3b-thinking',
+ 'qwen/qwen3-vl-32b-instruct',
+ 'qwen/qwen3-vl-8b-instruct',
+ 'qwen/qwen3-vl-8b-thinking',
+ 'qwen/qwen3.5-122b-a10b',
+ 'qwen/qwen3.5-27b',
+ 'qwen/qwen3.5-35b-a3b',
+ 'qwen/qwen3.5-397b-a17b',
+ 'qwen/qwen3.5-9b',
+ 'qwen/qwen3.5-flash-02-23',
+ 'qwen/qwen3.5-plus-02-15',
+ 'qwen/qwen3.5-plus-20260420',
+ 'qwen/qwen3.6-27b',
+ 'qwen/qwen3.6-35b-a3b',
+ 'qwen/qwen3.6-flash',
+ 'qwen/qwen3.6-max-preview',
+ 'qwen/qwen3.6-plus',
+ 'qwen/qwen3.7-max',
+ 'qwen/qwen3.7-plus',
+ 'rekaai/reka-edge',
+ 'relace/relace-search',
+ 'sakana/fugu-ultra',
+ 'sao10k/l3.1-euryale-70b',
+ 'stepfun/step-3.5-flash',
+ 'stepfun/step-3.7-flash',
+ 'tencent/hy3',
+ 'tencent/hy3-preview',
+ 'tencent/hy3:free',
+ 'thedrummer/unslopnemo-12b',
+ 'upstage/solar-pro-3',
+ 'x-ai/grok-4.20',
+ 'x-ai/grok-4.3',
+ 'x-ai/grok-4.5',
+ 'x-ai/grok-build-0.1',
+ 'xiaomi/mimo-v2.5',
+ 'xiaomi/mimo-v2.5-pro',
+ 'z-ai/glm-4.5',
+ 'z-ai/glm-4.5-air',
+ 'z-ai/glm-4.5v',
+ 'z-ai/glm-4.6',
+ 'z-ai/glm-4.6v',
+ 'z-ai/glm-4.7',
+ 'z-ai/glm-4.7-flash',
+ 'z-ai/glm-5',
+ 'z-ai/glm-5-turbo',
+ 'z-ai/glm-5.1',
+ 'z-ai/glm-5.2',
+ 'z-ai/glm-5v-turbo',
+ '~anthropic/claude-fable-latest',
+ '~anthropic/claude-haiku-latest',
+ '~anthropic/claude-opus-latest',
+ '~anthropic/claude-sonnet-latest',
+ '~google/gemini-flash-latest',
+ '~google/gemini-pro-latest',
+ '~moonshotai/kimi-latest',
+ '~openai/gpt-latest',
+ '~openai/gpt-mini-latest',
+ '~x-ai/grok-latest',
+ ],
+ fireworks: [
+ 'accounts/fireworks/models/deepseek-v4-flash',
+ 'accounts/fireworks/models/deepseek-v4-pro',
+ 'accounts/fireworks/models/glm-5p1',
+ 'accounts/fireworks/models/glm-5p2',
+ 'accounts/fireworks/models/gpt-oss-120b',
+ 'accounts/fireworks/models/gpt-oss-20b',
+ 'accounts/fireworks/models/kimi-k2p6',
+ 'accounts/fireworks/models/kimi-k2p7-code',
+ 'accounts/fireworks/models/minimax-m2p7',
+ 'accounts/fireworks/models/minimax-m3',
+ 'accounts/fireworks/models/qwen3p7-plus',
+ 'accounts/fireworks/routers/glm-5p1-fast',
+ 'accounts/fireworks/routers/glm-5p2-fast',
+ 'accounts/fireworks/routers/kimi-k2p6-fast',
+ 'accounts/fireworks/routers/kimi-k2p6-turbo',
+ 'accounts/fireworks/routers/kimi-k2p7-code-fast',
+ ],
+ together: [
+ 'MiniMaxAI/MiniMax-M2.7',
+ 'MiniMaxAI/MiniMax-M3',
+ 'Qwen/Qwen2.5-7B-Instruct-Turbo',
+ 'Qwen/Qwen3-235B-A22B-Instruct-2507-tput',
+ 'Qwen/Qwen3.5-397B-A17B',
+ 'Qwen/Qwen3.5-9B',
+ 'Qwen/Qwen3.6-Plus',
+ 'Qwen/Qwen3.7-Max',
+ 'deepseek-ai/DeepSeek-V4-Pro',
+ 'essentialai/Rnj-1-Instruct',
+ 'google/gemma-4-31B-it',
+ 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
+ 'moonshotai/Kimi-K2.6',
+ 'moonshotai/Kimi-K2.7-Code',
+ 'nvidia/nemotron-3-ultra-550b-a55b',
+ 'openai/gpt-oss-120b',
+ 'openai/gpt-oss-20b',
+ 'zai-org/GLM-5',
+ 'zai-org/GLM-5.1',
+ 'zai-org/GLM-5.2',
+ ],
+ nvidia: [
+ 'meta/llama-3.1-70b-instruct',
+ 'meta/llama-3.1-8b-instruct',
+ 'meta/llama-3.2-11b-vision-instruct',
+ 'meta/llama-3.2-90b-vision-instruct',
+ 'meta/llama-3.3-70b-instruct',
+ 'minimaxai/minimax-m3',
+ 'mistralai/mistral-large-3-675b-instruct-2512',
+ 'mistralai/mistral-small-4-119b-2603',
+ 'moonshotai/kimi-k2.6',
+ 'nvidia/nemotron-3-nano-30b-a3b',
+ 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning',
+ 'nvidia/nemotron-3-super-120b-a12b',
+ 'nvidia/nemotron-3-ultra-550b-a55b',
+ 'nvidia/nvidia-nemotron-nano-9b-v2',
+ 'openai/gpt-oss-120b',
+ 'openai/gpt-oss-20b',
+ 'qwen/qwen3.5-122b-a10b',
+ 'stepfun-ai/step-3.5-flash',
+ 'stepfun-ai/step-3.7-flash',
+ 'z-ai/glm-5.2',
+ ],
+ zai: ['glm-4.5-air', 'glm-4.7', 'glm-5-turbo', 'glm-5.1', 'glm-5.2', 'glm-5v-turbo'],
+ kimi: [
+ 'kimi-k2-0711-preview',
+ 'kimi-k2-0905-preview',
+ 'kimi-k2-thinking',
+ 'kimi-k2-thinking-turbo',
+ 'kimi-k2-turbo-preview',
+ 'kimi-k2.5',
+ 'kimi-k2.6',
+ 'kimi-k2.7-code',
+ 'kimi-k2.7-code-highspeed',
+ 'kimi-k3',
+ ],
+} as const
diff --git a/apps/sim/providers/pi-provider-configs.ts b/apps/sim/providers/pi-provider-configs.ts
new file mode 100644
index 00000000000..ac3cff73ce9
--- /dev/null
+++ b/apps/sim/providers/pi-provider-configs.ts
@@ -0,0 +1,81 @@
+import type { BYOKProviderId } from '@/tools/types'
+
+export interface PiProviderConfig {
+ id: string
+ piProviderId: string
+ apiKeyEnvVar: string
+ workspaceBYOKProviderId?: BYOKProviderId
+}
+
+/**
+ * Sim providers the Pi Coding Agent can run with one API key. `piProviderId`
+ * is explicit because Sim's `kimi` provider maps to Pi's `moonshotai`
+ * provider; the remaining provider IDs currently match.
+ *
+ * Providers that require richer configuration remain intentionally excluded:
+ * Vertex OAuth, Bedrock IAM, Azure endpoint configuration, OAuth-only providers,
+ * and user-supplied base-URL providers such as Ollama, vLLM, and LiteLLM.
+ */
+export const PI_PROVIDER_CONFIGS = [
+ {
+ id: 'anthropic',
+ piProviderId: 'anthropic',
+ apiKeyEnvVar: 'ANTHROPIC_API_KEY',
+ workspaceBYOKProviderId: 'anthropic',
+ },
+ {
+ id: 'openai',
+ piProviderId: 'openai',
+ apiKeyEnvVar: 'OPENAI_API_KEY',
+ workspaceBYOKProviderId: 'openai',
+ },
+ {
+ id: 'google',
+ piProviderId: 'google',
+ apiKeyEnvVar: 'GEMINI_API_KEY',
+ workspaceBYOKProviderId: 'google',
+ },
+ {
+ id: 'xai',
+ piProviderId: 'xai',
+ apiKeyEnvVar: 'XAI_API_KEY',
+ workspaceBYOKProviderId: 'xai',
+ },
+ { id: 'deepseek', piProviderId: 'deepseek', apiKeyEnvVar: 'DEEPSEEK_API_KEY' },
+ {
+ id: 'mistral',
+ piProviderId: 'mistral',
+ apiKeyEnvVar: 'MISTRAL_API_KEY',
+ workspaceBYOKProviderId: 'mistral',
+ },
+ { id: 'groq', piProviderId: 'groq', apiKeyEnvVar: 'GROQ_API_KEY' },
+ { id: 'cerebras', piProviderId: 'cerebras', apiKeyEnvVar: 'CEREBRAS_API_KEY' },
+ { id: 'openrouter', piProviderId: 'openrouter', apiKeyEnvVar: 'OPENROUTER_API_KEY' },
+ {
+ id: 'fireworks',
+ piProviderId: 'fireworks',
+ apiKeyEnvVar: 'FIREWORKS_API_KEY',
+ workspaceBYOKProviderId: 'fireworks',
+ },
+ {
+ id: 'together',
+ piProviderId: 'together',
+ apiKeyEnvVar: 'TOGETHER_API_KEY',
+ workspaceBYOKProviderId: 'together',
+ },
+ { id: 'nvidia', piProviderId: 'nvidia', apiKeyEnvVar: 'NVIDIA_API_KEY' },
+ {
+ id: 'zai',
+ piProviderId: 'zai',
+ apiKeyEnvVar: 'ZAI_API_KEY',
+ workspaceBYOKProviderId: 'zai',
+ },
+ {
+ id: 'kimi',
+ piProviderId: 'moonshotai',
+ apiKeyEnvVar: 'MOONSHOT_API_KEY',
+ workspaceBYOKProviderId: 'kimi',
+ },
+] as const satisfies readonly PiProviderConfig[]
+
+export type PiSupportedProvider = (typeof PI_PROVIDER_CONFIGS)[number]['id']
diff --git a/apps/sim/providers/pi-providers.test.ts b/apps/sim/providers/pi-providers.test.ts
new file mode 100644
index 00000000000..45e3ef6b8d3
--- /dev/null
+++ b/apps/sim/providers/pi-providers.test.ts
@@ -0,0 +1,39 @@
+import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
+import { describe, expect, it } from 'vitest'
+import { PI_MODEL_IDS_BY_PROVIDER } from '@/providers/pi-model-catalog.generated'
+import { PI_PROVIDER_CONFIGS } from '@/providers/pi-provider-configs'
+import { resolvePiModelId } from '@/providers/pi-providers'
+
+describe('Pi provider catalog', () => {
+ it('matches the model catalog in the pinned Pi package', () => {
+ for (const { id, piProviderId } of PI_PROVIDER_CONFIGS) {
+ expect([...PI_MODEL_IDS_BY_PROVIDER[id]].sort()).toEqual(
+ getBuiltinModels(piProviderId)
+ .map(({ id: modelId }) => modelId)
+ .sort()
+ )
+ }
+ })
+
+ it('keeps exact provider-relative model IDs', () => {
+ expect(resolvePiModelId('anthropic', 'claude-sonnet-4-6')).toBe('claude-sonnet-4-6')
+ })
+
+ it('normalizes Sim provider prefixes only when Pi declares the resulting ID', () => {
+ expect(resolvePiModelId('groq', 'groq/openai/gpt-oss-120b')).toBe('openai/gpt-oss-120b')
+ expect(resolvePiModelId('cerebras', 'cerebras/gpt-oss-120b')).toBe('gpt-oss-120b')
+ expect(resolvePiModelId('groq', 'groq/unknown-model')).toBeUndefined()
+ })
+
+ it('maps Sim provider IDs onto Pi provider IDs', () => {
+ expect(resolvePiModelId('kimi', 'kimi-k2.6')).toBe('kimi-k2.6')
+ expect(resolvePiModelId('nvidia', 'nvidia/nemotron-3-super-120b-a12b')).toBe(
+ 'nvidia/nemotron-3-super-120b-a12b'
+ )
+ })
+
+ it('rejects provider/model pairs absent from the installed Pi catalog', () => {
+ expect(resolvePiModelId('anthropic', 'claude-sonnet-999')).toBeUndefined()
+ expect(resolvePiModelId('unsupported', 'model')).toBeUndefined()
+ })
+})
diff --git a/apps/sim/providers/pi-providers.ts b/apps/sim/providers/pi-providers.ts
index af9fd305a40..57b3d66bcb7 100644
--- a/apps/sim/providers/pi-providers.ts
+++ b/apps/sim/providers/pi-providers.ts
@@ -1,29 +1,74 @@
+import { PI_MODEL_IDS_BY_PROVIDER } from '@/providers/pi-model-catalog.generated'
+import {
+ PI_PROVIDER_CONFIGS,
+ type PiProviderConfig,
+ type PiSupportedProvider,
+} from '@/providers/pi-provider-configs'
+import type { BYOKProviderId } from '@/tools/types'
+
/**
- * Providers the Pi Coding Agent can run with a single API key. This list is the
- * single source of truth for both the cloud env-var mapping (Pi handler) and the
- * Pi block's model dropdown (UI), so the block only offers Pi-runnable models.
- *
- * Excludes providers Pi's key-based flow can't drive: ones needing richer config
- * (Vertex OAuth, Bedrock IAM, Azure endpoint+key) and base-URL providers
- * (Ollama, vLLM, LiteLLM, Together, Baseten, Ollama Cloud).
+ * Shared provider and model bridge for the Pi model picker, executor, host SDK,
+ * and E2B CLI.
*/
-export const PI_SUPPORTED_PROVIDER_IDS = [
- 'anthropic',
- 'openai',
- 'google',
- 'xai',
- 'deepseek',
- 'mistral',
- 'groq',
- 'cerebras',
- 'openrouter',
-] as const
-
-export type PiSupportedProvider = (typeof PI_SUPPORTED_PROVIDER_IDS)[number]
-
-const PI_SUPPORTED_PROVIDER_SET = new Set(PI_SUPPORTED_PROVIDER_IDS)
-
-/** Whether the Pi Coding Agent can run a given provider via a single API key. */
+export const PI_SUPPORTED_PROVIDER_IDS: readonly PiSupportedProvider[] = PI_PROVIDER_CONFIGS.map(
+ ({ id }) => id
+)
+
+const PI_PROVIDER_CONFIG_BY_ID = new Map(
+ PI_PROVIDER_CONFIGS.map((config) => [config.id, config])
+)
+
+const PI_MODEL_IDS_BY_PROVIDER_ID = new Map>(
+ PI_PROVIDER_CONFIGS.map(({ id }) => [id, new Set(PI_MODEL_IDS_BY_PROVIDER[id])])
+)
+
+/** Whether Sim can run the provider through Pi's single-key flow. */
export function isPiSupportedProvider(providerId: string): providerId is PiSupportedProvider {
- return PI_SUPPORTED_PROVIDER_SET.has(providerId)
+ return PI_PROVIDER_CONFIG_BY_ID.has(providerId)
+}
+
+/** Returns Pi's provider ID for a supported Sim provider. */
+export function getPiProviderId(providerId: PiSupportedProvider): PiProviderConfig['piProviderId'] {
+ const config = PI_PROVIDER_CONFIG_BY_ID.get(providerId)
+ if (!config) throw new Error(`Pi provider configuration is missing for "${providerId}"`)
+ return config.piProviderId
+}
+
+/** Returns the environment variable consumed by Pi's CLI for a supported provider. */
+export function getPiProviderApiKeyEnvVar(
+ providerId: PiSupportedProvider
+): PiProviderConfig['apiKeyEnvVar'] {
+ const config = PI_PROVIDER_CONFIG_BY_ID.get(providerId)
+ if (!config) throw new Error(`Pi provider configuration is missing for "${providerId}"`)
+ return config.apiKeyEnvVar
+}
+
+/** Returns the stored workspace-key provider supported by this Pi provider. */
+export function getPiWorkspaceBYOKProviderId(
+ providerId: PiSupportedProvider
+): BYOKProviderId | undefined {
+ return PI_PROVIDER_CONFIG_BY_ID.get(providerId)?.workspaceBYOKProviderId
+}
+
+/**
+ * Resolves a Sim model ID to the exact provider-relative ID in Pi's pinned
+ * catalog. Sim prefixes model IDs for providers whose native IDs overlap; Pi
+ * sometimes keeps that prefix (NVIDIA) and sometimes does not (Groq), so exact
+ * IDs are checked before removing the Sim provider prefix.
+ */
+export function resolvePiModelId(providerId: string, modelId: string): string | undefined {
+ if (!isPiSupportedProvider(providerId)) return undefined
+ const modelIds = PI_MODEL_IDS_BY_PROVIDER_ID.get(providerId)
+ if (modelIds?.has(modelId)) return modelId
+
+ const providerPrefix = `${providerId}/`
+ if (!modelId.startsWith(providerPrefix)) return undefined
+
+ const providerRelativeId = modelId.slice(providerPrefix.length)
+ return modelIds?.has(providerRelativeId) ? providerRelativeId : undefined
+}
+
+/** Whether the provider/model pair exists in Pi's pinned catalog. */
+export function isPiSupportedModel(providerId: string, modelId: string): boolean {
+ return resolvePiModelId(providerId, modelId) !== undefined
}
diff --git a/apps/sim/scripts/build-pi-e2b-template.ts b/apps/sim/scripts/build-pi-e2b-template.ts
index 24ab4a09101..f4641a2b7b3 100644
--- a/apps/sim/scripts/build-pi-e2b-template.ts
+++ b/apps/sim/scripts/build-pi-e2b-template.ts
@@ -1,12 +1,11 @@
#!/usr/bin/env bun
/**
- * Builds the E2B sandbox template that powers the Pi Coding Agent cloud mode.
+ * Builds the E2B sandbox template used by Create PR and Review Code.
*
- * Layers the `pi` CLI plus git onto E2B's `code-interpreter` base (which already
- * ships node + python). The cloud backend runs `pi` and `git clone/commit/push`
- * inside this sandbox, so both must resolve on PATH — the global npm bin and
- * `/usr/bin` both are.
+ * Layers the `pi` CLI, its required Node version, and git onto E2B's
+ * `code-interpreter` base. The cloud backend runs `pi` and git inside this
+ * sandbox, so both must resolve on PATH.
*
* Usage:
* E2B_API_KEY=... bun run apps/sim/scripts/build-pi-e2b-template.ts [--name ] [--no-cache]
@@ -16,17 +15,31 @@
* `Sandbox.create` resolves by template name, so use the name (not the ID).
*/
-import { defaultBuildLogger, Template } from '@e2b/code-interpreter'
+import { defaultBuildLogger, Template, waitForTimeout } from '@e2b/code-interpreter'
const DEFAULT_TEMPLATE_NAME = 'sim-pi'
+/** Exact first-party Pi versions mirrored from bun.lock because E2B builds run npm independently. */
+const PI_PACKAGES = [
+ '@earendil-works/pi-coding-agent@0.80.10',
+ '@earendil-works/pi-agent-core@0.80.10',
+ '@earendil-works/pi-ai@0.80.10',
+ '@earendil-works/pi-tui@0.80.10',
+] as const
+
+/** Pi 0.80 requires Node >=22.19; E2B's code-interpreter base currently ships Node 20. */
+const INSTALL_NODE_COMMAND =
+ 'curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs && node -e "const [major, minor] = process.versions.node.split(\'.\').map(Number); if (major < 22 || (major === 22 && minor < 19)) process.exit(1)"'
+
+/** Pi uses E2B's command and filesystem APIs, so the inherited Jupyter service is unnecessary. */
+const START_COMMAND = 'sleep infinity'
+
const piTemplate = Template()
.fromTemplate('code-interpreter-v1')
- // git (+ ssh/certs) for clone/commit/push; ripgrep/fd give the agent fast
- // file search from its bash tool; gh enables richer GitHub workflows.
+ .runCmd(INSTALL_NODE_COMMAND, { user: 'root' })
.aptInstall(['git', 'gh', 'openssh-client', 'ca-certificates', 'ripgrep', 'fd-find'])
- // The `pi` CLI the cloud backend invokes.
- .npmInstall(['@earendil-works/pi-coding-agent'], { g: true })
+ .npmInstall([...PI_PACKAGES], { g: true })
+ .setStartCmd(START_COMMAND, waitForTimeout(1_000))
async function main() {
if (!process.env.E2B_API_KEY) {
diff --git a/apps/sim/scripts/generate-pi-model-catalog.ts b/apps/sim/scripts/generate-pi-model-catalog.ts
new file mode 100644
index 00000000000..239d3d6191b
--- /dev/null
+++ b/apps/sim/scripts/generate-pi-model-catalog.ts
@@ -0,0 +1,27 @@
+import { execFileSync } from 'node:child_process'
+import { writeFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
+import { PI_PROVIDER_CONFIGS } from '@/providers/pi-provider-configs'
+
+const OUTPUT_PATH = new URL('../providers/pi-model-catalog.generated.ts', import.meta.url)
+
+const catalog = Object.fromEntries(
+ PI_PROVIDER_CONFIGS.map(({ id, piProviderId }) => [
+ id,
+ getBuiltinModels(piProviderId)
+ .map(({ id: modelId }) => modelId)
+ .sort(),
+ ])
+)
+
+const source = `/**
+ * Generated from the installed Pi model catalog by
+ * \`bun run generate:pi-model-catalog\`. Do not edit manually.
+ */
+export const PI_MODEL_IDS_BY_PROVIDER = ${JSON.stringify(catalog, null, 2)} as const
+`
+
+const outputPath = fileURLToPath(OUTPUT_PATH)
+await writeFile(outputPath, source)
+execFileSync('bunx', ['biome', 'format', '--write', outputPath], { stdio: 'inherit' })
diff --git a/apps/sim/tools/github/create_pr_review.test.ts b/apps/sim/tools/github/create_pr_review.test.ts
new file mode 100644
index 00000000000..f96b18fc70f
--- /dev/null
+++ b/apps/sim/tools/github/create_pr_review.test.ts
@@ -0,0 +1,133 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { createPRReviewTool, createPRReviewV2Tool } from '@/tools/github/create_pr_review'
+
+describe('createPRReviewTool request body', () => {
+ const commitId = 'a'.repeat(40)
+ const base = {
+ owner: 'octo',
+ repo: 'demo',
+ pullNumber: 7,
+ event: 'COMMENT' as const,
+ apiKey: 'ghp_test',
+ }
+
+ it('includes comments and commit_id when provided', () => {
+ const body = createPRReviewTool.request.body!({
+ ...base,
+ body: 'Looks good',
+ commit_id: commitId,
+ comments: [{ path: 'src/a.ts', body: 'nit', line: 3, side: 'RIGHT' }],
+ })
+
+ expect(body).toEqual({
+ event: 'COMMENT',
+ body: 'Looks good',
+ commit_id: commitId,
+ comments: [{ path: 'src/a.ts', body: 'nit', line: 3, side: 'RIGHT' }],
+ })
+ })
+
+ it('requires commit_id when comments are present', () => {
+ expect(() =>
+ createPRReviewTool.request.body!({
+ ...base,
+ body: 'summary',
+ comments: [{ path: 'a.ts', body: 'x', line: 1, side: 'RIGHT' }],
+ })
+ ).toThrow(/commit_id is required/)
+ })
+
+ it('omits comments when none are provided', () => {
+ const body = createPRReviewTool.request.body!({
+ ...base,
+ body: 'summary only',
+ })
+
+ expect(body).toEqual({ event: 'COMMENT', body: 'summary only' })
+ expect(body.comments).toBeUndefined()
+ })
+
+ it.each(['COMMENT', 'REQUEST_CHANGES'] as const)('requires a non-empty body for %s', (event) => {
+ expect(() => createPRReviewTool.request.body!({ ...base, event, body: ' ' })).toThrow(
+ /body is required/
+ )
+ })
+
+ it('rejects invalid coordinates instead of forwarding them to GitHub', () => {
+ expect(() =>
+ createPRReviewTool.request.body!({
+ ...base,
+ body: 'summary',
+ commit_id: 'abc123',
+ comments: [{ path: 'a.ts', body: 'x', line: 1.5, side: 'RIGHT' }],
+ })
+ ).toThrow(/comments is invalid/)
+ })
+
+ it('rejects dynamic invalid events and malformed commit ids at the boundary', () => {
+ expect(() =>
+ createPRReviewTool.request.body!({ ...base, event: 'PENDING' as never, body: 'summary' })
+ ).toThrow(/event must be/)
+ expect(() =>
+ createPRReviewTool.request.body!({
+ ...base,
+ body: 'summary',
+ commit_id: ' ',
+ comments: [{ path: 'a.ts', body: 'x', line: 1, side: 'RIGHT' }],
+ })
+ ).toThrow(/commit_id must be a full/)
+ })
+})
+
+describe('createPRReviewV2Tool response', () => {
+ function reviewPayload(overrides: Record = {}) {
+ return {
+ id: 9,
+ user: {
+ login: 'octo',
+ id: 1,
+ avatar_url: 'https://avatars.githubusercontent.com/u/1',
+ html_url: 'https://github.com/octo',
+ type: 'User',
+ },
+ body: 'Review summary',
+ state: 'COMMENTED',
+ html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-9',
+ pull_request_url: 'https://api.github.com/repos/octo/demo/pulls/7',
+ commit_id: 'a'.repeat(40),
+ submitted_at: '2026-07-20T00:00:00Z',
+ ...overrides,
+ }
+ }
+
+ it('preserves GitHub review nullability without inventing values', async () => {
+ const payload = reviewPayload({ user: null, commit_id: null, submitted_at: undefined })
+
+ const result = await createPRReviewV2Tool.transformResponse!(Response.json(payload))
+
+ expect(result).toEqual({
+ success: true,
+ output: {
+ id: 9,
+ user: null,
+ body: 'Review summary',
+ state: 'COMMENTED',
+ html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-9',
+ pull_request_url: 'https://api.github.com/repos/octo/demo/pulls/7',
+ commit_id: null,
+ },
+ })
+ })
+
+ it('rejects malformed successful review payloads', async () => {
+ await expect(
+ createPRReviewV2Tool.transformResponse!(Response.json(reviewPayload({ body: null })))
+ ).rejects.toThrow('GitHub review response.body must be a string')
+ await expect(
+ createPRReviewV2Tool.transformResponse!(Response.json(reviewPayload({ html_url: '' })))
+ ).rejects.toThrow('GitHub review response.html_url must be a non-empty string')
+ })
+})
diff --git a/apps/sim/tools/github/create_pr_review.ts b/apps/sim/tools/github/create_pr_review.ts
index 4384205fca4..ea99eea649c 100644
--- a/apps/sim/tools/github/create_pr_review.ts
+++ b/apps/sim/tools/github/create_pr_review.ts
@@ -1,6 +1,101 @@
-import type { CreatePRReviewParams, PRReviewResponse } from '@/tools/github/types'
+import {
+ isRecord,
+ nullableNonEmptyString,
+ optionalNonEmptyString,
+ readGitHubErrorMessage,
+ requiredNonEmptyString,
+ requiredNumber,
+ requiredString,
+} from '@/tools/github/response-parsers'
+import {
+ parseReviewComments,
+ REVIEW_BODY_MAX_LENGTH,
+ reviewCommentSchema,
+} from '@/tools/github/review-schema'
+import type {
+ CreatePRReviewComment,
+ CreatePRReviewParams,
+ PRReviewResponse,
+} from '@/tools/github/types'
import { USER_OUTPUT } from '@/tools/github/types'
-import type { ToolConfig } from '@/tools/types'
+import type { ToolConfig, ToolResponse } from '@/tools/types'
+
+const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i
+
+interface GitHubReviewUser {
+ login: string
+ id: number
+ avatar_url: string
+ html_url: string
+ type: string
+}
+
+interface GitHubReview {
+ id: number
+ user: GitHubReviewUser | null
+ body: string
+ state: string
+ html_url: string
+ pull_request_url: string
+ commit_id: string | null
+ submitted_at?: string
+}
+
+interface CreatePRReviewV2Response extends ToolResponse {
+ output: GitHubReview
+}
+
+interface CreatePRReviewRequestBody {
+ event: CreatePRReviewParams['event']
+ body?: string
+ commit_id?: string
+ comments?: CreatePRReviewComment[]
+}
+
+const REVIEW_RESPONSE_CONTEXT = 'GitHub review response'
+
+function parseReviewUser(value: unknown): GitHubReviewUser | null {
+ if (value === null) return null
+ if (!isRecord(value)) throw new Error('GitHub review response has an invalid user')
+ const context = `${REVIEW_RESPONSE_CONTEXT}.user`
+ return {
+ login: requiredNonEmptyString(value, 'login', context),
+ id: requiredNumber(value, 'id', context),
+ avatar_url: requiredNonEmptyString(value, 'avatar_url', context),
+ html_url: requiredNonEmptyString(value, 'html_url', context),
+ type: requiredNonEmptyString(value, 'type', context),
+ }
+}
+
+function parseGitHubReview(value: unknown): GitHubReview {
+ if (!isRecord(value)) throw new Error('GitHub review response must be an object')
+ const submittedAt = optionalNonEmptyString(value, 'submitted_at', REVIEW_RESPONSE_CONTEXT)
+ return {
+ id: requiredNumber(value, 'id', REVIEW_RESPONSE_CONTEXT),
+ user: parseReviewUser(value.user),
+ body: requiredString(value, 'body', REVIEW_RESPONSE_CONTEXT),
+ state: requiredNonEmptyString(value, 'state', REVIEW_RESPONSE_CONTEXT),
+ html_url: requiredNonEmptyString(value, 'html_url', REVIEW_RESPONSE_CONTEXT),
+ pull_request_url: requiredNonEmptyString(value, 'pull_request_url', REVIEW_RESPONSE_CONTEXT),
+ commit_id: nullableNonEmptyString(value, 'commit_id', REVIEW_RESPONSE_CONTEXT),
+ ...(submittedAt ? { submitted_at: submittedAt } : {}),
+ }
+}
+
+function parseReviewEvent(value: unknown): CreatePRReviewParams['event'] {
+ if (value === 'APPROVE' || value === 'REQUEST_CHANGES' || value === 'COMMENT') {
+ return value
+ }
+ throw new Error('event must be APPROVE, REQUEST_CHANGES, or COMMENT')
+}
+
+function parseCommitId(value: unknown): string | undefined {
+ if (value === undefined) return undefined
+ if (typeof value !== 'string' || !COMMIT_SHA_PATTERN.test(value.trim())) {
+ throw new Error('commit_id must be a full 40- or 64-character commit SHA')
+ }
+ return value.trim()
+}
export const createPRReviewTool: ToolConfig = {
id: 'github_create_pr_review',
@@ -44,7 +139,15 @@ export const createPRReviewTool: ToolConfig {
- const body: Record = {
- event: params.event,
+ const comments = parseReviewComments(params.comments)
+ const commitId = parseCommitId(params.commit_id)
+ if (comments.length > 0 && !commitId) {
+ throw new Error('commit_id is required when posting inline review comments')
}
- if (params.body) body.body = params.body
- if (params.commit_id) body.commit_id = params.commit_id
+ const event = parseReviewEvent(params.event)
+ const reviewBody = params.body?.trim()
+ if ((event === 'COMMENT' || event === 'REQUEST_CHANGES') && !reviewBody) {
+ throw new Error(`body is required for ${event} reviews`)
+ }
+ if (reviewBody && reviewBody.length > REVIEW_BODY_MAX_LENGTH) {
+ throw new Error(`body must not exceed ${REVIEW_BODY_MAX_LENGTH} characters`)
+ }
+
+ const body: CreatePRReviewRequestBody = {
+ event,
+ }
+ if (reviewBody) body.body = reviewBody
+ if (commitId) body.commit_id = commitId
+ if (comments.length > 0) body.comments = comments
return body
},
},
transformResponse: async (response) => {
if (!response.ok) {
- const error = await response.json().catch(() => ({}))
return {
success: false,
- error: error.message || `Failed to submit PR review (HTTP ${response.status})`,
+ error:
+ (await readGitHubErrorMessage(response)) ??
+ `Failed to submit PR review (HTTP ${response.status})`,
output: {
content: '',
- metadata: { id: 0, state: '', body: '', html_url: '', commit_id: '' },
+ metadata: { id: 0, state: '', body: '', html_url: '', commit_id: null },
},
}
}
- const review = await response.json()
+ const value: unknown = await response.json()
+ const review = parseGitHubReview(value)
- const content = `Review submitted for PR #${review.pull_request_url?.split('/').pop() ?? ''}
+ const content = `Review submitted for PR #${review.pull_request_url.split('/').pop()}
State: ${review.state}
URL: ${review.html_url}`
@@ -99,7 +219,7 @@ URL: ${review.html_url}`
metadata: {
id: review.id,
state: review.state,
- body: review.body ?? '',
+ body: review.body,
html_url: review.html_url,
commit_id: review.commit_id,
},
@@ -120,13 +240,13 @@ URL: ${review.html_url}`
},
body: { type: 'string', description: 'Review body text' },
html_url: { type: 'string', description: 'GitHub web URL for the review' },
- commit_id: { type: 'string', description: 'SHA of the reviewed commit' },
+ commit_id: { type: 'string', description: 'SHA of the reviewed commit', nullable: true },
},
},
},
}
-export const createPRReviewV2Tool: ToolConfig = {
+export const createPRReviewV2Tool: ToolConfig = {
id: 'github_create_pr_review_v2',
name: createPRReviewTool.name,
description: createPRReviewTool.description,
@@ -136,47 +256,53 @@ export const createPRReviewV2Tool: ToolConfig = {
transformResponse: async (response: Response) => {
if (!response.ok) {
- const error = await response.json().catch(() => ({}))
return {
success: false,
- error: error.message || `Failed to submit PR review (HTTP ${response.status})`,
+ error: await responseErrorMessage(
+ response,
+ `Failed to submit PR review (HTTP ${response.status})`
+ ),
output: {
id: 0,
user: null,
- body: null,
+ body: '',
state: '',
html_url: '',
pull_request_url: '',
- commit_id: '',
- submitted_at: null,
+ commit_id: null,
},
}
}
- const review = await response.json()
+ const value: unknown = await response.json()
+ const review = parseGitHubReview(value)
return {
success: true,
output: {
id: review.id,
- user: review.user ?? null,
- body: review.body ?? null,
+ user: review.user,
+ body: review.body,
state: review.state,
html_url: review.html_url,
pull_request_url: review.pull_request_url,
commit_id: review.commit_id,
- submitted_at: review.submitted_at ?? null,
+ ...(review.submitted_at ? { submitted_at: review.submitted_at } : {}),
},
}
},
outputs: {
id: { type: 'number', description: 'Review ID' },
- user: { ...USER_OUTPUT, optional: true },
+ user: { ...USER_OUTPUT, nullable: true },
body: { type: 'string', description: 'Review body text' },
state: { type: 'string', description: 'Review state (APPROVED/CHANGES_REQUESTED/COMMENTED)' },
html_url: { type: 'string', description: 'GitHub web URL for the review' },
pull_request_url: { type: 'string', description: 'API URL of the reviewed pull request' },
- commit_id: { type: 'string', description: 'SHA of the reviewed commit' },
- submitted_at: { type: 'string', description: 'Review submission timestamp' },
+ commit_id: { type: 'string', description: 'SHA of the reviewed commit', nullable: true },
+ submitted_at: {
+ type: 'string',
+ description: 'Review submission timestamp',
+ optional: true,
+ },
},
}
diff --git a/apps/sim/tools/github/pr.test.ts b/apps/sim/tools/github/pr.test.ts
new file mode 100644
index 00000000000..8d1bb6b2604
--- /dev/null
+++ b/apps/sim/tools/github/pr.test.ts
@@ -0,0 +1,214 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
+import { prTool, prV2Tool } from '@/tools/github/pr'
+import type {
+ CreateCommentParams,
+ PROperationParams,
+ PRV2OperationParams,
+} from '@/tools/github/types'
+
+type HasIncludeFiles = 'includeFiles' extends keyof T ? true : false
+
+const BASE_PARAMS = {
+ owner: 'octo',
+ repo: 'demo',
+ pullNumber: 7,
+ apiKey: 'ghp_test',
+} as const
+
+function pullRequestPayload() {
+ return {
+ id: 1,
+ number: 7,
+ title: 'Review me',
+ state: 'open',
+ html_url: 'https://github.com/octo/demo/pull/7',
+ diff_url: 'https://github.com/octo/demo/pull/7.diff',
+ body: 'Description',
+ user: {
+ login: 'octo',
+ id: 2,
+ avatar_url: 'https://avatars.githubusercontent.com/u/2',
+ html_url: 'https://github.com/octo',
+ type: 'User',
+ },
+ head: { label: 'octo:feature', sha: 'a'.repeat(40), ref: 'feature' },
+ base: { label: 'octo:staging', sha: 'b'.repeat(40), ref: 'staging' },
+ merged: false,
+ mergeable: true,
+ merged_by: null,
+ comments: 0,
+ review_comments: 0,
+ commits: 1,
+ additions: 1,
+ deletions: 1,
+ changed_files: 1,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ closed_at: null,
+ merged_at: null,
+ }
+}
+
+function pullRequestResponse(): Response {
+ return Response.json(pullRequestPayload())
+}
+
+function pullRequestFilePayload(index = 0) {
+ return {
+ sha: 'c'.repeat(40),
+ filename: index === 0 ? 'src/index.ts' : `src/file-${index}.ts`,
+ status: 'modified',
+ additions: 2,
+ deletions: 1,
+ changes: 3,
+ blob_url: 'https://github.com/octo/demo/blob/abc/src/index.ts',
+ raw_url: 'https://github.com/octo/demo/raw/abc/src/index.ts',
+ contents_url: 'https://api.github.com/repos/octo/demo/contents/src/index.ts',
+ patch: '@@ -1 +1,2 @@',
+ }
+}
+
+describe('GitHub PR reader tools', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('exposes includeFiles only on the V2 contract', () => {
+ expect(prTool.params).not.toHaveProperty('includeFiles')
+ expect(prV2Tool.params).toMatchObject({
+ includeFiles: { type: 'boolean', required: false, default: true },
+ })
+ expectTypeOf>().toEqualTypeOf()
+ expectTypeOf>().toEqualTypeOf()
+ expectTypeOf>().toEqualTypeOf()
+ })
+
+ it('skips the files endpoint when includeFiles is false', async () => {
+ const filesFetch = vi.fn()
+ vi.stubGlobal('fetch', filesFetch)
+
+ const result = await prV2Tool.transformResponse!(pullRequestResponse(), {
+ ...BASE_PARAMS,
+ includeFiles: false,
+ })
+
+ expect(result.success).toBe(true)
+ expect(result.output).toMatchObject({
+ number: 7,
+ head: { sha: 'a'.repeat(40) },
+ base: { sha: 'b'.repeat(40), ref: 'staging' },
+ })
+ expect(result.output).not.toHaveProperty('files')
+ expect(filesFetch).not.toHaveBeenCalled()
+ })
+
+ it('fetches and parses files when includeFiles is true or omitted', async () => {
+ const filesFetch = vi.fn(() => Response.json([pullRequestFilePayload()]))
+ vi.stubGlobal('fetch', filesFetch)
+
+ const defaultResult = await prV2Tool.transformResponse!(pullRequestResponse(), BASE_PARAMS)
+ const explicitResult = await prV2Tool.transformResponse!(pullRequestResponse(), {
+ ...BASE_PARAMS,
+ includeFiles: true,
+ })
+
+ expect(defaultResult.success).toBe(true)
+ expect(explicitResult.success).toBe(true)
+ expect(defaultResult.output.files).toEqual([pullRequestFilePayload()])
+ expect(explicitResult.output.files).toEqual([pullRequestFilePayload()])
+ expect(filesFetch).toHaveBeenCalledTimes(2)
+ expect(filesFetch).toHaveBeenNthCalledWith(
+ 1,
+ 'https://api.github.com/repos/octo/demo/pulls/7/files?per_page=100&page=1',
+ expect.objectContaining({
+ headers: expect.objectContaining({ Authorization: 'Bearer ghp_test' }),
+ })
+ )
+ })
+
+ it('paginates changed files until GitHub returns a short page', async () => {
+ const firstPage = Array.from({ length: 100 }, (_, index) => pullRequestFilePayload(index + 1))
+ const finalFile = pullRequestFilePayload(101)
+ const filesFetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(firstPage))
+ .mockResolvedValueOnce(Response.json([finalFile]))
+ vi.stubGlobal('fetch', filesFetch)
+
+ const result = await prV2Tool.transformResponse!(pullRequestResponse(), BASE_PARAMS)
+
+ expect(result.success).toBe(true)
+ expect(result.output.files).toHaveLength(101)
+ expect(result.output.files?.at(-1)).toEqual(finalFile)
+ expect(filesFetch).toHaveBeenNthCalledWith(
+ 2,
+ 'https://api.github.com/repos/octo/demo/pulls/7/files?per_page=100&page=2',
+ expect.any(Object)
+ )
+ })
+
+ it('preserves files endpoint failures when file fetching is enabled', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Response.json({ message: 'secondary rate limit' }, { status: 403 }))
+ )
+
+ const result = await prV2Tool.transformResponse!(pullRequestResponse(), BASE_PARAMS)
+
+ expect(result).toMatchObject({
+ success: false,
+ error: 'secondary rate limit',
+ output: { number: 7 },
+ })
+ expect(result.output).not.toHaveProperty('files')
+ })
+
+ it('preserves the V1 files endpoint failure response', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Response.json({ message: 'files unavailable' }, { status: 503 }))
+ )
+
+ const result = await prTool.transformResponse!(pullRequestResponse(), BASE_PARAMS)
+
+ expect(result).toMatchObject({
+ success: false,
+ error: 'files unavailable',
+ output: {
+ content: '',
+ metadata: { number: 7, title: 'Review me', files: [] },
+ },
+ })
+ })
+
+ it('rejects malformed PR payloads instead of returning partial success', async () => {
+ vi.stubGlobal('fetch', vi.fn())
+ const response = Response.json({ ...pullRequestPayload(), title: 42 })
+
+ await expect(
+ prV2Tool.transformResponse!(response, { ...BASE_PARAMS, includeFiles: false })
+ ).rejects.toThrow('pull_request.title must be a string')
+ })
+
+ it('preserves primary pull request API failures', async () => {
+ const response = Response.json({ message: 'pull request unavailable' }, { status: 503 })
+
+ await expect(
+ prV2Tool.transformResponse!(response, { ...BASE_PARAMS, includeFiles: false })
+ ).rejects.toThrow('pull request unavailable')
+ })
+
+ it('rejects malformed successful files payloads instead of treating them as empty', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Response.json({ files: [] }))
+ )
+
+ await expect(prV2Tool.transformResponse!(pullRequestResponse(), BASE_PARAMS)).rejects.toThrow(
+ 'GitHub pull request files response must be an array'
+ )
+ })
+})
diff --git a/apps/sim/tools/github/pr.ts b/apps/sim/tools/github/pr.ts
index bed8b0df0af..2d246f49509 100644
--- a/apps/sim/tools/github/pr.ts
+++ b/apps/sim/tools/github/pr.ts
@@ -1,39 +1,230 @@
-import type { PROperationParams, PullRequestResponse } from '@/tools/github/types'
+import {
+ isRecord,
+ nullableString,
+ optionalString,
+ readGitHubErrorMessage,
+ requiredNumber,
+ requiredString,
+} from '@/tools/github/response-parsers'
+import type {
+ GitHubPullRequestBranch,
+ GitHubPullRequestFile,
+ GitHubPullRequestUser,
+ GitHubPullRequestV2Output,
+ PROperationParams,
+ PRV2OperationParams,
+ PullRequestResponse,
+ PullRequestV2Response,
+} from '@/tools/github/types'
import { BRANCH_REF_OUTPUT, PR_FILE_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
+type GitHubPullRequest = Omit
+
+type PullRequestFilesResult =
+ | { success: true; files: GitHubPullRequestFile[] }
+ | { success: false; error: string }
+
+const PULL_REQUEST_FILES_PER_PAGE = 100
+const MAX_PULL_REQUEST_FILES = 3_000
+
+function requiredBoolean(record: Record, key: string, context: string): boolean {
+ const value = record[key]
+ if (typeof value !== 'boolean') throw new Error(`${context}.${key} must be a boolean`)
+ return value
+}
+
+function nullableBoolean(
+ record: Record,
+ key: string,
+ context: string
+): boolean | null {
+ const value = record[key]
+ if (value === null) return null
+ if (typeof value !== 'boolean') throw new Error(`${context}.${key} must be a boolean or null`)
+ return value
+}
+
+function parsePullRequestUser(value: unknown, context: string): GitHubPullRequestUser {
+ if (!isRecord(value)) throw new Error(`${context} must be an object`)
+
+ return {
+ login: requiredString(value, 'login', context),
+ id: requiredNumber(value, 'id', context),
+ avatar_url: requiredString(value, 'avatar_url', context),
+ html_url: requiredString(value, 'html_url', context),
+ type: requiredString(value, 'type', context),
+ }
+}
+
+function parseNullablePullRequestUser(
+ value: unknown,
+ context: string
+): GitHubPullRequestUser | null {
+ if (value === null) return null
+ return parsePullRequestUser(value, context)
+}
+
+function parsePullRequestBranch(value: unknown, context: string): GitHubPullRequestBranch {
+ if (!isRecord(value)) throw new Error(`${context} must be an object`)
+
+ return {
+ label: requiredString(value, 'label', context),
+ ref: requiredString(value, 'ref', context),
+ sha: requiredString(value, 'sha', context),
+ }
+}
+
+function parsePullRequest(value: unknown): GitHubPullRequest {
+ if (!isRecord(value)) throw new Error('GitHub pull request response must be an object')
+
+ return {
+ id: requiredNumber(value, 'id', 'pull_request'),
+ number: requiredNumber(value, 'number', 'pull_request'),
+ title: requiredString(value, 'title', 'pull_request'),
+ state: requiredString(value, 'state', 'pull_request'),
+ html_url: requiredString(value, 'html_url', 'pull_request'),
+ diff_url: requiredString(value, 'diff_url', 'pull_request'),
+ body: nullableString(value, 'body', 'pull_request'),
+ user: parsePullRequestUser(value.user, 'pull_request.user'),
+ head: parsePullRequestBranch(value.head, 'pull_request.head'),
+ base: parsePullRequestBranch(value.base, 'pull_request.base'),
+ merged: requiredBoolean(value, 'merged', 'pull_request'),
+ mergeable: nullableBoolean(value, 'mergeable', 'pull_request'),
+ merged_by: parseNullablePullRequestUser(value.merged_by, 'pull_request.merged_by'),
+ comments: requiredNumber(value, 'comments', 'pull_request'),
+ review_comments: requiredNumber(value, 'review_comments', 'pull_request'),
+ commits: requiredNumber(value, 'commits', 'pull_request'),
+ additions: requiredNumber(value, 'additions', 'pull_request'),
+ deletions: requiredNumber(value, 'deletions', 'pull_request'),
+ changed_files: requiredNumber(value, 'changed_files', 'pull_request'),
+ created_at: requiredString(value, 'created_at', 'pull_request'),
+ updated_at: requiredString(value, 'updated_at', 'pull_request'),
+ closed_at: nullableString(value, 'closed_at', 'pull_request'),
+ merged_at: nullableString(value, 'merged_at', 'pull_request'),
+ }
+}
+
+function parsePullRequestFile(value: unknown, index: number): GitHubPullRequestFile {
+ const context = `pull_request_files[${index}]`
+ if (!isRecord(value)) throw new Error(`${context} must be an object`)
+
+ const patch = optionalString(value, 'patch', context)
+ const previousFilename = optionalString(value, 'previous_filename', context)
+
+ return {
+ sha: requiredString(value, 'sha', context),
+ filename: requiredString(value, 'filename', context),
+ status: requiredString(value, 'status', context),
+ additions: requiredNumber(value, 'additions', context),
+ deletions: requiredNumber(value, 'deletions', context),
+ changes: requiredNumber(value, 'changes', context),
+ blob_url: requiredString(value, 'blob_url', context),
+ raw_url: requiredString(value, 'raw_url', context),
+ contents_url: requiredString(value, 'contents_url', context),
+ ...(patch === undefined ? {} : { patch }),
+ ...(previousFilename === undefined ? {} : { previous_filename: previousFilename }),
+ }
+}
+
+function parsePullRequestFiles(value: unknown): GitHubPullRequestFile[] {
+ if (!Array.isArray(value)) throw new Error('GitHub pull request files response must be an array')
+ return value.map(parsePullRequestFile)
+}
+
+async function parsePullRequestResponse(response: Response): Promise {
+ if (!response.ok) {
+ throw new Error(
+ (await readGitHubErrorMessage(response)) ??
+ `Failed to fetch pull request (HTTP ${response.status})`
+ )
+ }
+
+ const value: unknown = await response.json()
+ return parsePullRequest(value)
+}
+
+async function fetchPullRequestFiles(
+ params: PROperationParams,
+ pullNumber: number
+): Promise {
+ const files: GitHubPullRequestFile[] = []
+ const maxPages = MAX_PULL_REQUEST_FILES / PULL_REQUEST_FILES_PER_PAGE
+
+ for (let page = 1; page <= maxPages; page += 1) {
+ const response = await fetch(
+ `https://api.github.com/repos/${params.owner}/${params.repo}/pulls/${pullNumber}/files?per_page=${PULL_REQUEST_FILES_PER_PAGE}&page=${page}`,
+ {
+ headers: {
+ Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${params.apiKey}`,
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ }
+ )
+
+ if (!response.ok) {
+ return {
+ success: false,
+ error:
+ (await readGitHubErrorMessage(response)) ??
+ `Failed to fetch PR files (HTTP ${response.status})`,
+ }
+ }
+
+ const value: unknown = await response.json()
+ const pageFiles = parsePullRequestFiles(value)
+ if (pageFiles.length > PULL_REQUEST_FILES_PER_PAGE) {
+ throw new Error(
+ `GitHub returned more than ${PULL_REQUEST_FILES_PER_PAGE} pull request files in one page`
+ )
+ }
+ files.push(...pageFiles)
+ if (pageFiles.length < PULL_REQUEST_FILES_PER_PAGE) break
+ }
+
+ return { success: true, files }
+}
+
+function requireParams(params: T | undefined): T {
+ if (!params) throw new Error('GitHub PR reader parameters are required')
+ return params
+}
+
+const PR_PARAMS = {
+ owner: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Repository owner',
+ },
+ repo: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Repository name',
+ },
+ pullNumber: {
+ type: 'number',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Pull request number',
+ },
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'GitHub API token',
+ },
+} satisfies ToolConfig['params']
+
export const prTool: ToolConfig = {
id: 'github_pr',
name: 'GitHub PR Reader',
description: 'Fetch PR details including diff and files changed',
version: '1.0.0',
- params: {
- owner: {
- type: 'string',
- required: true,
- visibility: 'user-or-llm',
- description: 'Repository owner',
- },
- repo: {
- type: 'string',
- required: true,
- visibility: 'user-or-llm',
- description: 'Repository name',
- },
- pullNumber: {
- type: 'number',
- required: true,
- visibility: 'user-or-llm',
- description: 'Pull request number',
- },
- apiKey: {
- type: 'string',
- required: true,
- visibility: 'user-only',
- description: 'GitHub API token',
- },
- },
+ params: PR_PARAMS,
request: {
url: (params) =>
@@ -46,24 +237,14 @@ export const prTool: ToolConfig = {
},
transformResponse: async (response, params) => {
- const pr = await response.json()
+ const requestParams = requireParams(params)
+ const pr = await parsePullRequestResponse(response)
+ const filesResult = await fetchPullRequestFiles(requestParams, pr.number)
- const filesResponse = await fetch(
- `https://api.github.com/repos/${pr.base.repo.owner.login}/${pr.base.repo.name}/pulls/${pr.number}/files`,
- {
- headers: {
- Accept: 'application/vnd.github+json',
- Authorization: `Bearer ${params?.apiKey}`,
- 'X-GitHub-Api-Version': '2022-11-28',
- },
- }
- )
-
- if (!filesResponse.ok) {
- const error = await filesResponse.json().catch(() => ({}))
+ if (!filesResult.success) {
return {
success: false,
- error: error.message || `Failed to fetch PR files (HTTP ${filesResponse.status})`,
+ error: filesResult.error,
output: {
content: '',
metadata: {
@@ -80,12 +261,9 @@ export const prTool: ToolConfig = {
}
}
- const filesJson = await filesResponse.json()
- const files = Array.isArray(filesJson) ? filesJson : []
-
const content = `PR #${pr.number}: "${pr.title}" (${pr.state}) - Created: ${pr.created_at}, Updated: ${pr.updated_at}
Description: ${pr.body || 'No description'}
-Files changed: ${files.length}
+Files changed: ${filesResult.files.length}
URL: ${pr.html_url}`
return {
@@ -100,7 +278,7 @@ URL: ${pr.html_url}`
diff_url: pr.diff_url,
created_at: pr.created_at,
updated_at: pr.updated_at,
- files: files.map((file: any) => ({
+ files: filesResult.files.map((file) => ({
filename: file.filename,
additions: file.additions,
deletions: file.deletions,
@@ -138,7 +316,7 @@ URL: ${pr.html_url}`
additions: { type: 'number', description: 'Lines added' },
deletions: { type: 'number', description: 'Lines deleted' },
changes: { type: 'number', description: 'Total changes' },
- patch: { type: 'string', description: 'File diff patch' },
+ patch: { type: 'string', description: 'File diff patch', optional: true },
blob_url: { type: 'string', description: 'GitHub blob URL' },
raw_url: { type: 'string', description: 'Raw file URL' },
status: { type: 'string', description: 'Change type (added/modified/deleted)' },
@@ -150,92 +328,52 @@ URL: ${pr.html_url}`
},
}
-export const prV2Tool: ToolConfig = {
+export const prV2Tool: ToolConfig = {
id: 'github_pr_v2',
name: prTool.name,
description: prTool.description,
version: '2.0.0',
- params: prTool.params,
+ params: {
+ ...PR_PARAMS,
+ includeFiles: {
+ type: 'boolean',
+ required: false,
+ default: true,
+ visibility: 'user-or-llm',
+ description: 'Whether to fetch changed-file details from the separate files endpoint',
+ },
+ },
request: prTool.request,
transformResponse: async (response: Response, params) => {
- const pr = await response.json()
+ const requestParams = requireParams(params)
+ const pr = await parsePullRequestResponse(response)
- const filesResponse = await fetch(
- `https://api.github.com/repos/${pr.base.repo.owner.login}/${pr.base.repo.name}/pulls/${pr.number}/files`,
- {
- headers: {
- Accept: 'application/vnd.github+json',
- Authorization: `Bearer ${params?.apiKey}`,
- 'X-GitHub-Api-Version': '2022-11-28',
- },
+ if (requestParams.includeFiles !== false) {
+ const filesResult = await fetchPullRequestFiles(requestParams, pr.number)
+ if (!filesResult.success) {
+ return {
+ success: false,
+ error: filesResult.error,
+ output: {
+ ...pr,
+ },
+ }
}
- )
- if (!filesResponse.ok) {
- const error = await filesResponse.json().catch(() => ({}))
return {
- success: false,
- error: error.message || `Failed to fetch PR files (HTTP ${filesResponse.status})`,
+ success: true,
output: {
- id: pr.id,
- number: pr.number,
- title: pr.title,
- state: pr.state,
- html_url: pr.html_url,
- diff_url: pr.diff_url,
- body: pr.body ?? null,
- user: pr.user,
- head: pr.head,
- base: pr.base,
- merged: pr.merged,
- mergeable: pr.mergeable ?? null,
- merged_by: pr.merged_by ?? null,
- comments: pr.comments,
- review_comments: pr.review_comments,
- commits: pr.commits,
- additions: pr.additions,
- deletions: pr.deletions,
- changed_files: pr.changed_files,
- created_at: pr.created_at,
- updated_at: pr.updated_at,
- closed_at: pr.closed_at ?? null,
- merged_at: pr.merged_at ?? null,
- files: [],
+ ...pr,
+ files: filesResult.files,
},
}
}
- const filesJson = await filesResponse.json()
- const files = Array.isArray(filesJson) ? filesJson : []
-
return {
success: true,
output: {
- id: pr.id,
- number: pr.number,
- title: pr.title,
- state: pr.state,
- html_url: pr.html_url,
- diff_url: pr.diff_url,
- body: pr.body ?? null,
- user: pr.user,
- head: pr.head,
- base: pr.base,
- merged: pr.merged,
- mergeable: pr.mergeable ?? null,
- merged_by: pr.merged_by ?? null,
- comments: pr.comments,
- review_comments: pr.review_comments,
- commits: pr.commits,
- additions: pr.additions,
- deletions: pr.deletions,
- changed_files: pr.changed_files,
- created_at: pr.created_at,
- updated_at: pr.updated_at,
- closed_at: pr.closed_at ?? null,
- merged_at: pr.merged_at ?? null,
- files: files ?? [],
+ ...pr,
},
}
},
@@ -247,13 +385,13 @@ export const prV2Tool: ToolConfig = {
state: { type: 'string', description: 'PR state (open/closed)' },
html_url: { type: 'string', description: 'GitHub web URL' },
diff_url: { type: 'string', description: 'Raw diff URL' },
- body: { type: 'string', description: 'PR description' },
+ body: { type: 'string', description: 'PR description', nullable: true },
user: USER_OUTPUT,
head: BRANCH_REF_OUTPUT,
base: BRANCH_REF_OUTPUT,
merged: { type: 'boolean', description: 'Whether PR is merged' },
- mergeable: { type: 'boolean', description: 'Whether PR is mergeable' },
- merged_by: USER_OUTPUT,
+ mergeable: { type: 'boolean', description: 'Whether PR is mergeable', nullable: true },
+ merged_by: { ...USER_OUTPUT, nullable: true },
comments: { type: 'number', description: 'Number of comments' },
review_comments: { type: 'number', description: 'Number of review comments' },
commits: { type: 'number', description: 'Number of commits' },
@@ -262,11 +400,12 @@ export const prV2Tool: ToolConfig = {
changed_files: { type: 'number', description: 'Number of changed files' },
created_at: { type: 'string', description: 'Creation timestamp' },
updated_at: { type: 'string', description: 'Last update timestamp' },
- closed_at: { type: 'string', description: 'Close timestamp' },
- merged_at: { type: 'string', description: 'Merge timestamp' },
+ closed_at: { type: 'string', description: 'Close timestamp', nullable: true },
+ merged_at: { type: 'string', description: 'Merge timestamp', nullable: true },
files: {
type: 'array',
description: 'Array of changed file objects',
+ optional: true,
items: {
type: 'object',
properties: PR_FILE_OUTPUT_PROPERTIES,
diff --git a/apps/sim/tools/github/response-parsers.ts b/apps/sim/tools/github/response-parsers.ts
new file mode 100644
index 00000000000..b790de3c770
--- /dev/null
+++ b/apps/sim/tools/github/response-parsers.ts
@@ -0,0 +1,118 @@
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+export function requiredString(
+ record: Record,
+ key: string,
+ context: string
+): string {
+ const value = record[key]
+ if (typeof value !== 'string') throw new Error(`${context}.${key} must be a string`)
+ return value
+}
+
+export function requiredNonEmptyString(
+ record: Record,
+ key: string,
+ context: string
+): string {
+ const value = record[key]
+ if (typeof value !== 'string' || !value) {
+ throw new Error(`${context}.${key} must be a non-empty string`)
+ }
+ return value
+}
+
+export function requiredTrimmedString(
+ record: Record,
+ key: string,
+ context: string
+): string {
+ const value = record[key]
+ if (typeof value !== 'string' || !value.trim()) {
+ throw new Error(`${context}.${key} must be a non-blank string`)
+ }
+ return value.trim()
+}
+
+export function optionalString(
+ record: Record,
+ key: string,
+ context: string
+): string | undefined {
+ const value = record[key]
+ if (value === undefined) return undefined
+ if (typeof value !== 'string') throw new Error(`${context}.${key} must be a string`)
+ return value
+}
+
+export function optionalNonEmptyString(
+ record: Record,
+ key: string,
+ context: string
+): string | undefined {
+ const value = record[key]
+ if (value === undefined) return undefined
+ if (typeof value !== 'string' || !value) {
+ throw new Error(`${context}.${key} must be a non-empty string when present`)
+ }
+ return value
+}
+
+export function nullableString(
+ record: Record,
+ key: string,
+ context: string
+): string | null {
+ const value = record[key]
+ if (value === null) return null
+ if (typeof value !== 'string') throw new Error(`${context}.${key} must be a string or null`)
+ return value
+}
+
+export function nullableNonEmptyString(
+ record: Record,
+ key: string,
+ context: string
+): string | null {
+ const value = record[key]
+ if (value === null) return null
+ if (typeof value !== 'string' || !value) {
+ throw new Error(`${context}.${key} must be a non-empty string or null`)
+ }
+ return value
+}
+
+export function requiredNumber(
+ record: Record,
+ key: string,
+ context: string
+): number {
+ const value = record[key]
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
+ throw new Error(`${context}.${key} must be a non-negative safe integer`)
+ }
+ return value
+}
+
+export function requiredRecord(
+ record: Record,
+ key: string,
+ context: string
+): Record {
+ const value = record[key]
+ if (!isRecord(value)) throw new Error(`${context}.${key} must be an object`)
+ return value
+}
+
+export async function readGitHubErrorMessage(response: Response): Promise {
+ try {
+ const value: unknown = await response.json()
+ if (!isRecord(value)) return undefined
+ const message = value.message
+ return typeof message === 'string' && message.trim() ? message : undefined
+ } catch {
+ return undefined
+ }
+}
diff --git a/apps/sim/tools/github/review-schema.test.ts b/apps/sim/tools/github/review-schema.test.ts
new file mode 100644
index 00000000000..a5668d5ddef
--- /dev/null
+++ b/apps/sim/tools/github/review-schema.test.ts
@@ -0,0 +1,148 @@
+/**
+ * @vitest-environment node
+ */
+
+import { Value } from 'typebox/value'
+import { describe, expect, it } from 'vitest'
+import {
+ parseReviewComments,
+ parseReviewFindings,
+ REVIEW_BODY_MAX_LENGTH,
+ REVIEW_COMMENT_MAX_COUNT,
+ reviewFindingsSchema,
+} from '@/tools/github/review-schema'
+
+describe('GitHub review schema', () => {
+ it('accepts summary-only and valid multiline findings', () => {
+ expect(parseReviewFindings({ body: 'Summary' })).toEqual({ body: 'Summary', comments: [] })
+ expect(
+ parseReviewFindings({
+ body: ' Review summary ',
+ comments: [
+ {
+ path: 'src/a.ts',
+ body: ' Tighten this branch ',
+ line: 12,
+ side: 'RIGHT',
+ start_line: 10,
+ start_side: 'RIGHT',
+ },
+ ],
+ })
+ ).toEqual({
+ body: 'Review summary',
+ comments: [
+ {
+ path: 'src/a.ts',
+ body: 'Tighten this branch',
+ line: 12,
+ side: 'RIGHT',
+ start_line: 10,
+ start_side: 'RIGHT',
+ },
+ ],
+ })
+ })
+
+ it.each([
+ { body: '' },
+ { body: ' ' },
+ { body: 'x', comments: null },
+ { body: 'x', extra: true },
+ { body: 'x'.repeat(REVIEW_BODY_MAX_LENGTH + 1) },
+ {
+ body: 'x',
+ comments: Array.from({ length: REVIEW_COMMENT_MAX_COUNT + 1 }, () => ({
+ path: 'a.ts',
+ body: 'x',
+ line: 1,
+ side: 'RIGHT',
+ })),
+ },
+ ])('rejects malformed findings %#', (value) => {
+ expect(() => parseReviewFindings(value)).toThrow()
+ })
+
+ it.each(['12', 0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
+ 'rejects the invalid line value %s when called with unnormalized input',
+ (line) => {
+ expect(() => parseReviewComments([{ path: 'a.ts', body: 'x', line, side: 'RIGHT' }])).toThrow(
+ /comments is invalid/
+ )
+ }
+ )
+
+ it('normalizes numeric strings on the Pi TypeBox validation path', () => {
+ const findings = {
+ body: 'Summary',
+ comments: [{ path: 'a.ts', body: 'Finding', line: '12', side: 'RIGHT' }],
+ }
+
+ Value.Convert(reviewFindingsSchema, findings)
+
+ expect(parseReviewFindings(findings)).toEqual({
+ body: 'Summary',
+ comments: [{ path: 'a.ts', body: 'Finding', line: 12, side: 'RIGHT' }],
+ })
+ })
+
+ it('requires explicit sides and complete, ordered multiline coordinates', () => {
+ expect(() => parseReviewComments([{ path: 'a.ts', body: 'x', line: 2 }])).toThrow(/side/)
+ expect(() =>
+ parseReviewComments([{ path: 'a.ts', body: 'x', line: 3, side: 'RIGHT', start_line: 1 }])
+ ).toThrow(/comments is invalid/)
+ expect(() =>
+ parseReviewComments([
+ {
+ path: 'a.ts',
+ body: 'x',
+ line: 3,
+ side: 'RIGHT',
+ start_side: 'RIGHT',
+ },
+ ])
+ ).toThrow(/comments is invalid/)
+ expect(() =>
+ parseReviewComments([
+ {
+ path: 'a.ts',
+ body: 'x',
+ line: 3,
+ side: 'RIGHT',
+ start_line: 3,
+ start_side: 'RIGHT',
+ },
+ ])
+ ).toThrow(/must be less than/)
+ })
+
+ it('rejects blank fields and unknown comment properties', () => {
+ expect(() => parseReviewComments([{ path: ' ', body: 'x', line: 1, side: 'RIGHT' }])).toThrow(
+ /leading or trailing whitespace/
+ )
+ expect(() =>
+ parseReviewComments([{ path: 'a.ts', body: ' ', line: 1, side: 'RIGHT' }])
+ ).toThrow(/body must not be blank/)
+ expect(() =>
+ parseReviewComments([{ path: 'a.ts', body: 'x', line: 1, side: 'RIGHT', position: 4 }])
+ ).toThrow(/additional properties/)
+ })
+
+ it('requires canonical paths and keeps multiline ranges on one side', () => {
+ expect(() =>
+ parseReviewComments([{ path: './a.ts', body: 'x', line: 1, side: 'RIGHT' }])
+ ).toThrow(/canonical repository-relative path/)
+ expect(() =>
+ parseReviewComments([
+ {
+ path: 'a.ts',
+ body: 'x',
+ line: 3,
+ side: 'RIGHT',
+ start_line: 1,
+ start_side: 'LEFT',
+ },
+ ])
+ ).toThrow(/must stay on one diff side/)
+ })
+})
diff --git a/apps/sim/tools/github/review-schema.ts b/apps/sim/tools/github/review-schema.ts
new file mode 100644
index 00000000000..bb431936a83
--- /dev/null
+++ b/apps/sim/tools/github/review-schema.ts
@@ -0,0 +1,138 @@
+import { type Static, type TSchema, Type } from 'typebox'
+import { Check, Errors } from 'typebox/schema'
+
+export const REVIEW_BODY_MAX_LENGTH = 65_000
+export const REVIEW_COMMENT_MAX_COUNT = 50
+const REVIEW_COMMENT_BODY_MAX_LENGTH = 10_000
+
+const reviewSideSchema = Type.Union([Type.Literal('LEFT'), Type.Literal('RIGHT')])
+
+const reviewCommentFields = {
+ path: Type.String({
+ minLength: 1,
+ maxLength: 4_096,
+ pattern: '^[^\\u0000-\\u001F\\u007F]+$',
+ description: 'Exact, canonical repository-relative path from the pull request diff',
+ }),
+ body: Type.String({
+ minLength: 1,
+ maxLength: REVIEW_COMMENT_BODY_MAX_LENGTH,
+ description: 'Specific, actionable inline review comment',
+ }),
+ line: Type.Integer({ minimum: 1, description: 'Line number in the selected side of the diff' }),
+ side: reviewSideSchema,
+}
+
+const singleLineReviewCommentSchema = Type.Object(reviewCommentFields, {
+ additionalProperties: false,
+})
+
+const multilineReviewCommentSchema = Type.Object(
+ {
+ ...reviewCommentFields,
+ start_line: Type.Integer({ minimum: 1, description: 'First line of a multiline comment' }),
+ start_side: reviewSideSchema,
+ },
+ { additionalProperties: false }
+)
+
+export const reviewCommentSchema = Type.Union([
+ singleLineReviewCommentSchema,
+ multilineReviewCommentSchema,
+])
+
+const reviewCommentsSchema = Type.Array(reviewCommentSchema, {
+ maxItems: REVIEW_COMMENT_MAX_COUNT,
+ description: 'Optional inline comments; omit this field or use an empty array when none',
+})
+
+/**
+ * Shared internal contract for pull request review submissions. This schema
+ * family is intentionally not registered as a separate GitHub block tool: the
+ * existing Create PR review operation and Pi's private `submit_review` tool
+ * reuse it so both paths enforce the same comment shape and coordinate rules.
+ */
+export const reviewFindingsSchema = Type.Object(
+ {
+ body: Type.String({
+ minLength: 1,
+ maxLength: REVIEW_BODY_MAX_LENGTH,
+ description: 'Markdown summary for the pull request review',
+ }),
+ comments: Type.Optional(reviewCommentsSchema),
+ },
+ { additionalProperties: false }
+)
+
+export type ReviewComment = Static
+
+export interface ReviewFindings {
+ body: string
+ comments: ReviewComment[]
+}
+
+function validationDetails(schema: TSchema, value: unknown): string {
+ const [, errors] = Errors(schema, value)
+ return errors
+ .slice(0, 3)
+ .map((error) => `${error.instancePath || '/'} ${error.message}`)
+ .join('; ')
+}
+
+function validateReviewPath(path: string, index: number): string {
+ if (path !== path.trim()) {
+ throw new Error(`comments[${index}].path must not have leading or trailing whitespace`)
+ }
+ const segments = path.split('/')
+ if (
+ path === '.' ||
+ path.startsWith('/') ||
+ segments.some((segment) => segment === '' || segment === '.' || segment === '..')
+ ) {
+ throw new Error(`comments[${index}].path must be a canonical repository-relative path`)
+ }
+ return path
+}
+
+/** Strictly validates inline comments after the caller's TypeBox normalization step. */
+export function parseReviewComments(value: unknown): ReviewComment[] {
+ if (value === undefined) return []
+
+ if (!Check(reviewCommentsSchema, value)) {
+ const details = validationDetails(reviewCommentsSchema, value)
+ throw new Error(`comments is invalid${details ? `: ${details}` : ''}`)
+ }
+
+ return value.map((comment, index) => {
+ const path = validateReviewPath(comment.path, index)
+ const body = comment.body.trim()
+ if (!body) throw new Error(`comments[${index}].body must not be blank`)
+
+ if ('start_line' in comment) {
+ if (comment.start_side !== comment.side) {
+ throw new Error(`comments[${index}] multiline range must stay on one diff side`)
+ }
+ if (comment.start_line >= comment.line) {
+ throw new Error(`comments[${index}].start_line must be less than comments[${index}].line`)
+ }
+ }
+
+ return { ...comment, path, body }
+ })
+}
+
+/** Strictly validates a complete, normalized agent review submission. */
+export function parseReviewFindings(value: unknown): ReviewFindings {
+ if (!Check(reviewFindingsSchema, value)) {
+ const details = validationDetails(reviewFindingsSchema, value)
+ throw new Error(`Review findings is invalid${details ? `: ${details}` : ''}`)
+ }
+
+ const body = value.body.trim()
+ if (!body) throw new Error('Review findings body must not be blank')
+
+ return {
+ body,
+ comments: parseReviewComments(value.comments),
+ }
+}
diff --git a/apps/sim/tools/github/types.ts b/apps/sim/tools/github/types.ts
index 2693ecdf92c..8fffa0b1929 100644
--- a/apps/sim/tools/github/types.ts
+++ b/apps/sim/tools/github/types.ts
@@ -1,3 +1,4 @@
+import type { ReviewComment } from '@/tools/github/review-schema'
import type { OutputProperty, ToolFileData, ToolResponse } from '@/tools/types'
/**
@@ -893,6 +894,12 @@ export interface PROperationParams extends BaseGitHubParams {
pullNumber: number
}
+/** Parameters accepted only by the V2 PR reader. */
+export interface PRV2OperationParams extends PROperationParams {
+ /** Whether to fetch the separate PR files endpoint. Defaults to true. */
+ includeFiles?: boolean
+}
+
// Comment operation parameters
export interface CreateCommentParams extends PROperationParams {
body: string
@@ -965,12 +972,17 @@ export interface RequestReviewersParams extends BaseGitHubParams {
team_reviewers?: string
}
+/** Inline review comment attached to a submitted PR review. */
+export type CreatePRReviewComment = ReviewComment
+
// Create PR review parameters
export interface CreatePRReviewParams extends BaseGitHubParams {
pullNumber: number
event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'
body?: string
commit_id?: string
+ /** Inline line comments submitted atomically with the review. */
+ comments?: CreatePRReviewComment[]
}
// Response metadata interfaces
@@ -1010,6 +1022,65 @@ interface PRCommentsMetadata {
}>
}
+/** GitHub user fields returned by the V2 PR reader. */
+export interface GitHubPullRequestUser {
+ login: string
+ id: number
+ avatar_url: string
+ html_url: string
+ type: string
+}
+
+/** GitHub branch reference fields returned by the V2 PR reader. */
+export interface GitHubPullRequestBranch {
+ label: string
+ ref: string
+ sha: string
+}
+
+/** Changed-file fields returned by the GitHub pull request files endpoint. */
+export interface GitHubPullRequestFile {
+ sha: string
+ filename: string
+ status: string
+ additions: number
+ deletions: number
+ changes: number
+ blob_url: string
+ raw_url: string
+ contents_url: string
+ patch?: string
+ previous_filename?: string
+}
+
+/** Parsed V2 pull request payload exposed as the tool output. */
+export interface GitHubPullRequestV2Output {
+ id: number
+ number: number
+ title: string
+ state: string
+ html_url: string
+ diff_url: string
+ body: string | null
+ user: GitHubPullRequestUser
+ head: GitHubPullRequestBranch
+ base: GitHubPullRequestBranch
+ merged: boolean
+ mergeable: boolean | null
+ merged_by: GitHubPullRequestUser | null
+ comments: number
+ review_comments: number
+ commits: number
+ additions: number
+ deletions: number
+ changed_files: number
+ created_at: string
+ updated_at: string
+ closed_at: string | null
+ merged_at: string | null
+ files?: GitHubPullRequestFile[]
+}
+
interface CommentMetadata {
id: number
html_url: string
@@ -1130,6 +1201,11 @@ export interface PullRequestResponse extends ToolResponse {
}
}
+/** Structured response returned by the V2 PR reader. */
+export interface PullRequestV2Response extends ToolResponse {
+ output: GitHubPullRequestV2Output
+}
+
export interface CreateCommentResponse extends ToolResponse {
output: {
content: string
@@ -1618,7 +1694,7 @@ export interface PRReviewResponse extends ToolResponse {
state: string
body: string
html_url: string
- commit_id: string
+ commit_id: string | null
}
}
}
@@ -1656,6 +1732,7 @@ export interface ReadmeResponse extends ToolResponse {
export type GitHubResponse =
| PullRequestResponse
+ | PullRequestV2Response
| PRReviewResponse
| TagsListResponse
| ReadmeResponse
diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts
index 26e6d67abfa..ba596692f20 100644
--- a/apps/sim/tools/params.ts
+++ b/apps/sim/tools/params.ts
@@ -18,7 +18,12 @@ import type {
} from '@/blocks/types'
import { safeAssign } from '@/tools/safe-assign'
import { isEmptyTagValue } from '@/tools/shared/tags'
-import type { OAuthConfig, ParameterVisibility, ToolConfig } from '@/tools/types'
+import type {
+ OAuthConfig,
+ ParameterVisibility,
+ ToolConfig,
+ ToolParameterItemSchema,
+} from '@/tools/types'
import { getTool } from '@/tools/utils'
const logger = createLogger('ToolsParams')
@@ -115,8 +120,8 @@ type ToolInputBlockConfig = Pick
interface SchemaProperty {
type: string
- description: string
- items?: Record
+ description?: string
+ items?: ToolParameterItemSchema
properties?: Record
required?: string[]
}
@@ -652,7 +657,7 @@ export async function createLLMToolSchema(
* Apply dynamic schema enrichment for workflow_executor's inputMapping parameter
*/
async function applyDynamicSchemaForWorkflow(
- propertySchema: any,
+ propertySchema: SchemaProperty,
workflowId: string
): Promise {
try {
@@ -716,7 +721,7 @@ export function createExecutionToolSchema(toolConfig: ToolConfig): ToolSchema {
}
Object.entries(toolConfig.params).forEach(([paramId, param]) => {
- const propertySchema: any = {
+ const propertySchema: SchemaProperty = {
type: param.type === 'json' ? 'object' : param.type,
description: param.description || '',
}
diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts
index 261ac103a50..887b1a0f96a 100644
--- a/apps/sim/tools/types.ts
+++ b/apps/sim/tools/types.ts
@@ -76,6 +76,13 @@ export interface OutputProperty {
}
}
+export interface ToolOutputProperty extends OutputProperty {
+ fileConfig?: {
+ mimeType?: string
+ extension?: string
+ }
+}
+
export type ParameterVisibility =
| 'user-or-llm' // User can provide OR LLM must generate
| 'user-only' // Only user can provide (required/optional determined by required field)
@@ -110,6 +117,22 @@ export interface ToolRetryConfig {
retryIdempotentOnly?: boolean
}
+/** JSON Schema subset supported for array item definitions in tool parameters. */
+export interface ToolParameterItemSchema {
+ readonly type?: string
+ readonly description?: string
+ readonly const?: string | number | boolean
+ readonly minimum?: number
+ readonly maximum?: number
+ readonly minLength?: number
+ readonly maxLength?: number
+ readonly pattern?: string
+ readonly additionalProperties?: boolean
+ readonly required?: readonly string[]
+ readonly properties?: Readonly>
+ readonly anyOf?: readonly ToolParameterItemSchema[]
+}
+
export interface ToolConfig {
// Basic tool identification
id: string
@@ -126,32 +149,11 @@ export interface ToolConfig
{
visibility?: ParameterVisibility
default?: any
description?: string
- items?: {
- type: string
- description?: string
- properties?: Record
- }
+ items?: ToolParameterItemSchema
}
>
// Output schema - what this tool produces
- outputs?: Record<
- string,
- {
- type: OutputType
- description?: string
- optional?: boolean
- fileConfig?: {
- mimeType?: string // Expected MIME type for file outputs
- extension?: string // Expected file extension
- }
- items?: {
- type: OutputType
- description?: string
- properties?: Record
- }
- properties?: Record
- }
- >
+ outputs?: Record
// OAuth configuration for this tool (if it requires authentication)
oauth?: OAuthConfig
diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts
index e71ab20d731..470884e3dce 100644
--- a/apps/sim/trigger.config.ts
+++ b/apps/sim/trigger.config.ts
@@ -60,7 +60,12 @@ export default defineConfig({
dirs: ['./background'],
...(grafanaTelemetry ? { telemetry: grafanaTelemetry } : {}),
build: {
- external: ['isolated-vm', '@earendil-works/pi-coding-agent', 'cpu-features'],
+ external: [
+ 'isolated-vm',
+ '@earendil-works/pi-ai',
+ '@earendil-works/pi-coding-agent',
+ 'cpu-features',
+ ],
extensions: [
syncEnvVars(() => [{ name: 'DB_APP_NAME', value: 'sim-trigger' }]),
additionalFiles({
@@ -77,6 +82,7 @@ export default defineConfig({
'isolated-vm',
'react-dom',
'@react-email/render',
+ '@earendil-works/pi-ai',
'@earendil-works/pi-coding-agent',
],
}),
diff --git a/bun.lock b/bun.lock
index f3ad75a78dd..18b739ea299 100644
--- a/bun.lock
+++ b/bun.lock
@@ -133,7 +133,8 @@
"@calcom/embed-react": "1.5.3",
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
"@e2b/code-interpreter": "^2.0.0",
- "@earendil-works/pi-coding-agent": "0.79.4",
+ "@earendil-works/pi-ai": "0.80.10",
+ "@earendil-works/pi-coding-agent": "0.80.10",
"@floating-ui/dom": "1.7.6",
"@google-cloud/storage": "7.21.0",
"@google/genai": "1.34.0",
@@ -281,6 +282,7 @@
"three": "0.177.0",
"tldts": "7.0.30",
"twilio": "5.9.0",
+ "typebox": "1.1.38",
"undici": "7.28.0",
"unpdf": "1.4.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
@@ -949,13 +951,13 @@
"@e2b/code-interpreter": ["@e2b/code-interpreter@2.6.0", "", { "dependencies": { "e2b": "^2.28.0" } }, "sha512-Xp3pajVf2LQ2rcXZynE/jYfZw4yyKTZM/LkVPB2vSqVft87GxqEUFDfWxssb811B4571uAMfJxKSHHIa8tMprA=="],
- "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-XKxgdjhcPuyjrthCOFSgfzT3xZ1uBrJ1IMVDxci1to6hIN6BIg9J5iY8q0pGXK1DLgATLP23da+1UyZLwA360Q=="],
+ "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg=="],
- "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-9jR23tOl0BIUdQMn70Gr72xYBpM7Xgl9Lyv7gAnU1USfkNRuYG/f/edLl+n/Dp/RafDW3JI4DF7y/GhgkORuew=="],
+ "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="],
- "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.4", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.4", "@earendil-works/pi-ai": "^0.79.4", "@earendil-works/pi-tui": "^0.79.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-PthzVzM5m4XH/hrU+2fVjuwuH5M4eMFWbd0NCRScH14XKpwlPc8/Fh6JDz0jQb5kTBT9oQT183YLTHVVulFL9A=="],
+ "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.10", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.10", "@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-tui": "^0.80.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg=="],
- "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.10", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-FUVOjDn1DVwM1uHD5MNYboXQrXjIDbSt+BQ3py7nQWCY62tKfxgiM1OBMxTcwRWLfSdZHUPpV0hm1loIdUJnPw=="],
+ "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.10", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-c2JO29PbhKPEQ6fgHQKAl0WhwuFqzWfzspMmP+8B5tpDuP+0mvarRbKKg8gq4b+pQx/QX+6aVS4ko7deoyjQjg=="],
"@electric-sql/client": ["@electric-sql/client@1.0.14", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-LtPAfeMxXRiYS0hyDQ5hue2PjljUiK9stvzsVyVb4nwxWQxfOWTSF42bHTs/o5i3x1T4kAQ7mwHpxa4A+f8X7Q=="],
@@ -3545,7 +3547,7 @@
"prosemirror-view": ["prosemirror-view@1.41.9", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A=="],
- "protobufjs": ["protobufjs@8.0.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w=="],
+ "protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@@ -4221,7 +4223,7 @@
"@earendil-works/pi-coding-agent/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
- "@earendil-works/pi-coding-agent/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
+ "@earendil-works/pi-coding-agent/undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="],
"@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
@@ -4229,8 +4231,6 @@
"@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
- "@grpc/proto-loader/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
-
"@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
@@ -4281,6 +4281,8 @@
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="],
+ "@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@8.0.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w=="],
+
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="],
@@ -4911,8 +4913,6 @@
"@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Mq7TNt/VhlEWiYRLQGpzUWeUxh899UGpjKh7Ru0WVIDIjnE+cTRAn0NYlFQ6bWfsQnKnpCbWJj86HzmcG0qEdg=="],
- "@earendil-works/pi-ai/@google/genai/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
-
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@@ -4961,12 +4961,12 @@
"@google-cloud/storage/google-auth-library/gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
- "@grpc/proto-loader/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
-
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
+ "@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+
"@radix-ui/react-accordion/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
@@ -5355,17 +5355,9 @@
"@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
- "@earendil-works/pi-ai/@google/genai/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
-
"@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
- "@grpc/proto-loader/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
-
- "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
-
- "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
-
- "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
+ "@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@trigger.dev/core/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
@@ -5433,8 +5425,6 @@
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
- "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
-
"rimraf/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -5451,14 +5441,6 @@
"@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
- "@earendil-works/pi-ai/@google/genai/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
-
- "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
-
- "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
-
- "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
-
"@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"lint-staged/listr2/cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -5475,8 +5457,6 @@
"log-update/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
- "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
-
"rimraf/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"rimraf/glob/jackspeak/@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -5485,20 +5465,12 @@
"sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
- "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
-
- "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
-
- "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
-
"lint-staged/listr2/cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"lint-staged/listr2/log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"lint-staged/listr2/log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
- "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
-
"rimraf/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"rimraf/glob/jackspeak/@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
diff --git a/bunfig.toml b/bunfig.toml
index 791be5f6eff..6145009f007 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -7,10 +7,16 @@ minimumReleaseAge = 604800
# dev builds, so every version is structurally younger than any age gate.
# typescript@7.0.2 and @typescript/typescript6@6.0.2 were vetted in #5521; they age
# out of the gate on 2026-07-15 and 2026-07-13 — drop those two entries after that.
+# The exactly pinned Pi 0.80.10 packages were vetted for the cloud-review SDK
+# migration; they age out of the gate on 2026-07-24 — drop these four entries then.
minimumReleaseAgeExcludes = [
"typescript",
"@typescript/typescript6",
"@typescript/native-preview",
+ "@earendil-works/pi-agent-core",
+ "@earendil-works/pi-ai",
+ "@earendil-works/pi-coding-agent",
+ "@earendil-works/pi-tui",
]
[run]