Skip to content

Commit 25d8019

Browse files
BillLeoutsakosvl346Bill LeoutsakoscursoragentBill Leoutsakosicecrasher321
authored
feat(pi): Babysit foundations — shared PR/push extraction, five GitHub tools, sandbox lifetime (#5962)
* feat(pi): optional multi-provider web search for the coding agent Adds a search provider dropdown (Exa, Serper, Parallel, Firecrawl) to the Pi block, off by default. The selected provider's key comes from the block field or Workspace Settings → BYOK; a Sim-hosted key is never spent, so a missing key fails the run with a setup message instead of quietly billing Sim. Search is available in all three modes. Local Dev and Review Code register a host-side tool that goes through the existing provider tools, while Create PR has no host in the loop and gets a generated Pi extension in the sandbox. Both paths derive their requests from one normalizer and are held together by a parity test, since the sandbox copy cannot import Sim's code. Results are normalized to title, URL, snippet, and publication date, capped per field and per envelope, marked untrusted in the prompt, and limited to 20 searches per run so a tool loop cannot drain the workspace's quota. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(pi): drop the banned JSON round-trip from the search parity test `check:utils` bans `JSON.parse(JSON.stringify(...))`. The round-trip was normalizing the host body to its wire form, which buys nothing here: the bodies are plain JSON and `toEqual` already ignores undefined members. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(agents): add reviewed-development skill and the babysit implementation plan Carries the plan and review protocol into the repository so a cloud agent working from the remote can read them. Temporary: the plan is removed before this branch goes for review. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(pi): babysit foundations — shared PR/push extraction, GitHub tools, sandbox lifetime Stage 1 of the Babysit plan (.agents/plans/pi-babysit-mode.plan.md), sections 0, 3, and 7 plus their tests. The Babysit mode itself lands in stage 2. Section 0 — shared extraction and push hardening: - Move PREPARE_SCRIPT, PUSH_SCRIPT, and the finalize path/size constants from cloud-backend.ts into cloud-shared.ts. - Move Review Code's PR snapshot helpers into pi/github-pr.ts, generalized off PiCloudReviewRunParams to a plain PullRequestCoordinates, and split the raw fetch from the "must be open" wrapper so a mode that has to report a closed PR gracefully can build on the raw form. - Harden the one token-bearing command: GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL on its env, git by absolute path, and an explicit HEAD:refs/heads/$BRANCH refspec. The clone script now emits a .git/config digest marker as its last line for every mode; only Babysit will verify it. Section 3 — five GitHub tools, registered but not wired into the block dropdown: github_list_review_threads, github_reply_review_thread, github_resolve_review_thread, github_status_check_rollup (GraphQL) and github_job_logs (REST). Plus a nullable repo_full_name on the PR reader's branch parse, declared on its own output rather than the shared BRANCH_REF_OUTPUT. Section 7 — CreateSandboxOptions.lifetimeMs threaded to E2B's timeoutMs, clamped below the one-hour Hobby ceiling and lowerable by PI_SANDBOX_LIFETIME_MS. Daytona is deliberately untouched. The per-command Pi timeout is capped at the lifetime. * fix(tools): correct the rollup CheckRun selection and stop leaking the token on redirect Two defects found in review, both verified against the live GitHub API. GraphQL's CheckRun has no `output` object — that is REST's shape. It exposes `title`, `summary`, and `text` as flat nullable fields, confirmed by introspecting the schema. The old selection made every github_status_check_rollup call fail with "Field 'output' doesn't exist on type 'CheckRun'", delivered as an HTTP 200 errors payload that no fixture-based test could catch. The corrected query was run against a real PR and returns 18 check runs plus a Vercel status context, with title and summary null on every Actions run exactly as the plan predicted. github_job_logs redirects to third-party blob storage, and Sim's tool fetch follows redirects itself rather than through the fetch spec, so it replayed the GitHub token to that host. Tools can now declare `stripAuthOnRedirect`, and the log reader does. Also from review: pin Review Code's "must be open" guard with a test now that it lives in its own function, drop the stream reader in github_job_logs since the executor already hands transformResponse a capped buffer, and correct three doc comments that overstated what they guaranteed. * test(tools): cover the stripAuthOnRedirect plumbing end to end Asserting the flag on the tool config alone would not catch a regression in formatRequestParams or in the executor's call into secureFetchWithPinnedIP, so pin what the fetch layer actually receives, in both the opted-in and the default case. * fix(pi): correct the push-hardening claim, reserve finalize time, tighten isRequired Three review findings, each verified rather than taken on faith. PUSH_SCRIPT's comment claimed GIT_CONFIG_NOSYSTEM and GIT_CONFIG_GLOBAL close config-driven URL rewriting. They do not: reproduced locally on git 2.43, a repository-local url.*.insteadOf still rewrites the push URL and sends the token's userinfo to another host. That is the scope a root agent in the checkout can actually write, and it stays open until a mode verifies the config digest — which is Babysit, per the plan. The comment now says that instead of the opposite. PI_TIMEOUT_MS capped the Pi command at the whole sandbox lifetime, so the sandbox always died first and the stated benefit — a clean timeout instead of an opaque SDK error — could never happen. It now reserves the clone and finalize budgets it shares the sandbox with, leaving the host time to commit and push whatever the agent produced. isRequired is Boolean! on both CheckRun and StatusContext (confirmed by schema introspection), so the nullable parse modelled a value GitHub cannot send and left stage 2 a tri-state to handle. It is required now, and an absent value fails loudly rather than reading as "not required", which would let a failing required check stop blocking the green verdict. Also: a github-pr.test.ts pinning that the raw fetchPrSnapshot does not throw on a closed PR (the entire reason for the wrapper split, previously untested), a cloud-shared.test.ts for the timeout reserve and the digest line, the E2B lifetime ceiling documented next to E2B_PI_TEMPLATE_ID as section 7 asks, and the new registry tests no longer leaving their fake tools registered. * fix(pi): reserve both finalize budgets in the Pi command timeout Create PR dispatches two commands at FINALIZE_TIMEOUT_MS, not one — the commit and the push — so reserving a single budget left the push unbudgeted and the worst case overshot the sandbox lifetime by exactly that amount. Losing the sandbox during the push is the most expensive moment to lose it: the work is committed and unpushed, which is the outcome the reserve exists to prevent. The comment no longer claims more than the arithmetic delivers. What is reserved is each command's timeout ceiling rather than its measured elapsed time, so this is a budget that adds up, not a guarantee. Two other comments described Babysit verifying the config digest in the present tense, when no mode verifies it yet. Also drops the digest-line test: it asserted a string constant contains its own substrings, while cloud-backend.test.ts already pins the property that matters — the marker being the clone's last line, after the remote rewrite. * docs(pi): stop describing Babysit's digest check in the present tense Three comments still read as statements of current behavior: the Create PR push test's note beside the assertion that proves no verification happens, github-pr's module doc naming a second consumer that does not exist yet, and the timeout floor claiming a short-lifetime run was doomed regardless when the reserved ceilings are pessimistic enough that it may well finish. * fix(pi): scope the sandbox lifetime cap to E2B and harden the job-log path Deriving PI_TIMEOUT_MS from the E2B lifetime applied it to every provider, so a Daytona Create PR run lost its ~90-minute agent turn to a ceiling Daytona does not have — it stops on inactivity instead. The reserve now only applies when the provider imposes an absolute lifetime. A configured PI_SANDBOX_LIFETIME_MS below the clone and finalize reserves left no positive remainder for the turn, so E2B could reap the sandbox before the push. Such a value is raised to a floor rather than rejected: a module-scope throw on a config typo would take down every path that imports this, not just Pi. github_job_logs returns its response body verbatim, so unlike its siblings that parse a typed shape, a coordinate carrying URL syntax turned a bearer-authenticated request into a general read. Path segments are now escaped and the job id checked. Also corrects the plan's rollup field path: GraphQL's CheckRun has no output object, and isRequired is Boolean!, so stage 2 needs neither the nested path nor an unknown-required branch. Co-authored-by: Cursor <cursoragent@cursor.com> * Add Pi Babysit mode * fix(pi): wait on required checks before optional failures * fix(pi): preserve Daytona babysit budget * fix(pi): bound babysit round setup * fix(pi): keep babysit sandboxes active * fix(pi): report babysit round state accurately * fix(pi): classify babysit finalize failures * fix(pi): preserve babysit partial state * fix(pi): retain post-push check state * fix(pi): retain pending rereview state * fix(pi): normalize empty sandbox provider * Move Babysit into Create PR * Fix Babysit wait-only budgeting * Wait for reviews before skipped-thread exit * Polish Babysit reviewer field spacing * Fix duplicated Internet Search section from staging merge The staging merge landed the branch's Internet Search section and staging's reviewed replacement side by side, leaving two `### Internet Search` headings and two `#internet-search` anchors. The branch's copy also still claimed a Settings > BYOK fallback for the search key, which staging deliberately removed. Keep staging's section and fold the branch's only new fact — that the Babysit continuation sandbox carries both keys — into its warning callout. * fix(pi): correct Babysit check, budget, and push-guard accuracy Correctness: - The `.github/` push refusal compared raw `git diff --name-only` output, which git C-quotes for non-ASCII paths. `.github/workflows/évil.yml` arrived as `".github/workflows/\303\251vil.yml"` — leading quote included — so neither `.github` nor `.github/` matched and the file pushed. Both name-listing diffs now pin `core.quotePath=false`; Create PR's does too so `changedFiles` reports real names. - `CANCELLED` and `STALE` were counted as non-failing conclusions, so a cancelled required check produced `checksGreen: true` and `stopReason: 'clean'` on a PR branch protection still blocks. Both now fall through to failing, matching every other unknown conclusion. - The per-round check bound counted optional checks, so a repo with a wide optional matrix ended the run at `bounds_exceeded` before a single review thread was addressed. Only required-check overflow is fatal now; the rest trims, required checks first, and reports what was left out. This also makes the prompt's existing slice reachable. - A re-review request that posted nothing still re-armed `requestedAt` with `landed: false`, leaving the loop waiting on a review nobody had asked for — burning the remaining lifetime on a billed idle sandbox before reporting `awaiting_review` on a clean PR. The previous request now stands. - Prompt bounds threw where every other bound in the file trims, ending a busy PR's run on round one after the PR and its review comments were already posted. They now drop trailing entries and note the omission. - `babysitMode` used a strict boolean compare; a `switch` input arriving as the string 'true' silently opened a draft PR and skipped Babysit while the editor showed it enabled. Matches `wait-handler`'s coercion now. - The fork check compared head against the block's typed owner/repo, so a renamed repository — which GitHub serves through a 301 while reporting the canonical name — was reported as a fork. Compares head against base now. - Each round's diff is capped like Create PR's. The cumulative guard measures the net change, so a round that reverts an earlier addition passed it while contributing a full-size diff. - Reviewer mentions must start with `@`. Each entry becomes its own issue comment re-posted every round, so a comma inside one left prose on the PR. Efficiency: - Thread and check reads per poll now run together; neither consumes the other's result and both are paginating loops. - `babysitReviewLandedSince` takes the `latestReview` the caller already fetched instead of re-listing the PR for it. - Actions log reads fan out in small batches rather than one at a time. Cleanup: - `BabysitFinalizeError` was a twin of `BabysitGitHubError`; folded together. - Replaced the hand-inlined copies of `threadsAreClean`. - Dropped `MEMORY_MODES`, a duplicate of `AUTHORING_MODES`, and the `parsePiMode` tombstone for a mode that never shipped. Adds regression tests for the quoted `.github` path, cancelled/stale required checks, and the renamed-repository snapshot. * fix(pi): budget Babysit against the run's real deadline Three fixes that each needed to land a level below where the symptom showed. Execution deadline. Babysit planned its wait loop against `getMaxExecutionTimeout()`, which unconditionally returns the enterprise async ceiling (90 min) with no regard for the run's plan or sync/async mode — the sync ceiling is 300s on free, 3000s on pro. A synchronously triggered run was therefore killed mid-loop with the PR already opened, its review comments already posted, and none of the rounds/stopReason outputs produced. Rather than thread a numeric deadline through ExecutionContext — five entry points that would each have to remember it, and nothing to catch the one that forgot — `createTimeoutAbortController` now records the deadline against the signal it creates, and `getRemainingExecutionMs(signal)` reads it back. The number cannot disagree with the timer that enforces it, because both are established in the one place the timeout exists, and every entry point already hands the executor that signal. `undefined` means unknown, not unlimited: Babysit keeps the old ceiling as its fallback for an untimed run. Job logs. The executor caps a response at 10 MB and throws rather than truncating, so a verbose CI job produced no diagnostic at all — and GitHub Actions reports null title/summary on every check run, leaving the agent a bare URL it has no tool to follow. The tool now sends `Range: bytes=-N` so the storage host returns only the tail. Since a suffix range is a request and not a guarantee, a 200 still takes the local slice. That made the old output dishonest, so the contract changed while the tool is still new and has one consumer: `totalCharacters` (which a ranged read cannot know) is replaced by `totalBytes`, sourced from the `Content-Range` total and null when unreported. A ranged body is trimmed at its first line break, since the byte window cuts mid-line and can split a multi-byte character. Generated docs. `github.mdx` was missing the `repo_full_name` the PR reader now returns, and regenerating deleted the head/base rows instead of adding it: `scripts/generate-docs.ts` only expands a spread at depth 0 of a const, and `PR_BRANCH_REF_OUTPUT` spread inline under `properties`. Restructured into a named properties const in types.ts, next to the shapes it belongs with, which the generator resolves the same way it already resolves BRANCH_REF_OUTPUT. Only the github.mdx hunk is committed. The generator is lossy elsewhere — it drops 126 lines of trigger configuration from jira.mdx — which is pre-existing drift for whoever owns that surface, not this branch. * fix(pi): refuse any Git-quoted path before the Babysit push `core.quotePath=false` stopped Git escaping non-ASCII bytes, but it closed one instance rather than the class. Git still quotes any path it cannot state on a single line — one containing a newline, a double quote, a backslash, or a tab — and such a path arrives with a leading `"`, so the `.github/` prefix test does not match it and the refusal is bypassed exactly as before. Refuse any quoted path outright. Unescaping instead would put a second implementation of Git's quoting rules on the security-relevant side of the push, and these characters have no legitimate place in a source path. * fix(pi): harden the Babysit push against a repository the agent controls Every host-side guard before the push was computed by a git invocation the agent could influence from inside `.git/`, which is never committed and so is covered by neither the config digest nor any content check. - Diff measurement ignored `.git/info/attributes`. One line saying `* -diff` reduced a 500 KB change to `Binary files differ`: verified locally as 119 bytes reported against a real 500,147, so the 200 KB cumulative bound never fired, and the `diff` block output — the only record of what an unattended, review-comment-steered agent pushed — recorded the same nothing. Both diffs now run `--text --no-ext-diff --no-textconv` with an empty `core.attributesFile`. - `rev-list --count` and `merge-base --is-ancestor` answered about replaced history. A `refs/replace/*` mapping makes a five-commit chain report one, while the pushed SHA stays real. `GIT_NO_REPLACE_OBJECTS` is now set for both the prepare and push commands. - The push asserted HEAD's *shape* — right branch, one commit past the pin, a descendant — every part of which survives `commit --amend` to a different tree. So the bounds, the quoted-path refusal, the `.github/` refusal and the reported diff could all describe a commit other than the one pushed. The validated SHA is now passed in and the refspec names it. - The digest comparison resolved `cat`, `sha256sum` and `cut` through `$PATH` in the same script that invokes git absolutely for exactly that reason, and was handed the expected digest in its own environment — so a shim could print it. The utilities are now absolute. - The clone left the token in `.git/config` when the pinned-SHA assertion failed, because `set -e` aborted before `remote set-url`. Reordered. - `changedFiles` reached the block output unscrubbed on the Babysit path, where Create PR already scrubs it. Names are agent-chosen. These reduce exposure; they do not bound it. E2B runs each command as `bash -l`, so a login-shell profile the agent writes executes ahead of the next host command with the token in its environment. Removing the token from the sandbox entirely means pushing host-side through the Git Data API, which the ≤50-file / ≤200 KB bound already makes practical. * fix(pi): correct switch coercion for draft and tidy Babysit reporting - `draft` had the same string-coercion bug that `babysitMode` was fixed for one line above it. A switch arrives as `'true'`/`'false'` when its value came through a variable reference, an API trigger payload, or a legacy serialized workflow, and `inputs.draft !== false` read `'false'` as truthy — opening a draft PR against the user's explicit setting. Both now go through one `isSwitchEnabled` helper that handles either polarity and takes the field's default, because the bug is opposite on each. - `mergePhaseDiffs` joined two separately-capped diffs without re-capping, so the combined output could reach twice MAX_DIFF_BYTES. - The cancellation poller's `logger.warn` was the one message in these files emitted unscrubbed. A Redis poll error is unlikely to carry a run credential, but a uniform invariant is easier to keep than a per-call-site argument. - Renamed `waitWithSandboxKeepalive` to `waitWithSandboxProbe`. E2B's `timeoutMs` counts down from create and is reset only by `Sandbox.setTimeout`, never by running a command, so `true` every four minutes proves liveness and buys no time. The old name invited raising the round wait on the assumption that waits extend the sandbox, which would let E2B reap it mid-wait. - Dropped a `{@link}` to a symbol in another module that was never imported. * docs(tools): record why the Babysit GitHub tools are registry-only The same branch added four user-facing GitHub tools through the full block-exposure recipe (v2 variant, tools.access, dropdown, subBlocks) and five internal ones through none of it. The distinction is deliberate — the five are called by the Pi Babysit handler via executeTool, which resolves against the registry rather than any block's access list — but nothing in CI encodes it, and `check-block-registry.ts` silently skips ids it cannot find. Worth stating because the trap is non-obvious: `GitHubV2Block` builds its access list by appending `_v2` to every entry, so adding one of these to `tools.access` without first adding a v2 variant would point the block at an id that does not exist. * docs(pi): document the clean stop reason and Babysit's fixed bounds The FAQ told readers to inspect `stopReason`, but the reference list never named `clean` — the one value that means the PR actually reached the goal state — and omitted `closed_or_merged`, `fork_pr`, and `check_read_failed`. Also records the bounds that were previously undiscoverable, split by how each one actually behaves: the reviewer-mention limits reject the block before the run starts, the 30-thread limit trims a round, and only the failing-check and cumulative-change limits produce `bounds_exceeded`. Corrects step 6, which claimed Babysit reruns CI. It never does — the push is what re-triggers checks. Co-Authored-By: Claude <noreply@anthropic.com> * docs(tools): correct and widen the registry-only note The previous note contrasted these five with "the user-facing tools added alongside them", implying this branch added both. It did not: the branch never touches blocks/blocks/github.ts, and github_create_pr_review came from #5471, which predates staging. The real contrast is with every user-facing GitHub tool in the registry. Also records the governance consequence, which was the part actually worth writing down: the permission-group deny list is built from tools.access, so an admin cannot deny these from the UI, and the allowedIntegrations gate keys on block type while Babysit calls them with a tool id alone. Co-Authored-By: Claude <noreply@anthropic.com> * fix(pi): size the sandbox to the run's own execution timeout The Pi sandbox lifetime was a global constant while the execution timeout is per-plan and per-mode, varying 18x (a free sync run gets 5 minutes, an async run 90). Every Pi sandbox asked E2B for the same sub-hour ceiling, so a five-minute run whose web process died left a sandbox billing for an hour. PI_SANDBOX_LIFETIME_MS could not close the gap: its floor is 31 minutes. resolvePiRunLifetimeMs lowers the provider ceiling to whatever the run's own deadline leaves, read from the signal that enforces it. Untimed runs and Daytona are unchanged, so no path gets a longer lifetime than before. The turn cap had to move with it. PI_TIMEOUT_MS reserved the clone and both finalize budgets out of the ceiling as a module constant; leaving it there while shrinking the lifetime would re-open the exact bug its docs describe — the sandbox dying first, taking the agent's finished work with it unpushed. It is now resolvePiTimeoutMs(lifetimeMs), and each backend resolves the lifetime once and feeds both, so the two cannot disagree. Two things this surfaced: Babysit had to read context.signal, not the cancellation signal it uses everywhere else. createCancellationSignal returns a fresh controller that only forwards aborts, so the deadline lookup answers "unknown" through it and would have silently left the longest-lived mode on the ceiling. Covered by a test that fails against the wrong signal. The E2B adapter tested lifetimeMs for truthiness, so a run resolving to zero would have had the key dropped and been handed the SDK's five-minute default - longer than it asked for, on the run least entitled to it. Options precede the callback in withPiSandbox so that adding one did not re-indent every caller's sandbox body. Co-Authored-By: Claude <noreply@anthropic.com> * fix(pi): raise the sandbox ceiling to the longest execution we allow The ceiling was pinned just under E2B's one-hour *Hobby* session limit. Sim is on Professional, where the limit is 24 hours, so the cap was enforcing a restriction no plan imposes — and it sat below the 90-minute async execution ceiling, which made the sandbox the binding constraint. A long Babysit run could be handed a 90-minute budget and still lose its sandbox at 59. Derived from getMaxExecutionTimeout rather than given a number of its own, so the sandbox always outlasts the longest run the platform permits and an operator who raises the async timeout does not have to know this file exists. The provider session limit stays as a clamp, so the derivation can never ask E2B for a lifetime it will refuse. Effect: ceiling 59 -> 90 min, and the agent turn it funds 29 -> 60 min, since resolvePiTimeoutMs reserves the clone and both finalize budgets out of it. Runs with a shorter deadline are unaffected — resolvePiRunLifetimeMs already lowers the ceiling to whatever the run itself has left. Co-Authored-By: Claude <noreply@anthropic.com> * docs(pi): describe the deadline-sized sandbox, not a fixed hour Both facts in this paragraph were stale: the lifetime is no longer a single sub-hour constant (it tracks the run's own remaining execution time), and the ceiling was justified by E2B's Hobby limit on an account that is on Professional. Co-Authored-By: Claude <noreply@anthropic.com> * fix(pi): share the sandbox sizing and lift E2B off the base default The two Pi images had drifted on exactly the axis their shared module exists to prevent. Daytona asked for 4 CPU / 8 GB; the E2B template asked for nothing and inherited its base default of 2 vCPU / 512 MB. That is a 16x memory gap between the provider Pi normally runs on and the one it fails over to, so a failover could be OOM-killed doing work that had just succeeded. 512 MB is too small independently of the drift: the Pi CLI is a Node process holding an LLM context, running beside a clone of the user's repository, and Node is OOM-killed rather than degraded at that ceiling — which reaches the user as an opaque agent failure. CPU and memory now come from pi-sandbox-packages.ts alongside the package lists. Disk stays in the Daytona renderer: its 10 GB per-sandbox cap is a hard provider limit with no E2B equivalent, so it is the one dimension where the images legitimately differ. E2B fixes resources at template build time, so this takes effect only when build-pi-e2b-template.ts is re-run — nothing builds these images in CI. Co-Authored-By: Claude <noreply@anthropic.com> * chore(pi): remove internal planning files * chore(pi): remove generated review commands * fix(pi): align babysit toggle visibility --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Bill Leoutsakos <billleoutsakos@Mac.localdomain> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9edf892 commit 25d8019

58 files changed

Lines changed: 8367 additions & 237 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/integrations/github.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,12 @@ Fetch PR details including diff and files changed
5959
|`label` | string | Branch label \(owner:branch\) |
6060
|`ref` | string | Branch name |
6161
|`sha` | string | Commit SHA |
62+
|`repo_full_name` | string | Full name \(owner/repo\) of the branch's repository |
6263
| `base` | object | Branch reference info |
6364
|`label` | string | Branch label \(owner:branch\) |
6465
|`ref` | string | Branch name |
6566
|`sha` | string | Commit SHA |
67+
|`repo_full_name` | string | Full name \(owner/repo\) of the branch's repository |
6668
| `id` | number | Pull request ID |
6769
| `number` | number | Pull request number |
6870
| `title` | string | PR title |

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

Lines changed: 66 additions & 14 deletions
Large diffs are not rendered by default.

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,16 @@ export function Editor() {
650650
: undefined
651651
}
652652
/>
653-
{showDivider && <FieldDivider subblockMarker />}
653+
{showDivider && (
654+
<FieldDivider
655+
subblockMarker
656+
className={
657+
regularSubBlocks[index + 1]?.hideDividerBefore
658+
? '[&>div]:invisible'
659+
: undefined
660+
}
661+
/>
662+
)}
654663
</div>
655664
)
656665
})}
@@ -707,7 +716,14 @@ export function Editor() {
707716
}
708717
/>
709718
{index < advancedOnlySubBlocks.length - 1 && (
710-
<FieldDivider subblockMarker />
719+
<FieldDivider
720+
subblockMarker
721+
className={
722+
advancedOnlySubBlocks[index + 1]?.hideDividerBefore
723+
? '[&>div]:invisible'
724+
: undefined
725+
}
726+
/>
711727
)}
712728
</div>
713729
)

apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1482,7 +1482,16 @@ function PreviewEditorContent({
14821482
subBlockValues={subBlockValues}
14831483
disabled={true}
14841484
/>
1485-
{index < visibleSubBlocks.length - 1 && <FieldDivider subblockMarker />}
1485+
{index < visibleSubBlocks.length - 1 && (
1486+
<FieldDivider
1487+
subblockMarker
1488+
className={
1489+
visibleSubBlocks[index + 1]?.hideDividerBefore
1490+
? '[&>div]:invisible'
1491+
: undefined
1492+
}
1493+
/>
1494+
)}
14861495
</div>
14871496
))}
14881497
</div>

apps/sim/blocks/blocks/pi.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { describe, expect, it, vi } from 'vitest'
77
// deliberately does not import it — no block imports from `@/executor`. This test is what ties the
88
// two copies together, so adding a provider to one and not the other fails here.
99
vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: vi.fn(), getApiKeyWithBYOK: vi.fn() }))
10+
vi.mock('@/lib/core/config/env', async (importOriginal) => {
11+
const original = await importOriginal<typeof import('@/lib/core/config/env')>()
12+
return {
13+
...original,
14+
getEnv: vi.fn((key: string) => (key === 'NEXT_PUBLIC_E2B_ENABLED' ? 'true' : undefined)),
15+
isTruthy: vi.fn((value: unknown) => value === 'true'),
16+
}
17+
})
1018

1119
import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
1220
import { PiBlock } from '@/blocks/blocks/pi'
@@ -75,3 +83,97 @@ describe('Pi block search fields', () => {
7583
expect(PiBlock.inputs.searchApiKey).toBeDefined()
7684
})
7785
})
86+
87+
describe('Pi Create PR Babysit surface', () => {
88+
it('offers exactly Create PR, Review Code, and Local Dev as top-level modes', () => {
89+
const mode = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'mode')
90+
const options =
91+
typeof mode?.options === 'function'
92+
? mode.options()
93+
: (mode?.options as Array<{ id: string }> | undefined)
94+
95+
expect(options?.map(({ id }) => id)).toEqual(['cloud', 'cloud_review', 'local'])
96+
})
97+
98+
it('declares the toggle, required reviewer mentions, advanced rounds, and result outputs', () => {
99+
const toggle = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'babysitMode')
100+
const maxRounds = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'maxRounds')
101+
const mentions = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'reviewMentions')
102+
103+
expect(toggle).toMatchObject({
104+
type: 'switch',
105+
defaultValue: false,
106+
condition: { field: 'mode', value: 'cloud' },
107+
})
108+
expect(maxRounds).toMatchObject({
109+
type: 'short-input',
110+
defaultValue: '3',
111+
mode: 'advanced',
112+
condition: {
113+
field: 'mode',
114+
value: 'cloud',
115+
and: { field: 'babysitMode', value: [true, 'true'] },
116+
},
117+
})
118+
expect(mentions).toMatchObject({
119+
type: 'short-input',
120+
defaultValue: '',
121+
hideDividerBefore: true,
122+
required: {
123+
field: 'mode',
124+
value: 'cloud',
125+
and: { field: 'babysitMode', value: [true, 'true'] },
126+
},
127+
condition: {
128+
field: 'mode',
129+
value: 'cloud',
130+
and: { field: 'babysitMode', value: [true, 'true'] },
131+
},
132+
})
133+
for (const output of [
134+
'rounds',
135+
'threadsClean',
136+
'checksGreen',
137+
'threadsResolved',
138+
'commitsPushed',
139+
'stopReason',
140+
]) {
141+
expect(PiBlock.outputs[output]).toMatchObject({
142+
condition: {
143+
field: 'mode',
144+
value: 'cloud',
145+
and: { field: 'babysitMode', value: [true, 'true'] },
146+
},
147+
})
148+
}
149+
})
150+
151+
it('requires a task and hides Draft PR while Babysit Mode is enabled', () => {
152+
const task = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'task')
153+
const draft = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'draft')
154+
const skills = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'skills')
155+
const tools = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'tools')
156+
const memory = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'memoryType')
157+
const pullNumber = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'pullNumber')
158+
159+
expect(task?.required).toBe(true)
160+
expect(evaluateSubBlockCondition(draft?.condition, { mode: 'cloud' })).toBe(true)
161+
expect(evaluateSubBlockCondition(draft?.condition, { mode: 'cloud', babysitMode: true })).toBe(
162+
false
163+
)
164+
expect(
165+
evaluateSubBlockCondition(draft?.condition, { mode: 'cloud', babysitMode: 'true' })
166+
).toBe(false)
167+
expect(
168+
evaluateSubBlockCondition(
169+
PiBlock.subBlocks.find((subBlock) => subBlock.id === 'reviewMentions')?.condition,
170+
{ mode: 'cloud', babysitMode: 'true' }
171+
)
172+
).toBe(true)
173+
expect(evaluateSubBlockCondition(skills?.condition, { mode: 'cloud' })).toBe(true)
174+
expect(evaluateSubBlockCondition(tools?.condition, { mode: 'cloud' })).toBe(false)
175+
expect(evaluateSubBlockCondition(memory?.condition, { mode: 'cloud' })).toBe(true)
176+
expect(evaluateSubBlockCondition(pullNumber?.condition, { mode: 'cloud' })).toBe(false)
177+
expect(evaluateSubBlockCondition(pullNumber?.condition, { mode: 'cloud_review' })).toBe(true)
178+
})
179+
})

apps/sim/blocks/blocks/pi.ts

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ interface PiResponse extends ToolResponse {
1919
branch?: string
2020
reviewUrl?: string
2121
commentsPosted?: number
22+
rounds?: number
23+
threadsClean?: boolean
24+
checksGreen?: boolean
25+
threadsResolved?: number
26+
commitsPushed?: number
27+
stopReason?: string
2228
tokens?: {
2329
input?: number
2430
output?: number
@@ -46,6 +52,31 @@ const CLOUD_ANY: { field: 'mode'; value: Array<'cloud' | 'cloud_review'> } = {
4652
field: 'mode',
4753
value: ['cloud', 'cloud_review'],
4854
}
55+
const BABYSIT_ENABLED_VALUES: Array<true | 'true'> = [true, 'true']
56+
const CLOUD_WITH_BABYSIT: {
57+
field: 'mode'
58+
value: 'cloud'
59+
and: { field: 'babysitMode'; value: Array<true | 'true'> }
60+
} = {
61+
field: 'mode',
62+
value: 'cloud',
63+
and: { field: 'babysitMode', value: BABYSIT_ENABLED_VALUES },
64+
}
65+
function getCloudWithoutBabysitCondition(values?: Record<string, unknown>): {
66+
field: 'mode'
67+
value: 'cloud'
68+
and: { field: 'babysitMode'; value: true | 'true'; not: true }
69+
} {
70+
return {
71+
field: 'mode',
72+
value: 'cloud',
73+
and: {
74+
field: 'babysitMode',
75+
value: values?.babysitMode === 'true' ? 'true' : true,
76+
not: true,
77+
},
78+
}
79+
}
4980
const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' }
5081
const AUTHORING_MODES: { field: 'mode'; value: Array<'cloud' | 'local'> } = {
5182
field: 'mode',
@@ -85,9 +116,10 @@ export const PiBlock: BlockConfig<PiResponse> = {
85116
description: 'Run an autonomous coding agent on a repo',
86117
authMode: AuthMode.ApiKey,
87118
longDescription:
88-
'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. Any mode can optionally get one web_search tool backed by your own Exa, Serper, Parallel AI, or Firecrawl key; the agent writes its own queries, so repository content may reach the provider, and results are untrusted third-party data.',
119+
'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; Babysit Mode then keeps that pull request under watch, fixing trusted bot review threads and failing required checks in bounded rounds. 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. Any mode can optionally get one web_search tool backed by your own Exa, Serper, Parallel AI, or Firecrawl key; the agent writes its own queries, so repository content may reach the provider, and results are untrusted third-party data.',
89120
bestPractices: `
90121
- Use Create PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable.
122+
- Enable Babysit Mode on Create PR when trusted review bots and required checks should be monitored and fixed in bounded rounds.
91123
- Use Review Code to analyze an existing PR and leave summary + inline review comments.
92124
- 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.
93125
- 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.
@@ -204,7 +236,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
204236
paramVisibility: 'user-only',
205237
placeholder: 'GitHub personal access token',
206238
tooltip:
207-
'Personal access token used for GitHub access. Create PR needs clone/push/PR permissions; Review Code needs clone + review permissions.',
239+
'Personal access token used for GitHub access. Create PR needs clone/push/PR permissions; with Babysit Mode it also needs check/Actions reads, thread writes, and issue comments. Review Code needs clone + review permissions.',
208240
required: true,
209241
condition: CLOUD_ANY,
210242
},
@@ -216,6 +248,27 @@ export const PiBlock: BlockConfig<PiResponse> = {
216248
tooltip: 'The branch the pull request is opened against; the repo is cloned from it too.',
217249
condition: CLOUD,
218250
},
251+
{
252+
id: 'babysitMode',
253+
title: 'Babysit Mode',
254+
type: 'switch',
255+
defaultValue: false,
256+
description:
257+
'Create the PR ready for review, request the configured bot reviews, and fix trusted feedback and required checks in bounded rounds.',
258+
condition: CLOUD,
259+
},
260+
{
261+
id: 'reviewMentions',
262+
title: 'Reviewer Mentions',
263+
type: 'short-input',
264+
defaultValue: '',
265+
placeholder: '@greptile, @cursor review',
266+
tooltip:
267+
'Required comma-separated issue comments. Each is posted after PR creation and again after every pushed Babysit fix.',
268+
hideDividerBefore: true,
269+
required: CLOUD_WITH_BABYSIT,
270+
condition: CLOUD_WITH_BABYSIT,
271+
},
219272
{
220273
id: 'branchName',
221274
title: 'Branch Name',
@@ -230,7 +283,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
230283
type: 'switch',
231284
defaultValue: true,
232285
mode: 'advanced',
233-
condition: CLOUD,
286+
condition: getCloudWithoutBabysitCondition,
234287
},
235288
{
236289
id: 'prTitle',
@@ -269,6 +322,16 @@ export const PiBlock: BlockConfig<PiResponse> = {
269322
'How GitHub records the submitted review. Comment is neutral; Request changes marks the pull request as changes requested.',
270323
condition: CLOUD_REVIEW,
271324
},
325+
{
326+
id: 'maxRounds',
327+
title: 'Maximum Rounds',
328+
type: 'short-input',
329+
defaultValue: '3',
330+
placeholder: '3',
331+
tooltip: 'Maximum number of agent fixing rounds, from 1 to 10.',
332+
mode: 'advanced',
333+
condition: CLOUD_WITH_BABYSIT,
334+
},
272335

273336
{
274337
id: 'host',
@@ -464,15 +527,28 @@ export const PiBlock: BlockConfig<PiResponse> = {
464527
},
465528
task: { type: 'string', description: 'Instruction for the coding agent' },
466529
model: { type: 'string', description: 'AI model to use' },
467-
owner: { type: 'string', description: 'GitHub repository owner (Create PR and Review Code)' },
468-
repo: { type: 'string', description: 'GitHub repository name (Create PR and Review Code)' },
469-
githubToken: { type: 'string', description: 'GitHub token (Create PR and Review Code)' },
530+
owner: { type: 'string', description: 'GitHub repository owner' },
531+
repo: { type: 'string', description: 'GitHub repository name' },
532+
githubToken: { type: 'string', description: 'GitHub token' },
470533
baseBranch: { type: 'string', description: 'Base branch for the PR (Create PR)' },
471534
branchName: { type: 'string', description: 'Branch to create (Create PR)' },
472535
draft: { type: 'boolean', description: 'Open the PR as a draft (Create PR)' },
473536
prTitle: { type: 'string', description: 'Pull request title (Create PR)' },
474537
prBody: { type: 'string', description: 'Pull request body (Create PR)' },
538+
babysitMode: {
539+
type: 'boolean',
540+
description: 'Create the PR and babysit trusted bot reviews and required checks',
541+
},
475542
pullNumber: { type: 'number', description: 'Pull request number (Review Code)' },
543+
maxRounds: {
544+
type: 'number',
545+
description: 'Maximum Create PR Babysit fixing rounds (1-10)',
546+
},
547+
reviewMentions: {
548+
type: 'string',
549+
description:
550+
'Required comma-separated bot review comments posted initially and after Babysit pushes',
551+
},
476552
reviewEvent: {
477553
type: 'string',
478554
description: 'GitHub review event: COMMENT or REQUEST_CHANGES',
@@ -524,6 +600,36 @@ export const PiBlock: BlockConfig<PiResponse> = {
524600
description: 'Number of inline review comments posted',
525601
condition: CLOUD_REVIEW,
526602
},
603+
rounds: {
604+
type: 'number',
605+
description: 'Babysit fixing rounds consumed',
606+
condition: CLOUD_WITH_BABYSIT,
607+
},
608+
threadsClean: {
609+
type: 'boolean',
610+
description: 'Whether all actionable review threads are resolved',
611+
condition: CLOUD_WITH_BABYSIT,
612+
},
613+
checksGreen: {
614+
type: 'boolean',
615+
description: 'Whether required checks are green with none pending',
616+
condition: CLOUD_WITH_BABYSIT,
617+
},
618+
threadsResolved: {
619+
type: 'number',
620+
description: 'Review threads resolved by Babysit',
621+
condition: CLOUD_WITH_BABYSIT,
622+
},
623+
commitsPushed: {
624+
type: 'number',
625+
description: 'Commits pushed by Babysit',
626+
condition: CLOUD_WITH_BABYSIT,
627+
},
628+
stopReason: {
629+
type: 'string',
630+
description: 'Why the Babysit run stopped',
631+
condition: CLOUD_WITH_BABYSIT,
632+
},
527633
tokens: { type: 'json', description: 'Token usage statistics' },
528634
cost: { type: 'json', description: 'Cost of the run' },
529635
providerTiming: { type: 'json', description: 'Provider timing information' },

apps/sim/blocks/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ export interface SubBlockConfig {
314314
connectionDroppable?: boolean
315315
hidden?: boolean
316316
hideFromPreview?: boolean // Hide this subblock from the workflow block preview
317+
hideDividerBefore?: boolean // Visually group this field with the preceding visible subblock
317318
showWhenEnvSet?: string // Show this subblock only when the named NEXT_PUBLIC_ env var is truthy
318319
hideWhenHosted?: boolean // Hide this subblock when running on hosted sim
319320
hideWhenEnvSet?: string // Hide this subblock when the named NEXT_PUBLIC_ env var is truthy

0 commit comments

Comments
 (0)