diff --git a/src/lib/admin/author.server.ts b/src/lib/admin/author.server.ts index 293a12af..8c598f11 100644 --- a/src/lib/admin/author.server.ts +++ b/src/lib/admin/author.server.ts @@ -12,25 +12,58 @@ You output strict JSON conforming to the PackageDraft schema. Every package must Type semantics: skill = capability; playbook = multi-step decision flow; soul = personality/values layer; guardrail = safety boundary. Slug must be lowercase-kebab.`; +// Author model fallback chain. The structured-output path (Output.object → +// tool/function calling) is sensitive to provider/model quirks, so we try a +// short, ordered list before giving up. First success wins. Adding a model +// here is cheap; do NOT silently swallow failures — every attempt logs the +// model + the error so the cause is visible in the server logs. +const AUTHOR_MODEL_FALLBACKS = [ + "google/gemini-3-flash-preview", + "google/gemini-2.5-flash", + "google/gemini-2.5-pro", + "openai/gpt-5.1-mini", +] as const; + export async function generateDraft( brief: string, type: "skill" | "playbook" | "soul" | "guardrail", vertical?: string, grounding?: string ) { - const model = getGatewayModel("google/gemini-3-flash-preview"); const prompt = `Brief:\n${brief}\n\nType: ${type}${vertical ? `\nVertical: ${vertical}` : ""}${ grounding ? `\n\nGrounding research (use as ground truth):\n${grounding.slice(0, 8000)}` : "" }\n\nDesign a complete, production-ready ${type} package. Return ONLY the JSON.`; - const { experimental_output } = await generateText({ - model, - system: META_SYSTEM, - prompt, - experimental_output: Output.object({ schema: PackageDraftSchema }), - }); - if (experimental_output.type !== type) experimental_output.type = type; - return experimental_output; + const attempts: Array<{ model: string; error: string }> = []; + for (const modelId of AUTHOR_MODEL_FALLBACKS) { + try { + const model = getGatewayModel(modelId); + const { experimental_output } = await generateText({ + model, + system: META_SYSTEM, + prompt, + experimental_output: Output.object({ schema: PackageDraftSchema }), + }); + if (experimental_output.type !== type) experimental_output.type = type; + if (attempts.length > 0) { + // We recovered — log so we can spot a regression on the primary model. + console.warn( + `[skillforge.author] recovered on ${modelId} after ${attempts.length} failure(s):`, + attempts, + ); + } + return experimental_output; + } catch (e: any) { + const msg = e?.message ?? String(e); + attempts.push({ model: modelId, error: msg }); + console.error(`[skillforge.author] ${modelId} failed:`, msg); + } + } + // All fallbacks exhausted. Surface a structured error so the upload + // pipeline can report it back to the caller instead of silently + // recording "failed". + const summary = attempts.map((a) => `${a.model}: ${a.error}`).join(" | "); + throw new Error(`SkillForge author failed across all fallback models — ${summary}`); } export async function insertDraftPackage( diff --git a/src/lib/mcp/tools/skills.ts b/src/lib/mcp/tools/skills.ts index 52d0eea4..13aac520 100644 --- a/src/lib/mcp/tools/skills.ts +++ b/src/lib/mcp/tools/skills.ts @@ -317,19 +317,39 @@ export const uploadPackagesTool = defineTool({ // Always private. Marketplace listing requires an explicit user action in the UI. const results = await processBulkUpload(supabaseAdmin as any, userId, files); const ok = results.filter((r) => r.ok).length; - const response = { + const failed = results.length - ok; + // Surface the per-file errors at the top of the payload too. MCP + // clients (and the LLMs reading their output) often render only + // the first ~200 chars of a tool response and miss the buried + // `results[].error`, leaving users with a generic "failed: N" + // and no way to act. The summary line is short enough to survive + // truncation and points at the actual root cause. + const errorMessages = results + .filter((r) => !r.ok) + .map((r) => `${r.name}: ${r.error ?? "unknown"}`); + const response: Record = { uploaded: ok, - failed: results.length - ok, + failed, visibility: "private_draft", - next_step: "Open /account/packages on superagentskill.com to submit a draft for admin review.", + next_step: + ok > 0 + ? "Open /account/packages on superagentskill.com to submit a draft for admin review." + : "All files failed to normalise. See `error_summary` for the cause and retry.", results, }; - if (idempotency_key) { + if (failed > 0) { + response.error_summary = errorMessages.slice(0, 3).join(" · "); + } + if (idempotency_key && ok > 0) { + // Only cache successful runs so a transient model failure doesn't + // poison the idempotency key for 24h. await putIdempotent(userId, "upload_packages", idempotency_key, response); } return json(response); } catch (e: any) { - return json({ error: e?.message ?? "upload_failed" }); + const msg = e?.message ?? "upload_failed"; + console.error(`[mcp.upload_packages] user=${userId} unexpected:`, e); + return json({ error: msg }); } }, }); diff --git a/src/lib/uploads/uploads.server.ts b/src/lib/uploads/uploads.server.ts index e9ef53ed..e7f2325e 100644 --- a/src/lib/uploads/uploads.server.ts +++ b/src/lib/uploads/uploads.server.ts @@ -87,7 +87,16 @@ export async function processBulkUpload( out.type = inferred; out.forge_report_url = `/forge/report/${pkg.slug}`; } catch (e: any) { - out.error = e?.message ?? "failed"; + const msg = e?.message ?? "failed"; + out.error = msg; + // Server-side visibility — without this the MCP caller sees a + // generic "failed" while the real cause (model rejection, schema + // mismatch, DB error) is invisible. Keep the user/filename so we + // can correlate complaints to actual rows in the logs. + console.error( + `[uploads.processBulkUpload] user=${userId} file=${f.name} type=${f.type ?? "?"}: ${msg}`, + e, + ); } results.push(out); }