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
5 changes: 4 additions & 1 deletion HACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ Piolium records retry metadata in `piolium/audit-state.json`. Retry counts are r
| Lite Q0/Q1 overrides | `2` retries, `5000` ms base backoff, `120000` ms max backoff | `--plm-lite-retries`, `--plm-lite-backoff`, `--plm-lite-backoff-max` |
| Command reruns | `3` retries, `5000` ms base backoff, `120000` ms max backoff | `--plm-command-retries`, `--plm-command-backoff`, `--plm-command-backoff-max` |

Provider policy refusals that explicitly flag cybersecurity content are treated as non-retryable: repeating an unchanged phase prompt cannot succeed and only wastes requests. The phase still records the refusal in `audit-state.json` so the operator can resume with another model.

Example:

```bash
Expand Down Expand Up @@ -397,7 +399,8 @@ Sub-agents under `agents/` are package-private because Pi has no first-class `ag
- Canonical phase order is in `extensions/piolium/modes/modes.ts`.
- Per-phase retry, state transitions, and heartbeat tracking are owned by `extensions/piolium/modes/phase-runner.ts`.
- Sub-agents run through `extensions/piolium/agent-runner.ts`, which creates child Pi sessions in-process with `createAgentSession`.
- Agent runs write transcripts to `<target>/piolium/tmp/piolium/runs/<runId>/`.
- Agent frontmatter model families (`haiku`, `sonnet`, `opus`) resolve against authenticated Pi models. Resolution prefers the parent's provider when it offers that family, then direct Anthropic and Anthropic Vertex, and selects the newest matching model. Unresolvable declarations fall back to the parent model.
- Agent runs write transcripts to `<target>/piolium/tmp/piolium/runs/<runId>/` and record both requested and resolved models in `prompt.md`.
- Durable state lives at `<target>/piolium/audit-state.json`.
- File writes to audit state should go through helpers in `extensions/piolium/audit-state.ts`.

Expand Down
80 changes: 76 additions & 4 deletions extensions/piolium/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { type WriteStream, createWriteStream } from "node:fs";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import {
type AgentSessionEvent,
DefaultResourceLoader,
Expand Down Expand Up @@ -66,8 +66,7 @@ export interface RunAgentOptions {
* Optional model override. When omitted the child boots with the
* settings-derived default, matching the parent.
*/
// biome-ignore lint/suspicious/noExplicitAny: pi-ai Model is generic over provider api
model?: Model<any>;
model?: Model<Api>;
/**
* Optional parent registry. This preserves extension-registered providers
* and their request headers for child sessions without loading extensions
Expand Down Expand Up @@ -119,6 +118,76 @@ export class AgentRunError extends Error {
}

const TRANSCRIPT_STRING_LIMIT = 8_000;
const AGENT_MODEL_FAMILIES = new Set(["haiku", "sonnet", "opus"]);

function modelVersionParts(model: Model<Api>): number[] {
return model.id.match(/\d+/g)?.map(Number) ?? [];
}

function compareVersionParts(a: number[], b: number[]): number {
const length = Math.max(a.length, b.length);
for (let index = 0; index < length; index++) {
const difference = (a[index] ?? 0) - (b[index] ?? 0);
if (difference !== 0) return difference;
}
return 0;
}

function providerPreference(model: Model<Api>, parentProvider: string | undefined): number {
if (parentProvider && model.provider === parentProvider) return 3;
if (model.provider === "anthropic") return 2;
if (model.provider === "anthropic-vertex") return 1;
return 0;
}

function choosePreferredModel(
models: Model<Api>[],
parentProvider: string | undefined,
): Model<Api> | undefined {
let preferred: Model<Api> | undefined;
for (const candidate of models) {
if (!preferred) {
preferred = candidate;
continue;
}
const providerDifference =
providerPreference(candidate, parentProvider) - providerPreference(preferred, parentProvider);
if (
providerDifference > 0 ||
(providerDifference === 0 &&
compareVersionParts(modelVersionParts(candidate), modelVersionParts(preferred)) > 0)
) {
preferred = candidate;
}
}
return preferred;
}

/** Resolve a Claude Code-style agent model declaration against authenticated Pi models. */
export function resolveAgentModel(
requested: string | undefined,
parentModel: Model<Api> | undefined,
modelRegistry: ModelRegistry | undefined,
): Model<Api> | undefined {
const normalized = requested?.trim().toLowerCase();
if (!normalized || !modelRegistry) return parentModel;

const available = modelRegistry.getAvailable();
let matches: Model<Api>[];
if (AGENT_MODEL_FAMILIES.has(normalized)) {
matches = available.filter((model) => {
const searchable = `${model.provider} ${model.id} ${model.name ?? ""}`.toLowerCase();
return searchable.includes(normalized);
});
} else {
matches = available.filter((model) => {
const qualified = `${model.provider}/${model.id}`.toLowerCase();
return qualified === normalized || model.id.toLowerCase() === normalized;
});
}

return choosePreferredModel(matches, parentModel?.provider) ?? parentModel;
}

export function buildRuntimeHeader(runtime: RuntimeContext): string {
const lines: string[] = ["# piolium Runtime", ""];
Expand Down Expand Up @@ -196,13 +265,16 @@ export async function runAgent(options: RunAgentOptions): Promise<RunAgentResult

const header = buildRuntimeHeader(options.runtime);
const composedSystemPrompt = `${header}\n\n${options.agent.systemPrompt}`;
const resolvedModel = resolveAgentModel(options.agent.model, options.model, options.modelRegistry);

writeFileSync(
promptPath,
[
`# Run ${options.runId}`,
`Agent: ${options.agent.name}`,
`Source: ${options.agent.sourcePath}`,
`Requested model: ${options.agent.model ?? "(parent/default)"}`,
`Resolved model: ${resolvedModel ? `${resolvedModel.provider}/${resolvedModel.id}` : "(default)"}`,
"",
"## Task",
"",
Expand Down Expand Up @@ -243,7 +315,7 @@ export async function runAgent(options: RunAgentOptions): Promise<RunAgentResult
const { session } = await createAgentSession({
cwd: options.runtime.cwd,
agentDir: getAgentDir(),
...(options.model ? { model: options.model } : {}),
...(resolvedModel ? { model: resolvedModel } : {}),
...(options.modelRegistry ? { modelRegistry: options.modelRegistry } : {}),
...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}),
tools: allowedTools,
Expand Down
8 changes: 7 additions & 1 deletion extensions/piolium/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ import { extractStatusPhase, renderPhaseStatusList } from "./phase-status-strip.
import { PioliumPromptPrefixEditor, shouldUsePioliumPromptPrefix } from "./prompt-prefix-editor.ts";
import { registerAnthropicVertex } from "./providers/anthropic-vertex.ts";
import { buildAuditResultStatsLines } from "./result-stats.ts";
import { readNonNegativeIntEnv, readPositiveIntEnv, runWithRetry } from "./retry.ts";
import {
isNonRetryableAgentError,
readNonNegativeIntEnv,
readPositiveIntEnv,
runWithRetry,
} from "./retry.ts";

const PIOLIUM_STREAM = "piolium-stream";
const FLAG_DIR = "plm-dir";
Expand Down Expand Up @@ -519,6 +524,7 @@ async function runCommandWithRetry<T>(
maxRetries: readNonNegativeIntEnv("PIOLIUM_COMMAND_MAX_RETRIES", 3),
backoffBaseMs: readPositiveIntEnv("PIOLIUM_COMMAND_BACKOFF_BASE_MS", 5000),
backoffMaxMs: readPositiveIntEnv("PIOLIUM_COMMAND_BACKOFF_MAX_MS", 120_000),
shouldRetry: (err) => !isNonRetryableAgentError(err),
onRetry: (info) => {
ui.notify(
`${label} attempt ${info.attempt}/${info.maxAttempts} failed; retrying in ${Math.ceil(info.backoffMs / 1000)}s.`,
Expand Down
10 changes: 9 additions & 1 deletion extensions/piolium/modes/balanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { runCandidateScanAsync } from "../candidate-scan.ts";
import { consolidateDrafts, findingsDraftDir, listFindingDirs } from "../findings.ts";
import { ingestKnowledgeBaseForRun } from "../knowledge-base-input.ts";
import { runReconAsync } from "../recon.ts";
import { findNonRetryableRejection, isNonRetryableAgentError } from "../retry.ts";
import { Scheduler } from "../scheduler.ts";
import { cleanupConfirmArtifacts } from "./confirm.ts";
import { type PhaseUiHooks, runAgentPhase } from "./phase-runner.ts";
Expand Down Expand Up @@ -350,6 +351,8 @@ async function runL3PlusL4Parallel(
}),
]);
scheduler.dispose();
const nonRetryable = findNonRetryableRejection(settled);
if (nonRetryable) throw nonRetryable.reason;
return { failed: settled.some((s) => s.status === "rejected") };
}

Expand Down Expand Up @@ -409,10 +412,12 @@ async function runPerFindingPhase(
);
scheduler.dispose();
const failed = results.some((r) => r.status === "rejected");
const nonRetryable = findNonRetryableRejection(results);
await applyPhaseStatus(cwd, audit, phaseName, {
status: failed ? "failed" : "complete",
...(failed ? { error: `Some per-finding ${phaseName} runs failed.` } : {}),
});
if (nonRetryable) throw nonRetryable.reason;
return { failed };
}

Expand Down Expand Up @@ -558,6 +563,7 @@ export async function runBalancedAudit(opts: RunBalancedOptions): Promise<RunBal
const reportAssembler = agents.get("report-assembler");

let failed = false;
let nonRetryableError: unknown;

try {
// L1
Expand Down Expand Up @@ -710,8 +716,9 @@ export async function runBalancedAudit(opts: RunBalancedOptions): Promise<RunBal
const r = await runBalancedVerificationCleanup(cwd, audit, ui, signal);
if (r.failed) failed = true;
}
} catch {
} catch (err) {
failed = true;
if (isNonRetryableAgentError(err)) nonRetryableError = err;
}

await markAuditStatus(cwd, audit.audit_id, failed ? "failed" : "complete");
Expand All @@ -727,5 +734,6 @@ export async function runBalancedAudit(opts: RunBalancedOptions): Promise<RunBal
failed ? "Balanced audit failed." : "Balanced audit complete.",
failed ? "error" : "info",
);
if (nonRetryableError) throw nonRetryableError;
return { auditId: audit.audit_id, status: failed ? "failed" : "complete", phases };
}
9 changes: 6 additions & 3 deletions extensions/piolium/modes/phase-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../heartbeat.ts";
import {
errorMessage,
isNonRetryableAgentError,
readNonNegativeIntEnv,
readPositiveIntEnv,
retryBackoffMs,
Expand Down Expand Up @@ -214,11 +215,13 @@ export async function runAgentPhase(opts: RunAgentPhaseOptions): Promise<void> {
return;
}

if (opts.signal?.aborted || attempt >= maxAttempts) {
const nonRetryable = isNonRetryableAgentError(err);
if (opts.signal?.aborted || attempt >= maxAttempts || nonRetryable) {
await applyPhaseStatus(cwd, audit, phaseName, {
status: "failed",
error:
attempt >= maxAttempts && maxRetries > 0
error: nonRetryable
? `Non-retryable provider policy error: ${message}`
: attempt >= maxAttempts && maxRetries > 0
? `Failed after ${maxRetries} retries: ${message}`
: message,
attempt,
Expand Down
20 changes: 20 additions & 0 deletions extensions/piolium/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : typeof err === "string" ? err : "Unknown error";
}

const NON_RETRYABLE_AGENT_ERROR_PATTERNS = [
/This content was flagged for possible cybersecurity risk/i,
/join the Trusted Access for Cyber program/i,
];

/** Provider policy refusals are deterministic for an unchanged phase prompt. */
export function isNonRetryableAgentError(err: unknown): boolean {
const message = errorMessage(err);
return NON_RETRYABLE_AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}

export function findNonRetryableRejection(
results: readonly PromiseSettledResult<unknown>[],
): PromiseRejectedResult | undefined {
for (const result of results) {
if (result.status === "rejected" && isNonRetryableAgentError(result.reason)) return result;
}
return undefined;
}

export function retryBackoffMs(attempt: number, baseMs: number, maxMs: number): number {
const exponent = Math.max(0, attempt - 1);
const raw = baseMs * 2 ** exponent;
Expand Down
51 changes: 50 additions & 1 deletion test/agent-runner.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { buildRuntimeHeader, compactTranscriptEvent } from "../extensions/piolium/agent-runner.ts";
import {
buildRuntimeHeader,
compactTranscriptEvent,
resolveAgentModel,
} from "../extensions/piolium/agent-runner.ts";

function fakeModel(provider: string, id: string, name = id): Model<Api> {
return { provider, id, name } as Model<Api>;
}

function fakeRegistry(models: Model<Api>[]): ModelRegistry {
return { getAvailable: () => models } as unknown as ModelRegistry;
}

describe("resolveAgentModel", () => {
it("routes a family alias away from the parent model when an authenticated match exists", () => {
const parent = fakeModel("openai-codex", "gpt-5.6-sol");
const sonnet45 = fakeModel("anthropic", "claude-sonnet-4-5");
const sonnet5 = fakeModel("anthropic", "claude-sonnet-5");
const registry = fakeRegistry([parent, sonnet45, sonnet5]);

expect(resolveAgentModel("sonnet", parent, registry)).toBe(sonnet5);
});

it("prefers a matching model from the parent's provider", () => {
const parent = fakeModel("anthropic-vertex", "claude-opus-4-6@default");
const direct = fakeModel("anthropic", "claude-sonnet-5");
const vertex = fakeModel("anthropic-vertex", "claude-sonnet-4-5@20250929");
const registry = fakeRegistry([direct, vertex]);

expect(resolveAgentModel("sonnet", parent, registry)).toBe(vertex);
});

it("resolves a fully-qualified model id", () => {
const parent = fakeModel("openai-codex", "gpt-5.6-sol");
const requested = fakeModel("anthropic", "claude-sonnet-4-6");
const registry = fakeRegistry([parent, requested]);

expect(resolveAgentModel("anthropic/claude-sonnet-4-6", parent, registry)).toBe(requested);
});

it("falls back to the parent model when the declaration cannot be resolved", () => {
const parent = fakeModel("openai-codex", "gpt-5.6-sol");
const registry = fakeRegistry([parent]);

expect(resolveAgentModel("sonnet", parent, registry)).toBe(parent);
});
});

describe("buildRuntimeHeader", () => {
it("includes the audit cwd, mode, and phase when provided", () => {
Expand Down
24 changes: 24 additions & 0 deletions test/retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import {
findNonRetryableRejection,
isNonRetryableAgentError,
readNonNegativeIntEnv,
readPositiveIntEnv,
runWithRetry,
Expand Down Expand Up @@ -59,6 +61,28 @@ describe("runWithRetry", () => {
});
});

describe("isNonRetryableAgentError", () => {
it("recognizes the Codex cybersecurity policy refusal", () => {
expect(
isNonRetryableAgentError(new Error("This content was flagged for possible cybersecurity risk.")),
).toBe(true);
});

it("does not suppress retries for ordinary security-audit failures", () => {
expect(isNonRetryableAgentError(new Error("cybersecurity scanner timed out"))).toBe(false);
});

it("finds policy refusals captured by Promise.allSettled", () => {
const policyError = new Error("Join the Trusted Access for Cyber program to continue.");
const results: PromiseSettledResult<unknown>[] = [
{ status: "fulfilled", value: undefined },
{ status: "rejected", reason: policyError },
];

expect(findNonRetryableRejection(results)?.reason).toBe(policyError);
});
});

describe("env readers", () => {
it("reads PIOLIUM_* values", () => {
process.env.PIOLIUM_TEST_LIMIT = "7";
Expand Down