| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { join } from "node:path"; |
| import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
|
|
| import { buildTaskPackage } from "@task-optimizer/core/rl-env"; |
| import { parse as parseApiZip, buildEvidence as buildApiEvidence } from "@task-optimizer/core/importers/api"; |
| import { parse as parseMcpZip, buildEvidence as buildMcpEvidence } from "@task-optimizer/core/importers/mcp"; |
| import { |
| collectMcpConfigUrls, |
| collectMcpToolsFromTask, |
| type PluginMcpTool, |
| } from "@task-optimizer/core/mcp-plugin-match"; |
| import { runLocalRules } from "@task-optimizer/core/rules/mcp"; |
| import { buildAnalysisZipBuffer } from "@task-optimizer/core/zip-export"; |
| import * as apiPrompt from "@task-optimizer/core/prompts/api"; |
| import * as mcpPrompt from "@task-optimizer/core/prompts/mcp"; |
|
|
| import type { Config } from "../config.js"; |
| import type { Logger } from "../log.js"; |
| import { AiClient, safePreview } from "../ai/client.js"; |
| import { runJudge, buildGoldenTrajectory } from "../judge/runner.js"; |
| import { evaluateJudgePolicy } from "../judge/policy.js"; |
| import { |
| buildIterationMemoryPrompt, |
| createIterationMemory, |
| optimizeIterationMemory, |
| type IterationMemory, |
| } from "./iteration-memory.js"; |
| import type { |
| DetectedMode, |
| ItemRow, |
| ItemStatus, |
| Store, |
| } from "./store.js"; |
|
|
| export interface QueueRunnerOptions { |
| store: Store; |
| config: Config; |
| logger: Logger; |
| } |
|
|
| export interface OptionsSnapshot { |
| |
| requestedMode?: DetectedMode; |
| model?: string; |
| baseURL?: string; |
| |
| |
| |
| |
| |
| apiKey?: string; |
| apiMode?: "chat" | "responses"; |
| |
| concurrency?: number; |
| temperature?: number; |
| topP?: number; |
| maxTokens?: number; |
| streaming?: boolean; |
| enableThinking?: boolean; |
| clearThinking?: boolean; |
| ruleProfile?: string; |
| optimizeTask?: boolean; |
| feedback?: string; |
| |
| strictFullMarks?: boolean; |
| |
| ignoreTaskComplexityForFullMarks?: boolean; |
| judge?: { |
| model?: string; |
| baseURL?: string; |
| apiMode?: "chat" | "responses"; |
| promptTemplate?: string; |
| }; |
| rubricGeneration?: { |
| model?: string; |
| baseURL?: string; |
| apiMode?: "chat" | "responses"; |
| }; |
| |
| |
| } |
|
|
| interface InFlightJob { |
| jobId: string; |
| cancelRequested: boolean; |
| } |
|
|
| type AnalyzerResult = { |
| mode: DetectedMode; |
| parsed: unknown; |
| evidence: Record<string, unknown>; |
| flags: Array<{ code: string; note: string; evidence: unknown }>; |
| result: unknown; |
| }; |
|
|
| type TaskPackageResult = ReturnType<typeof buildTaskPackage>; |
| type JudgeRunResult = Awaited<ReturnType<typeof runJudge>>; |
|
|
| |
| |
| |
| |
| const MAX_JUDGE_ITERATIONS = 5; |
|
|
| |
| |
| |
| |
| |
| const MAX_CONCURRENCY = 8; |
|
|
| class Semaphore { |
| private active = 0; |
| private readonly queue: Array<() => void> = []; |
|
|
| constructor(private readonly limit: number) {} |
|
|
| private async acquire(): Promise<void> { |
| if (this.limit <= 0) return; |
| if (this.active < this.limit) { |
| this.active += 1; |
| return; |
| } |
| await new Promise<void>((resolve) => this.queue.push(resolve)); |
| } |
|
|
| private release(): void { |
| if (this.limit <= 0) return; |
| const next = this.queue.shift(); |
| if (next) { |
| next(); |
| return; |
| } |
| this.active -= 1; |
| } |
|
|
| async run<T>(fn: () => Promise<T>): Promise<T> { |
| if (this.limit <= 0) return fn(); |
| await this.acquire(); |
| try { |
| return await fn(); |
| } finally { |
| this.release(); |
| } |
| } |
| } |
|
|
| |
| function buildIterationFeedback( |
| judgeResult: { |
| total_score?: number; |
| max_score?: number; |
| has_zeros?: boolean; |
| verdict?: string; |
| rationale?: string; |
| dimensions?: Record<string, { score?: number; explanation?: string }>; |
| }, |
| iter: number |
| ): string { |
| const score = Number(judgeResult.total_score || 0); |
| const maxScore = Number(judgeResult.max_score) || 12; |
| const dims = judgeResult.dimensions || {}; |
| const weakDims = Object.entries(dims) |
| .filter(([, v]) => Number(v?.score ?? 99) <= 1) |
| .map(([key, v]) => { |
| const reason = String(v?.explanation || "").slice(0, 320); |
| return `- ${key} (score=${v?.score ?? "?"}/2): ${reason}`; |
| }); |
| const rationale = String(judgeResult.rationale || "").slice(0, 480); |
| return [ |
| `=== JUDGE FEEDBACK (iteration ${iter}, score ${score}/${maxScore}, has_zeros=${judgeResult.has_zeros ? "true" : "false"}) ===`, |
| "", |
| "TOP PRIORITY — EVIDENCE-FLOOR RULE:", |
| "Every action verb in recommended_instruction MUST map to a real call in EVIDENCE_SUMMARY.", |
| "If the judge cited an unsupported action or missing rubric, DELETE that verb from recommended_instruction", |
| "and shrink the instruction so it describes only what the trajectory actually does.", |
| "", |
| "MANDATORY NEXT ITERATION BEHAVIOR:", |
| "- Explicitly fix every issue listed below before changing unrelated fields.", |
| "- Do NOT repeat any rubric name, checker_key, or task wording the judge criticized.", |
| "- Prefer the smallest targeted edit that removes the cited judge complaint.", |
| "- Task Complexity is allowed to remain below 2; do not invent extra work to inflate it.", |
| "", |
| rationale ? `JUDGE RATIONALE: ${rationale}` : "", |
| weakDims.length |
| ? "WEAK / ZERO DIMENSIONS:\n" + weakDims.join("\n") |
| : "", |
| "", |
| "=== END JUDGE FEEDBACK ===", |
| ] |
| .filter(Boolean) |
| .join("\n"); |
| } |
|
|
| export class QueueRunner { |
| private readonly store: Store; |
| private readonly config: Config; |
| private readonly logger: Logger; |
| private readonly aiClient: AiClient; |
| private readonly llmSemaphore: Semaphore; |
| private readonly inFlight = new Map<string, InFlightJob>(); |
| private shuttingDown = false; |
|
|
| constructor(opts: QueueRunnerOptions) { |
| this.store = opts.store; |
| this.config = opts.config; |
| this.logger = opts.logger; |
| this.aiClient = new AiClient({ upstreamProxy: opts.config.upstreamProxy }); |
| this.llmSemaphore = new Semaphore(Number(opts.config.globalLlmConcurrency || 0)); |
| } |
|
|
| |
| scheduleJob(jobId: string): void { |
| if (this.shuttingDown) return; |
| if (this.inFlight.has(jobId)) return; |
| this.inFlight.set(jobId, { jobId, cancelRequested: false }); |
| void this.store.setJobStatus(jobId, "running"); |
| |
| |
| |
| |
| void this.runJob(jobId).catch((err) => { |
| this.logger.error({ err, jobId }, "Unhandled error in runJob"); |
| }); |
| } |
|
|
| cancelJob(jobId: string): void { |
| const handle = this.inFlight.get(jobId); |
| if (handle) handle.cancelRequested = true; |
| |
| |
| } |
|
|
| async retryItem(itemId: string): Promise<void> { |
| const item = await this.store.getItem(itemId); |
| if (!item) return; |
| await this.store.resetItemForRetry(itemId); |
| this.scheduleJob(item.job_id); |
| } |
|
|
| requestShutdown(): void { |
| this.shuttingDown = true; |
| for (const handle of this.inFlight.values()) handle.cancelRequested = true; |
| } |
|
|
| |
| private async runJob(jobId: string): Promise<void> { |
| const log = this.logger.child({ jobId }); |
| const handle = this.inFlight.get(jobId); |
| if (!handle) { |
| log.warn("Job worker invoked but no in-flight handle present"); |
| return; |
| } |
|
|
| |
| |
| const job = await this.store.getJob(jobId); |
| const snapshot = parseOptionsSnapshot(job?.options_snapshot); |
| const requested = Number(snapshot.concurrency) || 1; |
| const concurrency = Math.max( |
| 1, |
| Math.min(MAX_CONCURRENCY, Math.floor(requested)) |
| ); |
| log.info({ concurrency }, "Job worker starting"); |
|
|
| try { |
| |
| |
| |
| const workers = Array.from({ length: concurrency }, (_, i) => |
| this.itemWorker(jobId, handle, i + 1) |
| ); |
| await Promise.all(workers); |
|
|
| if (handle.cancelRequested || this.shuttingDown) { |
| |
| for (const it of await this.store.listItemsByJob(jobId)) { |
| if (it.status === "queued") await this.store.setItemStatus(it.id, "stopped"); |
| } |
| await this.store.setJobStatus(jobId, "cancelled"); |
| log.info("Job cancelled"); |
| } else { |
| await this.store.setJobStatus(jobId, "completed"); |
| log.info("Job completed"); |
| } |
| } catch (err) { |
| log.error({ err }, "Job worker crashed"); |
| await this.store.setJobStatus(jobId, "completed"); |
| } finally { |
| this.inFlight.delete(jobId); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| private async itemWorker( |
| jobId: string, |
| handle: InFlightJob, |
| workerIndex: number |
| ): Promise<void> { |
| const log = this.logger.child({ jobId, worker: workerIndex }); |
| while (!handle.cancelRequested && !this.shuttingDown) { |
| const next = await this.store.takeNextQueuedItem(jobId); |
| if (!next) return; |
| try { |
| await this.runItem(next, jobId); |
| } catch (err) { |
| log.error({ err, itemId: next.id }, "Item runner threw unexpectedly"); |
| } |
| } |
| } |
|
|
| |
| private async runItem(item: ItemRow, jobId: string): Promise<void> { |
| const log = this.logger.child({ jobId, itemId: item.id }); |
| log.info({ filename: item.filename, detectedMode: item.detected_mode }, "Item start"); |
|
|
| const itemDir = join(this.config.exportDir, jobId, item.id); |
| try { |
| mkdirSync(itemDir, { recursive: true }); |
| } catch (err) { |
| log.error({ err, itemDir }, "Failed to create item export directory"); |
| await this.failItem( |
| item.id, |
| err instanceof Error ? err.message : String(err), |
| "create_export_directory", |
| ); |
| return; |
| } |
|
|
| let stage = "initializing"; |
| let uploadBytes: Buffer | null = null; |
| let latestAnalysis: AnalyzerResult | null = null; |
| let latestTaskPackage: TaskPackageResult | null = null; |
| let latestJudge: JudgeRunResult | null = null; |
| let bestAnalysis: AnalyzerResult | null = null; |
| let bestTaskPackage: TaskPackageResult | null = null; |
| let bestJudge: JudgeRunResult | null = null; |
| let bestScore = -1; |
| let lastJudge: JudgeRunResult | null = null; |
| let iter = 0; |
| let completedIterations = 0; |
| let iterationMemory = createIterationMemory(); |
| try { |
| stage = "loading_job_options"; |
| const job = await this.store.getJob(jobId); |
| const snapshot = parseOptionsSnapshot(job?.options_snapshot); |
| stage = "reading_upload"; |
| uploadBytes = readFileSync(item.upload_path); |
|
|
| |
| |
| |
| const handle = this.inFlight.get(jobId); |
| let extraFeedback = ""; |
| let passed = false; |
|
|
| for (iter = 1; iter <= MAX_JUDGE_ITERATIONS; iter++) { |
| if (handle?.cancelRequested || this.shuttingDown) break; |
| log.info({ iter, max: MAX_JUDGE_ITERATIONS }, "Iteration start"); |
|
|
| |
| stage = `iteration_${iter}:analyzer`; |
| await this.transitionItem(item.id, "running", undefined, stage); |
| const analysis = await this.runAnalyzer( |
| item, |
| uploadBytes, |
| snapshot, |
| extraFeedback |
| ); |
| stage = `iteration_${iter}:build_task_package`; |
| const taskPackage = buildTaskPackage( |
| analysis.result, |
| analysis.evidence, |
| analysis.parsed, |
| buildGoldenTrajectory |
| ); |
| latestAnalysis = analysis; |
| latestTaskPackage = taskPackage; |
| if (!bestAnalysis || !bestTaskPackage) { |
| bestAnalysis = analysis; |
| bestTaskPackage = taskPackage; |
| } |
|
|
| |
| |
| |
| writeJson(join(itemDir, `evidence-iter-${iter}.json`), { |
| mode: analysis.mode, |
| detectedMode: item.detected_mode, |
| flags: analysis.flags, |
| evidence: analysis.evidence, |
| analyzerResult: analysis.result, |
| }); |
| writeJson(join(itemDir, `task-package-iter-${iter}.json`), taskPackage); |
|
|
| |
| stage = `iteration_${iter}:judge`; |
| await this.transitionItem(item.id, "judging", undefined, stage); |
| const judgeResult = await this.withLlmSlot(() => |
| runJudge({ |
| aiClient: this.aiClient, |
| config: this.config, |
| snapshot, |
| taskPackage, |
| evidence: analysis.evidence, |
| }), |
| ); |
| latestJudge = judgeResult; |
| lastJudge = judgeResult; |
| const score = Number(judgeResult.total_score || 0); |
| if (score > bestScore) { |
| bestScore = score; |
| bestAnalysis = analysis; |
| bestTaskPackage = taskPackage; |
| bestJudge = judgeResult; |
| } |
|
|
| writeJson(join(itemDir, `judge-result-iter-${iter}.json`), judgeResult); |
| const iterationFeedback = buildIterationFeedback(judgeResult, iter); |
| iterationMemory = optimizeIterationMemory( |
| iterationMemory, |
| judgeResult, |
| iter, |
| iterationFeedback, |
| ); |
| writeJson(join(itemDir, `iteration-memory-iter-${iter}.json`), iterationMemory); |
| completedIterations = iter; |
|
|
| if (isJudgeTargetSatisfied(judgeResult, snapshot)) { |
| log.info( |
| { iter, score, verdict: judgeResult.verdict }, |
| "Judge target satisfied" |
| ); |
| passed = true; |
| break; |
| } |
|
|
| log.info( |
| { iter, score, max: MAX_JUDGE_ITERATIONS }, |
| "Judge target not yet satisfied; preparing next iteration" |
| ); |
| extraFeedback = buildIterationMemoryPrompt(iterationMemory); |
| } |
|
|
| |
| stage = "persisting_final_artifacts"; |
| const finalAnalysis = bestAnalysis ?? latestAnalysis; |
| const finalTaskPackage = bestTaskPackage ?? latestTaskPackage; |
| const finalJudge = bestJudge ?? lastJudge; |
| if (!finalAnalysis || !finalTaskPackage) { |
| throw new Error("迭代未产生任何可用结果(可能在第一次迭代前被取消或失败)。"); |
| } |
|
|
| |
| |
| |
| stage = "zip_export"; |
| const exportZipPath = await this.persistExportableArtifacts({ |
| item, |
| itemDir, |
| uploadBytes, |
| analysis: finalAnalysis, |
| taskPackage: finalTaskPackage, |
| judgeResult: finalJudge, |
| iterations: completedIterations || Math.min(iter, MAX_JUDGE_ITERATIONS), |
| passed, |
| iterationMemory, |
| }); |
|
|
| if (!finalJudge) { |
| throw new Error("Judge 未产生可用结果,但分组和 rubric 已生成并导出。"); |
| } |
|
|
| if (!passed) { |
| const message = |
| buildJudgeFailureMessage(finalJudge, finalTaskPackage, snapshot) + |
| `\n(已迭代 ${completedIterations}/${MAX_JUDGE_ITERATIONS} 轮,最佳 ${bestScore} 分)`; |
| throw new Error(message); |
| } |
|
|
| await this.transitionItem(item.id, "completed", undefined, stage); |
| log.info( |
| { |
| exportZipPath, |
| verdict: finalJudge.verdict, |
| score: finalJudge.total_score, |
| iterations: iter, |
| }, |
| "Item completed" |
| ); |
| } catch (err) { |
| if (uploadBytes && latestAnalysis && latestTaskPackage) { |
| try { |
| await this.persistExportableArtifacts({ |
| item, |
| itemDir, |
| uploadBytes, |
| analysis: bestAnalysis ?? latestAnalysis, |
| taskPackage: bestTaskPackage ?? latestTaskPackage, |
| judgeResult: bestJudge ?? latestJudge ?? lastJudge, |
| iterations: completedIterations || Math.min(Math.max(iter, 1), MAX_JUDGE_ITERATIONS), |
| passed: false, |
| iterationMemory, |
| }); |
| } catch (exportErr) { |
| log.error({ err: exportErr }, "Failed to preserve analyzer artifacts after item error"); |
| } |
| } |
| const details = serializeError(err, { |
| stage, |
| jobId, |
| itemId: item.id, |
| ordinal: item.ord, |
| filename: item.filename, |
| }); |
| const message = errorPreview(err, details); |
| writeFileSync(join(itemDir, "error.txt"), message + "\n"); |
| const errorDetailsPath = join(itemDir, "error-details.json"); |
| writeJson(errorDetailsPath, details); |
| if ( |
| isTransientItemError(err) && |
| Number(item.attempt_count || 0) < Number(this.config.itemMaxAttempts || 3) |
| ) { |
| await this.transitionItem( |
| item.id, |
| "queued", |
| `Transient failure, retrying (${item.attempt_count}/${this.config.itemMaxAttempts}): ${message}`, |
| stage, |
| errorDetailsPath, |
| ); |
| log.warn( |
| { preview: message, attempt: item.attempt_count, maxAttempts: this.config.itemMaxAttempts }, |
| "Item failed transiently; requeued for retry", |
| ); |
| return; |
| } |
| await this.failItem(item.id, message, stage, errorDetailsPath); |
| log.error({ err, preview: message }, "Item failed"); |
| } |
| } |
|
|
| private async runAnalyzer( |
| item: ItemRow, |
| uploadBytes: Buffer, |
| snapshot: OptionsSnapshot, |
| extraFeedback = "" |
| ): Promise<AnalyzerResult> { |
| const mode = resolveRequestedMode(snapshot, item.detected_mode); |
| const isMcp = mode === "mcp"; |
| const parsed = isMcp |
| ? await parseMcpZip(uploadBytes, item.filename) |
| : await parseApiZip(uploadBytes, item.filename); |
| let evidence: Record<string, unknown>; |
| if (isMcp) { |
| const toolResolution = await resolvePluginMcpTools(parsed, this.logger.child({ itemId: item.id })); |
| evidence = buildMcpEvidence(parsed as never, { |
| strictPluginMcp: true, |
| pluginMcpTools: toolResolution.tools, |
| pluginMcpToolSource: toolResolution.source, |
| }) as unknown as Record<string, unknown>; |
| assertStrictMcpEvidenceReady(evidence, item.filename); |
| } else { |
| evidence = { type: "http", ...buildApiEvidence(parsed as never) } as Record<string, unknown>; |
| } |
| const flags = runLocalRules(evidence); |
| const promptModule = isMcp ? mcpPrompt : apiPrompt; |
| const slimEvidence = promptModule.buildSlimEvidence(evidence); |
| const system = promptModule.getSystemPrompt(); |
| const baseFeedback = snapshot.feedback || ""; |
| const mergedFeedback = [baseFeedback, extraFeedback].filter(Boolean).join("\n\n"); |
| const user = promptModule.buildUserPrompt( |
| slimEvidence, |
| flags, |
| mergedFeedback, |
| { optimizeTask: snapshot.optimizeTask } |
| ); |
| const apiMode = snapshot.apiMode || this.config.defaultApiMode; |
| const result = await this.withLlmSlot(() => |
| this.aiClient.requestJson( |
| { |
| apiKey: snapshot.apiKey || this.config.defaultApiKey, |
| baseURL: snapshot.baseURL || this.config.defaultBaseURL, |
| apiMode, |
| model: snapshot.model || this.config.defaultModel, |
| temperature: snapshot.temperature ?? 0, |
| topP: snapshot.topP ?? 1, |
| maxTokens: snapshot.maxTokens || 16384, |
| enableThinking: false, |
| clearThinking: true, |
| }, |
| [ |
| { role: "system", content: system }, |
| { role: "user", content: user }, |
| ], |
| { |
| schema: apiMode === "responses" ? promptModule.RESULT_SCHEMA : undefined, |
| schemaName: "task_optimizer_analyzer_result", |
| stream: false, |
| jsonMode: apiMode === "chat", |
| jsonModeFallback: true, |
| retries: 2, |
| maxTokens: snapshot.maxTokens || 16384, |
| }, |
| ), |
| ); |
| return { mode, parsed, evidence, flags, result }; |
| } |
|
|
| private async persistExportableArtifacts(input: { |
| item: ItemRow; |
| itemDir: string; |
| uploadBytes: Buffer; |
| analysis: AnalyzerResult; |
| taskPackage: TaskPackageResult; |
| judgeResult?: JudgeRunResult | null; |
| iterations: number; |
| passed: boolean; |
| iterationMemory?: IterationMemory | null; |
| }): Promise<string> { |
| const evidencePath = join(input.itemDir, "evidence.json"); |
| writeJson(evidencePath, { |
| mode: input.analysis.mode, |
| detectedMode: input.item.detected_mode, |
| flags: input.analysis.flags, |
| evidence: input.analysis.evidence, |
| analyzerResult: input.analysis.result, |
| iterations: input.iterations, |
| passed: input.passed, |
| iterationMemory: input.iterationMemory ?? null, |
| }); |
|
|
| const taskPackagePath = join(input.itemDir, "task-package.json"); |
| writeJson(taskPackagePath, input.taskPackage); |
|
|
| const paths: Parameters<Store["setItemPaths"]>[1] = { |
| evidencePath, |
| taskPackagePath, |
| }; |
| if (input.judgeResult) { |
| const judgeResultPath = join(input.itemDir, "judge-result.json"); |
| writeJson(judgeResultPath, input.judgeResult); |
| paths.judgeResultPath = judgeResultPath; |
| } |
| await this.store.setItemPaths(input.item.id, paths); |
|
|
| const builtZip = await buildAnalysisZipBuffer({ |
| originalZipBytes: input.uploadBytes, |
| taskPackage: input.taskPackage, |
| mode: input.analysis.mode, |
| originalFilename: input.item.filename, |
| }); |
| const exportZipPath = join(input.itemDir, builtZip.filename); |
| writeFileSync(exportZipPath, builtZip.buffer); |
| await this.store.setItemPaths(input.item.id, { exportZipPath }); |
| return exportZipPath; |
| } |
|
|
| private async failItem( |
| itemId: string, |
| message: string, |
| lastStage?: string, |
| errorDetailsPath?: string, |
| ): Promise<void> { |
| await this.transitionItem(itemId, "failed", message, lastStage, errorDetailsPath); |
| } |
|
|
| private async transitionItem( |
| itemId: string, |
| status: ItemStatus, |
| errorPreview?: string, |
| lastStage?: string, |
| errorDetailsPath?: string, |
| ): Promise<void> { |
| await this.store.setItemStatus(itemId, status, errorPreview, { |
| lastStage: lastStage ?? null, |
| errorDetailsPath: errorDetailsPath ?? null, |
| }); |
| } |
|
|
| private async withLlmSlot<T>(fn: () => Promise<T>): Promise<T> { |
| return this.llmSemaphore.run(fn); |
| } |
| } |
|
|
| function parseOptionsSnapshot(raw: string | undefined): OptionsSnapshot { |
| if (!raw) return {}; |
| try { |
| const parsed = JSON.parse(raw); |
| return parsed && typeof parsed === "object" ? parsed : {}; |
| } catch (_) { |
| return {}; |
| } |
| } |
|
|
| function resolveRequestedMode( |
| snapshot: OptionsSnapshot, |
| detectedMode: DetectedMode |
| ): DetectedMode { |
| return snapshot.requestedMode === "api" || snapshot.requestedMode === "mcp" |
| ? snapshot.requestedMode |
| : detectedMode; |
| } |
|
|
| function writeJson(path: string, value: unknown): void { |
| writeFileSync(path, JSON.stringify(value, null, 2)); |
| } |
|
|
| function isJudgeTargetSatisfied( |
| judgeResult: { |
| total_score?: number; |
| max_score?: number; |
| has_zeros?: boolean; |
| verdict?: string; |
| dimensions?: Record<string, { score?: number; explanation?: string }>; |
| }, |
| snapshot: OptionsSnapshot |
| ): boolean { |
| return evaluateJudgePolicy(judgeResult, snapshot).passed; |
| } |
|
|
| function buildJudgeFailureMessage( |
| judgeResult: { total_score?: number; max_score?: number; dimensions?: Record<string, { score?: number; explanation?: string }> }, |
| taskPackage: Record<string, unknown>, |
| snapshot: OptionsSnapshot = {}, |
| ): string { |
| const maxScore = Number(judgeResult.max_score) || 12; |
| const requirement = snapshot.strictFullMarks |
| ? snapshot.ignoreTaskComplexityForFullMarks |
| ? "要求除 TASK COMPLEXITY 外其他维度满分。" |
| : "要求满分且无 0 分。" |
| : "要求 >=10 且无 0 分。"; |
| const dims = judgeResult.dimensions || {}; |
| const weak = Object.entries(dims) |
| .filter(([, value]) => Number(value && value.score) === 0) |
| .map(([key, value]) => key + ": " + String((value && value.explanation) || "").slice(0, 240)); |
| const limits = Array.isArray(taskPackage.evidence_limits) ? taskPackage.evidence_limits : []; |
| const rerecordReason = String(taskPackage.rerecord_required_reason || ""); |
| return [ |
| "Judge 未达到 Chrome 插件标准:得分 " + Number(judgeResult.total_score || 0) + "/" + maxScore + "," + requirement, |
| weak.length ? "0 分维度:" + weak.join(";") : "", |
| rerecordReason ? "重录建议:" + rerecordReason : "", |
| limits.length ? "证据限制:" + limits.map(String).join(";") : "", |
| ].filter(Boolean).join("\n"); |
| } |
|
|
| async function resolvePluginMcpTools(parsed: any, log: Logger): Promise<{ tools: PluginMcpTool[]; source: string }> { |
| const embedded = Array.isArray(parsed?.pluginMcpTools) ? parsed.pluginMcpTools : []; |
| if (embedded.length) return { tools: embedded, source: parsed.pluginMcpToolSource || "task.json" }; |
|
|
| const urls = collectMcpConfigUrls({ taskJson: parsed?.taskJson, networkJson: parsed?.networkJson }); |
| const errors: string[] = []; |
| for (const url of urls) { |
| try { |
| const controller = new AbortController(); |
| const timer = setTimeout(() => controller.abort(), 8000); |
| const response = await fetch(url, { signal: controller.signal }); |
| clearTimeout(timer); |
| if (!response.ok) { |
| errors.push(url + " -> HTTP " + response.status); |
| continue; |
| } |
| const payload = await response.json(); |
| const tools = collectMcpToolsFromTask(payload); |
| if (tools.length) return { tools, source: url }; |
| errors.push(url + " -> no tools"); |
| } catch (err) { |
| errors.push(url + " -> " + ((err as Error)?.message || String(err))); |
| } |
| } |
| if (urls.length) { |
| log.warn({ urls, errors: errors.slice(0, 8) }, "Failed to resolve plugin MCP tools from config URLs"); |
| } |
| return { |
| tools: [], |
| source: urls.length ? "mcp_config_unavailable" : (parsed?.pluginMcpToolSource || "unavailable"), |
| }; |
| } |
|
|
| function assertStrictMcpEvidenceReady(evidence: Record<string, unknown>, filename: string): void { |
| if (evidence?.type !== "mcp") return; |
| const status = evidence.mcpToolsStatus as |
| | { available?: boolean; reason?: string; source?: string; matchedCount?: number; toolCount?: number } |
| | undefined; |
| const calls = Array.isArray(evidence.calls) ? evidence.calls : []; |
| if (status && status.available === false) { |
| throw new Error( |
| [ |
| "严格 MCP 不可用:" + filename + " 没有可用的 Chrome 插件 tools config。", |
| "source=" + (status.source || "unknown"), |
| status.reason ? "reason=" + status.reason : "", |
| "请确认原始 task/metadata 中带有 MCP tools,或重新用插件采集包含 MCP config 的 recording。", |
| ].filter(Boolean).join("\n"), |
| ); |
| } |
| if (!calls.length) { |
| throw new Error( |
| [ |
| "严格 MCP 没有匹配到任何插件可见 MCP call:" + filename, |
| status |
| ? `source=${status.source || "unknown"}, tools=${status.toolCount || 0}, matched=${status.matchedCount || 0}` |
| : "", |
| "后端不会再把 HTTP 请求伪造成 MCP call;请补采 MCP tools config 或重录。", |
| ].filter(Boolean).join("\n"), |
| ); |
| } |
| } |
|
|
| export function isTransientItemError(err: unknown): boolean { |
| const anyErr = err as { name?: string; message?: string; status?: number; retryable?: boolean }; |
| const message = String(anyErr?.message || err || ""); |
| if (/严格 MCP|Judge 未达到|zip 缺少|zip 格式未识别|校验失败|schema invalid|not valid JSON/i.test(message)) { |
| return false; |
| } |
| if (anyErr?.retryable === true) return true; |
| const status = Number(anyErr?.status || 0); |
| if (status === 429 || (status >= 500 && status < 600)) return true; |
| return /AbortError|aborted|timeout|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|terminated/i.test( |
| String(anyErr?.name || "") + " " + message, |
| ); |
| } |
|
|
| function serializeError(err: unknown, context: Record<string, unknown>): Record<string, unknown> { |
| const anyErr = err as { |
| name?: string; |
| message?: string; |
| stack?: string; |
| rawPreview?: string; |
| status?: number; |
| endpoint?: string; |
| apiMode?: string; |
| cause?: unknown; |
| }; |
| return { |
| ...context, |
| name: anyErr?.name || (err && typeof err === "object" ? err.constructor?.name : typeof err), |
| message: anyErr?.message || String(err), |
| rawPreview: anyErr?.rawPreview, |
| status: anyErr?.status, |
| endpoint: anyErr?.endpoint, |
| apiMode: anyErr?.apiMode, |
| cause: |
| anyErr?.cause instanceof Error |
| ? { name: anyErr.cause.name, message: anyErr.cause.message, stack: anyErr.cause.stack } |
| : anyErr?.cause, |
| stack: anyErr?.stack, |
| aborted: /AbortError|aborted|abort/i.test(String(anyErr?.name || "") + " " + String(anyErr?.message || "")), |
| cancelled: /cancel|stopped/i.test(String(anyErr?.message || "")), |
| terminated: /terminated/i.test(String(anyErr?.message || "")), |
| }; |
| } |
|
|
| function errorPreview(err: unknown, details?: Record<string, unknown>): string { |
| const anyErr = err as { rawPreview?: string; message?: string }; |
| const stage = details?.stage ? "stage=" + String(details.stage) + "\n" : ""; |
| const message = anyErr?.rawPreview || anyErr?.message || String(err); |
| return safePreview(stage + message).slice(0, 2048); |
| } |
|
|