d25d482dc2
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
263 lines
8.7 KiB
TypeScript
263 lines
8.7 KiB
TypeScript
/**
|
|
* Executor handler for the Pi Coding Agent block. Resolves the model key,
|
|
* skills, and memory, selects a backend by `mode`, and runs it — streaming the
|
|
* agent's text to the client when the block is selected for streaming output,
|
|
* otherwise returning a plain block output. The handler depends only on the
|
|
* {@link PiBackendRun} seam and never reaches into backend internals.
|
|
*/
|
|
|
|
import { createLogger } from '@sim/logger'
|
|
import type { BlockOutput } from '@/blocks/types'
|
|
import { parseOptionalNumberInput } from '@/blocks/utils'
|
|
import { BlockType } from '@/executor/constants'
|
|
import type {
|
|
PiBackendRun,
|
|
PiCloudRunParams,
|
|
PiLocalRunParams,
|
|
PiRunParams,
|
|
PiRunResult,
|
|
} from '@/executor/handlers/pi/backend'
|
|
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
|
|
import {
|
|
appendPiMemory,
|
|
loadPiMemory,
|
|
type PiMemoryConfig,
|
|
resolvePiSkills,
|
|
} from '@/executor/handlers/pi/context'
|
|
import { streamTextForEvent } from '@/executor/handlers/pi/events'
|
|
import { computePiCost, resolvePiModelKey } from '@/executor/handlers/pi/keys'
|
|
import { runLocalPi } from '@/executor/handlers/pi/local-backend'
|
|
import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools'
|
|
import type {
|
|
BlockHandler,
|
|
ExecutionContext,
|
|
NormalizedBlockOutput,
|
|
StreamingExecution,
|
|
} from '@/executor/types'
|
|
import type { SerializedBlock } from '@/serializer/types'
|
|
|
|
const logger = createLogger('PiBlockHandler')
|
|
const DEFAULT_MODEL = 'claude-sonnet-5'
|
|
|
|
function asOptString(value: unknown): string | undefined {
|
|
if (typeof value !== 'string') return undefined
|
|
const trimmed = value.trim()
|
|
return trimmed ? trimmed : undefined
|
|
}
|
|
|
|
function asRawString(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value !== '' ? value : undefined
|
|
}
|
|
|
|
export class PiBlockHandler implements BlockHandler {
|
|
canHandle(block: SerializedBlock): boolean {
|
|
return block.metadata?.id === BlockType.PI
|
|
}
|
|
|
|
async execute(
|
|
ctx: ExecutionContext,
|
|
block: SerializedBlock,
|
|
inputs: Record<string, any>
|
|
): Promise<BlockOutput | StreamingExecution> {
|
|
const task = asOptString(inputs.task)
|
|
if (!task) throw new Error('Task is required')
|
|
const model = asOptString(inputs.model) ?? DEFAULT_MODEL
|
|
|
|
// Validate the mode up front so an invalid value reports a mode error rather
|
|
// than a misattributed credential error from key resolution below.
|
|
if (inputs.mode !== 'cloud' && inputs.mode !== 'local') {
|
|
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
|
|
}
|
|
const mode: 'cloud' | 'local' = inputs.mode
|
|
|
|
const { providerId, apiKey, isBYOK } = await resolvePiModelKey({
|
|
model,
|
|
mode,
|
|
workspaceId: ctx.workspaceId,
|
|
userId: ctx.userId,
|
|
apiKey: asRawString(inputs.apiKey),
|
|
vertexCredential: asOptString(inputs.vertexCredential),
|
|
})
|
|
|
|
const skills = await resolvePiSkills(inputs.skills, ctx.workspaceId)
|
|
const memoryConfig: PiMemoryConfig = {
|
|
memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'],
|
|
conversationId: asOptString(inputs.conversationId),
|
|
slidingWindowSize: asOptString(inputs.slidingWindowSize),
|
|
slidingWindowTokens: asOptString(inputs.slidingWindowTokens),
|
|
model,
|
|
}
|
|
const initialMessages = await loadPiMemory(ctx, memoryConfig)
|
|
|
|
const base = {
|
|
model,
|
|
providerId,
|
|
apiKey,
|
|
isBYOK,
|
|
task,
|
|
thinkingLevel: asOptString(inputs.thinkingLevel),
|
|
skills,
|
|
initialMessages,
|
|
}
|
|
|
|
if (mode === 'local') {
|
|
const host = asOptString(inputs.host)
|
|
const username = asOptString(inputs.username)
|
|
const repoPath = asOptString(inputs.repoPath)
|
|
if (!host || !username || !repoPath) {
|
|
throw new Error('Local mode requires host, username, and repository path')
|
|
}
|
|
const usePrivateKey = inputs.authMethod === 'privateKey'
|
|
const port = parseOptionalNumberInput(inputs.port, 'port', { integer: true, min: 1 }) ?? 22
|
|
const tools = await buildSimToolSpecs(ctx, inputs.tools)
|
|
const params: PiLocalRunParams = {
|
|
...base,
|
|
mode: 'local',
|
|
repoPath,
|
|
tools,
|
|
ssh: {
|
|
host,
|
|
port,
|
|
username,
|
|
password: usePrivateKey ? undefined : asRawString(inputs.password),
|
|
privateKey: usePrivateKey ? asRawString(inputs.privateKey) : undefined,
|
|
passphrase: usePrivateKey ? asRawString(inputs.passphrase) : undefined,
|
|
},
|
|
}
|
|
return this.runPi(ctx, block, runLocalPi, params, memoryConfig)
|
|
}
|
|
|
|
if (mode === 'cloud') {
|
|
const owner = asOptString(inputs.owner)
|
|
const repo = asOptString(inputs.repo)
|
|
const githubToken = asRawString(inputs.githubToken)
|
|
if (!owner || !repo || !githubToken) {
|
|
throw new Error('Cloud mode requires repository owner, name, and a GitHub token')
|
|
}
|
|
const params: PiCloudRunParams = {
|
|
...base,
|
|
mode: 'cloud',
|
|
owner,
|
|
repo,
|
|
githubToken,
|
|
baseBranch: asOptString(inputs.baseBranch),
|
|
branchName: asOptString(inputs.branchName),
|
|
draft: inputs.draft !== false,
|
|
prTitle: asOptString(inputs.prTitle),
|
|
prBody: asOptString(inputs.prBody),
|
|
}
|
|
return this.runPi(ctx, block, runCloudPi, params, memoryConfig)
|
|
}
|
|
|
|
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
|
|
}
|
|
|
|
private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
|
|
if (!ctx.stream) return false
|
|
return (
|
|
ctx.selectedOutputs?.some((outputId) => {
|
|
if (outputId === block.id) return true
|
|
return outputId === `${block.id}.content` || outputId === `${block.id}_content`
|
|
}) ?? false
|
|
)
|
|
}
|
|
|
|
private buildOutput(
|
|
result: PiRunResult,
|
|
model: string,
|
|
isBYOK: boolean,
|
|
startTime: number,
|
|
startTimeISO: string
|
|
): NormalizedBlockOutput {
|
|
const { totals } = result
|
|
const endTime = Date.now()
|
|
return {
|
|
content: totals.finalText,
|
|
model,
|
|
changedFiles: result.changedFiles ?? [],
|
|
diff: result.diff ?? '',
|
|
...(result.prUrl ? { prUrl: result.prUrl } : {}),
|
|
...(result.branch ? { branch: result.branch } : {}),
|
|
tokens: {
|
|
input: totals.inputTokens,
|
|
output: totals.outputTokens,
|
|
total: totals.inputTokens + totals.outputTokens,
|
|
},
|
|
cost: computePiCost(model, totals.inputTokens, totals.outputTokens, isBYOK),
|
|
providerTiming: {
|
|
startTime: startTimeISO,
|
|
endTime: new Date(endTime).toISOString(),
|
|
duration: endTime - startTime,
|
|
},
|
|
}
|
|
}
|
|
|
|
private async runPi<P extends PiRunParams>(
|
|
ctx: ExecutionContext,
|
|
block: SerializedBlock,
|
|
backend: PiBackendRun<P>,
|
|
params: P,
|
|
memoryConfig: PiMemoryConfig
|
|
): Promise<BlockOutput | StreamingExecution> {
|
|
const startTime = Date.now()
|
|
const startTimeISO = new Date(startTime).toISOString()
|
|
|
|
logger.info('Executing Pi block', {
|
|
blockId: block.id,
|
|
mode: params.mode,
|
|
model: params.model,
|
|
workflowId: ctx.workflowId,
|
|
executionId: ctx.executionId,
|
|
})
|
|
|
|
if (this.isContentSelectedForStreaming(ctx, block)) {
|
|
const output: NormalizedBlockOutput = { content: '', model: params.model }
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start: async (controller) => {
|
|
const encoder = new TextEncoder()
|
|
try {
|
|
const result = await backend(params, {
|
|
onEvent: (event) => {
|
|
const text = streamTextForEvent(event)
|
|
if (text) controller.enqueue(encoder.encode(text))
|
|
},
|
|
signal: ctx.abortSignal,
|
|
})
|
|
if (result.totals.errorMessage) {
|
|
controller.error(new Error(result.totals.errorMessage))
|
|
return
|
|
}
|
|
Object.assign(
|
|
output,
|
|
this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
|
|
)
|
|
await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
|
|
controller.close()
|
|
} catch (error) {
|
|
controller.error(error)
|
|
}
|
|
},
|
|
})
|
|
|
|
return {
|
|
stream,
|
|
execution: {
|
|
success: true,
|
|
output,
|
|
blockId: block.id,
|
|
logs: [],
|
|
metadata: { startTime: startTimeISO, duration: 0 },
|
|
isStreaming: true,
|
|
} as StreamingExecution['execution'] & { blockId: string },
|
|
}
|
|
}
|
|
|
|
const result = await backend(params, { onEvent: () => {}, signal: ctx.abortSignal })
|
|
if (result.totals.errorMessage) {
|
|
throw new Error(result.totals.errorMessage)
|
|
}
|
|
await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
|
|
return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
|
|
}
|
|
}
|