refactor(events): record tool capabilities on the invocation event
Capability is the canonical signal the plane-2 gates dispatch on, but ToolInvocationRequestedEvent only carried the tool name — so ReadFilesProjection classified reads by a hardcoded "file_read" string, bypassing the abstraction and (worse) re-deriving a semantic fact at replay from whatever the name meant. That violates the event-sourcing invariant that replay reads recorded facts, not live state. Now the tool's requiredCapabilities are recorded on the event at emission, and the projection classifies by ToolCapability.FILE_READ. To let the lowest module carry it, ToolCapability moves from core:tools into core:events keeping its package, so every existing import resolves unchanged and core:tools imports it from below. The field is defaulted, so pre-existing events deserialize as empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier
|
||||
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.tools.contract.ToolCapability
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -52,6 +53,10 @@ data class ToolInvocationRequestedEvent(
|
||||
val toolName: String,
|
||||
val tier: Tier,
|
||||
val request: ToolRequest,
|
||||
// The tool's declared capabilities, recorded at emission so replay classifies the call by what
|
||||
// it could actually do (e.g. FILE_READ/FILE_WRITE) without re-deriving from the live registry.
|
||||
// Defaulted for backward compatibility: events written before this field deserialize as empty.
|
||||
val capabilities: Set<ToolCapability> = emptySet(),
|
||||
) : EventPayload
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* What a tool is allowed to do, independent of its name. Plane-2 (`core:toolintent`) rules dispatch
|
||||
* on these — never on tool names — and they are recorded on
|
||||
* [com.correx.core.events.events.ToolInvocationRequestedEvent] at emission, so replay classifies a
|
||||
* call by the capability it actually had rather than re-deriving it from the live registry.
|
||||
*
|
||||
* Lives in `core:events` (the lowest module) so events can carry it; `core:tools` and everything
|
||||
* above import it from here — the package name is unchanged, so the move is import-transparent.
|
||||
*/
|
||||
@Serializable
|
||||
enum class ToolCapability {
|
||||
FILE_READ,
|
||||
FILE_WRITE,
|
||||
NETWORK_ACCESS,
|
||||
SHELL_EXEC,
|
||||
PROCESS_SPAWN,
|
||||
}
|
||||
+1
@@ -648,6 +648,7 @@ abstract class SessionOrchestrator(
|
||||
toolName = toolCall.function.name,
|
||||
tier = tier,
|
||||
request = request,
|
||||
capabilities = tool?.requiredCapabilities ?: emptySet(),
|
||||
),
|
||||
)
|
||||
if (toolCall.function.name != STAGE_COMPLETE_TOOL &&
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.Projection
|
||||
import com.correx.core.toolintent.rules.candidatePathStrings
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
|
||||
/** Reads still awaiting their completion event, plus the paths confirmed read so far. */
|
||||
data class ReadFilesState(
|
||||
@@ -14,11 +15,12 @@ data class ReadFilesState(
|
||||
)
|
||||
|
||||
/**
|
||||
* Folds one session's tool events into the set of file paths it has successfully read. A `file_read`
|
||||
* counts only once its [ToolExecutionCompletedEvent] lands — a read that failed (file not found)
|
||||
* never enters the set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule].
|
||||
* Paths are kept as the raw arguments; the rule resolves both sides through the probe, so
|
||||
* `./Foo.kt` read and `Foo.kt` written still match. The same projection precedent as
|
||||
* Folds one session's tool events into the set of file paths it has successfully read. A call counts
|
||||
* as a read by its recorded [ToolCapability.FILE_READ] capability (not its tool name), and only once
|
||||
* its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) never enters the
|
||||
* set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. Paths are kept as
|
||||
* the raw arguments; the rule resolves both sides through the probe, so `./Foo.kt` read and
|
||||
* `Foo.kt` written still match. Same projection precedent as
|
||||
* [com.correx.core.sessions.projections.EgressAllowlistProjection].
|
||||
*/
|
||||
class ReadFilesProjection(private val sessionId: SessionId) : Projection<ReadFilesState> {
|
||||
@@ -33,7 +35,7 @@ class ReadFilesProjection(private val sessionId: SessionId) : Projection<ReadFil
|
||||
}
|
||||
|
||||
private fun recordPending(state: ReadFilesState, payload: ToolInvocationRequestedEvent): ReadFilesState {
|
||||
if (payload.sessionId != sessionId || payload.toolName != FILE_READ_TOOL) return state
|
||||
if (payload.sessionId != sessionId || ToolCapability.FILE_READ !in payload.capabilities) return state
|
||||
val paths = candidatePathStrings(emptyMap(), payload.request.parameters)
|
||||
if (paths.isEmpty()) return state
|
||||
return state.copy(pending = state.pending + (payload.invocationId.value to paths))
|
||||
@@ -47,8 +49,4 @@ class ReadFilesProjection(private val sessionId: SessionId) : Projection<ReadFil
|
||||
readPaths = state.readPaths + paths,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE_READ_TOOL = "file_read"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.correx.core.events.types.EventId
|
||||
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.tools.contract.ToolCapability
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -38,6 +39,7 @@ class ReadFilesProjectionTest {
|
||||
toolName = "file_read",
|
||||
tier = Tier.T1,
|
||||
request = ToolRequest(ToolInvocationId(invId), session, StageId("st"), "file_read", mapOf("path" to path)),
|
||||
capabilities = setOf(ToolCapability.FILE_READ),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
enum class ToolCapability {
|
||||
FILE_READ,
|
||||
FILE_WRITE,
|
||||
NETWORK_ACCESS,
|
||||
SHELL_EXEC,
|
||||
PROCESS_SPAWN
|
||||
}
|
||||
Reference in New Issue
Block a user