Repository navigation
Field report: local Qwen3.8-Flash-Next (Strata) as an agent building 3D scenes in Unreal Engine 5.8 via MCP — what broke, what fixed it, what is still missing #980
Description
Field report: a local Qwen3.8-Flash-Next served by Strata, used as the "brain" of an agent (Hermes Agent on Windows 11) that builds and checks 3D scenes in Unreal Engine 5.8 through the official Unreal MCP. Four days of work (2026-10-01 → 10-05): what broke, why, what fixed it, and what is still missing.
Companion to #971 (hardware, VM/vGPU, CUDA 12 on driver 550, UD-IQ4_XS with images) and #804 / #970 (tool call written inside
<think>). Those details are not repeated here.Numbers come from the Strata logs,
/metrics, the Hermes session database and the project's own records. Counts are VERIFIED unless marked otherwise. Anything that is only an opinion is marked (impression).
TL;DR
What works today:
- The agent works in a real UE 5.8.3 project (an animation project with a throne room). It:
- places and poses skinned characters (Mixamo animations);
- fixes lighting and staging;
- frames and captures the viewport, and judges the capture with the model's own vision;
- writes its evidence to a structured log.
- It ran a 5-step experiment queue unattended: 198 tool calls in ~90 min, 7 human messages, none of them a repair.
What made the difference:
- Official Qwen sampling on the server. It had been greedy, which caused runaway loops.
- Turning vision on. We ran ~20 h "blind" without noticing.
- The
<think>tool-call fix (serve: a tool call written inside the thinking ends it implicitly (#804) #970). - Project skills written from the agent's own error log.
- Numeric gates (projection, bounds, traces) before any visual judgement.
Before → after:
| Before | After | |
|---|---|---|
| Runaway repetition loops | 3 in ~4.5 h | 0 in the last ~19.5 h |
| Agent stops after announcing an action | 4 times | 0 (5 recovered by the server) |
| Unreal MCP error rate | ~20–25% | ~8% on tasks the skills cover |
| Tool calls per human message | 11–17 | ~28 (night run) |
"20× better" is an impression. The measured proxies are 2–3×, plus whole failure classes removed (loops, stranded calls) and vision added.
Still missing:
- Vision is a second opinion, not a judge.
- Modelling furniture from primitives does not work yet (see "A design failure").
- New MCP toolsets still cost 15–19% errors.
- Long unattended runs need harness discipline.
- The current blocker (the Sequencer stops driving the mesh in the editor) is on the engine side.
How it fits together
Windows 11 Linux VM + GPU (see #971)
┌──────────────────────────────────────┐ OpenAI API ┌─────────────────────────────┐
│ Hermes Agent │ (ssh tunnel) │ Strata server │
│ main model ─────────────────────────┼───────────────>│ Qwen3.8-Flash-Next │
│ vision_analyze (aux task) ──────────┼───────────────>│ (IQ3_S → UD-IQ4_XS) │
│ goal judge: cloud model, │ │ image encoder on the GPU │
│ local Qwen as fallback │ │ /health, /metrics │
│ │ └─────────────────────────────┘
│ MCP client ── HTTP ──> Unreal Editor 5.8.3 (official MCP server)
│ execute_code / terminal / read_file / patch (local Python tools)
│ project files on a network drive (rclone SFTP) <── same files ──> VM disk (git only here)
└──────────────────────────────────────┘
How the agent "sees" the viewport. Images reach the model only through the auxiliary vision_analyze task: a separate, short request with the image and a question. The agent's own turns never carry images.
-
Frame by numbers. One
execute_tool_scriptcall:- places the editor camera;
- projects the 8 corners of the target volume (
WorldPosToScreenCoords); - moves until the target fills ≥12% of the frame;
- checks occlusion with
trace_world.
It returns PASS/FAIL without asking the model anything.
-
Capture inside Unreal.
CaptureViewport(passcaptureTransform), then write the base64 PNG to disk from Unreal. Only the path goes back through MCP: a lit capture is 3–6 M characters, and the client cuts tool results at 2 M. -
Decode locally and check the PNG signature.
-
Ask the model: a neutral question first ("describe only what is clearly visible in the centre"), then specific yes / no / can't see questions.
What went wrong, and how it was fixed
| # | Symptom | Root cause | Fix | Status |
|---|---|---|---|---|
| 1 | "Response dominated by repeated text"; one reply reached 102,833 tokens | No sampling in the Strata config, and Hermes sends none, so the engine was greedy |
Qwen thinking defaults in the config (temp 1.0, top_p 0.95, top_k 20, presence 0) + reasoning_budget_tokens: 16000 |
✅ No loop in the last ~19.5 h |
| 2 | Agent announces an action, then stops ("reasoning-only clean stop") | Tool call written inside <think> with no </think> |
#970 | ✅ 4 stops before, 0 after; 5 recovered by the server |
| 3 | Visual checks "ambiguous", errors, or skipped | Post-install check we skipped: setup asks "Do you want images?" (default No, so a --yes install has none). /health showed images: false, the first vision_analyze got HTTP 400, and nothing in the agent turned that into a stop |
Answer yes / --vision gpu, then check /health → "images": true and send one test image; later UD-IQ4_XS with images (#971) |
✅ ~20 h of blind work before we noticed |
| 4 | Vision answers looping or timing out (~120 s) | Hermes' auxiliary vision task defaults to temperature 0.1 | auxiliary.vision.temperature: 1.0 |
✅ loops / ❓ timeouts stopped later, unexplained |
| 5 | Captures described as "wireframe", contact "ambiguous" | Viewport in wireframe/unlit, or too dark | Capture in Lit; reject empty or black frames; "ambiguous" = NOT_EVALUATED | 📋 rule (view mode not settable via MCP) |
| 6 | Files with N| prefixes, a 0-byte state file, stale_write_blocked, corrupted .git/index |
The model did not know the real return shapes of the file tools; failed renames on the network drive | Return shapes documented in AGENTS.md; git and script edits only on the VM |
📋 contained |
| 7 | Broken PNGs, context pressure (4 compactions in one session) | Huge MCP results (largest 2,000,083 chars) | Captures via disk; schemas dumped to files and searched | ✅ worked around |
| 8 | 71 errors in 276 MCP calls in the first sessions | MCP tools hidden behind a meta-tool (JSON quoting broke); real UE 5.8 schemas differ from the model's guesses | Workaround if your model breaks JSON through the meta-tool: tools.tool_search: off. Plus a project skill with one rule per observed error |
✅ ~8% on known paths, 15–19% on new toolsets |
| 9 | Agent stops after one item or at the turn limit | max_iterations=60 |
agent.max_turns: 250, /goal with an explicit stop condition, rules on when a turn may end |
✅ night queue ran to the end |
| 10 | Wrong figure judged; details invented ("filling-in") | Several figures in frame; small-model vision | One target per capture, named by position; numeric gates first | 📋 protocol |
| 11 | Two agent sessions drove the same editor for 12 min | An old session with an active /goal resumed after a restart |
Stop it, /goal clear, discard that window's data |
📋 procedure |
| 12 | The Level Sequence stops driving the mesh in the editor | Editor/engine side (open) | Under investigation | ⏳ The agent refused to mark the recipe "verified" (correct) |
Row 12, the open Sequencer blocker:
Night lab E2: after Sit To Stand with KeepState, the centre character ends standing in front of its chair (frame 80). The only time the recipe worked.
E5 after an editor restart: a fresh sequence with the same recipe leaves the character seated at frame 80. The Sequencer stopped driving the mesh (engine/editor side, open).
Details on the costliest items (1, 3, 8)
1. Greedy loops.
- Hermes sent no sampling fields: checked in 7 saved request dumps.
- Strata keeps the engine default (greedy) when the temperature is absent.
- After the fix, the longest reply was ≤27,752 tokens. Part of that drop comes from the 16k reasoning budget.
- One later loop came right after a 503,090-character tool result, so huge noisy context may also trigger loops (impression, one case).
3. Blind for ~20 hours.
- The base model is multimodal, so everyone assumed it could see.
- The agent judged poses by numbers only and wrote "capture not judged by image".
- Once vision was on, a capture exposed legs going straight through a seat (a component-flag bug) that the numbers had missed.
- Lesson: after any server (re)install, check
/health→"images": trueand send one synthetic-image probe before trusting a visual step.
8. MCP schema friction. Breakdown of the 71 errors:
- ~18 in
execute_tool_script: wrong module,run()not returning a dict, code outside a function; - 7 ×
'calls' is not valid JSON, through the meta-tool; - 9 guessed property names;
- the editor paused the MCP for ~60 s after 3 consecutive errors, 9 times.
Schema traps we hit:
set_properties.valuesmust be a JSON string; with an object it just returnsfalse;CaptureViewportneedscaptureTransform, although the schema says it is optional.
What works today
Flows the agent completes on its own:
-
Seat a character with a Mixamo animation.
- Import the FBX against the existing skeleton.
- Set
AnimationSingleNode+animationData, thenbUseRefPoseOnInitAnim=false. - Turn the component to face the seat.
- Read back every property.
- Check with bounds (seated ≈ [pivot−44, pivot+136.5] cm vs. standing [pivot, pivot+180.5]).
- Capture and ask vision.
Five characters were done this way.
-
Stage background characters against a wall: a trace from the chest expects ~35–40 cm. Light them with fill lights measured as a figure/ambient luminance ratio on the same camera, because absolute values drift.
-
Unattended lab night.
- A JSONL queue of 5 experiments with conditions (E3 only if E2 is blocked, and so on).
- Limits of 3 strategies × 3 attempts and ~60 min per experiment.
- One JSONL line per attempt, with 4 gates (structural, geometric, capture, vision), metrics and capture paths.
Night 1 ran it all in ~90 min and correctly reported a FAIL with evidence.
Protocols that worked:
-
Gates before eyes: structural → geometric → capture → vision. Numbers PASS + image FAIL = FAIL. An empty or timed-out vision answer is NOT_EVALUATED, never PASS.
-
Vision as a second opinion: a neutral question first, one target per image, named by its position.
-
Operational memory in plain files:
- a state file with the next action;
- a recipe file (21 recipes, each with how it was validated);
- a failure log (19 entries).
The agent reads the state file always, opens the recipe for the task at hand, and searches the failure log by symptom. Dumping the whole history every session costs context (one session went through 4 compactions). (impression) This is what makes a small local model look much smarter than it is.
-
Safe saves: only explicit asset paths (
save_assetswith an empty list saves everything dirty). Experiments run in a duplicate level. -
Morning review: a script packages the night's evidence (summary + thumbnails) for a stronger cloud model to judge. If the cloud judge is unavailable, the night must not stop.
Where the Unreal skill came from. No public Unreal skill existed (the hubs have Blender ones). We did not port a Blender skill, because the tools differ completely. We took the verification ideas from the operator's Blender skills:
- compare from large to small (framing → occluders → silhouettes → contact → light);
- the first failing category is the only active problem;
- "a successful Python call or an attractive screenshot is not proof".
We rebuilt those ideas with Unreal tools (projection, occlusion trace, coverage threshold, clean capture, neutral question). The capture skill went from v0.1 to v0.4 in ~80 min. It was tested on two targets and a negative control.
How the human and the agent talk
All user turns from the six Qwen sessions with Unreal work, deduplicated, with the tool calls each one triggered. Categories come from simple text rules, so the boundaries are approximate.
| Kind of user turn | Count | Median length (chars) | Median tool calls it triggered |
|---|---|---|---|
| Short natural-language command ("make the guards…") | 31 | 230 | 7 |
| Long structured prompt, usually drafted with a stronger cloud assistant | 23 | 6,707 | 8 |
Mid-turn correction or rule (/steer-style) |
15 | 521 | 7 |
| Admin ("answer OK", "save everything") | 12 | 59 | 0.5 |
| Question / meta ("why did you stop again?") | 7 | 87 | 0 |
/goal text |
3 | 598 | 1 |
| System: compaction summary / restated request | 4 | 13,187 | 26.5 |
| System: goal continuation | ~20 turns | 2,361 | 25, then 5, then 0 for 17 turns |
How a command flows today:
- The operator decides what to do, often with a stronger cloud assistant. For anything non-trivial, that assistant writes a structured prompt: the goal, the files to read first, where the agent may work, the gates, the limits, the log format, and "don't ask for confirmation".
- The operator pastes it into Hermes, followed by a
/goalwith a stop condition. - The local model works through MCP. It reads its state and skills, calls tools, checks itself (bounds, traces, captures), writes evidence and reports.
- The operator corrects rules mid-turn (15 times). Afterwards, a cloud judge or the operator checks the evidence.
What already works as one plain sentence (VERIFIED, 10-05, UD-IQ4_XS):
| Request | Tool calls / time | What the agent did on its own |
|---|---|---|
| "The audience upstairs looks wrong: one head inside another, two look crouched" | 30 / 21 min | Found the cause: an idle animation with root displacement had moved two bodies ~57 cm sideways and ~34 cm below the floor. Swapped the animations and checked with bounds and floor traces. |
| "Place three guards behind the two seated characters, backs to the wall, 2 m apart" | 12 / 15 min | Also fixed a wrong facing left from an earlier mirror operation, and reported the measured spacing (2.00 m). |
Failure modes in the conversation:
- Stopped at the iteration budget: 3 times while
max_turnswas 60. - The operator had to ask why it stopped: 7 meta-questions.
- Goal-judge spin. With the local model as the goal judge, the judge kept answering
continue("lacks concrete evidence…") after the agent had finished. That gave ~20 continuation turns; the last 17 had 0 tool calls, while the agent kept saying "done, close the goal".- Fix: a stronger cloud judge, with the local model only as a fallback.
- Lesson: a weak judge can trap a strong-enough worker.
- Scope drift: a simple request sometimes turned into "pipeline research". Shorter, single-objective prompts helped.
- Answering instead of acting when the request was phrased as a question: say "do it", not "can you…".
(impression) The local model is now good at local, checkable edits described in one sentence. Decomposition (turning an intention into ordered, verifiable steps with limits) still comes from the human and a cloud assistant. The long prompts are where the "intelligence" of the plan lives today.
A design failure: a geometric PASS is not a design PASS, and the agent overrode its own (mostly correct) vision
What was asked (one sentence, 10-05 15:22–15:42, Hermes session DB): make the central witness/guest table fit the hall, "without overdoing it", usable by a seated character and for future props.
What the agent did (19 tool calls, ~20 min):
- built a table from 13 merged cube primitives;
- reported 1.59 × 0.79 m, top at 80 cm, with knee clearance "verified" by traces;
- saved it and reported success.
Asked in one sentence to make a central table "fit the hall, without overdoing it", the local agent built this from 13 merged cubes. It passed every numeric check (size, 80 cm top, knee clearance by traces) and was saved as done.
What its own vision said (3 calls):
- The table was not in the frame. Vision correctly answered "can't see".
- "Looks more like a closed cabinet / registry counter, not a console; no free knee space; chair at the short end."
- "The table looks undersized/low."
Two of the three design remarks were right and are visible in images 01/04: the agent's own reasoning computed side panels from 8 to 40 cm, leaving only ~26 cm of open space under the top (40–66 cm), so "no free knee space" and "closed cabinet" were true. The "too low" remark was probably wrong (bounds put the top at 80 cm). The failure is that the agent dismissed all of it, including the correct parts.
What the agent reasoned: "this bothers me" → "the vision misjudged; I verified with traces and bounds" → saved. The selection gizmo was still in the shot, and the asset name had a doubled prefix (SM_SM_).
The agent's own capture of the table. Vision answered "looks more like a closed cabinet / registry counter, not a console; no free knee space; chair at the short end", and a third call said "the table looks undersized/low". The agent reasoned "this bothers me", then "the vision misjudged; I verified with traces", and saved.
The first capture for the same check did not contain the table at all; vision correctly answered "can't see". "Target not in frame" must count as a failed capture.
The same table in context, seen from the far end facing the throne (editor, Lit mode). The over-built panels and the chair stuck to the short end are obvious to a human at a glance.
Lessons:
- (a) Numeric gates prove physical constraints, not design. Size, height and knee clearance were all "true", and the table is still wrong.
- (b) A small model will rationalise away a negative visual verdict, even a correct one, unless a rule forbids it. Our rule "numbers PASS + image FAIL = FAIL" lived only in the night-lab prompt, not in the skills used for ad-hoc requests.
- (c) Asset creation needs a final design gate:
- any NO from vision on the request's main criterion = FAIL, or ask the human;
- never save as accepted;
- "target not in frame" = capture FAIL.
- (d) Modelling from primitives with a small local model is far from usable. Prefer importing assets (marketplace or a Blender pipeline), and let the agent place and verify them. That is where it is good.
Speed: IQ3_S vs UD-IQ4_XS, and what images have to do with it
This updates #971. #971 gave ~10% slower decode, based on the first ~47 requests. With the full sample, the per-token cost is ~23–25%.
Hermes sends images only through the short vision_analyze requests. That lets us split the traffic cleanly (/metrics, same machine, same sampling, images on in both):
| IQ3_S (0.1.38) | UD-IQ4_XS (0.1.39) | Change | |
|---|---|---|---|
| Vision requests (image + question): output median | 2,058 tok | 2,251 tok | +9% |
| ↳ decode | 100 tok/s | 75 tok/s | −25% |
| Agent turns (prompt ≥60k, no images): output median | 630 tok | 1,355 tok | ×2.2 |
| ↳ decode | 102 tok/s | 78 tok/s | −23% |
| ↳ wall time per turn (median) | 12.8 s | 30.0 s | ×2.3 |
| Prompt reading (≥20k new tokens) | ~3,920 tok/s | ~3,200 tok/s | −18% |
| MTP draft acceptance | 62% | 62% | = |
How to read it:
- The per-token slowdown is mechanical and has nothing to do with images. It is the same with or without them. The cause is bigger experts, so fewer fit in VRAM: 17,761 → 14,672 cache slots, and the expert-cache hit rate drops from 98.6% to 96.8%.
- The new model does not write more about images. Vision answers are about the same length.
- The new model writes ~2× more in the agent's text turns. This is most of the longer wall time per turn. These logs cannot tell more careful reasoning from different tasks on different days.
- Stability (small sample for UD-IQ4_XS): replies ≥8k tokens 3.5% → 2.0%, cancels 2.3% → 1.4%, 0 server errors in both.
- UD-IQ4_XS + vision works. Its visual quality vs IQ3_S was not compared in a controlled way: IQ3_S had vision for ~20 h (period D), with the same image encoder (mmproj); only the LLM changed. Keeping UD-IQ4_XS is a preference (impression); a small blind text comparison has not shown a measurable difference yet.
How far is "the host opens the door, welcomes the guest, both sit and have tea"?
That sentence is a chain of verbs on two characters, with timing between them and checks over time. Status of each link (VERIFIED from the project's recipe, failure and state files):
| Link | Status today |
|---|---|
| Static placement, facing, seated pose (Mixamo) | ✅ Works (5 characters, validated by captures) |
| Standing idles without drift | ✅ Works (root-displacement pitfall known) |
| Stand up from a chair (one-shot in the Sequencer) | |
| Sit down | ⬜ Not tested (the animation file is there) |
| Walk A → B and stop at a mark | ⬜ Not validated (locomotion Blueprint saved as "recipe not validated") |
| Door mechanic (hinge, open/close) | ⬜ Not built (doors are box placeholders) |
| Hand on door handle (contact / IK) | ⬜ Not started |
| Greeting (two characters facing and timing each other) | ⬜ Not started |
| Props in hand (cup, teapot) | ⬜ Not started in Unreal (exists only on the Blender side) |
| One timeline with two actors | ⛔ Blocked by the same Sequencer issue |
| Verify motion (start/middle/end captures, contact over time) | |
| Decompose the sentence into ordered verbs with limits | 👤 Human + cloud assistant today |
Overview of the room the agent staged. Placement and posing work; modelling a piece of furniture does not.
What would have to exist:
- A validated recipe per verb (walk-to-mark, stand up, sit down, open door, greet, pick up, drink, put down). The night-lab method produced one candidate recipe in ~90 min of agent time, so it works once the Sequencer blocker is solved.
- A composition layer: a sentence → a list of verb recipes, with marks, durations and actors → one timeline, with gates at every hand-off.
- Two-character timing: who waits for whom, facing, distances.
- Reliable evaluation of the timeline in the editor, or verification through PIE / Movie Render Queue.
Estimate (impression, not measured):
- About 8 verbs at 1–2 lab nights each, plus the composition layer: weeks, not days.
- The first realistic milestone is "one character walks to the door, opens it, the other walks in, both sit".
- The tea part (props in hands) is the furthest away.
- The table case shows that modelling is even further away than animation.
Still far from ideal
- Vision cannot judge alone.
- It fills in details (4 panes where there are 2) and reads shading as body features.
- It misreads dark or wireframe frames and picks the wrong figure in crowded shots.
- Every visual claim still needs a numeric gate.
- New MCP toolsets are expensive: 15–19% errors while exploring the Sequencer. The model guesses names before reading the schema.
- UE limits:
- Character bounds stay at the T-pose and do not follow the pose.
trace_worlddoes not hit characters.- No view-mode switch via MCP;
load_levelcannot discard changes. - The script sandbox allows only a few modules, and
openis read-only.
- Skills are routed by their one-line description:
- Hermes shows only that line, and the body is read only if the model opens it, so a stale description misroutes a small model.
- We needed a hard rule in
AGENTS.md: "before any Unreal MCP call, read skill X".
- The network drive (rclone over SFTP) can truncate files on failed renames and serve stale content. We avoid writing through it.
- Unattended runs need the harness supervised more than the model: resumed sessions, a goal judge that pauses after transport failures, permission prompts.
Recipe: repeat this setup
Strata server:
- Install with images (
--vision gpu). Confirm/health→"images": trueand send a synthetic-image probe (3 coloured shapes: count, shape, colour). - Put a
samplingblock instrata-*.json, because many agent clients send none and the default is greedy:temperature 1.0, top_p 0.95, top_k 20, min_p 0, presence_penalty 0, repetition_penalty 1.0. Optionally addreasoning_budget_tokens(we use 16000). - Apply serve: a tool call written inside the thinking ends it implicitly (#804) #970 and watch
/metrics→totals.implicit_reasoning_ends. - When you change quant, add
"aliases": ["<old model name>"]so the client config stays the same. - Watch
/metricsforcancel/disconnect/lengthand very long replies.
Hermes Agent:
- Use a custom provider with
context_lengthequal to the server's (262,144). Compression at 50% with the last 20 messages protected worked. - Set
auxiliary.vision.temperature: 1.0and a generous timeout, then check that the timeout really applies. - Set
tools.tool_search: offif the model breaks JSON through the meta-tool. - Set
agent.max_turnshigh (250) and use/goalwith an explicit stop condition. Give a cloud goal judge a local fallback. - Write the real return shapes of the file tools into the agent instructions.
- Before an unattended run: exactly one session alive, old goals cleared, no pending permission prompts.
Unreal MCP:
- Write a project skill from your own error log, one rule per error, and point to it from the always-loaded instructions.
- Never send a big base64 image through MCP. Write it to disk inside Unreal and return the path.
-
set_properties.valuesis a JSON string. Read every property back after setting it. - Use
save_assetswith explicit paths only, and experiment in a duplicate level.
Verification:
- Run the gates in order: structural → geometric → capture → vision.
- Capture in Lit, one target per image, a neutral question first.
- Log one JSONL line per attempt. Keep a state file (read every session), a recipe file (opened for the task at hand) and a failure log (searched by symptom); do not dump them all into every session.
- Have a stronger model or a human review the evidence package, not the agent's summary.
Reusable kit
These are sanitized English versions of the files the agent actually used, written from its own errors (dates refer to this project). Adapt the <placeholders>. They target Hermes Agent + the official Unreal MCP (UE 5.8.3), but the rules are generic.
Skill: Unreal MCP rules for a local model (unreal-mcp-rules/SKILL.md)
---
name: unreal-mcp-rules
description: "Unreal MCP rules for the local model (read before ANY Unreal call)."
version: 0.1.0
license: MIT
---
# Unreal MCP with a local model
Applies to EVERY call to Unreal. The rules come from real errors (71 errors in 276 MCP calls); each rule names the error it prevents.
## How to call
- If `mcp__unreal_engine__call_tool` is in your tool list, call it DIRECTLY with `toolset_name` (full name, e.g. `animation_toolset.toolsets.controlrig.ControlRigTools`), `tool_name` (short name) and `arguments` (an object).
- If it is NOT in the list (deferred tools: "Tool 'mcp__unreal_engine__call_tool' does not exist"), use the `tool_call` meta-tool with ONE call in `calls`. Its JSON broke 7 times ("'calls' is not valid JSON"): keep arguments short and, in scripts, use single quotes in Python and no double quotes.
- One Unreal call per reply (the editor runs them serially).
- "Unknown tool X" = `toolset_name` is missing.
- Before using a tool for the first time: `describe_toolset` writes the schema to a file (path in the result); search the tool name in that file and pass ALL `required` parameters.
- "input param X is required by the function input schema": the message contains the schema; copy the names from it.
- After ONE error, read the message and change exactly the argument it points to. Three errors in a row pause the MCP for ~60 s (happened 9 times): wait, and do not repeat the same call.
## Scripts (`execute_tool_script`): the biggest error source (~18 of 71)
- Prefer `call_tool` directly. Use a script only for a loop of 5+ similar calls.
- Mandatory shape, always:
```python
import json
def run():
r = execute_tool("full.toolset.Tool.method", json.dumps({"arg": 1}))["returnValue"]
return {"result": r}
```
- `run()` returns a dict, never a list ("run() must return a dict"). All code lives inside functions (loose code gives "outside function").
- Allowed modules: `datetime`, `re`, `time`, `copy`, `json`, `math`. `unreal`, `base64` and anything else are forbidden.
- Keep scripts short (~30 lines). What ran before an error stays applied: re-read the state before running again.
## Properties
- `ObjectTools.set_properties`: `values` is a JSON STRING (e.g. `"{\"relativeLocation\": {\"x\": 0, \"y\": 0, \"z\": 0}}"`), not an object; with an object the answer is just `false`. One property per call, then read it back with `get_properties` (`instance`, `properties`).
- `AssetTools.find_assets` requires `folder_path` (e.g. `/Game`).
- `CaptureViewport` requires `annotations`: `{"gridSpacing":0,"maxLabelDistance":0}` gives a clean image. Put the camera INSIDE the room.
- Do not guess names in `get_properties` / `set_properties` (9 errors "the following properties could not be read"): read the object's properties unfiltered first and copy the exact name. After a `set`, read it back.
## Captures and vision
Follow the `unreal-capture-evidence` skill (framing by projection, capture through disk, neutral question first).
## Types and names
- A class is not a struct: parameters like `base_class` and `actor_type` take CLASSES. Control Rig nodes (`RigUnit_*`) are structs; never pass them there ("is not valid Class for property 'base_class'").
- Control Rig node names: only the ones confirmed in your project's pose skill. "Cannot find struct": do not vary the name by guessing.
- Blueprint event names: `EventBeginPlay` and localised variants failed ("AddEvent|... does not exist"). Find the name with `BlueprintTools.find_node_types` before creating it.
- "Cannot load X: the level has unsaved changes": the current level must be saved before opening another. Save only if the task allows it.
## If the `mcp__unreal_engine__*` tools are missing in the session
Tell the operator: the session must be reopened with Unreal already running. Do not improvise an MCP client.Skill: Unreal capture as evidence (unreal-capture-evidence/SKILL.md)
---
name: unreal-capture-evidence
description: "Unreal capture as visual evidence (camera framing and vision)."
version: 0.4.1
license: MIT
---
# Unreal capture as evidence
Use whenever an Unreal image is proof of something. Fixed order: target → volume to frame → framing by projection (script below) → gates → clean capture → neutral vision → specific questions. Do not touch the scene while setting the camera.
## Facts (tested on UE 5.8.3)
- `WorldPosToScreenCoords` (EditorAppToolset) projects world → screen 0..1 through the viewport camera: it is the framing gate. Off-screen it errors (treat as "out of frame").
- `CaptureViewport` requires `captureTransform` (errors without it, although the schema says optional) and `annotations` (`{"gridSpacing":0,"maxLabelDistance":0}` = clean image).
- `trace_world` hits walls and furniture only, NOT characters. Occlusion by another character is caught only by vision.
- Character bounds come from the T-pose and do not follow pose/position: do NOT frame by the character. Frame by the SEAT (or prop).
- All of this works inside ONE `editor_toolset.toolsets.programmatic.ProgrammaticToolset.execute_tool_script` call.
## 1. Frame (one call)
Volume for a seated person: the seat's bounds (`ActorTools.get_actor_bounds`), Z from the floor to the seat top + 150 cm, +70 cm in front of the seat. Run (change only the CONFIG line):
```python
import json, math
def run():
MN, MX, FRONT_YAW, ELEV = [<min_x>, <min_y>, <min_z>], [<max_x>, <max_y>, <max_z>], <seat_front_yaw>, 5 # CONFIG: volume min, max; yaw the seat faces; elevation (deg)
E = 'EditorToolset.EditorAppToolset.'
c = [(a + b) / 2 for a, b in zip(MN, MX)]
corners = [(x, y, z) for x in (MN[0], MX[0]) for y in (MN[1], MX[1]) for z in (MN[2], MX[2])]
dist, log = 250.0, []
for attempt in range(6):
a = math.radians(FRONT_YAW)
cam = {'x': c[0] + dist * math.cos(a), 'y': c[1] + dist * math.sin(a), 'z': c[2] + dist * math.tan(math.radians(ELEV))}
dx, dy, dz = c[0] - cam['x'], c[1] - cam['y'], c[2] - cam['z']
rot = {'pitch': math.degrees(math.atan2(dz, math.hypot(dx, dy))), 'yaw': math.degrees(math.atan2(dy, dx)), 'roll': 0}
execute_tool(E + 'SetCameraTransform', json.dumps({'transform': {'location': cam, 'rotation': rot}}))
pts = []
for p in corners:
try:
r = execute_tool(E + 'WorldPosToScreenCoords', json.dumps({'position': {'x': p[0], 'y': p[1], 'z': p[2]}})).get('returnValue')
except Exception:
r = None
pts.append(r)
if any(p is None for p in pts):
dist *= 1.4; continue
xs = [p['x'] for p in pts]; ys = [p['y'] for p in pts]
bb = [min(xs), min(ys), max(xs), max(ys)]
cov = (bb[2] - bb[0]) * (bb[3] - bb[1]); cen = [(bb[0] + bb[2]) / 2, (bb[1] + bb[3]) / 2]
log.append({'dist': round(dist), 'bbox': [round(v, 2) for v in bb], 'coverage': round(cov, 2)})
if bb[0] < 0.03 or bb[1] < 0.03 or bb[2] > 0.97 or bb[3] > 0.97:
dist *= 1.25; continue
if cov < 0.12:
dist *= 0.8; continue
hit = execute_tool('editor_toolset.toolsets.scene.SceneTools.trace_world', json.dumps({'start': cam, 'end': {'x': c[0], 'y': c[1], 'z': c[2]}})).get('returnValue')
d = math.dist([cam['x'], cam['y'], cam['z']], c)
return {'gate_framing': 'PASS', 'gate_scene_occlusion': 'PASS' if (hit is None or hit >= d - 30) else 'FAIL',
'camera': cam, 'rotation': rot, 'centre_on_screen': [round(v, 2) for v in cen], 'coverage': round(cov, 2), 'log': log}
return {'gate_framing': 'FAIL', 'log': log}
```
Gates: `gate_framing` PASS (whole volume on screen, coverage ≥ 0.12) and `gate_scene_occlusion` PASS. FAIL on either: change angle/volume and run again; do not call vision.
- Frontal (FRONT_YAW = the direction the seat faces) is mandatory for sitting. On a chair with arms, 3/4 hides the hips; up to ~15° to the side works.
## 2. Clean capture (ALWAYS through disk)
The PNG never goes through the MCP: the client cuts any tool result above 2,000,000 characters, and a lit capture is ~3–6 million in base64. Unreal writes the base64 to a `.txt` (`write_file` only accepts .csv .html .json .md .py .txt) and returns only the path:
```python
import json
def run():
CAM, ROT, NAME = {'x': <cam_x>, 'y': <cam_y>, 'z': <cam_z>}, {'pitch': <pitch>, 'yaw': <yaw>, 'roll': 0}, '<target> - frontal - HHMM' # CONFIG: camera/rotation from step 1
cap = execute_tool('EditorToolset.EditorAppToolset.CaptureViewport', json.dumps({'captureTransform': {'location': CAM, 'rotation': ROT, 'scale': {'x': 1, 'y': 1, 'z': 1}}, 'annotations': {'gridSpacing': 0, 'maxLabelDistance': 0}}))
data = cap['returnValue']['image']['data']
dest = '<project>/Saved/Captures/' + NAME + '.txt'
execute_tool('editor_toolset.toolsets.asset.AssetTools.write_file', json.dumps({'file_path': dest, 'content': data}))
return {'file': dest, 'chars': len(data)}
```
Inside `execute_tool_script` do NOT use `open()` to write (only 'r', 'rb', 'rt' are allowed) nor `base64`: write only with `AssetTools.write_file`. Then, with local Python: `base64.b64decode(open(file).read())` → save as `<captures folder>/<target> - <view> - <HHMM>.png` and check that it starts with `PNG`. Use that PNG for vision.
## 3. Vision (vision_analyze)
1. Neutral, always first: "Describe only what is clearly visible in the centre of this image. Do not assume anything that is not visible." If the target does not appear (e.g. seated figure + seat), or something else is in the way, the capture is invalid: **target not in frame = capture FAIL**.
2. Specific, about "the human figure in the centre", each answered YES / NO / CAN'T SEE: seated? knees towards the camera? legs come out of the front of the seat? any part through the seat or floor? feet on the FLOOR (not on top of the seat)? head fully visible?
- The neutral description does NOT prove contact: it once called a mannequin "seated" with its feet on top of the seat. Contact only through the specific question.
- Backrest behind the back: the frontal view cannot prove it when the torso hides the backrest → NOT_EVALUATED; use a side view only if that item is required.
- Empty answer = NOT_EVALUATED (the model reasons 1–2k characters before answering); retry, never count it as PASS.
## States
PASS / FAIL / NOT_EVALUATED, in order: framing → occlusion → neutral → specific. A later step never becomes PASS if an earlier one failed. Not verified does not count as approved. Numbers PASS + image FAIL = FAIL. Unreal state (name, transform, camera target) does not prove what the image shows.
- **Asset creation (added after the table incident, see "A design failure" above):** any NO from vision on the request's main criterion = FAIL, or ask the human. Never save as accepted, and never dismiss the verdict with numbers.State file template (STATE.md, read at the start of every session)
# <Project> — Unreal state
Updated: <YYYY-MM-DD HH:MM> (<what the last session did, one line>)
## Current level
`<level path>`: saved at <time>. Engine <version>, MCP <host:port>.
Unsaved/experimental actors in memory: <list, or "none">. Never save: <assets that must not be saved>.
## Done (do not redo)
1. <item>: <facts the next session needs: names, numbers, which recipe (R-xx) validated it>
2. ...
## Open problems
- <symptom>: <what is known>; see FAILURE-xx in the failure log.
## Next action
<one concrete next step, with the file/recipe to read first>
## Checkpoint of work in progress
<only while something is half-done: what was changed, what is still unsaved, how to resume or roll back>Companion files (opened on demand, not dumped into every session):
- Recipes (
R-01 … R-nn): the exact tool, minimal arguments, conditions, versions, observed result, and how it was verified. - Failure log (
FAILURE-01 … nn): symptom → cause → fix → evidence. The agent searches it by symptom before retrying something that failed.
Lab queue and JSONL attempt log (unattended experiments)
Queue (LAB_QUEUE.jsonl, one experiment per line, written by the human or a cloud assistant):
{"id": "N1-E2-sequencer-native", "night": 1, "verb": "stand_up", "order": 2,
"status": "pending",
"question": "Does <technique> make the character end standing at the right spot?",
"steps": "tool + essential parameters, in order",
"gate_pass": "what evidence makes this experiment PASS",
"condition": "only if N1-E1 passed",
"forbidden": "e.g. editing the main level; touching bones / Control Rig"}status: pending | done | done_result_FAIL | not_run_condition | BLOCKED.
Attempt log (LAB_LOG.jsonl, append ONE line per attempt, pass or fail; never rewrite the file):
{"id": "N1-E2-s2-t3", "experiment": "N1-E2-sequencer-native", "strategy_num": 2, "strategy": "<short name>",
"attempt": 3, "hypothesis": "...",
"steps": ["tool + essential parameters", "..."],
"gates": {"structural": "PASS", "geometric": "PASS", "capture": "PASS", "vision": "NOT_EVALUATED"},
"metrics": {"actor_pos_start": [0, 0, 0], "actor_pos_end": [0, 0, 0],
"bounds_centre_start": [0, 0, 0], "bounds_centre_mid": [0, 0, 0], "bounds_centre_end": [0, 0, 0],
"duration_s": 0.0},
"captures": ["<captures folder>/LAB N1-E2-t3 - end - frontal - HHMM.png"],
"result": "PASS|FAIL|NOT_EVALUATED|BLOCKED",
"blocked_reason": "only if BLOCKED: LIMITS_EXHAUSTED | INFRA_LOG | INFRA_UNREAL",
"lesson": "one factual sentence limited to exactly the conditions tested",
"logged_at": "YYYY-MM-DDTHH:MM"}How each attempt is judged (in order; a gate runs only if the previous one passed):
- A. Structural (no vision): the asset / track / section / binding exists, the right animation is assigned, duration and frames make sense, and every changed property is read back.
- B. Geometric (no vision): actor and component transforms read back, bounds, distance to the chair, nothing below the floor.
- C. Capture: through the capture skill. Start, middle and end (set the playhead + force evaluate before each), in a frontal AND a side view (forward/backward drift and snaps do not show in the frontal one).
- D. Vision: neutral question first, then the specific ones. An empty answer or a timeout = NOT_EVALUATED.
Result:
- A or B failed → FAIL, and skip the later gates.
- No valid capture or vision answer → NOT_EVALUATED.
- PASS only if A+B+C+D all pass. Numbers PASS + image FAIL = FAIL.
Limits:
- At most 3 attempts per strategy and 3 strategies per experiment, then BLOCKED with the reason.
- About 60 min per experiment at most.
- The same tool error twice → change the approach.
- If the log line cannot be written after 2 tries, stop the night (INFRA_LOG).
Agent-instruction rules (excerpt for AGENTS.md: file-tool return shapes and when a turn may end)
## File editing rules (hermes_tools / execute_code)
- Every `hermes_tools` function returns a ready dict (do not `json.loads` it). Real shapes:
- `read_file(path, offset=1, limit=2000)` → `{"content": "1|line\n2|line", "total_lines": N, "truncated": bool, ...}`.
Each line comes with an `N|` prefix and without `\r`: `content` is NOT the raw file text.
- `read_file` may come back WITHOUT `content`:
- re-reading the same unchanged range → `{"status": "unchanged", "content_returned": False}`;
- error or block → `{"error": "..."}`.
Use `r.get("content")`, check `"error"` / `"status"`, and keep the read in a variable instead of re-reading.
- `write_file(path, content)` → `{"bytes_written": N, ...}` or `{"error": ..., "stale_write_blocked": True}` (the file changed since the last full read: read it again first).
It always overwrites the whole file; never write empty content, nor content with the `N|` prefixes.
- `patch(path, old_string, new_string, replace_all=False)` → `{"success": True, "diff": ..., "files_modified": [...]}` or `{"success": False, "error": "Could not find ..."}`.
- `terminal(command, timeout=None, workdir=None)` → `{"output": "...", "exit_code": N}` (+ `"error"` on failure).
- Before replacing text:
- read the file and copy `old` from that read (without `N|`), never from memory;
- prefer `patch()` to `open()` + `replace`;
- use the smallest unique `old` (one distinctive line).
- For a small file (up to ~150 lines), read it whole and rewrite it with `write_file`.
- If a replacement fails, read the file again before retrying, and do not repeat the same `old`.
- If `write_file` / `patch` answers with any `error` or "post-write verification failed", the file may have been left EMPTY. Check its size, and do not say it was saved without reading it back.
- Run git only on the machine that owns the repository, never through a network drive (a failed rename on the drive corrupted `.git/index` once and zeroed a state file once).
## Replies and end of turn
- End the turn only with:
- the task done and checked (the path of the image/video in the report);
- a real blocker (which error, and what was tried);
- or a point where these rules require the operator's OK.
- Before any Unreal MCP call, read the `unreal-mcp-rules` skill; for image validation, read `unreal-capture-evidence`. (A small model routes skills by their one-line description, so this pointer is mandatory.)Appendix A — Server periods (Strata log)
| Period | Window | Config | Requests | stop / cancel / disconnect / length | replies ≥8k tok | longest | implicit ends |
|---|---|---|---|---|---|---|---|
| A | 10-03 18:30 → 21:14 | IQ3_S, 131k, greedy, no images | 275 | 267 / 2 / 5 / 1 | 10 | 102,833 | – |
| B | 10-03 21:16 → 23:01 | 262k, greedy, no images | 11 | 10 / 0 / 1 / 0 | 0 | 7,306 | – |
| C | 10-03 23:01 → 10-04 14:55 | official sampling + 16k budget, no images | 515 | 502 / 7 / 6 / 0 | 12 | 23,998 | – |
| D | 10-04 14:55 → 10-05 10:44 | + images on the GPU + #970 | 1,274 | 1,230 / 29 / 13 / 2 | 45 | 27,752 | 5 |
| E | 10-05 10:44 → 15:22 | UD-IQ4_XS, 0.1.39 | 137 | 132 / 2 / 2 / 1 | 3 | 12,363 | 0 |
The Strata log lines have no timestamps. The windows come from server restarts matched to config backups and scheduler logs.
Appendix B — Unreal MCP and vision errors per session (Hermes session DB)
| Session | Model | MCP errors / calls | vision errors / calls | tool calls | human msgs | duration |
|---|---|---|---|---|---|---|
| 10-02 17:17 | earlier local 27B (vLLM) | 73 / 294 (24.8%) | 11 / 13 | 618 | 36 | 1,173 min |
| 10-03 18:32 | Qwen IQ3_S (greedy → sampling; blind) | 44 / 227 (19.4%) | 1 / 1 (HTTP 400) | 584 | 53 | 978 min |
| 10-04 11:22 | Qwen IQ3_S (blind) | 30 / 110 (27.3%) | 0 / 3 | 183 | 17 | 294 min |
| 10-04 16:29 | + vision + skills | 17 / 221 (7.7%) | 7 / 43 | 332 | 18 | 200 min |
| 10-04 19:57 | same | 10 / 113 (8.8%) | 9 / 50 | 251 | 28 | 191 min |
| 10-04 23:29 | same (night 1, Sequencer) | 18 / 123 (14.6%) | 0 / 17 | 209 | 8 | 428 min |
| 10-05 06:22 | same (continuation + day) | 33 / 178 (18.5%) | 0 / 16 | 280 | 25 | 541 min |
- Human messages include mid-turn injected messages.
- The night-1 row includes the accidental resume.
- Three short cloud-model sessions on 10-03 (read-only tasks) are left out.
- MCP errors were counted with a regex on the first 700 characters of each result, so the rates are approximate.
Appendix C — Claims and where they come from
| Claim | Source |
|---|---|
| Night 1: 198 tool calls, 7 human messages, ~90 min | Hermes session DB |
| Loops at 10-03 20:55, 22:29, 22:57 and 10-04 11:04, 16:58, 19:49; none after | Hermes agent.log / desktop.log |
| Greedy when the temperature is absent; Hermes sent no sampling fields | serve/server.py docstring; 7 saved request dumps |
| 4 reasoning-only stops before #970; 5 implicit ends after | Hermes agent.log; Strata servidor.log |
images: false (10-03 21:16); HTTP 400 "started without the vision encoder" (10-03 19:58) |
Strata scheduler log; Hermes session DB |
auxiliary.vision temperature 1.0 from 10-04 19:41; 15 timeouts at ~120 s, 16:48–22:45; 0 errors in 33 calls afterwards |
Config backup; Hermes agent.log and DB |
| 74 large results persisted, largest 2,000,083 chars; 4 compactions | Hermes agent.log and DB |
| 71 errors in 276 MCP calls, with the breakdown | Project skill written from the DB |
| Capture skill v0.1 → v0.4 in ~80 min | git log of the skills folder |
| Speed table | Strata /metrics history (1,262 / 147 requests) and engine logs |
| "20× better" | (impression) of the operator; the measured proxies are 2–3× |
Next on this machine: testing even larger quantizations. Questions and corrections are welcome. The aim is that the next person (or LLM) wiring a local model to Unreal starts where we are now, not where we started.







Activity
serve: a specimen corpus for the format fixes
Niko1221 commentedon Oct 7, 2026
Thank you for the field report. We split it into items: a sampling default (Qwen's recommended values in the config, a reasoning budget), vision-off banner for --yes installs, tool call inside think (handled in 0.1.40), the 503 KB tool-result loop (use the opt-in reasoning_loop_recovery), the UD-IQ4_XS speed note (23-25% slower than IQ3_S, for docs), and an agent-harness note. The long loop came from greedy decoding because the client sent no sampling: set temperature and top_p in the request or the config.
Niko1221 commentedon Oct 13, 2026
Thanks for the report. To keep the tracker current we are closing issues older than three days: 0.1.44 is out with a long list of fixes, and many older reports are covered by it or by 0.1.43.1.
If this still happens on 0.1.44, please leave a comment here with your engine log (and the version line it prints), and we will reopen it. A new issue that links this one works too.