Repository navigation
fix: ReAct reliability pass (structured output, preferSummarize, stale docs) - #229
pluginslab wants to merge 5 commits into
Conversation
…, stale docs) Three independent fixes, all in the inference path. 1. Correct stale docs and delete dead config. ARCHITECTURE.md and .claude/CLAUDE.md claimed a 4096-token context window and 2000-char tool-result truncation. Both are wrong: MODEL_CONTEXT_SIZES sets 8192 for Qwen3 1.7B and 32768 for Qwen2.5 7B, and maxToolResultLength is 3000. MODEL_CONFIG.context_window_size looked authoritative, contradicted MODEL_CONTEXT_SIZES, and was never passed to the engine; its only reference was its own export. Removed. 2. Grammar-constrain the ReAct action envelope. The loop asked for JSON and then repaired whatever came back. It now passes REACT_ACTION_SCHEMA as response_format so the grammar enforces the envelope during decoding. Gated on thinking being disabled, because a JSON grammar cannot represent Qwen 3's leading <think> block. ExternalEngine strips the WebLLM-specific `schema` key so OpenAI-compatible providers still get plain JSON mode. Escape hatch: structuredOutput: false. 3. Adopt preferSummarize on the four report abilities. site-health, security-scan, verify-core-checksums and file-scan all render complete reports in summarize(), then had them re-rendered by the LLM against a 3000-char cap. A truncated security scan is worse than none. Workflows do not consult preferSummarize, so the four workflows chaining these abilities are unaffected. Prerequisite for 3: summarize() emitted broken markdown bold (`* * 8.2 * *` instead of `**8.2**`) in site-health, core-site-info, core-environment-info and plugin-list. The LLM was silently repairing it; rendering summarize() directly would have shipped it to users verbatim. Tests: 4 new cases covering the gating both ways plus the escape hatch. 95 unit tests pass, lint clean, production build succeeds. Refs #228
Correction: the Ollama harness cannot validate this PRThe original description suggested re-running the Ollama ability harness against this branch to see whether the structured-output change moves anything. That was wrong, and I have rewritten that section. Checked rather than assumed:
The harness measures tool selection, and nothing here can move that number either way. The one check it was worth doing, done without Ollama: the regex loader still extracts 35 abilities on this branch, identical to Validating the structured-output change for real needs WebGPU in a browser, so it belongs in the E2E suite. Happy to add a case there if that is worth doing before merge. |
Makes the structuredOutput gate observable in the browser console at DEBUG level, so the change can be validated manually against a real WebGPU engine. No behaviour change. Refs #229
window.wpAgenticLogLevel used the reserved `wp` prefix, the same class of issue the Plugins Team flagged on 28 May for the wpAgenticAdmin localize handle. The prefix rename in #227 missed it because it is set at runtime rather than declared in PHP. window.wpAgenticLogLevel -> window.agenticAdminLogLevel Also drops the redundant assignment before defineProperty, and clears the last "WP Agentic" branding strings from the SW console label and the SCSS headers, left over from the de-branding pass. Refs #228, #229
Validated in a real browser (WebGPU, Service Worker mode) rather than
against the mock, and the result contradicts the original assumption.
Qwen2.5-7B-Instruct structuredOutput ON -> works, 2 iterations, 1 tool
Qwen3-1.7B-q4f32_1 structuredOutput ON -> BROKEN, 0 tools, 2/2 runs
On Qwen3 the model emits '{' followed by thousands of newlines until it
hits max_tokens. parseActionFromResponse() then fails and the loop falls
through to "I had trouble understanding how to help."
Cause: Qwen 3 is a thinking model and wants to open <think> before the
JSON, which a JSON grammar cannot represent. /nothink is a soft
instruction, so when the model still reaches for <think> the grammar
blocks every token except whitespace and decoding degenerates.
Gating on suppressThinkingUi is necessary but not sufficient: it tracks
whether we asked for no thinking, not whether the model complied. Since
Qwen3-1.7B is DEFAULT_MODEL, shipping this on would regress the default
install, so it becomes opt-in for non-thinking models.
Machinery, schema and tests are kept. Tests now cover the off-by-default
case explicitly.
Refs #229
Browser validation done — and it failed.
|
| Model | structuredOutput |
Outcome |
|---|---|---|
Qwen2.5-7B-Instruct-q4f16_1 |
ON | works — 2 iterations, 1 tool, correct answer |
Qwen3-1.7B-q4f32_1 |
ON | broken — 0 tools, reproduced 2/2 |
On Qwen3 the console shows the gate firing correctly and then the generation collapsing:
[MessageRouter] Routing to ReAct (keyword + action match, no thinking)
[ReactAgent] ReAct iteration 1/10 (prompt-based mode, thinking UI: off)
[ReactAgent] Structured output: ON — action envelope is grammar-constrained
[ReactAgent] LLM response: {\n\n\n\n… (thousands of newlines, to max_tokens)
[ReactAgent] Initial JSON parse failed: Expected property name or '}' at position 1
[ReactAgent] Could not find complete JSON object
[ReactAgent] LLM output plain text instead of JSON, treating as final answer
[ChatOrchestrator] ReAct completed: 1 iterations, 0 tools used
The user sees "I had trouble understanding how to help. Could you rephrase your request?"
Cause. Qwen 3 is a thinking model and wants to open <think> before the JSON. A JSON grammar cannot represent that block. /nothink is only a soft instruction, so when the model still reaches for <think> the grammar blocks every token except whitespace and decoding degenerates.
This is precisely the risk in the original code comment, but the mitigation was wrong: gating on suppressThinkingUi tracks whether we asked for no thinking, not whether the model complied.
Change. structuredOutput now defaults to false. Since Qwen3-1.7B is DEFAULT_MODEL, shipping it on would have regressed every default install. It stays available as opt-in for non-thinking models:
new ReactAgent( modelLoader, toolRegistry, { structuredOutput: true } )Schema, machinery and tests are kept, and the tests now assert the off-by-default behaviour explicitly. The failure and its cause are documented on REACT_ACTION_SCHEMA so nobody re-enables it without reading why.
Also found
ConnectorEngine._createCompletion() builds an explicit whitelist body (connector_id, model_id, messages) and drops everything else — including temperature, max_tokens and response_format. So this feature is a silent no-op on the WP 7.0 AI Connector path. Nothing breaks; there is simply no effect there. Worth knowing separately, since temperature and max_tokens being dropped on that path is pre-existing and probably not intended.
Net effect on this PR
Items 1 (stale docs) and 3 (preferSummarize) are unaffected and still worth merging. Item 2 ships as documented, tested, off-by-default machinery plus a real reproduction — which is more useful than the speculative "this should help" it started as.
The log said "thinking turn" whenever the constraint was absent, but with structuredOutput now defaulting to off that is the wrong cause and it misled during in-browser debugging. Distinguish disabled-by-config from thinking-turn. Refs #229
Re-tested in the browser: fix confirmed, and the diagnosis is now observed rather than inferredSame site, same model ( Before (
After (
The mechanism, straight from the consoleThe model emitted
It also confirms why gating on One more fixThe debug line printed "thinking turn" whenever the constraint was absent. With the default now off, that is the wrong cause, and it actively misled me while reading these logs. It now distinguishes disabled-by-config from thinking-turn ( 95 unit tests pass, lint clean, build succeeds. |
|
Closing as stale. This pass predates all of the WordPress.org review changes that shipped in 0.11.0 (the knowledge base removal, read-file and connectors rework, and more), so it would need redoing on the current main. The branch is preserved as the tag |
Three independent reliability fixes in the inference path. They came out of investigating why the local 7B "struggles sometimes". None of them change the model or the runtime.
Deliberately not touching the engine, the model catalog, or anything WebLLM-adjacent while #228 (the WP.org submission) is open.
1. Correct stale docs, delete dead config
docs/ARCHITECTURE.mdand.claude/CLAUDE.mdboth claimed:Both are wrong, and have been for a while. The code says:
MODEL_CONTEXT_SIZES)maxToolResultLength)This matters more than a typo. Any analysis of the agent built on these docs inherits the error, and the obvious "fix" it suggests, bumping the context to 16384, would actually halve the 7B's window.
Also removed
MODEL_CONFIG.context_window_size = 8192frommodel-loader.js. It looked authoritative, contradictedMODEL_CONTEXT_SIZES, and was never passed to the engine: its only reference in the entire codebase was its own line in the export block. It is almost certainly where the "single global 4096-ish context" impression came from.The docs now point at
MODEL_CONTEXT_SIZESas the source of truth and mention theagentic_admin_context_sizelocalStorage override resolved byModelLoader.getEffectiveContextSize().2. Grammar-constrain the ReAct action envelope
The loop only ever accepts two shapes:
{"action": "tool_call", "tool": "<tool-id>", "args": {}} {"action": "final_answer", "content": "..."}Until now we asked for that in the system prompt and then repaired whatever came back.
parseActionFromResponse()is ~80 lines of strip-the-think-block, strip-the-code-fence, sanitize-control-characters, retry-with-quote-replacement. That is a lot of machinery for "the model produced not-quite-JSON".response_formatappeared exactly once in the whole codebase, inworkflow-orchestrator.js:469, and even there as bare{ type: 'json_object' }with no schema. The ReAct loop, the hot path, used none.Now
REACT_ACTION_SCHEMAis passed asresponse_format, so the grammar enforces the envelope during decoding. Malformed JSON becomes unrepresentable rather than something we repair after the fact.parseActionFromResponse()stays as-is, it is still the path for unconstrained turns and a safety net.The catch, and why this is gated
A JSON grammar cannot represent Qwen 3's leading
<think>...</think>block. Constraining a thinking turn would truncate the reasoning and fail. So this applies only when thinking is already off:which covers both existing nothink paths: router-level
disableThinkingfor clear action commands, and post-tooldisableThinkingAfterTool. Thinking turns are untouched.ExternalEnginestrips the WebLLM-specificschemakey fromresponse_formatbefore proxying. WebLLM understands it; OpenAI rejects unrecognized keys inresponse_formatoutright, so external providers keep plain JSON mode.Escape hatch if it misbehaves in the wild:
structuredOutput: false.3. Adopt
preferSummarizeon the report abilitiesreact-agent.js:451already has the right mechanism: if an ability setspreferSummarize, the loop skips the second LLM call and returnssummarize()directly. Added in the read-file work, explicitly to dodge truncation.It was used by 2 of 37 abilities.
Flipped it on for the four abilities that emit large, complete, self-contained reports:
site-healthsummarize()already branches on the user's question (PHP version, theme, memory limit, ...) and returns a complete answer. The LLM adds nothing.security-scanverify-core-checksumsfile-scanSelection criterion: the output is a report, not data the LLM needs to reason over or filter. I deliberately left
plugin-list,post-list,user-listand friends alone, since #109 wants the LLM filtering those to match the question, which is the opposite requirement.Workflows are unaffected.
preferSummarizeis only consulted byreact-agent.jsandchat-orchestrator.js;workflow-orchestrator.jsdoes not look at it. The four workflows chaining these abilities (check-if-hacked,performance-check,plugin-audit,site-cleanup) readr.abilityIdresults directly and are untouched.Prerequisite: broken markdown
Enabling
preferSummarizemeantsummarize()output goes to the user verbatim, with no LLM in between. Which surfaced this:* * 8.2 * *instead of**8.2**, so it renders as literal asterisks. Present throughoutsite-health, and also incore-site-info,core-environment-infoandplugin-list. The LLM had been silently repairing it on the way out, which is exactly why nobody noticed.Fixed in all four files. The last three do not get
preferSummarizein this PR, but leaving known-broken markdown in place because it happens to be masked did not seem right.Testing
disableThinkingis set, constrained on the follow-up turn oncedisableThinkingAfterToolfires, andstructuredOutput: falsehonoured.npx wp-scripts lint-jsclean on every modified file. The 4 remainingmodel-loader.jswarnings (nested ternary, JSDoc) are pre-existing and outside this diff.npx wp-scripts buildsucceeds. Only the pre-existing bundle-size warnings.What is NOT verified, and why
Item 2 is not empirically validated. The tests above prove the request is shaped correctly and gated correctly; they do not prove the model behaves better, because they run against a mocked LLM.
The Ollama ability harness cannot validate it either. Verified rather than assumed:
tests/abilities/runner.jsimports nothing fromsrc/extensions/services/. Its own comments say it mirrorsreact-agent.js's system prompt builder (line 168) and JSON parser (line 211). It is a reimplementation, so it never executes theresponse_formatpath.tests/abilities/load-abilities.jsextracts only{ id, label, description }by regex. This PR changes engine request params, post-tool rendering and docs, none of which the harness can see.So the harness measures tool selection only, and this PR cannot move that number in either direction. What it can do is catch a regression in the regex loader, since I edited seven ability files. That check passed without needing Ollama: 35 abilities extracted on this branch, identical to
main, with all seven edited files parsing correctly.Genuinely validating item 2 needs WebGPU in a real browser, which means the E2E suite, not the Ollama harness.
What this does not do
It does not touch the engine, the model, or the WebLLM version, and it does not address the "7B struggles" question at the model level. If these three do not move the needle, the next honest step is the cloud fallback already on the roadmap, not a different small model.
Refs #228