feat(context): inject CLAUDE.md / AGENTS.md as L0 standing context
On session start, discover CLAUDE.md and AGENTS.md at the bound workspace root and inject their (concatenated, header-labeled) contents as an L0 system context entry for every stage — mirroring the .correx/project.toml ProjectProfile path end to end. Recorded as AgentInstructionsBoundEvent (invariant #9) so replay reads the recorded fact, not the live file; folded into SessionState.boundAgentInstructions and rendered by buildAgentInstructionsEntry right after the project profile. AgentInstructionsLoader reads root-only, both files if present. Bound via ServerModule.bindAgentInstructions alongside bindProjectProfile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package com.correx.core.config
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Standing agent instructions discovered at the bound workspace root (`CLAUDE.md` and/or
|
||||
* `AGENTS.md`). Unlike the curated `.correx/project.toml` ProjectProfile, these are the
|
||||
* free-form markdown briefs the operator already maintains for coding agents: injected as
|
||||
* L0 for every stage of every session bound to the workspace.
|
||||
*/
|
||||
@Serializable
|
||||
data class AgentInstructions(
|
||||
val sources: List<String> = emptyList(),
|
||||
val content: String = "",
|
||||
) {
|
||||
fun isEmpty(): Boolean = sources.isEmpty() && content.isBlank()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.correx.core.config
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
/**
|
||||
* Discovers standing agent instructions (`CLAUDE.md`, `AGENTS.md`) at the bound workspace
|
||||
* root only — no parent walk, no nested lookup. Mirrors [ProjectProfileLoader]: the loaded
|
||||
* snapshot is bound as an event so replay reads the recorded fact, never the live file.
|
||||
*/
|
||||
object AgentInstructionsLoader {
|
||||
private val FILE_NAMES = listOf("CLAUDE.md", "AGENTS.md")
|
||||
|
||||
fun load(workspaceRoot: String): AgentInstructions {
|
||||
val sections = mutableListOf<String>()
|
||||
val sources = mutableListOf<String>()
|
||||
for (name in FILE_NAMES) {
|
||||
val contents = readIfPresent(Paths.get(workspaceRoot, name))
|
||||
if (contents == null || contents.isBlank()) continue
|
||||
sources.add(name)
|
||||
sections.add("# $name\n\n${contents.trim()}")
|
||||
}
|
||||
if (sections.isEmpty()) return AgentInstructions()
|
||||
return AgentInstructions(sources = sources, content = sections.joinToString("\n\n"))
|
||||
}
|
||||
|
||||
private fun readIfPresent(path: Path): String? {
|
||||
if (!Files.exists(path)) return null
|
||||
return runCatching { Files.readString(path) }.getOrElse { e ->
|
||||
System.err.println("Warning: Failed to read agent instructions at $path: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.correx.core.config
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
class AgentInstructionsLoaderTest {
|
||||
|
||||
@Test
|
||||
fun `both files present yields both sections and sources`(@TempDir root: Path) {
|
||||
Files.writeString(root.resolve("CLAUDE.md"), "Claude rules")
|
||||
Files.writeString(root.resolve("AGENTS.md"), "Agents rules")
|
||||
|
||||
val loaded = AgentInstructionsLoader.load(root.toString())
|
||||
|
||||
assertEquals(listOf("CLAUDE.md", "AGENTS.md"), loaded.sources)
|
||||
assertTrue(loaded.content.contains("# CLAUDE.md"), "content: ${loaded.content}")
|
||||
assertTrue(loaded.content.contains("Claude rules"), "content: ${loaded.content}")
|
||||
assertTrue(loaded.content.contains("# AGENTS.md"), "content: ${loaded.content}")
|
||||
assertTrue(loaded.content.contains("Agents rules"), "content: ${loaded.content}")
|
||||
assertFalse(loaded.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only one file present yields that one`(@TempDir root: Path) {
|
||||
Files.writeString(root.resolve("AGENTS.md"), "Agents only")
|
||||
|
||||
val loaded = AgentInstructionsLoader.load(root.toString())
|
||||
|
||||
assertEquals(listOf("AGENTS.md"), loaded.sources)
|
||||
assertTrue(loaded.content.contains("# AGENTS.md"), "content: ${loaded.content}")
|
||||
assertTrue(loaded.content.contains("Agents only"), "content: ${loaded.content}")
|
||||
assertFalse(loaded.content.contains("CLAUDE.md"), "content: ${loaded.content}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neither file present is empty`(@TempDir root: Path) {
|
||||
val loaded = AgentInstructionsLoader.load(root.toString())
|
||||
|
||||
assertTrue(loaded.isEmpty())
|
||||
assertEquals(AgentInstructions(), loaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank file is skipped`(@TempDir root: Path) {
|
||||
Files.writeString(root.resolve("CLAUDE.md"), " \n ")
|
||||
Files.writeString(root.resolve("AGENTS.md"), "Real content")
|
||||
|
||||
val loaded = AgentInstructionsLoader.load(root.toString())
|
||||
|
||||
assertEquals(listOf("AGENTS.md"), loaded.sources)
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,12 @@ data class ProjectProfileBoundEvent(
|
||||
val conventions: List<String>,
|
||||
val commands: Map<String, String>,
|
||||
) : EventPayload
|
||||
|
||||
@Serializable
|
||||
@SerialName("AgentInstructionsBound")
|
||||
data class AgentInstructionsBoundEvent(
|
||||
val sessionId: SessionId,
|
||||
val workspaceRoot: String,
|
||||
val sources: List<String>, // file names found, e.g. ["CLAUDE.md","AGENTS.md"]
|
||||
val content: String, // concatenated, header-labeled instructions
|
||||
) : EventPayload
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.AgentInstructionsBoundEvent
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantExpiredEvent
|
||||
@@ -126,6 +127,7 @@ val eventModule = SerializersModule {
|
||||
subclass(SessionWorkspaceBoundEvent::class)
|
||||
subclass(OperatorProfileBoundEvent::class)
|
||||
subclass(ProjectProfileBoundEvent::class)
|
||||
subclass(AgentInstructionsBoundEvent::class)
|
||||
subclass(L3MemoryRetrievedEvent::class)
|
||||
subclass(ContextTruncatedEvent::class)
|
||||
subclass(ExecutionPlanLockedEvent::class)
|
||||
|
||||
+15
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.AgentInstructionsBoundEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ProjectProfileBoundEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
@@ -24,6 +25,20 @@ class ProjectProfileBoundEventSerializationTest {
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AgentInstructionsBoundEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = AgentInstructionsBoundEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
workspaceRoot = "/repo",
|
||||
sources = listOf("CLAUDE.md", "AGENTS.md"),
|
||||
content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools",
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"AgentInstructionsBound\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips with empty fields`() {
|
||||
val sample: EventPayload = ProjectProfileBoundEvent(
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.sessions.BoundAgentInstructions
|
||||
import com.correx.core.sessions.BoundProjectProfile
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import java.util.UUID
|
||||
@@ -153,3 +154,17 @@ fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders bound CLAUDE.md / AGENTS.md instructions as a single L0 system entry. */
|
||||
fun buildAgentInstructionsEntry(instructions: BoundAgentInstructions): ContextEntry {
|
||||
val content = instructions.content
|
||||
return ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = content,
|
||||
sourceType = "agentInstructions",
|
||||
sourceId = "agent-instructions",
|
||||
tokenEstimate = content.length / 4,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
}
|
||||
|
||||
+4
-1
@@ -399,6 +399,8 @@ abstract class SessionOrchestrator(
|
||||
} ?: emptyList()
|
||||
val projectProfileEntries = session.state.boundProjectProfile
|
||||
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
|
||||
val agentInstructionsEntries = session.state.boundAgentInstructions
|
||||
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
|
||||
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
val vocabularyEntries = artifactKindRegistry
|
||||
@@ -411,7 +413,8 @@ abstract class SessionOrchestrator(
|
||||
.mapNotNull { it.payload as? StaticFindingsRecordedEvent }
|
||||
.flatMap { it.findings }
|
||||
var accumulatedEntries = excludeStaticFindingsFromReview(
|
||||
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
|
||||
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
journalEntries + repoMapEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
clarificationEntries + retryFeedbackEntries,
|
||||
recordedStaticFindings,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.sessions
|
||||
|
||||
/** Snapshot of standing agent instructions (CLAUDE.md / AGENTS.md) bound at session start. */
|
||||
data class BoundAgentInstructions(
|
||||
val sources: List<String>,
|
||||
val content: String,
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.sessions
|
||||
|
||||
import com.correx.core.events.events.AgentInstructionsBoundEvent
|
||||
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||
import com.correx.core.events.events.ProjectProfileBoundEvent
|
||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||
@@ -61,6 +62,14 @@ class DefaultSessionReducer : SessionReducer {
|
||||
else -> state.boundProjectProfile
|
||||
}
|
||||
|
||||
val boundAgentInstructions = when (payload) {
|
||||
is AgentInstructionsBoundEvent -> BoundAgentInstructions(
|
||||
sources = payload.sources,
|
||||
content = payload.content,
|
||||
)
|
||||
else -> state.boundAgentInstructions
|
||||
}
|
||||
|
||||
return state.copy(
|
||||
status = newStatus,
|
||||
createdAt = createdAt,
|
||||
@@ -68,6 +77,7 @@ class DefaultSessionReducer : SessionReducer {
|
||||
boundWorkspace = boundWorkspace,
|
||||
boundProfile = boundProfile,
|
||||
boundProjectProfile = boundProjectProfile,
|
||||
boundAgentInstructions = boundAgentInstructions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@ data class SessionState(
|
||||
val boundWorkspace: BoundWorkspace? = null,
|
||||
val boundProfile: BoundProfile? = null,
|
||||
val boundProjectProfile: BoundProjectProfile? = null,
|
||||
val boundAgentInstructions: BoundAgentInstructions? = null,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user