Skip to content
Open
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
17 changes: 16 additions & 1 deletion services/runner/src/engines/sandbox_agent/daytona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { join } from "node:path";

import { createAcpFetch } from "./acp-fetch.ts";
import {
resolvePiToolSpecsDelivery,
uploadPiExtensionToSandbox,
uploadPiToolSpecsToSandbox,
uploadSkillsToSandbox,
uploadSystemPromptToSandbox,
} from "./pi-assets.ts";
Expand All @@ -15,6 +17,7 @@ import {
import {
type RunPlan,
type RunPlanPrompt,
type RunPlanTools,
type RunPlanWorkspace,
} from "./run-plan.ts";

Expand Down Expand Up @@ -194,7 +197,8 @@ export async function removePiModelsConfigFromSandbox(
export interface PrepareDaytonaPiAssetsInput {
sandbox: any;
plan: Pick<RunPlan, "isPi"> & {
workspace: Pick<RunPlanWorkspace, "skillDirs">;
workspace: Pick<RunPlanWorkspace, "skillDirs" | "relayDir">;
tools: Pick<RunPlanTools, "toolSpecs">;
prompt: Pick<
RunPlanPrompt,
"hasSystemPrompt" | "systemPrompt" | "appendSystemPrompt"
Expand Down Expand Up @@ -232,6 +236,17 @@ export async function prepareDaytonaPiAssets({
DAYTONA_PI_DIR,
log,
);
// The run's tool specs. The sandbox env map was fixed at creation with the in-sandbox PATH of
// this file (`buildPiExtensionEnv`), so the bytes have to arrive here, before the session opens.
// They never rode the env itself: one env string holding every hydrated spec overflows Linux's
// per-string execve limit and the harness spawn dies with E2BIG (see `piToolSpecsFilePath`).
// A failure THROWS and is terminal in the engine's acquire try — a run whose tools silently
// vanished is worse than one that stops with a reason.
const toolSpecs = resolvePiToolSpecsDelivery(
plan.tools.toolSpecs,
plan.workspace.relayDir,
);
if (toolSpecs) await uploadPiToolSpecsToSandbox(sandbox, toolSpecs, log);
// A models.json plan (a custom provider, or a hand-entered model merged into a built-in one):
// upload the exact file (overwriting stale) before the session starts. No plan: remove any stale
// file so a reused sandbox keeps no earlier configuration. Upload failure THROWS here and is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type PiModelsJsonPlan,
} from "./pi-model-config.ts";
import { loadPiBuiltinRegistry } from "./pi-builtin-registry.ts";
import { PUBLIC_SPECS_FILE_ENV } from "../../tools/tool-mcp-env.ts";
import { buildRunPlan } from "./run-plan.ts";
import { configFingerprint } from "./session-identity.ts";
import type {
Expand Down Expand Up @@ -196,7 +197,7 @@ export async function prepareEnvironmentSetup(
const strictModel = modelResolutionStrict();
logger(
`tools=${plan.tools.toolSpecs.length} executableTools=${plan.tools.executableToolSpecs.length} ` +
`piPublicTools=${piExtEnv.AGENTA_AGENT_TOOLS_PUBLIC_SPECS ? "yes" : "no"}`,
`piPublicTools=${piExtEnv[PUBLIC_SPECS_FILE_ENV] ? "yes" : "no"}`,
);
if (!plan.isPi && plan.isDaytona) {
const clientTools = plan.tools.toolSpecs
Expand Down
121 changes: 117 additions & 4 deletions services/runner/src/engines/sandbox_agent/pi-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
encodePiModelProviderOverride,
PI_MODEL_PROVIDER_OVERRIDE_ENV,
} from "../../extensions/model-provider-override.ts";
import { advertisedToolSpecs } from "../../tools/public-spec.ts";
import {
advertisedToolSpecs,
type AdvertisedToolSpec,
} from "../../tools/public-spec.ts";
import { PUBLIC_SPECS_FILE_ENV } from "../../tools/tool-mcp-env.ts";
import type { MaterializedSkill } from "../skills.ts";
import { PKG_ROOT } from "./daemon.ts";
import {
Expand Down Expand Up @@ -340,6 +344,111 @@ export function writePiModelsConfigLocal(
}
}

/**
* Thrown (via the engine's named-message pattern) when the run's tool specs could not be
* delivered to the harness. Fail loud rather than start a run whose tools the model never sees —
* the silent-tool-drop failure (F-042) is indistinguishable from a model that chose not to call
* them. Mirrors `TOOL_MCP_UNAVAILABLE_MESSAGE` for the non-Pi shim. Single line so
* `conciseError` surfaces it verbatim.
*/
export const PI_TOOL_SPECS_UNAVAILABLE_MESSAGE =
"The agent could not deliver its tool definitions to the harness, so none of its tools would " +
"have been available to the model. The run was stopped rather than run without them. Ask your " +
"deployment operator to check that the runner can write its relay directory.";

/**
* The file the run's advertised tool specs ride to the Pi extension.
*
* A FILE, NEVER AN ENV VALUE. Linux caps a single argv/env string at `MAX_ARG_STRLEN`
* (131,072 bytes) and fails the whole `execve` with `E2BIG` when one exceeds it. Tool JSON
* Schemas are unbounded, so no size threshold is safe: a session with 44 hydrated Composio
* tools serialized to ~250 KB and every run died with "spawn E2BIG" before the harness
* started. The non-Pi stdio shim already rides a file for the same reason — see
* `PUBLIC_SPECS_FILE_ENV` in `tools/tool-mcp-env.ts`, whose name this reuses.
*
* A SIBLING of the relay dir, like the OTLP auth file, because `prepareWorkspace` clears and
* recreates the relay dir itself on every turn. It is keyed on the conversation (the relay dir
* is), non-secret, and rewritten in place per run, so it is left behind at teardown exactly as
* the relay dir is.
*/
export function piToolSpecsFilePath(relayDir: string): string {
return `${relayDir}.tool-specs.json`;
}

/** The run's advertised specs plus the path the extension reads them from. */
export interface PiToolSpecsDelivery {
/** A runner host path on local; the deterministic in-sandbox path on Daytona. */
path: string;
/** The serialized `AdvertisedToolSpec` array — the exact bytes written to `path`. */
contents: string;
specs: AdvertisedToolSpec[];
}

/**
* The tool specs this Pi run advertises, or `undefined` when there is nothing to advertise (no
* tools, or no relay dir to relay their execution back through). Takes the resolved specs so
* both sides derive from one source: the env builder from `request.customTools`, the Daytona
* upload from `plan.tools.toolSpecs` (the same array).
*/
export function resolvePiToolSpecsDelivery(
toolSpecs: ResolvedToolSpec[],
relayDir: string | undefined,
): PiToolSpecsDelivery | undefined {
const specs = advertisedToolSpecs(toolSpecs);
if (specs.length === 0 || !relayDir) return undefined;
return {
path: piToolSpecsFilePath(relayDir),
contents: JSON.stringify(specs),
specs,
};
}

/**
* Write the specs file for a LOCAL Pi run. THROWS the named message on failure: without the file
* the extension registers no tools, and a run whose tools silently vanished is worse than one
* that stops with a reason.
*/
export function writePiToolSpecsFileLocal(
delivery: PiToolSpecsDelivery,
log: Log = () => {},
): void {
try {
mkdirSync(dirname(delivery.path), { recursive: true });
writeFileSync(delivery.path, delivery.contents, "utf-8");
} catch (err) {
log(`pi tool specs write failed: ${(err as Error).message}`);
throw new Error(PI_TOOL_SPECS_UNAVAILABLE_MESSAGE);
}
log(
`pi tool specs written path=${delivery.path} tools=${delivery.specs.length} ` +
`bytes=${Buffer.byteLength(delivery.contents, "utf-8")}`,
);
}

/**
* Upload the specs file into a Daytona sandbox: the runner's filesystem is not the sandbox's, and
* the sandbox env map is fixed at creation, so the env var names a deterministic in-sandbox path
* that this fills in before the session starts. THROWS the named message on failure, for the same
* reason the local write does.
*/
export async function uploadPiToolSpecsToSandbox(
sandbox: any,
delivery: PiToolSpecsDelivery,
log: Log = () => {},
): Promise<void> {
try {
await sandbox.mkdirFs({ path: dirname(delivery.path) });
await sandbox.writeFsFile({ path: delivery.path }, delivery.contents);
} catch (err) {
log(`pi tool specs upload failed: ${(err as Error).message}`);
throw new Error(PI_TOOL_SPECS_UNAVAILABLE_MESSAGE);
}
log(
`pi tool specs uploaded path=${delivery.path} tools=${delivery.specs.length} ` +
`bytes=${Buffer.byteLength(delivery.contents, "utf-8")}`,
);
}

/**
* Env the Agenta Pi extension reads. Tool env contains only public metadata plus the
* relay directory; private specs/auth stay in the runner.
Expand Down Expand Up @@ -389,11 +498,15 @@ export function buildPiExtensionEnv(
});
}

const specs = advertisedToolSpecs(
// The specs themselves ride a file whose PATH is all env carries: one env string holding every
// hydrated tool spec overflows Linux's per-string execve limit and kills the spawn with E2BIG.
// See `piToolSpecsFilePath`. The runner writes that file locally, or uploads it on Daytona.
const toolSpecs = resolvePiToolSpecsDelivery(
(request.customTools as ResolvedToolSpec[]) ?? [],
opts.relayDir,
);
if (specs.length && opts.relayDir) {
env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS = JSON.stringify(specs);
if (toolSpecs && opts.relayDir) {
env[PUBLIC_SPECS_FILE_ENV] = toolSpecs.path;
env.AGENTA_AGENT_TOOLS_RELAY_DIR = opts.relayDir;
// Hop-1 response-watch kill switch (event-driven-tool-relay plan, decision 7): the
// in-sandbox writer defaults it to true, so it is only forwarded — verbatim — when
Expand Down
16 changes: 15 additions & 1 deletion services/runner/src/environment/runtime-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ import {
buildPiExtensionEnv,
configurePiSessionWorkspace,
configurePiSkillSnapshot,
resolvePiToolSpecsDelivery,
writeOtlpAuthFile,
writePiToolSpecsFileLocal,
} from "../engines/sandbox_agent/pi-assets.ts";
import { applyClaudeConnectionEnv } from "../engines/sandbox_agent/runtime-policy.ts";
import type { AgentRunRequest } from "../protocol.ts";
Expand Down Expand Up @@ -207,7 +209,8 @@ export function buildRuntimeEnvironment(
// specs, scoped env, callback endpoints, and callback auth in runner memory.
const otlpAuthFilePath =
p.isPi && !p.isDaytona ? `${p.workspace.relayDir}.otlp-auth` : undefined;
const otlpAuthorization = r.telemetry?.exporters?.otlp?.headers?.authorization;
const otlpAuthorization =
r.telemetry?.exporters?.otlp?.headers?.authorization;
if (otlpAuthFilePath && otlpAuthorization) {
writeOtlpAuthFile(otlpAuthFilePath, otlpAuthorization, input.log);
}
Expand All @@ -223,6 +226,17 @@ export function buildRuntimeEnvironment(
skills: p.workspace.skillDirs.map((skill) => skill.name),
})
: {};
// The tool specs `buildPiExtensionEnv` just pointed the extension at ride a FILE (they are far
// too large for an env string — see `piToolSpecsFilePath`). A local run writes it here, beside
// the OTLP bearer file; a Daytona run cannot, because the runner's filesystem is not the
// sandbox's, so `prepareDaytonaPiAssets` uploads the same bytes to the same path instead.
if (p.isPi && !p.isDaytona) {
const toolSpecs = resolvePiToolSpecsDelivery(
p.tools.toolSpecs,
p.workspace.relayDir,
);
if (toolSpecs) writePiToolSpecsFileLocal(toolSpecs, input.log);
}
// Daytona builds its provider from `piExtEnv` rather than the local daemon env, so the
// transcript location has to sit in BOTH slices or Pi and pi-acp disagree about the path.
if (piSessionDir) piExtEnv.PI_CODING_AGENT_SESSION_DIR = piSessionDir;
Expand Down
86 changes: 72 additions & 14 deletions services/runner/src/extensions/agenta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
* the OTLP Authorization bearer (never a plain env
* var — read once here, then deleted, see readOtlpAuthFile)
* AGENTA_AGENT_CONTENT_CAPTURE_ENABLED "false" to drop prompt/completion/tool I/O from spans
* AGENTA_AGENT_TOOLS_PUBLIC_SPECS JSON [{ name, description, inputSchema }]
* AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE path to a runner-written JSON file holding
* [{ name, description, inputSchema }] — a FILE because a
* large tool set exceeds the per-string execve limit and the
* harness spawn then fails with E2BIG (see loadPublicToolSpecs)
* AGENTA_AGENT_TOOLS_PUBLIC_SPECS the same JSON inline; the pre-file fallback, one release only
* AGENTA_AGENT_TOOLS_RELAY_DIR relay tool calls through the runner via files here
* AGENTA_AGENT_SKILLS_LOADED JSON [skillName] of skills that loaded this run (F-029)
*
Expand Down Expand Up @@ -47,6 +51,7 @@ import {
requiredFields,
specInputSchema,
} from "../tools/spec-schema.ts";
import { PUBLIC_SPECS_FILE_ENV } from "../tools/tool-mcp-env.ts";
import {
buildPiGateEnvelope,
PI_GATE_DIALOG_TITLE,
Expand Down Expand Up @@ -80,6 +85,13 @@ function log(message: string): void {
process.stderr.write(`[agenta-pi-ext] ${message}\n`);
}

/**
* The pre-file inline delivery of the public tool specs. Kept readable for ONE release so a
* harness whose env was built by an older runner still sees its tools; the runner itself only
* writes `PUBLIC_SPECS_FILE_ENV` now.
*/
const LEGACY_PUBLIC_SPECS_ENV = "AGENTA_AGENT_TOOLS_PUBLIC_SPECS";

/** The bundle cannot import the runner's identity table, so this copy is pinned against the
* shared golden in `tests/unit/pi-builtin-tools-parity.test.ts`. */
export const PI_BUILTIN_TOOL_NAMES = [
Expand Down Expand Up @@ -270,19 +282,60 @@ function parseSkillsLoaded(raw: string | undefined): string[] {
}
}

/** Register public tool metadata as Pi tools whose execution relays to the runner. */
function registerTools(pi: ExtensionAPI): void {
const raw = process.env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS;
const relayDir = process.env.AGENTA_AGENT_TOOLS_RELAY_DIR;
if (!raw || !relayDir) return;
/**
* Load the run's public tool specs, preferring the runner-written FILE.
*
* The file is the delivery route: one env var holding every hydrated spec overflows Linux's
* per-string `execve` limit (131,072 bytes) and kills the harness spawn with `E2BIG` before any
* tool can be registered. The inline var remains a fallback for ONE release so a harness started
* by an older runner — or a warm session whose env predates this deploy — still finds its tools;
* remove it once no such process can be live.
*
* Returns `undefined` on any defect (unreadable file, bad JSON, non-array) after logging it: the
* caller then registers nothing, which is the same outcome as before, but the reason is on stderr.
*/
function loadPublicToolSpecs():
{ specs: ResolvedToolSpec[]; route: string } | undefined {
const path = process.env[PUBLIC_SPECS_FILE_ENV];
const raw = process.env[LEGACY_PUBLIC_SPECS_ENV];
let json: string;
let route: string;
if (path) {
route = `file ${path}`;
try {
json = readFileSync(path, "utf-8");
} catch (err) {
log(`cannot read tool specs ${route}: ${(err as Error).message}`);
return undefined;
}
} else if (raw) {
route = `env ${LEGACY_PUBLIC_SPECS_ENV}`;
json = raw;
} else {
return undefined;
}

let specs: ResolvedToolSpec[] = [];
let parsed: unknown;
try {
specs = JSON.parse(raw);
parsed = JSON.parse(json);
} catch (err) {
log(`bad AGENTA_AGENT_TOOLS_PUBLIC_SPECS: ${(err as Error).message}`);
return;
log(`bad tool specs in ${route}: ${(err as Error).message}`);
return undefined;
}
if (!Array.isArray(parsed)) {
log(`tool specs in ${route} must be a JSON array`);
return undefined;
}
return { specs: parsed as ResolvedToolSpec[], route };
}

/** Register public tool metadata as Pi tools whose execution relays to the runner. */
function registerTools(pi: ExtensionAPI): void {
const relayDir = process.env.AGENTA_AGENT_TOOLS_RELAY_DIR;
if (!relayDir) return;
const loaded = loadPublicToolSpecs();
if (!loaded) return;
const specs = loaded.specs;

let registered = 0;
for (const spec of specs) {
Expand Down Expand Up @@ -352,13 +405,14 @@ function registerTools(pi: ExtensionAPI): void {
} as any);
registered += 1;
}
log(`registered ${registered} tool(s) -> relay ${relayDir}`);
log(
`registered ${registered} tool(s) from ${loaded.route} -> relay ${relayDir}`,
);
}

/** The Pi ExtensionFactory: tools + (env-driven) tracing + usage writeback. */
const factory = (pi: ExtensionAPI): void => {
const modelProviderOverrideRaw =
process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV];
const modelProviderOverrideRaw = process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV];
const modelProviderOverride =
modelProviderOverrideRaw === undefined
? undefined
Expand All @@ -369,7 +423,11 @@ const factory = (pi: ExtensionAPI): void => {
process.env.TRACEPARENT || process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
);
const relayDir = process.env.AGENTA_AGENT_TOOLS_RELAY_DIR;
const hasTools = !!(process.env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS && relayDir);
const hasTools = !!(
(process.env[PUBLIC_SPECS_FILE_ENV] ||
process.env[LEGACY_PUBLIC_SPECS_ENV]) &&
relayDir
);
const hasBuiltinActivation = isTruthyFlag(
process.env.AGENTA_AGENT_BUILTIN_ACTIVATION,
);
Expand Down
4 changes: 3 additions & 1 deletion services/runner/src/lifecycle/reconciliation-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export type HarnessKind = "pi" | "claude" | "codex" | "unknown";
*
* The shelved components, with their insertion points, are:
* - the untrusted best-effort acknowledgement (adapter-matrix.md section 4.3);
* - the Pi specs-file channel, replacing the `AGENTA_AGENT_TOOLS_PUBLIC_SPECS` env var;
* - re-reading the Pi specs file mid-session (the file channel itself now ships — the runner
* writes `AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE` on every Pi run — but the extension reads it
* once, at session start);
* - the MCP shim `tools.listChanged` capability plus its notification.
*/
export interface HarnessLifecycleCapabilities {
Expand Down
Loading
Loading