Skip to content

Commit c8d7b57

Browse files
Bill Leoutsakoscursoragent
authored andcommitted
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>
1 parent 5de66db commit c8d7b57

14 files changed

Lines changed: 1097 additions & 112 deletions

File tree

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

Lines changed: 52 additions & 24 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/pi.ts

Lines changed: 81 additions & 23 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,6 +38,14 @@ 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' }
4050
const MEMORY_TYPES = ['conversation', 'sliding_window', 'sliding_window_tokens']
4151

@@ -45,11 +55,12 @@ export const PiBlock: BlockConfig<PiResponse> = {
4555
description: 'Run an autonomous coding agent on a repo',
4656
authMode: AuthMode.ApiKey,
4757
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.',
58+
'The Pi Coding Agent runs the Pi harness against a real repository. Cloud PR spins up an isolated sandbox, clones a GitHub repo, edits with native shell + git, and opens a pull request. Cloud Code Review checks out an existing PR and posts a structured review with optional inline comments. Local mode edits files on your own machine over SSH. All modes stream progress and reuse your models, skills, and multi-turn memory.',
4959
bestPractices: `
50-
- Use Cloud mode for hands-off changes against a GitHub repo where a reviewable PR is the deliverable.
60+
- Use Cloud PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable.
61+
- Use Cloud Code Review to analyze an existing PR and leave summary + inline review comments.
5162
- 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.
63+
- Cloud modes require your own provider API key (BYOK); the model key is never injected as a hosted key into the sandbox.
5364
`,
5465
category: 'blocks',
5566
integrationType: IntegrationType.AI,
@@ -60,7 +71,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
6071
id: 'mode',
6172
title: 'Mode',
6273
type: 'dropdown',
63-
// Cloud mode runs in an E2B sandbox; only offer it where E2B is enabled.
74+
// Cloud modes run in an E2B sandbox; only offer them where E2B is enabled.
6475
value: () => (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED')) ? 'cloud' : 'local'),
6576
options: () => {
6677
const options = [
@@ -71,11 +82,18 @@ export const PiBlock: BlockConfig<PiResponse> = {
7182
},
7283
]
7384
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-
})
85+
options.unshift(
86+
{
87+
label: 'Cloud PR',
88+
id: 'cloud',
89+
description: 'Runs in an isolated sandbox, clones your repo, and opens a PR',
90+
},
91+
{
92+
label: 'Cloud Code Review',
93+
id: 'cloud_review',
94+
description: 'Reviews an existing PR and posts GitHub review comments',
95+
}
96+
)
7997
}
8098
return options
8199
},
@@ -106,26 +124,27 @@ export const PiBlock: BlockConfig<PiResponse> = {
106124
type: 'short-input',
107125
placeholder: 'e.g., your-org',
108126
required: true,
109-
condition: CLOUD,
127+
condition: CLOUD_ANY,
110128
},
111129
{
112130
id: 'repo',
113131
title: 'Repository Name',
114132
type: 'short-input',
115133
placeholder: 'e.g., my-repo',
116134
required: true,
117-
condition: CLOUD,
135+
condition: CLOUD_ANY,
118136
},
119137
{
120138
id: 'githubToken',
121139
title: 'GitHub Token',
122140
type: 'short-input',
123141
password: true,
124142
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.',
143+
placeholder: 'GitHub personal access token',
144+
tooltip:
145+
'Personal access token used for GitHub access. Cloud PR needs clone/push/PR permissions; Cloud Code Review needs clone + review permissions.',
127146
required: true,
128-
condition: CLOUD,
147+
condition: CLOUD_ANY,
129148
},
130149
{
131150
id: 'baseBranch',
@@ -167,6 +186,27 @@ export const PiBlock: BlockConfig<PiResponse> = {
167186
mode: 'advanced',
168187
condition: CLOUD,
169188
},
189+
{
190+
id: 'pullNumber',
191+
title: 'Pull Request Number',
192+
type: 'short-input',
193+
placeholder: 'e.g., 42',
194+
required: true,
195+
condition: CLOUD_REVIEW,
196+
},
197+
{
198+
id: 'reviewEvent',
199+
title: 'Review Event',
200+
type: 'dropdown',
201+
defaultValue: 'COMMENT',
202+
options: [
203+
{ label: 'Comment', id: 'COMMENT' },
204+
{ label: 'Request changes', id: 'REQUEST_CHANGES' },
205+
{ label: 'Approve', id: 'APPROVE' },
206+
],
207+
tooltip: 'GitHub review action submitted with the agent findings.',
208+
condition: CLOUD_REVIEW,
209+
},
170210

171211
{
172212
id: 'host',
@@ -336,17 +376,25 @@ export const PiBlock: BlockConfig<PiResponse> = {
336376
access: [],
337377
},
338378
inputs: {
339-
mode: { type: 'string', description: 'Execution mode: cloud or local' },
379+
mode: {
380+
type: 'string',
381+
description: 'Execution mode: cloud, cloud_review, or local',
382+
},
340383
task: { type: 'string', description: 'Instruction for the coding agent' },
341384
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)' },
385+
owner: { type: 'string', description: 'GitHub repository owner (cloud modes)' },
386+
repo: { type: 'string', description: 'GitHub repository name (cloud modes)' },
387+
githubToken: { type: 'string', description: 'GitHub token (cloud modes)' },
388+
baseBranch: { type: 'string', description: 'Base branch for the PR (Cloud PR)' },
389+
branchName: { type: 'string', description: 'Branch to create (Cloud PR)' },
390+
draft: { type: 'boolean', description: 'Open the PR as a draft (Cloud PR)' },
391+
prTitle: { type: 'string', description: 'Pull request title (Cloud PR)' },
392+
prBody: { type: 'string', description: 'Pull request body (Cloud PR)' },
393+
pullNumber: { type: 'number', description: 'Pull request number (Cloud Code Review)' },
394+
reviewEvent: {
395+
type: 'string',
396+
description: 'GitHub review event: COMMENT, REQUEST_CHANGES, or APPROVE',
397+
},
350398
host: { type: 'string', description: 'SSH host (local mode)' },
351399
port: { type: 'number', description: 'SSH port (local mode)' },
352400
username: { type: 'string', description: 'SSH username (local mode)' },
@@ -379,6 +427,16 @@ export const PiBlock: BlockConfig<PiResponse> = {
379427
description: 'Branch pushed with the changes',
380428
condition: CLOUD,
381429
},
430+
reviewUrl: {
431+
type: 'string',
432+
description: 'URL of the submitted GitHub review',
433+
condition: CLOUD_REVIEW,
434+
},
435+
commentsPosted: {
436+
type: 'number',
437+
description: 'Number of inline review comments posted',
438+
condition: CLOUD_REVIEW,
439+
},
382440
tokens: { type: 'json', description: 'Token usage statistics' },
383441
cost: { type: 'json', description: 'Cost of the run' },
384442
providerTiming: { type: 'json', description: 'Provider timing information' },

apps/sim/executor/handlers/pi/backend.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export interface PiLocalRunParams extends PiRunBaseParams {
6262
tools: PiToolSpec[]
6363
}
6464

65-
/** Parameters for a cloud (E2B) Pi run. */
65+
/** Parameters for a cloud (E2B) Pi run that opens a PR. */
6666
export interface PiCloudRunParams extends PiRunBaseParams {
6767
mode: 'cloud'
6868
owner: string
@@ -75,7 +75,17 @@ export interface PiCloudRunParams extends PiRunBaseParams {
7575
prBody?: string
7676
}
7777

78-
export type PiRunParams = PiLocalRunParams | PiCloudRunParams
78+
/** Parameters for a cloud (E2B) Pi run that reviews an existing PR. */
79+
export interface PiCloudReviewRunParams extends PiRunBaseParams {
80+
mode: 'cloud_review'
81+
owner: string
82+
repo: string
83+
githubToken: string
84+
pullNumber: number
85+
reviewEvent: 'COMMENT' | 'REQUEST_CHANGES' | 'APPROVE'
86+
}
87+
88+
export type PiRunParams = PiLocalRunParams | PiCloudRunParams | PiCloudReviewRunParams
7989

8090
/** Progress callbacks and cancellation passed into a backend run. */
8191
export interface PiRunContext {
@@ -90,6 +100,8 @@ export interface PiRunResult {
90100
diff?: string
91101
prUrl?: string
92102
branch?: string
103+
reviewUrl?: string
104+
commentsPosted?: number
93105
}
94106

95107
/** A Pi execution backend. Implemented by the local (SSH) and cloud (E2B) runners. */

apps/sim/executor/handlers/pi/cloud-backend.ts

Lines changed: 11 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Cloud-mode backend: runs the Pi CLI inside an E2B sandbox against a cloned
2+
* Cloud PR backend: runs the Pi CLI inside an E2B sandbox against a cloned
33
* GitHub repo, then pushes a branch and opens a PR. Secrets are isolated per
44
* command (S2/KTD10): the GitHub token is present only for the clone and push
55
* commands (and stripped from the cloned remote), while the Pi loop runs with a
@@ -15,9 +15,18 @@
1515
import { createLogger } from '@sim/logger'
1616
import { generateShortId } from '@sim/utils/id'
1717
import { truncate } from '@sim/utils/string'
18-
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1918
import { withPiSandbox } from '@/lib/execution/e2b'
2019
import type { PiBackendRun, PiCloudRunParams } from '@/executor/handlers/pi/backend'
20+
import {
21+
CLONE_TIMEOUT_MS,
22+
extractMarkerValues,
23+
PI_SCRIPT,
24+
PI_TIMEOUT_MS,
25+
PROMPT_PATH,
26+
REPO_DIR,
27+
raceAbort,
28+
scrubGitSecrets,
29+
} from '@/executor/handlers/pi/cloud-shared'
2130
import { buildPiPrompt } from '@/executor/handlers/pi/context'
2231
import {
2332
applyPiEvent,
@@ -30,16 +39,9 @@ import { executeTool } from '@/tools'
3039

3140
const logger = createLogger('PiCloudBackend')
3241

33-
const REPO_DIR = '/workspace/repo'
3442
const DIFF_PATH = '/workspace/pi.diff'
35-
36-
const PROMPT_PATH = '/workspace/pi-prompt.txt'
3743
const COMMIT_MSG_PATH = '/workspace/pi-commit.txt'
38-
3944
const PUSH_ERR_PATH = '/workspace/pi-push-err.txt'
40-
const CLONE_TIMEOUT_MS = 10 * 60 * 1000
41-
42-
const PI_TIMEOUT_MS = getMaxExecutionTimeout()
4345
const FINALIZE_TIMEOUT_MS = 10 * 60 * 1000
4446
const MAX_DIFF_BYTES = 200_000
4547
const COMMIT_TITLE_MAX = 72
@@ -68,9 +70,6 @@ echo "__DEFAULT_BRANCH__=$DEFAULT_BRANCH"
6870
git checkout -b "$BRANCH"
6971
git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"`
7072

71-
const PI_SCRIPT = `cd ${REPO_DIR}
72-
pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING" < ${PROMPT_PATH}`
73-
7473
// Finalize is split so the GitHub token is in scope for ONLY the push. `git add`,
7574
// `commit`, and `diff` run repo-config-driven programs that `core.hooksPath` does
7675
// NOT disable — gitattributes clean/smudge filters (on add), `core.fsmonitor`
@@ -95,43 +94,6 @@ if git diff --quiet "$BASE_SHA" HEAD; then echo "__NO_CHANGES__=1"; else echo "_
9594
const PUSH_SCRIPT = `cd ${REPO_DIR}
9695
git -c core.hooksPath=/dev/null -c credential.helper= -c core.fsmonitor= push "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" "$BRANCH" >/dev/null 2>${PUSH_ERR_PATH} && echo "__PUSHED__=1"`
9796

98-
function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
99-
if (!signal) return promise
100-
if (signal.aborted) return Promise.reject(new Error('Pi run aborted'))
101-
return new Promise<T>((resolve, reject) => {
102-
const onAbort = () => reject(new Error('Pi run aborted'))
103-
signal.addEventListener('abort', onAbort, { once: true })
104-
promise.then(
105-
(value) => {
106-
signal.removeEventListener('abort', onAbort)
107-
resolve(value)
108-
},
109-
(error) => {
110-
signal.removeEventListener('abort', onAbort)
111-
reject(error)
112-
}
113-
)
114-
})
115-
}
116-
117-
function extractMarkerValues(stdout: string, prefix: string): string[] {
118-
return stdout
119-
.split('\n')
120-
.filter((line) => line.startsWith(prefix))
121-
.map((line) => line.slice(prefix.length).trim())
122-
.filter(Boolean)
123-
}
124-
125-
/**
126-
* Redacts the GitHub token from git output before it is surfaced in an error.
127-
* Removes the literal token and any URL userinfo (`//user:token@`), so a failure
128-
* message can quote git's real stderr without leaking the credential.
129-
*/
130-
function scrubGitSecrets(text: string, token: string): string {
131-
const withoutToken = token ? text.split(token).join('***') : text
132-
return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@')
133-
}
134-
13597
function buildPrBody(task: string, finalText: string): string {
13698
const summary = finalText.trim()
13799
? truncate(finalText.trim(), PR_SUMMARY_MAX)

0 commit comments

Comments
 (0)