Implement closed-loop workspace follow-ups
This commit is contained in:
@@ -15,6 +15,7 @@ Adapter for LLM inference backends. Implements `core:inference` interfaces. No d
|
||||
- All LLM responses are proposals — they must be validated by the core before affecting state (invariant #7). Adapters return raw responses; they do not validate.
|
||||
- Network calls to inference backends are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9).
|
||||
- Model lifecycle (spawn/own the llama-server process) lives in `llama_cpp/`; the autonomous scheduler is intentionally unbuilt (see root CLAUDE.md).
|
||||
- Model descriptors default to a 24,576-token context window, matching the implementation-stage budget; operators may lower `[[models]].context_size` for constrained hardware.
|
||||
- `openai_compat/` is fully remote (no local process/GPU). It speaks `POST {baseUrl}/chat/completions` with `Authorization: Bearer`; `baseUrl` must include the version segment (e.g. `/v1`). It has no `/tokenize`, so it uses a heuristic tokenizer, and no GBNF — JSON artifacts rely on the core's validate-after-retry. Server dispatch keys `[[providers]] type = "nim" | "openai"`; the key comes from `api_key` or `api_key_env`.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@ data class ModelDescriptor(
|
||||
val modelPath: String,
|
||||
val residencyMode: ResidencyMode,
|
||||
val idleTimeoutMs: Long = 60_000L,
|
||||
val contextSize: Int = 8192,
|
||||
val contextSize: Int = 24_576,
|
||||
val capabilities: Set<CapabilityScore> = emptySet(),
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ Adapter for `core:tools`. Depends on `core:tools`, `core:events`, `core:approval
|
||||
- `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`.
|
||||
- `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly.
|
||||
- Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`.
|
||||
- `list_dir` is shallow by default, but collapses a non-symlink single-child directory chain (bounded depth) to the first branch point and explains that expansion in its output; recursive listings retain normal tree traversal.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
||||
+39
-2
@@ -127,6 +127,33 @@ class ListDirTool(
|
||||
}
|
||||
|
||||
private fun walk(root: Path, recursive: Boolean, request: ToolRequest): ToolResult {
|
||||
var listedRoot = root
|
||||
var listing = collect(listedRoot, recursive)
|
||||
val descended = ArrayList<String>()
|
||||
// Do not follow symlinks: a symlink chain can point back to an ancestor outside the walk's
|
||||
// normal cycle protections. A real directory chain is capped as a second guard against a
|
||||
// pathological generated tree.
|
||||
while (!recursive && descended.size < MAX_AUTO_DESCENT_DEPTH) {
|
||||
val only = listing.entries.singleOrNull() ?: break
|
||||
val child = listedRoot.resolve(only.removeSuffix("/"))
|
||||
if (!only.endsWith('/') || Files.isSymbolicLink(child) || !Files.isDirectory(child)) break
|
||||
descended += only.removeSuffix("/")
|
||||
listedRoot = child
|
||||
listing = collect(listedRoot, recursive = false)
|
||||
}
|
||||
return ToolResult.Success(
|
||||
request.invocationId,
|
||||
output = render(listedRoot, listing.entries, listing.truncated, listing.ignoredCount, descended),
|
||||
)
|
||||
}
|
||||
|
||||
private data class Listing(
|
||||
val entries: List<String>,
|
||||
val truncated: Boolean,
|
||||
val ignoredCount: Int,
|
||||
)
|
||||
|
||||
private fun collect(root: Path, recursive: Boolean): Listing {
|
||||
val matcher = GitignoreMatcher(workingDir ?: root, root)
|
||||
// The caller explicitly targeted `root` — if a rule already covers it (e.g. an entire
|
||||
// subtree gitignored as scaffold output), don't let that same rule keep pruning everything
|
||||
@@ -166,7 +193,7 @@ class ListDirTool(
|
||||
override fun visitFileFailed(file: Path, exc: java.io.IOException) = FileVisitResult.CONTINUE
|
||||
})
|
||||
out.sort()
|
||||
return ToolResult.Success(request.invocationId, output = render(root, out, truncated, ignoredCount))
|
||||
return Listing(out, truncated, ignoredCount)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,8 +203,17 @@ class ListDirTool(
|
||||
* gitignore-pruning are surfaced here too, so a shorter-than-expected listing isn't mistaken for
|
||||
* the real tree.
|
||||
*/
|
||||
private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String {
|
||||
private fun render(
|
||||
root: Path,
|
||||
entries: List<String>,
|
||||
truncated: Boolean,
|
||||
ignoredCount: Int,
|
||||
descended: List<String>,
|
||||
): String {
|
||||
val notes = buildList {
|
||||
if (descended.isNotEmpty()) {
|
||||
add("auto-descended ${descended.joinToString(" → ")} (single child at each level); listing shown is $root")
|
||||
}
|
||||
if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
|
||||
if (ignoredCount > 0) {
|
||||
add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
|
||||
@@ -240,6 +276,7 @@ class ListDirTool(
|
||||
|
||||
private companion object {
|
||||
const val MAX_ENTRIES = 400
|
||||
const val MAX_AUTO_DESCENT_DEPTH = 32
|
||||
const val MAX_SIMILAR_SUGGESTIONS = 3
|
||||
const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2
|
||||
const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3
|
||||
|
||||
+19
-1
@@ -49,9 +49,10 @@ class ListDirToolTest {
|
||||
fun `default is shallow — a bare list_dir does not descend`(): Unit = runBlocking {
|
||||
// Regression: a recursive default buried top-level answers ("does frontend/ exist?") under a
|
||||
// 400-entry alphabetical flood and drove models to re-issue the same list_dir 2-3x. A bare
|
||||
// call (no recursive param) must now list only immediate children.
|
||||
// call (no recursive param) must not descend a directory that already has a meaningful choice.
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") }
|
||||
Files.writeString(root.resolve("README.md"), "root file")
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request()) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/"), out)
|
||||
@@ -75,9 +76,26 @@ class ListDirToolTest {
|
||||
fun `non-recursive lists only immediate children`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") }
|
||||
Files.writeString(root.resolve("README.md"), "root file")
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/"), out)
|
||||
assertFalse(out.contains("deep"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-recursive listing auto-descends a single-child directory chain`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("core/kernel/src/main"))
|
||||
Files.writeString(root.resolve("core/kernel/src/main/App.kt"), "class App")
|
||||
Files.createDirectories(root.resolve("core/kernel/src/main/resources"))
|
||||
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output
|
||||
|
||||
assertTrue(out.contains("<path>${root.resolve("core/kernel/src/main")}</path>"), out)
|
||||
assertTrue(out.contains("App.kt"), out)
|
||||
assertTrue(out.contains("resources/"), out)
|
||||
assertTrue(out.contains("auto-descended core → kernel → src → main"), out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ Adapter for `core:transitions` and `core:inference` workflow interfaces. Depends
|
||||
- `ExecutionPlanCompiler` converts the parsed `ExecutionPlanModel` into a runnable plan for `core:transitions`; compilation is deterministic given the same input model.
|
||||
- `PlanLinter` enforces structural rules (e.g. unreachable stages are rejected); linting failures throw `WorkflowValidationException`.
|
||||
- `PlanDerivedManifest` exposes the write manifest derived from a plan.
|
||||
- For a plan with no explicit build expectation, `ExecutionPlanCompiler` marks every write-declaring stage for the runtime auto build gate; plans with no declared writes are not represented as compiler-verified.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
||||
+7
-4
@@ -55,7 +55,7 @@ private const val RECOVERY_PROMPT =
|
||||
// round, evicting the file_read results the model just gathered, so it re-reads forever and never
|
||||
// accumulates enough to write. Match the static execution baseline. (gemma ctx is 32768, so this
|
||||
// leaves ample headroom for completion.)
|
||||
private const val DEFAULT_STAGE_TOKEN_BUDGET = 16384
|
||||
private const val DEFAULT_STAGE_TOKEN_BUDGET = 24576
|
||||
|
||||
// A compiled freestyle stage must also lift its inference completion cap off StageConfig's 2048
|
||||
// default, or the model is truncated (finishReason=length) mid-artifact — and a degenerating local
|
||||
@@ -114,8 +114,11 @@ class ExecutionPlanCompiler(
|
||||
// ACTUALLY builds is decided at run time (SessionOrchestrator.runExecutionGate) from the real
|
||||
// FileWritten manifest — the plan's declared kinds don't reveal code-ness, but the written
|
||||
// paths do — so a docs-only plan is left alone and a code plan is always gated.
|
||||
val autoGateStageId: String? =
|
||||
if (declaredExpectations.values.all { it == BuildExpectation.NONE }) terminalStageId(plan) else null
|
||||
val autoGateStages: Set<String> = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) {
|
||||
plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
val stageMap = plan.stages.associate { s ->
|
||||
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
||||
@@ -149,7 +152,7 @@ class ExecutionPlanCompiler(
|
||||
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
|
||||
touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() },
|
||||
buildExpectation = declaredExpectations.getValue(s.id),
|
||||
autoBuildGate = s.id == autoGateStageId,
|
||||
autoBuildGate = s.id in autoGateStages,
|
||||
semanticReview = s.semanticReview,
|
||||
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
||||
generationConfig = defaultStageGeneration,
|
||||
|
||||
+12
-12
@@ -470,9 +470,9 @@ class ExecutionPlanCompilerTest {
|
||||
"goal": "scaffold a react app",
|
||||
"stages": [
|
||||
{ "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry",
|
||||
"needs": [], "tools": ["file_write"] },
|
||||
"needs": [], "tools": ["file_write"], "writes": ["src/main.tsx"] },
|
||||
{ "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component",
|
||||
"needs": ["app_entry"], "tools": ["file_write"] }
|
||||
"needs": ["app_entry"], "tools": ["file_write"], "writes": ["src/App.tsx"] }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } },
|
||||
@@ -482,12 +482,12 @@ class ExecutionPlanCompilerTest {
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `terminal stage of an ungated plan is flagged for an auto build gate`() {
|
||||
fun `every write-declaring stage of an ungated plan is flagged for an auto build gate`() {
|
||||
val graph = codeCompiler.compile(codePlanNoGate, "wf")
|
||||
val views = graph.stages[StageId("views")]!!
|
||||
assertTrue(
|
||||
views.autoBuildGate,
|
||||
"terminal stage of an ungated plan must be flagged for an auto build gate",
|
||||
"a write-declaring stage of an ungated plan must be flagged for an auto build gate",
|
||||
)
|
||||
assertEquals(
|
||||
com.correx.core.transitions.graph.BuildExpectation.NONE,
|
||||
@@ -496,8 +496,8 @@ class ExecutionPlanCompilerTest {
|
||||
"code module was actually written",
|
||||
)
|
||||
assertTrue(
|
||||
!graph.stages[StageId("entry")]!!.autoBuildGate,
|
||||
"intermediate stages are not flagged — the gate runs when the project is whole, not per-stage",
|
||||
graph.stages[StageId("entry")]!!.autoBuildGate,
|
||||
"implementation stages must not defer compiler coverage to a terminal verifier",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -531,15 +531,15 @@ class ExecutionPlanCompilerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly the terminal stage of an ungated plan is auto-flagged`() {
|
||||
// The compiler flags the terminal stage regardless of the produced kinds — code-ness is a
|
||||
// run-time decision (SessionOrchestrator reads the FileWritten manifest), so a non-code plan
|
||||
// is flagged too but the runtime build gate then skips it.
|
||||
fun `plan without declared writes has no auto build gate`() {
|
||||
// A plan with no write declarations cannot promise compiler coverage. Runtime still
|
||||
// inspects actual files for an opted-in gate, but the compiler must not pretend this
|
||||
// documentation-shaped plan has implementation-stage verification.
|
||||
val graph = compiler.compile(validPlan, "wf")
|
||||
assertEquals(
|
||||
1,
|
||||
0,
|
||||
graph.stages.values.count { it.autoBuildGate },
|
||||
"exactly the single terminal stage of an ungated plan is flagged for the auto build gate",
|
||||
"only write-declaring stages receive an auto build gate",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user