fix: 支持 Artificer 模型选择与 gpt-pika

关联 #325。固定 v0.2 gpt-pika SecretRef,支持 task 级模型与 reasoning override,并将 reasoning effort 传递到 Codex app-server。

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
root
2026-07-12 16:02:23 +02:00
parent 201beba9fe
commit 46b191e3e0
13 changed files with 578 additions and 44 deletions
+10 -9
View File
@@ -3,20 +3,20 @@ kind: AipodSpec
metadata:
name: Artificer
displayName: Artificer
description: UniDesk distributed development agent assembled from AgentRun v0.1 YAML.
description: UniDesk distributed development agent assembled from AgentRun v0.2 YAML.
labels:
agentrun.pikastech.local/lane: v0.1
agentrun.pikastech.local/lane: v0.2
agentrun.pikastech.local/purpose: distributed-development
spec:
tenantId: unidesk
projectId: pikasTech/unidesk
queue: commander
lane: v0.1
lane: v0.2
priority: 50
providerId: G14
backendProfile: sub2api
providerId: NC01
backendProfile: gpt-pika
model:
model: gpt-5.5
model: gpt-5.6-terra
reasoningEffort: xhigh
imageRef:
kind: env-image-dockerfile
@@ -34,9 +34,10 @@ spec:
secretScope:
allowCredentialEcho: false
providerCredentials:
- profile: sub2api
- profile: gpt-pika
secretRef:
name: agentrun-v01-provider-sub2api
namespace: agentrun-v02
name: agentrun-v02-provider-gpt-pika
keys:
- auth.json
- config.toml
@@ -127,4 +128,4 @@ spec:
source: config/aipods/artificer.yaml
dispatchDefaults:
runnerJob:
namespace: agentrun-v01
namespace: agentrun-v02
+15 -6
View File
@@ -1112,6 +1112,10 @@ function queueSubmitConfirmCommand(args: ParsedArgs): string {
function queueSubmitAipodConfirmCommand(args: ParsedArgs, aipod: string): string {
const parts = [`./scripts/agentrun queue submit --aipod ${aipod} --prompt-stdin`];
const model = optionalFlag(args, "model");
const reasoningEffort = optionalFlag(args, "reasoning-effort");
if (model) parts.push(`--model ${model}`);
if (reasoningEffort) parts.push(`--reasoning-effort ${reasoningEffort}`);
if (optionalFlag(args, "idempotency-key")) parts.push("--idempotency-key <idempotency-key>");
return parts.join(" ");
}
@@ -1522,6 +1526,8 @@ async function aipodRenderInput(args: ParsedArgs, trailingPromptStart: number, o
copyOptionalFlag(args, input as JsonRecord, "lane");
copyOptionalFlag(args, input as JsonRecord, "title");
copyOptionalFlag(args, input as JsonRecord, "provider-id", "providerId");
copyOptionalFlag(args, input as JsonRecord, "model");
copyOptionalFlag(args, input as JsonRecord, "reasoning-effort", "reasoningEffort");
const profile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile");
if (profile) input.backendProfile = normalizeProfile(profile);
const priority = optionalFlag(args, "priority");
@@ -1554,7 +1560,10 @@ async function submitQueueTaskWithAipod(args: ParsedArgs, aipod: string): Promis
const rendered = await renderAipodForCommand(args, aipod, 2);
const body = rendered.queueTask as unknown as JsonRecord;
if (args.flags.get("dry-run") === true) {
return queueMutationDryRunPlan("queue-submit", null, "/api/v1/queue/tasks", body, "POST", queueSubmitAipodConfirmCommand(args, aipod), undefined, { source: "aipod-spec", aipod, preferred: "--aipod", valuesPrinted: false });
return {
...queueMutationDryRunPlan("queue-submit", null, "/api/v1/queue/tasks", body, "POST", queueSubmitAipodConfirmCommand(args, aipod), undefined, { source: "aipod-spec", aipod, preferred: "--aipod", valuesPrinted: false }),
modelResolution: rendered.modelResolution,
};
}
return client(args).post("/api/v1/queue/tasks", body);
}
@@ -1592,8 +1601,8 @@ async function sessionSendWithAipod(args: ParsedArgs, positionalSessionId: strin
if (commandIdempotencyKey) sendBody.commandIdempotencyKey = commandIdempotencyKey;
if (args.flags.get("dry-run") !== true) await ensureSessionForSend(args, sessionId, String(task.tenantId), String(task.projectId), profile);
const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/send`, sendBody);
if (wantsExpandedOutput(args)) return { action: "session-send", aipod, result: result as JsonValue, valuesPrinted: false };
return summarizeSessionSendResult(result, sessionId, profile, aipod);
if (wantsExpandedOutput(args)) return { action: "session-send", aipod, modelResolution: rendered.modelResolution, result: result as JsonValue, valuesPrinted: false };
return { ...summarizeSessionSendResult(result, sessionId, profile, aipod), modelResolution: rendered.modelResolution };
}
async function submitQueueTask(args: ParsedArgs): Promise<JsonValue> {
@@ -2302,7 +2311,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
"sessions storage <sessionId>",
"sessions storage <sessionId> --delete",
"sessions show <sessionId> [--reader-id <reader>]",
"sessions send [sessionId] [--aipod <name>|--json-stdin|--json-file <run-base.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--runner-json-stdin|--runner-json-file <job.json>] [--no-runner-job] [--dry-run]",
"sessions send [sessionId] [--aipod <name>|--json-stdin|--json-file <run-base.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--model <model>] [--reasoning-effort <effort>] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--runner-json-stdin|--runner-json-file <job.json>] [--no-runner-job] [--dry-run]",
"sessions cancel <sessionId> [--reason <text>] [--full|--raw]",
"sessions trace <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>] [--summary-chars <n>] [--include-output] [--seq <n>|--event-id <id>|--item-id <id>] [--detail-scan-pages <n>] [--full|--raw]",
"sessions output <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>] [--summary-chars <n>] [--include-output] [--seq <n>|--event-id <id>|--item-id <id>] [--detail-scan-pages <n>] [--full|--raw]",
@@ -2318,7 +2327,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
"runner job-status [runnerJobId] --run-id <runId>",
"runner reconcile [--limit <n>] [--namespace <namespace>] [--dry-run]",
"queue submit --json-stdin|--json-file <task.json> [--idempotency-key <key>] [--dry-run]",
"queue submit --aipod <name> [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--idempotency-key <key>] [--dry-run]",
"queue submit --aipod <name> [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--model <model>] [--reasoning-effort <effort>] [--idempotency-key <key>] [--dry-run]",
"queue list [--queue <queue>] [--state <state>] [--cursor <cursor>] [--limit <limit>] [--updated-after <version>] [--full|--raw]",
"queue show <taskId> [--full|--raw]",
"queue stats [--queue <queue>]",
@@ -2331,7 +2340,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
"queue refresh <taskId> [--dry-run] [--full|--raw]",
"aipod-specs list",
"aipod-specs show <name>",
"aipod-specs render <name> [--json-stdin|--json-file <input.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>]",
"aipod-specs render <name> [--json-stdin|--json-file <input.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--model <model>] [--reasoning-effort <effort>]",
"aipod-specs apply [name] --yaml-stdin|--yaml-file <spec.yaml> [--dry-run]",
"aipod-specs delete <name>",
"secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>] [--codex-home <dir>] [--model-catalog-file <file>] [--namespace agentrun-v01] [--secret-name <name>]",
+1
View File
@@ -69,6 +69,7 @@ export function backendTurnOptions(run: RunRecord, command: CommandRecord, optio
timeoutMs: run.executionPolicy.timeoutMs,
};
if (typeof command.payload.model === "string") turnOptions.model = command.payload.model;
if (typeof command.payload.reasoningEffort === "string") turnOptions.reasoningEffort = command.payload.reasoningEffort;
if (typeof command.payload.threadId === "string" && command.payload.threadId.trim()) turnOptions.threadId = command.payload.threadId.trim();
else if (typeof run.sessionRef?.threadId === "string" && run.sessionRef.threadId.trim()) turnOptions.threadId = run.sessionRef.threadId.trim();
if (options.codexCommand) turnOptions.command = options.codexCommand;
+17 -2
View File
@@ -46,6 +46,7 @@ export interface CodexStdioTurnOptions {
prompt: string;
cwd: string;
model?: string;
reasoningEffort?: string;
threadId?: string;
approvalPolicy: string;
sandbox: string;
@@ -983,7 +984,7 @@ async function runCodexStdioTurnWithSession(options: CodexStdioTurnOptions, sess
let turnStart: { kind: "response"; response: unknown } | { kind: "terminal" };
try {
turnStart = await Promise.race([
client.request("turn/start", withOptionalModel({ threadId, input: textInputForUserMessage(options.prompt, promptInjection), cwd: options.cwd, approvalPolicy: options.approvalPolicy }, options.model), requestTimeoutMs).then((response) => ({ kind: "response" as const, response })),
client.request("turn/start", withOptionalEffort(withOptionalModel({ threadId, input: textInputForUserMessage(options.prompt, promptInjection), cwd: options.cwd, approvalPolicy: options.approvalPolicy }, options.model), options.reasoningEffort), requestTimeoutMs).then((response) => ({ kind: "response" as const, response })),
terminalObservedPromise.then(() => ({ kind: "terminal" as const })),
]);
} catch (error) {
@@ -993,7 +994,7 @@ async function runCodexStdioTurnWithSession(options: CodexStdioTurnOptions, sess
if (turnStart.kind === "response") {
const turnResponse = requireResponseRecord(turnStart.response, "turn/start");
turnId = requireNestedId(turnResponse, "turn/start", "turn");
emitEvent({ type: "backend_status", payload: { phase: "turn/start:completed", turnId } });
emitEvent({ type: "backend_status", payload: { phase: "turn/start:completed", turnId, model: options.model ?? null, reasoningEffort: options.reasoningEffort ?? null, reasoningProtocolField: "effort", valuesPrinted: false } });
waitingFor = "turn/completed";
emitCodexOtelSpan("codex_stdio.turn_start.completed", options, env, { waitingFor, threadId: threadId ?? null, turnId }, { startTimeMs: turnStartStartedAt, endTimeMs: Date.now() });
exposeActiveTurn("turn-start-response");
@@ -1542,6 +1543,12 @@ function withOptionalModel(params: JsonRecord, model: string | undefined): JsonR
return { ...params, model: value };
}
function withOptionalEffort(params: JsonRecord, reasoningEffort: string | undefined): JsonRecord {
const value = typeof reasoningEffort === "string" ? reasoningEffort.trim() : "";
if (!value) return params;
return { ...params, effort: value };
}
function childEnv(options: CodexStdioTurnOptions, codexHome: string): NodeJS.ProcessEnv {
return {
...process.env,
@@ -1675,6 +1682,12 @@ function backendMetadata(options: CodexStdioTurnOptions): JsonRecord {
backendKind: spec?.backendKind ?? "codex-app-server-stdio",
protocol: spec?.protocol ?? codexProtocol,
transport: spec?.transport ?? "stdio",
modelSelection: {
model: options.model ?? null,
reasoningEffort: options.reasoningEffort ?? null,
reasoningProtocolField: "turn/start.effort",
valuesPrinted: false,
},
sandbox: {
requested: options.requestedSandbox ?? options.sandbox,
effective: options.sandbox,
@@ -1733,6 +1746,8 @@ function codexOtelAttributes(options: CodexStdioTurnOptions, env: NodeJS.Process
sessionId: context?.run.sessionRef?.sessionId ?? null,
threadId: context?.run.sessionRef?.threadId ?? options.threadId ?? null,
backendProfile: profile,
model: options.model ?? null,
reasoningEffort: options.reasoningEffort ?? null,
codexHome: pathSummary(resolveCodexHome(options)),
appServerSessionId: shortHash(codexClientKey(options, env)),
jobName: context?.jobName ?? null,
+149 -11
View File
@@ -2,13 +2,24 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { AgentRunError } from "./errors.js";
import type { AipodSpec, AipodSpecRecord, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef } from "./types.js";
import type { AipodModelConfig, AipodSpec, AipodSpecRecord, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef } from "./types.js";
import { backendProfileSpec, isBackendProfile } from "./backend-profiles.js";
import { imageRefSourceSummary, validateAipodImageRef } from "./env-image-ref.js";
import { asRecord, stableHash, validateCreateQueueTask, validateExecutionPolicy, validatePrimaryWorkspaceContract, validateResourceBundleRef, validateSessionRef, validateWorkspaceRef } from "./validation.js";
const aipodApiVersion = "agentrun.pikastech.local/v0.1";
const aipodKind = "AipodSpec";
const modelIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/u;
const reasoningEffortPattern = /^[a-z][a-z0-9-]{0,31}$/u;
const payloadControlFields = ["backendProfile", "executionPolicy", "model", "modelConfig", "providerCredential", "providerCredentials", "reasoningEffort", "secretRef"] as const;
const renderControlOverrideFields = ["executionPolicy", "modelConfig", "providerCredential", "providerCredentials", "secretRef"] as const;
interface EffectiveAipodModel {
model: string | null;
reasoningEffort: string | null;
modelSource: "aipod-spec-default" | "render-input-override" | null;
reasoningEffortSource: "aipod-spec-default" | "render-input-override" | null;
}
export function aipodSpecDirectory(): string {
return process.env.AGENTRUN_AIPOD_SPEC_DIR ?? path.join(process.cwd(), "config", "aipods");
@@ -76,9 +87,11 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
const labels = metadata.labels === undefined ? undefined : asRecord(metadata.labels, "aipodSpec.metadata.labels");
const spec = asRecord(record.spec, "aipodSpec.spec");
const backendProfile = normalizeBackendProfile(requiredString(spec, "backendProfile"));
const model = spec.model === undefined ? undefined : validateAipodModelConfig(spec.model, "aipodSpec.spec.model");
const imageRef = validateAipodImageRef(spec.imageRef, "aipodSpec.spec.imageRef");
const executionPolicy = validateExecutionPolicy(asRecord(spec.executionPolicy, "aipodSpec.spec.executionPolicy"));
validateAipodProviderCredential(backendProfile, executionPolicy);
const payloadDefaults = spec.payloadDefaults === undefined ? undefined : validateAipodPayload(spec.payloadDefaults, "aipodSpec.spec.payloadDefaults");
const resourceBundleRef = validateResourceBundleRef(spec.resourceBundleRef);
const workspaceRef = isJsonRecord(spec.workspaceRef) ? validateWorkspaceRef(spec.workspaceRef) : undefined;
validatePrimaryWorkspaceContract(workspaceRef ?? null, resourceBundleRef);
@@ -99,13 +112,13 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
...(typeof spec.priority === "number" ? { priority: spec.priority } : {}),
...(stringValue(spec.providerId) ? { providerId: stringValue(spec.providerId) as string } : {}),
backendProfile,
...(isJsonRecord(spec.model) ? { model: spec.model } : {}),
...(model ? { model } : {}),
imageRef,
...(workspaceRef ? { workspaceRef } : {}),
...(spec.sessionRef !== undefined ? { sessionRef: validateSessionRef(spec.sessionRef) } : {}),
executionPolicy,
resourceBundleRef,
...(isJsonRecord(spec.payloadDefaults) ? { payloadDefaults: spec.payloadDefaults } : {}),
...(payloadDefaults ? { payloadDefaults } : {}),
...(Array.isArray(spec.references) ? { references: spec.references.map((item, index) => asRecord(item, `aipodSpec.spec.references[${index}]`)) } : {}),
...(isJsonRecord(spec.metadata) ? { metadata: spec.metadata } : {}),
...(isJsonRecord(spec.dispatchDefaults) ? { dispatchDefaults: spec.dispatchDefaults } : {}),
@@ -116,11 +129,15 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput = {}): RenderedAipodQueueTask {
const spec = record.spec.spec;
rejectRenderControlOverrides(input);
const backendProfile = fixedAipodBackendProfile(spec.backendProfile, input);
const effectiveModel = resolveEffectiveAipodModel(spec.model, input);
const imageRef = imageRefSourceSummary(spec.imageRef);
const metadata = mergeRecords(spec.metadata, input.metadata, { aipod: record.name, aipodSpecHash: record.specHash, aipodImageRef: imageRef });
const payload = mergeRecords(spec.payloadDefaults, input.payload);
const inputPayload = input.payload === undefined ? undefined : validateAipodPayload(input.payload, "aipodRender.payload");
const payload = mergeRecords(spec.payloadDefaults, inputPayload);
if (typeof input.prompt === "string" && input.prompt.trim().length > 0) payload.prompt = input.prompt;
applyModelPayload(payload, spec.model);
applyEffectiveModelPayload(payload, effectiveModel);
const sessionRef = renderSessionRef(spec.sessionRef ?? null, input, record, payload);
const queueTask = validateCreateQueueTask({
tenantId: input.tenantId ?? spec.tenantId ?? "unidesk",
@@ -129,7 +146,7 @@ export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput
lane: input.lane ?? spec.lane ?? "v0.1",
title: input.title ?? stringValue(payload.title) ?? record.spec.metadata.displayName ?? record.name,
priority: input.priority ?? spec.priority ?? 50,
backendProfile: input.backendProfile ?? spec.backendProfile,
backendProfile,
providerId: input.providerId ?? spec.providerId ?? "G14",
workspaceRef: input.workspaceRef ?? spec.workspaceRef ?? { kind: "opaque", path: "." },
sessionRef,
@@ -143,6 +160,15 @@ export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput
return {
action: "aipod-spec-render",
aipod: summarizeAipodSpecRecord(record),
modelResolution: {
...effectiveModel,
modelOverridden: effectiveModel.modelSource === "render-input-override",
reasoningEffortOverridden: effectiveModel.reasoningEffortSource === "render-input-override",
backendProfile,
backendProfileSource: "aipod-spec",
providerCredential: summarizeSelectedProviderCredential(backendProfile, spec.executionPolicy),
valuesPrinted: false,
},
queueTask,
dispatchDefaults: aipodDispatchDefaults(spec.dispatchDefaults, spec.imageRef),
valuesPrinted: false,
@@ -159,6 +185,7 @@ export function summarizeAipodSpecRecord(record: AipodSpecRecord): JsonRecord {
source: record.source,
backendProfile: spec.backendProfile,
model: spec.model ?? null,
modelDefaults: summarizeAipodModelDefaults(spec.model),
imageRef: imageRefSourceSummary(spec.imageRef),
queue: spec.queue ?? "commander",
lane: spec.lane ?? "v0.1",
@@ -266,11 +293,122 @@ function aipodDispatchDefaults(base: JsonRecord | undefined, imageRef: AipodSpec
return result;
}
function applyModelPayload(payload: JsonRecord, model: JsonRecord | undefined): void {
if (!model) return;
const modelName = stringValue(model.model);
if (modelName) payload.model = modelName;
payload.modelConfig = { ...model, valuesPrinted: false };
function validateAipodModelConfig(value: unknown, label: string): AipodModelConfig {
const record = asRecord(value, label);
const unexpected = Object.keys(record).filter((key) => key !== "model" && key !== "reasoningEffort");
if (unexpected.length > 0) throw new AgentRunError("schema-invalid", `${label} contains unsupported fields: ${unexpected.join(", ")}`, { httpStatus: 400, details: { unexpected, allowed: ["model", "reasoningEffort"], valuesPrinted: false } });
const model = record.model === undefined ? undefined : validateModelId(record.model, `${label}.model`);
const reasoningEffort = record.reasoningEffort === undefined ? undefined : validateReasoningEffort(record.reasoningEffort, `${label}.reasoningEffort`);
if (!model && !reasoningEffort) throw new AgentRunError("schema-invalid", `${label} must declare model or reasoningEffort`, { httpStatus: 400 });
return { ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}) };
}
function validateAipodPayload(value: unknown, label: string): JsonRecord {
const payload = asRecord(value, label);
const reserved = payloadControlFields.filter((key) => Object.hasOwn(payload, key));
if (reserved.length > 0) {
throw new AgentRunError("schema-invalid", `${label} cannot set Aipod control fields: ${reserved.join(", ")}`, {
httpStatus: 400,
details: { reserved, useTopLevel: ["model", "reasoningEffort"], valuesPrinted: false },
});
}
return payload;
}
function rejectRenderControlOverrides(input: RenderAipodInput): void {
const rejected = renderControlOverrideFields.filter((key) => Object.hasOwn(input, key));
if (rejected.length > 0) {
throw new AgentRunError("schema-invalid", `aipod render cannot override Aipod control fields: ${rejected.join(", ")}`, {
httpStatus: 400,
details: { rejected, fixedFields: ["backendProfile", "executionPolicy", "providerCredential", "providerCredentials", "secretRef"], valuesPrinted: false },
});
}
}
function fixedAipodBackendProfile(profile: BackendProfile, input: RenderAipodInput): BackendProfile {
if (!Object.hasOwn(input, "backendProfile")) return profile;
if (typeof input.backendProfile !== "string") throw new AgentRunError("schema-invalid", "aipod render backendProfile must be a string", { httpStatus: 400 });
const requested = normalizeBackendProfile(input.backendProfile);
if (requested !== profile) {
throw new AgentRunError("schema-invalid", `aipod render cannot override backendProfile ${profile} with ${requested}`, {
httpStatus: 400,
details: { fixedBackendProfile: profile, requestedBackendProfile: requested, providerCredentialChanged: false, valuesPrinted: false },
});
}
return profile;
}
function resolveEffectiveAipodModel(defaults: AipodModelConfig | undefined, input: RenderAipodInput): EffectiveAipodModel {
const modelOverride = renderModelOverride(input, "model", validateModelId);
const reasoningOverride = renderModelOverride(input, "reasoningEffort", validateReasoningEffort);
const defaultModel = defaults?.model ?? null;
const defaultReasoningEffort = defaults?.reasoningEffort ?? null;
return {
model: modelOverride.provided ? modelOverride.value : defaultModel,
reasoningEffort: reasoningOverride.provided ? reasoningOverride.value : defaultReasoningEffort,
modelSource: modelOverride.provided ? "render-input-override" : defaultModel ? "aipod-spec-default" : null,
reasoningEffortSource: reasoningOverride.provided ? "render-input-override" : defaultReasoningEffort ? "aipod-spec-default" : null,
};
}
function renderModelOverride(input: RenderAipodInput, key: "model" | "reasoningEffort", validate: (value: unknown, label: string) => string): { provided: boolean; value: string | null } {
if (!Object.hasOwn(input, key)) return { provided: false, value: null };
return { provided: true, value: validate(input[key], `aipodRender.${key}`) };
}
function validateModelId(value: unknown, label: string): string {
if (typeof value !== "string" || !modelIdPattern.test(value.trim())) {
throw new AgentRunError("schema-invalid", `${label} must be a safe non-empty model id`, { httpStatus: 400, details: { pattern: String(modelIdPattern), valuesPrinted: false } });
}
return value.trim();
}
function validateReasoningEffort(value: unknown, label: string): string {
if (typeof value !== "string" || !reasoningEffortPattern.test(value.trim())) {
throw new AgentRunError("schema-invalid", `${label} must be a lowercase reasoning effort id`, { httpStatus: 400, details: { pattern: String(reasoningEffortPattern), valuesPrinted: false } });
}
return value.trim();
}
function applyEffectiveModelPayload(payload: JsonRecord, effective: EffectiveAipodModel): void {
delete payload.model;
delete payload.reasoningEffort;
delete payload.modelConfig;
if (effective.model) payload.model = effective.model;
if (effective.reasoningEffort) payload.reasoningEffort = effective.reasoningEffort;
if (!effective.model && !effective.reasoningEffort) return;
payload.modelConfig = {
model: effective.model,
reasoningEffort: effective.reasoningEffort,
modelSource: effective.modelSource,
reasoningEffortSource: effective.reasoningEffortSource,
valuesPrinted: false,
};
}
function summarizeAipodModelDefaults(defaults: AipodModelConfig | undefined): JsonRecord {
return {
model: defaults?.model ?? null,
reasoningEffort: defaults?.reasoningEffort ?? null,
modelSource: defaults?.model ? "aipod-spec-default" : null,
reasoningEffortSource: defaults?.reasoningEffort ? "aipod-spec-default" : null,
valuesPrinted: false,
};
}
function summarizeSelectedProviderCredential(profile: BackendProfile, policy: ExecutionPolicy): JsonRecord {
const item = (policy.secretScope.providerCredentials ?? []).find((credential) => credential.profile === profile);
if (!item) throw new AgentRunError("secret-unavailable", `aipod backendProfile ${profile} has no matching provider credential SecretRef`, { httpStatus: 400, details: { profile, valuesPrinted: false } });
return {
profile,
secretRef: {
name: item.secretRef.name,
namespace: item.secretRef.namespace ?? null,
keys: item.secretRef.keys ?? [],
valuesPrinted: false,
},
valuesPrinted: false,
};
}
function normalizeBackendProfile(value: string): BackendProfile {
+12
View File
@@ -28,6 +28,18 @@ const builtinBackendProfileSpecs: readonly BackendProfileSpec[] = [
profileIsolation: "profile-scoped-codex-home",
description: "Default Codex-compatible profile",
},
{
profile: "gpt-pika",
backendKind: "codex-app-server-stdio",
protocol: "codex-app-server-jsonrpc-stdio",
transport: "stdio",
command: "codex app-server --listen stdio://",
status: "registered",
requiredSecretKeys: ["auth.json", "config.toml"],
defaultSecretName: "agentrun-v02-provider-gpt-pika",
profileIsolation: "profile-scoped-codex-home",
description: "Pika API profile through Codex app-server stdio",
},
{
profile: "deepseek",
backendKind: "codex-app-server-stdio",
+9 -1
View File
@@ -119,7 +119,7 @@ export interface AipodSpec extends JsonRecord {
priority?: number;
providerId?: string;
backendProfile: BackendProfile;
model?: JsonRecord;
model?: AipodModelConfig;
imageRef: AipodImageRef;
workspaceRef?: WorkspaceRef;
sessionRef?: SessionRef | null;
@@ -132,6 +132,11 @@ export interface AipodSpec extends JsonRecord {
};
}
export interface AipodModelConfig extends JsonRecord {
model?: string;
reasoningEffort?: string;
}
export interface AipodSpecRecord extends JsonRecord {
name: string;
spec: AipodSpec;
@@ -144,6 +149,8 @@ export interface AipodSpecRecord extends JsonRecord {
export interface RenderAipodInput extends JsonRecord {
prompt?: string;
payload?: JsonRecord;
model?: string;
reasoningEffort?: string;
tenantId?: string;
projectId?: string;
queue?: string;
@@ -164,6 +171,7 @@ export interface RenderAipodInput extends JsonRecord {
export interface RenderedAipodQueueTask extends JsonRecord {
action: "aipod-spec-render";
aipod: JsonRecord;
modelResolution: JsonRecord;
queueTask: CreateQueueTaskInput;
dispatchDefaults: JsonRecord;
valuesPrinted: false;
+32 -1
View File
@@ -95,6 +95,37 @@ const selfTest: SelfTestCase = async (context) => {
const explicitModelResult = await runOnce({ managerUrl: server.baseUrl, runId: explicitModel.runId, commandId: explicitCommand.id, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "require-explicit-model" }, oneShot: true });
assert.equal(explicitModelResult.terminalStatus, "completed", "explicit command payload model should still be forwarded");
const explicitModelEffort = await createRunWithCommand(client, { ...context, backendProfile: "gpt-pika" }, "hello gpt-pika placeholder", "selftest-gpt-pika-placeholder", 15_000);
const explicitModelEffortCommand = await client.post(`/api/v1/runs/${explicitModelEffort.runId}/commands`, {
type: "turn",
payload: { prompt: "hello gpt-pika", model: "gpt-5.6-terra", reasoningEffort: "xhigh" },
idempotencyKey: "selftest-gpt-pika-model-effort-command",
}) as { id: string };
const explicitModelEffortResult = await runOnce({
managerUrl: server.baseUrl,
runId: explicitModelEffort.runId,
commandId: explicitModelEffortCommand.id,
backendProfile: "gpt-pika",
codexCommand: context.fakeCodexCommand,
codexArgs: context.fakeCodexArgs,
codexHome: context.codexHome,
env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "require-explicit-model-effort" },
oneShot: true,
});
assert.equal(explicitModelEffortResult.terminalStatus, "completed", "gpt-pika model and reasoning effort should reach Codex app-server");
const explicitModelEffortEvents = await client.get(`/api/v1/runs/${explicitModelEffort.runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: JsonRecord }> };
const modelStarting = explicitModelEffortEvents.items?.find((event) => event.type === "backend_status" && event.payload.phase === "codex-app-server-starting");
const modelSelection = modelStarting?.payload.modelSelection as JsonRecord | undefined;
assert.equal(modelStarting?.payload.backendProfile, "gpt-pika");
assert.equal(modelSelection?.model, "gpt-5.6-terra");
assert.equal(modelSelection?.reasoningEffort, "xhigh");
assert.equal(modelSelection?.reasoningProtocolField, "turn/start.effort");
const turnStartCompleted = explicitModelEffortEvents.items?.find((event) => event.type === "backend_status" && event.payload.phase === "turn/start:completed");
assert.equal(turnStartCompleted?.payload.model, "gpt-5.6-terra");
assert.equal(turnStartCompleted?.payload.reasoningEffort, "xhigh");
assert.equal(turnStartCompleted?.payload.reasoningProtocolField, "effort");
assertNoSecretLeak(explicitModelEffortEvents);
const finalMessage = await createRunWithCommand(client, context, "hello final message", "selftest-final-agent-message", 15_000);
const finalMessageResult = await runOnce({ managerUrl: server.baseUrl, runId: finalMessage.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "multi-agent-message-final" }, oneShot: true });
assert.equal(finalMessageResult.terminalStatus, "completed", "multi agentMessage run should complete");
@@ -271,7 +302,7 @@ const selfTest: SelfTestCase = async (context) => {
await runSessionStorageSubdirCase({ client, managerUrl: server.baseUrl, context });
await runSessionStorageNoSecretLeakCase({ client, managerUrl: server.baseUrl, context });
return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "runner-lease-conflict-recovery", "codex-stdio-fake-turn", "codex-stdio-k8s-sandbox-override", "codex-stdio-projected-writable-home", "codex-stdio-deepseek-profile-fake-turn", "codex-stdio-dsflash-go-profile-fake-turn", "codex-stdio-dsflash-go-config-metadata", "codex-stdio-minimax-m3-profile-fake-turn", "codex-stdio-deepseek-missing-secret-no-fallback", "codex-stdio-minimax-m3-missing-secret-no-fallback", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-final-agent-message-only", "codex-stdio-web-search-progress", "codex-stdio-stale-thread-resume-failed", "codex-stdio-live-tool-events", "codex-stdio-interrupt-before-turn-start-response", "codex-stdio-tool-progress-refreshes-idle-timeout", "codex-stdio-noisy-reasoning-suppression", "codex-stdio-missing-turn-result", "codex-stdio-provider-auth-failed", "codex-stdio-provider-rate-limited", "codex-stdio-provider-invalid-tool-call", "codex-stdio-provider-compact-unsupported", "codex-stdio-provider-stream-disconnected", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-unavailable", "codex-stdio-provider-503-retry-event", "codex-stdio-provider-refused-retry-recovered", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-idle-timeout-progress-refresh", "codex-stdio-command-failure-keeps-run-open", "codex-stdio-secret-unavailable", "codex-stdio-spawn-failure"] };
return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "runner-lease-conflict-recovery", "codex-stdio-fake-turn", "codex-stdio-k8s-sandbox-override", "codex-stdio-projected-writable-home", "codex-stdio-deepseek-profile-fake-turn", "codex-stdio-dsflash-go-profile-fake-turn", "codex-stdio-dsflash-go-config-metadata", "codex-stdio-minimax-m3-profile-fake-turn", "codex-stdio-deepseek-missing-secret-no-fallback", "codex-stdio-minimax-m3-missing-secret-no-fallback", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-gpt-pika-model-effort-forwarded", "codex-stdio-gpt-pika-model-visibility", "codex-stdio-final-agent-message-only", "codex-stdio-web-search-progress", "codex-stdio-stale-thread-resume-failed", "codex-stdio-live-tool-events", "codex-stdio-interrupt-before-turn-start-response", "codex-stdio-tool-progress-refreshes-idle-timeout", "codex-stdio-noisy-reasoning-suppression", "codex-stdio-missing-turn-result", "codex-stdio-provider-auth-failed", "codex-stdio-provider-rate-limited", "codex-stdio-provider-invalid-tool-call", "codex-stdio-provider-compact-unsupported", "codex-stdio-provider-stream-disconnected", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-unavailable", "codex-stdio-provider-503-retry-event", "codex-stdio-provider-refused-retry-recovered", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-idle-timeout-progress-refresh", "codex-stdio-command-failure-keeps-run-open", "codex-stdio-secret-unavailable", "codex-stdio-spawn-failure"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
+129 -10
View File
@@ -1,16 +1,38 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { ManagerClient } from "../../mgr/client.js";
import { startManagerServer } from "../../mgr/server.js";
import { MemoryAgentRunStore } from "../../mgr/store.js";
import type { JsonRecord } from "../../common/types.js";
import { validateAipodSpec } from "../../common/aipod-specs.js";
import { validatePrimaryWorkspaceContract, validateResourceBundleRef } from "../../common/validation.js";
import { classifyGitStderr, resolveGitBundleFetchSource } from "../../runner/resource-bundle.js";
import { assertNoSecretLeak, loadArtificerImageRef, type SelfTestCase } from "../harness.js";
const selfTest: SelfTestCase = async (context) => {
const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store: new MemoryAgentRunStore(), aipodSpecDir: path.join(context.root, "config", "aipods") });
const aipodSpecDir = path.join(context.tmp, "aipod-specs-76");
const artificerSpecFile = path.join(aipodSpecDir, "artificer.yaml");
await mkdir(aipodSpecDir, { recursive: true });
await copyFile(path.join(context.root, "config", "aipods", "artificer.yaml"), artificerSpecFile);
const server = await startManagerServer({
port: 0,
host: "127.0.0.1",
sourceCommit: "self-test",
store: new MemoryAgentRunStore(),
aipodSpecDir,
runnerJobDefaults: {
namespace: "agentrun-v02",
managerUrl: "http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080",
image: "agentrun-selftest:latest",
serviceAccountName: "agentrun-v02-runner",
jobNamePrefix: "agentrun-v02-runner",
runnerApiKeySecretRef: { name: "agentrun-v02-api-key", key: "HWLAB_API_KEY" },
lane: "v0.2",
},
});
try {
const client = new ManagerClient(server.baseUrl);
const artificerImageRef = await loadArtificerImageRef(context.root);
@@ -24,8 +46,18 @@ const selfTest: SelfTestCase = async (context) => {
const shown = await client.get("/api/v1/aipod-specs/Artificer") as JsonRecord;
const shownItem = shown.item as JsonRecord;
assert.equal(shownItem.backendProfile, "sub2api");
assert.equal(((shownItem.model as JsonRecord).model), "gpt-5.5");
assert.equal(shownItem.backendProfile, "gpt-pika");
assert.equal(shownItem.providerId, "NC01");
assert.equal(shownItem.lane, "v0.2");
assert.equal(((shownItem.model as JsonRecord).model), "gpt-5.6-terra");
assert.equal(((shownItem.model as JsonRecord).reasoningEffort), "xhigh");
const shownModelDefaults = shownItem.modelDefaults as JsonRecord;
assert.equal(shownModelDefaults.modelSource, "aipod-spec-default");
assert.equal(shownModelDefaults.reasoningEffortSource, "aipod-spec-default");
const shownProviderCredentials = shownItem.providerCredentials as JsonRecord;
const shownProviderCredential = (shownProviderCredentials.items as JsonRecord[]).find((item) => item.profile === "gpt-pika");
assert.equal(shownProviderCredential?.name, "agentrun-v02-provider-gpt-pika");
assert.equal(shownProviderCredential?.namespace, "agentrun-v02");
const shownImageRef = shownItem.imageRef as JsonRecord;
assert.equal(shownImageRef.kind, "env-image-dockerfile");
assert.equal(shownImageRef.repoUrl, "git@github.com:pikasTech/agentrun.git");
@@ -36,8 +68,9 @@ const selfTest: SelfTestCase = async (context) => {
const rendered = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "处理 pikasTech/unidesk#245", idempotencyKey: "selftest-aipod-artificer" }) as JsonRecord;
assert.equal(rendered.action, "aipod-spec-render");
const task = rendered.queueTask as JsonRecord;
assert.equal(task.backendProfile, "sub2api");
assert.equal(task.providerId, "G14");
assert.equal(task.backendProfile, "gpt-pika");
assert.equal(task.providerId, "NC01");
assert.equal(task.lane, "v0.2");
assert.equal(task.idempotencyKey, "selftest-aipod-artificer");
const workspaceRef = task.workspaceRef as JsonRecord;
assert.deepEqual(workspaceRef, { kind: "opaque", path: "." });
@@ -50,12 +83,31 @@ const selfTest: SelfTestCase = async (context) => {
const runnerDefaults = ((rendered.dispatchDefaults as JsonRecord).runnerJob as JsonRecord);
assert.equal(((runnerDefaults.imageRef as JsonRecord).kind), "env-image-dockerfile");
assert.equal(((runnerDefaults.imageRef as JsonRecord).dockerfilePath), "deploy/container/Containerfile");
assert.equal(((task.payload as JsonRecord).model), "gpt-5.5");
assert.equal((((task.payload as JsonRecord).modelConfig as JsonRecord).reasoningEffort), "xhigh");
assert.equal(runnerDefaults.namespace, "agentrun-v02");
const taskPayload = task.payload as JsonRecord;
assert.equal(taskPayload.model, "gpt-5.6-terra");
assert.equal(taskPayload.reasoningEffort, "xhigh");
assert.equal((taskPayload.modelConfig as JsonRecord).modelSource, "aipod-spec-default");
assert.equal((taskPayload.modelConfig as JsonRecord).reasoningEffortSource, "aipod-spec-default");
const modelResolution = rendered.modelResolution as JsonRecord;
assert.equal(modelResolution.model, "gpt-5.6-terra");
assert.equal(modelResolution.reasoningEffort, "xhigh");
assert.equal(modelResolution.modelSource, "aipod-spec-default");
assert.equal(modelResolution.reasoningEffortSource, "aipod-spec-default");
assert.equal(modelResolution.backendProfile, "gpt-pika");
assert.equal(modelResolution.backendProfileSource, "aipod-spec");
assert.equal(modelResolution.valuesPrinted, false);
const policy = task.executionPolicy as JsonRecord;
const secretScope = policy.secretScope as JsonRecord;
const providerCredentials = secretScope.providerCredentials as JsonRecord[];
assert.equal(providerCredentials.some((item) => item.profile === "sub2api" && ((item.secretRef as JsonRecord).name) === "agentrun-v01-provider-sub2api"), true);
const providerCredential = providerCredentials.find((item) => item.profile === "gpt-pika") as JsonRecord | undefined;
assert.equal(((providerCredential?.secretRef as JsonRecord).name), "agentrun-v02-provider-gpt-pika");
assert.equal(((providerCredential?.secretRef as JsonRecord).namespace), "agentrun-v02");
assert.deepEqual(((providerCredential?.secretRef as JsonRecord).keys), ["auth.json", "config.toml"]);
assert.equal(runnerDefaults.namespace, (providerCredential?.secretRef as JsonRecord).namespace, "runner Pod and gpt-pika Secret must share a namespace");
const disclosedProviderCredential = modelResolution.providerCredential as JsonRecord;
assert.equal(disclosedProviderCredential.profile, "gpt-pika");
assert.deepEqual(disclosedProviderCredential.secretRef, { name: "agentrun-v02-provider-gpt-pika", namespace: "agentrun-v02", keys: ["auth.json", "config.toml"], valuesPrinted: false });
const toolCredentials = secretScope.toolCredentials as JsonRecord[];
assert.equal(toolCredentials.some((item) => item.tool === "github" && ((item.projection as JsonRecord).kind) === "env" && ((item.projection as JsonRecord).envName) === "GH_TOKEN" && ((item.secretRef as JsonRecord).name) === "agentrun-v01-tool-github-pr"), true);
assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && ((item.projection as JsonRecord).envName) === "UNIDESK_SSH_CLIENT_TOKEN"), true);
@@ -75,6 +127,46 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal((bundle.requiredSkills as JsonRecord[]).some((item) => item.name === "unidesk-gh"), true);
assertNoSecretLeak(rendered);
const modelOverride = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "override model", model: "gpt-5.4", reasoningEffort: "high" }) as JsonRecord;
const modelOverrideTask = modelOverride.queueTask as JsonRecord;
const modelOverridePayload = modelOverrideTask.payload as JsonRecord;
const modelOverrideResolution = modelOverride.modelResolution as JsonRecord;
assert.equal(modelOverrideTask.backendProfile, "gpt-pika");
assert.equal(modelOverridePayload.model, "gpt-5.4");
assert.equal(modelOverridePayload.reasoningEffort, "high");
assert.equal(modelOverrideResolution.modelSource, "render-input-override");
assert.equal(modelOverrideResolution.reasoningEffortSource, "render-input-override");
assert.deepEqual((modelOverrideResolution.providerCredential as JsonRecord).secretRef, disclosedProviderCredential.secretRef);
assert.deepEqual(((modelOverrideTask.executionPolicy as JsonRecord).secretScope as JsonRecord).providerCredentials, providerCredentials);
const reasoningOverride = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "override reasoning", reasoningEffort: "medium" }) as JsonRecord;
const reasoningOverridePayload = (reasoningOverride.queueTask as JsonRecord).payload as JsonRecord;
const reasoningOverrideResolution = reasoningOverride.modelResolution as JsonRecord;
assert.equal(reasoningOverridePayload.model, "gpt-5.6-terra");
assert.equal(reasoningOverridePayload.reasoningEffort, "medium");
assert.equal(reasoningOverrideResolution.modelSource, "aipod-spec-default");
assert.equal(reasoningOverrideResolution.reasoningEffortSource, "render-input-override");
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { model: "" }), /safe non-empty model id/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { reasoningEffort: "XHIGH" }), /lowercase reasoning effort id/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { backendProfile: "codex" }), /cannot override backendProfile gpt-pika with codex/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { backendProfile: "gpt.pika" }), /must be a lowercase slug/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { executionPolicy: {} }), /cannot override Aipod control fields: executionPolicy/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { modelConfig: { model: "other" } }), /cannot override Aipod control fields: modelConfig/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { secretRef: { name: "other" } }), /cannot override Aipod control fields: secretRef/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { payload: { backendProfile: "codex" } }), /cannot set Aipod control fields: backendProfile/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { payload: { modelConfig: { model: "other" } } }), /cannot set Aipod control fields: modelConfig/u);
await assert.rejects(() => client.post("/api/v1/aipod-specs/Artificer/render", { payload: { providerCredentials: [{ profile: "codex" }] } }), /cannot set Aipod control fields: providerCredentials/u);
const originalSpecDocument = parseYaml(await readFile(artificerSpecFile, "utf8")) as JsonRecord;
const missingCredentialDocument = structuredClone(originalSpecDocument);
const missingCredentialPolicy = ((missingCredentialDocument.spec as JsonRecord).executionPolicy as JsonRecord);
((missingCredentialPolicy.secretScope as JsonRecord).providerCredentials) = [];
assert.throws(() => validateAipodSpec(missingCredentialDocument, "selftest-missing-gpt-pika-credential"), /requires exactly one matching provider credential/u);
const payloadDefaultsBypassDocument = structuredClone(originalSpecDocument);
((payloadDefaultsBypassDocument.spec as JsonRecord).payloadDefaults as JsonRecord).modelConfig = { model: "bypass" };
assert.throws(() => validateAipodSpec(payloadDefaultsBypassDocument, "selftest-payload-defaults-bypass"), /cannot set Aipod control fields: modelConfig/u);
const mirrored = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { baseUrl: "http://mirror.example.test/root/" }, {});
assert.equal(mirrored.fetchRepoUrl, "http://mirror.example.test/root/pikasTech/unidesk.git");
assert.equal(mirrored.mirrorUsed, true);
@@ -98,19 +190,46 @@ const selfTest: SelfTestCase = async (context) => {
assert.throws(() => validatePrimaryWorkspaceContract({ kind: "opaque", path: ".", repo: "pikasTech/unidesk" }, primaryContractBundle), /only supports kind and path/u);
assert.throws(() => validatePrimaryWorkspaceContract({ kind: "opaque", path: ".", branch: "master" }, primaryContractBundle), /only supports kind and path/u);
const submitPlan = await runCliJson(context, server.baseUrl, ["queue", "submit", "--aipod", "Artificer", "--prompt", "处理 pikasTech/unidesk#245", "--idempotency-key", "selftest-aipod-cli", "--dry-run"]);
const submitPlan = await runCliJson(context, server.baseUrl, ["queue", "submit", "--aipod", "Artificer", "--prompt", "处理 pikasTech/unidesk#245", "--model", "gpt-5.4", "--reasoning-effort", "high", "--idempotency-key", "selftest-aipod-cli", "--dry-run"]);
assert.equal(submitPlan.ok, true);
assert.equal(((submitPlan.data as JsonRecord).action), "queue-submit-plan");
assert.equal((((submitPlan.data as JsonRecord).jsonInput as JsonRecord).source), "aipod-spec");
const request = ((submitPlan.data as JsonRecord).request as JsonRecord);
assert.equal(((request.body as JsonRecord).idempotencyKey), "selftest-aipod-cli");
const submitModelResolution = (submitPlan.data as JsonRecord).modelResolution as JsonRecord;
assert.equal(submitModelResolution.model, "gpt-5.4");
assert.equal(submitModelResolution.reasoningEffort, "high");
assert.match(String(((submitPlan.data as JsonRecord).next as JsonRecord).confirm), /--model gpt-5\.4 --reasoning-effort high/u);
const sendPlan = await runCliJson(context, server.baseUrl, ["sessions", "send", "--aipod", "Artificer", "--prompt", "dry-run session", "--model", "gpt-5.4", "--reasoning-effort", "medium", "--no-runner-job", "--dry-run"]);
assert.equal(sendPlan.ok, true);
const sendPlanData = sendPlan.data as JsonRecord;
assert.equal(sendPlanData.action, "session-send-plan");
assert.equal(sendPlanData.profile, "gpt-pika");
assert.equal((sendPlanData.modelResolution as JsonRecord).model, "gpt-5.4");
assert.equal((sendPlanData.modelResolution as JsonRecord).reasoningEffort, "medium");
assert.equal((((sendPlanData.modelResolution as JsonRecord).providerCredential as JsonRecord).secretRef as JsonRecord).name, "agentrun-v02-provider-gpt-pika");
const help = await runCliJson(context, server.baseUrl, ["help"]);
const commands = ((help.data as JsonRecord).commands as string[]) ?? [];
assert.equal(commands.some((item) => item.includes("aipod-specs render <name>")), true);
assert.equal(commands.some((item) => item.includes("queue submit --aipod <name>")), true);
assert.equal(commands.some((item) => item.includes("queue submit --aipod <name>") && item.includes("--model <model>") && item.includes("--reasoning-effort <effort>")), true);
assert.equal(commands.some((item) => item.includes("sessions send") && item.includes("--model <model>") && item.includes("--reasoning-effort <effort>")), true);
assertNoSecretLeak(submitPlan);
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-artificer-github-ssh-required-keys", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "aipod-cli-help"] };
const durableTask = await client.post("/api/v1/queue/tasks", rendered.queueTask) as JsonRecord;
const changedSpecDocument = structuredClone(originalSpecDocument);
((changedSpecDocument.spec as JsonRecord).model as JsonRecord).model = "gpt-5.7-terra";
((changedSpecDocument.spec as JsonRecord).model as JsonRecord).reasoningEffort = "low";
await writeFile(artificerSpecFile, stringifyYaml(changedSpecDocument), "utf8");
const changedRender = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "new yaml default" }) as JsonRecord;
assert.equal(((changedRender.queueTask as JsonRecord).payload as JsonRecord).model, "gpt-5.7-terra");
const storedTask = await client.get(`/api/v1/queue/tasks/${String(durableTask.id)}`) as JsonRecord;
assert.equal((storedTask.payload as JsonRecord).model, "gpt-5.6-terra");
assert.equal((storedTask.payload as JsonRecord).reasoningEffort, "xhigh");
assert.deepEqual((storedTask.payload as JsonRecord).modelConfig, taskPayload.modelConfig);
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-v02-runtime-authority", "aipod-spec-gpt-pika-secret-namespace", "aipod-spec-model-defaults", "aipod-spec-model-reasoning-overrides", "aipod-spec-model-override-keeps-provider", "aipod-spec-control-override-fail-closed", "aipod-spec-payload-control-bypass-fail-closed", "aipod-spec-missing-gpt-pika-credential", "aipod-spec-durable-task-model-snapshot", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-artificer-github-ssh-required-keys", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "session-send-aipod-model-dry-run", "aipod-cli-help"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
+20 -1
View File
@@ -141,6 +141,13 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.equal([first.mutation, duplicate.mutation].filter(Boolean).length, 1);
const retryRunId = String(first.attempt?.runId);
const retryCommandId = String(first.attempt?.commandId);
const retryRun = await client.get(`/api/v1/runs/${retryRunId}`) as JsonRecord;
const retryCommand = await client.get(`/api/v1/runs/${retryRunId}/commands/${retryCommandId}`) as JsonRecord;
assert.equal(retryRun.backendProfile, failed.task.backendProfile);
assert.deepEqual(retryRun.executionPolicy, failed.task.executionPolicy);
assert.deepEqual(retryCommand.payload, failed.task.payload, "retry must reuse the durable effective model payload without consulting current AipodSpec YAML");
assert.equal((retryCommand.payload as JsonRecord).model, "gpt-5.6-terra");
assert.equal((retryCommand.payload as JsonRecord).reasoningEffort, "xhigh");
const jobs = await client.get(`/api/v1/runs/${retryRunId}/runner-jobs?commandId=${retryCommandId}`) as { count?: number; items?: JsonRecord[] };
assert.equal(jobs.count, 1);
assert.equal(jobs.items?.[0]?.id, first.attempt?.runnerJobId);
@@ -231,6 +238,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
"retry-reserved-attempt-same-key-resume",
"retry-stable-run-command-job-identity",
"retry-old-attempt-immutable",
"retry-effective-model-payload-immutable",
"retry-same-key-before-state",
"retry-different-key-running-rejected",
"retry-completed-cancelled-rejected",
@@ -285,7 +293,18 @@ function taskInput(workspace: string, key: string): JsonRecord {
secretScope: { allowCredentialEcho: false, providerCredentials: [{ profile: "codex", secretRef: { name: "agentrun-v01-provider-codex", keys: ["auth.json", "config.toml"] } }] },
},
resourceBundleRef: null,
payload: { prompt: `execute ${key}` },
payload: {
prompt: `execute ${key}`,
model: "gpt-5.6-terra",
reasoningEffort: "xhigh",
modelConfig: {
model: "gpt-5.6-terra",
reasoningEffort: "xhigh",
modelSource: "aipod-spec-default",
reasoningEffortSource: "aipod-spec-default",
valuesPrinted: false,
},
},
references: [{ kind: "issue", url: "https://github.com/pikasTech/agentrun/issues/307" }],
metadata: { source: "queue-q5-self-test" },
idempotencyKey: key,
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { chmod, writeFile } from "node:fs/promises";
import path from "node:path";
import { ManagerClient } from "../../mgr/client.js";
import { retryQueueTask } from "../../mgr/queue-retry.js";
import { startManagerServer } from "../../mgr/server.js";
import { MemoryAgentRunStore } from "../../mgr/store.js";
import { runOnce } from "../../runner/run-once.js";
import type { CreateQueueTaskInput, JsonRecord } from "../../common/types.js";
import { assertNoSecretLeak, type SelfTestCase } from "../harness.js";
const selfTest: SelfTestCase = async (context) => {
const server = await startManagerServer({
port: 0,
host: "127.0.0.1",
sourceCommit: "self-test",
store: new MemoryAgentRunStore(),
aipodSpecDir: path.join(context.root, "config", "aipods"),
runnerJobDefaults: {
namespace: "agentrun-v02",
managerUrl: "http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080",
image: "agentrun-selftest:latest",
serviceAccountName: "agentrun-v02-runner",
jobNamePrefix: "agentrun-v02-runner",
runnerApiKeySecretRef: { name: "agentrun-v02-api-key", key: "HWLAB_API_KEY" },
lane: "v0.2",
},
});
try {
const client = new ManagerClient(server.baseUrl);
const rendered = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "verify gpt-pika model selection" }) as JsonRecord;
const task = rendered.queueTask as JsonRecord;
const modelResolution = rendered.modelResolution as JsonRecord;
assert.equal(task.backendProfile, "gpt-pika");
assert.equal(task.providerId, "NC01");
assert.equal(modelResolution.model, "gpt-5.6-terra");
assert.equal(modelResolution.reasoningEffort, "xhigh");
await assertDurableRetryModel(context.tmp, task as unknown as CreateQueueTaskInput);
const runtimeExecutionPolicy = structuredClone(task.executionPolicy as JsonRecord);
((runtimeExecutionPolicy.secretScope as JsonRecord).toolCredentials) = [];
const run = await client.post("/api/v1/runs", {
tenantId: task.tenantId,
projectId: task.projectId,
providerId: task.providerId,
backendProfile: task.backendProfile,
workspaceRef: { kind: "host-path", path: context.workspace },
sessionRef: null,
executionPolicy: runtimeExecutionPolicy,
resourceBundleRef: null,
traceSink: { kind: "self-test", source: "artificer-model-selection" },
}) as JsonRecord;
const command = await client.post(`/api/v1/runs/${String(run.id)}/commands`, {
type: "turn",
payload: task.payload,
idempotencyKey: "selftest-artificer-model-selection",
}) as JsonRecord;
const result = await runOnce({
managerUrl: server.baseUrl,
runId: String(run.id),
commandId: String(command.id),
backendProfile: "gpt-pika",
codexCommand: context.fakeCodexCommand,
codexArgs: context.fakeCodexArgs,
codexHome: context.codexHome,
env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "require-explicit-model-effort" },
oneShot: true,
});
assert.equal(result.terminalStatus, "completed");
const events = await client.get(`/api/v1/runs/${String(run.id)}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: JsonRecord }> };
const starting = events.items?.find((event) => event.type === "backend_status" && event.payload.phase === "codex-app-server-starting");
const selection = starting?.payload.modelSelection as JsonRecord | undefined;
assert.equal(starting?.payload.backendProfile, "gpt-pika");
assert.equal(selection?.model, "gpt-5.6-terra");
assert.equal(selection?.reasoningEffort, "xhigh");
assert.equal(selection?.reasoningProtocolField, "turn/start.effort");
const turnStart = events.items?.find((event) => event.type === "backend_status" && event.payload.phase === "turn/start:completed");
assert.equal(turnStart?.payload.model, "gpt-5.6-terra");
assert.equal(turnStart?.payload.reasoningEffort, "xhigh");
assert.equal(turnStart?.payload.reasoningProtocolField, "effort");
assertNoSecretLeak({ rendered, result, events });
return { name: "artificer-model-selection", tests: ["artificer-gpt-pika-default-render", "artificer-durable-retry-model", "artificer-gpt-pika-model-runtime", "artificer-reasoning-effort-turn-start", "artificer-model-selection-visible"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
};
async function assertDurableRetryModel(tmp: string, input: CreateQueueTaskInput): Promise<void> {
const store = new MemoryAgentRunStore();
const retryInput = { ...structuredClone(input), sessionRef: null };
assert.ok(retryInput.executionPolicy);
retryInput.executionPolicy.secretScope.toolCredentials = [];
const task = store.createQueueTask(retryInput);
store.updateQueueTaskAttempt(task.id, {
state: "failed",
latestAttempt: {
attemptId: "attempt_artificer_initial",
state: "failed",
runId: null,
commandId: null,
runnerJobId: null,
sessionId: null,
sessionPath: null,
failureKind: "infra-failed",
failureMessage: "initial attempt failed",
},
sessionPath: null,
});
const fakeKubectl = path.join(tmp, "fake-kubectl-artificer-retry.js");
await writeFile(fakeKubectl, `#!/usr/bin/env bun
const chunks = [];
for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk));
const manifest = JSON.parse(Buffer.concat(chunks).toString("utf8"));
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { ...manifest.metadata, uid: "artificer-retry", resourceVersion: "1" } }));
`, "utf8");
await chmod(fakeKubectl, 0o755);
const retried = await retryQueueTask({
store,
taskId: task.id,
input: { idempotencyKey: "artificer-model-retry", reason: "verify durable model payload" },
defaults: {
namespace: "agentrun-v02",
managerUrl: "http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080",
runnerApiKeySecretRef: { name: "agentrun-v02-api-key", key: "HWLAB_API_KEY" },
image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111",
sourceCommit: "self-test",
serviceAccountName: "agentrun-v02-runner",
jobNamePrefix: "agentrun-v02-runner",
lane: "v0.2",
kubectlCommand: fakeKubectl,
},
});
const command = retried.command as JsonRecord;
assert.deepEqual(command.payload, task.payload);
assert.equal((command.payload as JsonRecord).model, "gpt-5.6-terra");
assert.equal((command.payload as JsonRecord).reasoningEffort, "xhigh");
assert.equal(((command.payload as JsonRecord).modelConfig as JsonRecord).modelSource, "aipod-spec-default");
}
export default selfTest;
+22 -1
View File
@@ -36,6 +36,10 @@ for await (const line of rl) {
respond(message.id, null, { code: -32000, message: "thread/start did not include expected model" });
continue;
}
if (mode === "require-explicit-model-effort" && (message.params?.model !== "gpt-5.6-terra" || Object.hasOwn(message.params ?? {}, "effort"))) {
respond(message.id, null, { code: -32000, message: "thread/start expected model without turn effort" });
continue;
}
threadCounter += 1;
const thread = { id: `thread_selftest_${threadCounter}` };
notify("thread/started", { thread });
@@ -60,13 +64,17 @@ for await (const line of rl) {
respond(message.id, null, { code: -32000, message: "thread/resume did not include expected model" });
continue;
}
if (mode === "require-explicit-model-effort" && (message.params?.model !== "gpt-5.6-terra" || Object.hasOwn(message.params ?? {}, "effort"))) {
respond(message.id, null, { code: -32000, message: "thread/resume expected model without turn effort" });
continue;
}
const thread = { id: String(message.params?.threadId ?? "thread_selftest_resumed") };
notify("thread/started", { thread });
respond(message.id, { thread });
continue;
}
if (message.method === "turn/start") {
if (process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE) appendFileSync(process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE, `${JSON.stringify({ threadId: message.params?.threadId ?? null, input: message.params?.input ?? null })}\n`);
if (process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE) appendFileSync(process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE, `${JSON.stringify({ threadId: message.params?.threadId ?? null, input: message.params?.input ?? null, model: message.params?.model ?? null, effort: message.params?.effort ?? null })}\n`);
if (mode === "reject-unexpected-model" && (observedThreadModel || Object.hasOwn(message.params ?? {}, "model"))) {
respond(message.id, null, { code: -32000, message: "turn/start unexpectedly included model" });
continue;
@@ -75,6 +83,19 @@ for await (const line of rl) {
respond(message.id, null, { code: -32000, message: "turn/start did not include expected model" });
continue;
}
if (mode === "require-explicit-model-effort" && (message.params?.model !== "gpt-5.6-terra" || message.params?.effort !== "xhigh")) {
respond(message.id, null, { code: -32000, message: "turn/start did not include expected model and effort" });
continue;
}
if (mode === "require-explicit-model-effort") {
turnCounter += 1;
const turn = { id: `turn_selftest_${turnCounter}`, status: "completed" };
respond(message.id, { turn: { id: turn.id, status: "running" } });
notify("turn/started", { turn: { id: turn.id, status: "running" } });
notify("item/completed", { item: { id: "msg_model_effort", type: "agentMessage", text: "model effort verified" } });
notify("turn/completed", { turn });
continue;
}
if (mode === "missing-turn-result") {
respond(message.id, {});
continue;
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { Pool } from "pg";
import { AgentRunError } from "../../common/errors.js";
import type { CreateQueueTaskInput, QueueAttemptRef } from "../../common/types.js";
import type { CreateQueueTaskInput, JsonRecord, QueueAttemptRef } from "../../common/types.js";
import { createPostgresAgentRunStore } from "../../mgr/postgres-store.js";
const connectionString = process.env.AGENTRUN_SELFTEST_DATABASE_URL?.trim();
@@ -16,6 +16,8 @@ let taskId: string | null = null;
try {
const task = await primary.createQueueTask(queueTaskInput(suffix));
taskId = task.id;
assert.equal(task.payload.model, "gpt-5.6-terra");
assert.equal(task.payload.reasoningEffort, "xhigh");
const initialAttempt: QueueAttemptRef = {
attemptId: `attempt_initial_${suffix}`,
state: "failed",
@@ -53,6 +55,10 @@ try {
assert.equal((await primary.listQueueAttempts(task.id, { limit: 100 })).count, 2);
assert.equal((await primary.getQueueTask(task.id)).state, "failed");
assert.equal((await primary.getQueueTask(task.id)).latestAttempt?.attemptId, initialAttempt.attemptId);
const persistedTask = await primary.getQueueTask(task.id);
assert.equal(persistedTask.payload.model, "gpt-5.6-terra");
assert.equal(persistedTask.payload.reasoningEffort, "xhigh");
assert.equal((persistedTask.payload.modelConfig as JsonRecord).modelSource, "aipod-spec-default");
await assert.rejects(
() => concurrent.reserveQueueRetryAttempt(task.id, { idempotencyKey: `retry-different-${suffix}`, reason: "must hit active attempt fence", dryRun: false }),
@@ -66,6 +72,7 @@ try {
"postgres-queue-retry-same-key-concurrent-single-reservation",
"postgres-queue-retry-stable-identities",
"postgres-queue-retry-different-key-active-fence",
"postgres-queue-retry-effective-model-payload-round-trip",
],
taskId: task.id,
attemptId: first.attempt?.attemptId ?? null,
@@ -97,7 +104,18 @@ function queueTaskInput(key: string): CreateQueueTaskInput {
secretScope: { allowCredentialEcho: false, providerCredentials: [] },
},
resourceBundleRef: null,
payload: { prompt: "postgres queue retry concurrency self-test" },
payload: {
prompt: "postgres queue retry concurrency self-test",
model: "gpt-5.6-terra",
reasoningEffort: "xhigh",
modelConfig: {
model: "gpt-5.6-terra",
reasoningEffort: "xhigh",
modelSource: "aipod-spec-default",
reasoningEffortSource: "aipod-spec-default",
valuesPrinted: false,
},
},
references: [{ kind: "issue", url: "https://github.com/pikasTech/agentrun/issues/307" }],
metadata: { source: "postgres-queue-retry-self-test" },
idempotencyKey: `postgres-queue-retry-${key}`,