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.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.ToolInvocationId
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -52,6 +53,10 @@ data class ToolInvocationRequestedEvent(
|
|||||||
val toolName: String,
|
val toolName: String,
|
||||||
val tier: Tier,
|
val tier: Tier,
|
||||||
val request: ToolRequest,
|
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
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@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,
|
toolName = toolCall.function.name,
|
||||||
tier = tier,
|
tier = tier,
|
||||||
request = request,
|
request = request,
|
||||||
|
capabilities = tool?.requiredCapabilities ?: emptySet(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (toolCall.function.name != STAGE_COMPLETE_TOOL &&
|
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.events.types.SessionId
|
||||||
import com.correx.core.sessions.projections.Projection
|
import com.correx.core.sessions.projections.Projection
|
||||||
import com.correx.core.toolintent.rules.candidatePathStrings
|
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. */
|
/** Reads still awaiting their completion event, plus the paths confirmed read so far. */
|
||||||
data class ReadFilesState(
|
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`
|
* Folds one session's tool events into the set of file paths it has successfully read. A call counts
|
||||||
* counts only once its [ToolExecutionCompletedEvent] lands — a read that failed (file not found)
|
* as a read by its recorded [ToolCapability.FILE_READ] capability (not its tool name), and only once
|
||||||
* never enters the set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule].
|
* its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) never enters the
|
||||||
* Paths are kept as the raw arguments; the rule resolves both sides through the probe, so
|
* set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. Paths are kept as
|
||||||
* `./Foo.kt` read and `Foo.kt` written still match. The same projection precedent 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].
|
* [com.correx.core.sessions.projections.EgressAllowlistProjection].
|
||||||
*/
|
*/
|
||||||
class ReadFilesProjection(private val sessionId: SessionId) : Projection<ReadFilesState> {
|
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 {
|
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)
|
val paths = candidatePathStrings(emptyMap(), payload.request.parameters)
|
||||||
if (paths.isEmpty()) return state
|
if (paths.isEmpty()) return state
|
||||||
return state.copy(pending = state.pending + (payload.invocationId.value to paths))
|
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,
|
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.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.ToolInvocationId
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -38,6 +39,7 @@ class ReadFilesProjectionTest {
|
|||||||
toolName = "file_read",
|
toolName = "file_read",
|
||||||
tier = Tier.T1,
|
tier = Tier.T1,
|
||||||
request = ToolRequest(ToolInvocationId(invId), session, StageId("st"), "file_read", mapOf("path" to path)),
|
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