diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt index a69f68ee..333badc0 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt @@ -85,7 +85,6 @@ internal fun SessionOrchestrator.toolArgsHint(tool: Tool?): String = "\nAccepted parameters for '${it.name}': ${it.parametersSchema}" }.orEmpty() - /** * Render a tool result into the consistently-framed, bounded text the model sees. Success output * is run through the tool's [ToolOutputCompressor], then framed with a uniform `[tool exit=N]` @@ -99,20 +98,28 @@ internal fun SessionOrchestrator.toolArgsHint(tool: Tool?): String = internal suspend fun SessionOrchestrator.renderToolResult(toolName: String, tool: Tool?, result: ToolResult): RenderedToolResult = when (result) { is ToolResult.Failure -> RenderedToolResult( - if (!result.recoverable) "FATAL: ${result.reason}" else "ERROR: ${result.reason}${toolArgsHint(tool)}", + if (!result.recoverable) { + "FATAL: ${result.reason}" + } else { + "ERROR: ${result.reason}${toolArgsHint(tool)}${packageNotFoundAdvisory(result.reason)}" + }, null, ) is ToolResult.Success -> { val compressed = tool?.outputCompressor?.compress(result.output, ToolOutputContext(result.exitCode)) ?: result.output val header = "[$toolName exit=${result.exitCode}]" - if (compressed.length <= TOOL_RESULT_MAX_CHARS) { - RenderedToolResult("$header\n$compressed", null) + // A package-install 404 usually surfaces as a nonzero-exit Success (the shell ran; the + // installer failed). Parse it deterministically and name the offending manifest entry so + // the model edits the manifest instead of thrashing the installer (Vikunja #192). + val advisory = if (result.exitCode != 0) packageNotFoundAdvisory(result.output) else "" + if (compressed.length + advisory.length <= TOOL_RESULT_MAX_CHARS) { + RenderedToolResult("$header\n$compressed$advisory", null) } else { // Spill the full RAW output (most complete) so retrieval returns everything, not the // already-compressed preview the model saw. val ref = artifactStore.put(result.output.toByteArray()).value - RenderedToolResult(frameTruncatedToolResult(header, compressed, ref), ref) + RenderedToolResult(frameTruncatedToolResult(header, compressed, ref) + advisory, ref) } } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ToolResultAdvisories.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ToolResultAdvisories.kt new file mode 100644 index 00000000..bf263afa --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ToolResultAdvisories.kt @@ -0,0 +1,36 @@ +package com.correx.core.kernel.orchestration + +/** + * Deterministic recovery evidence for an unambiguous package-not-found (Vikunja #192). A registry + * 404 means the dependency does not exist — retrying the installer, pinning a version, or switching + * package managers cannot fix it (the 2026-07-16 audition burned 26 inferences doing exactly that). + * Parse the offending name out of npm/yarn/pnpm output and hand back a directive that points at the + * *manifest*, not the installer. Empty string when no 404 is detected, so callers concatenate + * unconditionally. Pure over the tool output (already recorded on the ToolReceipt) — no new event. + */ +internal fun packageNotFoundAdvisory(output: String): String { + val pkg = PACKAGE_NOT_FOUND_PATTERNS.firstNotNullOfOrNull { it.find(output)?.groupValues?.get(1) } + ?.replace("%2f", "/", ignoreCase = true) // npm URL-encodes the scope slash + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: return "" + // Strip a trailing @version so the name matches the manifest key. + val name = if (pkg.startsWith("@")) { + "@" + pkg.removePrefix("@").substringBefore('@') + } else { + pkg.substringBefore('@') + } + return "\nPACKAGE_NOT_FOUND: '$name' does not exist in the registry (404). Do NOT retry the " + + "installer, pin a version, or switch package managers — none can create a nonexistent " + + "package. Edit the manifest (e.g. package.json) to remove or correct '$name', then reinstall." +} + +// Registry-404 shapes across npm/yarn/pnpm. Group 1 is the offending package spec. +// npm modern: npm error 404 '@types/vite@^4.0.0' is not in this registry. +// npm/GET url: npm error 404 Not Found - GET https://registry.npmjs.org/@types%2fvite - Not found +// yarn: error ... https://registry.yarnpkg.com/@types%2fvite: Not found +// pnpm: ERR_PNPM_FETCH_404 ... GET https://registry.npmjs.org/@types%2fvite: Not Found - 404 +private val PACKAGE_NOT_FOUND_PATTERNS = listOf( + Regex("""404[^\n']*'([^'\n]+)'"""), + Regex("""404[^\n]*?registry\.\S+?/(@?[\w.%-]+(?:/[\w.%-]+)?)"""), +) diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/PackageNotFoundAdvisoryTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/PackageNotFoundAdvisoryTest.kt new file mode 100644 index 00000000..88aed1c7 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/PackageNotFoundAdvisoryTest.kt @@ -0,0 +1,33 @@ +package com.correx.core.kernel.orchestration + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PackageNotFoundAdvisoryTest { + + @Test + fun `npm quoted-spec 404 names the scoped package without its version`() { + val out = "npm error code E404\nnpm error 404 '@types/vite@^4.0.0' is not in this registry." + val advisory = packageNotFoundAdvisory(out) + assertTrue(advisory.contains("'@types/vite'"), advisory) + assertTrue(advisory.contains("Edit the manifest"), advisory) + } + + @Test + fun `npm GET-url 404 decodes the scope slash`() { + val out = "npm error 404 Not Found - GET https://registry.npmjs.org/@types%2fvite - Not found" + assertTrue(packageNotFoundAdvisory(out).contains("'@types/vite'")) + } + + @Test + fun `pnpm registry 404 names the package`() { + val out = "ERR_PNPM_FETCH_404 GET https://registry.npmjs.org/leftpad-nope: Not Found - 404" + assertTrue(packageNotFoundAdvisory(out).contains("'leftpad-nope'")) + } + + @Test + fun `unrelated failures produce no advisory`() { + assertTrue(packageNotFoundAdvisory("tsc: type error in App.tsx").isEmpty()) + assertTrue(packageNotFoundAdvisory("npm warn deprecated foo@1.0.0").isEmpty()) + } +}