返回 DeepSeek-Reasonix
tools.go
根目录 / internal / skill / tools.go
1 package skill
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "regexp"
9 "strconv"
10 "strings"
11
12 "gopkg.in/yaml.v3"
13
14 "reasonix/internal/event"
15 "reasonix/internal/tool"
16 )
17
18 // SubagentRunner runs a runAs=subagent skill: it spawns an isolated child loop
19 // with the skill body as system prompt and `task` as its only input, returning
20 // the final answer. boot wires this over the agent's sub-agent machinery; nil
21 // means subagent skills are unavailable in this session (they error rather than
22 // silently inlining, which would lose the isolation the author asked for).
23 type SubagentRunOptions struct {
24 ContinueFrom string
25 ForkFrom string
26 // HostInitiated marks an explicit controller entry point such as
27 // /<subagent-skill>. It may still carry a synthetic call context for nested
28 // UI events, but that ephemeral event ID must not be persisted as though it
29 // were a provider-visible parent tool call.
30 HostInitiated bool
31 }
32
33 type SubagentRunner func(ctx context.Context, sk Skill, task string, opts SubagentRunOptions) (string, error)
34
35 // SubagentOutputError is implemented by host runners that can preserve a
36 // bounded result envelope alongside a terminal error. Tool dispatchers should
37 // return that output to the parent model while retaining the error for host
38 // status and recovery classification.
39 type SubagentOutputError interface {
40 error
41 SubagentOutput() string
42 }
43
44 // ProfileResolver returns the model/effort profile a subagent skill will use.
45 // It is optional; without one, skill frontmatter still supplies display metadata.
46 type ProfileResolver func(sk Skill) *event.Profile
47
48 // InstalledHook fires after install_skill writes a new file, so a host can
49 // refresh UI (e.g. a skills sidebar) without a reload. nil is fine.
50 type InstalledHook func(name, path string, scope Scope)
51
52 // run_skill
53
54 type runSkillTool struct {
55 store *Store
56 runner SubagentRunner
57 profileResolver ProfileResolver
58 }
59
60 // NewRunSkillTool builds the general skill-invocation tool. runner may be nil
61 // (subagent skills then error).
62 func NewRunSkillTool(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) tool.Tool {
63 var pr ProfileResolver
64 if len(profileResolver) > 0 {
65 pr = profileResolver[0]
66 }
67 return &runSkillTool{store: store, runner: runner, profileResolver: pr}
68 }
69
70 func (*runSkillTool) Name() string { return tool.HostRunSkill }
71
72 // ReadOnly is false: an invoked subagent skill could call writer tools, so
73 // classify conservatively to keep the parallel-dispatch path from racing two
74 // skill runs' writes (mirrors the `task` tool).
75 func (*runSkillTool) ReadOnly() bool { return false }
76
77 func (*runSkillTool) Description() string {
78 return "Invoke a playbook from the Skills index pinned in the system prompt. For the built-in subagent skills (explore / research / review / security_review), prefer the dedicated top-level tools of the same name — they're easier to pick and do the same thing. Pass `name` as the BARE identifier (e.g. 'explore'), NOT the `[🧬 subagent]` tag that follows it in the index. `[🧬 subagent]` skills spawn an isolated subagent — only the final distilled answer returns; supply `arguments` describing the concrete task since the subagent has no other context. Untagged skills are inlined: the body becomes a tool result you read and follow."
79 }
80
81 func (*runSkillTool) Schema() json.RawMessage {
82 return json.RawMessage(`{
83 "type":"object",
84 "properties":{
85 "name":{"type":"string","description":"Skill identifier as it appears in the pinned Skills index (e.g. 'explore', 'review'). Case-sensitive. Just the identifier, not the [🧬 subagent] tag."},
86 "arguments":{"type":"string","description":"Free-form arguments. For inline skills: appended as an 'Arguments:' line; the skill's own instructions decide how to use them. For subagent skills: REQUIRED — becomes the entire task the subagent receives."},
87 "continue_from":{"type":"string","description":"Continue a prior compatible subagent transcript in the current conversation context. Only valid for runAs=subagent skills. Pass only the 'sa_...' value from the prior result's 'Subagent reference: ...' line."}
88 },
89 "required":["name"]
90 }`)
91 }
92
93 // ValidateArguments enforces the conditional subagent task contract before a
94 // runner is started. The provider-visible schema stays stable because whether a
95 // skill is inline or isolated is catalog data, not a new tool shape.
96 func (t *runSkillTool) ValidateArguments(args json.RawMessage) []tool.ArgumentViolation {
97 var p struct {
98 Name string `json:"name"`
99 Arguments string `json:"arguments"`
100 }
101 if json.Unmarshal(args, &p) != nil {
102 return nil // The ordinary JSON Schema/parser owns malformed JSON.
103 }
104 name := cleanSkillName(p.Name)
105 sk, ok := t.store.Candidate(name)
106 if !ok || sk.RunAs != RunSubagent || strings.TrimSpace(p.Arguments) != "" {
107 return nil
108 }
109 return []tool.ArgumentViolation{{
110 Path: "/arguments",
111 Keyword: "required",
112 Expected: "a non-empty string describing the concrete subagent task",
113 }}
114 }
115
116 func (t *runSkillTool) CapabilityArguments(capabilityID string) (tool.CapabilityArgumentContract, bool) {
117 name := strings.TrimSpace(strings.TrimPrefix(capabilityID, "skill:"))
118 sk, ok := t.store.Candidate(name)
119 if !ok {
120 return tool.CapabilityArgumentContract{}, false
121 }
122 required := ""
123 if sk.RunAs == RunSubagent {
124 required = `,"required":["arguments"]`
125 }
126 schema := json.RawMessage(`{"type":"object","properties":{"arguments":{"type":"string","description":"Concrete task or inline skill arguments."},"continue_from":{"type":"string","description":"Optional compatible subagent reference."}}` + required + `}`)
127 example, _ := json.Marshal(map[string]any{
128 "action": "call",
129 "capability_id": "skill:" + name,
130 "arguments": map[string]any{
131 "arguments": "specific task for " + name,
132 },
133 })
134 return tool.CapabilityArgumentContract{Schema: schema, Example: example}, true
135 }
136
137 func (t *runSkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
138 var p struct {
139 Name string `json:"name"`
140 Arguments string `json:"arguments"`
141 Continue string `json:"continue_from"`
142 Fork string `json:"fork_from"`
143 }
144 if err := json.Unmarshal(args, &p); err != nil {
145 return "", fmt.Errorf("invalid args: %w", err)
146 }
147 name := cleanSkillName(p.Name)
148 if name == "" {
149 return "", fmt.Errorf("run_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name)
150 }
151 sk, ok := t.store.Load(ctx, name)
152 if !ok {
153 if err := ctx.Err(); err != nil {
154 return "", err
155 }
156 return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store))
157 }
158 if err := t.store.ValidateInvocation(sk); err != nil {
159 return "", fmt.Errorf("run_skill: %w", err)
160 }
161 sk = t.store.Prepare(sk)
162 rawArgs := strings.TrimSpace(p.Arguments)
163 opts := SubagentRunOptions{ContinueFrom: strings.TrimSpace(p.Continue), ForkFrom: strings.TrimSpace(p.Fork)}
164 if opts.ContinueFrom != "" && opts.ForkFrom != "" {
165 return "", fmt.Errorf("run_skill: continue_from and fork_from are mutually exclusive; pass only continue_from")
166 }
167
168 if sk.RunAs == RunSubagent {
169 if t.runner == nil {
170 return "", fmt.Errorf("run_skill: skill %q is runAs=subagent but no subagent runner is configured in this session", name)
171 }
172 if rawArgs == "" {
173 return "", fmt.Errorf("run_skill: skill %q is a subagent and requires 'arguments' — the subagent has no other context, so describe the concrete task", name)
174 }
175 out, err := t.runner(ctx, sk, rawArgs, opts)
176 if err != nil {
177 var outputErr SubagentOutputError
178 if errors.As(err, &outputErr) && strings.TrimSpace(outputErr.SubagentOutput()) != "" {
179 return outputErr.SubagentOutput(), err
180 }
181 return "", err
182 }
183 return tool.GuardSubagentHostDecisionText(out), nil
184 }
185 if opts.ContinueFrom != "" || opts.ForkFrom != "" {
186 return "", fmt.Errorf("run_skill: subagent continuation is only valid for runAs=subagent skills")
187 }
188 return renderInline(sk, rawArgs), nil
189 }
190
191 func (t *runSkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
192 var p struct {
193 Name string `json:"name"`
194 }
195 if err := json.Unmarshal(args, &p); err != nil {
196 return nil
197 }
198 name := cleanSkillName(p.Name)
199 if name == "" {
200 return nil
201 }
202 sk, ok := t.store.Read(name)
203 if !ok || sk.RunAs != RunSubagent {
204 return nil
205 }
206 return t.profileForSkill(sk)
207 }
208
209 func (t *runSkillTool) profileForSkill(sk Skill) *event.Profile {
210 return profileForSkill(sk, t.profileResolver)
211 }
212
213 // read_only_skill
214
215 type readOnlySkillTool struct {
216 store *Store
217 runner SubagentRunner
218 profileResolver ProfileResolver
219 }
220
221 // NewReadOnlySkillTool builds an explicitly read-only skill entry point. Inline
222 // skills are rendered like read_skill; subagent skills run through a host-provided
223 // read-only subagent runner with no continuation/fork controls.
224 func NewReadOnlySkillTool(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) tool.Tool {
225 var pr ProfileResolver
226 if len(profileResolver) > 0 {
227 pr = profileResolver[0]
228 }
229 return &readOnlySkillTool{store: store, runner: runner, profileResolver: pr}
230 }
231
232 func (*readOnlySkillTool) Name() string { return tool.HostReadOnlySkill }
233
234 func (*readOnlySkillTool) ReadOnly() bool { return true }
235
236 // PlanModeSafe reports true because this explicit read-only capability is also
237 // semantically valid during the planning phase.
238 func (*readOnlySkillTool) PlanModeSafe() bool { return true }
239
240 func (*readOnlySkillTool) Description() string {
241 return "Invoke a skill in read-only mode. Inline skills are loaded into context like read_skill. `[🧬 subagent]` skills run in an isolated ephemeral read-only subagent with only read-only research tools and safe foreground bash; no writes, installers, memory mutation, continuation/fork, background jobs, or writer-capable delegation are available. Read-only nested delegation may be available until max_subagent_depth is reached. Pass `name` as the bare skill identifier and `arguments` as the concrete task."
242 }
243
244 func (*readOnlySkillTool) Schema() json.RawMessage {
245 return json.RawMessage(`{
246 "type":"object",
247 "properties":{
248 "name":{"type":"string","description":"Skill identifier as it appears in the pinned Skills index. Just the identifier, not the [🧬 subagent] tag."},
249 "arguments":{"type":"string","description":"Free-form arguments. For inline skills: appended as an 'Arguments:' line. For subagent skills: REQUIRED — becomes the read-only subagent's entire task."}
250 },
251 "required":["name"]
252 }`)
253 }
254
255 func (t *readOnlySkillTool) ValidateArguments(args json.RawMessage) []tool.ArgumentViolation {
256 var p struct {
257 Name string `json:"name"`
258 Arguments string `json:"arguments"`
259 }
260 if json.Unmarshal(args, &p) != nil {
261 return nil
262 }
263 name := cleanSkillName(p.Name)
264 sk, ok := t.store.Candidate(name)
265 if !ok || sk.RunAs != RunSubagent || strings.TrimSpace(p.Arguments) != "" {
266 return nil
267 }
268 return []tool.ArgumentViolation{{
269 Path: "/arguments",
270 Keyword: "required",
271 Expected: "a non-empty string describing the concrete read-only subagent task",
272 }}
273 }
274
275 func (t *readOnlySkillTool) CapabilityArguments(capabilityID string) (tool.CapabilityArgumentContract, bool) {
276 name := strings.TrimSpace(strings.TrimPrefix(capabilityID, "skill:"))
277 sk, ok := t.store.Candidate(name)
278 if !ok {
279 return tool.CapabilityArgumentContract{}, false
280 }
281 required := ""
282 if sk.RunAs == RunSubagent {
283 required = `,"required":["arguments"]`
284 }
285 schema := json.RawMessage(`{"type":"object","properties":{"arguments":{"type":"string","description":"Concrete read-only task or inline skill arguments."}}` + required + `}`)
286 example, _ := json.Marshal(map[string]any{
287 "action": "call",
288 "capability_id": "skill:" + name,
289 "arguments": map[string]any{
290 "arguments": "specific read-only task for " + name,
291 },
292 })
293 return tool.CapabilityArgumentContract{Schema: schema, Example: example}, true
294 }
295
296 func (t *readOnlySkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
297 var p struct {
298 Name string `json:"name"`
299 Arguments string `json:"arguments"`
300 }
301 if err := json.Unmarshal(args, &p); err != nil {
302 return "", fmt.Errorf("invalid args: %w", err)
303 }
304 name := cleanSkillName(p.Name)
305 if name == "" {
306 return "", fmt.Errorf("read_only_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name)
307 }
308 sk, ok := t.store.Load(ctx, name)
309 if !ok {
310 if err := ctx.Err(); err != nil {
311 return "", err
312 }
313 return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store))
314 }
315 if err := t.store.ValidateInvocation(sk); err != nil {
316 return "", fmt.Errorf("read_only_skill: %w", err)
317 }
318 sk = t.store.Prepare(sk)
319 rawArgs := strings.TrimSpace(p.Arguments)
320 if sk.RunAs == RunSubagent {
321 if t.runner == nil {
322 return "", fmt.Errorf("read_only_skill: skill %q is runAs=subagent but no read-only subagent runner is configured in this session", name)
323 }
324 if rawArgs == "" {
325 return "", fmt.Errorf("read_only_skill: skill %q is a subagent and requires 'arguments' — the subagent has no other context, so describe the concrete read-only task", name)
326 }
327 out, err := t.runner(ctx, sk, rawArgs, SubagentRunOptions{})
328 if err != nil {
329 var outputErr SubagentOutputError
330 if errors.As(err, &outputErr) && strings.TrimSpace(outputErr.SubagentOutput()) != "" {
331 return outputErr.SubagentOutput(), err
332 }
333 return "", err
334 }
335 return tool.GuardSubagentHostDecisionText(out), nil
336 }
337 return renderInline(sk, rawArgs), nil
338 }
339
340 func (t *readOnlySkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
341 var p struct {
342 Name string `json:"name"`
343 }
344 if err := json.Unmarshal(args, &p); err != nil {
345 return nil
346 }
347 name := cleanSkillName(p.Name)
348 if name == "" {
349 return nil
350 }
351 sk, ok := t.store.Read(name)
352 if !ok || sk.RunAs != RunSubagent {
353 return nil
354 }
355 return profileForSkill(sk, t.profileResolver)
356 }
357
358 func profileForSkill(sk Skill, resolver ProfileResolver) *event.Profile {
359 if resolver != nil {
360 if pr := resolver(sk); pr != nil {
361 return pr
362 }
363 }
364 model, effort := strings.TrimSpace(sk.Model), strings.TrimSpace(sk.Effort)
365 if model == "" && effort == "" {
366 return nil
367 }
368 return &event.Profile{Model: model, Effort: effort}
369 }
370
371 // dedicated subagent wrappers (explore / research / review / security_review)
372
373 type subagentSkillTool struct {
374 toolName string
375 skillName string
376 description string
377 taskDesc string
378 store *Store
379 runner SubagentRunner
380 profile ProfileResolver
381 }
382
383 func (t *subagentSkillTool) Name() string { return t.toolName }
384 func (*subagentSkillTool) ReadOnly() bool { return false }
385 func (t *subagentSkillTool) Description() string { return t.description }
386
387 func (t *subagentSkillTool) Schema() json.RawMessage {
388 return json.RawMessage(`{"type":"object","properties":{"task":{"type":"string","description":` +
389 strconv.Quote(t.taskDesc) + `},"continue_from":{"type":"string","description":"Continue a prior compatible subagent transcript in the current conversation context. Pass only the 'sa_...' value from the prior result's 'Subagent reference: ...' line."}},"required":["task"]}`)
390 }
391
392 func (t *subagentSkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
393 var p struct {
394 Task string `json:"task"`
395 Continue string `json:"continue_from"`
396 Fork string `json:"fork_from"`
397 }
398 if err := json.Unmarshal(args, &p); err != nil {
399 return "", fmt.Errorf("invalid args: %w", err)
400 }
401 task := strings.TrimSpace(p.Task)
402 if task == "" {
403 return "", fmt.Errorf("%s requires a non-empty 'task' argument — describe the concrete question", t.toolName)
404 }
405 sk, ok := t.store.Load(ctx, t.skillName)
406 if !ok {
407 if err := ctx.Err(); err != nil {
408 return "", err
409 }
410 return "", fmt.Errorf("%s: built-in skill %q is not registered", t.toolName, t.skillName)
411 }
412 if err := t.store.ValidateInvocation(sk); err != nil {
413 return "", fmt.Errorf("%s: %w", t.toolName, err)
414 }
415 sk = t.store.Prepare(sk)
416 // A user file overriding the built-in name with runAs:inline would lose
417 // isolation if dispatched here — bounce to run_skill where inline is defined.
418 if sk.RunAs != RunSubagent {
419 return "", fmt.Errorf("%s: skill %q is overridden as inline; invoke it via run_skill instead", t.toolName, t.skillName)
420 }
421 if t.runner == nil {
422 return "", fmt.Errorf("%s: no subagent runner is configured in this session", t.toolName)
423 }
424 opts := SubagentRunOptions{ContinueFrom: strings.TrimSpace(p.Continue), ForkFrom: strings.TrimSpace(p.Fork)}
425 if opts.ContinueFrom != "" && opts.ForkFrom != "" {
426 return "", fmt.Errorf("%s: continue_from and fork_from are mutually exclusive; pass only continue_from", t.toolName)
427 }
428 out, err := t.runner(ctx, sk, task, opts)
429 if err != nil {
430 return "", err
431 }
432 return tool.GuardSubagentHostDecisionText(out), nil
433 }
434
435 func (t *subagentSkillTool) ResolveProfile(json.RawMessage) *event.Profile {
436 sk, ok := t.store.Read(t.skillName)
437 if !ok || sk.RunAs != RunSubagent {
438 return nil
439 }
440 if t.profile != nil {
441 if pr := t.profile(sk); pr != nil {
442 return pr
443 }
444 }
445 model, effort := strings.TrimSpace(sk.Model), strings.TrimSpace(sk.Effort)
446 if model == "" && effort == "" {
447 return nil
448 }
449 return &event.Profile{Model: model, Effort: effort}
450 }
451
452 // BuiltinSubagentTools returns top-level wrapper tools for the built-in subagent
453 // skills, named after the verb so the model picks them naturally (affordance >
454 // prompt rules). Each is skipped when its underlying skill isn't present (e.g. a
455 // user disabled it), so the tool set never advertises a phantom skill.
456 func BuiltinSubagentTools(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) []tool.Tool {
457 var pr ProfileResolver
458 if len(profileResolver) > 0 {
459 pr = profileResolver[0]
460 }
461 specs := []struct {
462 toolName, skillName, description, taskDesc string
463 }{
464 {tool.HostExplore, "explore",
465 "Run a focused read-only codebase investigation in an isolated subagent. Use for broad survey questions across many files — 'find all places that X', 'how does Y work across the project', 'audit Z'. Returns one distilled answer with file:line citations. Its reads + reasoning never enter your context, unlike chained read_file.",
466 "Concrete investigation question. The subagent has none of your context — write a self-contained prompt naming the symbol / pattern / behavior to survey."},
467 {tool.HostResearch, "research",
468 "Combine web_fetch + code reading in an isolated subagent. Use when the answer needs both an external reference and local verification — 'is X supported by lib Y', 'compare our impl against the spec'. Returns one synthesis citing code (file:line) and web (URL).",
469 "Concrete research question. The subagent has none of your context — name the external thing to look up and the local code to compare against."},
470 {tool.HostReview, "review",
471 "Review the pending changes (current branch diff) in an isolated subagent — flags correctness / security / missing-tests / hidden behavior per file:line. Read-only; you decide what to act on. Use before suggesting a PR-shaped change or after finishing a multi-step edit.",
472 "What to focus the review on (e.g. 'focus on the auth changes' or 'general'). The subagent reads the diff itself."},
473 {tool.HostSecurityReview, "security-review",
474 "Security-focused review of the current branch diff in an isolated subagent — injection / authz / secrets / deserialization / path-traversal / crypto, severity-tagged. Read-only. Use when shipping changes that touch auth, input parsing, file IO, or external requests.",
475 "Optional scope hint (e.g. 'focus on token handling in internal/auth/') or 'full' for everything in the diff."},
476 }
477 var out []tool.Tool
478 for _, s := range specs {
479 // Skill profiles are diagnostic-only; do not hide builtin subagent
480 // entry points based on the session role setting.
481 if _, ok := store.Read(s.skillName); !ok {
482 continue
483 }
484 out = append(out, &subagentSkillTool{
485 toolName: s.toolName,
486 skillName: s.skillName,
487 description: s.description,
488 taskDesc: s.taskDesc,
489 store: store,
490 runner: runner,
491 profile: pr,
492 })
493 }
494 return out
495 }
496
497 // install_skill
498
499 type installSkillTool struct {
500 store *Store
501 onInstalled InstalledHook
502 }
503
504 // NewInstallSkillTool builds the skill-authoring tool. onInstalled may be nil.
505 func NewInstallSkillTool(store *Store, onInstalled InstalledHook) tool.Tool {
506 return &installSkillTool{store: store, onInstalled: onInstalled}
507 }
508
509 func (*installSkillTool) Name() string { return tool.HostInstallSkill }
510 func (*installSkillTool) ReadOnly() bool { return false }
511
512 func (t *installSkillTool) Description() string {
513 scope := "'global' (only option — no project workspace) writes to the Reasonix home skills directory."
514 if t.store.HasProjectScope() {
515 scope = "'project' (default) writes to <repo>/.reasonix/skills/ (this workspace only); 'global' writes to the Reasonix home skills directory (every project)."
516 }
517 return "Author and save a new skill — a reusable playbook future turns invoke via run_skill (or /<name>). Runnable immediately this turn; appears in the pinned Skills index on the next launch. " + scope
518 }
519
520 func (*installSkillTool) Schema() json.RawMessage {
521 return json.RawMessage(`{
522 "type":"object",
523 "properties":{
524 "name":{"type":"string","description":"Identifier — letters/digits/_/-/., 1-64 chars, starts alphanumeric. Becomes the skill folder name under the selected skills directory."},
525 "description":{"type":"string","description":"≤120-char one-liner shown in the pinned Skills index — future agents read it to decide whether to invoke."},
526 "body":{"type":"string","description":"Markdown playbook. For subagent skills, write the subagent's persona/rules — it gets no context besides 'arguments' at runtime."},
527 "scope":{"type":"string","enum":["project","global"],"description":"Where to write. Defaults to project when a workspace exists, else global."},
528 "runAs":{"type":"string","enum":["inline","subagent"],"description":"inline (default) folds the body into the parent turn; subagent spawns an isolated child loop returning only its final answer (use for context-heavy work)."},
529 "model":{"type":"string","description":"Optional model override for runAs=subagent (a configured provider/model name). Ignored otherwise."},
530 "effort":{"type":"string","description":"Optional effort for runAs=subagent (e.g. high, max). Ignored otherwise."},
531 "allowedTools":{"type":"array","items":{"type":"string"},"description":"Optional tool allowlist for runAs=subagent (e.g. ['read_file','grep'])."}
532 },
533 "required":["name","description","body"]
534 }`)
535 }
536
537 func (t *installSkillTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
538 var p struct {
539 Name string `json:"name"`
540 Description string `json:"description"`
541 Body string `json:"body"`
542 Scope string `json:"scope"`
543 RunAs string `json:"runAs"`
544 Model string `json:"model"`
545 Effort string `json:"effort"`
546 AllowedTools []string `json:"allowedTools"`
547 }
548 if err := json.Unmarshal(args, &p); err != nil {
549 return "", fmt.Errorf("invalid args: %w", err)
550 }
551 name := strings.TrimSpace(p.Name)
552 desc := strings.TrimSpace(collapseSpaces(p.Description))
553 if name == "" {
554 return "", fmt.Errorf("install_skill requires a non-empty 'name'")
555 }
556 if desc == "" {
557 return "", fmt.Errorf("install_skill requires a non-empty 'description' — it is what appears in the Skills index")
558 }
559 if strings.TrimSpace(p.Body) == "" {
560 return "", fmt.Errorf("install_skill requires a non-empty 'body' — the playbook the skill executes")
561 }
562
563 scope := ScopeGlobal
564 switch strings.TrimSpace(p.Scope) {
565 case "global":
566 scope = ScopeGlobal
567 case "project":
568 scope = ScopeProject
569 default:
570 if t.store.HasProjectScope() {
571 scope = ScopeProject
572 }
573 }
574 if scope == ScopeProject && !t.store.HasProjectScope() {
575 return "", fmt.Errorf("install_skill: scope='project' requires a workspace — use scope='global'")
576 }
577
578 runAs := RunInline
579 if strings.TrimSpace(p.RunAs) == "subagent" {
580 runAs = RunSubagent
581 }
582
583 content := RenderSkillFile(SkillFileOptions{
584 Name: name,
585 Description: desc,
586 Body: p.Body,
587 RunAs: runAs,
588 Model: strings.TrimSpace(p.Model),
589 Effort: strings.TrimSpace(p.Effort),
590 AllowedTools: p.AllowedTools,
591 })
592 path, err := t.store.CreateWithContent(name, scope, content)
593 if err != nil {
594 return "", err
595 }
596 if t.onInstalled != nil {
597 t.onInstalled(name, path, scope)
598 }
599 res, _ := json.Marshal(map[string]any{
600 "ok": true,
601 "name": name,
602 "scope": string(scope),
603 "path": path,
604 "runAs": string(runAs),
605 "note": "Callable immediately in this tool loop via run_skill({name}) or /" + name + ". It will appear in session-context on the next real user turn.",
606 })
607 return string(res), nil
608 }
609
610 // SkillFileOptions configures a rendered skill markdown file's frontmatter.
611 // Shared by the model-facing install_skill tool and host-side authoring
612 // surfaces (e.g. a desktop subagent-profile settings page) so both produce
613 // identical, correctly-escaped frontmatter instead of hand-built YAML.
614 type SkillFileOptions struct {
615 Name string
616 Description string
617 Body string
618 RunAs RunAs
619 Model string // subagent-only; ignored when RunAs != RunSubagent
620 Effort string // subagent-only; ignored when RunAs != RunSubagent
621 AllowedTools []string
622 // ReadOnly, when true, emits frontmatter read-only: true so the profile
623 // runs against the read-only registry. Omitted/false keeps the legacy
624 // writable default for older profiles.
625 ReadOnly bool
626 Color string // optional display tag; emitted regardless of RunAs
627 // Invocation, when "manual", keeps the written skill out of automatic
628 // session-context discovery — invocable by name only, never
629 // model-discovered. Anything else (including empty) is the default "auto".
630 Invocation string
631 }
632
633 // skillFileFrontmatter is the YAML shape RenderSkillFile emits. Field order is
634 // the emission order (yaml.v3 preserves struct order); values are marshaled by
635 // yaml.v3 so free-text fields with colons, '#', quotes, or newlines are
636 // escaped correctly instead of corrupting the block — an unparseable
637 // frontmatter would make the loader fall back to an EMPTY field map, silently
638 // resetting runAs to inline and invocation to auto (see frontmatter.Split).
639 type skillFileFrontmatter struct {
640 Name string `yaml:"name"`
641 Description string `yaml:"description"`
642 Color string `yaml:"color,omitempty"`
643 Invocation string `yaml:"invocation,omitempty"`
644 RunAs string `yaml:"runAs,omitempty"`
645 Model string `yaml:"model,omitempty"`
646 Effort string `yaml:"effort,omitempty"`
647 ReadOnly *bool `yaml:"read-only,omitempty"`
648 AllowedTools []string `yaml:"allowed-tools,omitempty,flow"`
649 }
650
651 // RenderSkillFile assembles a skill file's frontmatter + body. Subagent-only
652 // fields (model, effort, allowed-tools, read-only) are emitted only when
653 // RunAs=subagent; color and invocation are independent of RunAs.
654 func RenderSkillFile(opts SkillFileOptions) string {
655 fm := skillFileFrontmatter{
656 Name: opts.Name,
657 Description: opts.Description,
658 Color: strings.TrimSpace(opts.Color),
659 }
660 if strings.EqualFold(strings.TrimSpace(opts.Invocation), "manual") {
661 fm.Invocation = "manual"
662 }
663 if opts.RunAs == RunSubagent {
664 fm.RunAs = string(RunSubagent)
665 fm.Model = strings.TrimSpace(opts.Model)
666 fm.Effort = strings.TrimSpace(opts.Effort)
667 if opts.ReadOnly {
668 v := true
669 fm.ReadOnly = &v
670 }
671 for _, t := range opts.AllowedTools {
672 if t = strings.TrimSpace(t); t != "" {
673 fm.AllowedTools = append(fm.AllowedTools, t)
674 }
675 }
676 }
677 // Marshaling a flat struct of strings cannot fail.
678 raw, _ := yaml.Marshal(fm)
679 return "---\n" + string(raw) + "---\n\n" + strings.TrimRight(opts.Body, " \t\r\n") + "\n"
680 }
681
682 // shared helpers
683
684 // Render builds a skill's invocation text: a header (name, description, source)
685 // followed by the body and any arguments. Used directly when a user invokes a
686 // skill via "/<name>" (sent as a turn); the run_skill tool wraps the same text
687 // in a skill-pin sentinel (see renderInline).
688 func Render(sk Skill, args string) string {
689 var b strings.Builder
690 b.WriteString("# Skill: " + sk.Name)
691 if sk.Description != "" {
692 b.WriteString("\n> " + sk.Description)
693 }
694 b.WriteString("\n(scope: " + string(sk.Scope) + " · " + sk.Path + ")\n\n")
695 b.WriteString(sk.Body)
696 if args != "" {
697 b.WriteString("\n\nArguments: " + args)
698 }
699 return b.String()
700 }
701
702 // renderInline wraps Render's output in a skill-pin sentinel so context
703 // compaction preserves the body verbatim instead of paraphrasing it.
704 func renderInline(sk Skill, args string) string {
705 return "<skill-pin name=" + strconv.Quote(sk.Name) + ">\n" + Render(sk, args) + "\n</skill-pin>"
706 }
707
708 var bracketTagRe = regexp.MustCompile(`\[[^\]]*\]`)
709
710 // cleanSkillName extracts the bare identifier from a possibly-decorated name:
711 // models sometimes copy the index's "explore [🧬 subagent]" verbatim into the
712 // `name` arg. Drop any [..] tag, then take the first token starting alphanumeric.
713 func cleanSkillName(raw string) string {
714 raw = strings.TrimSpace(raw)
715 if raw == "" {
716 return ""
717 }
718 stripped := strings.TrimSpace(bracketTagRe.ReplaceAllString(raw, " "))
719 for tok := range strings.FieldsSeq(stripped) {
720 if c := tok[0]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
721 return tok
722 }
723 }
724 return ""
725 }
726
727 // collapseSpaces turns any run of whitespace (incl. newlines) into a single
728 // space, so a multi-line description stays a one-liner in the index.
729 func collapseSpaces(s string) string {
730 return strings.Join(strings.Fields(s), " ")
731 }
732
733 // availableNames lists the discoverable skill names for an error message.
734 func availableNames(store *Store) string {
735 skills := store.List()
736 if len(skills) == 0 {
737 return "(none — no skills defined)"
738 }
739 names := make([]string, len(skills))
740 for i, s := range skills {
741 names[i] = s.Name
742 }
743 return strings.Join(names, ", ")
744 }
745
745 lines GO