From 7424ada7ed98f2b2269abcc18e0896643835d341 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Mon, 16 Mar 2026 20:15:50 +0100 Subject: [PATCH] feat: support running skills as isolated sub-agents via context: fork Add 'context: fork' SKILL.md frontmatter support, matching Claude Code's syntax. When set, the agent spawns an isolated child session for the skill instead of reading its instructions inline. Changes: - Extract SubSessionConfig and shared sub-session primitives from the duplicated handleTaskTransfer/RunAgent logic (newSubSession, runSubSessionForwarding, runSubSessionCollecting) - Add Context field and IsFork() to skills.Skill - Add run_skill tool to SkillsToolset, exposed only when fork skills exist - Add handleRunSkill runtime handler using the shared sub-session infra - Extend SubSessionConfig with SystemMessage and ImplicitUserMessage overrides for custom sub-session prompts - Update Instructions() to tag fork skills with sub-agent - Document the feature in docs/features/skills/index.md Assisted-By: docker-agent --- docs/features/skills/index.md | 47 ++++++ pkg/runtime/agent_delegation.go | 214 ++++++++++++++++++--------- pkg/runtime/agent_delegation_test.go | 198 +++++++++++++++++++++++++ pkg/runtime/loop.go | 1 + pkg/runtime/skill_runner.go | 83 +++++++++++ pkg/skills/skills.go | 8 + pkg/skills/skills_test.go | 42 ++++++ pkg/tools/builtin/skills.go | 46 ++++++ pkg/tools/builtin/skills_test.go | 99 +++++++++++++ 9 files changed, 668 insertions(+), 70 deletions(-) create mode 100644 pkg/runtime/agent_delegation_test.go create mode 100644 pkg/runtime/skill_runner.go diff --git a/docs/features/skills/index.md b/docs/features/skills/index.md index 7cfcdfe3c..91c962f4d 100644 --- a/docs/features/skills/index.md +++ b/docs/features/skills/index.md @@ -57,6 +57,53 @@ When asked to create a Dockerfile: 4. Follow security best practices (non-root user, etc.) ``` +### Frontmatter Fields + +| Field | Required | Description | +| ---------------- | -------- | --------------------------------------------------------------------------- | +| `name` | Yes | Unique skill identifier | +| `description` | Yes | Short description shown to the agent for skill matching | +| `context` | No | Set to `fork` to run the skill as an isolated sub-agent (see below) | +| `allowed-tools` | No | List of tools the skill needs (YAML list or comma-separated string) | +| `license` | No | License identifier (e.g. `Apache-2.0`) | +| `compatibility` | No | Free-text compatibility notes | +| `metadata` | No | Arbitrary key-value pairs (e.g. `author`, `version`) | + +## Running a Skill as a Sub-Agent + +By default, when an agent invokes a skill it reads the instructions inline into its own conversation. For complex, multi-step skills this can consume a large portion of the agent's context window and pollute the parent conversation with intermediate tool calls. + +Adding `context: fork` to the SKILL.md frontmatter tells the agent to run the skill in an **isolated sub-agent** instead: + + +```yaml +--- +name: bump-go-dependencies +description: Update Go module dependencies one by one +context: fork +--- + +# Bump Dependencies + +1. List outdated deps +2. Update each one, run tests, commit or revert +3. Produce a summary table +``` + +When the agent encounters a task that matches a `context: fork` skill, it uses the `run_skill` tool instead of `read_skill`. This: + +- **Spawns a child session** with the skill content as the system prompt and the caller's task as the user message +- **Isolates the context window** — the sub-agent has its own conversation history, so lengthy tool-call chains don't eat into the parent's token budget +- **Folds the result** — only the sub-agent's final answer is returned to the parent as the tool result +- **Inherits the parent's model and tools** — the sub-agent can use all tools available to the parent agent + +
+
💡 When to use context: fork +
+

Use context: fork for skills that involve many steps, heavy tool usage, or that should not clutter the main conversation — for example dependency bumping, large refactors, or code generation pipelines.

+ +
+ ## Search Paths Skills are discovered from these locations (later overrides earlier): diff --git a/pkg/runtime/agent_delegation.go b/pkg/runtime/agent_delegation.go index 36bde3d2f..ad2b75422 100644 --- a/pkg/runtime/agent_delegation.go +++ b/pkg/runtime/agent_delegation.go @@ -58,55 +58,110 @@ func buildTaskSystemMessage(task, expectedOutput string) string { return msg } -// CurrentAgentSubAgentNames implements agenttool.Runner. -func (r *LocalRuntime) CurrentAgentSubAgentNames() []string { - a := r.CurrentAgent() - if a == nil { - return nil - } - return agentNames(a.SubAgents()) +// SubSessionConfig describes how to build and run a child session. +// Both handleTaskTransfer and RunAgent (background agents) use this +// to avoid duplicating session-construction logic. Future callers +// (e.g. skill-as-sub-agent) can use it as well. +type SubSessionConfig struct { + // Task is the user-facing task description. + Task string + // ExpectedOutput is an optional description of what the sub-agent should produce. + ExpectedOutput string + // SystemMessage, when non-empty, replaces the default task-based system + // message. This is used by skill sub-agents whose system prompt is the + // skill content itself rather than the team delegation boilerplate. + SystemMessage string + // AgentName is the name of the agent that will execute the sub-session. + AgentName string + // Title is a human-readable label for the sub-session (e.g. "Transferred task"). + Title string + // ToolsApproved overrides whether tools are pre-approved in the child session. + ToolsApproved bool + // Thinking propagates the parent's thinking-mode flag. + Thinking bool + // PinAgent, when true, pins the child session to AgentName via + // session.WithAgentName. This is required for concurrent background + // tasks that must not share the runtime's mutable currentAgent field. + PinAgent bool + // ImplicitUserMessage, when non-empty, overrides the default "Please proceed." + // user message sent to the child session. This allows callers like skill + // sub-agents to pass the task description as the user message. + ImplicitUserMessage string } -// RunAgent implements agenttool.Runner. It starts a sub-agent synchronously and -// blocks until completion or cancellation. -func (r *LocalRuntime) RunAgent(ctx context.Context, params agenttool.RunParams) *agenttool.RunResult { - child, err := r.team.Agent(params.AgentName) - if err != nil { - return &agenttool.RunResult{ErrMsg: fmt.Sprintf("agent %q not found: %s", params.AgentName, err)} +// newSubSession builds a *session.Session from a SubSessionConfig and a parent +// session. It consolidates the session options that were previously duplicated +// across handleTaskTransfer and RunAgent. +func newSubSession(parent *session.Session, cfg SubSessionConfig, childAgent *agent.Agent) *session.Session { + sysMsg := cfg.SystemMessage + if sysMsg == "" { + sysMsg = buildTaskSystemMessage(cfg.Task, cfg.ExpectedOutput) } - sess := params.ParentSession + userMsg := cfg.ImplicitUserMessage + if userMsg == "" { + userMsg = "Please proceed." + } - // Background tasks run with tools pre-approved because there is no user present - // to respond to interactive approval prompts during async execution. This is a - // deliberate design trade-off: the user implicitly authorises all tool calls made - // by the sub-agent when they approve run_background_agent. Callers should be aware - // that prompt injection in the sub-agent's context could exploit this gate-bypass. - // - // TODO: propagate the parent session's per-tool permission rules once the runtime - // supports per-session permission scoping rather than a single shared ToolsApproved flag. - s := session.New( - session.WithSystemMessage(buildTaskSystemMessage(params.Task, params.ExpectedOutput)), - session.WithImplicitUserMessage("Please proceed."), - session.WithMaxIterations(child.MaxIterations()), - session.WithMaxConsecutiveToolCalls(child.MaxConsecutiveToolCalls()), - session.WithTitle("Background agent task"), - session.WithToolsApproved(true), - session.WithThinking(sess.Thinking), + opts := []session.Opt{ + session.WithSystemMessage(sysMsg), + session.WithImplicitUserMessage(userMsg), + session.WithMaxIterations(childAgent.MaxIterations()), + session.WithMaxConsecutiveToolCalls(childAgent.MaxConsecutiveToolCalls()), + session.WithTitle(cfg.Title), + session.WithToolsApproved(cfg.ToolsApproved), + session.WithThinking(cfg.Thinking), session.WithSendUserMessage(false), - session.WithParentID(sess.ID), - session.WithAgentName(params.AgentName), - ) + session.WithParentID(parent.ID), + } + if cfg.PinAgent { + opts = append(opts, session.WithAgentName(cfg.AgentName)) + } + return session.New(opts...) +} + +// runSubSessionForwarding runs a child session within the parent, forwarding all +// events to the caller's event channel and propagating session state (tool +// approvals, thinking) back to the parent when done. +// +// This is the "interactive" path used by transfer_task where the parent agent +// loop is blocked while the child executes. +func (r *LocalRuntime) runSubSessionForwarding(ctx context.Context, parent, child *session.Session, span trace.Span, evts chan Event, callerAgent string) (*tools.ToolCallResult, error) { + for event := range r.RunStream(ctx, child) { + evts <- event + if errEvent, ok := event.(*ErrorEvent); ok { + span.RecordError(fmt.Errorf("%s", errEvent.Error)) + span.SetStatus(codes.Error, "sub-session error") + return nil, fmt.Errorf("%s", errEvent.Error) + } + } + parent.ToolsApproved = child.ToolsApproved + parent.Thinking = child.Thinking + + parent.AddSubSession(child) + evts <- SubSessionCompleted(parent.ID, child, callerAgent) + + span.SetStatus(codes.Ok, "sub-session completed") + return tools.ResultSuccess(child.GetLastAssistantMessageContent()), nil +} + +// runSubSessionCollecting runs a child session, collecting output via an +// optional content callback instead of forwarding events. This is the path +// used by background agents and other non-interactive callers. +// +// It returns a RunResult containing either the final assistant message or +// an error message. +func (r *LocalRuntime) runSubSessionCollecting(ctx context.Context, parent, child *session.Session, onContent func(string)) *agenttool.RunResult { var errMsg string - events := r.RunStream(ctx, s) + events := r.RunStream(ctx, child) for event := range events { if ctx.Err() != nil { break } if choice, ok := event.(*AgentChoiceEvent); ok && choice.Content != "" { - if params.OnContent != nil { - params.OnContent(choice.Content) + if onContent != nil { + onContent(choice.Content) } } if errEvt, ok := event.(*ErrorEvent); ok { @@ -123,11 +178,53 @@ func (r *LocalRuntime) RunAgent(ctx context.Context, params agenttool.RunParams) return &agenttool.RunResult{ErrMsg: errMsg} } - result := s.GetLastAssistantMessageContent() - sess.AddSubSession(s) + result := child.GetLastAssistantMessageContent() + parent.AddSubSession(child) return &agenttool.RunResult{Result: result} } +// CurrentAgentSubAgentNames implements agenttool.Runner. +func (r *LocalRuntime) CurrentAgentSubAgentNames() []string { + a := r.CurrentAgent() + if a == nil { + return nil + } + return agentNames(a.SubAgents()) +} + +// RunAgent implements agenttool.Runner. It starts a sub-agent synchronously and +// blocks until completion or cancellation. +func (r *LocalRuntime) RunAgent(ctx context.Context, params agenttool.RunParams) *agenttool.RunResult { + child, err := r.team.Agent(params.AgentName) + if err != nil { + return &agenttool.RunResult{ErrMsg: fmt.Sprintf("agent %q not found: %s", params.AgentName, err)} + } + + sess := params.ParentSession + + // Background tasks run with tools pre-approved because there is no user present + // to respond to interactive approval prompts during async execution. This is a + // deliberate design trade-off: the user implicitly authorises all tool calls made + // by the sub-agent when they approve run_background_agent. Callers should be aware + // that prompt injection in the sub-agent's context could exploit this gate-bypass. + // + // TODO: propagate the parent session's per-tool permission rules once the runtime + // supports per-session permission scoping rather than a single shared ToolsApproved flag. + cfg := SubSessionConfig{ + Task: params.Task, + ExpectedOutput: params.ExpectedOutput, + AgentName: params.AgentName, + Title: "Background agent task", + ToolsApproved: true, + Thinking: sess.Thinking, + PinAgent: true, + } + + s := newSubSession(sess, cfg, child) + + return r.runSubSessionCollecting(ctx, sess, s, params.OnContent) +} + func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, evts chan Event) (*tools.ToolCallResult, error) { var params struct { Agent string `json:"agent"` @@ -185,41 +282,18 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses return nil, err } - s := session.New( - session.WithSystemMessage(buildTaskSystemMessage(params.Task, params.ExpectedOutput)), - session.WithImplicitUserMessage("Please proceed."), - session.WithMaxIterations(child.MaxIterations()), - session.WithMaxConsecutiveToolCalls(child.MaxConsecutiveToolCalls()), - session.WithTitle("Transferred task"), - session.WithToolsApproved(sess.ToolsApproved), - session.WithThinking(sess.Thinking), - session.WithSendUserMessage(false), - session.WithParentID(sess.ID), - ) - - return r.runSubSession(ctx, sess, s, span, evts, a.Name()) -} - -// runSubSession runs a child session within the parent, forwarding events and -// propagating state (tool approvals, thinking) back to the parent when done. -func (r *LocalRuntime) runSubSession(ctx context.Context, parent, child *session.Session, span trace.Span, evts chan Event, agentName string) (*tools.ToolCallResult, error) { - for event := range r.RunStream(ctx, child) { - evts <- event - if errEvent, ok := event.(*ErrorEvent); ok { - span.RecordError(fmt.Errorf("%s", errEvent.Error)) - span.SetStatus(codes.Error, "sub-session error") - return nil, fmt.Errorf("%s", errEvent.Error) - } + cfg := SubSessionConfig{ + Task: params.Task, + ExpectedOutput: params.ExpectedOutput, + AgentName: params.Agent, + Title: "Transferred task", + ToolsApproved: sess.ToolsApproved, + Thinking: sess.Thinking, } - parent.ToolsApproved = child.ToolsApproved - parent.Thinking = child.Thinking - - parent.AddSubSession(child) - evts <- SubSessionCompleted(parent.ID, child, agentName) + s := newSubSession(sess, cfg, child) - span.SetStatus(codes.Ok, "sub-session completed") - return tools.ResultSuccess(child.GetLastAssistantMessageContent()), nil + return r.runSubSessionForwarding(ctx, sess, s, span, evts, a.Name()) } func (r *LocalRuntime) handleHandoff(_ context.Context, _ *session.Session, toolCall tools.ToolCall, _ chan Event) (*tools.ToolCallResult, error) { diff --git a/pkg/runtime/agent_delegation_test.go b/pkg/runtime/agent_delegation_test.go new file mode 100644 index 000000000..c7f2f1c75 --- /dev/null +++ b/pkg/runtime/agent_delegation_test.go @@ -0,0 +1,198 @@ +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/agent" + "github.com/docker/docker-agent/pkg/session" +) + +func TestBuildTaskSystemMessage(t *testing.T) { + t.Run("with expected output", func(t *testing.T) { + msg := buildTaskSystemMessage("do the thing", "a result") + assert.Contains(t, msg, "\ndo the thing\n") + assert.Contains(t, msg, "\na result\n") + }) + + t.Run("without expected output", func(t *testing.T) { + msg := buildTaskSystemMessage("do the thing", "") + assert.Contains(t, msg, "\ndo the thing\n") + assert.NotContains(t, msg, "expected_output") + }) +} + +func TestAgentNames(t *testing.T) { + agents := []*agent.Agent{ + agent.New("alpha", ""), + agent.New("beta", ""), + } + assert.Equal(t, []string{"alpha", "beta"}, agentNames(agents)) + assert.Empty(t, agentNames(nil)) +} + +func TestValidateAgentInList(t *testing.T) { + agents := []*agent.Agent{ + agent.New("sub1", ""), + agent.New("sub2", ""), + } + + t.Run("valid agent returns nil", func(t *testing.T) { + result := validateAgentInList("root", "sub1", "transfer to", "sub-agents", agents) + assert.Nil(t, result) + }) + + t.Run("invalid agent with non-empty list", func(t *testing.T) { + result := validateAgentInList("root", "missing", "transfer to", "sub-agents", agents) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "sub1") + assert.Contains(t, result.Output, "sub2") + }) + + t.Run("invalid agent with empty list", func(t *testing.T) { + result := validateAgentInList("root", "missing", "transfer to", "sub-agents", nil) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "No agents are configured") + }) +} + +func TestNewSubSession(t *testing.T) { + parent := session.New(session.WithUserMessage("hello")) + childAgent := agent.New("worker", "a worker agent", + agent.WithMaxIterations(10), + ) + + t.Run("basic config", func(t *testing.T) { + cfg := SubSessionConfig{ + Task: "write tests", + ExpectedOutput: "passing tests", + AgentName: "worker", + Title: "Test task", + ToolsApproved: true, + Thinking: true, + } + + s := newSubSession(parent, cfg, childAgent) + + assert.Equal(t, parent.ID, s.ParentID) + assert.Equal(t, "Test task", s.Title) + assert.True(t, s.ToolsApproved) + assert.True(t, s.Thinking) + assert.False(t, s.SendUserMessage) + assert.Equal(t, 10, s.MaxIterations) + // AgentName should NOT be set when PinAgent is false + assert.Empty(t, s.AgentName) + }) + + t.Run("pin agent", func(t *testing.T) { + cfg := SubSessionConfig{ + Task: "background work", + AgentName: "worker", + Title: "Background task", + PinAgent: true, + } + + s := newSubSession(parent, cfg, childAgent) + + assert.Equal(t, "worker", s.AgentName) + }) + + t.Run("custom implicit user message", func(t *testing.T) { + cfg := SubSessionConfig{ + Task: "bump deps", + AgentName: "worker", + Title: "Skill task", + ImplicitUserMessage: "Update all Go dependencies", + } + + s := newSubSession(parent, cfg, childAgent) + + // The implicit user message should be the custom one, not "Please proceed." + assert.Equal(t, "Update all Go dependencies", s.GetLastUserMessageContent()) + }) + + t.Run("default implicit user message", func(t *testing.T) { + cfg := SubSessionConfig{ + Task: "do work", + AgentName: "worker", + Title: "Task", + } + + s := newSubSession(parent, cfg, childAgent) + + assert.Equal(t, "Please proceed.", s.GetLastUserMessageContent()) + }) + + t.Run("custom system message", func(t *testing.T) { + cfg := SubSessionConfig{ + Task: "bump deps", + SystemMessage: "You are a skill sub-agent. Follow these instructions.", + AgentName: "worker", + Title: "Skill task", + } + + s := newSubSession(parent, cfg, childAgent) + + // When SystemMessage is set, the default task-based message should not be used. + // We can verify the user message is still the default. + assert.Equal(t, "Please proceed.", s.GetLastUserMessageContent()) + }) +} + +func TestSubSessionConfig_DefaultValues(t *testing.T) { + // Verify zero-value SubSessionConfig produces a valid session + parent := session.New(session.WithUserMessage("hello")) + childAgent := agent.New("worker", "") + + cfg := SubSessionConfig{ + Task: "minimal task", + AgentName: "worker", + Title: "Minimal", + } + + s := newSubSession(parent, cfg, childAgent) + + assert.False(t, s.ToolsApproved) + assert.False(t, s.Thinking) + assert.False(t, s.SendUserMessage) + assert.Empty(t, s.AgentName) +} + +func TestSubSessionConfig_InheritsAgentLimits(t *testing.T) { + parent := session.New(session.WithUserMessage("hello")) + + t.Run("with custom limits", func(t *testing.T) { + childAgent := agent.New("worker", "", + agent.WithMaxIterations(42), + agent.WithMaxConsecutiveToolCalls(7), + ) + + cfg := SubSessionConfig{ + Task: "work", + AgentName: "worker", + Title: "test", + } + + s := newSubSession(parent, cfg, childAgent) + assert.Equal(t, 42, s.MaxIterations) + assert.Equal(t, 7, s.MaxConsecutiveToolCalls) + }) + + t.Run("with zero limits (defaults)", func(t *testing.T) { + childAgent := agent.New("worker", "") + + cfg := SubSessionConfig{ + Task: "work", + AgentName: "worker", + Title: "test", + } + + s := newSubSession(parent, cfg, childAgent) + assert.Equal(t, 0, s.MaxIterations) + assert.Equal(t, 0, s.MaxConsecutiveToolCalls) + }) +} diff --git a/pkg/runtime/loop.go b/pkg/runtime/loop.go index 3f768e958..d841b35b4 100644 --- a/pkg/runtime/loop.go +++ b/pkg/runtime/loop.go @@ -33,6 +33,7 @@ func (r *LocalRuntime) registerDefaultTools() { r.toolMap[builtin.ToolNameHandoff] = r.handleHandoff r.toolMap[builtin.ToolNameChangeModel] = r.handleChangeModel r.toolMap[builtin.ToolNameRevertModel] = r.handleRevertModel + r.toolMap[builtin.ToolNameRunSkill] = r.handleRunSkill r.bgAgents.RegisterHandlers(func(name string, fn func(context.Context, *session.Session, tools.ToolCall) (*tools.ToolCallResult, error)) { r.toolMap[name] = func(ctx context.Context, sess *session.Session, tc tools.ToolCall, _ chan Event) (*tools.ToolCallResult, error) { diff --git a/pkg/runtime/skill_runner.go b/pkg/runtime/skill_runner.go new file mode 100644 index 000000000..3cdc19bb2 --- /dev/null +++ b/pkg/runtime/skill_runner.go @@ -0,0 +1,83 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tools/builtin" +) + +// handleRunSkill executes a skill as an isolated sub-agent. The skill's +// SKILL.md content (with command expansions) becomes the system prompt, and +// the caller-provided task becomes the implicit user message. The sub-agent +// runs in a child session using the current agent's model and tools, and +// its final response is returned as the tool result. +// +// This implements the `context: fork` behaviour from the SKILL.md +// frontmatter, following the same convention as Claude Code. +func (r *LocalRuntime) handleRunSkill(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, evts chan Event) (*tools.ToolCallResult, error) { + var params builtin.RunSkillArgs + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), ¶ms); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + + st := r.CurrentAgentSkillsToolset() + if st == nil { + return tools.ResultError("no skills are available for the current agent"), nil + } + + skill := st.FindSkill(params.Name) + if skill == nil { + return tools.ResultError(fmt.Sprintf("skill %q not found", params.Name)), nil + } + + if !skill.IsFork() { + return tools.ResultError(fmt.Sprintf( + "skill %q is not configured for sub-agent execution (missing context: fork in SKILL.md frontmatter); use read_skill instead", + params.Name, + )), nil + } + + // Load and expand the skill content for the system prompt. + skillContent, err := st.ReadSkillContent(ctx, params.Name) + if err != nil { + return tools.ResultError(fmt.Sprintf("failed to read skill content: %s", err)), nil + } + + a := r.CurrentAgent() + ca := a.Name() + + ctx, span := r.startSpan(ctx, "runtime.run_skill", trace.WithAttributes( + attribute.String("agent", ca), + attribute.String("skill", params.Name), + attribute.String("session.id", sess.ID), + )) + defer span.End() + + slog.Debug("Running skill as sub-agent", + "agent", ca, + "skill", params.Name, + "task", params.Task, + ) + + cfg := SubSessionConfig{ + Task: params.Task, + SystemMessage: skillContent, + ImplicitUserMessage: params.Task, + AgentName: ca, + Title: "Skill: " + params.Name, + ToolsApproved: sess.ToolsApproved, + Thinking: sess.Thinking, + } + + s := newSubSession(sess, cfg, a) + + return r.runSubSessionForwarding(ctx, sess, s, span, evts, ca) +} diff --git a/pkg/skills/skills.go b/pkg/skills/skills.go index 0fcd5e782..9ee61c772 100644 --- a/pkg/skills/skills.go +++ b/pkg/skills/skills.go @@ -27,6 +27,14 @@ type Skill struct { Compatibility string `yaml:"compatibility"` Metadata map[string]string `yaml:"metadata"` AllowedTools stringOrList `yaml:"allowed-tools"` + Context string `yaml:"context"` // "fork" to run the skill as an isolated sub-agent +} + +// IsFork returns true when the skill should be executed in an isolated +// sub-agent context rather than inline in the current conversation. +// This matches Claude Code's `context: fork` frontmatter syntax. +func (s *Skill) IsFork() bool { + return s.Context == "fork" } // stringOrList is a []string that can be unmarshalled from either a YAML list diff --git a/pkg/skills/skills_test.go b/pkg/skills/skills_test.go index 38f730c2d..35ab553e8 100644 --- a/pkg/skills/skills_test.go +++ b/pkg/skills/skills_test.go @@ -125,6 +125,40 @@ Body`, }, wantOK: true, }, + { + name: "context fork", + content: `--- +name: forked-skill +description: A skill that runs as a sub-agent +context: fork +--- + +Body`, + want: Skill{ + Name: "forked-skill", + Description: "A skill that runs as a sub-agent", + Context: "fork", + }, + wantOK: true, + }, + { + name: "context fork with allowed-tools", + content: `--- +name: scoped-fork +description: Fork skill with tool restrictions +context: fork +allowed-tools: Read, Grep +--- + +Body`, + want: Skill{ + Name: "scoped-fork", + Description: "Fork skill with tool restrictions", + Context: "fork", + AllowedTools: stringOrList{"Read", "Grep"}, + }, + wantOK: true, + }, } for _, tt := range tests { @@ -138,6 +172,7 @@ Body`, assert.Equal(t, tt.want.Compatibility, got.Compatibility) assert.Equal(t, tt.want.Metadata, got.Metadata) assert.Equal(t, tt.want.AllowedTools, got.AllowedTools) + assert.Equal(t, tt.want.Context, got.Context) } }) } @@ -607,6 +642,13 @@ func TestFindGitRoot(t *testing.T) { }) } +func TestSkill_IsFork(t *testing.T) { + assert.True(t, (&Skill{Context: "fork"}).IsFork()) + assert.False(t, (&Skill{Context: ""}).IsFork()) + assert.False(t, (&Skill{Context: "inline"}).IsFork()) + assert.False(t, (&Skill{}).IsFork()) +} + func TestProjectSearchDirs(t *testing.T) { t.Run("in git repo", func(t *testing.T) { tmpRepo := t.TempDir() diff --git a/pkg/tools/builtin/skills.go b/pkg/tools/builtin/skills.go index 38379c00b..fe9fbb116 100644 --- a/pkg/tools/builtin/skills.go +++ b/pkg/tools/builtin/skills.go @@ -14,6 +14,7 @@ import ( const ( ToolNameReadSkill = "read_skill" ToolNameReadSkillFile = "read_skill_file" + ToolNameRunSkill = "run_skill" ) var ( @@ -50,6 +51,11 @@ func (s *SkillsToolset) findSkill(name string) *skills.Skill { return nil } +// FindSkill returns the skill with the given name, or nil if not found. +func (s *SkillsToolset) FindSkill(name string) *skills.Skill { + return s.findSkill(name) +} + // ReadSkillContent returns the content of a skill's SKILL.md by name. // For local skills, it expands any !`command` patterns in the content by // executing the commands and replacing the patterns with their stdout output. @@ -154,6 +160,16 @@ func (s *SkillsToolset) hasFiles() bool { return false } +// hasForkSkills reports whether any loaded skill uses context: fork. +func (s *SkillsToolset) hasForkSkills() bool { + for i := range s.skills { + if s.skills[i].IsFork() { + return true + } + } + return false +} + func (s *SkillsToolset) Instructions() string { if len(s.skills) == 0 { return "" @@ -163,6 +179,13 @@ func (s *SkillsToolset) Instructions() string { sb.WriteString("Skills provide specialized instructions for specific tasks. ") sb.WriteString("When a user's request matches a skill's description, use read_skill to load its instructions.\n\n") + hasFork := s.hasForkSkills() + if hasFork { + sb.WriteString("Some skills are configured to run as isolated sub-agents (context: fork). ") + sb.WriteString("For those skills use run_skill instead of read_skill so they execute in a dedicated context ") + sb.WriteString("with their own conversation history.\n\n") + } + if s.hasFiles() { sb.WriteString("Some skills have supporting files. ") sb.WriteString("Use read_skill_file to load referenced files on demand — do not preload them.\n\n") @@ -177,6 +200,9 @@ func (s *SkillsToolset) Instructions() string { sb.WriteString(" ") sb.WriteString(skill.Description) sb.WriteString("\n") + if skill.IsFork() { + sb.WriteString(" sub-agent\n") + } if len(skill.Files) > 1 { sb.WriteString(" ") // List files excluding SKILL.md itself @@ -200,6 +226,12 @@ func (s *SkillsToolset) Instructions() string { return sb.String() } +// RunSkillArgs specifies the parameters for the run_skill tool. +type RunSkillArgs struct { + Name string `json:"name" jsonschema:"The name of the skill to run as a sub-agent"` + Task string `json:"task" jsonschema:"A clear description of the task the skill sub-agent should achieve"` +} + func (s *SkillsToolset) Tools(context.Context) ([]tools.Tool, error) { if len(s.skills) == 0 { return nil, nil @@ -236,5 +268,19 @@ func (s *SkillsToolset) Tools(context.Context) ([]tools.Tool, error) { }) } + // Expose run_skill if any skill uses context: fork + if s.hasForkSkills() { + result = append(result, tools.Tool{ + Name: ToolNameRunSkill, + Category: "skills", + Description: "Run a skill as an isolated sub-agent with its own conversation context. Use this for skills marked with sub-agent mode.", + Parameters: tools.MustSchemaFor[RunSkillArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Annotations: tools.ToolAnnotations{ + Title: "Run Skill", + }, + }) + } + return result, nil } diff --git a/pkg/tools/builtin/skills_test.go b/pkg/tools/builtin/skills_test.go index 5fc0c20cf..7242c7ea1 100644 --- a/pkg/tools/builtin/skills_test.go +++ b/pkg/tools/builtin/skills_test.go @@ -280,3 +280,102 @@ func TestSkillsToolset_ReadSkillContent_RemoteSkillSkipsExpansion(t *testing.T) require.NoError(t, err) assert.Equal(t, content, result, "commands in remote skills must not be expanded") } + +func TestSkillsToolset_FindSkill(t *testing.T) { + st := NewSkillsToolset([]skills.Skill{ + {Name: "alpha", Description: "Alpha skill"}, + {Name: "beta", Description: "Beta skill"}, + }, "") + + found := st.FindSkill("alpha") + require.NotNil(t, found) + assert.Equal(t, "alpha", found.Name) + + found = st.FindSkill("beta") + require.NotNil(t, found) + assert.Equal(t, "beta", found.Name) + + assert.Nil(t, st.FindSkill("missing")) +} + +func TestSkillsToolset_Instructions_ForkSkills(t *testing.T) { + st := NewSkillsToolset([]skills.Skill{ + {Name: "inline-skill", Description: "Runs inline"}, + {Name: "fork-skill", Description: "Runs as sub-agent", Context: "fork"}, + }, "") + + instructions := st.Instructions() + + // Should mention run_skill for fork skills + assert.Contains(t, instructions, "run_skill") + assert.Contains(t, instructions, "context: fork") + assert.Contains(t, instructions, "isolated sub-agents") + + // Fork skill should have sub-agent mode tag + assert.Contains(t, instructions, "sub-agent") + + // Inline skill should NOT have the mode tag + // We check that inline-skill's entry does not contain + assert.Contains(t, instructions, "inline-skill") + assert.Contains(t, instructions, "fork-skill") +} + +func TestSkillsToolset_Instructions_NoForkSkills(t *testing.T) { + st := NewSkillsToolset([]skills.Skill{ + {Name: "normal", Description: "Normal skill"}, + }, "") + + instructions := st.Instructions() + + // Should NOT mention run_skill or sub-agent + assert.NotContains(t, instructions, "run_skill") + assert.NotContains(t, instructions, "sub-agent") + assert.NotContains(t, instructions, "context: fork") +} + +func TestSkillsToolset_Tools_WithForkSkills(t *testing.T) { + st := NewSkillsToolset([]skills.Skill{ + {Name: "inline", Description: "Inline skill"}, + {Name: "forked", Description: "Forked skill", Context: "fork"}, + }, "") + + result, err := st.Tools(t.Context()) + require.NoError(t, err) + + // Should have read_skill + run_skill (no files, so no read_skill_file) + require.Len(t, result, 2) + assert.Equal(t, ToolNameReadSkill, result[0].Name) + assert.Equal(t, ToolNameRunSkill, result[1].Name) +} + +func TestSkillsToolset_Tools_NoForkSkills(t *testing.T) { + st := NewSkillsToolset([]skills.Skill{ + {Name: "inline", Description: "Inline skill"}, + }, "") + + result, err := st.Tools(t.Context()) + require.NoError(t, err) + + // Should only have read_skill + require.Len(t, result, 1) + assert.Equal(t, ToolNameReadSkill, result[0].Name) +} + +func TestSkillsToolset_Tools_ForkAndFiles(t *testing.T) { + st := NewSkillsToolset([]skills.Skill{ + {Name: "full", Description: "Full skill", Context: "fork", Files: []string{"SKILL.md", "ref.md"}}, + }, "") + + result, err := st.Tools(t.Context()) + require.NoError(t, err) + + // Should have read_skill + read_skill_file + run_skill + require.Len(t, result, 3) + names := make([]string, len(result)) + for i, tool := range result { + names[i] = tool.Name + } + assert.Contains(t, names, ToolNameReadSkill) + assert.Contains(t, names, ToolNameReadSkillFile) + assert.Contains(t, names, ToolNameRunSkill) +}