Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ce8b932
fix(mcp): reject capture escapes before directory creation
drsnuggles8 Oct 12, 2026
56b1662
feat(mcp): retain per-frame CPU scope recordings
drsnuggles8 Oct 12, 2026
db4d9e4
fix(docs): document consent-gated foliage rebuild tool
drsnuggles8 Oct 13, 2026
c7d7443
test(mcp): record baseline frame breakdown checks
drsnuggles8 Oct 13, 2026
dc25d0c
test(mcp): retain live recorder evidence and contention limits
drsnuggles8 Oct 13, 2026
5f2aa53
merge: integrate master scene-path changes and clarify trace windows
drsnuggles8 Oct 13, 2026
85a514e
docs(perf): distinguish trace windows and diagnostic thresholds
drsnuggles8 Oct 13, 2026
8e447b8
docs(perf): record competing editor overlap in GL trace
drsnuggles8 Oct 13, 2026
526755c
fix(mcp): preserve valid UTF-8 when exporting truncated scope names
drsnuggles8 Oct 13, 2026
d74ffcc
fix(perf): verify rendered camera windows and reject reset histories
drsnuggles8 Oct 13, 2026
ed18260
perf(renderer): retain per-pass CPU scopes for hitch attribution
drsnuggles8 Oct 13, 2026
990653a
docs(perf): publish uncontended scope, camera-window and overhead evi…
drsnuggles8 Oct 13, 2026
23dbff0
merge: integrate master and consolidate foliage rebuild documentation
drsnuggles8 Oct 13, 2026
e9045cf
docs(perf): identify timing evidence collected before master integration
drsnuggles8 Oct 13, 2026
7124d33
docs(perf): record verified master integration
drsnuggles8 Oct 13, 2026
8242801
fix(mcp): reduce default discovery payload and enforce a 16 KB budget
drsnuggles8 Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/skills/run-oloengine/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,10 @@ Read-only work (screenshots, camera moves, `olo_render_capture_target`,
actually intend to mutate the project.

**`tools/list` shows a core set, not the whole surface (issue #1124).** The editor
defaults to the `core` exposure profile: ~16 curated tools plus the discovery gateway,
because listing all 96 costs ~270 KB / ~68k tokens of schemas before you ask anything.
defaults to the `core` exposure profile: scene summary, performance/liveness and
engine logs plus the four discovery tools. Detailed inspection schemas are fetched
when needed; the builtin default has a 16 KB budget. See the current measurements in
[the MCP guide](../../../docs/guides/mcp-diagnostics-server.md#exposure-profiles--toolslist-shows-a-core-set-by-default-issue-1124).
Everything named above — `olo_scene_open`, `olo_renderer_settings_set`,
`olo_entity_set_field`, the whole `olo_render_*` / `olo_physics_*` / `olo_perf_*`
families — **is still callable by name**; exposure filters the listing, never dispatch.
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/acceptance-live-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Live acceptance sampler tests

on:
pull_request:
paths:
- 'scripts/perf/acceptance-live.py'
- 'scripts/perf/test_acceptance_live.py'
- '.github/workflows/acceptance-live-tests.yml'

permissions:
contents: read

jobs:
camera-window:
name: Verify live camera frame windows
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.10'
- run: python -m pip install PyYAML==6.0.2
- run: python -m unittest discover -s scripts/perf -p 'test_acceptance_live.py' -v
2 changes: 2 additions & 0 deletions OloEditor/src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"MCP/McpToolsDiagnostics.cpp"
"MCP/McpToolsEditor.cpp"
"MCP/McpToolsPerf.cpp"
"MCP/McpToolsCpuScopeRing.cpp"
"MCP/McpToolsPhysics.cpp"
"MCP/McpToolsRender.cpp"
"MCP/McpToolsScene.cpp"
Expand Down Expand Up @@ -254,6 +255,7 @@ set(OLO_EDITOR_CORE_SOURCES
"MCP/McpToolsGateway.cpp"
"MCP/McpToolsInput.cpp"
"MCP/McpToolsPerf.cpp"
"MCP/McpToolsCpuScopeRing.cpp"
"MCP/McpToolsPhysics.cpp"
"MCP/McpToolsRender.cpp"
"MCP/McpToolsScene.cpp"
Expand Down
54 changes: 29 additions & 25 deletions OloEditor/src/MCP/McpCapturePath.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ namespace OloEngine::MCP::CapturePath

// The lexical, filesystem-free half. On success `outRelative` is the path
// relative to the editor CWD (so a bare "shot.png" becomes
// assets/mcp-captures/shot.png) with a .png extension forced on. Returns a
// assets/mcp-captures/shot.png) with the caller's fixed extension forced on.
// Screenshot callers use .png; CPU scope dumps use .json. Returns a
// human-readable error otherwise.
//
// Note the order: every rejection is decided on the path AS WRITTEN, before
Expand All @@ -59,7 +60,8 @@ namespace OloEngine::MCP::CapturePath
// to something inside it whenever the root is deep enough, so the check
// would pass for some roots and fail for others.
[[nodiscard]] inline std::optional<std::string> ValidateRelative(std::string_view input,
std::filesystem::path& outRelative)
std::filesystem::path& outRelative,
std::string_view extension = ".png")
{
namespace fs = std::filesystem;
if (input.empty())
Expand Down Expand Up @@ -126,10 +128,9 @@ namespace OloEngine::MCP::CapturePath
const auto mm = std::mismatch(root.begin(), root.end(), normalized.begin(), normalized.end());
fs::path candidate = (mm.first == root.end() ? normalized : (root / normalized)).lexically_normal();

// Force .png: the bytes are always a PNG, and a caller that named the
// file ".jpg" would otherwise get a file that lies about its format.
if (const fs::path ext = candidate.extension(); ext != ".png" && ext != ".PNG")
candidate += ".png";
// The format is chosen by the tool, never by an untrusted argument.
if (const fs::path ext = candidate.extension(); ext != extension && !(extension == ".png" && ext == ".PNG"))
candidate += extension;

// CONTAINMENT, RE-CHECKED ON THE FINAL PATH — not on the input. Every
// check above reasons about what the caller wrote; this one reasons
Expand All @@ -151,43 +152,46 @@ namespace OloEngine::MCP::CapturePath
return std::nullopt;
}

// The full resolve: `ValidateRelative`, then create the parent directory and
// confirm the result really is inside the captures root once symlinks have
// been followed. `outAbsolute` is what the tool reports back.
// Check canonical containment before creating directories, and again after
// creation. Rejected symlink escapes must not change the outside tree.
// `outAbsolute` is what the tool reports back.
//
// The lexical checks stop '..' and absolute paths; they do NOT stop a
// symlinked component inside the root redirecting the write outside it.
// weakly_canonical resolves the existing prefix — which catches a symlinked
// root — and handles the not-yet-created file lexically.
[[nodiscard]] inline std::optional<std::string> Resolve(std::string_view input,
std::filesystem::path& outRelative,
std::filesystem::path& outAbsolute)
std::filesystem::path& outAbsolute,
std::string_view extension = ".png")
{
namespace fs = std::filesystem;
if (auto error = ValidateRelative(input, outRelative))
if (auto error = ValidateRelative(input, outRelative, extension))
return error;

std::error_code ec;
const auto checkContainment = [&]() -> std::optional<std::string>
{
const fs::path root = fs::weakly_canonical(Root(), ec);
if (ec)
return "Could not resolve the capture root (" + RootDisplay() + ").";
const fs::path candidate = fs::weakly_canonical(outRelative, ec);
if (ec)
return "Could not resolve 'path' to a canonical location.";
if (const fs::path rel = candidate.lexically_relative(root); rel.empty() || *rel.begin() == "..")
return "Invalid 'path': resolves outside " + RootDisplay() + " (possible symlink escape). The capture was not written.";
outAbsolute = candidate;
return std::nullopt;
};
if (auto error = checkContainment())
return error;
fs::create_directories(outRelative.parent_path(), ec);
if (ec)
{
return "Could not create the capture directory '" + outRelative.parent_path().generic_string() +
"': " + ec.message();
}

const fs::path canonicalRoot = fs::weakly_canonical(Root(), ec);
if (ec)
return "Could not resolve the capture root (" + RootDisplay() + ").";
const fs::path canonicalOut = fs::weakly_canonical(outRelative, ec);
if (ec)
return "Could not resolve 'path' to a canonical location.";
if (const fs::path rel = canonicalOut.lexically_relative(canonicalRoot); rel.empty() || *rel.begin() == "..")
{
return "Invalid 'path': resolves outside " + RootDisplay() +
" (possible symlink escape). The capture was not written.";
}

outAbsolute = canonicalOut;
return std::nullopt;
return checkContainment();
}
} // namespace OloEngine::MCP::CapturePath
45 changes: 45 additions & 0 deletions OloEditor/src/MCP/McpCpuScopeRing.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#pragma once

#include "OloEngine/Core/CpuScopeRing.h"

#include <nlohmann/json.hpp>
#include <string_view>
#include <utility>

namespace OloEngine::MCP::CpuScopeRecording
{
// Recording remains a bounded byte copy. Drop only an incomplete trailing
// UTF-8 character here, off the recording thread, before strict JSON output.
[[nodiscard]] inline std::string_view ExportName(const CpuScopeRing::Event& event)
{
std::string_view name(event.Name.data());
if (!event.NameTruncated || name.empty())
return name;
sizet start = name.size() - 1;
while (start > 0 && (static_cast<u8>(name[start]) & 0xC0u) == 0x80u)
--start;
const auto lead = static_cast<u8>(name[start]);
const sizet width = lead >= 0xF0u ? 4 : lead >= 0xE0u ? 3
: lead >= 0xC0u ? 2
: 1;
return name.size() - start < width ? name.substr(0, start) : name;
}

// This is the real recorder snapshot, not a second synthetic input model.
[[nodiscard]] inline nlohmann::json ToJson(const CpuScopeRing::Snapshot& capture)
{
using Json = nlohmann::json;
Json out{ { "armed", capture.Armed }, { "frameCapacity", capture.FrameCapacity }, { "eventsPerFrame", capture.EventsPerFrame }, { "framesWritten", capture.FramesWritten }, { "framesOverwritten", capture.FramesOverwritten }, { "lateCompletions", capture.LateCompletions }, { "partialFramesDiscarded", capture.PartialFramesDiscarded }, { "retainedBytes", capture.RetainedBytes }, { "frames", Json::array() } };
for (const auto& frame : capture.Frames)
{
Json scopes = Json::array();
for (u32 i = 0; i < frame.EventCount; ++i)
{
const auto& event = capture.Events[static_cast<i32>(frame.EventOffset + i)];
scopes.push_back(Json{ { "name", ExportName(event) }, { "nameTruncated", event.NameTruncated }, { "depth", event.Depth }, { "thread", event.Thread }, { "startMs", event.StartMs }, { "durationMs", event.Complete ? Json(event.DurationMs) : Json(nullptr) }, { "complete", event.Complete } });
}
out["frames"].push_back(Json{ { "frameId", frame.Id }, { "frameTimeMs", frame.ElapsedMs }, { "rendererCpuMs", frame.Render.RendererFrameAdvanced ? Json(frame.Render.RendererCpuMs) : Json(nullptr) }, { "rendererFrameId", frame.Render.RendererFrameId }, { "rendererFrameAdvanced", frame.Render.RendererFrameAdvanced }, { "gpuMs", frame.Render.GpuStatus == GpuTimingStatus::Valid ? Json(frame.Render.GpuMs) : Json(nullptr) }, { "gpuStatus", ToString(frame.Render.GpuStatus) }, { "gpuMeasurementFrameId", frame.Render.GpuFrameId }, { "gpuIssueFrameId", frame.Render.GpuIssueFrameId }, { "gpuFrameAdvanced", frame.Render.GpuFrameAdvanced }, { "sameFrameGpuMs", frame.SameFrameGpu.IsValid() ? Json(frame.SameFrameGpu.GpuMs) : Json(nullptr) }, { "sameFrameGpuStatus", ToString(frame.SameFrameGpu.Status) }, { "droppedEvents", frame.DroppedEvents }, { "scopes", std::move(scopes) } });
}
return out;
}
} // namespace OloEngine::MCP::CpuScopeRecording
31 changes: 7 additions & 24 deletions OloEditor/src/MCP/McpExposure.h
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,11 @@ namespace OloEngine::MCP

// ---- the core set ---------------------------------------------------------
//
// THE CRITERION: a tool is core if a session that cannot see it would be unable to
// work out what to ask next. That is orientation (what scene, which entities, what
// just happened, is the editor even running frames), the two eyes on the frame
// (screenshot + intermediate target capture) and the camera controls those need to
// be useful, plus the two error channels an agent must check before believing any
// of it (engine log, shader errors). Everything else — the 30-odd render
// introspection tools, physics, perf, assets, scripting, the editor panels — is
// reachable in one `olo_tool_search` call and is not needed to form the question.
// THE CRITERION: core answers what scene is loaded, whether frames are running,
// and what the engine just logged. The gateway then supplies detailed inspection
// schemas on demand. Camera, capture, entity, event and shader tools remain fully
// registered and directly callable; loading their schemas up front exhausted the
// old 46 KB budget. The builtin core now has a 16 KB ceiling (#1604).
//
// Deliberately NOT core: every write tool. A narrowed default should not advertise
// mutation; a session that wants it is already configuring consent, and can ask.
Expand All @@ -123,24 +120,10 @@ namespace OloEngine::MCP
[[nodiscard]] inline std::span<const std::string_view> CoreToolNames()
{
static constexpr std::array kNames{
// orientation
// scene orientation
std::string_view{ "olo_scene_summary" },
std::string_view{ "olo_scene_list_entities" },
std::string_view{ "olo_scene_get_entity" },
std::string_view{ "olo_events_tail" },
// the subscription half of the tail (#1131): an agent that can list the
// tail but not the wait polls, which is the loop the bus exists to end
std::string_view{ "olo_events_wait" },
// the two error channels
// engine errors and recent activity
std::string_view{ "olo_log_tail" },
std::string_view{ "olo_shader_errors" },
// eyes on the frame
std::string_view{ "olo_screenshot" },
std::string_view{ "olo_render_list_targets" },
std::string_view{ "olo_render_capture_target" },
// camera control, without which the two above only ever see one angle
std::string_view{ "olo_camera_get" },
std::string_view{ "olo_camera_set_pose" },
// "is it actually running frames?" — a parked editor answers every read
// tool with a stale frame, so this is the check that validates the rest
std::string_view{ "olo_perf_snapshot" },
Expand Down
1 change: 1 addition & 0 deletions OloEditor/src/MCP/McpToolsCommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ namespace OloEngine::MCP
void RegisterDiagnosticsTools(AutomationRegistry& registry);
void RegisterSceneTools(AutomationRegistry& registry);
void RegisterPerfTools(AutomationRegistry& registry);
void RegisterCpuScopeRingTools(AutomationRegistry& registry);
void RegisterStreamingResidencyTools(AutomationRegistry& registry);
void RegisterRenderTools(AutomationRegistry& registry);
void RegisterShaderTools(AutomationRegistry& registry);
Expand Down
Loading
Loading