Unreal Engine Agent Harness
Build a minimal AI agent harness that can read a UE project, write C++ source,
trigger builds, and report results. Keep it generic enough that anyone can
fork it for their own project and engine.
When to use
- You are building an agent harness for UE C++ / gameplay work.
- You need to choose between a native UE bridge, an MCP server, or an external
protocol.
- You want to scope a first milestone that can be tested on macOS/Linux without
launching the UE editor.
- You want a standalone agent process that talks to a running editor through a
file-based bridge.
Core principles
- The loop is trivial; the tools are the work. Spend effort on the
UE-specific tool wrappers, not the ReAct loop.
- Start standalone. If the agent must run as its own process, design a
file-based bridge from the start; use a stub mode for off-PC development.
- Ship a stub bridge. Let the harness run and be tested on a machine that
does not have the editor open.
- One real end-to-end task in v1. Pick a single C++ class addition, not a
feature family.
- Make memory optional. Persistent memory is useful only after the harness
already works.
Architecture
┌──────────────────────────┐
│ CLI / REPL / TUI │
├──────────────────────────┤
│ Agent Loop │ ReAct: LLM → tool → LLM
├──────────────────────────┤
│ Tool Registry │ project, file, build, editor
├──────────────────────────┤
│ UE Bridge │ native Python Editor Scripting API or stub
├──────────────────────────┤
│ LLM Provider │ pluggable (Anthropic, OpenAI, local)
├──────────────────────────┤
│ Memory (optional) │ Mnemosyne / journal / session JSONL
└──────────────────────────┘
The loop is a single Python file. Tools are thin classes. The UE bridge is a
standalone file-based JSON protocol, with a swappable stub for off-PC development.
The UI layer is separate: start with a REPL, add a one-shot CLI, and only then
add a Textual dashboard.
Key design decisions
Bridge: standalone file-based protocol first
- The agent runs as a standalone Python process.
- It communicates with a running Unreal Editor through a simple file-based
JSON bridge (one file, polled by both sides).
- A
ue_bridge_listener.py script runs inside the editor and executes
requests.
- A
stub bridge mode returns canned responses so the harness can be built
and tested on macOS/Linux without the editor open.
Cross-platform dev split
- Build and test the harness on your main dev machine (macOS/Linux) using a
stub bridge that returns canned responses.
- Move the same code to the Windows PC, set bridge to file, and run with
the editor open.
- This avoids the "I cannot test until I am at the PC" trap.
Tool inventory
- Project:
scan_project, read_build_cs.
- File:
read_file, write_file, dry_run.
- Build:
build_module, parse_build_errors.
- Editor:
editor_command, is_editor_running, compile_blueprints.
- Memory (optional):
memory_remember, memory_recall.
Memory integration
- Persistent memory (e.g. Mnemosyne) is a feature flag, not a dependency.
- If enabled, recall before each task and remember after each success.
- If disabled, rely on a
progress.md journal and the current project state.
- See
references/mnemosyne-integration-recipe.md for a standalone Python integration that does not require running inside Hermes.
Naming the harness
- Check PyPI, GitHub, and generic web search before a name hardens into files.
- If a collision exists, rename early. See
references/agentu-naming-collision.md.
Minimal v1 milestone
- User says: "Add a simple C++ class."
- Agent scans the project and reads existing source.
- Agent recalls prior project memory when memory is enabled.
- Agent proposes changes with
dry_run.
- Agent writes
.h and .cpp files.
- Agent runs
build_module.
- If the build fails, parse errors, feed them back to the LLM, edit files, and
retry up to a configured limit (e.g. 3).
- If the build succeeds, remember the outcome, write
progress.md, and return a summary.
Implementation notes for v1
Config dataclass
Use a single Config dataclass loaded from config.yaml and pass it to every
service that needs configuration (LLM, build tools, bridge, etc.). Do not let
sub-components re-read the YAML file. This keeps the agent's behavior fully
determined by the loaded config.
Required fields:
@dataclass
class Config:
uproject_path: str
default_module: str
bridge_type: str # stub | file
bridge_file_path: str
bridge_poll_interval: float
bridge_timeout: float
llm_provider: str
llm_model: str
llm_api_key_env: str
llm_base_url: str # empty string means provider default
max_build_retries: int
memory_enabled: bool
journal_path: str
db_path: str
approval_mode: str = "auto" # auto | ask | readonly
allowed_paths: list[str] = field(default_factory=list)
_profile_root: str | None = None # set by from_profile
Profile isolation
State (memory.db, sessions/, progress.md, skills/) should live outside the repo. Use a profile directory layout:
~/.agentunreal/profiles/<name>/
├── config.yaml
├── memory.db
├── sessions/
├── progress.md
├── skills/
├── plugins/
└── .env
Add from_profile to Config and a profile_path helper:
@classmethod
def from_profile(cls, name: str, profile_root: str | None = None) -> "Config":
if profile_root is None:
profile_root = str(Path.home() / ".agentunreal" / "profiles" / name)
profile = Path(profile_root)
config_path = profile / "config.yaml"
if not config_path.exists():
raise FileNotFoundError(f"No config.yaml in profile: {profile}")
cfg = cls.from_yaml(str(config_path))
cfg.journal_path = str(profile / "progress.md")
cfg.db_path = str(profile / "memory.db")
cfg._profile_root = str(profile)
return cfg
def profile_path(self, *parts: str) -> Path:
root = getattr(self, "_profile_root", None)
if root is None:
return Path(*parts)
return Path(root) / Path(*parts)
Wire _profile_root into session paths, metrics paths, and SkillTools(skills_dir=...). CLI: --profile <name> loads from the profile dir; falls back to --config / default config.yaml when no profile is specified.
Auto-generated tool schemas
Create a small tools/schema.py that walks the tool registry with inspect:
import inspect
def schema_for(fn):
sig = inspect.signature(fn)
properties = {}
required = []
for name, param in sig.parameters.items():
if name in ("self", "cls"):
continue
param_type = "string"
if param.annotation is not inspect.Parameter.empty:
origin = getattr(param.annotation, "__origin__", None)
if origin in (list, dict):
param_type = "array" if origin is list else "object"
elif isinstance(param.annotation, type):
param_type = {str:"string", int:"integer", float:"number", bool:"boolean"}.get(param.annotation, "string")
properties[name] = {"type": param_type, "description": f"Parameter `{name}`."}
if param.default is inspect.Parameter.empty:
required.append(name)
return {
"type": "function",
"function": {
"name": fn.__name__,
"description": (fn.__doc__ or "").strip(),
"parameters": {"type": "object", "properties": properties, "required": required},
},
}
def schemas_from_registry(registry: dict) -> list[dict]:
return [schema_for(fn) for fn in registry.values()]
Then agent.py exposes the registry to the LLM with schemas_from_registry(self.tools).
Add a one-line docstring to each tool method so the schema has meaningful descriptions.
Path guard
from pathlib import Path
class PathGuard:
def __init__(self, project_root: Path, allowed_paths: list[Path] | None = None):
self.project_root = project_root.resolve()
self.allowed = [p.resolve() for p in (allowed_paths or [])]
def is_safe(self, target: Path) -> bool:
resolved = target.resolve()
if resolved == self.project_root or self.project_root in resolved.parents:
return True
for allowed in self.allowed:
if resolved == allowed or allowed in resolved.parents:
return True
return False
def assert_safe(self, target: Path) -> None:
if not self.is_safe(target):
raise ValueError(f"Path {target} is outside the allowed project tree.")
Pass the guard into FileTools and call assert_safe before reading/writing.
Catch the exception at the tool boundary and return {"error": str(e)}.
Security guardrails
Define:
DANGEROUS_TOOLS = {"write_file", "build_module", "editor_command"}
READONLY_TOOLS = {"scan_project", "read_file", "read_build_cs", "list_source_files", "dry_run", "is_editor_running", "memory_recall"}
In _call_tool:
- If
approval_mode == "readonly" and name not in READONLY_TOOLS, return {"error": "Readonly mode: ..."}.
- If
approval_mode == "ask" and name in DANGEROUS_TOOLS, prompt on stdin with the tool name, path, and a truncated content preview. [y/N] — defaults to deny. Auto-approve if the dry_run hash matches (already reviewed). On EOFError/KeyboardInterrupt (non-interactive context), deny.
- If
approval_mode == "auto", no prompts.
The approval prompt and the dry_run→write_file hash gate are independent layers. The prompt asks "should this dangerous tool run at all?"; the hash gate asks "has this exact content been reviewed via dry_run?". Both must pass for write_file to execute.
def _prompt_approval(self, name: str, args: dict) -> bool:
"""Interactive stdin prompt for dangerous tools in 'ask' mode."""
path = args.get("path", "")
content_preview = args.get("content", "")
if content_preview:
content_preview = content_preview[:200] + ("..." if len(args.get("content", "")) > 200 else "")
prompt = f"\n[APPROVAL] {name}"
if path:
prompt += f" path={path}"
if content_preview:
prompt += f"\n content: {content_preview}"
prompt += "\nApprove? [y/N] "
try:
resp = input(prompt)
except (EOFError, KeyboardInterrupt):
return False
return resp.strip().lower() in ("y", "yes")
Set approval_mode: "auto" in tests so the harness can run without user prompts.
Build retry loop
Inside Agent.run, track build_attempts. When build_module returns
exit_code != 0 and build_attempts < config.max_build_retries, parse the
errors and fold the retry nudge into the tool result — do NOT append a
synthetic user message (see pitfalls: that breaks role alternation + prompt
cache). Cap the error dump so a 30KB UBT log doesn't blow context.
if tool_name == "build_module":
build_attempts += 1
if result.get("result", {}).get("exit_code", 1) != 0 and build_attempts < self.config.max_build_retries:
errors = self.tools["parse_build_errors"](output=...)
result["retry_nudge"] = f"Build failed (attempt {build_attempts}/{self.config.max_build_retries}). Fix these errors and call build_module again: {json.dumps(errors)[:2000]}"
Enforce dry_run → write_file (hash gate)
The system-prompt rule "always dry_run before write_file" is not enforced by
default — a hallucinating agent writes unconditionally. Make it code in
_call_tool by hash-matching the last dry_run(path+content):
import hashlib # + self._last_dry_run_hash = None in __init__
if name == "write_file":
h = hashlib.sha256((args.get("path","") + args.get("content","")).encode()).hexdigest()
if h != self._last_dry_run_hash:
return {"error": "write_file blocked: call dry_run with the same path+content first."}
if name == "dry_run":
self._last_dry_run_hash = hashlib.sha256((args.get("path","") + args.get("content","")).encode()).hexdigest()
Auto memory recall and remember
Before the first LLM call, build a memory context from several queries and
prepend it to the user prompt. After a final answer, remember the outcome. Tools
are only added to the registry when memory is enabled.
def _recall_memory_context(self, user_prompt: str) -> str:
if not self.memory:
return ""
project = Path(self.config.uproject_path).stem
queries = [user_prompt, f"{project} project conventions", f"{project} build failures"]
seen = set()
memories = []
for q in queries:
for m in self.memory.recall(q, top_k=3):
mid = m.get("memory_id")
if mid and mid not in seen:
seen.add(mid)
memories.append(m)
if not memories:
return ""
return "\n".join(["Relevant memory context:"] + [f"- {m.get('content', '')}" for m in memories])
def _remember_outcome(self, user_prompt: str, summary: str, success: bool = True) -> None:
if not self.memory:
return
status = "succeeded" if success else "failed"
self.memory.remember(
f"Project: {Path(self.config.uproject_path).stem}\nTask: {user_prompt}\nOutcome: {status}\nSummary: {summary}",
source="agent", importance=0.7, scope="session"
)
System prompt safety rules
Add explicit safety and workflow rules to prompts/system.txt so the model
knows the constraints:
SAFETY RULES:
- Never write files outside the project Source directory or allowed_paths.
- Always call dry_run before write_file.
- Always pair new .h files with .cpp files under the same module.
- Never delete existing source files unless the user explicitly asks.
- If a build fails, read the error output, fix the root cause, and retry.
WORKFLOW RULES:
- Before writing code, plan: scan the project, read relevant source, then dry_run.
- After writing code, call build_module.
- After a successful build, update progress.md and remember the outcome.
Current state
Verified 2026-07-17 (git log + pytest, 22 green): all 11 evolution-plan tasks are merged — config propagation, auto schemas, path guards, build retry, memory recall/remember, planner, MCP flag, guardrails/approval, eval harness, config sync, e2e test. Beyond the plan: streaming on_event hook, two-pane Textual TUI, --json machine contract, ue5.py CLI, .env loading.
Milestone M3 (2026-07-17, commit 669dd7d) — Phase 0+1 of the three-way diagnostic (docs/DIAGNOSTIC_AND_PLAN_2026-07.md) landed:
- Phase 0 (correctness): role-alternation bug fixed (build-retry nudge folded into tool result via
result["retry_nudge"], no synthetic user msg mid-loop); dry_run→write_file hash gate in _call_tool; stale docs synced, AGENTU_COMPARISON.md deleted.
- Phase 1 (backbone): skills system (
tools/skills.py → skills_list/skill_view, skills/ dir, seeded new-cpp-class.md, system prompt says load-before-task + offer-to-save-after); trajectory harvest (eval/harvest.py → sessions/trajectories.jsonl).
Milestone M4 (2026-07-17) — Phase 2 (Evaluate) + package reorg + web dashboard:
- Phase 2 (Evaluate): deterministic eval in
agentunreal/eval/ — suite.py (EvalExample loader + fixture materializer, tasks in eval/tasks/*.json, kinds new-class/build-fix/refactor/editor-command), score.py (deterministic scoring: build_success, dry_run-before-write, MSVC/LNK codes, constraint violations — NO LLM judge; the engine gives binary ground truth), run_suite.py (stub-mode runner + aggregate rollup), ab.py (A/B two system prompts → pass-rate/build/attempt/violation deltas). "Measure before optimize" gate = python -m agentunreal.eval.run_suite + pytest green; a variant that breaks the suite is rejected regardless of A/B numbers.
- Package reorg (pi-style, stays Python): installable
agentunreal/ package — core/ (agent, llm, Config), tools/, eval/, ui/ (tui, ui_cli, repl, ue5_cli), bridge/ (stub_bridge, ue_bridge_listener) — with thin root shims (agent.py, ue5.py, tui.py, repl.py) so old commands still run. pyproject.toml adds console scripts ue5/agentunreal/agentunreal-tui.
- Web dashboard:
web/ = Vite+React+TS (Murim Noir, from docs/ui-design-spec.md) + serve.py stdlib backend (port 7822). Contract + pitfalls in references/web-dashboard-contract.md.
Next (needs user go): expand eval/tasks/ to 15–30 tasks — DONE (26 tasks, 100% pass, 0 violations, 16 tests green, see references/eval-harness-debugging.md for the 7 bugs found and fixed during expansion); Phase 3 (plugins + context compression + GEPA on system.txt once 50+ real sessions exist); MCP server mode (user chose MCP over bespoke bridge). UI: murim-noir spec in docs/ui-design-spec.md, parallel polish track.
agent.py (~370 lines) is the single-file core: Config dataclass, ReAct loop (max_iter=10) with plan step, memory recall before / remember after, build-retry via retry_nudge, dry-run gate, SessionMetrics → sessions/metrics.jsonl, per-run sessions/*.jsonl.
Common pitfalls
Trust the code, not the roadmap markdown. The repo's README roadmap and AGENTU_COMPARISON.md both lagged the actual code by a full milestone (11 merged tasks) — they claimed hand-wired schemas, no guardrails, no retry loop when all existed. Before planning work against this repo, run git log --oneline -15 and pytest -q to establish the real baseline; treat markdown roadmaps as hints, not ground truth.
Black terminal between submit and answer. If run() blocks through plan+tool-loop+LLM and only print()s the final string, the user sees a dead screen. Add ONE event hook on the Agent and a rich.live.Live renderer (see references/streaming-cli-renderer.md). The hook fires at the single dispatch chokepoint (_call_tool): tool.start (id+name+args), tool.complete (id+✓/✗ result), plus plan and result. The same hook later feeds a Textual dashboard — no loop rewrite.
Synchronous agent.run() inside a Textual event handler. The existing tui.py calls self.agent.run() directly in action_run_task → it freezes and shows nothing until done (same black-screen root cause, just framed). Fix: run agent.run in a Textual worker thread and push events from on_event into the #log widget. The hook already exists from the streaming-CLI work.
Building the TUI/IDE before the loop works. Use a REPL until the loop is proven.
Choosing editor-only when you need standalone. If the agent must run as its own process, design a bridge interface from the start, even if v1 uses a stub.
Requiring the editor for every test. Use a stub bridge for off-PC development.
Making memory a hard dependency. It adds failure modes and can be added later behind a flag. When you do add it, keep the tools out of the registry unless memory is actually enabled.
Editing files without source control. The first safety tool should be a snapshot / revert wrapper around git or P4.
Over-fitting to one project. Keep project names, module names, and class names in config, not in code.
Naming the harness before checking for collisions. A name that is already a PyPI package, GitHub org, or framework (e.g. AgentU vs. the published agentu package) creates search ambiguity, import conflicts, and brand confusion. Before committing to a public name, search PyPI and GitHub, and document the distinction or rename early.
Forgetting to update test constructors when the config dataclass changes. Adding db_path to Config also requires updating every Config(...) call in test_stub.py and any other test fixtures.
Instantiating the LLM client without passing config. A generic LLM() that reads config.yaml itself may ignore the model/provider the agent was configured with. Pass the config object to the LLM client so one source of truth controls provider, model, and base URL.
Hand-coding OpenAI-style tool schemas. Derive tool schemas from function signatures and docstrings, or maintain one schema file per tool. This avoids drift when a tool gains a parameter and is the pattern used by production agent frameworks.
Config-path args that crash the LLM when omitted. A tool signature like scan_project(self, uproject_path: str) -> dict forces the LLM to always pass uproject_path, which is wrong for a harness where the project is already known from config.uproject_path. Make such config-derived params optional and fall back: def scan_project(self, uproject_path: str | None = None) -> dict: then if uproject_path is None: uproject_path = self.config.uproject_path. This matches sibling tools like read_build_cs (which already reads self.config.uproject_path) and prevents the tool-call crash where the model invokes scan_project() with no args.
Re-editing the schema to make an arg optional. Unnecessary. tools/schema.py: schema_for only appends a param to required when param.default is inspect.Parameter.empty. Giving the param a default (=None) automatically drops it from required — the single signature change is sufficient. Verify with agent._tool_schemas() and assert the tool's required == [].
Forgetting a regression test for a tool-crash fix. A one-line signature change that fixes a crash is invisible to the suite until a test calls the new path. Add a test that invokes the tool with no args (e.g. agent.project_tools.scan_project()) and asserts the expected happy-path result (error is None, name matches Path(config.uproject_path).stem). This both locks the fix and documents the contract.
Writing files outside the project tree. A destructive file tool should reject paths that resolve outside the configured project root.
Hard-coding platform paths in build tools. Windows UBT paths belong in config or environment variables, not in source code. Fallback to a synthetic pass on non-Windows so the harness can be tested off-PC.
Letting max_build_retries sit unused. A config value for retries is only useful if the agent loop actually consumes it: parse errors, append them to the LLM context, and loop until success or retries are exhausted.
Path guard exception handling. Letting PathGuard raise an exception inside a file tool is fine for internal callers, but a tool that returns JSON to the LLM must surface guard failures as a result dict with an error key. The LLM cannot parse a stack trace, but it can read "error": "Path ... is outside ..." and choose another path. Catch guard exceptions at the tool boundary and return {"error": str(e)}.
Approval-mode serialization for tool returns. Security guardrails such as approval_mode: "ask" / "readonly" should return the same dict-shaped error to the LLM, not raise. A consistent {"error": ...} shape across guardrails, path checks, and missing-tool errors lets the agent retry rather than crash.
Synthetic pass when UBT is unavailable. On macOS/Linux the Windows Build.bat will not exist. Make build_module return a synthetic success (exit_code: 0) in that case so the harness can be tested off-PC, but log that it is synthetic. This is a stub-test enabler, not a production build path.
Tests must use Path(str(tmpdir)) with pytest. pytest passes a py.path.local (or LocalPath) that lacks pathlib methods like .parent and the encoding= keyword on write_text. Wrap it with Path(str(tmpdir)) before using pathlib operations, or write tests that explicitly pass encoding="utf-8".
Shipping a zero-arg LLM() demo path. If the LLM wrapper has a demo() or __main__ that constructs the client without config, it will break after the constructor changes. Update the demo path to build a minimal Config object or remove it.
Not tracking dependencies. A Python harness that uses pyyaml, anthropic, openai, and optional extras needs a requirements.txt or pyproject.toml so a fresh venv can reproduce it. Without this, pytest and other tools may not be installed on the next machine.
Execution-order assumption. When running the evolution plan with subagents, land Task 10 (config + prompt sync) before Tasks 6/7/9. The later tasks read config keys (mcp_enabled, approval_mode) that only exist after Task 10. Running them in parallel against an old config.yaml causes false test failures.
Same-file parallel edits. agent.py and test_stub.py are touched by multiple evolution tasks. Do not dispatch Tasks 6, 7, and 9 as parallel leaf subagents that all edit agent.py and test_stub.py — they will race and clobber each other. Serialize them or give each task a disjoint region (e.g. one adds _plan, another adds MCP wiring in __init__, another adds metrics in run). CONFIRMED in Task 7: a sibling subagent clobbered agent.py (deleted _load_system_prompt body) and mangled test_stub.py (joined two lines into one) mid-edit. Re-read before writing and verify after with python -m pytest.
datetime.utcnow() is deprecated (Python 3.12+). Any session-log or timestamp code in the agent/TUI must use datetime.now(timezone.utc) (import timezone from datetime). A bare utcnow() still works but emits a DeprecationWarning and will break on future Python.
The entry point should expose a --json machine contract. python3 -m agent is both a human REPL and a callable tool. Add a --json flag (cli-builder Pattern 3): collect the on_event stream and emit ONE JSON object on stdout with {prompt, events, result}, no auxiliary text. This lets other agents/scripts drive the harness. See the --json mode section in references/streaming-cli-renderer.md.
Synthetic user messages mid-loop break prompt cache and alternation. agent.py's build-retry loop injects {"role": "user", "content": "Build failed..."} between tool results. Hermes's AGENTS.md explicitly forbids this pattern: strict user/assistant alternation is required, and any mid-loop role mutation invalidates the per-conversation prompt cache (real $ cost on every subsequent turn). Deliver retry hints as a tool result on the original build_module tool_call_id, or fold into the next assistant content. See references/hermes-port-map.md §"Concrete bug to fix first".
When borrowing from a larger reference codebase, diff against its TOP-LEVEL module layout, not its feature docs. The Hermes review surfaced components (cron/, plugins/, providers/base.py, optional-mcps/unreal-engine/) that don't appear in any of Hermes's own feature READMEs — you only find them by ls-ing the repo root and reading __init__.py docstrings. Feature docs lag; module layout is ground truth.
A trajectory harvest is only as good as the raw log it folds. eval/harvest.py produced 41 sessions but mostly steps: 0 because the per-session sessions/*.jsonl records tool names, not args/results/exit_codes. If you want to score or optimize later, enrich the log at write time (per-step args, ok, exit_code, error codes) — a harvester can't recover detail that was never written. Enrich first, harvest second. CONFIRMED 2026-07-23: the harvest must also track dry_run_before_write (did dry_run fire before write_file for the same path?) and files_written (paths from successful write_file results). The raw log must include tool_name in each tool message so the harvester can distinguish dry_run from write_file calls — without it, dry_run_before_write is always False and every requires_dry_run task fails. See references/eval-harvest-enrichment.md.
Prompt path resolution breaks when run as a module. Agent._plan and _load_system_prompt that use Path("prompts/system.txt") resolve relative to the current working directory, not the package root. When python -m agentunreal.eval.run_suite runs from a tmp dir (as the eval runner does), the prompts are never found and the planner silently returns []. Fix: resolve from __file__: Path(__file__).resolve().parent.parent.parent / "prompts" / name. The _plan method must also gracefully return [] when the planner file is missing, not crash.
ScriptedLLM consumes its first call on the planner step. The eval runner's _scripted_llm() returns a fixed sequence of tool calls, but _plan calls self.llm.invoke(messages) (no tools=) before the main loop. If the ScriptedLLM doesn't handle tools is None separately, the planner call consumes the first scripted tool call (scan_project), shifting the entire sequence and producing a broken trajectory. Fix: in the ScriptedLLM's invoke, check if tools is None: return a canned plan before consuming the scripted sequence.
Session file selection picks the wrong file. sorted(Path("sessions").glob("*.jsonl"))[-1] returns metrics.jsonl (alphabetically last) instead of the actual session log, because m > 2. The harvest then reads the metrics file (which has role=None for its single JSON line) and produces steps: 0, build_success: False. Fix: filter out metrics.jsonl and trajectories.jsonl before selecting the last file.
Tool parameter names must match between ScriptedLLM and the real tool. The ScriptedLLM called build_module with {"module": "MyGame"} but BuildTools.build_module expects module_name. The _call_tool method calls tool(**args), so the mismatch raises TypeError and the build result is {"error": ...} with no exit_code. Always verify the ScriptedLLM's call args match the actual tool signature — inspect.signature is the source of truth.
Tool messages in the session log must include tool_name. The Agent.run method writes {"role": "tool", "tool_call_id": ..., "content": ...} to the session log. Without tool_name, the harvester cannot distinguish dry_run from write_file calls and cannot verify the dry_run → write_file ordering invariant. Add "tool_name": tool_name to the log entry.
Schema generator must use the registry key, not fn.__name__. When tools are registered under names that differ from the method name (e.g. registry["memory_recall"] = self._memory_recall), the schema generator must use the registry key as the tool name, not fn.__name__ which returns _memory_recall. Fix: schema_for(fn, name=None) where name defaults to fn.__name__ but schemas_from_registry passes the registry key. Also rename the loop variable in schema_for to avoid shadowing the name parameter.
Eval task count must match the suite's coverage. The original 4 tasks covered all four kinds (new-class, build-fix, refactor, editor-command) but were too narrow. Expanding to 26 tasks (10 new-class, 3 build-fix, 3 refactor, 6 editor-command, 4 original) across the MyGame domain gives the suite enough breadth to catch regressions in each tool category. Each task must have a clear expect block: must_build, max_build_attempts, requires_dry_run, forbidden_error_codes.
Parallel subagents on one repo → reconciliation debt + contract drift. Dispatching a reorg agent and a feature agent against the same working tree concurrently makes the reorg move the feature's just-written files, splitting them across the old/new layout — and a separately-built frontend/backend pair silently diverges on the JSON contract. CONFIRMED M4: the reorg moved eval/score.py+suite.py into the package but left run_suite.py/ab.py/tasks/ in the stale eval/ dir; and serve.py (written before reading the frontend) got the port AND field names wrong. Mitigate: serialize repo-wide reorgs before/after feature work (not during), and define the shared contract FIRST (see next pitfall).
Define the frontend↔backend contract BEFORE building both sides. When a Python backend and a TS/Vite frontend are built in parallel (or the backend is written before reading the frontend), they drift on the JSON shape — port, field names, event schema. Have the frontend author publish types.ts (the single source of truth) FIRST, then conform the backend to it; it's cheaper to change the backend. See references/web-dashboard-contract.md.
Verification
Off-PC test: stub bridge returns canned responses, loop completes.
PC test: run the same command with the editor open and the real bridge.
References
references/ue-agent-harness-decisions.md — bridge choice and lazy split
from a real scoping session.
references/standalone-file-bridge.md — recipe for the file-based bridge
between a standalone agent and the UE editor.
references/agentu-naming-collision.md — why to check for package/name
collisions before settling on a harness name.
references/tui-dashboard.md — adding a Textual/Rich dashboard after the
loop and stub tests are working.
references/streaming-cli-renderer.md — minimal rich.live.Live renderer that
streams plan + tool calls live, killing the "black terminal" between task
submit and final answer. Reuse when the CLI shows nothing until run() returns.
references/mnemosyne-integration-recipe.md — adding durable memory to a
standalone Python harness without running inside Hermes.
references/integrating-production-agent-patterns.md — how to borrow
memory, MCP, guardrails, multi-agent, and eval patterns from popular agent
repositories and apply them to the harness.
references/mcp-client-pattern.md — optional MCP client feature-flag wiring (Task 7).
references/hermes-port-map.md — ranked top-10 components to port from the Hermes fork (with LOC estimates + sequencing), a "do not port" list, and the role-alternation bug to fix first. Replaces the loose "next frontier" guesses.
references/self-improvement-roadmap.md — Collect→Evaluate→Optimize self-growth plan mined from hermes-agent-self-evolution + Icarus fabric: stage-by-stage loop, portability map, UE-specific eval signals (UBT exit codes, MSVC error taxonomy), trajectory JSONL schema, and the honest "GEPA later, A/B now" call.
references/engine-verified-self-growth-roadmap.md — condensed 2026-07-17 three-way diagnostic: the engine-verified thesis, the free-binary-ground-truth eval signals, the phased Collect→Evaluate→Optimize build order, and the GEPA-later/fabric-capstone call.
references/web-dashboard-contract.md — the serve.py↔web/ JSON contract (RunState/event schema, port, Vite proxy), how the backend enriches the raw on_event stream, and the frontend-first-contract pitfall hit when both sides were built without sharing types.ts first.
references/approval-v2-interactive-ask.md — interactive ask mode with stdin
prompt, two-layer gate (approval prompt + dry_run hash gate), and test patterns.
references/eval-harness-debugging.md — session-specific debugging notes from the 2026-07-23 pass that expanded the eval task suite from 4 to 26 tasks and fixed 7 bugs in the eval pipeline (prompt path resolution, ScriptedLLM planner collision, session file selection, harvest enrichment, tool parameter naming, task suite expansion, schema generator name shadowing).
templates/config.yaml — starter config for a new harness.
references/mvp-implementation-checklist.md — exact files, fields, and test
cases to implement the v1 milestone end-to-end.
Session format
Use JSONL for session logs: one line per message, append-only, easy to inspect.
Keep the journal (progress.md) short: what changed, what failed, what is next.
1---2name: unreal-engine-agent-harness3description: Design and bootstrap a minimal, generic AI agent harness for Unreal Engine game development. Covers architecture, bridge selection, tool inventory, build loop, optional memory integration, and a lazy cross-platform dev split.4---5
6# Unreal Engine Agent Harness
7
8Build a minimal AI agent harness that can read a UE project, write C++ source,
9trigger builds, and report results. Keep it generic enough that anyone can
10fork it for their own project and engine.
11
12## When to use
13
14- You are building an agent harness for UE C++ / gameplay work.
15- You need to choose between a native UE bridge, an MCP server, or an external
16 protocol.
17- You want to scope a first milestone that can be tested on macOS/Linux without
18 launching the UE editor.
19- You want a standalone agent process that talks to a running editor through a
20 file-based bridge.
21
22## Core principles
23
241. **The loop is trivial; the tools are the work.** Spend effort on the
25 UE-specific tool wrappers, not the ReAct loop.
262. **Start standalone.** If the agent must run as its own process, design a
27 file-based bridge from the start; use a stub mode for off-PC development.
283. **Ship a stub bridge.** Let the harness run and be tested on a machine that
29 does not have the editor open.
304. **One real end-to-end task in v1.** Pick a single C++ class addition, not a
31 feature family.
325. **Make memory optional.** Persistent memory is useful only after the harness
33 already works.
34
35## Architecture
36
37```
38┌──────────────────────────┐
39│ CLI / REPL / TUI │
40├──────────────────────────┤
41│ Agent Loop │ ReAct: LLM → tool → LLM
42├──────────────────────────┤
43│ Tool Registry │ project, file, build, editor
44├──────────────────────────┤
45│ UE Bridge │ native Python Editor Scripting API or stub
46├──────────────────────────┤
47│ LLM Provider │ pluggable (Anthropic, OpenAI, local)
48├──────────────────────────┤
49│ Memory (optional) │ Mnemosyne / journal / session JSONL
50└──────────────────────────┘
51```
52
53The loop is a single Python file. Tools are thin classes. The UE bridge is a
54standalone file-based JSON protocol, with a swappable stub for off-PC development.
55The UI layer is separate: start with a REPL, add a one-shot CLI, and only then
56add a Textual dashboard.
57
58## Key design decisions
59
60### Bridge: standalone file-based protocol first
61
62- The agent runs as a standalone Python process.
63- It communicates with a running Unreal Editor through a simple file-based
64 JSON bridge (one file, polled by both sides).
65- A `ue_bridge_listener.py` script runs inside the editor and executes
66 requests.
67- A `stub` bridge mode returns canned responses so the harness can be built
68 and tested on macOS/Linux without the editor open.
69
70### Cross-platform dev split
71
72- Build and test the harness on your main dev machine (macOS/Linux) using a
73 **stub bridge** that returns canned responses.
74- Move the same code to the Windows PC, set bridge to **file**, and run with
75 the editor open.
76- This avoids the "I cannot test until I am at the PC" trap.
77
78### Tool inventory
79
80- **Project:** `scan_project`, `read_build_cs`.
81- **File:** `read_file`, `write_file`, `dry_run`.
82- **Build:** `build_module`, `parse_build_errors`.
83- **Editor:** `editor_command`, `is_editor_running`, `compile_blueprints`.
84- **Memory (optional):** `memory_remember`, `memory_recall`.
85
86### Memory integration
87
88- Persistent memory (e.g. Mnemosyne) is a feature flag, not a dependency.
89- If enabled, recall before each task and remember after each success.
90- If disabled, rely on a `progress.md` journal and the current project state.
91- See `references/mnemosyne-integration-recipe.md` for a standalone Python integration that does not require running inside Hermes.
92
93### Naming the harness
94
95- Check PyPI, GitHub, and generic web search before a name hardens into files.
96- If a collision exists, rename early. See `references/agentu-naming-collision.md`.
97
98## Minimal v1 milestone
99
1001. User says: "Add a simple C++ class."
1012. Agent scans the project and reads existing source.
1023. Agent recalls prior project memory when memory is enabled.
1034. Agent proposes changes with `dry_run`.
1045. Agent writes `.h` and `.cpp` files.
1056. Agent runs `build_module`.
1067. If the build fails, parse errors, feed them back to the LLM, edit files, and
107 retry up to a configured limit (e.g. 3).
1088. If the build succeeds, remember the outcome, write `progress.md`, and return a summary.
109
110## Implementation notes for v1
111
112### Config dataclass
113
114Use a single `Config` dataclass loaded from `config.yaml` and pass it to every
115service that needs configuration (LLM, build tools, bridge, etc.). Do not let
116sub-components re-read the YAML file. This keeps the agent's behavior fully
117determined by the loaded config.
118
119Required fields:
120
121```python
122@dataclass
123class Config:
124 uproject_path: str
125 default_module: str
126 bridge_type: str # stub | file
127 bridge_file_path: str
128 bridge_poll_interval: float
129 bridge_timeout: float
130 llm_provider: str
131 llm_model: str
132 llm_api_key_env: str
133 llm_base_url: str # empty string means provider default
134 max_build_retries: int
135 memory_enabled: bool
136 journal_path: str
137 db_path: str
138 approval_mode: str = "auto" # auto | ask | readonly
139 allowed_paths: list[str] = field(default_factory=list)
140 _profile_root: str | None = None # set by from_profile
141```
142
143### Profile isolation
144
145State (memory.db, sessions/, progress.md, skills/) should live outside the repo. Use a profile directory layout:
146
147```
148~/.agentunreal/profiles/<name>/
149├── config.yaml
150├── memory.db
151├── sessions/
152├── progress.md
153├── skills/
154├── plugins/
155└── .env
156```
157
158Add `from_profile` to `Config` and a `profile_path` helper:
159
160```python
161@classmethod
162def from_profile(cls, name: str, profile_root: str | None = None) -> "Config":
163 if profile_root is None:
164 profile_root = str(Path.home() / ".agentunreal" / "profiles" / name)
165 profile = Path(profile_root)
166 config_path = profile / "config.yaml"
167 if not config_path.exists():
168 raise FileNotFoundError(f"No config.yaml in profile: {profile}")
169 cfg = cls.from_yaml(str(config_path))
170 cfg.journal_path = str(profile / "progress.md")
171 cfg.db_path = str(profile / "memory.db")
172 cfg._profile_root = str(profile)
173 return cfg
174
175def profile_path(self, *parts: str) -> Path:
176 root = getattr(self, "_profile_root", None)
177 if root is None:
178 return Path(*parts)
179 return Path(root) / Path(*parts)
180```
181
182Wire `_profile_root` into session paths, metrics paths, and `SkillTools(skills_dir=...)`. CLI: `--profile <name>` loads from the profile dir; falls back to `--config` / default `config.yaml` when no profile is specified.
183
184### Auto-generated tool schemas
185
186Create a small `tools/schema.py` that walks the tool registry with `inspect`:
187
188```python
189import inspect
190
191def schema_for(fn):
192 sig = inspect.signature(fn)
193 properties = {}
194 required = []
195 for name, param in sig.parameters.items():
196 if name in ("self", "cls"):
197 continue
198 param_type = "string"
199 if param.annotation is not inspect.Parameter.empty:
200 origin = getattr(param.annotation, "__origin__", None)
201 if origin in (list, dict):
202 param_type = "array" if origin is list else "object"
203 elif isinstance(param.annotation, type):
204 param_type = {str:"string", int:"integer", float:"number", bool:"boolean"}.get(param.annotation, "string")
205 properties[name] = {"type": param_type, "description": f"Parameter `{name}`."}
206 if param.default is inspect.Parameter.empty:
207 required.append(name)
208 return {
209 "type": "function",
210 "function": {
211 "name": fn.__name__,
212 "description": (fn.__doc__ or "").strip(),
213 "parameters": {"type": "object", "properties": properties, "required": required},
214 },
215 }
216
217def schemas_from_registry(registry: dict) -> list[dict]:
218 return [schema_for(fn) for fn in registry.values()]
219```
220
221Then `agent.py` exposes the registry to the LLM with `schemas_from_registry(self.tools)`.
222Add a one-line docstring to each tool method so the schema has meaningful descriptions.
223
224### Path guard
225
226```python
227from pathlib import Path
228
229class PathGuard:
230 def __init__(self, project_root: Path, allowed_paths: list[Path] | None = None):
231 self.project_root = project_root.resolve()
232 self.allowed = [p.resolve() for p in (allowed_paths or [])]
233
234 def is_safe(self, target: Path) -> bool:
235 resolved = target.resolve()
236 if resolved == self.project_root or self.project_root in resolved.parents:
237 return True
238 for allowed in self.allowed:
239 if resolved == allowed or allowed in resolved.parents:
240 return True
241 return False
242
243 def assert_safe(self, target: Path) -> None:
244 if not self.is_safe(target):
245 raise ValueError(f"Path {target} is outside the allowed project tree.")
246```
247
248Pass the guard into `FileTools` and call `assert_safe` before reading/writing.
249Catch the exception at the tool boundary and return `{"error": str(e)}`.
250
251### Security guardrails
252
253Define:
254
255```python
256DANGEROUS_TOOLS = {"write_file", "build_module", "editor_command"}
257READONLY_TOOLS = {"scan_project", "read_file", "read_build_cs", "list_source_files", "dry_run", "is_editor_running", "memory_recall"}
258```
259
260In `_call_tool`:
261- If `approval_mode == "readonly"` and `name not in READONLY_TOOLS`, return `{"error": "Readonly mode: ..."}`.
262- If `approval_mode == "ask"` and `name in DANGEROUS_TOOLS`, prompt on stdin with the tool name, path, and a truncated content preview. `[y/N]` — defaults to deny. Auto-approve if the `dry_run` hash matches (already reviewed). On `EOFError`/`KeyboardInterrupt` (non-interactive context), deny.
263- If `approval_mode == "auto"`, no prompts.
264
265The approval prompt and the dry_run→write_file hash gate are **independent layers**. The prompt asks "should this dangerous tool run at all?"; the hash gate asks "has this exact content been reviewed via dry_run?". Both must pass for `write_file` to execute.
266
267```python
268def _prompt_approval(self, name: str, args: dict) -> bool:
269 """Interactive stdin prompt for dangerous tools in 'ask' mode."""
270 path = args.get("path", "")
271 content_preview = args.get("content", "")
272 if content_preview:
273 content_preview = content_preview[:200] + ("..." if len(args.get("content", "")) > 200 else "")
274 prompt = f"\n[APPROVAL] {name}"
275 if path:
276 prompt += f" path={path}"
277 if content_preview:
278 prompt += f"\n content: {content_preview}"
279 prompt += "\nApprove? [y/N] "
280 try:
281 resp = input(prompt)
282 except (EOFError, KeyboardInterrupt):
283 return False
284 return resp.strip().lower() in ("y", "yes")
285```
286
287Set `approval_mode: "auto"` in tests so the harness can run without user prompts.
288
289### Build retry loop
290
291Inside `Agent.run`, track `build_attempts`. When `build_module` returns
292`exit_code != 0` and `build_attempts < config.max_build_retries`, parse the
293errors and **fold the retry nudge into the tool result** — do NOT append a
294synthetic `user` message (see pitfalls: that breaks role alternation + prompt
295cache). Cap the error dump so a 30KB UBT log doesn't blow context.
296
297```python
298if tool_name == "build_module":
299 build_attempts += 1
300 if result.get("result", {}).get("exit_code", 1) != 0 and build_attempts < self.config.max_build_retries:
301 errors = self.tools["parse_build_errors"](output=...)
302 result["retry_nudge"] = f"Build failed (attempt {build_attempts}/{self.config.max_build_retries}). Fix these errors and call build_module again: {json.dumps(errors)[:2000]}"
303```
304
305### Enforce dry_run → write_file (hash gate)
306
307The system-prompt rule "always dry_run before write_file" is not enforced by
308default — a hallucinating agent writes unconditionally. Make it code in
309`_call_tool` by hash-matching the last `dry_run(path+content)`:
310
311```python
312import hashlib # + self._last_dry_run_hash = None in __init__
313if name == "write_file":
314 h = hashlib.sha256((args.get("path","") + args.get("content","")).encode()).hexdigest()
315 if h != self._last_dry_run_hash:
316 return {"error": "write_file blocked: call dry_run with the same path+content first."}
317if name == "dry_run":
318 self._last_dry_run_hash = hashlib.sha256((args.get("path","") + args.get("content","")).encode()).hexdigest()
319```
320
321### Auto memory recall and remember
322
323Before the first LLM call, build a memory context from several queries and
324prepend it to the user prompt. After a final answer, remember the outcome. Tools
325are only added to the registry when memory is enabled.
326
327```python
328def _recall_memory_context(self, user_prompt: str) -> str:
329 if not self.memory:
330 return ""
331 project = Path(self.config.uproject_path).stem
332 queries = [user_prompt, f"{project} project conventions", f"{project} build failures"]
333 seen = set()
334 memories = []
335 for q in queries:
336 for m in self.memory.recall(q, top_k=3):
337 mid = m.get("memory_id")
338 if mid and mid not in seen:
339 seen.add(mid)
340 memories.append(m)
341 if not memories:
342 return ""
343 return "\n".join(["Relevant memory context:"] + [f"- {m.get('content', '')}" for m in memories])
344
345def _remember_outcome(self, user_prompt: str, summary: str, success: bool = True) -> None:
346 if not self.memory:
347 return
348 status = "succeeded" if success else "failed"
349 self.memory.remember(
350 f"Project: {Path(self.config.uproject_path).stem}\nTask: {user_prompt}\nOutcome: {status}\nSummary: {summary}",
351 source="agent", importance=0.7, scope="session"
352 )
353```
354
355### System prompt safety rules
356
357Add explicit safety and workflow rules to `prompts/system.txt` so the model
358knows the constraints:
359
360```
361SAFETY RULES:
362- Never write files outside the project Source directory or allowed_paths.
363- Always call dry_run before write_file.
364- Always pair new .h files with .cpp files under the same module.
365- Never delete existing source files unless the user explicitly asks.
366- If a build fails, read the error output, fix the root cause, and retry.
367
368WORKFLOW RULES:
369- Before writing code, plan: scan the project, read relevant source, then dry_run.
370- After writing code, call build_module.
371- After a successful build, update progress.md and remember the outcome.
372```
373
374## Current state
375
376Verified 2026-07-17 (`git log` + `pytest`, 22 green): **all 11 evolution-plan tasks are merged** — config propagation, auto schemas, path guards, build retry, memory recall/remember, planner, MCP flag, guardrails/approval, eval harness, config sync, e2e test. Beyond the plan: streaming `on_event` hook, two-pane Textual TUI, `--json` machine contract, `ue5.py` CLI, `.env` loading.
377
378**Milestone M3 (2026-07-17, commit `669dd7d`) — Phase 0+1 of the three-way diagnostic (`docs/DIAGNOSTIC_AND_PLAN_2026-07.md`) landed:**
379- **Phase 0 (correctness):** role-alternation bug fixed (build-retry nudge folded into tool result via `result["retry_nudge"]`, no synthetic `user` msg mid-loop); dry_run→write_file hash gate in `_call_tool`; stale docs synced, `AGENTU_COMPARISON.md` deleted.
380- **Phase 1 (backbone):** skills system (`tools/skills.py` → `skills_list`/`skill_view`, `skills/` dir, seeded `new-cpp-class.md`, system prompt says load-before-task + offer-to-save-after); trajectory harvest (`eval/harvest.py` → `sessions/trajectories.jsonl`).
381
382**Milestone M4 (2026-07-17) — Phase 2 (Evaluate) + package reorg + web dashboard:**
383- **Phase 2 (Evaluate):** deterministic eval in `agentunreal/eval/` — `suite.py` (EvalExample loader + fixture materializer, tasks in `eval/tasks/*.json`, kinds new-class/build-fix/refactor/editor-command), `score.py` (deterministic scoring: build_success, dry_run-before-write, MSVC/LNK codes, constraint violations — NO LLM judge; the engine gives binary ground truth), `run_suite.py` (stub-mode runner + aggregate rollup), `ab.py` (A/B two system prompts → pass-rate/build/attempt/violation deltas). "Measure before optimize" gate = `python -m agentunreal.eval.run_suite` + pytest green; a variant that breaks the suite is rejected regardless of A/B numbers.
384- **Package reorg (pi-style, stays Python):** installable `agentunreal/` package — `core/` (agent, llm, Config), `tools/`, `eval/`, `ui/` (tui, ui_cli, repl, ue5_cli), `bridge/` (stub_bridge, ue_bridge_listener) — with thin root shims (`agent.py`, `ue5.py`, `tui.py`, `repl.py`) so old commands still run. `pyproject.toml` adds console scripts `ue5`/`agentunreal`/`agentunreal-tui`.
385- **Web dashboard:** `web/` = Vite+React+TS (Murim Noir, from `docs/ui-design-spec.md`) + `serve.py` stdlib backend (port 7822). Contract + pitfalls in `references/web-dashboard-contract.md`.
386
387**Next (needs user go):** expand `eval/tasks/` to 15–30 tasks — **DONE (26 tasks, 100% pass, 0 violations, 16 tests green, see `references/eval-harness-debugging.md` for the 7 bugs found and fixed during expansion**); Phase 3 (plugins + context compression + GEPA on `system.txt` once 50+ real sessions exist); MCP server mode (user chose MCP over bespoke bridge). UI: murim-noir spec in `docs/ui-design-spec.md`, parallel polish track.
388
389`agent.py` (~370 lines) is the single-file core: Config dataclass, ReAct loop (max_iter=10) with plan step, memory recall before / remember after, build-retry via `retry_nudge`, dry-run gate, `SessionMetrics` → `sessions/metrics.jsonl`, per-run `sessions/*.jsonl`.
390
391## Common pitfalls
392
393- **Trust the code, not the roadmap markdown.** The repo's README roadmap and `AGENTU_COMPARISON.md` both lagged the actual code by a full milestone (11 merged tasks) — they claimed hand-wired schemas, no guardrails, no retry loop when all existed. Before planning work against this repo, run `git log --oneline -15` and `pytest -q` to establish the real baseline; treat markdown roadmaps as hints, not ground truth.
394- **Black terminal between submit and answer.** If `run()` blocks through plan+tool-loop+LLM and only `print()`s the final string, the user sees a dead screen. Add ONE event hook on the Agent and a `rich.live.Live` renderer (see `references/streaming-cli-renderer.md`). The hook fires at the single dispatch chokepoint (`_call_tool`): `tool.start` (id+name+args), `tool.complete` (id+✓/✗ result), plus `plan` and `result`. The same hook later feeds a Textual dashboard — no loop rewrite.
395- **Synchronous `agent.run()` inside a Textual event handler.** The existing `tui.py` calls `self.agent.run()` directly in `action_run_task` → it freezes and shows nothing until done (same black-screen root cause, just framed). Fix: run `agent.run` in a Textual worker thread and push events from `on_event` into the `#log` widget. The hook already exists from the streaming-CLI work.
396- **Building the TUI/IDE before the loop works.** Use a REPL until the loop is proven.
397- **Choosing editor-only when you need standalone.** If the agent must run as its own process, design a bridge interface from the start, even if v1 uses a stub.
398- **Requiring the editor for every test.** Use a stub bridge for off-PC development.
399- **Making memory a hard dependency.** It adds failure modes and can be added later behind a flag. When you do add it, keep the tools out of the registry unless memory is actually enabled.
400- **Editing files without source control.** The first safety tool should be a `snapshot` / `revert` wrapper around git or P4.
401- **Over-fitting to one project.** Keep project names, module names, and class names in config, not in code.
402- **Naming the harness before checking for collisions.** A name that is already a PyPI package, GitHub org, or framework (e.g. `AgentU` vs. the published `agentu` package) creates search ambiguity, import conflicts, and brand confusion. Before committing to a public name, search PyPI and GitHub, and document the distinction or rename early.
403- **Forgetting to update test constructors when the config dataclass changes.** Adding `db_path` to `Config` also requires updating every `Config(...)` call in `test_stub.py` and any other test fixtures.
404- **Instantiating the LLM client without passing config.** A generic `LLM()` that reads `config.yaml` itself may ignore the model/provider the agent was configured with. Pass the config object to the LLM client so one source of truth controls provider, model, and base URL.
405- **Hand-coding OpenAI-style tool schemas.** Derive tool schemas from function signatures and docstrings, or maintain one schema file per tool. This avoids drift when a tool gains a parameter and is the pattern used by production agent frameworks.
406- **Config-path args that crash the LLM when omitted.** A tool signature like `scan_project(self, uproject_path: str) -> dict` forces the LLM to always pass `uproject_path`, which is wrong for a harness where the project is already known from `config.uproject_path`. Make such config-derived params optional and fall back: `def scan_project(self, uproject_path: str | None = None) -> dict:` then `if uproject_path is None: uproject_path = self.config.uproject_path`. This matches sibling tools like `read_build_cs` (which already reads `self.config.uproject_path`) and prevents the tool-call crash where the model invokes `scan_project()` with no args.
407- **Re-editing the schema to make an arg optional.** Unnecessary. `tools/schema.py: schema_for` only appends a param to `required` when `param.default is inspect.Parameter.empty`. Giving the param a default (`=None`) automatically drops it from `required` — the single signature change is sufficient. Verify with `agent._tool_schemas()` and assert the tool's `required == []`.
408- **Forgetting a regression test for a tool-crash fix.** A one-line signature change that fixes a crash is invisible to the suite until a test calls the new path. Add a test that invokes the tool with no args (e.g. `agent.project_tools.scan_project()`) and asserts the expected happy-path result (`error is None`, `name` matches `Path(config.uproject_path).stem`). This both locks the fix and documents the contract.
409- **Writing files outside the project tree.** A destructive file tool should reject paths that resolve outside the configured project root.
410- **Hard-coding platform paths in build tools.** Windows UBT paths belong in config or environment variables, not in source code. Fallback to a synthetic pass on non-Windows so the harness can be tested off-PC.
411- **Letting `max_build_retries` sit unused.** A config value for retries is only useful if the agent loop actually consumes it: parse errors, append them to the LLM context, and loop until success or retries are exhausted.
412- **Path guard exception handling.** Letting `PathGuard` raise an exception inside a file tool is fine for internal callers, but a tool that returns JSON to the LLM must surface guard failures as a result dict with an `error` key. The LLM cannot parse a stack trace, but it can read `"error": "Path ... is outside ..."` and choose another path. Catch guard exceptions at the tool boundary and return `{"error": str(e)}`.
413- **Approval-mode serialization for tool returns.** Security guardrails such as `approval_mode: "ask"` / `"readonly"` should return the same dict-shaped error to the LLM, not raise. A consistent `{"error": ...}` shape across guardrails, path checks, and missing-tool errors lets the agent retry rather than crash.
414- **Synthetic pass when UBT is unavailable.** On macOS/Linux the Windows `Build.bat` will not exist. Make `build_module` return a synthetic success (`exit_code: 0`) in that case so the harness can be tested off-PC, but log that it is synthetic. This is a stub-test enabler, not a production build path.
415- **Tests must use `Path(str(tmpdir))` with pytest.** `pytest` passes a `py.path.local` (or `LocalPath`) that lacks `pathlib` methods like `.parent` and the `encoding=` keyword on `write_text`. Wrap it with `Path(str(tmpdir))` before using pathlib operations, or write tests that explicitly pass `encoding="utf-8"`.
416- **Shipping a zero-arg `LLM()` demo path.** If the LLM wrapper has a `demo()` or `__main__` that constructs the client without config, it will break after the constructor changes. Update the demo path to build a minimal `Config` object or remove it.
417- **Not tracking dependencies.** A Python harness that uses `pyyaml`, `anthropic`, `openai`, and optional extras needs a `requirements.txt` or `pyproject.toml` so a fresh venv can reproduce it. Without this, `pytest` and other tools may not be installed on the next machine.
418- **Execution-order assumption.** When running the evolution plan with subagents, land Task 10 (config + prompt sync) before Tasks 6/7/9. The later tasks read config keys (`mcp_enabled`, `approval_mode`) that only exist after Task 10. Running them in parallel against an old `config.yaml` causes false test failures.
419- **Same-file parallel edits.** `agent.py` and `test_stub.py` are touched by multiple evolution tasks. Do not dispatch Tasks 6, 7, and 9 as parallel leaf subagents that all edit `agent.py` and `test_stub.py` — they will race and clobber each other. Serialize them or give each task a disjoint region (e.g. one adds `_plan`, another adds MCP wiring in `__init__`, another adds metrics in `run`). CONFIRMED in Task 7: a sibling subagent clobbered `agent.py` (deleted `_load_system_prompt` body) and mangled `test_stub.py` (joined two lines into one) mid-edit. Re-read before writing and verify after with `python -m pytest`.
420- **`datetime.utcnow()` is deprecated (Python 3.12+).** Any session-log or timestamp code in the agent/TUI must use `datetime.now(timezone.utc)` (import `timezone` from `datetime`). A bare `utcnow()` still works but emits a `DeprecationWarning` and will break on future Python.
421- **The entry point should expose a `--json` machine contract.** `python3 -m agent` is both a human REPL and a callable tool. Add a `--json` flag (cli-builder Pattern 3): collect the `on_event` stream and emit ONE JSON object on stdout with `{prompt, events, result}`, no auxiliary text. This lets other agents/scripts drive the harness. See the `--json` mode section in `references/streaming-cli-renderer.md`.
422- **Synthetic `user` messages mid-loop break prompt cache and alternation.** `agent.py`'s build-retry loop injects `{"role": "user", "content": "Build failed..."}` between tool results. Hermes's AGENTS.md explicitly forbids this pattern: strict user/assistant alternation is required, and any mid-loop role mutation invalidates the per-conversation prompt cache (real $ cost on every subsequent turn). Deliver retry hints as a tool result on the original `build_module` tool_call_id, or fold into the next assistant content. See `references/hermes-port-map.md` §"Concrete bug to fix first".
423- **When borrowing from a larger reference codebase, diff against its TOP-LEVEL module layout, not its feature docs.** The Hermes review surfaced components (`cron/`, `plugins/`, `providers/base.py`, `optional-mcps/unreal-engine/`) that don't appear in any of Hermes's own feature READMEs — you only find them by `ls`-ing the repo root and reading `__init__.py` docstrings. Feature docs lag; module layout is ground truth.
424- **A trajectory harvest is only as good as the raw log it folds.** `eval/harvest.py` produced 41 sessions but mostly `steps: 0` because the per-session `sessions/*.jsonl` records tool *names*, not args/results/exit_codes. If you want to score or optimize later, enrich the log at write time (per-step args, ok, exit_code, error codes) — a harvester can't recover detail that was never written. Enrich first, harvest second. **CONFIRMED 2026-07-23:** the harvest must also track `dry_run_before_write` (did `dry_run` fire before `write_file` for the same path?) and `files_written` (paths from successful `write_file` results). The raw log must include `tool_name` in each tool message so the harvester can distinguish `dry_run` from `write_file` calls — without it, `dry_run_before_write` is always `False` and every `requires_dry_run` task fails. See `references/eval-harvest-enrichment.md`.
425- **Prompt path resolution breaks when run as a module.** `Agent._plan` and `_load_system_prompt` that use `Path("prompts/system.txt")` resolve relative to the *current working directory*, not the package root. When `python -m agentunreal.eval.run_suite` runs from a tmp dir (as the eval runner does), the prompts are never found and the planner silently returns `[]`. Fix: resolve from `__file__`: `Path(__file__).resolve().parent.parent.parent / "prompts" / name`. The `_plan` method must also gracefully return `[]` when the planner file is missing, not crash.
426- **ScriptedLLM consumes its first call on the planner step.** The eval runner's `_scripted_llm()` returns a fixed sequence of tool calls, but `_plan` calls `self.llm.invoke(messages)` (no `tools=`) before the main loop. If the ScriptedLLM doesn't handle `tools is None` separately, the planner call consumes the first scripted tool call (`scan_project`), shifting the entire sequence and producing a broken trajectory. Fix: in the ScriptedLLM's `invoke`, check `if tools is None: return a canned plan` before consuming the scripted sequence.
427- **Session file selection picks the wrong file.** `sorted(Path("sessions").glob("*.jsonl"))[-1]` returns `metrics.jsonl` (alphabetically last) instead of the actual session log, because `m` > `2`. The harvest then reads the metrics file (which has `role=None` for its single JSON line) and produces `steps: 0`, `build_success: False`. Fix: filter out `metrics.jsonl` and `trajectories.jsonl` before selecting the last file.
428- **Tool parameter names must match between ScriptedLLM and the real tool.** The ScriptedLLM called `build_module` with `{"module": "MyGame"}` but `BuildTools.build_module` expects `module_name`. The `_call_tool` method calls `tool(**args)`, so the mismatch raises `TypeError` and the build result is `{"error": ...}` with no `exit_code`. Always verify the ScriptedLLM's call args match the actual tool signature — `inspect.signature` is the source of truth.
429- **Tool messages in the session log must include `tool_name`.** The `Agent.run` method writes `{"role": "tool", "tool_call_id": ..., "content": ...}` to the session log. Without `tool_name`, the harvester cannot distinguish `dry_run` from `write_file` calls and cannot verify the `dry_run → write_file` ordering invariant. Add `"tool_name": tool_name` to the log entry.
430- **Schema generator must use the registry key, not `fn.__name__`.** When tools are registered under names that differ from the method name (e.g. `registry["memory_recall"] = self._memory_recall`), the schema generator must use the registry key as the tool name, not `fn.__name__` which returns `_memory_recall`. Fix: `schema_for(fn, name=None)` where `name` defaults to `fn.__name__` but `schemas_from_registry` passes the registry key. Also rename the loop variable in `schema_for` to avoid shadowing the `name` parameter.
431- **Eval task count must match the suite's coverage.** The original 4 tasks covered all four kinds (new-class, build-fix, refactor, editor-command) but were too narrow. Expanding to 26 tasks (10 new-class, 3 build-fix, 3 refactor, 6 editor-command, 4 original) across the MyGame domain gives the suite enough breadth to catch regressions in each tool category. Each task must have a clear `expect` block: `must_build`, `max_build_attempts`, `requires_dry_run`, `forbidden_error_codes`.
432- **Parallel subagents on one repo → reconciliation debt + contract drift.** Dispatching a reorg agent and a feature agent against the same working tree concurrently makes the reorg *move* the feature's just-written files, splitting them across the old/new layout — and a separately-built frontend/backend pair silently diverges on the JSON contract. CONFIRMED M4: the reorg moved `eval/score.py`+`suite.py` into the package but left `run_suite.py`/`ab.py`/`tasks/` in the stale `eval/` dir; and `serve.py` (written before reading the frontend) got the port AND field names wrong. Mitigate: serialize repo-wide reorgs before/after feature work (not during), and define the shared contract FIRST (see next pitfall).
433- **Define the frontend↔backend contract BEFORE building both sides.** When a Python backend and a TS/Vite frontend are built in parallel (or the backend is written before reading the frontend), they drift on the JSON shape — port, field names, event schema. Have the frontend author publish `types.ts` (the single source of truth) FIRST, then conform the backend to it; it's cheaper to change the backend. See `references/web-dashboard-contract.md`.
434
435 ## Verification
436
437- **Off-PC test:** stub bridge returns canned responses, loop completes.
438- **PC test:** run the same command with the editor open and the real bridge.
439
440## References
441
442- `references/ue-agent-harness-decisions.md` — bridge choice and lazy split
443 from a real scoping session.
444- `references/standalone-file-bridge.md` — recipe for the file-based bridge
445 between a standalone agent and the UE editor.
446- `references/agentu-naming-collision.md` — why to check for package/name
447 collisions before settling on a harness name.
448- `references/tui-dashboard.md` — adding a Textual/Rich dashboard after the
449 loop and stub tests are working.
450- `references/streaming-cli-renderer.md` — minimal rich.live.Live renderer that
451 streams plan + tool calls live, killing the "black terminal" between task
452 submit and final answer. Reuse when the CLI shows nothing until `run()` returns.
453- `references/mnemosyne-integration-recipe.md` — adding durable memory to a
454 standalone Python harness without running inside Hermes.
455- `references/integrating-production-agent-patterns.md` — how to borrow
456 memory, MCP, guardrails, multi-agent, and eval patterns from popular agent
457 repositories and apply them to the harness.
458- `references/mcp-client-pattern.md` — optional MCP client feature-flag wiring (Task 7).
459- `references/hermes-port-map.md` — ranked top-10 components to port from the Hermes fork (with LOC estimates + sequencing), a "do not port" list, and the role-alternation bug to fix first. Replaces the loose "next frontier" guesses.
460- `references/self-improvement-roadmap.md` — Collect→Evaluate→Optimize self-growth plan mined from hermes-agent-self-evolution + Icarus fabric: stage-by-stage loop, portability map, UE-specific eval signals (UBT exit codes, MSVC error taxonomy), trajectory JSONL schema, and the honest "GEPA later, A/B now" call.
461- `references/engine-verified-self-growth-roadmap.md` — condensed 2026-07-17 three-way diagnostic: the engine-verified thesis, the free-binary-ground-truth eval signals, the phased Collect→Evaluate→Optimize build order, and the GEPA-later/fabric-capstone call.
462- `references/web-dashboard-contract.md` — the serve.py↔web/ JSON contract (RunState/event schema, port, Vite proxy), how the backend enriches the raw on_event stream, and the frontend-first-contract pitfall hit when both sides were built without sharing types.ts first.
463- `references/approval-v2-interactive-ask.md` — interactive `ask` mode with stdin
464 prompt, two-layer gate (approval prompt + dry_run hash gate), and test patterns.
465- `references/eval-harness-debugging.md` — session-specific debugging notes from the 2026-07-23 pass that expanded the eval task suite from 4 to 26 tasks and fixed 7 bugs in the eval pipeline (prompt path resolution, ScriptedLLM planner collision, session file selection, harvest enrichment, tool parameter naming, task suite expansion, schema generator name shadowing).
466- `templates/config.yaml` — starter config for a new harness.
467- `references/mvp-implementation-checklist.md` — exact files, fields, and test
468 cases to implement the v1 milestone end-to-end.
469
470## Session format
471
472Use JSONL for session logs: one line per message, append-only, easy to inspect.
473Keep the journal (`progress.md`) short: what changed, what failed, what is next.