From 955d1cc92a74267b20f6430a41fcb2ecd905b623 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 20 Jun 2026 10:59:18 +0200 Subject: [PATCH] feat(agent): run the Agenta harness on the rivet/ACP backend with forced skills The Agenta harness (Pi plus a forced base AGENTS.md, persona, tools, and skills) only ran on the in-process Pi backend. Selecting it on the rivet path (AGENTA_AGENT_RUNTIME=rivet, a non-local sandbox, or the playground Harness dropdown) sent the agent id "agenta" to the rivet daemon, which only knows real agents (pi/claude) and answered "Unsupported Agent: agenta". The rejection never resolved the request, so the playground spun forever. Map "agenta" onto the "pi" ACP agent (acpAgent) for every daemon operation, keeping "agenta" as the user-facing identity. Add AGENTA to RivetBackend.supported_harnesses so select_backend routes it. Deliver the forced skills by laying the bundled dirs into a per-run Pi agent dir (so they never leak into later plain-pi runs) for local, and uploading them to the fresh sandbox for Daytona. --- .../design/agent-workflows/adapters/agenta.md | 42 ++++- .../docker-compose/ee/docker-compose.dev.yml | 4 + .../agenta/sdk/agents/adapters/rivet.py | 12 +- .../unit/agents/test_harness_adapters.py | 9 + services/agent/docker/Dockerfile.dev | 3 + services/agent/src/engines/pi.ts | 37 +---- services/agent/src/engines/rivet.ts | 155 +++++++++++++++++- services/agent/src/engines/skills.ts | 50 ++++++ services/agent/test/skills.test.ts | 63 +++++++ services/oss/src/agent/app.py | 11 +- .../pytest/unit/agent/test_select_backend.py | 14 ++ 11 files changed, 341 insertions(+), 59 deletions(-) create mode 100644 services/agent/src/engines/skills.ts create mode 100644 services/agent/test/skills.test.ts diff --git a/docs/design/agent-workflows/adapters/agenta.md b/docs/design/agent-workflows/adapters/agenta.md index ca9fd4ea77..7d6eef78d9 100644 --- a/docs/design/agent-workflows/adapters/agenta.md +++ b/docs/design/agent-workflows/adapters/agenta.md @@ -52,13 +52,39 @@ instructions are the `AGENTS.md`. An author's own `system` / `append_system` (vi ## Selecting it `agenta` is a harness option alongside `pi` and `claude` (the playground dropdown, the -`harness` field). It runs on the in-process Pi backend (`InProcessPiBackend` now lists -`HarnessType.AGENTA` as supported), so `select_backend` keeps `agenta` on the local Pi path. +`harness` field). On a local sandbox it runs on the in-process Pi backend (`InProcessPiBackend` +lists `HarnessType.AGENTA` as supported), so `select_backend` keeps `agenta` on the local Pi +path. A non-local sandbox (e.g. Daytona) or `AGENTA_AGENT_RUNTIME=rivet` routes it to the rivet +backend, which drives it too (see below). -## Deferred +## On the rivet (ACP) path -Only the in-process Pi (local) path is wired. The ACP/rivet path (and therefore the Daytona -sandbox) does not yet deliver the forced skills — it would teach `runRivet` to read the -`skills` field and lay the bundled skill directories into the sandbox via the existing -bundled-file provisioning. Until then, `agenta` with a non-local sandbox raises -`UnsupportedHarnessError` rather than silently running without its skills. +`RivetBackend` also lists `HarnessType.AGENTA` as supported, so `agenta` runs over ACP through +the rivet daemon as well — this is what lets it use the Daytona sandbox. The Agenta harness is +Pi with an opinion, and the rivet daemon only knows real agents (`pi`, `claude`, …), so the +runner maps `agenta` onto the `pi` ACP agent (`acpAgent` in `engines/rivet.ts`) and treats it +as Pi for capabilities, model resolution, and tracing. + +The forced *skills* cannot ride the `/run` wire as text (a skill is a directory that may +reference relative scripts and assets), so the wire carries only the skill **names** and the +runner lays the bundled directories into the Pi **agent dir**'s `skills/` (user scope). +`runRivet` resolves the names against the bundled `skills/` root (`engines/skills.ts`, shared +with the in-process engine). The agent dir is deliberate — Pi auto-discovers and enables +user-scope skills (`/skills/`) on every run, whereas project skills +(`/.pi/skills/`) are trust-gated and would not load in this headless run. + +Because the forced skills are user-scope, writing them into the *shared* agent dir would leak +them into later plain `pi` runs on the same sidecar (and could pollute a developer's real +`~/.pi/agent`). So each path gives the run its own agent dir: on **Daytona** the sandbox is +already fresh per run (`uploadSkillsToSandbox`); on **local** an Agenta run gets a throwaway +per-run agent dir seeded from the login (`auth.json` / `settings.json`), with the extension and +skills installed into it and the daemon pointed at it via `PI_CODING_AGENT_DIR` +(`prepareLocalAgentDir`), removed after the run. A plain `pi` run is unchanged (it installs only +the extension into the shared agent dir). + +The base AGENTS.md preamble still rides the wire as `agentsMd` (written into the session `cwd`), +and the forced `read` / `bash` tools are Pi defaults under pi-acp. The one gap versus the +in-process path is the persona `appendSystemPrompt`, which pi-acp gives no per-run hook to set; +it is logged and skipped on the rivet Pi path (the same pre-existing limitation as plain Pi over +ACP), so on rivet the Agenta persona is not yet applied. Daytona skill uploads are UTF-8 text +only (`writeFsFile` takes a string body); binary skill assets are a follow-up. diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index 85f98ead68..75c329e50c 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -463,6 +463,10 @@ services: # === STORAGE ============================================== # volumes: - ../../../services/agent/src:/app/src + # The Agenta harness's forced skills are real files the runner lays into the + # sandbox per run (resolved from /app/skills). Bind-mounted like src so edits are + # live; the prod image bakes them with `COPY skills ./skills`. + - ../../../services/agent/skills:/app/skills - ${HOME}/.pi/agent:/pi-agent-ro:ro # === NETWORK ============================================== # networks: diff --git a/sdks/python/agenta/sdk/agents/adapters/rivet.py b/sdks/python/agenta/sdk/agents/adapters/rivet.py index 78dbee0635..2e1462924b 100644 --- a/sdks/python/agenta/sdk/agents/adapters/rivet.py +++ b/sdks/python/agenta/sdk/agents/adapters/rivet.py @@ -2,8 +2,10 @@ This backend hard-codes that it is the rivet engine. It reaches the same runner the deployed sidecar runs (HTTP when a ``url`` is set, otherwise a subprocess CLI), and the runner starts -the rivet daemon, the ACP adapter, and the harness. Supports Pi and Claude. The ``sandbox`` -axis (``local`` / ``daytona``) is a real runtime choice, so it stays a constructor arg. +the rivet daemon, the ACP adapter, and the harness. Supports Pi, Claude, and Agenta (Pi with +an opinion, which the runner drives on the same ``pi`` ACP agent plus forced skills). The +``sandbox`` axis (``local`` / ``daytona``) is a real runtime choice, so it stays a constructor +arg. It is its own class, not a subclass of any other backend; it shares only the ``utils`` wire and transport helpers. @@ -111,9 +113,11 @@ def stream(self, messages: Sequence[Message]) -> AgentRun: class RivetBackend(Backend): - """The rivet engine: a harness over ACP through the TS runner. Pi and Claude.""" + """The rivet engine: a harness over ACP through the TS runner. Pi, Claude, and Agenta.""" - supported_harnesses = frozenset({HarnessType.PI, HarnessType.CLAUDE}) + supported_harnesses = frozenset( + {HarnessType.PI, HarnessType.CLAUDE, HarnessType.AGENTA} + ) _ENGINE = "rivet" # hard-coded engine identity, not a constructor arg def __init__( diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py index fe0eb52fbe..3066577dde 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py @@ -162,6 +162,15 @@ def test_agenta_is_in_process_pi_supported(): assert InProcessPiBackend(url="http://runner").supports(HarnessType.AGENTA) +def test_agenta_is_rivet_supported(): + # Agenta is Pi with an opinion, so the rivet backend drives it too (on the `pi` ACP + # agent, with the runner laying the forced skills into the sandbox). This is what lets + # `agenta` run on a non-local sandbox (e.g. daytona) instead of raising. + from agenta.sdk.agents import RivetBackend + + assert RivetBackend(url="http://runner").supports(HarnessType.AGENTA) + + # ------------------------------------------------------------------------- Claude diff --git a/services/agent/docker/Dockerfile.dev b/services/agent/docker/Dockerfile.dev index 4f2f64f126..53b4415e6d 100644 --- a/services/agent/docker/Dockerfile.dev +++ b/services/agent/docker/Dockerfile.dev @@ -25,6 +25,9 @@ RUN pnpm install --frozen-lockfile COPY tsconfig.json ./ COPY scripts ./scripts COPY src ./src +# The Agenta harness's forced skills (resolved from /app/skills per run). Baked like the +# prod image; the dev compose also bind-mounts skills/ over this for live edits. +COPY skills ./skills # Bundle the Agenta Pi extension (tracing + tools) into dist/. dist/ is NOT bind-mounted # in dev, so this baked copy is what runRivet installs into Pi's agent dir. Rebuild the diff --git a/services/agent/src/engines/pi.ts b/services/agent/src/engines/pi.ts index 2be7d1698f..65bbf31bee 100644 --- a/services/agent/src/engines/pi.ts +++ b/services/agent/src/engines/pi.ts @@ -16,10 +16,9 @@ * Important: stdout is reserved for the JSON result (see cli.ts). Everything here logs to * stderr so it never pollutes the result channel. */ -import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; import { AuthStorage, @@ -46,6 +45,7 @@ import { } from "../protocol.ts"; import { EMPTY_OBJECT_SCHEMA } from "../tools/callback.ts"; import { runResolvedTool } from "../tools/dispatch.ts"; +import { resolveSkillDirs } from "./skills.ts"; /** What the in-process Pi engine supports. Static (no daemon to probe, unlike rivet). */ const PI_CAPABILITIES: HarnessCapabilities = { @@ -66,35 +66,6 @@ function log(message: string): void { process.stderr.write(`[pi-wrapper] ${message}\n`); } -// services/agent/src/engines/pi.ts -> services/agent. Bundled skills (the Agenta harness's -// forced skills) live under services/agent/skills//. Overridable for non-default layouts. -const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); -const SKILLS_ROOT = process.env.AGENTA_AGENT_SKILLS_DIR || join(PKG_ROOT, "skills"); - -/** - * Resolve the requested skill names to bundled skill directories under SKILLS_ROOT. Each name - * must be a committed dir holding a SKILL.md (Pi loads them and surfaces them in the system - * prompt). Absolute paths are honored as-is; unknown or non-directory entries are skipped with - * a warning so a stale name never fails the run. - */ -function resolveSkillDirs(names: string[] | undefined): string[] { - const dirs: string[] = []; - for (const name of names ?? []) { - if (!name) continue; - const path = isAbsolute(name) ? name : join(SKILLS_ROOT, name); - try { - if (existsSync(path) && statSync(path).isDirectory()) { - dirs.push(path); - } else { - log(`skipping unknown skill "${name}" (no directory at ${path})`); - } - } catch { - log(`skipping skill "${name}": cannot stat ${path}`); - } - } - return dirs; -} - // In-process Pi reads provider keys from process.env. Since process.env is process-global, // serialize Pi runs while applying request-scoped provider env, then restore the prior env // exactly so one request's vault keys cannot leak into the next request. @@ -282,7 +253,7 @@ async function runPiWithEnv( // `noSkills` suppresses host/global discovery so the run is deterministic; the loader still // merges `additionalSkillPaths` on top, so the bundled skills load. They only surface in // the prompt when `read` is enabled (the harness forces it). - const skillDirs = resolveSkillDirs(request.skills); + const skillDirs = resolveSkillDirs(request.skills, log); if (skillDirs.length > 0) { log(`skills: ${skillDirs.join(", ")}`); } diff --git a/services/agent/src/engines/rivet.ts b/services/agent/src/engines/rivet.ts index 3a5d138106..d54f7ee2f6 100644 --- a/services/agent/src/engines/rivet.ts +++ b/services/agent/src/engines/rivet.ts @@ -27,17 +27,19 @@ import { randomBytes } from "node:crypto"; import { chmodSync, copyFileSync, + cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, + statSync, writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { SandboxAgent, InMemorySessionPersistDriver } from "sandbox-agent"; @@ -45,6 +47,7 @@ import { local } from "sandbox-agent/local"; import { daytona } from "sandbox-agent/daytona"; import { createRivetOtel } from "../tracing/otel.ts"; +import { resolveSkillDirs } from "./skills.ts"; import { buildToolMcpServers, type McpServerStdio } from "../tools/mcp-bridge.ts"; import { executableToolSpecs, publicToolSpecs } from "../tools/public-spec.ts"; import { @@ -201,6 +204,107 @@ async function uploadPiExtensionToSandbox(sandbox: any, agentDir: string): Promi } } +/** + * Install the Agenta harness's forced skill dirs into a local Pi agent dir's `skills/`. Pi + * auto-discovers and enables user-scope skills (`/skills/`) on every run, unlike + * project skills (`/.pi/skills/`), which are trust-gated and would not load in this + * headless run — so the agent dir is the right home, mirroring the extension install above. + * Each skill keeps its directory name (the contract with the SDK's forced-skill list). + * Best-effort: a skill that fails to copy is logged and skipped, never failing the run. + */ +function installSkillsLocal(agentDir: string, skillDirs: string[]): void { + for (const src of skillDirs) { + try { + const dest = join(agentDir, "skills", basename(src)); + mkdirSync(dirname(dest), { recursive: true }); + // dereference so a skill's symlinked assets materialize as real files, matching the + // Daytona uploader (which has no symlink target on the remote FS). + cpSync(src, dest, { recursive: true, dereference: true }); + } catch (err) { + log(`skill install skipped for ${basename(src)}: ${(err as Error).message}`); + } + } +} + +/** + * Seed a throwaway local Pi agent dir from `sourceAgentDir` (the login: auth.json / + * settings.json) and install the Agenta extension and forced skills into it. The Agenta + * harness forces skills into the *user-scope* agent dir (the only place pi-acp auto-loads + * them headlessly), so writing them into the shared `PI_CODING_AGENT_DIR` would leak them + * into later plain `pi` runs on the same sidecar and could pollute a developer's real + * `~/.pi/agent`. A per-run dir keeps each Agenta run's skills to itself; the daemon is + * pointed at it via `PI_CODING_AGENT_DIR`, and the caller removes it after the run. This + * mirrors the Daytona path, where the sandbox already gives each run a fresh agent dir. + */ +function prepareLocalAgentDir(sourceAgentDir: string, skillDirs: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "agenta-pi-agentdir-")); + // Carry the login forward so pi-acp still authenticates (OAuth/auth.json), exactly the + // files the Daytona path uploads. + for (const name of ["auth.json", "settings.json"]) { + const src = join(sourceAgentDir, name); + try { + if (existsSync(src)) copyFileSync(src, join(dir, name)); + } catch (err) { + log(`agent-dir seed skipped for ${name}: ${(err as Error).message}`); + } + } + installPiExtensionLocal(dir); + installSkillsLocal(dir, skillDirs); + return dir; +} + +/** + * Upload the forced skill dirs into a Daytona sandbox's Pi `skills/` (user scope), the remote + * counterpart of {@link installSkillsLocal}. Walks each skill directory and writes every file + * through the sandbox FS API. `writeFsFile` takes a string body, so skill assets are uploaded + * as UTF-8 text (the SKILL.md and any text helpers); binary skill assets are a follow-up. + * Best-effort per skill. + */ +async function uploadSkillsToSandbox( + sandbox: any, + agentDir: string, + skillDirs: string[], +): Promise { + for (const src of skillDirs) { + try { + await uploadDirToSandbox(sandbox, src, `${agentDir}/skills/${basename(src)}`); + } catch (err) { + log(`skill upload skipped for ${basename(src)}: ${(err as Error).message}`); + } + } +} + +/** Recursively upload a host directory tree into a sandbox path via the FS API. */ +async function uploadDirToSandbox( + sandbox: any, + srcDir: string, + destDir: string, +): Promise { + await sandbox.mkdirFs({ path: destDir }); + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + const srcPath = join(srcDir, entry.name); + const destPath = `${destDir}/${entry.name}`; + // Resolve symlinks to their target kind so a symlinked file/dir is uploaded by content, + // matching the dereferencing local copy (a broken link is skipped). + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + const st = statSync(srcPath); + isDir = st.isDirectory(); + isFile = st.isFile(); + } catch { + continue; + } + } + if (isDir) { + await uploadDirToSandbox(sandbox, srcPath, destPath); + } else if (isFile) { + await sandbox.writeFsFile({ path: destPath }, readFileSync(srcPath, "utf-8")); + } + } +} + /** * The environment the daemon is born with. The local provider merges this into the * `sandbox-agent server` subprocess, which passes it to the ACP adapter and then to @@ -683,6 +787,13 @@ export async function runRivet( const harness = request.harness || process.env.AGENTA_AGENT_HARNESS || "pi"; const sandboxId = request.sandbox || process.env.AGENTA_AGENT_SANDBOX || "local"; + // The Agenta harness is Pi with an opinion: it runs on the `pi` ACP agent (the rivet + // daemon only knows real agents like `pi`/`claude`, not `agenta`), plus a base AGENTS.md, + // a persona, forced tools, and forced skills. `acpAgent` is the agent the daemon launches; + // `harness` stays the selected identity (logging, span label, user-facing errors). The + // forced skills are delivered below by laying them into the Pi agent dir. + const acpAgent = harness === "agenta" ? "pi" : harness; + const prompt = resolvePrompt(request); if (!prompt) { return { ok: false, error: "No user message to send (prompt/messages empty)." }; @@ -691,14 +802,14 @@ export async function runRivet( // context when this is a continued conversation. const turnText = buildTurnText(request); - const isPi = harness === "pi"; + const isPi = acpAgent === "pi"; const isDaytona = sandboxId === "daytona"; // Provider API keys resolved from the vault (OPENAI_API_KEY/ANTHROPIC_API_KEY/...). // Present => the harness authenticates with the key; absent => it uses its own login // (OAuth: local Codex / a mounted-or-uploaded auth.json). const secrets = request.secrets ?? {}; - const harnessKeyVar = harness === "claude" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; + const harnessKeyVar = acpAgent === "claude" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; const hasApiKey = !!secrets[harnessKeyVar]; // Session cwd holds AGENTS.md. Local: a host temp dir. Daytona: an in-sandbox path @@ -717,7 +828,7 @@ export async function runRivet( // caller can roll them onto the workflow span (separate OTLP batch, see piExtension). const usageOutPath = isPi ? `${cwd}/.agenta-usage.json` : undefined; - const env = buildDaemonEnv(harness); + const env = buildDaemonEnv(acpAgent); Object.assign(env, secrets); // local daemon inherits the provider keys // Pi self-instruments locally: propagate the trace context + public tool metadata into Pi // via the Agenta extension. Tool execution always relays back to this runner, which keeps @@ -729,9 +840,32 @@ export async function runRivet( // undefined is fine: the local provider runs its own resolution and errors clearly. const binaryPath = resolveDaemonBinary(); - // For local Pi, install the extension into the agent dir Pi loads from. + // The Agenta harness's forced skills: bundled dirs named on the request, resolved against + // the runner's skills root. Laid into the Pi agent dir's `skills/` below (local or daytona) + // so Pi auto-discovers them on every run. Non-Pi harnesses do not load Pi skills. + const skillDirs = isPi ? resolveSkillDirs(request.skills, log) : []; + // Note: pass an arrow, not `basename` directly — Array.map would feed the index as + // basename's `suffix` arg (a number), which throws ERR_INVALID_ARG_TYPE. + if (skillDirs.length > 0) log(`skills: ${skillDirs.map((d) => basename(d)).join(", ")}`); + + // For local Pi, set up the agent dir pi-acp loads from. A plain `pi` run installs the + // extension into the shared agent dir (unchanged). An Agenta run forces skills, which are + // user-scope and would otherwise leak into later plain `pi` runs on this sidecar (and could + // pollute a developer's real ~/.pi/agent); so it gets a throwaway per-run agent dir seeded + // from the login, and the daemon is pointed at it. Cleaned up in the finally below. const localPiAgentDir = process.env.PI_CODING_AGENT_DIR; - if (isPi && !isDaytona && localPiAgentDir) installPiExtensionLocal(localPiAgentDir); + let runAgentDir: string | undefined; + if (isPi && !isDaytona) { + if (skillDirs.length > 0) { + runAgentDir = prepareLocalAgentDir( + localPiAgentDir || join(homedir(), ".pi", "agent"), + skillDirs, + ); + env.PI_CODING_AGENT_DIR = runAgentDir; + } else if (localPiAgentDir) { + installPiExtensionLocal(localPiAgentDir); + } + } // Pi's system-prompt overrides (systemPrompt / appendSystemPrompt) are honored on the // in-process Pi engine via the resource loader. The ACP path drives Pi through pi-acp, @@ -775,6 +909,7 @@ export async function runRivet( // uploading the Codex/OAuth login when no key is available. if (!hasApiKey) await uploadPiAuthToSandbox(sandbox); await uploadPiExtensionToSandbox(sandbox, DAYTONA_PI_DIR); + if (skillDirs.length > 0) await uploadSkillsToSandbox(sandbox, DAYTONA_PI_DIR, skillDirs); if (DAYTONA_PI_INSTALL) await installPiInSandbox(sandbox); } await sandbox.mkdirFs({ path: cwd }).catch(() => {}); @@ -789,7 +924,7 @@ export async function runRivet( // name. Tool delivery: Pi loads our extension (native tools, set up above); any other // harness takes tools over MCP only when it advertises `mcpTools` (pi-acp does not // forward MCP, Claude/Codex do). - const capabilities = await probeCapabilities(sandbox, harness); + const capabilities = await probeCapabilities(sandbox, acpAgent); const toolSpecs = (request.customTools as ResolvedToolSpec[]) ?? []; const userMcpCount = request.mcpServers?.length ?? 0; // MCP delivery is gated on `mcpTools`: pi-acp does not forward MCP, Claude/Codex do. The @@ -814,7 +949,7 @@ export async function runRivet( } const session = await sandbox.createSession({ - agent: harness, + agent: acpAgent, cwd, sessionInit: { cwd, mcpServers }, }); @@ -944,5 +1079,7 @@ export async function runRivet( await sandbox.destroySandbox().catch(() => {}); await sandbox.dispose().catch(() => {}); rmSync(cwd, { recursive: true, force: true }); + // The per-run Agenta agent dir (skills isolation) is throwaway; remove it too. + if (runAgentDir) rmSync(runAgentDir, { recursive: true, force: true }); } } diff --git a/services/agent/src/engines/skills.ts b/services/agent/src/engines/skills.ts new file mode 100644 index 0000000000..d2c0f2824d --- /dev/null +++ b/services/agent/src/engines/skills.ts @@ -0,0 +1,50 @@ +/** + * Bundled-skill resolution, shared by both engines. + * + * The Agenta harness ships a fixed set of skills (see the SDK's `agenta_builtins`). They + * cannot ride the `/run` wire as text because each skill is a directory that may reference + * relative scripts and assets, so the wire carries only the skill *names* and each engine + * resolves them here against the runner's bundled `skills/` root: + * + * - the in-process Pi engine (`engines/pi.ts`) feeds the resolved dirs to Pi's resource + * loader as `additionalSkillPaths`; + * - the rivet engine (`engines/rivet.ts`) lays the resolved dirs into the Pi agent dir's + * `skills/` (user scope), where Pi auto-discovers them on every run. + */ +import { existsSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// services/agent/src/engines/skills.ts -> services/agent. Bundled skills (the Agenta +// harness's forced skills) live under services/agent/skills//. Overridable for +// non-default layouts (e.g. a relocated sidecar). +const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +export const SKILLS_ROOT = process.env.AGENTA_AGENT_SKILLS_DIR || join(PKG_ROOT, "skills"); + +/** + * Resolve the requested skill names to bundled skill directories under SKILLS_ROOT. Each name + * must be a committed dir holding a SKILL.md (Pi loads it and surfaces it in the system + * prompt). Absolute paths are honored as-is; unknown or non-directory entries are skipped with + * a warning so a stale name never fails the run. `log` defaults to a no-op so callers without a + * logger stay quiet. + */ +export function resolveSkillDirs( + names: string[] | undefined, + log: (message: string) => void = () => {}, +): string[] { + const dirs: string[] = []; + for (const name of names ?? []) { + if (!name) continue; + const path = isAbsolute(name) ? name : join(SKILLS_ROOT, name); + try { + if (existsSync(path) && statSync(path).isDirectory()) { + dirs.push(path); + } else { + log(`skipping unknown skill "${name}" (no directory at ${path})`); + } + } catch { + log(`skipping skill "${name}": cannot stat ${path}`); + } + } + return dirs; +} diff --git a/services/agent/test/skills.test.ts b/services/agent/test/skills.test.ts new file mode 100644 index 0000000000..7366ce1168 --- /dev/null +++ b/services/agent/test/skills.test.ts @@ -0,0 +1,63 @@ +/** + * Unit tests for bundled-skill resolution (`engines/skills.ts`), the shared helper both + * engines use to turn the Agenta harness's forced skill *names* into directories on disk. + * + * No harness, no network: just disk resolution against a temp SKILLS_ROOT and absolute paths. + * + * Run: pnpm exec tsx test/skills.test.ts + */ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// A throwaway skills root with one real skill dir and one bare file (not a skill dir). +const root = mkdtempSync(join(tmpdir(), "agenta-skills-test-")); +mkdirSync(join(root, "alpha")); +writeFileSync(join(root, "alpha", "SKILL.md"), "---\nname: alpha\n---\n"); +writeFileSync(join(root, "loose.md"), "not a dir"); + +// skills.ts reads AGENTA_AGENT_SKILLS_DIR at import time, so set it before importing. +process.env.AGENTA_AGENT_SKILLS_DIR = root; +const { resolveSkillDirs, SKILLS_ROOT } = await import("../src/engines/skills.ts"); + +// --- SKILLS_ROOT honors the override ---------------------------------------- +{ + assert.equal(SKILLS_ROOT, root, "SKILLS_ROOT reads AGENTA_AGENT_SKILLS_DIR"); +} + +// --- resolves a known name to its directory under the root ------------------ +{ + assert.deepEqual(resolveSkillDirs(["alpha"]), [join(root, "alpha")]); +} + +// --- skips unknown names and non-directories, logging each ------------------ +{ + const logs: string[] = []; + assert.deepEqual(resolveSkillDirs(["nope", "loose.md"], (m) => logs.push(m)), []); + assert.equal(logs.length, 2, "one log line per skipped entry"); + assert.ok( + logs.every((m) => /skipping/.test(m)), + "skips are surfaced through the logger", + ); +} + +// --- honors absolute paths as-is (the in-process loader path) --------------- +{ + assert.deepEqual(resolveSkillDirs([join(root, "alpha")]), [join(root, "alpha")]); +} + +// --- empty / undefined input is a no-op ------------------------------------- +{ + assert.deepEqual(resolveSkillDirs(undefined), []); + assert.deepEqual(resolveSkillDirs([]), []); + assert.deepEqual(resolveSkillDirs([""]), [], "blank names are dropped"); +} + +// --- the default logger is a silent no-op (no throw without a logger) ------- +{ + assert.deepEqual(resolveSkillDirs(["nope"]), [], "missing skill is skipped, not thrown"); +} + +rmSync(root, { recursive: true, force: true }); +console.log("skills.test.ts: ok"); diff --git a/services/oss/src/agent/app.py b/services/oss/src/agent/app.py index ac1bdbcc1b..cdb5b652b2 100644 --- a/services/oss/src/agent/app.py +++ b/services/oss/src/agent/app.py @@ -51,11 +51,12 @@ def select_backend(selection: RunSelection) -> Backend: """Pick the backend for a run. The in-process Pi backend runs Pi locally, and the Agenta harness is Pi with an opinion, - so both ``pi`` and ``agenta`` stay on it. Any other harness, a non-local sandbox, or - ``AGENTA_AGENT_RUNTIME=rivet`` selects the rivet backend instead of silently dropping the - choice (``agenta`` is not yet supported on the rivet path, so ``agenta`` + a non-local - sandbox raises ``UnsupportedHarnessError`` rather than running the wrong thing). The - transport to the TypeScript runner is a deployment detail each backend takes: + so both ``pi`` and ``agenta`` stay on it locally. Any other harness, a non-local sandbox, + or ``AGENTA_AGENT_RUNTIME=rivet`` selects the rivet backend instead of silently dropping + the choice. The rivet backend drives ``agenta`` too (it runs on the same ``pi`` ACP agent + and the runner lays the forced skills into the sandbox), so ``agenta`` + ``daytona`` is a + supported combination, not an ``UnsupportedHarnessError``. The transport to the TypeScript + runner is a deployment detail each backend takes: ``AGENTA_AGENT_PI_URL`` set (docker) -> HTTP to the sidecar; unset (local checkout) -> spawn the runner CLI from the wrapper dir. """ diff --git a/services/oss/tests/pytest/unit/agent/test_select_backend.py b/services/oss/tests/pytest/unit/agent/test_select_backend.py index 4f65bb29f3..500e573b7e 100644 --- a/services/oss/tests/pytest/unit/agent/test_select_backend.py +++ b/services/oss/tests/pytest/unit/agent/test_select_backend.py @@ -55,6 +55,20 @@ def test_claude_routes_to_rivet(): assert isinstance(select_backend(_sel("claude", "local")), RivetBackend) +def test_agenta_daytona_routes_to_rivet(): + # Agenta stays in-process only on local; a non-local sandbox routes it to rivet, which now + # drives agenta (Pi on the `pi` ACP agent + forced skills laid into the sandbox). + backend = select_backend(_sel("agenta", "daytona")) + assert isinstance(backend, RivetBackend) + assert backend._sandbox == "daytona" + + +def test_agenta_runtime_override_routes_to_rivet(monkeypatch): + # The deployment override forces agenta onto rivet even for a local sandbox. + monkeypatch.setenv("AGENTA_AGENT_RUNTIME", "rivet") + assert isinstance(select_backend(_sel("agenta", "local")), RivetBackend) + + def test_non_local_sandbox_routes_to_rivet(): backend = select_backend(_sel("pi", "daytona")) assert isinstance(backend, RivetBackend)