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:
@@ -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
@@ -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>]",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user