chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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(() => {})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user