Files
simstudioai--sim/apps/sim/executor/handlers/pi/local-backend.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

205 lines
7.9 KiB
TypeScript

/**
* Local-mode backend: runs the Pi harness embedded in Sim with its built-in
* tools disabled and replaced by SSH-backed file/bash tools (plus any adapted
* Sim tools), all over a single reused SSH connection. The provider key stays in
* Sim's process (injected via `authStorage.setRuntimeApiKey`); only file/bash
* operations cross to the target machine.
*
* The Pi SDK is imported dynamically and externalized from the bundle, mirroring
* how `@e2b/code-interpreter` is loaded, so the package is resolved at runtime.
*/
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ModelRegistry, ToolDefinition } from '@earendil-works/pi-coding-agent'
import { createLogger } from '@sim/logger'
import type { PiBackendRun, PiLocalRunParams, PiToolSpec } from '@/executor/handlers/pi/backend'
import { buildPiPrompt } from '@/executor/handlers/pi/context'
import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/events'
import { mapThinkingLevel } from '@/executor/handlers/pi/keys'
import {
buildSshToolSpecs,
captureRepoChanges,
openSshSession,
} from '@/executor/handlers/pi/ssh-tools'
const logger = createLogger('PiLocalBackend')
const MAX_DIFF_BYTES = 200_000
// Local mode edits in place and reports the working-tree diff. The agent must not
// commit (a commit would hide the changes from `git diff HEAD`) or push/open a PR.
const LOCAL_GUIDANCE =
'Use the provided read/write/edit/bash tools to make the file changes needed to complete the task; they ' +
'operate on the target repository. Do not commit, push, or open a pull request — leave your changes in the ' +
'working tree; Sim reports them after you finish.'
/** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */
type PiSdk = typeof import('@earendil-works/pi-coding-agent')
let sdkPromise: Promise<PiSdk> | undefined
function loadPiSdk(): Promise<PiSdk> {
if (!sdkPromise) {
// A static specifier (not a variable) is required so Next's dependency tracer
// copies the package + its transitive deps into the standalone Docker output,
// the same way `@e2b/code-interpreter` is handled. Clear the cache on failure
// so a transient import error doesn't permanently break later local runs.
sdkPromise = import('@earendil-works/pi-coding-agent').catch((error) => {
sdkPromise = undefined
throw error
})
}
return sdkPromise
}
function toPiTool(sdk: PiSdk, spec: PiToolSpec): ToolDefinition {
return sdk.defineTool({
name: spec.name,
label: spec.name,
description: spec.description,
// double-cast-allowed: Pi accepts a plain JSON Schema at runtime (pi-ai validation.js coerceWithJsonSchema); the static type requires a TypeBox TSchema
parameters: spec.parameters as unknown as ToolDefinition['parameters'],
execute: async (_toolCallId, params) => {
const result = await spec.execute(params as Record<string, unknown>)
return {
content: [{ type: 'text', text: result.text }],
details: { isError: result.isError },
}
},
})
}
/**
* Builds a model definition for a provider Pi supports but whose bundled catalog
* doesn't list this exact id (e.g. a newer model Pi wires to a different
* provider). Mirrors the cloud CLI's passthrough: clone one of the provider's
* models as a template, swap in the requested id, and force reasoning when a
* thinking level is requested. Returns undefined only when the provider has no
* models at all, so even passthrough can't route it.
*/
function buildPiFallbackModel(
modelRegistry: ModelRegistry,
provider: string,
modelId: string,
thinkingLevel: ReturnType<typeof mapThinkingLevel>
) {
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider)
if (providerModels.length === 0) return undefined
const fallback = { ...providerModels[0], id: modelId, name: modelId }
return thinkingLevel && thinkingLevel !== 'off' ? { ...fallback, reasoning: true } : fallback
}
export const runLocalPi: PiBackendRun<PiLocalRunParams> = async (params, context) => {
// Isolate Pi resource discovery: an empty cwd/agentDir keeps DefaultResourceLoader
// from loading the Sim server's own .agents/skills, AGENTS.md, extensions, or settings.
const isolatedDir = await mkdtemp(join(tmpdir(), 'sim-pi-'))
// Clean up the scratch dir if the SSH connection fails — the try/finally below
// is only entered once the session is open, so an early handshake failure would
// otherwise orphan the directory.
const session = await openSshSession(params.ssh).catch(async (error) => {
await rm(isolatedDir, { recursive: true, force: true }).catch(() => {})
throw error
})
try {
const sdk = await loadPiSdk()
const authStorage = sdk.AuthStorage.create()
authStorage.setRuntimeApiKey(params.providerId, params.apiKey)
const modelRegistry = sdk.ModelRegistry.create(authStorage)
const thinkingLevel = mapThinkingLevel(params.thinkingLevel)
// Parity with cloud: when the model isn't in Pi's bundled catalog under the
// resolved provider, pass it through on that provider instead of failing.
const model =
modelRegistry.find(params.providerId, params.model) ??
buildPiFallbackModel(modelRegistry, params.providerId, params.model, thinkingLevel)
if (!model) {
throw new Error(
`Pi has no models for provider "${params.providerId}" (cannot run ${params.model})`
)
}
const specs = [...buildSshToolSpecs(session, params.repoPath), ...params.tools]
const customTools = specs.map((spec) => toPiTool(sdk, spec))
const { session: agentSession } = await sdk.createAgentSession({
cwd: isolatedDir,
agentDir: isolatedDir,
model,
thinkingLevel,
noTools: 'builtin',
customTools,
authStorage,
modelRegistry,
sessionManager: sdk.SessionManager.inMemory(isolatedDir),
})
const totals = createPiTotals()
const unsubscribe = agentSession.subscribe((raw) => {
const event = normalizePiEvent(raw)
if (!event) return
applyPiEvent(totals, event)
context.onEvent(event)
})
const onAbort = () => {
void agentSession.abort()
}
if (context.signal?.aborted) {
onAbort()
} else {
context.signal?.addEventListener('abort', onAbort, { once: true })
}
let runErrorMessage: string | undefined
try {
await agentSession.prompt(
buildPiPrompt({
skills: params.skills,
initialMessages: params.initialMessages,
task: params.task,
guidance: LOCAL_GUIDANCE,
})
)
// Pi has no error event; a failed run surfaces on the agent state. Capture
// it before `dispose()` so the failure can't be missed by a later read.
runErrorMessage = agentSession.agent.state.errorMessage
} finally {
unsubscribe()
context.signal?.removeEventListener('abort', onAbort)
try {
agentSession.dispose()
} catch (error) {
logger.warn('Failed to dispose Pi session', { error })
}
}
// Aborts propagate as errors so a cancelled/timed-out run is not reported as
// success and no partial memory turn is persisted (cloud mode mirrors this).
// Pi resolves `prompt()` on abort rather than rejecting, so check explicitly.
if (context.signal?.aborted) {
throw new Error('Pi run aborted')
}
if (runErrorMessage) {
totals.errorMessage = runErrorMessage
return { totals }
}
// Local mode edits in place (no PR), so report what changed via the repo's
// working-tree diff over the same SSH session.
const { changedFiles, diff } = await captureRepoChanges(
session,
params.repoPath,
MAX_DIFF_BYTES
)
return { totals, changedFiles, diff }
} finally {
session.close()
await rm(isolatedDir, { recursive: true, force: true }).catch(() => {})
}
}