refactor(skills): make the brief's skill list a names-only index (MUL-5529) (#6207)

* refactor(skills): make the brief's skill list a names-only index (MUL-5529)

Step 3 of MUL-5529. Every runtime CLI discovers the SKILL.md files the daemon
writes and builds its own listing from their frontmatter — verified against 11
locally installed CLIs plus official docs for 5 more. The brief's copy of those
descriptions was therefore the same routing signal paid for twice: measured on
a real task, `## Skills` was 13,295 chars, 40% of the entire brief, against a
16,304-char CLI listing of the same 28 skills.

Now 850 chars for that same set — roughly 3,100 tokens back per brief.

The index itself stays. It is the one skill listing Multica controls; each
CLI's own listing is theirs, and its format — or its existence — can change
with any release.

Three changes:

  - Descriptions dropped from the `## Skills` entries.

  - The per-provider branch is gone. Its fallback told providers outside a
    hardcoded list to read `.agent_context/skills/`, but the only providers
    that ever reached it were grok and traecli, whose files are written to
    `.grok/skills` and `.traecli/skills` and which discover natively. The
    pointer was wrong for everyone it addressed, so removing the branch
    deletes the bug rather than relocating it. This closes MUL-5537.

  - issue_context.md and its quick-create / autopilot variants no longer render
    `## Agent Skills`. That copy duplicated the brief once both were
    names-only, and nothing ever read it: no prompt references the path, and
    grepping the server finds only the writer. `.agent_context/skills/` had the
    same fate for hermes (issue #5242). Quick-create, previously skipped in the
    brief and served only by that unread copy, now gets the brief section like
    every other kind — one index, one place.

Not included: skills carrying `disable-model-invocation` are still written to
disk for every provider. The plan assumed that key needed provider-specific
handling for everything except claude; probing the installed CLIs shows 9 of 11
honor it, and only opencode and hermes do not. The remaining question is
narrow and a genuine product tradeoff — withholding the file honors the
author's intent but also removes explicit invocation — so it is left to a
separate decision rather than folded in here.

Co-authored-by: multica-agent <[email protected]>

* docs(skills): align stale comments with the names-only brief contract (MUL-5529)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Steve Jobs (Multica Agent) <[email protected]>
This commit is contained in:
Bohan Jiang
2026-07-31 13:27:50 +08:00
committed by GitHub
co-authored by multica-agent Bohan-J Steve Jobs
parent 00e2060978
commit abdfd3e28c
7 changed files with 125 additions and 104 deletions
+4 -31
View File
@@ -1007,23 +1007,14 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string {
b.WriteString("## Quick Start\n\n")
fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID)
skills := modelVisibleSkills(ctx.AgentSkills)
if len(skills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("The following skills are available to you:\n\n")
for _, skill := range skills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}
// renderQuickCreateContext renders issue_context.md for quick-create tasks.
// This file carries only task data (user input, skills). Behavioral rules
// and guardrails live in AGENTS.md (runtime config) and the per-turn prompt
// to avoid redundancy and conflicting instructions.
// This file carries only task data (the user input). Behavioral rules and
// guardrails live in AGENTS.md (runtime config) and the per-turn prompt to
// avoid redundancy and conflicting instructions; the skill index lives in the
// runtime brief like every other kind (MUL-5529).
func renderQuickCreateContext(ctx TaskContextForEnv) string {
var b strings.Builder
b.WriteString("# Quick Create\n\n")
@@ -1032,14 +1023,6 @@ func renderQuickCreateContext(ctx TaskContextForEnv) string {
b.WriteString("> ")
b.WriteString(ctx.QuickCreatePrompt)
b.WriteString("\n\n")
skills := modelVisibleSkills(ctx.AgentSkills)
if len(skills) > 0 {
b.WriteString("## Agent Skills\n\n")
for _, skill := range skills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}
@@ -1072,15 +1055,5 @@ func renderAutopilotContext(ctx TaskContextForEnv) string {
b.WriteString("\n\n")
}
skills := modelVisibleSkills(ctx.AgentSkills)
if len(skills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("The following skills are available to you:\n\n")
for _, skill := range skills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}
+13 -17
View File
@@ -129,11 +129,12 @@ func TestPrepareDirectoryMode(t *testing.T) {
if err != nil {
t.Fatalf("failed to read issue_context.md: %v", err)
}
// "Code Review" is listed by its on-disk slug (MUL-5529).
for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "code-review"} {
if !strings.Contains(string(content), want) {
t.Fatalf("issue_context.md missing %q", want)
}
if !strings.Contains(string(content), "a1b2c3d4-e5f6-7890-abcd-ef1234567890") {
t.Fatalf("issue_context.md missing the issue id")
}
// The skill list lives in the runtime brief only (MUL-5529).
if strings.Contains(string(content), "code-review") {
t.Fatalf("issue_context.md should no longer carry a skill list:\n%s", content)
}
markerContent, err := os.ReadFile(filepath.Join(env.WorkDir, TaskContextMarkerRelPath))
@@ -449,22 +450,17 @@ func TestWriteContextFiles(t *testing.T) {
}
s := string(content)
for _, want := range []string{
"test-issue-id-1234",
"## Agent Skills",
// Listed by on-disk slug, not the "Go Conventions" display name —
// the slug is the only name the model can invoke (MUL-5529).
"go-conventions",
} {
if !strings.Contains(s, want) {
t.Errorf("content missing %q", want)
}
if !strings.Contains(s, "test-issue-id-1234") {
t.Errorf("content missing %q", "test-issue-id-1234")
}
// Issue details should NOT be in the context file (agent fetches via CLI).
for _, absent := range []string{"## Description", "## Workspace Context"} {
//
// Nor the skill list: nothing ever read this copy, and the runtime brief
// carries the same names-only index (MUL-5529).
for _, absent := range []string{"## Description", "## Workspace Context", "## Agent Skills", "go-conventions"} {
if strings.Contains(s, absent) {
t.Errorf("content should NOT contain %q — agent fetches details via CLI", absent)
t.Errorf("content should NOT contain %q", absent)
}
}
@@ -143,9 +143,9 @@ func TestBuildMetaSkillContentSlimKindMatrix(t *testing.T) {
{"## Issue Metadata", issueKinds},
{"## Instruction Precedence", issueKinds},
{"## Sub-issue Creation", issueKinds},
{"## Skills", map[taskKind]bool{
kindIssue: true, kindAutopilotRunOnly: true, kindChat: true,
}},
// Quick-create included: it used to be skipped here and carry its own
// copy in issue_context.md, which nothing read. One index, one place.
{"## Skills", allKinds},
{"## Mentions", issueKinds},
{"## Attachments", issueKinds},
}
@@ -548,28 +548,32 @@ func writeSubIssueCreation(b *strings.Builder) {
b.WriteString("**Ordering with stages.** For phased plans, group children with `--stage <N>` (N ≥ 1) instead of hand-promoting the backlog chain — stage members run together, and the parent wakes once per stage. Use `--stage k --status backlog` for later stages, then `multica issue children <id>` to inspect groupings before promoting. Reach for stages whenever a plan has more than one step or a step must wait for a group.\n\n")
}
// writeSkills emits the Skills section listing skill names + descriptions.
func writeSkills(b *strings.Builder, provider string, ctx TaskContextForEnv) {
// writeSkills emits the Skills section: an index of invocable skill names.
//
// Names only, deliberately. Every runtime CLI discovers the SKILL.md files the
// daemon writes and builds its own listing from their frontmatter, so repeating
// the descriptions here bought a second, more expensive copy of what the model
// already had — measured at ~3,100 tokens per brief on a real task, 40% of the
// whole brief — and no extra routing signal (MUL-5529).
//
// The index itself stays because it is the one skill listing Multica controls.
// Each CLI's own listing is theirs: its format, and whether it exists at all,
// can change with any release.
//
// There is no per-provider branch. The old fallback told providers outside a
// hardcoded list to read `.agent_context/skills/`, which was the wrong path for
// every provider that actually reached it — grok and traecli write to
// `.grok/skills` and `.traecli/skills` — while both discover natively and never
// needed the pointer.
func writeSkills(b *strings.Builder, ctx TaskContextForEnv) {
skills := modelVisibleSkills(ctx.AgentSkills)
if len(skills) == 0 {
return
}
b.WriteString("## Skills\n\n")
switch provider {
case "claude", "codebuddy", "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "qoder", "antigravity", "qwen":
// Hermes discovers these from its per-task HERMES_HOME/skills (seeded by
// the daemon), so it needs the same "discovered automatically" framing
// as the other native-discovery runtimes rather than a path pointer.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
default:
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
}
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
for _, skill := range skills {
if desc := strings.TrimSpace(skill.Description); desc != "" {
fmt.Fprintf(b, "- **%s** — %s\n", skill.Name, desc)
} else {
fmt.Fprintf(b, "- **%s**\n", skill.Name)
}
fmt.Fprintf(b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
@@ -685,7 +689,7 @@ func writeOutput(b *strings.Builder, kind taskKind, ctx TaskContextForEnv) {
// Issue Metadata | ✓ | ✓ | — | — | —
// Instruction Precedence| — | ✓ | — | — | —
// Sub-issue Creation | ✓ | ✓ | — | — | —
// Skills | ✓ | ✓ | ✓ | — | ✓
// Skills | ✓ | ✓ | ✓ | ✓ | ✓
// Mentions | ✓ | ✓ | — | — | —
// Attachments | ✓ | ✓ | — | — | —
//
@@ -749,9 +753,10 @@ func buildMetaSkillContentSlim(provider string, ctx TaskContextForEnv) string {
writeSubIssueCreation(&b)
}
if kind != kindQuickCreate {
writeSkills(&b, provider, ctx)
}
// Every kind, quick-create included. Quick-create used to be skipped here
// and carried its own copy in issue_context.md instead; now that both are
// the same names-only index, the brief is the one that survives.
writeSkills(&b, ctx)
if kind == kindIssue {
writeMentions(&b)
@@ -1744,3 +1744,54 @@ func TestBriefByteIdenticalAcrossRunsForEveryKind(t *testing.T) {
})
}
}
// TestBriefSkillsListIsNamesOnly pins the shape of the `## Skills` section: an
// index of invocable names, with no descriptions and no per-provider branch.
//
// Descriptions were removed because every runtime CLI already builds its own
// listing from the SKILL.md frontmatter the daemon writes, so the brief's copy
// was the same routing signal paid for twice — ~3,100 tokens per brief on a
// real task, 40% of the whole brief (MUL-5529).
//
// The provider branch was removed because its fallback was wrong: it told
// providers outside a hardcoded list to look in `.agent_context/skills/`, but
// the only providers that ever reached it — grok and traecli — have their files
// written to `.grok/skills` and `.traecli/skills` and discover them natively.
func TestBriefSkillsListIsNamesOnly(t *testing.T) {
t.Parallel()
ctx := TaskContextForEnv{
IssueID: "issue-1",
AgentName: "Eve",
AgentID: "eve-1",
AgentSkills: []SkillContextForEnv{
{
Name: "PR Review",
Description: "Use when reviewing a pull request for the Multica project.",
Content: "---\nname: pr-review\n---\n\nbody",
},
},
}
// grok and traecli are the providers that used to take the removed branch;
// the rest are a spread across the native-discovery list.
for _, provider := range []string{"claude", "codex", "opencode", "hermes", "grok", "traecli", "some-unknown-provider"} {
t.Run(provider, func(t *testing.T) {
t.Parallel()
out := buildMetaSkillContent(provider, ctx)
if !strings.Contains(out, "- **pr-review**\n") {
t.Errorf("brief does not list the skill by slug:\n%s", out)
}
if strings.Contains(out, "Use when reviewing a pull request") {
t.Errorf("brief still carries the skill description; the CLI's own listing already has it:\n%s", out)
}
if strings.Contains(out, ".agent_context/skills/") {
t.Errorf("brief still points at the removed fallback path:\n%s", out)
}
if !strings.Contains(out, "discovered automatically") {
t.Errorf("brief lost the native-discovery framing:\n%s", out)
}
})
}
}
@@ -9,14 +9,12 @@ import (
// modelVisibleSkills returns the skills a model may invoke, with Name
// normalized to the on-disk slug.
//
// The normalization is not cosmetic. Every caller renders these entries into a
// listing the model reads to pick a skill, and the only name it can actually
// invoke is the directory slug writeSkillFiles lays down — sanitizeSkillName of
// the same field. A workspace skill's Name is its human display name ("PR
// review"), so listing it verbatim hands the model an identifier that does not
// resolve (MUL-5529). Normalizing here rather than at each call site keeps the
// four listings (runtime brief + the three issue_context renderers) from
// drifting apart.
// The normalization is not cosmetic. The runtime brief renders these entries
// into the listing the model reads to pick a skill, and the only name it can
// actually invoke is the directory slug writeSkillFiles lays down —
// sanitizeSkillName of the same field. A workspace skill's Name is its human
// display name ("PR review"), so listing it verbatim hands the model an
// identifier that does not resolve (MUL-5529).
//
// Slugs come from resolveSkillSlugs over the *unfiltered* batch, because
// writeSkillFiles lays down every skill — including the ones hidden here — and
@@ -117,37 +117,35 @@ Hidden body.`,
},
}
rendered := map[string]string{
"runtime config": buildMetaSkillContent("codex", TaskContextForEnv{
IssueID: ctx.IssueID,
AgentName: ctx.AgentName,
AgentID: ctx.AgentID,
AgentSkills: ctx.AgentSkills,
}),
"issue context": renderIssueContext("codex", TaskContextForEnv{
IssueID: ctx.IssueID,
AgentSkills: ctx.AgentSkills,
}),
"quick create": renderQuickCreateContext(TaskContextForEnv{
QuickCreatePrompt: ctx.QuickCreatePrompt,
AgentSkills: ctx.AgentSkills,
}),
"autopilot": renderAutopilotContext(TaskContextForEnv{
AutopilotRunID: ctx.AutopilotRunID,
AgentSkills: ctx.AgentSkills,
}),
}
for name, out := range rendered {
// The brief is now the only Multica-rendered skill listing, for every kind.
for _, kind := range []TaskContextForEnv{
{IssueID: ctx.IssueID, AgentName: ctx.AgentName, AgentID: ctx.AgentID, AgentSkills: ctx.AgentSkills},
{QuickCreatePrompt: ctx.QuickCreatePrompt, AgentName: ctx.AgentName, AgentID: ctx.AgentID, AgentSkills: ctx.AgentSkills},
{AutopilotRunID: ctx.AutopilotRunID, AgentName: ctx.AgentName, AgentID: ctx.AgentID, AgentSkills: ctx.AgentSkills},
{ChatSessionID: "c-1", AgentName: ctx.AgentName, AgentID: ctx.AgentID, AgentSkills: ctx.AgentSkills},
} {
out := buildMetaSkillContent("codex", kind)
// Listings carry the on-disk slug, not the display name (MUL-5529).
if !strings.Contains(out, "visible-skill") {
t.Errorf("%s missing visible skill:\n%s", name, out)
t.Errorf("brief missing visible skill:\n%s", out)
}
if strings.Contains(out, "Visible Skill") {
t.Errorf("%s lists the display name instead of the slug:\n%s", name, out)
t.Errorf("brief lists the display name instead of the slug:\n%s", out)
}
if strings.Contains(out, "hidden-skill") || strings.Contains(out, "hidden description") {
t.Errorf("%s advertised disable-model-invocation skill:\n%s", name, out)
t.Errorf("brief advertised disable-model-invocation skill:\n%s", out)
}
}
// issue_context.md and its quick-create / autopilot variants no longer
// carry a skill list at all; nothing read that copy.
for name, out := range map[string]string{
"issue context": renderIssueContext("codex", TaskContextForEnv{IssueID: ctx.IssueID, AgentSkills: ctx.AgentSkills}),
"quick create": renderQuickCreateContext(TaskContextForEnv{QuickCreatePrompt: ctx.QuickCreatePrompt, AgentSkills: ctx.AgentSkills}),
"autopilot": renderAutopilotContext(TaskContextForEnv{AutopilotRunID: ctx.AutopilotRunID, AgentSkills: ctx.AgentSkills}),
} {
if strings.Contains(out, "## Agent Skills") || strings.Contains(out, "visible-skill") {
t.Errorf("%s still renders a skill list:\n%s", name, out)
}
}
}