Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(skills): one load-skill tool, surface enforced at execute time

merged#2627CedarCopilot

CedarCopilot wants to merge 2 commits into staging from fix/single-load-skill-tool

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Aug 30, 2026, 1:57 PM
  2. Aug 30, 2026, 3:13 PM
  3. Merged
    Aug 30, 2026, 5:00 PM
  4. Live on prod
    Aug 30, 2026, 5:00 PM
  5. Observed 0 hours
    Aug 30, 2026, 5:00 PM
  6. Unobserved

    Live on prod, no production signal yet

    Aug 30, 2026, 5:00 PM
  7. Pipelines steady after this deploy
    Aug 30, 2026, 5:00 PM

Behaviors Libra is checking

When a run supplies a non-empty `allowedSkills` request-context list, `load-skill` permits only skills in that list while absent or empty lists remain unrestricted.Not checked
prod
Automation-only skills such as `daily-agenda` load only when the request context has `workflowType=automation`, and are refused on chat or other surfaces before skill content is loaded.Not checked
prod
For an allowed skill configured with specific resources, `load-skill` permits only those resources, while a bare skill entry permits any resource or no resource.Not checked
prod
All agent surfaces use one canonical `load-skill` tool whose schema accepts chat, brain, and automation skill names, so automation requests for `daily-agenda` are no longer rejected by a same-ID tool override.Not checked
prod

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

Replaces #2625, which filtered load-skill out of the injected catalog. That fixed the instance and left the trap armed. This removes the condition that made it possible.

The condition

Two tools shared the id load-skill with different enums:

toolaccepts
loadSkillTool (in-app)chat + brain
createScopedLoadSkillTool (automation)chat + daily-agenda

The automation run injects the chat skill catalog as a toolset; a toolset entry overrides the agent's tool of the same id; and multi-conversation-operations ships the in-app tool among its own. So the chat-enum tool replaced the automation one on every run.

The enums disagreed about exactly one name, and it was the only one the daily-agenda run needed.

62 Ɨ   "skill": "daily-agenda"
62 Ɨ   skill: Invalid enum value. Expected 'drafting' | … | 'analysis-decomposition',
       received 'daily-agenda'

Zero successful loads across 1,562 agenda runs in 14 days. tasks was loaded in 1,264 , the skill the pipeline lived in until it was split out on 2026-08-24. Downstream: no Step 2b status exclusion (closed-lost deals reached the morning brief), no Step 2c stale cleanup (388 overdue tasks stranded on closed deals), agenda format improvised.

Why it survived five months

The collision has existed since 2026-04-02, when both the scoped tool and the toolset injection landed. It was harmless: the scoped tool originally used .enum(CHAT_SKILL_NAMES) , the same list it was being overridden by, so the override changed nothing.

4d8fb3437 (2026-08-24) pointed it at AUTOMATION_LOADABLE_SKILL_NAMES and made the two enums differ for the first time. The commit that armed the bug and the commit that created the override are four months apart, which is why nobody connected them.

The fix

One load-skill.

  • ALL_LOADABLE_SKILL_NAMES = chat + brain + automation. One schema.
  • Surface enforced at execute time , where brain skills were already enforced. An automation-only skill requires workflowType === 'automation', a key buildSurfaceContext sets in services/aop/automations.ts and nowhere else.
  • The description still lists only CHAT_SKILL_NAMES, so an agent on the wrong surface is never told the name exists and is refused if it guesses.

With one tool holding the id, an override cannot change what is loadable. The failure is structurally impossible rather than filtered against.

The allow-list

Moved from a construction argument to a request-context key rather than deleted with the wrapper.

It was dead either way: every production call site passed [], and the allowedSkills computed from a subagent's permissions frontmatter never reached it. That wiring gap is unchanged here and worth its own issue , a scoping feature that has never been enforced, not a regression introduced by this PR.

Verified

  • tsc -b on @zero/server , clean
  • pnpm deps:check , no violations (1587 modules, 8259 dependencies)
  • All four vitest shards. tests/services/organizations/organizations.test.ts fails in shard 2 and is pre-existing , reproduces identically on a clean origin/staging worktree
  • automation-agent.test.ts rewritten against the unified tool: 10 tests, same coverage as the wrapper's, plus the surface gate in both directions

How to confirm

Tomorrow's 09:00 UTC batch. load-skill {"skill":"daily-agenda"} should start appearing in agent_tool_calls, and cancel-task with cancelOlderThan (Tier 1) should fire for the first time.

šŸ¤– Generated with Claude Code

Greptile Summary

The PR eliminates competing load-skill definitions by expanding one shared schema and enforcing automation-only access at execution time.

  • Adds a unified list of chat, brain, and automation-loadable skill names.
  • Registers the shared loadSkillTool with the automation agent.
  • Gates daily-agenda on `workflowType
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/agents/automation-agent.tsno production surface mapped
  • apps/server/src/mastra/skills/types.tsno production surface mapped
  • apps/server/src/mastra/tools/skills/loadSkillTool.tsno production surface mapped
  • apps/server/src/services/aop/__tests__/automation-agent.test.tsno production surface mapped