Skip to content

Commit 2c4d209

Browse files
BillLeoutsakosvl346Bill Leoutsakoscursoragenticecrasher321
authored
feat(pi): add code review mode (#5577)
* feat(pi): add Cloud Code Review mode and rename Cloud to Cloud PR Introduce a third Pi mode that reviews an existing GitHub PR in an E2B sandbox and posts a structured review with optional inline comments. Keep the stored cloud id for backward compatibility, extend github_create_pr_review for inline comments, and harden review submission against stale SHAs and invalid comment payloads. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(pi): cleanup code * address comments * address mor * address comments * update --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent d71e348 commit 2c4d209

49 files changed

Lines changed: 5522 additions & 736 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/workflows/blocks/pi.mdx

Lines changed: 70 additions & 40 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/pi.ts

Lines changed: 121 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ interface PiResponse extends ToolResponse {
1717
diff?: string
1818
prUrl?: string
1919
branch?: string
20+
reviewUrl?: string
21+
commentsPosted?: number
2022
tokens?: {
2123
input?: number
2224
output?: number
@@ -36,7 +38,19 @@ interface PiResponse extends ToolResponse {
3638
}
3739

3840
const CLOUD: { field: 'mode'; value: 'cloud' } = { field: 'mode', value: 'cloud' }
41+
const CLOUD_REVIEW: { field: 'mode'; value: 'cloud_review' } = {
42+
field: 'mode',
43+
value: 'cloud_review',
44+
}
45+
const CLOUD_ANY: { field: 'mode'; value: Array<'cloud' | 'cloud_review'> } = {
46+
field: 'mode',
47+
value: ['cloud', 'cloud_review'],
48+
}
3949
const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' }
50+
const AUTHORING_MODES: { field: 'mode'; value: Array<'cloud' | 'local'> } = {
51+
field: 'mode',
52+
value: ['cloud', 'local'],
53+
}
4054
const MEMORY_TYPES = ['conversation', 'sliding_window', 'sliding_window_tokens']
4155

4256
export const PiBlock: BlockConfig<PiResponse> = {
@@ -45,11 +59,12 @@ export const PiBlock: BlockConfig<PiResponse> = {
4559
description: 'Run an autonomous coding agent on a repo',
4660
authMode: AuthMode.ApiKey,
4761
longDescription:
48-
'The Pi Coding Agent runs the Pi harness against a real repository. In Cloud mode it spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a pull request. In Local mode it edits files on your own machine over SSH. Both modes stream progress and reuse your models, skills, and multi-turn memory.',
62+
'The Pi Coding Agent runs the Pi harness against a real repository. Create PR spins up an isolated sandbox, clones a GitHub repo, edits with native shell + git, and opens a pull request. Review Code checks out a pinned PR snapshot with read-only tools and posts a structured review with optional inline comments. Local Dev edits files on your own machine over SSH. Create PR and Local Dev can reuse skills and multi-turn memory; Review Code runs without either because PR contents are untrusted.',
4963
bestPractices: `
50-
- Use Cloud mode for hands-off changes against a GitHub repo where a reviewable PR is the deliverable.
51-
- Use Local mode to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH.
52-
- Cloud mode requires your own provider API key (BYOK); the model key is never injected as a hosted key into the sandbox.
64+
- Use Create PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable.
65+
- Use Review Code to analyze an existing PR and leave summary + inline review comments.
66+
- Use Local Dev to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH.
67+
- Create PR requires your own provider API key because the model runs in the sandbox. Review Code keeps the model key in Sim and can use either BYOK or a hosted key.
5368
`,
5469
category: 'blocks',
5570
integrationType: IntegrationType.AI,
@@ -60,22 +75,29 @@ export const PiBlock: BlockConfig<PiResponse> = {
6075
id: 'mode',
6176
title: 'Mode',
6277
type: 'dropdown',
63-
// Cloud mode runs in an E2B sandbox; only offer it where E2B is enabled.
78+
/** Create PR and Review Code require E2B and stay hidden when it is disabled. */
6479
value: () => (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED')) ? 'cloud' : 'local'),
6580
options: () => {
6681
const options = [
6782
{
68-
label: 'Local',
83+
label: 'Local Dev',
6984
id: 'local',
7085
description: 'Edits files on your own machine over SSH',
7186
},
7287
]
7388
if (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED'))) {
74-
options.unshift({
75-
label: 'Cloud',
76-
id: 'cloud',
77-
description: 'Runs in an isolated sandbox, clones your repo, and opens a PR',
78-
})
89+
options.unshift(
90+
{
91+
label: 'Create PR',
92+
id: 'cloud',
93+
description: 'Runs in an isolated sandbox, clones your repo, and opens a PR',
94+
},
95+
{
96+
label: 'Review Code',
97+
id: 'cloud_review',
98+
description: 'Reviews an existing PR and posts GitHub review comments',
99+
}
100+
)
79101
}
80102
return options
81103
},
@@ -93,7 +115,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
93115
type: 'combobox',
94116
placeholder: 'Type or select a model...',
95117
required: true,
96-
defaultValue: 'claude-sonnet-5',
118+
defaultValue: 'claude-sonnet-4-6',
97119
options: getPiModelOptions,
98120
commandSearchable: true,
99121
},
@@ -106,26 +128,27 @@ export const PiBlock: BlockConfig<PiResponse> = {
106128
type: 'short-input',
107129
placeholder: 'e.g., your-org',
108130
required: true,
109-
condition: CLOUD,
131+
condition: CLOUD_ANY,
110132
},
111133
{
112134
id: 'repo',
113135
title: 'Repository Name',
114136
type: 'short-input',
115137
placeholder: 'e.g., my-repo',
116138
required: true,
117-
condition: CLOUD,
139+
condition: CLOUD_ANY,
118140
},
119141
{
120142
id: 'githubToken',
121143
title: 'GitHub Token',
122144
type: 'short-input',
123145
password: true,
124146
paramVisibility: 'user-only',
125-
placeholder: 'GitHub personal access token (repo scope)',
126-
tooltip: 'Personal access token with repo scope, used to clone, push, and open the PR.',
147+
placeholder: 'GitHub personal access token',
148+
tooltip:
149+
'Personal access token used for GitHub access. Create PR needs clone/push/PR permissions; Review Code needs clone + review permissions.',
127150
required: true,
128-
condition: CLOUD,
151+
condition: CLOUD_ANY,
129152
},
130153
{
131154
id: 'baseBranch',
@@ -167,6 +190,27 @@ export const PiBlock: BlockConfig<PiResponse> = {
167190
mode: 'advanced',
168191
condition: CLOUD,
169192
},
193+
{
194+
id: 'pullNumber',
195+
title: 'Pull Request Number',
196+
type: 'short-input',
197+
placeholder: 'e.g., 42',
198+
required: true,
199+
condition: CLOUD_REVIEW,
200+
},
201+
{
202+
id: 'reviewEvent',
203+
title: 'Review Outcome',
204+
type: 'dropdown',
205+
defaultValue: 'COMMENT',
206+
options: [
207+
{ label: 'Comment', id: 'COMMENT' },
208+
{ label: 'Request changes', id: 'REQUEST_CHANGES' },
209+
],
210+
tooltip:
211+
'How GitHub records the submitted review. Comment is neutral; Request changes marks the pull request as changes requested.',
212+
condition: CLOUD_REVIEW,
213+
},
170214

171215
{
172216
id: 'host',
@@ -275,6 +319,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
275319
type: 'skill-input',
276320
defaultValue: [],
277321
mode: 'advanced',
322+
condition: AUTHORING_MODES,
278323
},
279324
{
280325
id: 'thinkingLevel',
@@ -288,6 +333,8 @@ export const PiBlock: BlockConfig<PiResponse> = {
288333
{ label: 'high', id: 'high' },
289334
{ label: 'max', id: 'max' },
290335
],
336+
tooltip:
337+
"Requested reasoning effort for Pi. Pi clamps it to the selected model's supported levels; models without reasoning run with thinking off. Higher levels usually increase latency and token cost.",
291338
mode: 'advanced',
292339
},
293340
{
@@ -302,15 +349,24 @@ export const PiBlock: BlockConfig<PiResponse> = {
302349
{ label: 'Sliding window (tokens)', id: 'sliding_window_tokens' },
303350
],
304351
mode: 'advanced',
352+
condition: AUTHORING_MODES,
305353
},
306354
{
307355
id: 'conversationId',
308356
title: 'Conversation ID',
309357
type: 'short-input',
310358
placeholder: 'e.g., user-123, session-abc',
311359
mode: 'advanced',
312-
required: { field: 'memoryType', value: MEMORY_TYPES },
313-
condition: { field: 'memoryType', value: MEMORY_TYPES },
360+
required: {
361+
field: 'mode',
362+
value: ['cloud', 'local'],
363+
and: { field: 'memoryType', value: MEMORY_TYPES },
364+
},
365+
condition: {
366+
field: 'mode',
367+
value: ['cloud', 'local'],
368+
and: { field: 'memoryType', value: MEMORY_TYPES },
369+
},
314370
dependsOn: ['memoryType'],
315371
},
316372
{
@@ -319,7 +375,11 @@ export const PiBlock: BlockConfig<PiResponse> = {
319375
type: 'short-input',
320376
placeholder: 'Enter number of messages (e.g., 10)...',
321377
mode: 'advanced',
322-
condition: { field: 'memoryType', value: ['sliding_window'] },
378+
condition: {
379+
field: 'mode',
380+
value: ['cloud', 'local'],
381+
and: { field: 'memoryType', value: ['sliding_window'] },
382+
},
323383
dependsOn: ['memoryType'],
324384
},
325385
{
@@ -328,34 +388,46 @@ export const PiBlock: BlockConfig<PiResponse> = {
328388
type: 'short-input',
329389
placeholder: 'Enter max tokens (e.g., 4000)...',
330390
mode: 'advanced',
331-
condition: { field: 'memoryType', value: ['sliding_window_tokens'] },
391+
condition: {
392+
field: 'mode',
393+
value: ['cloud', 'local'],
394+
and: { field: 'memoryType', value: ['sliding_window_tokens'] },
395+
},
332396
dependsOn: ['memoryType'],
333397
},
334398
],
335399
tools: {
336400
access: [],
337401
},
338402
inputs: {
339-
mode: { type: 'string', description: 'Execution mode: cloud or local' },
403+
mode: {
404+
type: 'string',
405+
description: 'Execution mode: Create PR, Review Code, or Local Dev',
406+
},
340407
task: { type: 'string', description: 'Instruction for the coding agent' },
341408
model: { type: 'string', description: 'AI model to use' },
342-
owner: { type: 'string', description: 'GitHub repository owner (cloud mode)' },
343-
repo: { type: 'string', description: 'GitHub repository name (cloud mode)' },
344-
githubToken: { type: 'string', description: 'GitHub token override (cloud mode)' },
345-
baseBranch: { type: 'string', description: 'Base branch for the PR (cloud mode)' },
346-
branchName: { type: 'string', description: 'Branch to create (cloud mode)' },
347-
draft: { type: 'boolean', description: 'Open the PR as a draft (cloud mode)' },
348-
prTitle: { type: 'string', description: 'Pull request title (cloud mode)' },
349-
prBody: { type: 'string', description: 'Pull request body (cloud mode)' },
350-
host: { type: 'string', description: 'SSH host (local mode)' },
351-
port: { type: 'number', description: 'SSH port (local mode)' },
352-
username: { type: 'string', description: 'SSH username (local mode)' },
353-
authMethod: { type: 'string', description: 'SSH authentication method (local mode)' },
354-
password: { type: 'string', description: 'SSH password (local mode)' },
355-
privateKey: { type: 'string', description: 'SSH private key (local mode)' },
356-
passphrase: { type: 'string', description: 'SSH key passphrase (local mode)' },
357-
repoPath: { type: 'string', description: 'Repository path on the target (local mode)' },
358-
tools: { type: 'json', description: 'Sim tools exposed to the agent (local mode)' },
409+
owner: { type: 'string', description: 'GitHub repository owner (Create PR and Review Code)' },
410+
repo: { type: 'string', description: 'GitHub repository name (Create PR and Review Code)' },
411+
githubToken: { type: 'string', description: 'GitHub token (Create PR and Review Code)' },
412+
baseBranch: { type: 'string', description: 'Base branch for the PR (Create PR)' },
413+
branchName: { type: 'string', description: 'Branch to create (Create PR)' },
414+
draft: { type: 'boolean', description: 'Open the PR as a draft (Create PR)' },
415+
prTitle: { type: 'string', description: 'Pull request title (Create PR)' },
416+
prBody: { type: 'string', description: 'Pull request body (Create PR)' },
417+
pullNumber: { type: 'number', description: 'Pull request number (Review Code)' },
418+
reviewEvent: {
419+
type: 'string',
420+
description: 'GitHub review event: COMMENT or REQUEST_CHANGES',
421+
},
422+
host: { type: 'string', description: 'SSH host (Local Dev)' },
423+
port: { type: 'number', description: 'SSH port (Local Dev)' },
424+
username: { type: 'string', description: 'SSH username (Local Dev)' },
425+
authMethod: { type: 'string', description: 'SSH authentication method (Local Dev)' },
426+
password: { type: 'string', description: 'SSH password (Local Dev)' },
427+
privateKey: { type: 'string', description: 'SSH private key (Local Dev)' },
428+
passphrase: { type: 'string', description: 'SSH key passphrase (Local Dev)' },
429+
repoPath: { type: 'string', description: 'Repository path on the target (Local Dev)' },
430+
tools: { type: 'json', description: 'Sim tools exposed to the agent (Local Dev)' },
359431
skills: { type: 'json', description: 'Selected skills configuration' },
360432
thinkingLevel: { type: 'string', description: 'Thinking level for the model' },
361433
memoryType: { type: 'string', description: 'Memory type for multi-turn conversations' },
@@ -379,6 +451,16 @@ export const PiBlock: BlockConfig<PiResponse> = {
379451
description: 'Branch pushed with the changes',
380452
condition: CLOUD,
381453
},
454+
reviewUrl: {
455+
type: 'string',
456+
description: 'URL of the submitted GitHub review',
457+
condition: CLOUD_REVIEW,
458+
},
459+
commentsPosted: {
460+
type: 'number',
461+
description: 'Number of inline review comments posted',
462+
condition: CLOUD_REVIEW,
463+
},
382464
tokens: { type: 'json', description: 'Token usage statistics' },
383465
cost: { type: 'json', description: 'Cost of the run' },
384466
providerTiming: { type: 'json', description: 'Provider timing information' },
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
5+
import { getPiModelOptions } from '@/blocks/utils'
6+
import { resolvePiModelId } from '@/providers/pi-providers'
7+
import { getProviderFromModel } from '@/providers/utils'
8+
import { useProvidersStore } from '@/stores/providers/store'
9+
10+
const originalBaseModels = useProvidersStore.getState().providers.base.models
11+
const originalOpenRouterModels = useProvidersStore.getState().providers.openrouter.models
12+
13+
describe('Pi model options', () => {
14+
beforeAll(() => {
15+
const store = useProvidersStore.getState()
16+
store.setProviderModels('base', ['claude-sonnet-4-6', 'claude-sonnet-4-0', 'gpt-5.4'])
17+
store.setProviderModels('openrouter', [
18+
'openrouter/openai/gpt-5',
19+
'openrouter/openrouter/fusion',
20+
])
21+
})
22+
23+
afterAll(() => {
24+
const store = useProvidersStore.getState()
25+
store.setProviderModels('base', originalBaseModels)
26+
store.setProviderModels('openrouter', originalOpenRouterModels)
27+
})
28+
29+
it("only exposes models present in Pi's pinned catalog", () => {
30+
const options = getPiModelOptions()
31+
32+
expect(options.length).toBeGreaterThan(0)
33+
for (const option of options) {
34+
const providerId = getProviderFromModel(option.id)
35+
expect(resolvePiModelId(providerId, option.id), option.id).toBeDefined()
36+
}
37+
})
38+
39+
it('keeps current models and excludes stale catalog entries', () => {
40+
const modelIds = getPiModelOptions().map(({ id }) => id)
41+
42+
expect(modelIds).toContain('claude-sonnet-4-6')
43+
expect(modelIds).not.toContain('claude-sonnet-4-0')
44+
})
45+
46+
it("does not apply OpenRouter capability filters beyond Pi's catalog", () => {
47+
const modelIds = getPiModelOptions().map(({ id }) => id)
48+
49+
expect(modelIds).toContain('openrouter/openai/gpt-5')
50+
expect(modelIds).toContain('openrouter/openrouter/fusion')
51+
})
52+
})

apps/sim/blocks/utils.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
getProviderModels,
1616
orderModelIdsByReleaseDate,
1717
} from '@/providers/models'
18-
import { isPiSupportedProvider } from '@/providers/pi-providers'
18+
import { isPiSupportedModel } from '@/providers/pi-providers'
1919
import { getProviderFromModel } from '@/providers/utils'
2020
import { useProvidersStore } from '@/stores/providers/store'
2121

@@ -81,16 +81,14 @@ export function getModelOptions() {
8181
}
8282

8383
/**
84-
* Model options filtered to providers the Pi Coding Agent can run (see
85-
* {@link isPiSupportedProvider}), so the Pi block never offers a model that would
86-
* error at execution. Uses the same `getProviderFromModel` resolution as the Pi
87-
* handler, so the dropdown matches runtime behavior; unresolved/blacklisted
88-
* models (which `getProviderFromModel` can throw on) are excluded.
84+
* Model options filtered to exact provider/model pairs in Pi's pinned catalog.
85+
* Unresolved or blacklisted models (which `getProviderFromModel` can throw on)
86+
* are excluded.
8987
*/
9088
export function getPiModelOptions() {
9189
return getModelOptions().filter((option) => {
9290
try {
93-
return isPiSupportedProvider(getProviderFromModel(option.id))
91+
return isPiSupportedModel(getProviderFromModel(option.id), option.id)
9492
} catch {
9593
return false
9694
}

0 commit comments

Comments
 (0)