feat(toolintent): reference-must-exist gate (anti-hallucination)
A file_read of a path that is inside the workspace but does not exist is now BLOCKED with "no such path" instead of silently returning empty — so an agent that hallucinated a filename fails loud and self-corrects (list the directory, fix the name) rather than proceeding on an absent reference. Out-of-workspace targets stay PathContainmentRule's concern, so the gates don't double-handle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,7 @@ import com.correx.core.toolintent.rules.ManifestContainmentRule
|
|||||||
import com.correx.core.toolintent.rules.NetworkHostRule
|
import com.correx.core.toolintent.rules.NetworkHostRule
|
||||||
import com.correx.core.toolintent.rules.PathContainmentRule
|
import com.correx.core.toolintent.rules.PathContainmentRule
|
||||||
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
|
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
|
||||||
|
import com.correx.core.toolintent.rules.ReferenceExistsRule
|
||||||
import com.correx.apps.server.freestyle.FreestyleDriver
|
import com.correx.apps.server.freestyle.FreestyleDriver
|
||||||
import com.correx.apps.server.inference.summarizeWithInference
|
import com.correx.apps.server.inference.summarizeWithInference
|
||||||
import com.correx.core.kernel.orchestration.JournalCompactionService
|
import com.correx.core.kernel.orchestration.JournalCompactionService
|
||||||
@@ -263,6 +264,7 @@ fun main() {
|
|||||||
rules = listOf(
|
rules = listOf(
|
||||||
PathContainmentRule(),
|
PathContainmentRule(),
|
||||||
ReadBeforeWriteRule(),
|
ReadBeforeWriteRule(),
|
||||||
|
ReferenceExistsRule(),
|
||||||
ManifestContainmentRule(),
|
ManifestContainmentRule(),
|
||||||
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
|
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
|
||||||
NetworkHostRule(
|
NetworkHostRule(
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package com.correx.core.toolintent.rules
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ToolCallObservation
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessment
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||||
|
import com.correx.core.toolintent.ToolCallRule
|
||||||
|
import com.correx.core.toolintent.maxAction
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import com.correx.core.validation.model.ValidationIssue
|
||||||
|
import com.correx.core.validation.model.ValidationSeverity
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference-must-exist gate (anti-hallucination). Dispatches on FILE_READ: a read of a path that is
|
||||||
|
* inside the workspace but does not exist is BLOCKED with "no such path", so an agent that
|
||||||
|
* hallucinated a filename fails loud and self-corrects (list the directory, fix the name) instead of
|
||||||
|
* proceeding on an empty/absent read. Out-of-workspace targets are [PathContainmentRule]'s concern
|
||||||
|
* (it prompts), so this gate stays silent on them to avoid double-handling.
|
||||||
|
*/
|
||||||
|
class ReferenceExistsRule : ToolCallRule {
|
||||||
|
|
||||||
|
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
|
||||||
|
ToolCapability.FILE_READ in capabilities
|
||||||
|
|
||||||
|
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||||
|
val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
|
||||||
|
val root = input.workspace.workspaceRoot
|
||||||
|
|
||||||
|
val issues = mutableListOf<ValidationIssue>()
|
||||||
|
val observations = mutableListOf<ToolCallObservation>()
|
||||||
|
var disposition = RiskAction.PROCEED
|
||||||
|
|
||||||
|
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
|
||||||
|
val candidate = Path.of(raw)
|
||||||
|
val resolvedInput = if (candidate.isAbsolute) candidate else root.resolve(candidate)
|
||||||
|
val exists = input.probe.exists(resolvedInput)
|
||||||
|
val inWorkspace = input.probe.resolveReal(resolvedInput).startsWith(workspaceReal)
|
||||||
|
|
||||||
|
observations += ToolCallObservation(
|
||||||
|
ruleCode = RULE_CODE,
|
||||||
|
facts = mapOf("path" to raw, "exists" to exists.toString(), "inWorkspace" to inWorkspace.toString()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (inWorkspace && !exists) {
|
||||||
|
issues += ValidationIssue(
|
||||||
|
code = RULE_CODE,
|
||||||
|
message = "Tool '${input.request.toolName}' references '$raw', which does not " +
|
||||||
|
"exist — list the directory or fix the path; do not assume its contents.",
|
||||||
|
severity = ValidationSeverity.ERROR,
|
||||||
|
)
|
||||||
|
disposition = maxAction(disposition, RiskAction.BLOCK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val RULE_CODE = "REFERENCE_EXISTS"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.toolintent.rules.ReferenceExistsRule
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ReferenceExistsRuleTest {
|
||||||
|
|
||||||
|
private val workspace = Path.of("/work/project")
|
||||||
|
private val rule = ReferenceExistsRule()
|
||||||
|
|
||||||
|
private class FakeProbe(private val existing: Set<Path> = emptySet()) : WorldProbe {
|
||||||
|
override fun exists(path: Path): Boolean = path.toAbsolutePath().normalize() in existing
|
||||||
|
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
|
||||||
|
|
||||||
|
private fun input(pathArg: String, probe: WorldProbe) = ToolCallAssessmentInput(
|
||||||
|
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_read", mapOf("path" to pathArg)),
|
||||||
|
capabilities = setOf(ToolCapability.FILE_READ),
|
||||||
|
workspace = WorkspacePolicy(workspace, emptyList()),
|
||||||
|
probe = probe,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applies only to FILE_READ`() {
|
||||||
|
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
|
||||||
|
assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_WRITE)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reading a non-existent in-workspace path is blocked`() {
|
||||||
|
val r = rule.assess(input("/work/project/src/Ghost.kt", FakeProbe(existing = emptySet())))
|
||||||
|
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||||
|
assertEquals("REFERENCE_EXISTS", r.issues.single().code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reading an existing path proceeds`() {
|
||||||
|
val target = "/work/project/src/A.kt"
|
||||||
|
val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target)))))
|
||||||
|
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||||
|
assertTrue(r.issues.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-existent path outside the workspace is left to other gates`() {
|
||||||
|
val r = rule.assess(input("/tmp/elsewhere.txt", FakeProbe(existing = emptySet())))
|
||||||
|
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||||
|
assertEquals("false", r.observations.single().facts["inWorkspace"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user