diff --git a/config/aipods/artificer.yaml b/config/aipods/artificer.yaml index f827f85..43fe5be 100644 --- a/config/aipods/artificer.yaml +++ b/config/aipods/artificer.yaml @@ -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 diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index 67fef58..6c96b34 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -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 "); 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 { @@ -2302,7 +2311,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "sessions storage ", "sessions storage --delete", "sessions show [--reader-id ]", - "sessions send [sessionId] [--aipod |--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ] [--profile codex|deepseek|minimax-m3|dsflash-go||M3] [--runner-json-stdin|--runner-json-file ] [--no-runner-job] [--dry-run]", + "sessions send [sessionId] [--aipod |--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ] [--model ] [--reasoning-effort ] [--profile codex|deepseek|minimax-m3|dsflash-go||M3] [--runner-json-stdin|--runner-json-file ] [--no-runner-job] [--dry-run]", "sessions cancel [--reason ] [--full|--raw]", "sessions trace [--after-seq ] [--limit ] [--run-id ] [--summary-chars ] [--include-output] [--seq |--event-id |--item-id ] [--detail-scan-pages ] [--full|--raw]", "sessions output [--after-seq ] [--limit ] [--run-id ] [--summary-chars ] [--include-output] [--seq |--event-id |--item-id ] [--detail-scan-pages ] [--full|--raw]", @@ -2318,7 +2327,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "runner job-status [runnerJobId] --run-id ", "runner reconcile [--limit ] [--namespace ] [--dry-run]", "queue submit --json-stdin|--json-file [--idempotency-key ] [--dry-run]", - "queue submit --aipod [--prompt-stdin|--prompt-file |--prompt ] [--idempotency-key ] [--dry-run]", + "queue submit --aipod [--prompt-stdin|--prompt-file |--prompt ] [--model ] [--reasoning-effort ] [--idempotency-key ] [--dry-run]", "queue list [--queue ] [--state ] [--cursor ] [--limit ] [--updated-after ] [--full|--raw]", "queue show [--full|--raw]", "queue stats [--queue ]", @@ -2331,7 +2340,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "queue refresh [--dry-run] [--full|--raw]", "aipod-specs list", "aipod-specs show ", - "aipod-specs render [--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ]", + "aipod-specs render [--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ] [--model ] [--reasoning-effort ]", "aipod-specs apply [name] --yaml-stdin|--yaml-file [--dry-run]", "aipod-specs delete ", "secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|] [--codex-home ] [--model-catalog-file ] [--namespace agentrun-v01] [--secret-name ]", diff --git a/src/backend/adapter.ts b/src/backend/adapter.ts index a5d93f7..70bb0b5 100644 --- a/src/backend/adapter.ts +++ b/src/backend/adapter.ts @@ -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; diff --git a/src/backend/codex-stdio.ts b/src/backend/codex-stdio.ts index d364d1d..7c81a9b 100644 --- a/src/backend/codex-stdio.ts +++ b/src/backend/codex-stdio.ts @@ -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, diff --git a/src/common/aipod-specs.ts b/src/common/aipod-specs.ts index f676d44..0db02fd 100644 --- a/src/common/aipod-specs.ts +++ b/src/common/aipod-specs.ts @@ -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 { diff --git a/src/common/backend-profiles.ts b/src/common/backend-profiles.ts index 3614348..e9fffe6 100644 --- a/src/common/backend-profiles.ts +++ b/src/common/backend-profiles.ts @@ -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", diff --git a/src/common/types.ts b/src/common/types.ts index 2425b9b..e1bdf22 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -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; diff --git a/src/selftest/cases/30-codex-stdio.ts b/src/selftest/cases/30-codex-stdio.ts index 6cbcae5..b5a1477 100644 --- a/src/selftest/cases/30-codex-stdio.ts +++ b/src/selftest/cases/30-codex-stdio.ts @@ -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((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/cases/76-aipod-spec.ts b/src/selftest/cases/76-aipod-spec.ts index 8285cd7..48fe86a 100644 --- a/src/selftest/cases/76-aipod-spec.ts +++ b/src/selftest/cases/76-aipod-spec.ts @@ -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 ")), true); assert.equal(commands.some((item) => item.includes("queue submit --aipod ")), true); + assert.equal(commands.some((item) => item.includes("queue submit --aipod ") && item.includes("--model ") && item.includes("--reasoning-effort ")), true); + assert.equal(commands.some((item) => item.includes("sessions send") && item.includes("--model ") && item.includes("--reasoning-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((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/cases/78-queue-q5-retry.ts b/src/selftest/cases/78-queue-q5-retry.ts index ada1e03..0faa3f4 100644 --- a/src/selftest/cases/78-queue-q5-retry.ts +++ b/src/selftest/cases/78-queue-q5-retry.ts @@ -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, diff --git a/src/selftest/cases/80-artificer-model-selection.ts b/src/selftest/cases/80-artificer-model-selection.ts new file mode 100644 index 0000000..85b9946 --- /dev/null +++ b/src/selftest/cases/80-artificer-model-selection.ts @@ -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((resolve) => server.server.close(() => resolve())); + } +}; + +async function assertDurableRetryModel(tmp: string, input: CreateQueueTaskInput): Promise { + 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; diff --git a/src/selftest/fake-codex-app-server.ts b/src/selftest/fake-codex-app-server.ts index 20d295e..1b47963 100644 --- a/src/selftest/fake-codex-app-server.ts +++ b/src/selftest/fake-codex-app-server.ts @@ -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; diff --git a/src/selftest/integration/postgres-queue-retry.ts b/src/selftest/integration/postgres-queue-retry.ts index 65ab157..51a51cd 100644 --- a/src/selftest/integration/postgres-queue-retry.ts +++ b/src/selftest/integration/postgres-queue-retry.ts @@ -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}`,