]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(sandcastle): add configurable agent provider (pi/opencode)
authorJérôme Benoit <jerome.benoit@sap.com>
Thu, 7 May 2026 19:33:28 +0000 (21:33 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Thu, 7 May 2026 19:48:23 +0000 (21:48 +0200)
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
.sandcastle/constants.ts
.sandcastle/main.ts
.sandcastle/refinement-loop.ts
.sandcastle/task-source.ts
.sandcastle/utils.ts
eslint.config.js

index a02b714fd79652632ccfeb8b246dd893568584b5..783ed2afe9c62bf76295734e1fd0e0583a19d63c 100644 (file)
@@ -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
index 5f2b078a6e8523f52b7eaa396e0aa05e178195ef..2f4b497c3f6277eddee82a8eb5b561e0b1710d43 100644 (file)
@@ -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.
  */
index 46ad4c70c40d4d07111134a4bb120e47c6bd8fa5..f5a21acf7eb44b933a8a9d86291a0bb32a9508b7 100644 (file)
@@ -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] }),
           })
 
index e3f1208080867be253089bffbd873e179922bed1..2cdcc8dae9b4fefd95c9bd65fac00779cfedafc1 100644 (file)
@@ -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<ReturnType<typeof sandbox.run>>
   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,
index 0ed39ec763270b84392a0dba0a69f0ba69c15ed2..c22c247c1a8dd73f3a28847758014997f85756bc 100644 (file)
@@ -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<ReturnType<typeof sandcastle.run>>
       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',
index 3c8321b4edee67cb900d716b3cdfc42d08260650..eefa48d9434b7f9262237e092203a842f3a03487 100644 (file)
@@ -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).
index a9b7dbc3b46aaf8aa193b3f08bc034894c52aa0a..ab053f25822d714a19cef0200c5e35a15fb30720 100644 (file)
@@ -35,6 +35,7 @@ export default defineConfig([
               'imsi',
               'ocpp',
               'onconnection',
+              'opencode',
               'evse',
               'evses',
               'kvar',