Skip to content

Commit 10cec57

Browse files
authored
Merge pull request #2137 from dgageot/board/what-is-needed-for-us-to-optionnaly-run-17583cad
feat: support running skills as isolated sub-agents via context: fork
2 parents dada675 + 7424ada commit 10cec57

9 files changed

Lines changed: 668 additions & 70 deletions

File tree

docs/features/skills/index.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,53 @@ When asked to create a Dockerfile:
5757
4. Follow security best practices (non-root user, etc.)
5858
```
5959

60+
### Frontmatter Fields
61+
62+
| Field | Required | Description |
63+
| ---------------- | -------- | --------------------------------------------------------------------------- |
64+
| `name` | Yes | Unique skill identifier |
65+
| `description` | Yes | Short description shown to the agent for skill matching |
66+
| `context` | No | Set to `fork` to run the skill as an isolated sub-agent (see below) |
67+
| `allowed-tools` | No | List of tools the skill needs (YAML list or comma-separated string) |
68+
| `license` | No | License identifier (e.g. `Apache-2.0`) |
69+
| `compatibility` | No | Free-text compatibility notes |
70+
| `metadata` | No | Arbitrary key-value pairs (e.g. `author`, `version`) |
71+
72+
## Running a Skill as a Sub-Agent
73+
74+
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.
75+
76+
Adding `context: fork` to the SKILL.md frontmatter tells the agent to run the skill in an **isolated sub-agent** instead:
77+
78+
<!-- yaml-lint:skip -->
79+
```yaml
80+
---
81+
name: bump-go-dependencies
82+
description: Update Go module dependencies one by one
83+
context: fork
84+
---
85+
86+
# Bump Dependencies
87+
88+
1. List outdated deps
89+
2. Update each one, run tests, commit or revert
90+
3. Produce a summary table
91+
```
92+
93+
When the agent encounters a task that matches a `context: fork` skill, it uses the `run_skill` tool instead of `read_skill`. This:
94+
95+
- **Spawns a child session** with the skill content as the system prompt and the caller's task as the user message
96+
- **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
97+
- **Folds the result** — only the sub-agent's final answer is returned to the parent as the tool result
98+
- **Inherits the parent's model and tools** — the sub-agent can use all tools available to the parent agent
99+
100+
<div class="callout callout-tip">
101+
<div class="callout-title">💡 When to use context: fork
102+
</div>
103+
<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>
104+
105+
</div>
106+
60107
## Search Paths
61108

62109
Skills are discovered from these locations (later overrides earlier):

pkg/runtime/agent_delegation.go

Lines changed: 144 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -58,55 +58,110 @@ func buildTaskSystemMessage(task, expectedOutput string) string {
5858
return msg
5959
}
6060

61-
// CurrentAgentSubAgentNames implements agenttool.Runner.
62-
func (r *LocalRuntime) CurrentAgentSubAgentNames() []string {
63-
a := r.CurrentAgent()
64-
if a == nil {
65-
return nil
66-
}
67-
return agentNames(a.SubAgents())
61+
// SubSessionConfig describes how to build and run a child session.
62+
// Both handleTaskTransfer and RunAgent (background agents) use this
63+
// to avoid duplicating session-construction logic. Future callers
64+
// (e.g. skill-as-sub-agent) can use it as well.
65+
type SubSessionConfig struct {
66+
// Task is the user-facing task description.
67+
Task string
68+
// ExpectedOutput is an optional description of what the sub-agent should produce.
69+
ExpectedOutput string
70+
// SystemMessage, when non-empty, replaces the default task-based system
71+
// message. This is used by skill sub-agents whose system prompt is the
72+
// skill content itself rather than the team delegation boilerplate.
73+
SystemMessage string
74+
// AgentName is the name of the agent that will execute the sub-session.
75+
AgentName string
76+
// Title is a human-readable label for the sub-session (e.g. "Transferred task").
77+
Title string
78+
// ToolsApproved overrides whether tools are pre-approved in the child session.
79+
ToolsApproved bool
80+
// Thinking propagates the parent's thinking-mode flag.
81+
Thinking bool
82+
// PinAgent, when true, pins the child session to AgentName via
83+
// session.WithAgentName. This is required for concurrent background
84+
// tasks that must not share the runtime's mutable currentAgent field.
85+
PinAgent bool
86+
// ImplicitUserMessage, when non-empty, overrides the default "Please proceed."
87+
// user message sent to the child session. This allows callers like skill
88+
// sub-agents to pass the task description as the user message.
89+
ImplicitUserMessage string
6890
}
6991

70-
// RunAgent implements agenttool.Runner. It starts a sub-agent synchronously and
71-
// blocks until completion or cancellation.
72-
func (r *LocalRuntime) RunAgent(ctx context.Context, params agenttool.RunParams) *agenttool.RunResult {
73-
child, err := r.team.Agent(params.AgentName)
74-
if err != nil {
75-
return &agenttool.RunResult{ErrMsg: fmt.Sprintf("agent %q not found: %s", params.AgentName, err)}
92+
// newSubSession builds a *session.Session from a SubSessionConfig and a parent
93+
// session. It consolidates the session options that were previously duplicated
94+
// across handleTaskTransfer and RunAgent.
95+
func newSubSession(parent *session.Session, cfg SubSessionConfig, childAgent *agent.Agent) *session.Session {
96+
sysMsg := cfg.SystemMessage
97+
if sysMsg == "" {
98+
sysMsg = buildTaskSystemMessage(cfg.Task, cfg.ExpectedOutput)
7699
}
77100

78-
sess := params.ParentSession
101+
userMsg := cfg.ImplicitUserMessage
102+
if userMsg == "" {
103+
userMsg = "Please proceed."
104+
}
79105

80-
// Background tasks run with tools pre-approved because there is no user present
81-
// to respond to interactive approval prompts during async execution. This is a
82-
// deliberate design trade-off: the user implicitly authorises all tool calls made
83-
// by the sub-agent when they approve run_background_agent. Callers should be aware
84-
// that prompt injection in the sub-agent's context could exploit this gate-bypass.
85-
//
86-
// TODO: propagate the parent session's per-tool permission rules once the runtime
87-
// supports per-session permission scoping rather than a single shared ToolsApproved flag.
88-
s := session.New(
89-
session.WithSystemMessage(buildTaskSystemMessage(params.Task, params.ExpectedOutput)),
90-
session.WithImplicitUserMessage("Please proceed."),
91-
session.WithMaxIterations(child.MaxIterations()),
92-
session.WithMaxConsecutiveToolCalls(child.MaxConsecutiveToolCalls()),
93-
session.WithTitle("Background agent task"),
94-
session.WithToolsApproved(true),
95-
session.WithThinking(sess.Thinking),
106+
opts := []session.Opt{
107+
session.WithSystemMessage(sysMsg),
108+
session.WithImplicitUserMessage(userMsg),
109+
session.WithMaxIterations(childAgent.MaxIterations()),
110+
session.WithMaxConsecutiveToolCalls(childAgent.MaxConsecutiveToolCalls()),
111+
session.WithTitle(cfg.Title),
112+
session.WithToolsApproved(cfg.ToolsApproved),
113+
session.WithThinking(cfg.Thinking),
96114
session.WithSendUserMessage(false),
97-
session.WithParentID(sess.ID),
98-
session.WithAgentName(params.AgentName),
99-
)
115+
session.WithParentID(parent.ID),
116+
}
117+
if cfg.PinAgent {
118+
opts = append(opts, session.WithAgentName(cfg.AgentName))
119+
}
120+
return session.New(opts...)
121+
}
122+
123+
// runSubSessionForwarding runs a child session within the parent, forwarding all
124+
// events to the caller's event channel and propagating session state (tool
125+
// approvals, thinking) back to the parent when done.
126+
//
127+
// This is the "interactive" path used by transfer_task where the parent agent
128+
// loop is blocked while the child executes.
129+
func (r *LocalRuntime) runSubSessionForwarding(ctx context.Context, parent, child *session.Session, span trace.Span, evts chan Event, callerAgent string) (*tools.ToolCallResult, error) {
130+
for event := range r.RunStream(ctx, child) {
131+
evts <- event
132+
if errEvent, ok := event.(*ErrorEvent); ok {
133+
span.RecordError(fmt.Errorf("%s", errEvent.Error))
134+
span.SetStatus(codes.Error, "sub-session error")
135+
return nil, fmt.Errorf("%s", errEvent.Error)
136+
}
137+
}
100138

139+
parent.ToolsApproved = child.ToolsApproved
140+
parent.Thinking = child.Thinking
141+
142+
parent.AddSubSession(child)
143+
evts <- SubSessionCompleted(parent.ID, child, callerAgent)
144+
145+
span.SetStatus(codes.Ok, "sub-session completed")
146+
return tools.ResultSuccess(child.GetLastAssistantMessageContent()), nil
147+
}
148+
149+
// runSubSessionCollecting runs a child session, collecting output via an
150+
// optional content callback instead of forwarding events. This is the path
151+
// used by background agents and other non-interactive callers.
152+
//
153+
// It returns a RunResult containing either the final assistant message or
154+
// an error message.
155+
func (r *LocalRuntime) runSubSessionCollecting(ctx context.Context, parent, child *session.Session, onContent func(string)) *agenttool.RunResult {
101156
var errMsg string
102-
events := r.RunStream(ctx, s)
157+
events := r.RunStream(ctx, child)
103158
for event := range events {
104159
if ctx.Err() != nil {
105160
break
106161
}
107162
if choice, ok := event.(*AgentChoiceEvent); ok && choice.Content != "" {
108-
if params.OnContent != nil {
109-
params.OnContent(choice.Content)
163+
if onContent != nil {
164+
onContent(choice.Content)
110165
}
111166
}
112167
if errEvt, ok := event.(*ErrorEvent); ok {
@@ -123,11 +178,53 @@ func (r *LocalRuntime) RunAgent(ctx context.Context, params agenttool.RunParams)
123178
return &agenttool.RunResult{ErrMsg: errMsg}
124179
}
125180

126-
result := s.GetLastAssistantMessageContent()
127-
sess.AddSubSession(s)
181+
result := child.GetLastAssistantMessageContent()
182+
parent.AddSubSession(child)
128183
return &agenttool.RunResult{Result: result}
129184
}
130185

186+
// CurrentAgentSubAgentNames implements agenttool.Runner.
187+
func (r *LocalRuntime) CurrentAgentSubAgentNames() []string {
188+
a := r.CurrentAgent()
189+
if a == nil {
190+
return nil
191+
}
192+
return agentNames(a.SubAgents())
193+
}
194+
195+
// RunAgent implements agenttool.Runner. It starts a sub-agent synchronously and
196+
// blocks until completion or cancellation.
197+
func (r *LocalRuntime) RunAgent(ctx context.Context, params agenttool.RunParams) *agenttool.RunResult {
198+
child, err := r.team.Agent(params.AgentName)
199+
if err != nil {
200+
return &agenttool.RunResult{ErrMsg: fmt.Sprintf("agent %q not found: %s", params.AgentName, err)}
201+
}
202+
203+
sess := params.ParentSession
204+
205+
// Background tasks run with tools pre-approved because there is no user present
206+
// to respond to interactive approval prompts during async execution. This is a
207+
// deliberate design trade-off: the user implicitly authorises all tool calls made
208+
// by the sub-agent when they approve run_background_agent. Callers should be aware
209+
// that prompt injection in the sub-agent's context could exploit this gate-bypass.
210+
//
211+
// TODO: propagate the parent session's per-tool permission rules once the runtime
212+
// supports per-session permission scoping rather than a single shared ToolsApproved flag.
213+
cfg := SubSessionConfig{
214+
Task: params.Task,
215+
ExpectedOutput: params.ExpectedOutput,
216+
AgentName: params.AgentName,
217+
Title: "Background agent task",
218+
ToolsApproved: true,
219+
Thinking: sess.Thinking,
220+
PinAgent: true,
221+
}
222+
223+
s := newSubSession(sess, cfg, child)
224+
225+
return r.runSubSessionCollecting(ctx, sess, s, params.OnContent)
226+
}
227+
131228
func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, evts chan Event) (*tools.ToolCallResult, error) {
132229
var params struct {
133230
Agent string `json:"agent"`
@@ -185,41 +282,18 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses
185282
return nil, err
186283
}
187284

188-
s := session.New(
189-
session.WithSystemMessage(buildTaskSystemMessage(params.Task, params.ExpectedOutput)),
190-
session.WithImplicitUserMessage("Please proceed."),
191-
session.WithMaxIterations(child.MaxIterations()),
192-
session.WithMaxConsecutiveToolCalls(child.MaxConsecutiveToolCalls()),
193-
session.WithTitle("Transferred task"),
194-
session.WithToolsApproved(sess.ToolsApproved),
195-
session.WithThinking(sess.Thinking),
196-
session.WithSendUserMessage(false),
197-
session.WithParentID(sess.ID),
198-
)
199-
200-
return r.runSubSession(ctx, sess, s, span, evts, a.Name())
201-
}
202-
203-
// runSubSession runs a child session within the parent, forwarding events and
204-
// propagating state (tool approvals, thinking) back to the parent when done.
205-
func (r *LocalRuntime) runSubSession(ctx context.Context, parent, child *session.Session, span trace.Span, evts chan Event, agentName string) (*tools.ToolCallResult, error) {
206-
for event := range r.RunStream(ctx, child) {
207-
evts <- event
208-
if errEvent, ok := event.(*ErrorEvent); ok {
209-
span.RecordError(fmt.Errorf("%s", errEvent.Error))
210-
span.SetStatus(codes.Error, "sub-session error")
211-
return nil, fmt.Errorf("%s", errEvent.Error)
212-
}
285+
cfg := SubSessionConfig{
286+
Task: params.Task,
287+
ExpectedOutput: params.ExpectedOutput,
288+
AgentName: params.Agent,
289+
Title: "Transferred task",
290+
ToolsApproved: sess.ToolsApproved,
291+
Thinking: sess.Thinking,
213292
}
214293

215-
parent.ToolsApproved = child.ToolsApproved
216-
parent.Thinking = child.Thinking
217-
218-
parent.AddSubSession(child)
219-
evts <- SubSessionCompleted(parent.ID, child, agentName)
294+
s := newSubSession(sess, cfg, child)
220295

221-
span.SetStatus(codes.Ok, "sub-session completed")
222-
return tools.ResultSuccess(child.GetLastAssistantMessageContent()), nil
296+
return r.runSubSessionForwarding(ctx, sess, s, span, evts, a.Name())
223297
}
224298

225299
func (r *LocalRuntime) handleHandoff(_ context.Context, _ *session.Session, toolCall tools.ToolCall, _ chan Event) (*tools.ToolCallResult, error) {

0 commit comments

Comments
 (0)