feat(kernel): structured package-404 recovery evidence (#192)

The 2026-07-16 audition run-3 burned 26 inferences in install_dependencies
because a registry 404 for a hallucinated package (@types/vite) surfaced only
as raw shell output: the model retried npm, pinned versions/flags, switched to
Yarn, and probed pnpm/network before rewriting the manifest.

packageNotFoundAdvisory deterministically parses npm/yarn/pnpm 404 output,
names the offending manifest entry, and appends a directive telling the model
to edit the manifest — not retry the installer. Wired into renderToolResult
for both the nonzero-exit Success and recoverable Failure framings. Pure over
already-recorded tool output (no new event, invariant #9 unaffected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 18:56:21 +04:00
parent 59a0ad3c2b
commit 633da3d2df
3 changed files with 81 additions and 5 deletions
@@ -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)
}
}
}
@@ -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.%-]+)?)"""),
)
@@ -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())
}
}