Tool results injected into model context are now consistently framed and
globally bounded. Success output over the floor (TOOL_RESULT_MAX_CHARS=8000)
is head/tail-truncated with a marker naming a retrieval ref; the full raw
output spills to the artifact store (CAS) and its hash is recorded on the
ToolReceipt (fullOutputHash) in the event log. Agents recover the full text
via the new read-only tool_output(ref=...) tool.
- SessionOrchestrator: frameTruncatedToolResult + renderToolResult; char-cap
head/tail so a single pathological long line can't defeat the bound.
- ToolReceipt.fullOutputHash (additive, nullable).
- ToolOutputTool (Tier T1, no fs capability) resolves ref -> full bytes.
- Wired via extraTools in Main.kt; tool_output added to ALWAYS_AVAILABLE_READ_TOOLS.
- Failure path keeps ERROR:/FATAL: prefixes (all-rejected breaker dependency).
Tests: FrameTruncatedToolResultTest, ToolOutputToolTest.
The kernel's ReAct-loop tuning constants (max tool rounds, read/rejection-loop
nudge thresholds, feedback issue cap, repo-map top-k/files-per-dir, docs catalog
cap, clarification-round cap, review-block confidence/retry cap, default
refinement, recovery/intent route budgets) were hardcoded in SessionOrchestrator/
DefaultSessionOrchestrator. Relocated to a new OrchestrationTuning value threaded
through the orchestrator constructor, mapped from CorrexConfig.orchestration in
Main.kt, parsed in ConfigLoader, written by CorrexConfigWriter. Defaults equal the
former constants so an absent [orchestration] section reproduces prior behavior.
Startup-load (not hot-reload); pure output-truncation caps left as constants.
Vikunja #46 (task 76).
Two unbounded per-session leaks that never released after a workflow ended:
- SessionOrchestrator.artifactContentCache (full file contents, keyed
"<sessionId>:<path>") grew for the process lifetime. Added
evictArtifactContentCache(sessionId) — session-prefix key removal,
rehydrate-safe — called on both completeWorkflow and failWorkflow.
cancellations was already evicted on both terminal paths.
- NarrationSubscriber leaked a Channel + worker coroutine + lanes map entry
per session forever. closeLane() now closes the lane channel on
WorkflowCompleted/WorkflowFailed (draining the terminal narration first);
the worker self-removes from lanes when its for-loop exits on close.
Deferred: SqliteEventStore.subscriptions eviction — removing the SharedFlow
mid-life would strand LiveArtifactRepository's downstream collector (a
suspended-coroutine leak worse than the tiny empty-SharedFlow entry).
Green: :core:kernel compile+detekt, :apps:server *NarrationSubscriber* (8).
Real LLM Usage is already recorded (InferenceCompletedEvent.tokensUsed),
aggregated per-session (MetricsProjection), exposed via correx stats + server
MetricsInspectionService, and rendered in tui-go. The only remaining hardcoded
length/4 estimates were the journal context-entry budget and the compaction
threshold — both pre-injection (no LLM response exists for the journal text), so
estimation is legitimate, but they now use the real tokenizer estimateTokens()
for consistency with sibling context entries instead of length/4.
The Created->Validating->Validated triple fires unconditionally back-to-back at
three artifact-emission sites — three single-event append transactions (each its
own commit + flow-publish pass) for one logical unit. Added emitAll(sessionId,
payloads) which routes 2+ events through EventStore.appendAll (one transaction,
one publish pass) and falls back to emit() for 0/1. Same events, same order.
Adjacent-but-conditional emits (clarification/approval pause+resume) are left on
emit(): they straddle suspension points, so batching would change observable
ordering.
runInference filtered the offered tool list with isReadOnlyMode(sessionId)
evaluated inside the per-tool .filter{} — a full log read+fold per offered
tool, per inference round (the audit's dominant O(events^2) hot path). The
flag is tool-independent, so read it once before the filter.
The per-tool-CALL re-check in dispatchToolCalls is left as-is: it is
semantically load-bearing (a read completing earlier in the same batch lifts
read-only mode for a later write), not redundant.
computeToolPreview/readFileIfExists resolved relative file_write/file_edit
paths against the daemon CWD, so the diff shown to the operator for approval
read the wrong file (or nothing) when server CWD != workspace_root. Thread the
bound workspaceRoot (effectives.policy.workspaceRoot) through and resolve
relative paths against it, same as the tools do.
Threads reasoning_content across inference calls and journal renders, filters
markdown noise out of L3 repo-knowledge retrieval, adds PlanLinter H3 checks,
and tightens filesystem tool output/dir-listing behavior surfaced by prior
live QA (see project_readloop_campaign memory).
Adds the failure-ticket + recovery-routing mechanism: a deterministic
gate->capability table gates whether a stage has agency to fix its own
failure, opens a FailureTicketOpenedEvent when it doesn't, and routes to
a metadata role=recovery stage (per-stage budget, cap 2, not reset by
TransitionExecuted) instead of retrying in place. Extends salvage
decisions with a RECOVER option so the review-gate judge can also route
to recovery, unifies deterministic-gate and review-gate routing through
routeToRecovery(), and has ExecutionPlanCompiler synthesize a
write-capable recovery stage + edge for freestyle plans. Read-only tools
(file_read, list_dir) are now always available on any tool-granting
stage so a recovery stage can inspect the write-less stage's failure
without flooding context via shell ls -R.
A stage parked on operator clarification lost its modal whenever the
client disconnected or the server restarted — the questions were stranded
in the event log with no way to re-surface them (unlike approvals, which
already rehydrate).
Two composing mechanisms, mirroring the approval path:
- On reconnect: SessionEventBridge.replaySnapshot() now re-pushes the last
unanswered ClarificationRequestedEvent for a PAUSED/CLARIFICATION_PENDING
session as a ClarificationRequired message. Gated on the orchestrator
still holding a live deferred (SessionOrchestrator.liveClarificationRequestIds)
so a client never sees a modal whose answer could not be delivered.
- On restart: ServerModule.resumeAbandonedSessions() eagerly relaunches
clarification-parked sessions (approval-parked stay lazy). rehydrate+resume
re-runs the parked stage, which re-emits a fresh, live
ClarificationRequestedEvent (round budget MAX=3, 1 prior round recorded, so
it re-parks rather than silently proceeding), re-arming the deferred that
the reconnect path gates on.
pauseReason == "CLARIFICATION_PENDING" is the discriminator because the
reducer sets pendingApproval=true for every pause.
Docs were indexed as up to 40 h1-h3 headings per .md (a TOC flood) and
force-fed or excluded wholesale by a binary docs gate. Replace with a
compact, always-on catalog the agent reads on demand.
- RepoMapIndexer: .md now yields ONE descriptor (frontmatter
description/summary/title -> first # H1 -> first prose line), not a
heading dump. Replay-safe (still List<String> symbols).
- SessionOrchestrator: always-on '## Docs available (file_read to open)'
catalog — top-N docs by recency as 'path — descriptor', hard-capped at
DOCS_CATALOG_MAX. One line each, so it stays present without the
poisoning the docs gate suppressed. Source layout still excludes doc
content for non-doc prompts.
Greenfield discovery/analyst stages thrashed on plane-2 REFERENCE_EXISTS
rejections: the model listed a not-yet-created dir (e.g. frontend/), got
hard-BLOCKED every round, and burned MAX_TOOL_ROUNDS without progress.
- New ToolCapability.DIRECTORY_LIST; ListDirTool declares it alongside
FILE_READ. ReferenceExistsRule now exempts directory enumeration (a
listing of an absent dir truthfully reports empty; only hallucinated
file *reads* still fail loud). Dispatch stays on capability, not name.
- Stage-agnostic rejection-loop breaker in the tool loop: after 3 rounds
where every tool call is rejected (BLOCKED/ERROR) with no success,
force the model to produce its output. Covers JSON-emitting stages the
read-loop breaker (file_written-only) never protected.
Bounded LLM "are you sure?" pass over the capability gaps part 1 detects, run
once at plan-lock before the operator is asked to approve — honest mistakes
self-correct without escalation; only a genuine tool need reaches the human.
- CapabilityGapReflector: fun-interface seam in core:kernel (plain-data in/out,
no infrastructure:workflow dep), mirroring SemanticReviewer/SalvageJudge.
- CapabilityGapReflectorImpl (apps/server): one InferenceRouter call for the
whole gap batch, no per-gap loop, no retry; runCatching degrades to a safe
RESOLVED-advisory default on any failure (unroutable/timeout/malformed JSON) —
a broken pass can never manufacture a NEEDS_TOOL escalation or a grant.
- CapabilityGapReflectedEvent(verdict RESOLVED|NEEDS_TOOL) recorded per gap and
registered in eventModule; replay reads the event, never re-invokes inference
(invariants #7/#8/#9).
- FreestyleDriver.lockAndRun: RESOLVED is advisory (recorded, plan untouched —
invariant #3); NEEDS_TOOL is appended to the existing requestPlanApproval
preview so the operator decides — no auto-grant anywhere (#4/#5).
- reflector nullable -> degrades to part-1 behavior when unwired.
Verified: ./gradlew :core:events:test :core:kernel:test :infrastructure:workflow:test :apps:server:test green.
Bundles three operator-reliability guardrails (Vikunja #28/#29/#30) plus the
in-flight branch WIP they were built on top of (reasoning_content capture,
operator/project profile editor, write-jail workspaceRoot fix) — the tree is
interdependent (SessionOrchestrator references reasoningArtifactId from the WIP)
and does not compile as separable subsets, so it lands as one commit.
Guardrails:
- #28 mid-stage steering: ClientMessage.SteerSession -> GlobalStreamHandler ->
orchestrator.submitSteering, reusing SteeringNoteAddedEvent + existing context
fold (advisory, non-authoritative; invariants #3/#7). Closes the gap where
steering typed off an approval gate was silently dropped.
- #29 shell-in-file guardrail: ShellInFileContentRule (core:toolintent) blocks a
file_write whose content is a bare shell command (e.g. "mkdir -p ..."); FileWriteTool
description now advertises auto-mkdir of parent dirs. Basename-allowlist so the
extensionless case is caught; scripts/Makefiles/multiline exempt.
- #30 pt1 capability-gap detector: deterministic CapabilityGapDetector maps stage
intent -> implied ToolCapability, compares to granted tools, emits advisory
CapabilityGapDetectedEvent in FreestyleDriver.lockAndRun. Recorded, never fails
the gate and never auto-grants (invariants #3/#4/#5). Reflection rung is pt2.
Verified: ./gradlew check green (whole tree).
Retry-agency invariant: a stage may only retry a gate it has the capability
to change. A write-less stage failing a build/contract/static gate (e.g.
freestyle final_verification with allowedTools=[shell]) can never fix it, so
retrying in place is futile until the budget drains (Vikunja #41).
Instead: open a FailureTicketOpenedEvent (category + requiredCapability derived
deterministically from the gate id, no LLM) and route to a recovery stage that
holds the capability, bounded by a small per-stage route budget.
- Slice 2: SalvageDecision.RECOVER (ternary CONTINUE/RECOVER/FAIL) lets the
review-gate salvage judge hand off to recovery. Shared routeToRecovery()
unifies the deterministic agency guard and the judge on one destination;
decideGateExhaustion now returns StepResult?.
- Return-to-sender: recovery's exit is dynamic (recoveryReturnMove) — it goes
back to the exact ticket-origin stage to re-run its gate, so a write-less
gate anywhere in the graph is handled without skipping intervening stages.
The synthesized recovery->terminal edge is now only a no-ticket fallback.
- Freestyle wiring: ExecutionPlanCompiler injectRecovery flag (Main passes
true) synthesizes a write-capable recovery stage; ticket evidence reaches it
via buildRecoveryTicketEntry.
- Read-only tools always present: StageConfig.effectiveAllowedTools adds
file_read/list_dir to any tool-granting stage (fixes the verifier reaching
for `shell ls -R` to inspect the tree).
Tests: RecoveryRoutingTest (deterministic gate, no static return edge — proves
dynamic return) + GateRetryBudgetExhaustionTest RECOVER case + two compiler
tests. All green; detekt clean.
Closes the COMPLETED-lie where a freestyle stage passes by producing a
schema-valid artifact regardless of whether its build actually succeeded:
a verify stage could run 'npm run build', watch it fail, then self-attest a
build_verified artifact and transition to done. Tool failures never failed
the stage.
The compiler now flags the terminal stage autoBuildGate=true when no stage
declares an explicit build_expectation. Code-ness is decided at runtime, not
compile time (the declared kind is always the generic file_written; only the
written paths reveal code): runExecutionGate folds the FileWritten manifest via
sessionProducedBuildTarget() and promotes to a PROJECT build when a build
manifest (package_json/gradle_module) or an imports_resolve-bearing path was
written, then runs the real command from .correx/project.toml [commands]. A
non-clean exit fails the stage retryably; a docs-only plan is left untouched.
Live-proven (run 3, session 28812b44): gate fired on the real
'npm --prefix frontend run build', exit 2 on a bad tsconfig, RetryAttempted
instead of a false COMPLETED.
Modules green: transitions+workflow 67, kernel 60.
Replaces the single shared per-stage retryCount (reset on TransitionExecuted,
shared across all post-stage gates) with a per-gate budget. Motivated by run
2e468f9f, where a promising freestyle run was terminally FAILED because the
contract gate burned all 3 shared retries on one trivial miss before the
semantic-review gate ever ran.
- StageExecutionResult.Failure gains a `gate` id; each gate tags its failures.
- OrchestrationState gains gateRetryBudgets / gateFailureFingerprints /
gateSalvageUsed (all rebuilt from events, reset per-stage on TransitionExecuted).
- FailureFingerprint normalizes+hashes the failure reason; RetryCoordinator.decide
charges a gate's budget only when the fingerprint is unchanged (no progress),
so a moving run is never penalised. Returns Retry | Exhausted.
- Hybrid exhaustion: deterministic gates fail; the review gate consults a
SalvageJudge (LLM, or a deterministic allow-one-reset fallback), recorded as
RetrySalvageDecidedEvent (invariant #9). CONTINUE resets the gate budget once;
a second exhaustion is terminal.
- Review gate is now the sole authority on review-retry termination; the old
REVIEW_BLOCK_RETRY_CAP is repurposed as a high absolute backstop only.
- ServerModule escaped-exception path now records a truthful terminal
WorkflowFailed (real stage/reason/retryExhausted) via recordUnhandledFailure.
Tests: DefaultRetryCoordinatorTest (fingerprint charging) +
GateRetryBudgetExhaustionTest (deterministic exhaust / review CONTINUE-then-
terminal / review FAIL / null-judge fallback) + reducer coverage.
Plan-compile gate (#19): PlanCompilationCheck seam (null-on-replay),
runPlanCompileGate guards a produced execution_plan slot and compiles its
cached content via ExecutionPlanCompiler, emitting PlanCompileCheckedEvent.
On failure the architect gets a retryable hand-back and self-corrects;
exhaustion terminates the dead-end workflow instead of shipping an
uncompilable plan.
Semantic-review gate (#20): SemanticReviewer seam + StageConfig.semanticReview
opt-in (TOML semantic_review + freestyle plan JSON). runReviewGate reads the
FileWritten manifest (never blind context), asks a review-capable model for
PR-comment findings, and is advisory — blocks retryably only on FAIL plus a
correctness finding >=0.7 confidence, capped at 2 blocks so a stubborn stage
completes advisory-only rather than trapping. SemanticReviewerImpl talks to
the provider directly; any reviewer error degrades to PASS. REVIEW_MAX_TOKENS
is 8192: gemma is a reasoning model and 1024 truncated it mid-reasoning before
it reached the JSON verdict (empty content -> parse degraded to PASS).
Both gates live-QA'd on the real repo: plan-compile passed on a freestyle
web-UI scaffold; semantic review caught all 3 planted bugs in a maxOf() and
exercised the block->retry->cap safety valve end-to-end.
Gate 2 (contract) and Gate 4 (execution) of the 4-gate staged-verification
design, plus the two gate-quality fixes found in live QA.
- Gate 2: KindContractTable (kind→assertions registry, universal floor,
additive project overrides) + KindInference (path→kind, pure/replay-safe)
+ ContractAssertion model; ContractGateEvaluatedEvent (registered);
ContractAssertionEvaluator seam + FileSystemContractEvaluator (FS/TEXT,
COMPILER skipped); runContractGate in runPostStageGates — a stage that
produces file_written artifacts must have every declared+written file
exist/non-empty/parse, else retryable hand-back with per-assertion feedback.
- Option A: ExecutionPlanCompiler puts concrete (non-glob) plan `writes` into
StageConfig.expectedFiles, so a declared-but-unwritten file fails file_exists.
- Gate 4: BuildExpectation enum (none|module|project|tests) + plan field +
closed-set compiler validation; runExecutionGate resolves the alias against
the bound project profile commands and runs it as a deterministic gate.
- Live-QA fixes: react_entry kind so main.tsx/index.tsx aren't required to
export a default component (was an unwinnable false-positive); JSONC
tolerance (allowComments/allowTrailingComma) so tsconfig.json passes valid_json.
Seams are null on the replay harness (no-op) — recorded events are truth (#9).
Context rework from the 2026-07-05 context-poisoning diagnosis (session
6ca236c4 read-loop):
- ContextBucket on ContextEntry: REQUIRED (task, constraints, needed
artifacts, failure feedback) is never pruned/compressed/dropped; the
build fails fast (RequiredContextOverflowException -> non-retryable
stage failure) if required alone exceeds the stage budget.
- OptionalCeilings: per-sourceType token caps for the optional block
(repoMap 1200, relevantFiles 1600, journal 800, profiles 400/600,
vocabulary 400), tuned for the 9-26B tier at 16k; enforced by
truncation (journal keeps its tail, others their head).
- Repo map is now a structural 'what exists' listing (alphabetical,
per-directory, no recency ranking, no symbols); .md/docs paths are
gated behind an explicit docs mention in the stage prompt — retrieval
hits remain the only other path for docs to enter context.
- file_written needs render as a manifest of ALL files the producing
stage wrote (projected from ArtifactContentStored -> ToolInvocation
-> FileWritten events), fixing the last-write-wins collapse; content
stays lazy behind file_read.
- Decision journal folds a stage's RETRY records into one summary line
once a later transition leaves that stage (render-time only).
- Dedupe retrieved-file lines in buildRelevantFilesEntry.
Deterministic repair pipeline (extraction, classification, policy, gated
LLM-repair rung) with ArtifactRepairAttempted/Failed events. Also fix
JsonSchemaValidator: a schema with no declared properties describes 'any
object' — unknown-property enforcement now only applies against a declared
shape (empty-properties schemas were rejecting every real artifact).
RepoMapComputed recorded per session (in-memory scan cache keeps the walk-once
guarantee); zero-score embedder hits filtered before reaching the agent, falling
back to the deterministic repo map; REFERENCE_EXISTS surfaces a closest-path hint
via closestPathByFilename. Breaks the hallucinated-package-path read loop even
with a noop embedder.
Three tool-path correctness fixes:
- Tool-execution events were emitted twice — SandboxedToolExecutor and
SessionOrchestrator.recordToolExecution both emitted Completed/Failed/
FileWritten per call (double TUI rows, double-counted metrics). Split
ownership: orchestrator is the sole recorder of Completed/Failed (truncates
output, drives the read-before-write gate); executor owns Started +
FileWritten (pre/post-image hashes it alone can capture).
- computeToolPreview still gated on operation == "write", but file_write lost
its operation param when delete was split out, so it bailed to raw-args for
every write (approval card showed raw JSON). Drop the dead guard.
- A file_read blocked by REFERENCE_EXISTS can never complete, so it could never
lift read-only mode: a stage writing a NEW file deadlocked (writes filtered,
every read of the not-yet-created target blocked) until MAX_TOOL_ROUNDS.
Lift read-only on a REFERENCE_EXISTS block, and reword the block message to
tell the model to file_write directly. Regression test added.
DefaultSessionReducer skipped WorkflowCompletedEvent to avoid terminalizing on freestyle planning's mid-session completion, but that left execution/normal completions stuck ACTIVE forever — headless watchers and approval loops never saw a terminal state. Add workflowId to WorkflowCompletedEvent (defaulted for replay compat), pass graph.id at all completeWorkflow sites, and terminalize to COMPLETED unless it's the freestyle_planning handoff. Correct stale tests that pinned the ACTIVE-forever behavior.
Uncommitted work from the session-robustness-and-dox branch sweep
(docs/qa/QA-session-robustness-and-dox.md), verified alongside the
compression/context fixes:
- SandboxedToolExecutor: validate tool args centrally before dispatch. A
malformed/missing-arg call becomes a recoverable ERROR: (surfaced with the
tool's arg schema so the model can correct + retry) instead of stranding
the stage with no artifact. + validation test.
- PlanLinter: seed artifacts (analysis) produced by the planning phase count
as available producers, so a plan stage that `needs` them isn't flagged as
an unproduced-need; H1 unproduced-needs + trap-state checks. + tests.
- DefaultSessionOrchestrator: live-QA robustness fixes (event-tail /
per-stage budget + retry handling).
- workflow prompts/configs: DOX AGENTS.md alignment + freestyle/task/role
prompt tweaks.
- SessionOrchestratorIntegrationTest: coverage for the above.
- FreestylePlanningWorkflowTest: allow list_dir in analyst tools (follows the
list_dir wiring in 968cbfa).
- QA plan doc for the branch sweep.
ContextSummarizedEvent (registered) keyed on stable session sequence, not the
ephemeral pack ordinal, so replay reuses the recorded summary artifact and never
re-calls the LLM (invariants #6/#8). TierContextSummarizer mirrors
JournalCompactionService for freeform context (tool outputs/docs/retrieved text)
the journal path doesn't cover. ContextState + DefaultContextReducer fold the
event; reducer replay-safety unit-tested.
Three defects surfaced by a live headless freestyle_planning run (all made a
long execution workflow fail or stall in ways invisible to a REST operator):
1. retryCount was session-global (DefaultOrchestrationReducer): TransitionExecuted
updated currentStageId but never reset retryCount, while shouldRetry gates on
it per-stage. An early stage that burned its 3 retries starved every later
stage of its own — the next stage's first *retryable* failure went terminal.
Reset retryCount on stage entry; back-edge loops stay bounded by the separate
refinementIterations counter. Regression test added.
2. Freestyle stages ran at StageConfig's 4096-token default (ExecutionPlanCompiler
never set a budget) vs 16384-32768 for static role_pipeline stages. A tool-heavy
stage truncated its context every round, evicting the file_read results it just
gathered, so it re-read forever and never accumulated enough to write. Set 16384.
3. GET /events returned the OLDEST `limit` events (readFrom(0) then take): for a
session past `limit` events the tail — including a pending ApprovalRequested —
was invisible to a headless/REST poller, the exact caller POST /approve serves.
Default now tails; explicit fromSeq still paginates forward.
Two fixes that together let the freestyle analyst/architect stages actually
produce valid artifacts on a local model (verified end-to-end: analyst passed
first try, architect produced a validated plan):
1. Tools-less final emission (SessionOrchestrator): producing the artifact
while tools are in the request is unreliable — llama.cpp's Gemma template
switches to a channel format and leaks <|channel> markers into the text, and
models keep tool-calling until the round cap without ever emitting. After the
tool loop, fire ONE tools-less inference to get clean JSON (also re-enables
the JSON grammar, since grammar+tools is rejected). Gated on the artifact not
already being produced via emit_artifact.
2. GBNF grammar handles arrays/objects (GbnfGrammarConverter): array-typed
properties fell through to a 'string' rule, so the grammar forced a string
where the schema demanded an array — an unwinnable grammar-vs-validator
disagreement. Add generic value/object/array rules and map types correctly.
Note: also needs schema 'items' on array props (runtime schemas updated; mirror
to repo schemas/*.json).
Two robustness fixes for LLM-emitted artifact stages (e.g. freestyle analyst/
architect) that were exhausting their retry budget on weak models:
1. emit_artifact meta-tool: stages with an LLM-emitted slot now expose an
emit_artifact tool whose parameters ARE the artifact's JSON schema, giving
the model a structured tool-call channel to produce the artifact instead of
a free-form final message. Sidesteps llama.cpp's grammar+tools
incompatibility (the artifact can't be grammar-constrained while tools are
present). The call's arguments are captured as the artifact content.
2. Actionable retry feedback: validation rejection now surfaces the concrete
error messages (e.g. 'required property summary missing') as the stage
failure reason instead of a bare 'validation failed', so the retry-feedback
entry tells the model what to fix.
Add CapabilityAwareRoutingStrategy — it hard-filters to providers that declare every
required capability (like FirstAvailable) but ranks the matches by summed required-capability
score; ties keep list order, so it is a strict superset of first-available. The server now
wires it as the routing policy.
The orchestrator augments a stage's required capabilities with ModelCapability.ToolCalling
when the stage grants tools, so tool-heavy stages route to the best tool-calling model rather
than whichever healthy provider comes first.
Scores come from each provider's declared capabilities(); observed per-model reliability
(GET /metrics/tool-reliability) can feed in later as an additional weight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enable autonomous QA through a remote OpenAI-compatible provider (NVIDIA NIM)
and harden the tool/approval path so unattended multi-stage runs complete.
- inference: add openai_compat provider (Bearer chat-completions for NIM/OpenAI),
dispatched by provider type "nim"/"openai"; key via api_key/api_key_env.
- server: bind configured [server] host/port instead of a hardcoded 8080;
POST /sessions accepts an optional `intent` (WS parity) for intent-driven workflows.
- kernel: thread the bound operator profile's approval_mode into per-tool gating so
auto/yolo enable unattended approval (engine still consulted; policy/plane-2 BLOCK
stays terminal); on a recoverable tool failure feed the tool's arg-schema back into
context so the model self-corrects instead of repeating a malformed call.
- tools: split deletion out of file_write into a separate, explicitly-named file_delete
tool — a model can no longer delete a file by getting a write-mode parameter wrong.
- server: add GET /metrics/tool-reliability — per-model tool-call validity from the
event log (measurement groundwork for capability-aware routing).
- docs: update AGENTS.md across kernel, tools, server, inference.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a write is blocked for being outside the claimed task's
affected_paths, the implementer can propose widening scope via a new
T2 propose_scope tool. The operator decides (invariant #4 — the agent
can't self-grant). Approve unions the proposed paths into the task's
affected_paths (existing TaskAffectedPathsSetEvent); activeScope
re-derives the wider manifest from events so the same write then passes.
Reject blocks the task (via TaskClaimCoordinator.blockActiveTask, hooked
on both approval-denial branches, scoped to propose_scope) so the loop
advances. No new event types.
A stage flagged claimTask gets the kernel (not the LLM) to claim the
next ready task — or re-surface the one already claimed on a review
bounce — and inject its TaskContextAssembler bundle as L0. While a task
is claimed, plane-2's write manifest narrows to the task's affectedPaths.
Project identity is resolved from the origin-session link the task tools
already record (TaskService.projectForSession), so the loop predicate and
claim stage agree regardless of the free-form project key the agent passed.
Claiming flows through a kernel-declared TaskClaimCoordinator implemented
in apps/server (no cross-core import).
New TasksReady condition driven by EvaluationContext.readyTaskCount,
fed at resolve time via a kernel-declared ReadyTaskCounter interface
implemented in apps/server over TaskService (keeps core:kernel
decoupled from core:tasks). Loops a graph while ready>0, exits at 0.
Root Child DOX Index assembled, plus a per-module AGENTS.md across the tree
(core/*, infrastructure/*, apps/*, testing/*, and docs/examples/frontend/etc),
each following the DOX section shape: Purpose, Ownership, Local Contracts,
Work Guidance, Verification, Child DOX Index.
Two orchestrator tool-gating behaviors (both touch SessionOrchestrator):
- Read-only switch: when a tool call is BLOCKed with READ_BEFORE_WRITE, derive a
read-only mode from the event log (block until a later FILE_READ completes) and
withhold FILE_WRITE tools from the next inference turn(s), so a weak model is
pushed down the read-then-write path instead of looping on the blocked write.
- require_task_decompose stage flag (TomlWorkflowLoader) + owesTaskDecompose guard:
a stage so marked cannot stage_complete until a task_decompose/task_create has
succeeded this stage. New task_planning workflow + prompt are the first consumer
(decompose-only: swoop codebase, emit a parent + DEPENDS_ON task graph, stop).
GET /tasks now reports dependency-graph status per task — ready (TODO with no unmet
deps) and blockedBy (ids of unfinished blockers) — resolved via TaskGraph over each
task's FULL project board, so a status filter can't hide a blocker. Default-valued
fields are omitted (encodeDefaults=false); consumers read absent as zero.
The task_decompose approval card now shows a readable graph (epic + numbered children
with their "after" dependency labels) via renderDecomposePreview wired into
computeToolPreview, instead of the truncated raw JSON the generic fallback gave.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The orchestrator stringified every non-primitive tool argument, so a JSON array
arrived as its toString() and listParam (as? List<*>) read it empty — silently
dropping a task's affected_paths/acceptance_criteria. That left WriteScopeRule
(activeTask.scope empty -> no-op) and cite-before-claim with nothing to enforce
for agent-created tasks.
Extract parseToolArguments: a primitive array becomes List<String>; objects and
arrays-of-objects keep their JSON text for tools that re-parse them
(task_decompose). Unit-tested in ParseToolArgumentsTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Block writing a file whose on-disk content changed since the session read it — a
concurrent/external edit the agent's view doesn't reflect. file_read records a
content hash on whole-file reads; the orchestrator's completion path now carries
the tool's structuredOutput (it previously dropped it, unlike SandboxedToolExecutor
— the two paths were inconsistent), so SessionContext.readHashes folds it.
StaleWriteRule compares the current hash (WorldProbe.contentHash) against the
read-time hash and BLOCKs a mismatch, telling the agent to re-read first. Files the
session wrote itself are excluded, so its own edits never look stale; partial reads
set no baseline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Capability is the canonical signal the plane-2 gates dispatch on, but
ToolInvocationRequestedEvent only carried the tool name — so ReadFilesProjection
classified reads by a hardcoded "file_read" string, bypassing the abstraction and
(worse) re-deriving a semantic fact at replay from whatever the name meant. That
violates the event-sourcing invariant that replay reads recorded facts, not live
state.
Now the tool's requiredCapabilities are recorded on the event at emission, and the
projection classifies by ToolCapability.FILE_READ. To let the lowest module carry
it, ToolCapability moves from core:tools into core:events keeping its package, so
every existing import resolves unchanged and core:tools imports it from below. The
field is defaulted, so pre-existing events deserialize as empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An LLM that edits or overwrites a file it never read is acting on hallucinated
contents. This plane-2 ToolCallRule blocks it: dispatching on FILE_WRITE (covers
file_write and file_edit), a write to a path that exists on disk but was not
file_read earlier this session is BLOCKED. A brand-new file passes — you can't
read what doesn't exist, and creating is legitimate.
- ReadFilesProjection folds the session's file_read events (request + completion,
so a failed read doesn't count) into the set of paths read, mirroring
EgressAllowlistProjection; threaded into ToolCallAssessmentInput.sessionReadPaths
via SessionOrchestrator.resolveSessionReadPaths.
- Read paths and the write target are both resolved through the probe (toRealPath),
so spelling differences and symlinks can't dodge the gate.
- The orchestrator already feeds a block's rationale back as a tool result, so the
agent reads the file and retries; the rejected-event reason now carries that
rationale too (specific audit trail instead of a generic string).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two reviewer-reliability tracks, both deterministic and unit-verified (no model/network).
§B-§5 — static_check stage seam. The StaticAnalysisRunner + StaticFindingsRecordedEvent +
reviewer-context filter already existed; what was missing was a stage that runs the tool and
emits the event. Added:
- StaticCheckStageExecutor: reads stage metadata (static_tool/static_argv), runs the configured
command via StaticAnalysisRunner, returns a StaticFindingsRecordedEvent. No-op (empty findings)
when no runner is wired or no command is configured — safe to carry unconfigured.
- A deterministic-stage seam in DefaultSessionOrchestrator.enterStage: any stage with
metadata["stage_type"] == "static_check" is run by the executor instead of the LLM subagent,
then advances on its (unconditional) exit edge.
- TomlWorkflowLoader: stage_type/static_tool/static_argv fields → StageConfig.metadata.
- role_pipeline.toml: a static_check stage between implementer and reviewer (no-op until
static_argv + a CommandRunner are set; activation is live-QA-gated, see StaticAnalysisRunner doc).
§B-§6 — critique-outcome producer. The CritiqueFinding type + CriticCalibrationProjection existed
but nothing fed them. Added:
- CritiqueFindingsRecordedEvent (+ CritiqueVerdict): the producing side — a critic's findings +
verdict for one review iteration, carrying modelHash for per-model calibration.
- CritiqueOutcomeCorrelator: pure loop-resolution logic deciding UPHELD (fixed between rounds) /
DISMISSED (persisted into an approved final) / INCONCLUSIVE (open at a non-approved terminal),
per critic (role + modelHash) and per finding id.
- A hook in completeWorkflow/failWorkflow that correlates recorded findings into
CritiqueOutcomeCorrelatedEvents at loop resolution — no-op when none recorded, idempotent.
(LLM-side finding emission stays a separate model-gated activation.)
Tests: StaticCheckStageExecutorTest (4), StaticCheckStageTest integration (1, fake runner →
event + transition), CritiqueOutcomeCorrelatorTest (8), CritiqueCalibrationWiringTest integration
(1, seeded findings → outcomes at completion), updated RolePipelineWorkflowTest. Full suites for
core:events/kernel/critique, infrastructure:workflow, testing:integration green; detekt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the grant system's wider scopes real and add a revoke producer.
Before: grants were loaded per-session (the gate folds only the running
session's own stream), so GrantScope.PROJECT — though declared and matched
in the engine — was dead in practice, and there was no GLOBAL scope or any
way to revoke a grant (ApprovalGrantExpiredEvent had no producer).
- GrantScope: add GLOBAL(toolName); make PROJECT tool-bound. Every
operator-creatable scope is now tool-bound so a grant can never be a
blanket "approve everything" (that's YOLO mode).
- DefaultApprovalEngine.scopeMatches: GLOBAL matches the bound tool in any
context; PROJECT matches when the session's projectId AND tool match.
- Cross-session ledger: PROJECT/GLOBAL grants are appended to a reserved
GRANT_LEDGER_SESSION_ID stream instead of a session's. The approval gate
now unions the ledger's grants with the session's, and derives projectId
from the bound workspace root (ProjectIdentity.of) so PROJECT grants match
later sessions on the same repo. SESSION/STAGE grants still live in (and
die with) their session.
- Revoke: ClientMessage.RevokeGrant appends ApprovalGrantExpiredEvent to the
ledger (reducer already drops it); ListGrants/GrantList expose the active
standing grants for the TUI viewer.
- Drop the server-side T2 grant ceiling per operator request: a grant may
now authorize any tier (incl. destructive T3/T4). Tool-binding is retained
as the remaining guard.
Backend only; the TUI scope picker + grants viewer follow. core:events,
core:approvals, core:kernel, apps:server compile; approvals/events/kernel/
server suites green; new GrantScopeMatchingTest (5) covers GLOBAL/PROJECT
match, project isolation, and the no-cap T4 path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>