refactor(toolintent): one SessionContext read-model for the gates
Replace the single-purpose sessionReadPaths plumbing with a SessionContext
{reads, writes, activeTask} folded by SessionContextProjection over the session
stream — the read-model every anti-hallucination gate consumes (plane-2 rules via
ToolCallAssessmentInput.session; tool gates via a port, next).
- reads: completions whose recorded capability includes FILE_READ.
- writes: the resolved paths the orchestrator already records on each completion's
receipt.affectedEntities (FileAffectingTool) — no param-parsing reinvention.
- activeTask: null for now; populated once claim records a session fact.
ReadBeforeWriteRule now reads input.session.reads; ReadFilesProjection is retired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+7
-8
@@ -69,7 +69,7 @@ import com.correx.core.events.events.ToolReceipt
|
|||||||
import com.correx.core.events.events.ToolRequest
|
import com.correx.core.events.events.ToolRequest
|
||||||
import com.correx.core.events.risk.RiskAction
|
import com.correx.core.events.risk.RiskAction
|
||||||
import com.correx.core.events.risk.RiskSummary
|
import com.correx.core.events.risk.RiskSummary
|
||||||
import com.correx.core.toolintent.ReadFilesProjection
|
import com.correx.core.toolintent.SessionContextProjection
|
||||||
import com.correx.core.toolintent.ToolCallAssessmentInput
|
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||||
import com.correx.core.toolintent.ToolCallAssessor
|
import com.correx.core.toolintent.ToolCallAssessor
|
||||||
import com.correx.core.toolintent.WorkspacePolicy
|
import com.correx.core.toolintent.WorkspacePolicy
|
||||||
@@ -970,7 +970,7 @@ abstract class SessionOrchestrator(
|
|||||||
paramRoles = tool?.paramRoles ?: emptyMap(),
|
paramRoles = tool?.paramRoles ?: emptyMap(),
|
||||||
writeManifest = writeManifest,
|
writeManifest = writeManifest,
|
||||||
sessionEgressHosts = sessionEgressHosts,
|
sessionEgressHosts = sessionEgressHosts,
|
||||||
sessionReadPaths = resolveSessionReadPaths(sessionId),
|
session = resolveSessionContext(sessionId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
emit(
|
emit(
|
||||||
@@ -1001,15 +1001,14 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Folds this session's file_read tool events through [ReadFilesProjection] into the set of paths
|
* Folds this session's tool events through [SessionContextProjection] into the read-model the
|
||||||
* it has successfully read — the input to [com.correx.core.toolintent.rules.ReadBeforeWriteRule].
|
* anti-hallucination gates consume (reads, writes, active task). Replay-safe (reads the log,
|
||||||
* Replay-safe (reads the log, never live state); empty before any read completes.
|
* never live state); empty before anything has happened.
|
||||||
*/
|
*/
|
||||||
internal fun resolveSessionReadPaths(sessionId: SessionId): Set<String> {
|
internal fun resolveSessionContext(sessionId: SessionId): com.correx.core.toolintent.SessionContext {
|
||||||
val projection = ReadFilesProjection(sessionId)
|
val projection = SessionContextProjection(sessionId)
|
||||||
return eventStore.read(sessionId)
|
return eventStore.read(sessionId)
|
||||||
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
|
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
|
||||||
.readPaths
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildSchemaEntries(
|
private suspend fun buildSchemaEntries(
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package com.correx.core.toolintent
|
|
||||||
|
|
||||||
import com.correx.core.events.events.StoredEvent
|
|
||||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
|
||||||
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(
|
|
||||||
val pending: Map<String, List<String>> = emptyMap(),
|
|
||||||
val readPaths: Set<String> = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
|
||||||
|
|
||||||
override fun initial(): ReadFilesState = ReadFilesState()
|
|
||||||
|
|
||||||
override fun apply(state: ReadFilesState, event: StoredEvent): ReadFilesState =
|
|
||||||
when (val payload = event.payload) {
|
|
||||||
is ToolInvocationRequestedEvent -> recordPending(state, payload)
|
|
||||||
is ToolExecutionCompletedEvent -> confirmRead(state, payload)
|
|
||||||
else -> state
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recordPending(state: ReadFilesState, payload: ToolInvocationRequestedEvent): ReadFilesState {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun confirmRead(state: ReadFilesState, payload: ToolExecutionCompletedEvent): ReadFilesState {
|
|
||||||
val paths = state.pending[payload.invocationId.value]
|
|
||||||
if (payload.sessionId != sessionId || paths == null) return state
|
|
||||||
return state.copy(
|
|
||||||
pending = state.pending - payload.invocationId.value,
|
|
||||||
readPaths = state.readPaths + paths,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a session has done to the workspace this run, plus the task it is working — the single
|
||||||
|
* read-model every anti-hallucination gate consumes (plane-2 rules via the threaded
|
||||||
|
* [ToolCallAssessmentInput.session]; tool gates via a provider port). [pendingReads] is
|
||||||
|
* fold-internal (reads awaiting their completion event) and not meant for consumers.
|
||||||
|
*/
|
||||||
|
data class SessionContext(
|
||||||
|
val reads: Set<String> = emptySet(),
|
||||||
|
val writes: Set<String> = emptySet(),
|
||||||
|
val activeTask: ActiveTask? = null,
|
||||||
|
val pendingReads: Map<String, List<String>> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The task this session has claimed and is working, with its declared write scope. */
|
||||||
|
data class ActiveTask(val taskId: String, val scope: List<String>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds one session's tool events into a [SessionContext]:
|
||||||
|
* - **reads** — completed invocations whose recorded capability includes FILE_READ (path from the
|
||||||
|
* request args; a read that failed never completes, so it never counts).
|
||||||
|
* - **writes** — the resolved paths the orchestrator already records on each completion's
|
||||||
|
* `receipt.affectedEntities` (via FileAffectingTool), so writes need no param-parsing here.
|
||||||
|
* - **activeTask** — set when the session claims a task; null until a SessionWorkingTaskEvent lands.
|
||||||
|
*/
|
||||||
|
class SessionContextProjection(private val sessionId: SessionId) : Projection<SessionContext> {
|
||||||
|
|
||||||
|
override fun initial(): SessionContext = SessionContext()
|
||||||
|
|
||||||
|
override fun apply(state: SessionContext, event: StoredEvent): SessionContext =
|
||||||
|
when (val payload = event.payload) {
|
||||||
|
is ToolInvocationRequestedEvent -> recordPendingRead(state, payload)
|
||||||
|
is ToolExecutionCompletedEvent -> recordCompletion(state, payload)
|
||||||
|
else -> state
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordPendingRead(state: SessionContext, payload: ToolInvocationRequestedEvent): SessionContext {
|
||||||
|
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(pendingReads = state.pendingReads + (payload.invocationId.value to paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordCompletion(state: SessionContext, payload: ToolExecutionCompletedEvent): SessionContext {
|
||||||
|
if (payload.sessionId != sessionId) return state
|
||||||
|
val justRead = state.pendingReads[payload.invocationId.value]
|
||||||
|
return state.copy(
|
||||||
|
reads = if (justRead != null) state.reads + justRead else state.reads,
|
||||||
|
writes = state.writes + payload.receipt.affectedEntities,
|
||||||
|
pendingReads = state.pendingReads - payload.invocationId.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,10 +29,10 @@ data class ToolCallAssessmentInput(
|
|||||||
// EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty
|
// EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty
|
||||||
// means "no per-session grants", which never narrows the static allow-list.
|
// means "no per-session grants", which never narrows the static allow-list.
|
||||||
val sessionEgressHosts: Set<String> = emptySet(),
|
val sessionEgressHosts: Set<String> = emptySet(),
|
||||||
// Paths this session has successfully file_read (folded via ReadFilesProjection). The input to
|
// What this session has done this run (reads, writes) and the task it's working — folded via
|
||||||
// ReadBeforeWriteRule; empty means nothing has been read yet (so any write to an existing file
|
// SessionContextProjection. The input to the anti-hallucination rules (read-before-write reads
|
||||||
// is unread). Raw argument strings — the rule resolves both sides through the probe.
|
// .reads, write-scope reads .activeTask). Empty by default so unrelated rules ignore it.
|
||||||
val sessionReadPaths: Set<String> = emptySet(),
|
val session: SessionContext = SessionContext(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ToolCallAssessment(
|
data class ToolCallAssessment(
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ class ReadBeforeWriteRule : ToolCallRule {
|
|||||||
|
|
||||||
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||||
val root = input.workspace.workspaceRoot
|
val root = input.workspace.workspaceRoot
|
||||||
val readReal = input.sessionReadPaths.map { realOf(input, root, it) }.toSet()
|
val readReal = input.session.reads.map { realOf(input, root, it) }.toSet()
|
||||||
|
|
||||||
val issues = mutableListOf<ValidationIssue>()
|
val issues = mutableListOf<ValidationIssue>()
|
||||||
val observations = mutableListOf<ToolCallObservation>()
|
val observations = mutableListOf<ToolCallObservation>()
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ class ReadBeforeWriteRuleTest {
|
|||||||
capabilities = setOf(ToolCapability.FILE_WRITE),
|
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||||
workspace = WorkspacePolicy(workspace, emptyList()),
|
workspace = WorkspacePolicy(workspace, emptyList()),
|
||||||
probe = probe,
|
probe = probe,
|
||||||
sessionReadPaths = read,
|
session = SessionContext(reads = read),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+14
-20
@@ -18,14 +18,14 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class ReadFilesProjectionTest {
|
class SessionContextProjectionTest {
|
||||||
|
|
||||||
private val session = SessionId("s")
|
private val session = SessionId("s")
|
||||||
private val projection = ReadFilesProjection(session)
|
private val projection = SessionContextProjection(session)
|
||||||
|
|
||||||
private var seq = 0L
|
private var seq = 0L
|
||||||
private fun stored(payload: EventPayload) = StoredEvent(
|
private fun stored(payload: EventPayload) = StoredEvent(
|
||||||
metadata = EventMetadata(EventId("e-${seq}"), session, Instant.parse("2026-01-01T00:00:00Z"), 1, null, null),
|
metadata = EventMetadata(EventId("e-$seq"), session, Instant.parse("2026-01-01T00:00:00Z"), 1, null, null),
|
||||||
sequence = ++seq,
|
sequence = ++seq,
|
||||||
sessionSequence = seq,
|
sessionSequence = seq,
|
||||||
payload = payload,
|
payload = payload,
|
||||||
@@ -43,16 +43,17 @@ class ReadFilesProjectionTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun completed(invId: String, tool: String = "file_read") = stored(
|
private fun completed(invId: String, affected: List<String> = emptyList()) = stored(
|
||||||
ToolExecutionCompletedEvent(
|
ToolExecutionCompletedEvent(
|
||||||
invocationId = ToolInvocationId(invId),
|
invocationId = ToolInvocationId(invId),
|
||||||
sessionId = session,
|
sessionId = session,
|
||||||
toolName = tool,
|
toolName = "tool",
|
||||||
receipt = ToolReceipt(
|
receipt = ToolReceipt(
|
||||||
invocationId = ToolInvocationId(invId),
|
invocationId = ToolInvocationId(invId),
|
||||||
toolName = tool,
|
toolName = "tool",
|
||||||
exitCode = 0,
|
exitCode = 0,
|
||||||
outputSummary = "ok",
|
outputSummary = "ok",
|
||||||
|
affectedEntities = affected,
|
||||||
durationMs = 1,
|
durationMs = 1,
|
||||||
tier = Tier.T1,
|
tier = Tier.T1,
|
||||||
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
@@ -64,22 +65,15 @@ class ReadFilesProjectionTest {
|
|||||||
events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) }
|
events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a completed file_read adds the path to the read set`() {
|
fun `a completed read enters reads but a pending one does not`() {
|
||||||
val state = fold(listOf(readRequested("r1", "src/A.kt"), completed("r1")))
|
assertEquals(setOf("src/A.kt"), fold(listOf(readRequested("r1", "src/A.kt"), completed("r1"))).reads)
|
||||||
assertEquals(setOf("src/A.kt"), state.readPaths)
|
assertTrue(fold(listOf(readRequested("r1", "src/A.kt"))).reads.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a read still pending its completion does not count`() {
|
fun `writes come from the completion's affectedEntities`() {
|
||||||
val state = fold(listOf(readRequested("r1", "src/A.kt")))
|
val ctx = fold(listOf(completed("w1", affected = listOf("core/A.kt", "core/B.kt"))))
|
||||||
assertTrue(state.readPaths.isEmpty())
|
assertEquals(setOf("core/A.kt", "core/B.kt"), ctx.writes)
|
||||||
assertTrue(state.pending.containsKey("r1"))
|
assertTrue(ctx.reads.isEmpty())
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a non-read tool completion is ignored`() {
|
|
||||||
// file_write request never enters pending, so its completion adds nothing.
|
|
||||||
val state = fold(listOf(completed("w1", tool = "file_write")))
|
|
||||||
assertTrue(state.readPaths.isEmpty())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user