Skip to content

Commit 5ece9fd

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
chore(pi): upgrade agent to 1.0.0
1 parent d05ae7a commit 5ece9fd

19 files changed

Lines changed: 895 additions & 239 deletions
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { execFile } from 'node:child_process'
2+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { promisify } from 'node:util'
6+
import { describe, expect, it } from 'vitest'
7+
import { PI_EVENT_FILTER_SOURCE } from '@/executor/handlers/pi/cloud/event-filter-source'
8+
import { buildPiScript } from '@/executor/handlers/pi/cloud/shared'
9+
import { PI_PACKAGE_VERSION } from '@/scripts/pi-sandbox-packages'
10+
11+
const exec = promisify(execFile)
12+
13+
async function invokeSandbox(version: string, exitCode = 0) {
14+
const root = await mkdtemp(join(tmpdir(), 'sim-pi-cli-'))
15+
const workspace = join(root, 'workspace')
16+
const repo = join(workspace, 'repo')
17+
const bin = join(root, 'bin')
18+
await mkdir(repo, { recursive: true })
19+
await mkdir(bin)
20+
await writeFile(join(workspace, 'pi-prompt.txt'), 'fixture prompt')
21+
await writeFile(join(workspace, 'sim-pi-event-filter.mjs'), PI_EVENT_FILTER_SOURCE)
22+
await writeFile(
23+
join(bin, 'pi'),
24+
`#!/bin/bash
25+
if [ "$1" = "--version" ]; then
26+
printf "%s\\n" "${version}"
27+
exit 0
28+
fi
29+
printf "%s" "$PI_CODING_AGENT_DIR" > "${root}/invoked"
30+
printf "%s\\n" '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"ok"}}'
31+
exit ${exitCode}
32+
`,
33+
{ mode: 0o700 }
34+
)
35+
const script = buildPiScript().replaceAll('/workspace', workspace)
36+
try {
37+
const result = await exec('/bin/bash', ['-c', script], {
38+
env: {
39+
...process.env,
40+
PATH: `${bin}:${process.env.PATH}`,
41+
PI_PROVIDER: 'fixture',
42+
PI_MODEL: 'fixture',
43+
PI_THINKING: 'off',
44+
},
45+
}).then(
46+
({ stdout, stderr }) => ({ code: 0, stdout, stderr }),
47+
(error: { code: number; stdout: string; stderr: string }) => error
48+
)
49+
const invoked = await readFile(join(root, 'invoked'), 'utf8').catch(() => null)
50+
const settings = invoked
51+
? await readFile(join(invoked, 'settings.json'), 'utf8').catch(() => null)
52+
: null
53+
const storedFiles = invoked ? await readdir(invoked).catch(() => []) : []
54+
return { ...result, invoked, settings, storedFiles, repo }
55+
} finally {
56+
await rm(root, { recursive: true, force: true })
57+
}
58+
}
59+
60+
describe('sandbox Pi runtime boundary', () => {
61+
it.each(['0.80.10', 'unexpected-version'])(
62+
'rejects %s before starting the agent',
63+
async (version) => {
64+
const result = await invokeSandbox(version)
65+
expect(result.code).not.toBe(0)
66+
expect(result.invoked).toBeNull()
67+
expect(result.stderr).toMatch(/rebuild|update/i)
68+
expect(result.stderr).toContain(PI_PACKAGE_VERSION)
69+
}
70+
)
71+
72+
it('uses private settings outside the repository with warming off and no saved credentials', async () => {
73+
const result = await invokeSandbox(PI_PACKAGE_VERSION)
74+
expect(result.code).toBe(0)
75+
expect(result.invoked).toBeTruthy()
76+
expect(result.invoked?.startsWith(result.repo)).toBe(false)
77+
expect(JSON.parse(result.settings ?? '{}')).toEqual({ cacheWarming: 'off' })
78+
expect(result.storedFiles).toEqual(['settings.json'])
79+
expect(JSON.parse(result.stdout.trim())).toEqual({
80+
type: 'message_update',
81+
assistantMessageEvent: { type: 'text_delta', delta: 'ok' },
82+
})
83+
})
84+
85+
it('propagates a failing CLI even when the stdout filter succeeds', async () => {
86+
expect((await invokeSandbox(PI_PACKAGE_VERSION, 42)).code).toBe(42)
87+
})
88+
})

‎apps/sim/executor/handlers/pi/cloud/event-filter-source.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
* The stdout filter the sandbox modes pipe the Pi CLI through, written at runtime like the search
33
* extension and the review tools script next to it.
44
*
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.
5+
* Pi 1.0 emits delta-only `message_update` events, but `tool_execution_end`, `turn_end`, and
6+
* `agent_end` still include tool results and transcripts. The filter preserves Sim's event
7+
* contract without retaining those cumulative payloads.
98
*
109
* The reduction has to happen in the sandbox because by the time Sim could drop the bytes they are
1110
* already retained: E2B's SDK accumulates every callback-delivered chunk internally, so its adapter
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { execFile } from 'node:child_process'
2+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3+
import * as http from 'node:http'
4+
import { tmpdir } from 'node:os'
5+
import { dirname, join } from 'node:path'
6+
import { fileURLToPath } from 'node:url'
7+
import { promisify } from 'node:util'
8+
import { describe, expect, it } from 'vitest'
9+
import { PI_EVENT_FILTER_SOURCE } from '@/executor/handlers/pi/cloud/event-filter-source'
10+
import { buildPiScript } from '@/executor/handlers/pi/cloud/shared'
11+
import {
12+
FIXTURE_KEY,
13+
startPiProviderFixture,
14+
} from '@/executor/handlers/pi/core/__fixtures__/provider'
15+
import { applyPiEvent, createPiTotals, parseJsonLine } from '@/executor/handlers/pi/core/events'
16+
import { PI_SEARCH_EXTENSION_SOURCE } from '@/executor/handlers/pi/search/extension-source'
17+
18+
const exec = promisify(execFile)
19+
20+
describe('installed Pi CLI wire', () => {
21+
it('loads explicit Sim search with --no-extensions and keeps warming disabled', async () => {
22+
const root = await mkdtemp(join(tmpdir(), 'sim-pi-installed-cli-'))
23+
const workspace = join(root, 'workspace')
24+
const fixture = await startPiProviderFixture(http)
25+
try {
26+
const repo = join(workspace, 'repo')
27+
const bin = join(root, 'bin')
28+
const providerPath = join(root, 'provider.ts')
29+
const searchPath = join(workspace, 'sim-search-extension.ts')
30+
const preload = join(root, 'preload.mjs')
31+
await mkdir(join(repo, '.pi', 'extensions'), { recursive: true })
32+
await mkdir(bin)
33+
await writeFile(
34+
join(repo, '.pi', 'extensions', 'poison.ts'),
35+
'throw new Error("repository extension loaded")'
36+
)
37+
await writeFile(join(workspace, 'pi-prompt.txt'), 'search-fixture')
38+
await writeFile(join(workspace, 'sim-pi-event-filter.mjs'), PI_EVENT_FILTER_SOURCE)
39+
await writeFile(searchPath, PI_SEARCH_EXTENSION_SOURCE)
40+
await writeFile(
41+
providerPath,
42+
`export default function(pi) { pi.registerProvider("fixture", ${JSON.stringify({ ...fixture.provider, apiKey: FIXTURE_KEY })}) }`
43+
)
44+
await writeFile(
45+
preload,
46+
`const guard = (originalFetch) => (input, init) => {
47+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
48+
if (url.origin === "https://api.exa.ai" && url.pathname === "/search") return originalFetch(${JSON.stringify(`${fixture.baseUrl}/exa`)}, init);
49+
if (url.origin === ${JSON.stringify(fixture.baseUrl)}) return originalFetch(input, init);
50+
throw new Error("Unexpected external fixture traffic: " + url.origin);
51+
};
52+
let guardedFetch = guard(globalThis.fetch);
53+
Object.defineProperty(globalThis, "fetch", { configurable: true, get: () => guardedFetch, set: (fetch) => { guardedFetch = guard(fetch); } });`
54+
)
55+
const cli = join(
56+
dirname(fileURLToPath(import.meta.resolve('@earendil-works/pi-coding-agent'))),
57+
'bundle',
58+
'cli.js'
59+
)
60+
await writeFile(join(bin, 'pi'), `#!/bin/bash\nexec node "${cli}" "$@"\n`, { mode: 0o700 })
61+
const script = buildPiScript('/workspace/sim-search-extension.ts', {
62+
disableRepositoryResources: true,
63+
})
64+
.replaceAll('/workspace', workspace)
65+
.replace(' < ', ` -e ${providerPath} < `)
66+
const { stdout } = await exec('/bin/bash', ['-c', script], {
67+
env: {
68+
...process.env,
69+
PATH: `${bin}:${process.env.PATH}`,
70+
NODE_OPTIONS: `--import ${preload}`,
71+
PI_OFFLINE: '1',
72+
PI_PROVIDER: 'fixture',
73+
PI_MODEL: 'fixture-model',
74+
PI_THINKING: 'medium',
75+
SIM_SEARCH_PROVIDER: 'exa',
76+
SIM_SEARCH_API_KEY: FIXTURE_KEY,
77+
},
78+
timeout: 15_000,
79+
})
80+
const totals = createPiTotals()
81+
for (const line of stdout.split('\n')) {
82+
const event = parseJsonLine(line)
83+
if (event) applyPiEvent(totals, event)
84+
}
85+
expect(JSON.stringify(fixture.requests.at(-1)?.body.messages)).toContain(
86+
'Offline search evidence'
87+
)
88+
expect(totals).toEqual({
89+
finalText: 'fixture complete',
90+
inputTokens: 14,
91+
outputTokens: 6,
92+
toolCalls: [{ name: 'web_search', isError: false }],
93+
})
94+
expect(fixture.requests.map(({ path }) => path)).toEqual([
95+
'/v1/chat/completions',
96+
'/exa',
97+
'/v1/chat/completions',
98+
])
99+
const agentDir = join(workspace, 'sim-pi-agent')
100+
expect(JSON.parse(await readFile(join(agentDir, 'settings.json'), 'utf8'))).toEqual({
101+
cacheWarming: 'off',
102+
})
103+
expect(JSON.parse(await readFile(join(agentDir, 'auth.json'), 'utf8'))).toEqual({})
104+
} finally {
105+
await fixture.close()
106+
await rm(root, { recursive: true, force: true })
107+
}
108+
}, 30_000)
109+
})

‎apps/sim/executor/handlers/pi/cloud/review/backend.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ describe('runCloudReviewPi', () => {
201201

202202
expect(mockSetRuntimeApiKey).toHaveBeenCalledWith('anthropic', 'sk-byok')
203203
expect(mockRemoveRuntimeApiKey).toHaveBeenCalledWith('anthropic')
204+
expect(mockSdk.SettingsManager.inMemory).toHaveBeenCalledWith({ cacheWarming: 'off' })
204205
expect(mockCreateSealedResourceLoader).toHaveBeenCalledTimes(1)
205206
expect(mockCreateAgentSession).toHaveBeenCalledWith(
206207
expect.objectContaining({

‎apps/sim/executor/handlers/pi/cloud/review/backend.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ export const runCloudReviewPi: PiBackendRun<PiCloudReviewRunParams> = async (par
300300
)
301301
}
302302

303-
const settingsManager = sdk.SettingsManager.inMemory()
303+
const settingsManager = sdk.SettingsManager.inMemory({ cacheWarming: 'off' })
304304
const resourceLoader = createSealedPiResourceLoader(
305305
sdk,
306306
buildReviewSystemPrompt(Boolean(searchTool))

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
99
import { resolvePiSandboxLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime'
1010
import { PI_EVENT_FILTER_PATH } from '@/executor/handlers/pi/cloud/event-filter-source'
1111
import { scrubPiSecrets } from '@/executor/handlers/pi/core/redaction'
12+
import { PI_PACKAGE_VERSION } from '@/scripts/pi-sandbox-packages'
1213

1314
export const REPO_DIR = '/workspace/repo'
1415
export const PROMPT_PATH = '/workspace/pi-prompt.txt'
@@ -141,6 +142,10 @@ export const PUSH_SCRIPT = `cd ${REPO_DIR}
141142
* there first — skipping that write does not fall back to the raw stream, it fails the run on the
142143
* missing module.
143144
*
145+
* Rejects stale images before starting the agent. Each invocation resets Sim's private agent
146+
* directory outside the clone, so neither stored credentials nor cache-warming settings can leak
147+
* between rounds. Model credentials remain environment-only.
148+
*
144149
* Selects `/bin/bash` explicitly because `pipefail` is not portable to `/bin/sh`, and without it
145150
* the pipeline reports the filter's exit code rather than Pi's, so an upstream crash would read as
146151
* a clean run. Both dedicated Pi images are Debian-based and provide Bash, so provider
@@ -165,7 +170,17 @@ export function buildPiScript(
165170
? ' --no-extensions'
166171
: ''
167172
const extensionArgs = extensionPath ? ` -e ${extensionPath}` : ''
168-
return `/bin/bash -o pipefail -c 'cd ${REPO_DIR}
173+
return `/bin/bash -o pipefail -c 'set -e
174+
if ! PI_INSTALLED_VERSION="$(pi --version 2>/dev/null)" || [ "$PI_INSTALLED_VERSION" != "${PI_PACKAGE_VERSION}" ]; then
175+
printf "%s\\n" "Pi sandbox runtime must be ${PI_PACKAGE_VERSION}. Rebuild or update the configured Pi sandbox image before running this workflow." >&2
176+
exit 1
177+
fi
178+
export PI_CODING_AGENT_DIR=/workspace/sim-pi-agent
179+
(umask 077
180+
rm -rf "$PI_CODING_AGENT_DIR"
181+
mkdir -p "$PI_CODING_AGENT_DIR"
182+
printf "%s\\n" "{\\"cacheWarming\\":\\"off\\"}" > "$PI_CODING_AGENT_DIR/settings.json")
183+
cd ${REPO_DIR}
169184
pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING"${repositoryArgs}${extensionArgs} < ${PROMPT_PATH} | node ${PI_EVENT_FILTER_PATH}'`
170185
}
171186

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { once } from 'node:events'
2+
import type * as NodeHTTP from 'node:http'
3+
import type { AddressInfo } from 'node:net'
4+
import type { ProviderConfig } from '@earendil-works/pi-coding-agent'
5+
6+
export const FIXTURE_KEY = 'pi-offline-fixture-key'
7+
8+
export async function startPiProviderFixture(http: typeof NodeHTTP) {
9+
const requests: Array<{ path: string; body: Record<string, unknown>; authorization?: string }> =
10+
[]
11+
let onRequest: (() => void) | undefined
12+
const requested = new Promise<void>((resolve) => {
13+
onRequest = resolve
14+
})
15+
const server = http.createServer(async (request, response) => {
16+
let raw = ''
17+
for await (const chunk of request) raw += chunk
18+
const body = JSON.parse(raw || '{}') as Record<string, unknown>
19+
requests.push({ path: request.url ?? '', body, authorization: request.headers.authorization })
20+
onRequest?.()
21+
if (request.url === '/exa') {
22+
response.setHeader('content-type', 'application/json')
23+
response.end(
24+
JSON.stringify({
25+
results: [
26+
{
27+
title: 'Fixture result',
28+
url: 'https://example.com/fixture',
29+
text: 'Offline search evidence',
30+
},
31+
],
32+
})
33+
)
34+
return
35+
}
36+
const messages = Array.isArray(body.messages) ? body.messages : []
37+
const prompt = JSON.stringify(messages)
38+
if (prompt.includes('provider-error')) {
39+
response.writeHead(400, { 'content-type': 'application/json' })
40+
response.end(
41+
JSON.stringify({
42+
error: { message: `provider failed ${FIXTURE_KEY}`, type: 'invalid_request_error' },
43+
})
44+
)
45+
return
46+
}
47+
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
48+
const chunk = (delta: Record<string, unknown>, finish: string | null = null) => {
49+
response.write(
50+
`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk', model: 'fixture-model', choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`
51+
)
52+
}
53+
chunk({ role: 'assistant' })
54+
if (prompt.includes('cancel-fixture')) return
55+
const hasToolResult = messages.some((message: { role?: string }) => message.role === 'tool')
56+
if (!hasToolResult) {
57+
chunk({ reasoning_content: 'checking fixture' })
58+
const search = prompt.includes('search-fixture')
59+
chunk({
60+
tool_calls: [
61+
{
62+
index: 0,
63+
id: 'call-fixture',
64+
type: 'function',
65+
function: {
66+
name: search ? 'web_search' : 'fixture_tool',
67+
arguments: JSON.stringify(
68+
search ? { query: 'fixture query' } : { value: 'fixture input' }
69+
),
70+
},
71+
},
72+
],
73+
})
74+
chunk({}, 'tool_calls')
75+
} else {
76+
chunk({ content: 'fixture complete' })
77+
chunk({}, 'stop')
78+
}
79+
response.write(
80+
`data: ${JSON.stringify({ id: 'fixture', choices: [], usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 } })}\n\ndata: [DONE]\n\n`
81+
)
82+
response.end()
83+
})
84+
server.listen(0, '127.0.0.1')
85+
await once(server, 'listening')
86+
const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
87+
const provider: ProviderConfig = {
88+
baseUrl: `${baseUrl}/v1`,
89+
api: 'openai-completions',
90+
models: [
91+
{
92+
id: 'fixture-model',
93+
name: 'Offline fixture',
94+
reasoning: true,
95+
input: ['text'],
96+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
97+
contextWindow: 100_000,
98+
maxTokens: 1000,
99+
compat: {
100+
supportsDeveloperRole: false,
101+
supportsStore: false,
102+
supportsReasoningEffort: false,
103+
},
104+
},
105+
],
106+
}
107+
return {
108+
baseUrl,
109+
provider,
110+
requests,
111+
requested,
112+
async close() {
113+
server.closeAllConnections()
114+
await new Promise<void>((resolve, reject) =>
115+
server.close((error) => (error ? reject(error) : resolve()))
116+
)
117+
},
118+
}
119+
}

0 commit comments

Comments
 (0)