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:
2026-07-02 00:44:04 +04:00
parent cb4e41a59f
commit 968cbfa973
15 changed files with 482 additions and 55 deletions
@@ -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())
}
@@ -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(
@@ -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(