Skip to content

Commit 8be5253

Browse files
authored
Merge pull request #2289 from dgageot/board/docker-agent-issue-2267-status-inquiry-37a00c98
fix: prevent recursive run_skill loop in context:fork skill sub-sessions
2 parents cca8638 + a22a3c4 commit 8be5253

5 files changed

Lines changed: 119 additions & 0 deletions

File tree

pkg/runtime/agent_delegation.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ type SubSessionConfig struct {
8585
// user message sent to the child session. This allows callers like skill
8686
// sub-agents to pass the task description as the user message.
8787
ImplicitUserMessage string
88+
// ExcludedTools lists tool names that should be filtered out of the agent's
89+
// tool list for the child session. This prevents recursive tool calls
90+
// (e.g. run_skill calling itself in a skill sub-session).
91+
ExcludedTools []string
8892
}
8993

9094
// newSubSession builds a *session.Session from a SubSessionConfig and a parent
@@ -115,9 +119,39 @@ func newSubSession(parent *session.Session, cfg SubSessionConfig, childAgent *ag
115119
if cfg.PinAgent {
116120
opts = append(opts, session.WithAgentName(cfg.AgentName))
117121
}
122+
// Merge parent's excluded tools with config's excluded tools so that
123+
// nested sub-sessions (e.g. skill → transfer_task → child) inherit
124+
// exclusions from all ancestors and don't re-introduce filtered tools.
125+
excludedTools := mergeExcludedTools(parent.ExcludedTools, cfg.ExcludedTools)
126+
if len(excludedTools) > 0 {
127+
opts = append(opts, session.WithExcludedTools(excludedTools))
128+
}
118129
return session.New(opts...)
119130
}
120131

132+
// mergeExcludedTools combines two excluded-tool lists, deduplicating entries.
133+
// It returns nil when both inputs are empty.
134+
func mergeExcludedTools(parent, child []string) []string {
135+
if len(parent) == 0 {
136+
return child
137+
}
138+
if len(child) == 0 {
139+
return parent
140+
}
141+
set := make(map[string]struct{}, len(parent)+len(child))
142+
for _, t := range parent {
143+
set[t] = struct{}{}
144+
}
145+
for _, t := range child {
146+
set[t] = struct{}{}
147+
}
148+
merged := make([]string, 0, len(set))
149+
for t := range set {
150+
merged = append(merged, t)
151+
}
152+
return merged
153+
}
154+
121155
// runSubSessionForwarding runs a child session within the parent, forwarding all
122156
// events to the caller's event channel and propagating tool approval state
123157
// back to the parent when done.

pkg/runtime/loop.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
104104
events <- Error(fmt.Sprintf("failed to get tools: %v", err))
105105
return
106106
}
107+
agentTools = filterExcludedTools(agentTools, sess.ExcludedTools)
107108

108109
events <- ToolsetInfo(len(agentTools), false, a.Name())
109110

@@ -158,6 +159,7 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
158159
events <- Error(fmt.Sprintf("failed to get tools: %v", err))
159160
return
160161
}
162+
agentTools = filterExcludedTools(agentTools, sess.ExcludedTools)
161163

162164
// Emit updated tool count. After a ToolListChanged MCP notification
163165
// the cache is invalidated, so getTools above re-fetches from the
@@ -573,6 +575,25 @@ func formatToolWarning(a *agent.Agent, warnings []string) string {
573575
return strings.TrimSuffix(builder.String(), "\n")
574576
}
575577

578+
// filterExcludedTools removes tools whose names appear in the excluded list.
579+
// This is used by skill sub-sessions to prevent recursive run_skill calls.
580+
func filterExcludedTools(agentTools []tools.Tool, excluded []string) []tools.Tool {
581+
if len(excluded) == 0 {
582+
return agentTools
583+
}
584+
excludeSet := make(map[string]bool, len(excluded))
585+
for _, name := range excluded {
586+
excludeSet[name] = true
587+
}
588+
filtered := make([]tools.Tool, 0, len(agentTools))
589+
for _, t := range agentTools {
590+
if !excludeSet[t.Name] {
591+
filtered = append(filtered, t)
592+
}
593+
}
594+
return filtered
595+
}
596+
576597
// chanSend wraps a channel as a func(Event) for use with emitAgentWarnings
577598
// and RAG event forwarding. The send is non-blocking: if the channel is full
578599
// or closed, the event is silently dropped. This prevents a panic when a

pkg/runtime/runtime_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,3 +1901,52 @@ func TestProcessToolCalls_UsesPinnedAgent(t *testing.T) {
19011901
}
19021902
}
19031903
}
1904+
1905+
func TestFilterExcludedTools(t *testing.T) {
1906+
allTools := []tools.Tool{
1907+
{Name: "read_skill"},
1908+
{Name: "run_skill"},
1909+
{Name: "shell"},
1910+
}
1911+
1912+
t.Run("no exclusions returns all tools", func(t *testing.T) {
1913+
result := filterExcludedTools(allTools, nil)
1914+
assert.Len(t, result, 3)
1915+
})
1916+
1917+
t.Run("excludes run_skill", func(t *testing.T) {
1918+
result := filterExcludedTools(allTools, []string{"run_skill"})
1919+
assert.Len(t, result, 2)
1920+
for _, tool := range result {
1921+
assert.NotEqual(t, "run_skill", tool.Name)
1922+
}
1923+
})
1924+
1925+
t.Run("excludes multiple tools", func(t *testing.T) {
1926+
result := filterExcludedTools(allTools, []string{"run_skill", "shell"})
1927+
assert.Len(t, result, 1)
1928+
assert.Equal(t, "read_skill", result[0].Name)
1929+
})
1930+
}
1931+
1932+
func TestMergeExcludedTools(t *testing.T) {
1933+
t.Run("both empty", func(t *testing.T) {
1934+
assert.Nil(t, mergeExcludedTools(nil, nil))
1935+
})
1936+
1937+
t.Run("parent only", func(t *testing.T) {
1938+
result := mergeExcludedTools([]string{"run_skill"}, nil)
1939+
assert.Equal(t, []string{"run_skill"}, result)
1940+
})
1941+
1942+
t.Run("child only", func(t *testing.T) {
1943+
result := mergeExcludedTools(nil, []string{"run_skill"})
1944+
assert.Equal(t, []string{"run_skill"}, result)
1945+
})
1946+
1947+
t.Run("deduplicates", func(t *testing.T) {
1948+
result := mergeExcludedTools([]string{"run_skill", "shell"}, []string{"run_skill", "read_skill"})
1949+
assert.Len(t, result, 3)
1950+
assert.ElementsMatch(t, []string{"run_skill", "shell", "read_skill"}, result)
1951+
})
1952+
}

pkg/runtime/skill_runner.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ func (r *LocalRuntime) handleRunSkill(ctx context.Context, sess *session.Session
7474
AgentName: ca,
7575
Title: "Skill: " + params.Name,
7676
ToolsApproved: sess.ToolsApproved,
77+
ExcludedTools: []string{builtin.ToolNameRunSkill},
7778
}
7879

7980
s := newSubSession(sess, cfg, a)

pkg/session/session.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ type Session struct {
128128
// These are shown in the model picker for easy re-selection.
129129
CustomModelsUsed []string `json:"custom_models_used,omitempty"`
130130

131+
// ExcludedTools lists tool names that should be filtered out of the agent's
132+
// tool list for this session. This is used by skill sub-sessions to prevent
133+
// recursive run_skill calls.
134+
ExcludedTools []string `json:"-"`
135+
131136
// AgentName, when set, tells RunStream which agent to use for this session
132137
// instead of reading from the shared runtime currentAgent field. This is
133138
// required for background agent tasks where multiple sessions may run
@@ -524,6 +529,15 @@ func WithParentID(parentID string) Opt {
524529
}
525530
}
526531

532+
// WithExcludedTools sets tool names that should be filtered out of the agent's
533+
// tool list for this session. This prevents recursive tool calls in skill
534+
// sub-sessions.
535+
func WithExcludedTools(names []string) Opt {
536+
return func(s *Session) {
537+
s.ExcludedTools = names
538+
}
539+
}
540+
527541
// IsSubSession returns true if this session is a sub-session (has a parent).
528542
func (s *Session) IsSubSession() bool {
529543
return s.ParentID != ""

0 commit comments

Comments
 (0)