From b37c57bcf844d99cd6e90a7fdc15c8baa9e270d9 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 7 May 2026 21:33:28 +0200 Subject: [PATCH] fix(sandcastle): add configurable agent provider (pi/opencode) Introduce AGENT_PROVIDER constant to switch between pi and opencode backends. pi streams JSON output immediately, avoiding the opencode idle timeout bug where git check-ignore indexing produces zero stdout. Also removes redundant copyToWorktree since onSandboxReady pnpm install handles node_modules via the mounted pnpm store. --- .github/workflows/sandcastle.yml | 7 +++++++ .sandcastle/constants.ts | 28 ++++++++++++++++++++++++++++ .sandcastle/main.ts | 5 ++--- .sandcastle/refinement-loop.ts | 9 ++++----- .sandcastle/task-source.ts | 6 ++++-- .sandcastle/utils.ts | 19 +++++++++++++++++++ eslint.config.js | 1 + 7 files changed, 65 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sandcastle.yml b/.github/workflows/sandcastle.yml index a02b714f..783ed2af 100644 --- a/.github/workflows/sandcastle.yml +++ b/.github/workflows/sandcastle.yml @@ -32,11 +32,16 @@ jobs: - name: Validate secrets run: | + if [ -z "$PI_AUTH_CONTENT" ]; then + echo "::error::PI_AUTH_CONTENT secret is not configured" + exit 1 + fi if [ -z "$OPENCODE_AUTH_CONTENT" ]; then echo "::error::OPENCODE_AUTH_CONTENT secret is not configured" exit 1 fi env: + PI_AUTH_CONTENT: ${{ secrets.PI_AUTH_CONTENT }} OPENCODE_AUTH_CONTENT: ${{ secrets.OPENCODE_AUTH_CONTENT }} - run: docker build -t sandcastle-sandbox .sandcastle/ @@ -45,9 +50,11 @@ jobs: run: | printf 'GH_TOKEN=%s\n' "$GH_TOKEN" > .sandcastle/.env printf 'GITHUB_TOKEN=%s\n' "$GITHUB_TOKEN" >> .sandcastle/.env + printf 'PI_AUTH_CONTENT=%s\n' "$PI_AUTH_CONTENT" >> .sandcastle/.env printf 'OPENCODE_AUTH_CONTENT=%s\n' "$OPENCODE_AUTH_CONTENT" >> .sandcastle/.env env: GH_TOKEN: ${{ github.token }} + PI_AUTH_CONTENT: ${{ secrets.PI_AUTH_CONTENT }} OPENCODE_AUTH_CONTENT: ${{ secrets.OPENCODE_AUTH_CONTENT }} - run: pnpm run sandcastle diff --git a/.sandcastle/constants.ts b/.sandcastle/constants.ts index 5f2b078a..2f4b497c 100644 --- a/.sandcastle/constants.ts +++ b/.sandcastle/constants.ts @@ -3,6 +3,10 @@ import { existsSync } from 'node:fs' // ── Agent ──────────────────────────────────────────────────────────────────── +export type AgentProviderType = 'opencode' | 'pi' + +export const AGENT_PROVIDER = 'pi' as AgentProviderType + export const AGENT_ACTOR_EFFORT = 'high' export const AGENT_ACTOR_MODEL = 'github-copilot/claude-sonnet-4.6' @@ -43,6 +47,30 @@ export const DOCKER_IMAGE = 'sandcastle-sandbox' export const DOCKER_MOUNTS = resolveDockerMounts() +export const SANDBOX_AUTH_HOOKS = { + sandbox: { + onSandboxReady: [ + ...(AGENT_PROVIDER === 'pi' + ? [ + { + command: + 'mkdir -p ~/.pi/agent && printf \'%s\' "$PI_AUTH_CONTENT" > ~/.pi/agent/auth.json', + }, + ] + : []), + ], + }, +} + +export const SANDBOX_BUILD_HOOKS = { + sandbox: { + onSandboxReady: [ + ...SANDBOX_AUTH_HOOKS.sandbox.onSandboxReady, + { command: 'pnpm install && pnpm run build' }, + ], + }, +} + /** * @returns Mount entries for pnpm store, or empty if store path is unavailable. */ diff --git a/.sandcastle/main.ts b/.sandcastle/main.ts index 46ad4c70..f5a21acf 100644 --- a/.sandcastle/main.ts +++ b/.sandcastle/main.ts @@ -13,6 +13,7 @@ import { GIT_BRANCH_PREFIX, GITHUB_ISSUE_LABEL, MAX_PARALLEL, + SANDBOX_BUILD_HOOKS, } from './constants.js' import { runRefinementLoop } from './refinement-loop.js' import { implementStrategy } from './strategies/implement/strategy.js' @@ -50,9 +51,7 @@ if (tasks.length === 0) { try { await using sandbox = await sandcastle.createSandbox({ branch: spec.branch, - hooks: { - sandbox: { onSandboxReady: [{ command: 'pnpm install && pnpm run build' }] }, - }, + hooks: SANDBOX_BUILD_HOOKS, sandbox: docker({ imageName: DOCKER_IMAGE, mounts: [...DOCKER_MOUNTS] }), }) diff --git a/.sandcastle/refinement-loop.ts b/.sandcastle/refinement-loop.ts index e3f12080..2cdcc8da 100644 --- a/.sandcastle/refinement-loop.ts +++ b/.sandcastle/refinement-loop.ts @@ -1,4 +1,3 @@ -import * as sandcastle from '@ai-hero/sandcastle' import crypto from 'node:crypto' import { readFile, realpath } from 'node:fs/promises' import { join, sep } from 'node:path' @@ -25,7 +24,7 @@ import { HASH_PREFIX_LENGTH, } from './constants.js' import { parseFindingsSafe } from './types.js' -import { execFileAsync } from './utils.js' +import { agentProvider, execFileAsync } from './utils.js' import { runValidation } from './validation.js' /** Options for configuring the refinement loop. */ @@ -435,7 +434,7 @@ async function executeRound ( let actorResult: Awaited> try { actorResult = await sandbox.run({ - agent: sandcastle.opencode(strategy.actorModel ?? AGENT_ACTOR_MODEL), + agent: agentProvider(strategy.actorModel ?? AGENT_ACTOR_MODEL), completionSignal: COMPLETION_SIGNAL, idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S, maxIterations: budget, @@ -598,7 +597,7 @@ async function runCritic ( const { baseBranch, sandbox, signal, spec, strategy } = ctx let critic = await sandbox.run({ - agent: sandcastle.opencode(strategy.criticModel ?? AGENT_CRITIC_MODEL), + agent: agentProvider(strategy.criticModel ?? AGENT_CRITIC_MODEL), completionSignal: COMPLETION_SIGNAL, idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S, maxIterations: 1, @@ -613,7 +612,7 @@ async function runCritic ( if (findings === null) { console.warn(` #${spec.id}: Critic parse failed. Retrying.`) critic = await sandbox.run({ - agent: sandcastle.opencode(strategy.criticModel ?? AGENT_CRITIC_MODEL), + agent: agentProvider(strategy.criticModel ?? AGENT_CRITIC_MODEL), completionSignal: COMPLETION_SIGNAL, idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S, maxIterations: 1, diff --git a/.sandcastle/task-source.ts b/.sandcastle/task-source.ts index 0ed39ec7..c22c247c 100644 --- a/.sandcastle/task-source.ts +++ b/.sandcastle/task-source.ts @@ -14,8 +14,9 @@ import { GITHUB_MAX_ISSUES_FETCH, GITHUB_MAX_PRS_FETCH, MAX_TITLE_CHARS, + SANDBOX_AUTH_HOOKS, } from './constants.js' -import { execFileAsync, toErrorMessage } from './utils.js' +import { agentProvider, execFileAsync, toErrorMessage } from './utils.js' const RawIssueSchema = z.object({ body: z @@ -96,8 +97,9 @@ export class GithubIssueSource implements TaskSource { let plan: Awaited> try { plan = await sandcastle.run({ - agent: sandcastle.opencode(AGENT_PLANNER_MODEL), + agent: agentProvider(AGENT_PLANNER_MODEL), completionSignal: COMPLETION_SIGNAL, + hooks: SANDBOX_AUTH_HOOKS, idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S, maxIterations: 1, name: 'Planner', diff --git a/.sandcastle/utils.ts b/.sandcastle/utils.ts index 3c8321b4..eefa48d9 100644 --- a/.sandcastle/utils.ts +++ b/.sandcastle/utils.ts @@ -1,9 +1,28 @@ +import type { AgentProvider } from '@ai-hero/sandcastle' + +import * as sandcastle from '@ai-hero/sandcastle' import { execFile } from 'node:child_process' import util from 'node:util' +import { AGENT_PROVIDER } from './constants.js' + /** Async execFile — does not block the event loop. Same error shape as execFileSync. */ export const execFileAsync = util.promisify(execFile) +/** + * Returns a sandcastle agent provider for the given model, selected by AGENT_PROVIDER constant. + * @param model - The model identifier (e.g., 'github-copilot/claude-sonnet-4.6'). + * @returns The configured agent provider. + */ +export function agentProvider (model: string): AgentProvider { + switch (AGENT_PROVIDER) { + case 'opencode': + return sandcastle.opencode(model) + case 'pi': + return sandcastle.pi(model) + } +} + /** * Converts an unknown thrown value to a human-readable error message. * @param err - The caught value (may be an `Error` or any other type). diff --git a/eslint.config.js b/eslint.config.js index a9b7dbc3..ab053f25 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,7 @@ export default defineConfig([ 'imsi', 'ocpp', 'onconnection', + 'opencode', 'evse', 'evses', 'kvar', -- 2.53.0