@@ -30,6 +30,9 @@ const ( |
30 | 30 | type SkillMeta struct { |
31 | 31 | Name string |
32 | 32 | Description string |
| 33 | + // PluginName is the owning Agent Plugin's name, empty for a plain |
| 34 | + // workspace skill. Together with Name it identifies the skill. |
| 35 | + PluginName string |
33 | 36 | // Dir is the absolute path to the skill directory inside |
34 | 37 | // the workspace filesystem. |
35 | 38 | Dir string |
@@ -44,6 +47,28 @@ type SkillMeta struct { |
44 | 47 | Meta []byte |
45 | 48 | } |
46 | 49 |
|
| 50 | +// SplitPinnedSkills splits pinned skill metadata into SourceWorkspace skills |
| 51 | +// and SourcePlugin skills, in the argument shape skillspkg.MergeSkills takes. |
| 52 | +func SplitPinnedSkills(pinned []SkillMeta) (workspace, plugin []skillspkg.Skill) { |
| 53 | + for _, skill := range pinned { |
| 54 | + if skill.PluginName != "" { |
| 55 | + plugin = append(plugin, skillspkg.Skill{ |
| 56 | + Name: skill.Name, |
| 57 | + Description: skill.Description, |
| 58 | + Source: skillspkg.SourcePlugin, |
| 59 | + PluginName: skill.PluginName, |
| 60 | + }) |
| 61 | + continue |
| 62 | + } |
| 63 | + workspace = append(workspace, skillspkg.Skill{ |
| 64 | + Name: skill.Name, |
| 65 | + Description: skill.Description, |
| 66 | + Source: skillspkg.SourceWorkspace, |
| 67 | + }) |
| 68 | + } |
| 69 | + return workspace, plugin |
| 70 | +} |
| 71 | + |
47 | 72 | // SkillContent is the full body of a skill, loaded on demand |
48 | 73 | // when the model calls read_skill. |
49 | 74 | type SkillContent struct { |
@@ -65,32 +90,44 @@ func FormatResolvedSkillIndex(resolved []skillspkg.ResolvedSkill) string { |
65 | 90 |
|
66 | 91 | entries := make([]skillIndexEntry, 0, len(resolved)) |
67 | 92 | hasQualifiedAlias := false |
68 | | - hasWorkspaceSkill := false |
| 93 | + hasQualifiedPluginAlias := false |
| 94 | + hasFileBackedSkill := false |
69 | 95 | for _, s := range resolved { |
70 | | - entries = append(entries, skillIndexEntry{ |
| 96 | + entry := skillIndexEntry{ |
71 | 97 | Alias: s.Alias, |
72 | 98 | Description: s.Description, |
73 | | - }) |
74 | | - if s.Source == skillspkg.SourceWorkspace { |
75 | | - hasWorkspaceSkill = true |
| 99 | + } |
| 100 | + if s.Source == skillspkg.SourcePlugin { |
| 101 | + entry.PluginName = s.PluginName |
| 102 | + } |
| 103 | + entries = append(entries, entry) |
| 104 | + if s.Source == skillspkg.SourceWorkspace || s.Source == skillspkg.SourcePlugin { |
| 105 | + hasFileBackedSkill = true |
76 | 106 | } |
77 | 107 | if s.Alias == s.QualifiedAlias() { |
78 | 108 | hasQualifiedAlias = true |
| 109 | + if s.Source == skillspkg.SourcePlugin { |
| 110 | + hasQualifiedPluginAlias = true |
| 111 | + } |
79 | 112 | } |
80 | 113 | } |
81 | 114 | return renderSkillIndex(entries, skillIndexFormatOptions{ |
82 | 115 | includeQualifiedAliasInstruction: hasQualifiedAlias, |
83 | | - includeReadSkillFileInstruction: hasWorkspaceSkill, |
| 116 | + qualifiedAliasHintListsPlugin: hasQualifiedPluginAlias, |
| 117 | + includeReadSkillFileInstruction: hasFileBackedSkill, |
84 | 118 | }) |
85 | 119 | } |
86 | 120 |
|
87 | 121 | type skillIndexEntry struct { |
88 | 122 | Alias string |
89 | 123 | Description string |
| 124 | + // PluginName is rendered as a "(plugin: <name>)" label after the alias. |
| 125 | + PluginName string |
90 | 126 | } |
91 | 127 |
|
92 | 128 | type skillIndexFormatOptions struct { |
93 | 129 | includeQualifiedAliasInstruction bool |
| 130 | + qualifiedAliasHintListsPlugin bool |
94 | 131 | includeReadSkillFileInstruction bool |
95 | 132 | } |
96 | 133 |
|
@@ -108,19 +145,28 @@ func renderSkillIndex(entries []skillIndexEntry, opts skillIndexFormatOptions) s |
108 | 145 | if opts.includeReadSkillFileInstruction { |
109 | 146 | _, _ = b.WriteString( |
110 | 147 | "Use read_skill_file to read supporting files " + |
111 | | - "referenced by a workspace skill.\n", |
| 148 | + "referenced by a workspace or plugin skill.\n", |
112 | 149 | ) |
113 | 150 | } |
114 | 151 | if opts.includeQualifiedAliasInstruction { |
| 152 | + aliasForms := "personal/name or workspace/name" |
| 153 | + if opts.qualifiedAliasHintListsPlugin { |
| 154 | + aliasForms = "personal/name, workspace/name, or plugin/pluginname/name" |
| 155 | + } |
115 | 156 | _, _ = b.WriteString( |
116 | | - "When a skill is listed as personal/name or workspace/name, " + |
| 157 | + "When a skill is listed as " + aliasForms + ", " + |
117 | 158 | "pass that qualified alias to read_skill.\n", |
118 | 159 | ) |
119 | 160 | } |
120 | 161 | _, _ = b.WriteString("\n") |
121 | 162 | for _, s := range entries { |
122 | 163 | _, _ = b.WriteString("- ") |
123 | 164 | _, _ = b.WriteString(s.Alias) |
| 165 | + if s.PluginName != "" { |
| 166 | + _, _ = b.WriteString(" (plugin: ") |
| 167 | + _, _ = b.WriteString(s.PluginName) |
| 168 | + _, _ = b.WriteString(")") |
| 169 | + } |
124 | 170 | if s.Description != "" { |
125 | 171 | _, _ = b.WriteString(": ") |
126 | 172 | _, _ = b.WriteString(s.Description) |
@@ -288,9 +334,11 @@ const DefaultSkillMetaFile = "SKILL.md" |
288 | 334 | // ReadSkillOptions configures the read_skill and read_skill_file |
289 | 335 | // tools. |
290 | 336 | type ReadSkillOptions struct { |
291 | | - GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) |
292 | | - GetSkills func() []SkillMeta |
293 | | - ResolveAlias func(string) (skillspkg.ResolvedSkill, error) |
| 337 | + GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) |
| 338 | + GetSkills func() []SkillMeta |
| 339 | + // PersonalSkills are merged with GetSkills to resolve names and |
| 340 | + // qualified aliases. |
| 341 | + PersonalSkills []skillspkg.Skill |
294 | 342 | LoadPersonalSkillBody func(context.Context, string) (skillspkg.ParsedSkill, error) |
295 | 343 | } |
296 | 344 |
|
@@ -342,8 +390,8 @@ func ReadSkill(options ReadSkillOptions) fantasy.AgentTool { |
342 | 390 | "body": content.Body, |
343 | 391 | "files": []string{}, |
344 | 392 | }), nil |
345 | | - case skillspkg.SourceWorkspace: |
346 | | - content, response, ok := readWorkspaceSkillBody(ctx, options, args.Name, resolved.Name) |
| 393 | + case skillspkg.SourceWorkspace, skillspkg.SourcePlugin: |
| 394 | + content, response, ok := readPinnedSkillBody(ctx, options, args.Name, resolved.Skill) |
347 | 395 | if ok { |
348 | 396 | return response, nil |
349 | 397 | } |
@@ -397,11 +445,11 @@ func ReadSkillFile(options ReadSkillOptions) fantasy.AgentTool { |
397 | 445 | "read_skill_file is not supported for personal skills (no supporting files)", |
398 | 446 | ), nil |
399 | 447 | } |
400 | | - if resolved.Source != skillspkg.SourceWorkspace { |
| 448 | + if resolved.Source != skillspkg.SourceWorkspace && resolved.Source != skillspkg.SourcePlugin { |
401 | 449 | return skillNotFoundResponse(args.Name), nil |
402 | 450 | } |
403 | 451 |
|
404 | | - skill, ok := findSkill(options.GetSkills, resolved.Name) |
| 452 | + skill, ok := findSkill(options.GetSkills, resolved.Skill) |
405 | 453 | if !ok { |
406 | 454 | return skillNotFoundResponse(args.Name), nil |
407 | 455 | } |
@@ -443,31 +491,21 @@ func ReadSkillFile(options ReadSkillOptions) fantasy.AgentTool { |
443 | 491 | } |
444 | 492 |
|
445 | 493 | func resolveSkillAlias(options ReadSkillOptions, name string) (skillspkg.ResolvedSkill, error) { |
446 | | - if options.ResolveAlias != nil { |
447 | | - return options.ResolveAlias(name) |
448 | | - } |
449 | | - |
450 | | - skill, ok := findSkill(options.GetSkills, name) |
451 | | - if !ok { |
452 | | - return skillspkg.ResolvedSkill{}, skillspkg.ErrSkillNotFound |
| 494 | + var pinned []SkillMeta |
| 495 | + if options.GetSkills != nil { |
| 496 | + pinned = options.GetSkills() |
453 | 497 | } |
454 | | - return skillspkg.ResolvedSkill{ |
455 | | - Skill: skillspkg.Skill{ |
456 | | - Name: skill.Name, |
457 | | - Description: skill.Description, |
458 | | - Source: skillspkg.SourceWorkspace, |
459 | | - }, |
460 | | - Alias: skill.Name, |
461 | | - }, nil |
| 498 | + workspace, plugin := SplitPinnedSkills(pinned) |
| 499 | + return skillspkg.Lookup(skillspkg.MergeSkills(options.PersonalSkills, workspace, plugin), name) |
462 | 500 | } |
463 | 501 |
|
464 | | -func readWorkspaceSkillBody( |
| 502 | +func readPinnedSkillBody( |
465 | 503 | ctx context.Context, |
466 | 504 | options ReadSkillOptions, |
467 | 505 | requestedName string, |
468 | | - canonicalName string, |
| 506 | + resolved skillspkg.Skill, |
469 | 507 | ) (SkillContent, fantasy.ToolResponse, bool) { |
470 | | - skill, ok := findSkill(options.GetSkills, canonicalName) |
| 508 | + skill, ok := findSkill(options.GetSkills, resolved) |
471 | 509 | if !ok { |
472 | 510 | return SkillContent{}, skillNotFoundResponse(requestedName), true |
473 | 511 | } |
@@ -508,16 +546,22 @@ func nonNilFiles(files []string) []string { |
508 | 546 | return files |
509 | 547 | } |
510 | 548 |
|
511 | | -// findSkill looks up a skill by name in the current skill list. |
| 549 | +// findSkill returns the pinned skill matching a resolved identity. A |
| 550 | +// SourcePlugin identity matches on both plugin name and skill name; any |
| 551 | +// other source matches a skill with no plugin name by skill name alone. |
512 | 552 | func findSkill( |
513 | 553 | getSkills func() []SkillMeta, |
514 | | - name string, |
| 554 | + resolved skillspkg.Skill, |
515 | 555 | ) (SkillMeta, bool) { |
516 | 556 | if getSkills == nil { |
517 | 557 | return SkillMeta{}, false |
518 | 558 | } |
| 559 | + wantPlugin := "" |
| 560 | + if resolved.Source == skillspkg.SourcePlugin { |
| 561 | + wantPlugin = resolved.PluginName |
| 562 | + } |
519 | 563 | for _, s := range getSkills() { |
520 | | - if s.Name == name { |
| 564 | + if s.Name == resolved.Name && s.PluginName == wantPlugin { |
521 | 565 | return s, true |
522 | 566 | } |
523 | 567 | } |
|