Skip to content

Commit 1234a31

Browse files
committed
address comments
1 parent 0453c8e commit 1234a31

6 files changed

Lines changed: 177 additions & 139 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import {
2828
resolvePiSandboxLifetimeMs,
2929
} from '@/lib/execution/remote-sandbox/pi-lifetime'
3030
import { runBabysitPi } from '@/executor/handlers/pi/cloud/babysit/backend'
31+
import {
32+
PI_EVENT_FILTER_PATH,
33+
PI_EVENT_FILTER_SOURCE,
34+
} from '@/executor/handlers/pi/cloud/event-filter-source'
3135
import {
3236
type BranchPullRequest,
3337
fetchOpenPrForBranch,
@@ -43,8 +47,6 @@ import {
4347
FINALIZE_TIMEOUT_MS,
4448
GIT_CONFIG_DIGEST_LINE,
4549
MAX_DIFF_BYTES,
46-
PI_EVENT_FILTER_PATH,
47-
PI_EVENT_FILTER_SOURCE,
4850
PREPARE_SCRIPT,
4951
PROMPT_PATH,
5052
PUSH_ERR_PATH,

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ import {
3636
MAX_THREADS_PER_ROUND,
3737
parseBabysitRound,
3838
} from '@/executor/handlers/pi/cloud/babysit/round'
39+
import {
40+
PI_EVENT_FILTER_PATH,
41+
PI_EVENT_FILTER_SOURCE,
42+
} from '@/executor/handlers/pi/cloud/event-filter-source'
3943
import {
4044
buildPiScript,
4145
CLONE_TIMEOUT_MS,
@@ -47,8 +51,6 @@ import {
4751
GIT_CONFIG_DIGEST_MARKER,
4852
MAX_DIFF_BYTES,
4953
MIN_PI_TIMEOUT_MS,
50-
PI_EVENT_FILTER_PATH,
51-
PI_EVENT_FILTER_SOURCE,
5254
PROMPT_PATH,
5355
PUSH_ERROR_MAX,
5456
REPO_DIR,
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* The stdout filter the sandbox modes pipe the Pi CLI through, written at runtime like the search
3+
* extension and the review tools script next to it.
4+
*
5+
* Pi's `--mode json` writes `JSON.stringify(event)` for every session event with no filtering, and
6+
* `message_update` repeats the whole assistant message alongside each delta — so raw stdout grows
7+
* with the square of the response length, and `tool_execution_end`, `turn_end`, and `agent_end`
8+
* each add a full tool result, turn transcript, or run transcript on top of that.
9+
*
10+
* The reduction has to happen in the sandbox because by the time Sim could drop the bytes they are
11+
* already retained: E2B's SDK accumulates every callback-delivered chunk internally, so its adapter
12+
* charges all of it against `MAX_SANDBOX_PROCESS_OUTPUT_BYTES` even though the caller streams it,
13+
* and crossing that limit kills the sandbox mid-run — which on Create PR loses finished agent work
14+
* before the push. Daytona keeps only a rolling tail of a streamed output, so there the same stream
15+
* costs bandwidth and parse time rather than the run.
16+
*/
17+
18+
/**
19+
* Written outside `REPO_DIR` so `git add -A` cannot stage it into the user's pull request. The
20+
* agent can still read or overwrite it — it holds bash on the sandbox — but the pipeline loads it
21+
* at startup, before the agent's first turn, so a rewrite cannot change the events of the
22+
* invocation that read it.
23+
*/
24+
export const PI_EVENT_FILTER_PATH = '/workspace/sim-pi-event-filter.mjs'
25+
26+
/**
27+
* Every emitted shape is a subset of the fields `normalizePiEvent` reads, so the events Sim parses
28+
* are identical to the unfiltered stream's — an event it would normalize to `other` is dropped
29+
* outright, since nothing downstream distinguishes those from an absent line.
30+
*
31+
* That makes the two modules a pair: a field added to `normalizePiEvent` has to be added here too,
32+
* or it arrives only on the local and review paths while the cloud backends silently lose it.
33+
* `shared.test.ts` pins the equivalence by running this filter and comparing normalized output
34+
* against the normalized raw events, for the shapes it covers.
35+
*/
36+
export const PI_EVENT_FILTER_SOURCE = `/**
37+
* Generated by Sim. Compacts the Pi CLI's JSON event stream before the sandbox provider retains it.
38+
* Mirrors apps/sim/executor/handlers/pi/cloud/event-filter-source.ts.
39+
*/
40+
41+
function asRecord(value) {
42+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null
43+
}
44+
45+
function asString(value) {
46+
return typeof value === 'string' ? value : undefined
47+
}
48+
49+
function compactUsage(value) {
50+
const usage = asRecord(value)
51+
if (!usage) return null
52+
return {
53+
input: usage.input,
54+
output: usage.output,
55+
inputTokens: usage.inputTokens,
56+
outputTokens: usage.outputTokens,
57+
prompt_tokens: usage.prompt_tokens,
58+
completion_tokens: usage.completion_tokens,
59+
}
60+
}
61+
62+
function compactAssistantMessage(value) {
63+
const message = asRecord(value)
64+
if (!message || message.role !== 'assistant') return null
65+
return {
66+
role: 'assistant',
67+
stopReason: asString(message.stopReason),
68+
errorMessage: asString(message.errorMessage),
69+
}
70+
}
71+
72+
function compactEvent(value) {
73+
const event = asRecord(value)
74+
if (!event) return null
75+
76+
switch (event.type) {
77+
case 'message_update': {
78+
const update = asRecord(event.assistantMessageEvent)
79+
if (update?.type !== 'text_delta' && update?.type !== 'thinking_delta') return null
80+
return {
81+
type: 'message_update',
82+
assistantMessageEvent: { type: update.type, delta: asString(update.delta) },
83+
}
84+
}
85+
case 'tool_execution_start':
86+
return { type: 'tool_execution_start', toolName: asString(event.toolName) }
87+
case 'tool_execution_end':
88+
return {
89+
type: 'tool_execution_end',
90+
toolName: asString(event.toolName),
91+
isError: event.isError === true,
92+
}
93+
case 'turn_end': {
94+
const message = asRecord(event.message)
95+
const usage = compactUsage(event.usage)
96+
const messageUsage = compactUsage(message?.usage)
97+
if (!usage && !messageUsage) return null
98+
return {
99+
type: 'turn_end',
100+
...(usage ? { usage } : {}),
101+
...(messageUsage ? { message: { usage: messageUsage } } : {}),
102+
}
103+
}
104+
case 'agent_end': {
105+
if (event.willRetry === true) return { type: 'agent_end', willRetry: true }
106+
const messages = Array.isArray(event.messages) ? event.messages : []
107+
let assistant = null
108+
for (let index = messages.length - 1; index >= 0; index -= 1) {
109+
assistant = compactAssistantMessage(messages[index])
110+
if (assistant) break
111+
}
112+
return { type: 'agent_end', messages: assistant ? [assistant] : [] }
113+
}
114+
case 'error':
115+
return {
116+
type: 'error',
117+
error: asString(event.error),
118+
message: asString(event.message),
119+
}
120+
default:
121+
return null
122+
}
123+
}
124+
125+
function processLine(line) {
126+
if (!line.trim()) return
127+
try {
128+
const event = compactEvent(JSON.parse(line))
129+
if (event) process.stdout.write(JSON.stringify(event) + '\\n')
130+
} catch {}
131+
}
132+
133+
process.stdin.setEncoding('utf8')
134+
let buffer = ''
135+
process.stdin.on('data', (chunk) => {
136+
buffer += chunk
137+
const lines = buffer.split('\\n')
138+
buffer = lines.pop() ?? ''
139+
for (const line of lines) processLine(line)
140+
})
141+
process.stdin.on('end', () => processLine(buffer))
142+
`

apps/sim/executor/handlers/pi/cloud/shared.test.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import {
88
PI_SANDBOX_MAX_LIFETIME_MS,
99
PI_SANDBOX_MIN_LIFETIME_MS,
1010
} from '@/lib/execution/remote-sandbox/pi-lifetime'
11+
import {
12+
PI_EVENT_FILTER_PATH,
13+
PI_EVENT_FILTER_SOURCE,
14+
} from '@/executor/handlers/pi/cloud/event-filter-source'
1115
import {
1216
buildPiScript,
1317
CLONE_TIMEOUT_MS,
1418
FINALIZE_TIMEOUT_MS,
1519
MIN_PI_TIMEOUT_MS,
16-
PI_EVENT_FILTER_PATH,
17-
PI_EVENT_FILTER_SOURCE,
1820
resolvePiTimeoutMs,
1921
} from '@/executor/handlers/pi/cloud/shared'
2022
import { normalizePiEvent } from '@/executor/handlers/pi/core/events'
@@ -198,15 +200,12 @@ describe('PI_EVENT_FILTER_SOURCE', () => {
198200
event.messages.length > 0 &&
199201
(event.messages[0] as { stopReason?: string }).stopReason === 'stop'
200202
)
203+
// Reduced to the one message `normalizePiEvent` inspects, and to the three fields it reads off
204+
// it. The transcript, the thinking block, and the final text all go: the run's text reaches
205+
// Sim through the deltas, so carrying it again here would be the whole answer twice.
201206
expect(completed).toEqual({
202207
type: 'agent_end',
203-
messages: [
204-
{
205-
role: 'assistant',
206-
content: [{ type: 'text', text: 'final answer' }],
207-
stopReason: 'stop',
208-
},
209-
],
208+
messages: [{ role: 'assistant', stopReason: 'stop' }],
210209
})
211210
expect(output.some((event) => event.type === 'tool_execution_update')).toBe(false)
212211
})

apps/sim/executor/handlers/pi/cloud/shared.ts

Lines changed: 12 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77

88
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
99
import { resolvePiSandboxLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime'
10+
import { PI_EVENT_FILTER_PATH } from '@/executor/handlers/pi/cloud/event-filter-source'
1011
import { scrubPiSecrets } from '@/executor/handlers/pi/core/redaction'
1112

1213
export const REPO_DIR = '/workspace/repo'
1314
export const PROMPT_PATH = '/workspace/pi-prompt.txt'
1415
export const DIFF_PATH = '/workspace/pi.diff'
1516
export const COMMIT_MSG_PATH = '/workspace/pi-commit.txt'
1617
export const PUSH_ERR_PATH = '/workspace/pi-push-err.txt'
17-
export const PI_EVENT_FILTER_PATH = '/workspace/sim-pi-event-filter.mjs'
1818
export const CLONE_TIMEOUT_MS = 10 * 60 * 1000
1919
export const FINALIZE_TIMEOUT_MS = 10 * 60 * 1000
2020
export const MAX_DIFF_BYTES = 200_000
@@ -129,132 +129,19 @@ export const PUSH_SCRIPT = `cd ${REPO_DIR}
129129
/usr/bin/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" "HEAD:refs/heads/$BRANCH" >/dev/null 2>${PUSH_ERR_PATH} && echo "__PUSHED__=1"`
130130

131131
/**
132-
* Reduces Pi's cumulative JSON event stream before either sandbox provider retains it.
133-
* The emitted shapes contain only fields consumed by `normalizePiEvent`; in particular,
134-
* every `message_update` drops the full message Pi repeats alongside its delta.
135-
*/
136-
export const PI_EVENT_FILTER_SOURCE = `function asRecord(value) {
137-
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null
138-
}
139-
140-
function asString(value) {
141-
return typeof value === 'string' ? value : undefined
142-
}
143-
144-
function compactUsage(value) {
145-
const usage = asRecord(value)
146-
if (!usage) return null
147-
return {
148-
input: usage.input,
149-
output: usage.output,
150-
inputTokens: usage.inputTokens,
151-
outputTokens: usage.outputTokens,
152-
prompt_tokens: usage.prompt_tokens,
153-
completion_tokens: usage.completion_tokens,
154-
}
155-
}
156-
157-
function compactAssistantMessage(value) {
158-
const message = asRecord(value)
159-
if (!message || message.role !== 'assistant') return null
160-
const content = Array.isArray(message.content)
161-
? message.content.flatMap((value) => {
162-
const block = asRecord(value)
163-
return block?.type === 'text' && typeof block.text === 'string'
164-
? [{ type: 'text', text: block.text }]
165-
: []
166-
})
167-
: []
168-
return {
169-
role: 'assistant',
170-
content,
171-
stopReason: asString(message.stopReason),
172-
errorMessage: asString(message.errorMessage),
173-
}
174-
}
175-
176-
function compactEvent(value) {
177-
const event = asRecord(value)
178-
if (!event) return null
179-
180-
switch (event.type) {
181-
case 'message_update': {
182-
const update = asRecord(event.assistantMessageEvent)
183-
if (update?.type !== 'text_delta' && update?.type !== 'thinking_delta') return null
184-
return {
185-
type: 'message_update',
186-
assistantMessageEvent: { type: update.type, delta: asString(update.delta) },
187-
}
188-
}
189-
case 'tool_execution_start':
190-
return { type: 'tool_execution_start', toolName: asString(event.toolName) }
191-
case 'tool_execution_end':
192-
return {
193-
type: 'tool_execution_end',
194-
toolName: asString(event.toolName),
195-
isError: event.isError === true,
196-
}
197-
case 'turn_end': {
198-
const message = asRecord(event.message)
199-
const usage = compactUsage(event.usage)
200-
const messageUsage = compactUsage(message?.usage)
201-
if (!usage && !messageUsage) return null
202-
return {
203-
type: 'turn_end',
204-
...(usage ? { usage } : {}),
205-
...(messageUsage ? { message: { usage: messageUsage } } : {}),
206-
}
207-
}
208-
case 'agent_end': {
209-
if (event.willRetry === true) return { type: 'agent_end', willRetry: true }
210-
const messages = Array.isArray(event.messages) ? event.messages : []
211-
let assistant = null
212-
for (let index = messages.length - 1; index >= 0; index -= 1) {
213-
assistant = compactAssistantMessage(messages[index])
214-
if (assistant) break
215-
}
216-
return { type: 'agent_end', messages: assistant ? [assistant] : [] }
217-
}
218-
case 'error':
219-
return {
220-
type: 'error',
221-
error: asString(event.error),
222-
message: asString(event.message),
223-
}
224-
default:
225-
return null
226-
}
227-
}
228-
229-
function processLine(line) {
230-
if (!line.trim()) return
231-
try {
232-
const event = compactEvent(JSON.parse(line))
233-
if (event) process.stdout.write(JSON.stringify(event) + '\\n')
234-
} catch {}
235-
}
236-
237-
process.stdin.setEncoding('utf8')
238-
let buffer = ''
239-
process.stdin.on('data', (chunk) => {
240-
buffer += chunk
241-
const lines = buffer.split('\\n')
242-
buffer = lines.pop() ?? ''
243-
for (const line of lines) processLine(line)
244-
})
245-
process.stdin.on('end', () => processLine(buffer))
246-
`
247-
248-
/**
249-
* The Pi CLI invocation for the sandbox modes. Every caller receives the compact event stream;
250-
* options only control which repository resources Pi may load.
132+
* The Pi CLI invocation for the sandbox modes, piped through the sandbox event filter. The command
133+
* names {@link PI_EVENT_FILTER_PATH}, so every caller must have written `PI_EVENT_FILTER_SOURCE`
134+
* there first — skipping that write does not fall back to the raw stream, it fails the run on the
135+
* missing module.
251136
*
252-
* Selects `/bin/bash` explicitly because `pipefail` is not portable to `/bin/sh`. Both dedicated
253-
* Pi images are Debian-based and provide Bash, so provider default-shell behavior cannot change
254-
* whether an upstream Pi failure reaches the caller.
137+
* Selects `/bin/bash` explicitly because `pipefail` is not portable to `/bin/sh`, and without it
138+
* the pipeline reports the filter's exit code rather than Pi's, so an upstream crash would read as
139+
* a clean run. Both dedicated Pi images are Debian-based and provide Bash, so provider
140+
* default-shell behavior cannot change whether an upstream Pi failure reaches the caller.
255141
*
256-
* With one, `--no-extensions` drops any extension the cloned repository ships while leaving the
257-
* explicit `-e` path loaded, so the loaded set is exactly Sim's own extension. That is deliberate —
142+
* With no options the repository resources Pi loads are exactly what Create PR always had. With an
143+
* `extensionPath`, `--no-extensions` drops any extension the cloned repository ships while leaving
144+
* the explicit `-e` path loaded, so the loaded set is exactly Sim's own extension. That is deliberate —
258145
* a repository must not be able to register tools into a run holding the workspace's keys — but it
259146
* does mean enabling search also stops loading a repository's own Pi extensions, which is why the
260147
* flag is not passed on Create PR's no-search path. Babysit supplies

apps/sim/executor/handlers/pi/core/events.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,13 @@ function extractUsage(
114114
return null
115115
}
116116

117-
/** Normalizes a raw Pi/SDK event object into a {@link PiEvent}. */
117+
/**
118+
* Normalizes a raw Pi/SDK event object into a {@link PiEvent}.
119+
*
120+
* The cloud backends feed this from a stream the sandbox already reduced to the fields read below
121+
* (`cloud/event-filter-source.ts`), so a field added here has to be added there too — otherwise it
122+
* arrives only on the local and review paths and the cloud ones silently lose it.
123+
*/
118124
export function normalizePiEvent(raw: unknown): PiEvent | null {
119125
const ev = asRecord(raw)
120126
if (!ev) return null

0 commit comments

Comments
 (0)