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.
IdeaReader folded eventStore.allEvents() on every activeIdeas()/capturedOf()
call — a full deserializing log scan per CHAT context build. Now folds once at
construction and stays current incrementally via subscribeAll(); the fold is
idempotent so seed/live overlap needs no dedupe.
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.
Adds a nullable `reasoning` field to InferenceResponse and maps the
llama.cpp / OpenAI-compat `reasoning_content` field into it, so local models
that emit their chain-of-thought have it captured. Provider/model side only —
the emit-site event field (reasoningArtifactId) + CAS storage in the
orchestrator are still pending (tracked in Vikunja Correx #4).
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).
The actions line is session-wide, but the old 'Actions taken this stage' label
made a literal narrator (Qwen2.5-1.5B) contradict itself ('no files read this
stage'). Relabel to match reality.
Live run showed the actions were in the prompt but the 1.2B narrator ignored
them: they sat in a system-role entry (small models under-weight system context)
and were stage-scoped to the pausing stage, which usually isn't the one that ran
the tools. Fold the recent actions into the USER trigger instruction instead, and
take the last N tool calls across the whole run rather than per-stage. Narration
now names the concrete actions ("reviewing server directories and reading the
specified file") instead of generic filler.
Narration was generic ("Stage X completed. Summarise.") because nothing about
what the stage actually did reached the narrator — the L2 summary is a
placeholder and StageCompletedEvent carries no output. narrate() now reads the
stage's ToolInvocationRequested events from the log and feeds the narrator a
concrete 'Actions taken this stage: file_read(path), ...' entry, and the system
prompt asks it to prefer those concrete actions over restating the stage name.
The 'router' name was misleading — it's the always-on conversational front-end
(CHAT triage + STEERING into a running workflow), not a routing layer, and it's
distinct from InferenceRouter. Rename core:router -> core:talkie, package
com.correx.core.router -> com.correx.core.talkie, and RouterFacade/Config/State/
Repository/ContextBuilder/Projector/Reducer/Response -> Talkie*. Config section
[router] -> [talkie] (legacy [router] still read as fallback).
Persisted wire formats are preserved: ChatTurnRole.ROUTER and the
RouterNarrationEvent @SerialName("RouterNarration") stay, so existing event
logs still replay. RouterNarrationEvent the class is now TalkieNarrationEvent.
They are shared infra (the orchestrator uses them for workflow repo-knowledge,
not just the router chat layer), so [router.embedder]/[router.l3] was misleading.
Loader/writer use the new names; legacy [router.*] still read as a fallback.
Also notes the STEERING LLM-reformulation cost as a ponytail follow-up.
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.
toJsonElement wrote a null map value as JSON null, but toAny threw on reading it back — asymmetric, so a single null-valued tool-arg made the whole session's events undecodable: 500 on /events AND a runtime WorkflowFailed when the projection rebuilt mid-run. A Map<String, Any> can't hold a null anyway, so drop null-valued keys on both serialize and deserialize, keeping the log round-trippable.
Two copies had drifted — the core:sessions one and the testing:projections one — and one had rotted into asserting the ACTIVE-forever bug as correct. Port the unique boundProfile + freestyle-planning cases into the comprehensive projections copy and delete the core:sessions duplicate.
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.
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.
The big win the pipeline missed: projectProfile + agentInstructions doc dumps
(~14k chars) are L0-SYSTEM -> STATIC -> never pruned, re-sent verbatim every turn.
Now pruned via the same TokenPruner at a gentler ratio (keep 60% vs 45% freeform),
protected spans (code fences/paths/keys) kept so directives survive. Gated by
TOKEN_PRUNE (level 3+); base systemPrompt stays verbatim.
Move embedder construction ahead of the engines so the context pack builder ranks
freeform turns by query relevance (query-conditioned selection). Default
compression_level 4 (format + static cache + token pruning + age-tiering) for a 12b
on a 16k window.
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.
FactSheet derives load-bearing spans (newest-first, capped) and pins them as an
L0 SYSTEM entry never dropped by the compressor (level 7). Event-derived each
assembly, not a persisted file, per invariant #1. Strict budget allocation
(level 9) compresses structured groups before freeform so conversation can't
starve tool logs / artifacts.
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.