Skip to content
Merged
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
77 changes: 37 additions & 40 deletions packages/evals/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import { IncompleteLedgerReceipt } from "@moltzap/simulator";
import { ledgerRef } from "@moltzap/simulator/ledger";
import { evaluationCase } from "./cases.js";
import {
infrastructureFailed,
invalidImageDetail,
ledgerAllocationFailed,
missingImageDetail,
runInfrastructureFailed,
type AttemptContext,
type EvaluationImageKey,
} from "./cli.js";
Expand Down Expand Up @@ -94,45 +93,37 @@ effect.each([
}).pipe(Effect.provide(NodeContext.layer)),
);

const CLUSTER_LOST = { _tag: "ClusterLost", receipt: RECEIPT } as const;
const ALLOCATION_FAILED = { _tag: "LedgerAllocationFailed" } as const;

// The operator-facing account either infrastructure attempt carries.
function attemptAccount(
attempt: Effect.Effect.Success<ReturnType<typeof infrastructureFailed>>,
): string {
return attempt._tag === "RunFailedAttempt"
? attempt.detail
: attempt.failure.detail;
}

// Both infrastructure attempts already carry the operator-facing account of a
// failure, and phoenix-run publishes exactly that field as the run's error, so
// the controller's own account belongs in it rather than beside it.
effect.each([undefined, DIAGNOSTIC])(
"gives a failed run the controller account %s",
(diagnostic?: string) =>
Effect.gen(function* () {
const attempt = yield* runInfrastructureFailed(
attemptContext(),
RECEIPT,
diagnostic,
);

assert.strictEqual(attempt.detail, diagnostic ?? attempt.detail);
assert.isNotEmpty(attempt.detail);
if (diagnostic === undefined) {
assert.include(attempt.detail, "infrastructure failure");
}
}),
);

effect.each([undefined, DIAGNOSTIC])(
"gives a failed allocation the controller account %s",
(diagnostic?: string) =>
Effect.gen(function* () {
const attempt = yield* ledgerAllocationFailed(
attemptContext(),
diagnostic,
);
effect.each([
["a lost cluster", CLUSTER_LOST, "infrastructure failure"],
["a failed allocation", ALLOCATION_FAILED, "durable ledger"],
] as const)("gives %s the controller account", ([, summary, canned]) =>
Effect.gen(function* () {
const context = attemptContext();

assert.strictEqual(
attempt.failure.detail,
diagnostic ?? attempt.failure.detail,
);
assert.isNotEmpty(attempt.failure.detail);
if (diagnostic === undefined) {
assert.include(attempt.failure.detail, "durable ledger");
}
}),
assert.strictEqual(
attemptAccount(yield* infrastructureFailed(context, summary, DIAGNOSTIC)),
DIAGNOSTIC,
);
assert.include(
attemptAccount(yield* infrastructureFailed(context, summary)),
canned,
);
}),
);

// Without a controller account the two attempts still have to be told apart:
Expand All @@ -141,8 +132,14 @@ effect.each([undefined, DIAGNOSTIC])(
it("distinguishes the two infrastructure failures when neither left an account", () =>
Effect.gen(function* () {
const context = attemptContext();
const failedRun = yield* runInfrastructureFailed(context, RECEIPT);
const failedAllocation = yield* ledgerAllocationFailed(context);
const failedRun = yield* infrastructureFailed(context, CLUSTER_LOST);
const failedAllocation = yield* infrastructureFailed(
context,
ALLOCATION_FAILED,
);

assert.notStrictEqual(failedRun.detail, failedAllocation.failure.detail);
assert.notStrictEqual(
attemptAccount(failedRun),
attemptAccount(failedAllocation),
);
}));
124 changes: 52 additions & 72 deletions packages/evals/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@
import { Command as CliCommand, Options } from "@effect/cli";
import { Command, Path } from "@effect/platform";
import { NodeContext, NodeRuntime } from "@effect/platform-node";
import {
isEntryModule,
type CompletedLedgerReceipt,
type LedgerReceipt,
} from "@moltzap/simulator";
import { isEntryModule, type CompletedLedgerReceipt } from "@moltzap/simulator";
import {
LedgerStorageError,
type CompletedLedgerArtifacts,
Expand Down Expand Up @@ -489,58 +485,50 @@ function completeExecution(
});
}

// Both infrastructure attempts already own a `detail`, and Phoenix already
// publishes it as a run's error. The controller's own account of the failure
// belongs there rather than beside it: a canned sentence is what an operator
// reads today, and it says only that something went wrong.
const ALLOCATION_FAILED_DETAIL =
"the simulator controller could not allocate its durable ledger";
const RUN_FAILED_DETAIL =
"the simulator controller reported an infrastructure failure";

/**
* Record that ledger allocation failed, with whatever account the run left.
* @param context Cell identity and the instant execution began.
* @param diagnostic The controller's own account, when it produced one.
* @returns The terminal attempt this cell commits.
*/
export function ledgerAllocationFailed(
context: AttemptContext,
diagnostic?: string,
) {
return DateTime.now.pipe(
Effect.map((completedAt) =>
LedgerAllocationFailedAttempt.make({
...terminalFields(context, completedAt),
failure: LedgerStorageError.make({
operation: "allocate",
detail: diagnostic ?? ALLOCATION_FAILED_DETAIL,
}),
}),
),
);
}
/** Summary of a submission that never reached a gradeable run. */
type InfrastructureSummary = Exclude<
EvaluationSubmissionResult["result"]["summary"],
{ readonly _tag: "ProgramFinished" }
>;

// A run that never got a ledger and a run that lost its cluster are different
// operator problems, and this text is all that says which happened when the
// controller left no account of its own.
const INFRASTRUCTURE_FAILED_DETAIL: Readonly<
Record<InfrastructureSummary["_tag"], string>
> = {
LedgerAllocationFailed:
"the simulator controller could not allocate its durable ledger",
ClusterLost: "the simulator controller reported an infrastructure failure",
};

/**
* Record an infrastructure failure, with whatever account the run left.
* @param context Cell identity and the instant execution began.
* @param receipt Durable evidence the controller retained.
* @param summary Terminal summary the controller printed for this cell.
* @param diagnostic The controller's own account, when it produced one.
* @returns The terminal attempt this cell commits.
*/
export function runInfrastructureFailed(
export function infrastructureFailed(
context: AttemptContext,
receipt: LedgerReceipt,
summary: InfrastructureSummary,
diagnostic?: string,
) {
return DateTime.now.pipe(
Effect.map((completedAt) =>
RunFailedAttempt.make({
...terminalFields(context, completedAt),
receipt,
detail: diagnostic ?? RUN_FAILED_DETAIL,
}),
),
Effect.map((completedAt) => {
const detail = diagnostic ?? INFRASTRUCTURE_FAILED_DETAIL[summary._tag];
const fields = terminalFields(context, completedAt);
return summary._tag === "LedgerAllocationFailed"
? LedgerAllocationFailedAttempt.make({
...fields,
failure: LedgerStorageError.make({ operation: "allocate", detail }),
})
: RunFailedAttempt.make({
...fields,
receipt: summary.receipt,
detail,
});
}),
);
}

Expand Down Expand Up @@ -601,19 +589,14 @@ function completeSubmission(
submission: EvaluationSubmissionResult,
) {
const summary = submission.result.summary;
const diagnostic = submissionDiagnostic(submission);
if (summary._tag === "LedgerAllocationFailed") {
return ledgerAllocationFailed(context, diagnostic);
}
if (summary._tag === "ClusterLost") {
return runInfrastructureFailed(context, summary.receipt, diagnostic);
}
return readCompletedArtifacts(
environment,
context,
submission.namespace,
summary.receipt,
);
return summary._tag === "ProgramFinished"
? readCompletedArtifacts(
environment,
context,
submission.namespace,
summary.receipt,
)
: infrastructureFailed(context, summary, submissionDiagnostic(submission));
}

function conditionModelId(
Expand Down Expand Up @@ -742,22 +725,20 @@ function requiredEnvironment(key: string) {
);
}

/** Environment key naming one digest-pinned image an evaluation run needs. */
export type EvaluationImageKey =
| "MOLTZAP_CONTROLLER_IMAGE"
| "MOLTZAP_SUPPORT_IMAGE"
| "MOLTZAP_NANOCLAW_IMAGE";
const CONTROLLER_IMAGE_PRODUCER =
"packages/simulator/scripts/build-controller-image.mjs";

// Nothing else in the repository produces these references, so a missing one is
// an operator who has not run the producer yet rather than one who forgot to
// export a value they already had. Naming the producer is the whole remedy.
const imageProducer: Readonly<Record<EvaluationImageKey, string>> = {
MOLTZAP_CONTROLLER_IMAGE:
"packages/simulator/scripts/build-controller-image.mjs",
MOLTZAP_SUPPORT_IMAGE:
"packages/simulator/scripts/build-controller-image.mjs",
const imageProducer = {
MOLTZAP_CONTROLLER_IMAGE: CONTROLLER_IMAGE_PRODUCER,
MOLTZAP_SUPPORT_IMAGE: CONTROLLER_IMAGE_PRODUCER,
MOLTZAP_NANOCLAW_IMAGE: "packages/simulator/scripts/build-nanoclaw-image.mjs",
};
} as const;

/** Environment key naming one digest-pinned image an evaluation run needs. */
export type EvaluationImageKey = keyof typeof imageProducer;

/**
* Say which producer builds an evaluation image the environment omitted.
Expand Down Expand Up @@ -1013,8 +994,7 @@ const cli = CliCommand.run(evaluationCommand, {
});

// Guarded so this module can be imported: without it, reading any value here
// runs the CLI against the importer's argv, which is why the operator-facing
// vocabulary above had to live in a module that does not read it.
// runs the CLI against the importer's argv.
// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- Direct-entry detection has no Effect Platform equivalent.
if (isEntryModule(import.meta.url, process.argv[1])) {
// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- @effect/cli owns argv decoding at the process boundary.
Expand Down
13 changes: 4 additions & 9 deletions packages/evals/src/submission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,11 @@ effect.each([undefined, CARRIED_DIAGNOSTIC])(
// The rest of that line is the run's only receipt. Refusing an over-long
// diagnostic would discard it, turning a cell that failed with evidence into a
// cell with no attempt at all.
it("keeps the receipt when a submitter diagnostic exceeds the bound", () =>
it("keeps the receipt when a submitter diagnostic is over-long", () =>
Effect.gen(function* () {
const decoded = yield* decodeSubmissionOutput(
submitterLine("x".repeat(OVERSIZED_DIAGNOSTIC_LENGTH)),
);
const oversized = "x".repeat(OVERSIZED_DIAGNOSTIC_LENGTH);
const decoded = yield* decodeSubmissionOutput(submitterLine(oversized));

// The receipt survives; only the decoration is trimmed.
assert.isTrue("receipt" in decoded.result.summary);
assert.isBelow(
(submissionDiagnostic(decoded) ?? "").length,
OVERSIZED_DIAGNOSTIC_LENGTH,
);
assert.strictEqual(submissionDiagnostic(decoded), oversized);
}));
9 changes: 2 additions & 7 deletions packages/evals/src/submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,6 @@ const runInfrastructureFailedSummary = Schema.Struct({
const ledgerAllocationFailedSummary = Schema.Struct({
_tag: Schema.Literal("LedgerAllocationFailed"),
});
// Bounded here rather than in the schema, because the diagnostic is decoration
// on a line whose other fields are the run's only receipt: a producer that
// overshoots must not be able to take the receipt down with it. The submitter
// bounds what it publishes; this bounds what a report will hold.
const DIAGNOSTIC_MAX_LENGTH = 8_192;
const evaluationSubmissionResult = Schema.Struct({
runId: Schema.NonEmptyString,
namespace: Schema.NonEmptyString,
Expand Down Expand Up @@ -76,7 +71,7 @@ export type EvaluationSubmissionResult = typeof evaluationSubmissionResult.Type;
/**
* The controller's own account of why one submission failed.
* @param submission Decoded submitter result for one cell.
* @returns Bounded diagnostic text, or undefined when the cell carried none.
* @returns The diagnostic the submitter published, or undefined when it carried none.
*/
export function submissionDiagnostic(
submission: EvaluationSubmissionResult,
Expand All @@ -85,7 +80,7 @@ export function submissionDiagnostic(
submission.result.exitCode === 1 ? submission.result.diagnostic : undefined;
return diagnostic === undefined || diagnostic.length === 0
? undefined
: diagnostic.slice(-DIAGNOSTIC_MAX_LENGTH);
: diagnostic;
}

/** A repository-local cell could not be submitted or decoded. */
Expand Down
5 changes: 3 additions & 2 deletions packages/simulator/gke/profile.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,9 @@ test("the evals verb hands the sweep every identity it cannot derive", async ()
// The forward supervisor is a background job of this shell; exec would strand
// it with no trap left to reap it.
assert.doesNotMatch(script, /exec corepack/);
// One prelude, so a trap fix cannot reach `run` and miss `evals`.
assert.equal(script.match(/begin_cluster_session/g)?.length, 3);
// One prelude, shared rather than re-inlined per verb.
assert.match(script, /^begin_cluster_session\(\) \{$/m);
assert.match(script, /^\s+begin_cluster_session$/m);
assert.equal(
script.match(/open_temporal_forward "\$forward_port"/g)?.length,
1,
Expand Down
3 changes: 0 additions & 3 deletions packages/simulator/nanoclaw-image/entrypoint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,6 @@ async function readBootstrapConfig() {
return config;
}

// Concurrently, because every entry targets a distinct path and this runs
// before the bridge port opens — which is what the controller reads as
// readiness, so anything serialized here is startup latency for the whole run.
async function materializeProjectRoot(config) {
const projectRoot =
config.stateDirectory ?? requiredEnvironment("MOLTZAP_NANOCLAW_STATE");
Expand Down
3 changes: 1 addition & 2 deletions packages/simulator/scripts/build-nanoclaw-image.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ const workspacePackages = {
*/
export function assertRepository(repository) {
// The repository half excludes `@` so a trailing digest cannot be smuggled in
// behind an earlier one — the same reason the image schema excludes it. One
// rule, checked when the argument arrives rather than only after the build.
// behind an earlier one — the same reason the image schema excludes it.
if (repository.length === 0 || /[@\s]/.test(repository)) {
throw new TypeError(
"a nanoclaw image repository must be nonempty and carry no digest",
Expand Down
8 changes: 6 additions & 2 deletions packages/simulator/src/agents/openclaw/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import type {
ToolsConfig,
} from "openclaw/plugin-sdk/config-types";
import { Redacted } from "effect";
import { SIMULATOR_PROFILE_NAME, type McpServer } from "../workspace.js";
import {
isHttpMcpServer,
SIMULATOR_PROFILE_NAME,
type McpServer,
} from "../workspace.js";

const DEFAULT_OPENCLAW_MODEL_ID = "openai/gpt-5.5";
const OPENCLAW_CHANNEL_ID = "moltzap" satisfies MoltzapChannelPlugin["id"];
Expand Down Expand Up @@ -42,7 +46,7 @@ function mcpConfigSection(
servers: Object.fromEntries(
mcpServers.map((server) => [
server.name,
"url" in server
isHttpMcpServer(server)
? { transport: "streamable-http" as const, url: server.url }
: {
transport: "stdio" as const,
Expand Down
Loading
Loading