Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions docs/design/agent-workflows/adapters/agenta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<agentDir>/skills/`) on every run, whereas project skills
(`<cwd>/.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.
4 changes: 4 additions & 0 deletions hosting/docker-compose/ee/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 8 additions & 4 deletions sdks/python/agenta/sdk/agents/adapters/rivet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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__(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 3 additions & 0 deletions services/agent/docker/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 4 additions & 33 deletions services/agent/src/engines/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand All @@ -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/<name>/. 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.
Expand Down Expand Up @@ -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(", ")}`);
}
Expand Down
Loading
Loading