Skip to content

feat(mcp): serve the Experience tools from the server MCP endpoint and generalize usage attribution - #3866

Draft
t0saki wants to merge 6 commits into
volcengine:mainfrom
t0saki:feat/experience-tools-server-mcp
Draft

feat(mcp): serve the Experience tools from the server MCP endpoint and generalize usage attribution#3866
t0saki wants to merge 6 commits into
volcengine:mainfrom
t0saki:feat/experience-tools-server-mcp

Conversation

@t0saki

@t0saki t0saki commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Description

Moves the Agent Evolution tool pair search_experience / read_experience from the Codex plugin's local JS shim onto the server's /mcp endpoint, so every MCP-connected harness (claude-code, codex, cursor, opencode, zcode, trae, pi) gets the same tools under a single server-side contract. Alongside the move, usage attribution is generalized so recall/injection statistics survive every harness's tool-name namespacing and tool-output envelope, the Experience Memory skill is distributed to every harness with a skill loader, and a new openviking-memory usage skill teaches agents the core memory-tool lifecycle.

Root cause of the attribution fix. Usage attribution (openviking/usage_reporter/extractors.py, openviking/session/memory/experience_lineage.py) compared bare tool names exactly (part.tool_name == "search_experience") and expected tool_output to parse as a plain JSON dict. This worked only because the tools were a Codex-only pilot: Codex rollouts record bare tool names and the raw JSON string. Once other harnesses have the tools, Claude Code records mcp__openviking__search_experience and an MCP content-block array, opencode records openviking_search_experience, and some clients record FastMCP's structuredContent envelope {"result": "<json>"}. Each of those silently zeroes the recall/injection statistics and trajectory lineage — no error, just empty /api/v1/agent-evolution data. The mismatch was introduced with the pilot itself: the extraction pipeline was written against the Codex rollout shape, and nothing normalized names or unwrapped envelopes because no other shape existed yet.

Human Involvement

  • A human participated in the implementation or review loop
  • This PR was generated entirely by AI agents without human participation in the loop

Related Issue

N/A

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Test update

Changes Made

  • New openviking/core/experience.py leaf module with the shared attribution primitives: is_experience_uri_for_user, normalize_experience_tool_name (strips mcp__<server>__ / <server>_ prefixes), and load_tool_output_mapping (unwraps MCP content-block arrays, structuredContent, and {"result": "<json>"} envelopes). experience_lineage.py and usage_reporter/extractors.py both consume it; the module lives under core/ rather than session/memory/ because a top-level import of openviking.session.memory from extractors.py breaks import openviking.usage_reporter through the pre-existing session → storage → service import cycle.
  • openviking/server/mcp_endpoint.py registers search_experience and read_experience. Search is pinned to the authenticated user's viking://user/memories/experiences/, applies no score threshold (the neighbouring find default of 0.35 would silently drop weaker matches), clamps limit to [1, 20] in the body (a pydantic constraint would turn out-of-range values into tool errors), dedupes canonicalized URIs (each result row becomes one memory.recalled event), zeroes non-finite scores (bare NaN breaks JSON.parse client-side), and truncates snippets to 120 chars (keeps a full result set under harness tool_output caps). read_experience requires the canonical URI verbatim and raises InvalidArgumentError instead of returning error strings, so a rejected call surfaces as isError and is never counted as an injection.
  • Deleted examples/codex-memory-plugin/servers/experience-tools.mjs (+ its test) and the localToolProvider wiring in mcp-proxy.mjs; the shared mcp-proxy-core.mjs hook stays as a generic escape hatch. The Python tools' JSON output is byte-identical to the deleted JS implementation (verified json.dumps vs JSON.stringify, CJK included).
  • Skill distribution: sync.mjs gains SKILL_TARGETS with a byte-equality test in sync.test.mjs; ov-experience-memory is registered in openclaw's skills[] and shipped to claude-code/cursor; the marketplace staging script's required list covers the new paths; SKILL.md is rewritten for the server-side semantics (version 2026.8.7).
  • New examples/skills/openviking-memory usage skill covering the session lifecycle (injected <openviking-context> first, retrieval-tool selection, deliberate writes, automatic capture/extraction, ov CLI pointer), synced to codex/claude-code/cursor; cursor's previous minimal variant converges onto the shared source, and openclaw keeps its REST-specific openviking-context-database skill.
  • Docs: guides/06 (en+zh) now lists 15 tools with the two new rows, and api/19 (en+zh) gains an MCP tool contract section documenting the fixed input/output schemas, prefix stripping, and the error-vs-empty-result rule.
  • CI: capture-utils.test.mjs added to pr.yml (guards the tool-output truncation path attribution depends on).

Testing

  • I have added tests that prove my fix is effective or that my feature works

  • New and existing unit tests pass locally with my changes

  • I have tested this on the following platforms:

    • Linux
    • macOS
    • Windows
  • tests/server/test_mcp_experience_tools.py: attribution parametrized across five recorded harness shapes (codex bare, claude-code mcp__ + content-block array, opencode openviking_, openclaw bare, structuredContent), envelope/name-normalization unit cases, and an end-to-end round trip that drives the real mcp.call_tool dispatch and replays its output through MemoryUsageExtractor and collect_read_experience_uris.

  • tests/server/test_mcp_endpoint.py: target-URI pinning, absent score_threshold, limit clamping, cross-user/sidecar/aliased-URI filtering, CJK percent-decoded titles, snippet fallback and truncation, non-finite score handling, and 12 rejected-URI cases asserting storage is never touched.

  • pytest tests/server/ green (128 passed); the 3 failures in tests/unit/session/memory/test_extract_loop_match_text.py reproduce identically on main (pre-existing).

  • node --test over the full pr.yml suite: 249 passed. openclaw vitest: 750 passed; the 4 failures in architecture-boundaries.test.ts also fail on main against untouched sources.

  • ruff format --check and ruff check clean on all changed Python files.

Checklist

  • My code follows the project's coding style
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

Screenshots (if applicable)

N/A

Additional Notes

  • Known tool-name collision, deliberately untouched: benchmark/tau2/train/rollout_executor_vikingbot.py registers same-named tools with different parameters and output shapes (read_experience(experience_uri), search_experience returning candidates over memories/cases). It produces no attribution statistics today and this PR does not change that; to be reconciled in a follow-up.
  • examples/openclaw-plugin/plugin/openviking-experience-tools.ts isCanonicalExperienceUri degrades to accepting any tenant's experiences directory when user is empty — the same flaw the deleted JS shim had. Left for a follow-up since the openclaw REST path is out of scope here.
  • Users who update the plugin before their server ships these tools lose the pair for the interim window (accepted trade-off; the skill tells agents the tools come from the server).

t0saki added 5 commits August 7, 2026 15:43
The Experience attribution pipeline compared bare tool names and required a
plain JSON-object tool_output. Only Codex records that shape: Claude Code
namespaces MCP tools as mcp__openviking__* and records the result as an MCP
content-block array, opencode uses openviking_*. On any other harness both
recall/injection events and trajectory lineage silently came out empty.

Move the Experience URI predicate, tool-name normalization, and tool-output
envelope parsing into openviking/core/experience.py so both the usage
extractor and the lineage collector share them.
search_experience / read_experience were piloted as a JS localToolProvider
shim inside the Codex plugin, so only Codex had them. Register them on the
server's /mcp endpoint instead: every proxy-based harness picks them up with
no plugin change, and the fixed name + JSON payload contract that Agent
Evolution attribution depends on is now enforced in one place.

The port is not literal in three places:
- no score_threshold, unlike the neighbouring find/search tools which default
  to 0.35 and would drop the weaker half of the matches
- URI ownership goes through canonical_experience_uri, closing the JS
  fallback that accepted any tenant's experiences dir when user was empty
- invalid input raises InvalidArgumentError rather than returning an error
  string, so a failed read is not recorded as an injection

Delete the JS implementation and its Codex wiring. The shared proxy's
localToolProvider hook stays as a generic escape hatch.
… a skill loader

The MCP tools now reach every proxy-based harness on their own, but the skill
that teaches an agent when to call them only reached Codex. Register it in
OpenClaw's skills[] (the files were already packaged, just never declared) and
add it to Claude Code and Cursor.

The three byte-identical copies were maintained by hand; sync.mjs now generates
them from examples/skills/ and sync.test.mjs fails on drift. Skills are copied
verbatim — a GENERATED FROM banner ahead of the --- frontmatter would break
every skill loader.

Also wire capture-utils.test.mjs into pr.yml; it guards the tool_output
truncation path and was not running in CI.
…attribution

Drives mcp.call_tool and replays both the content-block array and FastMCP's
structuredContent wrapper through the usage extractor and lineage collector,
so the payload the tools actually emit is what attribution is proven against.
…sses

A lifecycle-oriented skill for the core memory tools (recall/find/search/
grep, read, remember/add_resource/forget), synced byte-identically from
examples/skills to the codex, claude-code, and cursor plugins. Converges
cursor's minimal openviking-memory variant onto the shared source. The
openclaw plugin keeps its REST-specific operator skill instead.
Copilot AI lite review requested due to automatic review settings August 7, 2026 08:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An agent whose plugin updated before the server would otherwise see the
tools missing and improvise with generic find/read, which never counts as
experience recall or injection.
ZaynJarvis
ZaynJarvis previously approved these changes Aug 7, 2026

@ZaynJarvis ZaynJarvis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@ZaynJarvis
ZaynJarvis dismissed their stale review August 7, 2026 09:21

pending

@t0saki
t0saki marked this pull request as draft August 7, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

3 participants