Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/features/skills/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-lint:skip -->
```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

<div class="callout callout-tip">
<div class="callout-title">💡 When to use context: fork
</div>
<p>Use <code>context: fork</code> 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.</p>

</div>

## Search Paths

Skills are discovered from these locations (later overrides earlier):
Expand Down
214 changes: 144 additions & 70 deletions pkg/runtime/agent_delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading