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:
+12
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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.%-]+)?)"""),
|
||||
)
|
||||
+33
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user