fix(context,tools): context hygiene + tool ergonomics from freestyle QA
Found while live-QAing freestyle_planning on a 12B local model: - list_dir tool: recursive, .gitignore-aware listing so weak models stop flooding context with `ls -R` over node_modules/build/dist. Wired into the fileRead toggle + advertised to the planner (architect_freestyle). - ContextClassifier: assistantToolCall turns are STRUCTURED, so the token pruner never shreds the model's own tool-call history — that was causing amnesia loops (re-issuing calls it had already made). - Retire instruction-doc LLMLingua pruning (DOC_SOURCE_TYPES emptied): it fused load-bearing procedural text into unparseable soup. The static block stays small by dropping CLAUDE.md at the loader instead. - AgentInstructionsLoader: load only AGENTS.md, not CLAUDE.md — the latter targets the outer assistant and polluted the agent's stage context. - DefaultSessionReducer: WorkflowFailed flips session status to FAILED (was stuck ACTIVE forever, so clients/approval loops never saw a terminal). - ShellTool: run shell command lines (cd/&&/pipes) via `sh -c`; unrunnable program is recoverable instead of an uncaught IOException killing the stage; malformed argv (non-string/collapsed-array) rejected with guidance. - llmlingua sidecar: cap force_tokens to max_force_token (big docs blew the assert and 500'd, so doc pruning silently failed open). Tests added/updated across all of the above.
This commit is contained in:
@@ -5,12 +5,17 @@ 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.
|
||||
* Discovers standing agent instructions (`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.
|
||||
*
|
||||
* CLAUDE.md is deliberately excluded: it targets the outer assistant (agent-dispatch / model-
|
||||
* selection guidance) and is noise to an in-workflow execution agent — injecting it bloated and
|
||||
* polluted the stage context. AGENTS.md holds the correx-agent operating contract (the numbered
|
||||
* how-to-finish-a-stage steps), which is what the agent actually needs.
|
||||
*/
|
||||
object AgentInstructionsLoader {
|
||||
private val FILE_NAMES = listOf("CLAUDE.md", "AGENTS.md")
|
||||
private val FILE_NAMES = listOf("AGENTS.md")
|
||||
|
||||
fun load(workspaceRoot: String): AgentInstructions {
|
||||
val sections = mutableListOf<String>()
|
||||
|
||||
@@ -11,17 +11,18 @@ import java.nio.file.Path
|
||||
class AgentInstructionsLoaderTest {
|
||||
|
||||
@Test
|
||||
fun `both files present yields both sections and sources`(@TempDir root: Path) {
|
||||
fun `AGENTS is loaded and CLAUDE is ignored`(@TempDir root: Path) {
|
||||
// CLAUDE.md is deliberately not an agent-instruction source: it targets the outer assistant
|
||||
// and polluted the stage context. Only AGENTS.md (the agent operating contract) is loaded.
|
||||
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}")
|
||||
assertEquals(listOf("AGENTS.md"), loaded.sources)
|
||||
assertTrue(loaded.content.contains("# AGENTS.md"), "content: ${loaded.content}")
|
||||
assertTrue(loaded.content.contains("Agents rules"), "content: ${loaded.content}")
|
||||
assertFalse(loaded.content.contains("Claude rules"), "content: ${loaded.content}")
|
||||
assertFalse(loaded.isEmpty())
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -54,10 +54,13 @@ class DefaultContextPackBuilder(
|
||||
const val DOC_PRUNE_RATIO = 0.6
|
||||
const val DOC_MIN_CHARS = 1200
|
||||
const val TIER0_TURNS = 3
|
||||
// Large static instruction docs (CLAUDE.md project profile, AGENTS.md / DOX framework)
|
||||
// injected verbatim every turn. Pruned at DOC_PRUNE_RATIO — gentler than freeform, with
|
||||
// protected spans (code fences, paths, config keys) kept, so directives survive.
|
||||
val DOC_SOURCE_TYPES = setOf("projectProfile", "agentInstructions")
|
||||
// Instruction-doc LLMLingua pruning is retired: it fused load-bearing procedural text
|
||||
// (the numbered stage_complete / emit_artifact steps in AGENTS.md, the curated
|
||||
// project.toml profile) into unparseable soup, and the agent could no longer tell how to
|
||||
// finish a stage. The static block is instead kept small by DROPPING CLAUDE.md at the
|
||||
// loader — not by garbling what remains. Left empty (not deleted) so the branch is a
|
||||
// no-op guard rather than dead-code churn; freeform conversation pruning is unaffected.
|
||||
val DOC_SOURCE_TYPES = emptySet<String>()
|
||||
}
|
||||
|
||||
override suspend fun build(
|
||||
|
||||
+8
-1
@@ -26,6 +26,13 @@ class ContextClassifier {
|
||||
|
||||
private companion object {
|
||||
val STATIC_SOURCES = setOf("systemPrompt", "toolSchema", "fewShot")
|
||||
val STRUCTURED_SOURCES = setOf("toolLog", "artifact", "config", "structured", "steeringNote")
|
||||
// "assistantToolCall" carries the model's own tool-call JSON (id + function + args). It is
|
||||
// load-bearing structured history: token-pruning it (it was FREEFORM by role=ASSISTANT)
|
||||
// shredded the JSON to a bare id + protected path, giving the model amnesia about what it
|
||||
// had already done → it re-issued the same call and looped. Structured = format-compress
|
||||
// ok, never pruned.
|
||||
val STRUCTURED_SOURCES = setOf(
|
||||
"toolLog", "artifact", "config", "structured", "steeringNote", "assistantToolCall",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,24 +138,29 @@ class CompressionPipelineStagesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large static doc entries are pruned at level 3 while base system prompt is untouched`() = kotlinx.coroutines.runBlocking {
|
||||
fun `static instruction docs are never token-pruned`() = kotlinx.coroutines.runBlocking {
|
||||
// Instruction-doc pruning is retired: curated/procedural docs (project profile, AGENTS.md
|
||||
// how-to) must reach the model verbatim — token-pruning fused them into unparseable soup.
|
||||
val pruner = object : com.correx.core.context.compression.TokenPruner {
|
||||
override suspend fun prune(content: String, protectedSpans: List<String>, targetRatio: Double): String = "DOC-PRUNED"
|
||||
}
|
||||
val builder = com.correx.core.context.builder.DefaultContextPackBuilder(
|
||||
com.correx.core.context.compression.DefaultContextCompressor(), CompressionPolicy(3), tokenPruner = pruner,
|
||||
)
|
||||
val bigDoc = "# CLAUDE.md\n" + "guidance line ".repeat(120) // > DOC_MIN_CHARS
|
||||
val bigProfile = "# Project profile\n" + "convention line ".repeat(120) // > old DOC_MIN_CHARS
|
||||
val bigInstructions = "# AGENTS.md\n" + "5 stage_complete when done. ".repeat(80)
|
||||
val entries = listOf(
|
||||
entry("proj", ContextLayer.L0, EntryRole.SYSTEM, bigDoc).copy(sourceType = "projectProfile"),
|
||||
entry("proj", ContextLayer.L0, EntryRole.SYSTEM, bigProfile).copy(sourceType = "projectProfile"),
|
||||
entry("sys", ContextLayer.L0, EntryRole.SYSTEM, "you are an agent").copy(sourceType = "systemPrompt"),
|
||||
entry("agi", ContextLayer.L0, EntryRole.SYSTEM, bigInstructions).copy(sourceType = "agentInstructions"),
|
||||
)
|
||||
val flat = builder.build(
|
||||
com.correx.core.events.types.ContextPackId("p"), com.correx.core.events.types.SessionId("s"),
|
||||
com.correx.core.events.types.StageId("st"), entries, com.correx.core.context.model.TokenBudget(limit = 4000),
|
||||
).layers.values.flatten()
|
||||
assertEquals("DOC-PRUNED", flat.first { it.sourceType == "projectProfile" }.content)
|
||||
assertEquals(bigProfile, flat.first { it.sourceType == "projectProfile" }.content)
|
||||
assertEquals("you are an agent", flat.first { it.sourceType == "systemPrompt" }.content)
|
||||
assertEquals(bigInstructions, flat.first { it.sourceType == "agentInstructions" }.content)
|
||||
Unit
|
||||
}
|
||||
|
||||
@@ -165,6 +170,8 @@ class CompressionPipelineStagesTest {
|
||||
assertEquals(ContextClass.STATIC, c.classify(entry("systemPrompt", ContextLayer.L0, EntryRole.SYSTEM)))
|
||||
assertEquals(ContextClass.STRUCTURED, c.classify(entry("toolLog", ContextLayer.L2, EntryRole.TOOL)))
|
||||
assertEquals(ContextClass.STRUCTURED, c.classify(entry("steeringNote", ContextLayer.L2, EntryRole.SYSTEM)))
|
||||
// Assistant tool-call turns are structured history, never token-pruned (amnesia-loop fix).
|
||||
assertEquals(ContextClass.STRUCTURED, c.classify(entry("assistantToolCall", ContextLayer.L2, EntryRole.ASSISTANT)))
|
||||
assertEquals(ContextClass.FREEFORM, c.classify(entry("chat", ContextLayer.L1, EntryRole.USER)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
|
||||
class DefaultSessionReducer : SessionReducer {
|
||||
|
||||
@@ -22,6 +23,14 @@ class DefaultSessionReducer : SessionReducer {
|
||||
is StageFailedEvent ->
|
||||
SessionStatus.FAILED
|
||||
|
||||
// A failed workflow is terminal for the session: no current workflow recovers after
|
||||
// one fails (freestyle's planning→execution handoff only fires on WorkflowCompleted).
|
||||
// Without this, an exhausted-retry WorkflowFailed left the session ACTIVE forever, so
|
||||
// clients/approval loops never saw a terminal state. WorkflowCompleted is deliberately
|
||||
// NOT mapped here — planning emits its own mid-session, so it isn't session-terminal.
|
||||
is WorkflowFailedEvent ->
|
||||
SessionStatus.FAILED
|
||||
|
||||
is StageCompletedEvent,
|
||||
is TransitionExecutedEvent ->
|
||||
SessionStatus.ACTIVE
|
||||
|
||||
@@ -57,6 +57,19 @@ class DefaultSessionReducerTest {
|
||||
assertNull(initialState.boundProfile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowFailedEvent flips session status to FAILED`() {
|
||||
val event = stored(
|
||||
com.correx.core.events.events.WorkflowFailedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = com.correx.core.events.types.StageId("define_styling_tokens"),
|
||||
reason = "did not produce declared artifacts",
|
||||
retryExhausted = true,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionStatus.FAILED, reducer.reduce(initialState, event).status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated event leaves boundProfile unchanged`() {
|
||||
val state = initialState.copy(
|
||||
|
||||
Reference in New Issue
Block a user