Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(agents): gateToolExecute preserves the Tool prototype it wraps

merged#2701CedarCopilot

CedarCopilot wants to merge 1 commit into staging from fix/gate-tool-execute-instanceof-clean

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Sep 6, 2026, 3:25 PM
  2. Sep 6, 2026, 3:33 PM
  3. Merged
    Sep 6, 2026, 3:39 PM
  4. Live on prod
    Sep 6, 2026, 3:39 PM
  5. Observed 1 hour
    Sep 6, 2026, 3:39 PM
  6. Unobserved

    Live on prod, no production signal yet

    Sep 6, 2026, 3:39 PM
  7. Pipelines steady after this deploy
    Sep 6, 2026, 3:39 PM

Behaviors Libra is checking

Gated alias tools retain their Mastra `Tool` prototype so dispatch invokes them with the full tool context, including `requestContext` and `userId`.Not checked
prod

Libra has verdicts on 0 of 1 tracked behaviors on prod; 1 is still being checked. Libra checks hourly for 3 days after each deploy.

What was wrong

gateToolExecute (tool-grant.ts) built its wrapped tool with a plain object spread:

return { ...tool, execute: wrapped } as T;

That produces a plain object, stripping whatever Tool class prototype the original tool had. Mastra's own dispatcher decides which calling convention to use for a tool based on exactly one check , tool instanceof Tool (isVercelTool in @mastra/core). A tool that fails that check gets invoked with (args, execOptions) instead of (args, toolContext), and only toolContext carries requestContext. So every one of the five gated alias tools this wrapper actively wraps (search-live-emails, read-live-email-thread, search-live-slack, read-live-slack-thread, save-draft) silently lost requestContext on every call , independent of what the grant check itself decided.

Confirmed empirically, not just from reading the code:

original instanceof Tool: true
wrapped instanceof Tool: false          ← the bug
wrapped misclassified as Vercel tool: true
--- fix verification ---
fixed instanceof Tool: true
fixed misclassified as Vercel tool: false

How this was found

Traced via Sherlock (2026-09-06) from two independent duplicate-draft investigations (Concentrate, Aspireiq), which led to search-live-emails/read-live-email-thread failing "Missing userId in request context." in production. Confirmed fleet-wide across ≄29 distinct users, multiple orgs , not account-specific. Ruled out several other hypotheses along the way (toolset-vs-base-tool delivery, a misconfigured connection scope, Mastra dispatching by function arity) before finding the actual mechanism in Mastra's own bundled CoreToolBuilder/isVercelTool source.

What changed

  • gateToolExecute now builds its wrapped tool via the same prototype-preserving Object.create + Object.defineProperty pattern instrumentToolExecute (tool-call-timing.ts) already used correctly for this exact reason.
  • Extracted that pattern into a shared cloneToolWithExecute() helper both wrappers now call, so the two can't drift apart the way the plain-spread version drifted from this one.
  • Added regression coverage using a real createTool()-produced Tool instance , the file's existing fakeTool() plain-object helper could never have caught this, since it was never instanceof Tool on either side of the fix.

How to test

  • pnpm --dir apps/server exec vitest run src/mastra/tools/__tests__/tool-grant.test.ts , 26/26, including the new instanceof-preservation and refusal-still-works assertions.
  • Confirmed directly (not just asserted): stashed only the fix, re-ran the suite , the new instanceof test fails with expected false to be true on the pre-fix code while every other test still passes, proving this specific assertion is what the bug breaks.
  • pnpm --dir apps/server run types, pnpm deps:check, and the full tool-grant/family-tool-grant-enforcement/*.recorder-grant/drafting-executor-live-mail-grant test set (124 tests total) all pass.

Scope note

This closes the mechanism, not the whole investigation. Two related things traced in the same session are not covered here and are worth separate follow-ups:

  1. A review-dispatched drafter that never attempts an existing-draft check at all (a model-behavior gap, addressed separately in #2697).
  2. Whether any other agent besides drafter reaches for these same gated tools in production , sampled failures all trace back to drafter specifically, but that may just reflect which agent happens to call them today, not a structural immunity for others.

šŸ¤– Generated with Claude Code

https://claude.ai/code/session_01TdeWk6Xnh5bt6eRLfgvAk7

Greptile Summary

This PR fixes gated Mastra tools losing their Tool prototype and consolidates prototype-preserving execution wrapping into a shared helper.

  • Replaces the object-spread clone in gateToolExecute with
Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/server/src/mastra/tools/__tests__/tool-grant.test.tsno production surface mapped
  • apps/server/src/mastra/tools/tool-grant.tsno production surface mapped
  • apps/server/src/services/agent-action-queue/tool-call-timing.tsno production surface mapped