fix(subturn): inherit runtime tool registry semantics for spawn
This commit is contained in:
parent
19979e731f
commit
f05a023e03
6 changed files with 176 additions and 40 deletions
|
|
@ -354,12 +354,12 @@ func spawnSubTurn(
|
||||||
ephemeralStore := newEphemeralSession(nil)
|
ephemeralStore := newEphemeralSession(nil)
|
||||||
agent := *baseAgent // shallow copy
|
agent := *baseAgent // shallow copy
|
||||||
agent.Sessions = ephemeralStore
|
agent.Sessions = ephemeralStore
|
||||||
if cfg.Tools != nil {
|
if baseAgent.Tools != nil {
|
||||||
// Explicit tool slice from caller has highest priority for sub-turns.
|
// Inherit the parent's tool registry snapshot so hidden/TTL metadata is preserved.
|
||||||
agent.Tools = toolRegistryFromSlice(cfg.Tools)
|
|
||||||
} else if baseAgent.Tools != nil {
|
|
||||||
// Otherwise inherit the parent's tool snapshot.
|
|
||||||
agent.Tools = baseAgent.Tools.Clone()
|
agent.Tools = baseAgent.Tools.Clone()
|
||||||
|
} else if len(cfg.Tools) > 0 {
|
||||||
|
// Fallback path for callers that provide explicit tool slices without a parent registry.
|
||||||
|
agent.Tools = toolRegistryFromSlice(cfg.Tools)
|
||||||
} else {
|
} else {
|
||||||
agent.Tools = tools.NewToolRegistry()
|
agent.Tools = tools.NewToolRegistry()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -955,9 +955,11 @@ func (p *toolCaptureProvider) GetDefaultModel() string {
|
||||||
return "test-model"
|
return "test-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
type subturnProbeTool struct{}
|
type subturnProbeTool struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
func (t *subturnProbeTool) Name() string { return "subturn_probe_tool" }
|
func (t *subturnProbeTool) Name() string { return t.name }
|
||||||
|
|
||||||
func (t *subturnProbeTool) Description() string { return "subturn probe tool" }
|
func (t *subturnProbeTool) Description() string { return "subturn probe tool" }
|
||||||
|
|
||||||
|
|
@ -971,7 +973,7 @@ func (t *subturnProbeTool) Execute(ctx context.Context, args map[string]any) *to
|
||||||
return tools.SilentResult("ok")
|
return tools.SilentResult("ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpawnSubTurn_UsesExplicitConfigTools(t *testing.T) {
|
func TestSpawnSubTurn_EmptyExplicitToolsStillInheritParentRegistry(t *testing.T) {
|
||||||
provider := &toolCaptureProvider{}
|
provider := &toolCaptureProvider{}
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
|
|
@ -990,11 +992,12 @@ func TestSpawnSubTurn_UsesExplicitConfigTools(t *testing.T) {
|
||||||
t.Fatal("expected default agent")
|
t.Fatal("expected default agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep parent tools empty so child tools can only come from cfg.Tools.
|
|
||||||
parentAgent.Tools = tools.NewToolRegistry()
|
parentAgent.Tools = tools.NewToolRegistry()
|
||||||
|
parentAgent.Tools.Register(&subturnProbeTool{name: "inherited_tool"})
|
||||||
|
|
||||||
parent := &turnState{
|
parent := &turnState{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
turnID: "parent-explicit-tools",
|
turnID: "parent-empty-explicit-tools",
|
||||||
depth: 0,
|
depth: 0,
|
||||||
pendingResults: make(chan *tools.ToolResult, 1),
|
pendingResults: make(chan *tools.ToolResult, 1),
|
||||||
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
|
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
|
||||||
|
|
@ -1005,14 +1008,164 @@ func TestSpawnSubTurn_UsesExplicitConfigTools(t *testing.T) {
|
||||||
_, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{
|
_, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
SystemPrompt: "run task",
|
SystemPrompt: "run task",
|
||||||
Tools: []tools.Tool{&subturnProbeTool{}},
|
Tools: []tools.Tool{},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("spawnSubTurn returned error: %v", err)
|
t.Fatalf("spawnSubTurn returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(provider.lastToolNames) != 1 || provider.lastToolNames[0] != "subturn_probe_tool" {
|
if len(provider.lastToolNames) != 1 || provider.lastToolNames[0] != "inherited_tool" {
|
||||||
t.Fatalf("expected explicit cfg tool to be sent, got %v", provider.lastToolNames)
|
t.Fatalf("expected inherited parent tools, got %v", provider.lastToolNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnSubTurn_InheritsRuntimeAddedTools(t *testing.T) {
|
||||||
|
provider := &toolCaptureProvider{}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||||
|
|
||||||
|
parentAgent := al.registry.GetDefaultAgent()
|
||||||
|
if parentAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentAgent.Tools = tools.NewToolRegistry()
|
||||||
|
parentAgent.Tools.Register(&subturnProbeTool{name: "base_tool"})
|
||||||
|
parent := &turnState{
|
||||||
|
ctx: context.Background(),
|
||||||
|
turnID: "parent-runtime-tools",
|
||||||
|
depth: 0,
|
||||||
|
pendingResults: make(chan *tools.ToolResult, 1),
|
||||||
|
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
|
||||||
|
session: &ephemeralSessionStore{},
|
||||||
|
agent: parentAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate runtime-added tools registered after initial manager setup.
|
||||||
|
parentAgent.Tools.Register(&subturnProbeTool{name: "runtime_tool"})
|
||||||
|
|
||||||
|
_, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
SystemPrompt: "run task",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawnSubTurn returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, name := range provider.lastToolNames {
|
||||||
|
got[name] = true
|
||||||
|
}
|
||||||
|
if !got["base_tool"] || !got["runtime_tool"] {
|
||||||
|
t.Fatalf("expected runtime-added parent tools in provider defs, got %v", provider.lastToolNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnSubTurn_PreservesHiddenTTLSemantics(t *testing.T) {
|
||||||
|
provider := &toolCaptureProvider{}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||||
|
|
||||||
|
parentAgent := al.registry.GetDefaultAgent()
|
||||||
|
if parentAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentAgent.Tools = tools.NewToolRegistry()
|
||||||
|
parentAgent.Tools.Register(&subturnProbeTool{name: "core_tool"})
|
||||||
|
parentAgent.Tools.RegisterHidden(&subturnProbeTool{name: "hidden_active"})
|
||||||
|
parentAgent.Tools.RegisterHidden(&subturnProbeTool{name: "hidden_inactive"})
|
||||||
|
parentAgent.Tools.PromoteTools([]string{"hidden_active"}, 2)
|
||||||
|
|
||||||
|
parent := &turnState{
|
||||||
|
ctx: context.Background(),
|
||||||
|
turnID: "parent-hidden-ttl",
|
||||||
|
depth: 0,
|
||||||
|
pendingResults: make(chan *tools.ToolResult, 1),
|
||||||
|
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
|
||||||
|
session: &ephemeralSessionStore{},
|
||||||
|
agent: parentAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
SystemPrompt: "run task",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawnSubTurn returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, name := range provider.lastToolNames {
|
||||||
|
got[name] = true
|
||||||
|
}
|
||||||
|
if !got["core_tool"] || !got["hidden_active"] {
|
||||||
|
t.Fatalf("expected core + active hidden tools, got %v", provider.lastToolNames)
|
||||||
|
}
|
||||||
|
if got["hidden_inactive"] {
|
||||||
|
t.Fatalf("inactive hidden tool leaked into provider defs: %v", provider.lastToolNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnSubTurn_UsesExplicitToolsWhenParentRegistryUnavailable(t *testing.T) {
|
||||||
|
provider := &toolCaptureProvider{}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||||
|
|
||||||
|
parentAgent := al.registry.GetDefaultAgent()
|
||||||
|
if parentAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
parentAgent.Tools = nil
|
||||||
|
|
||||||
|
parent := &turnState{
|
||||||
|
ctx: context.Background(),
|
||||||
|
turnID: "parent-explicit-fallback",
|
||||||
|
depth: 0,
|
||||||
|
pendingResults: make(chan *tools.ToolResult, 1),
|
||||||
|
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
|
||||||
|
session: &ephemeralSessionStore{},
|
||||||
|
agent: parentAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
SystemPrompt: "run task",
|
||||||
|
Tools: []tools.Tool{&subturnProbeTool{name: "explicit_tool"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawnSubTurn returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(provider.lastToolNames) != 1 || provider.lastToolNames[0] != "explicit_tool" {
|
||||||
|
t.Fatalf("expected explicit fallback tools, got %v", provider.lastToolNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ type SpawnTool struct {
|
||||||
defaultModel string
|
defaultModel string
|
||||||
maxTokens int
|
maxTokens int
|
||||||
temperature float64
|
temperature float64
|
||||||
tools *ToolRegistry
|
|
||||||
allowlistCheck func(targetAgentID string) bool
|
allowlistCheck func(targetAgentID string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,7 +25,6 @@ func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
||||||
defaultModel: manager.defaultModel,
|
defaultModel: manager.defaultModel,
|
||||||
maxTokens: manager.maxTokens,
|
maxTokens: manager.maxTokens,
|
||||||
temperature: manager.temperature,
|
temperature: manager.temperature,
|
||||||
tools: manager.tools,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +124,7 @@ Task: %s`,
|
||||||
go func() {
|
go func() {
|
||||||
result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
|
result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
|
||||||
Model: t.defaultModel,
|
Model: t.defaultModel,
|
||||||
Tools: snapshotTools(t.tools),
|
Tools: nil, // Inherit from the parent turn registry at runtime.
|
||||||
SystemPrompt: systemPrompt,
|
SystemPrompt: systemPrompt,
|
||||||
MaxTokens: t.maxTokens,
|
MaxTokens: t.maxTokens,
|
||||||
Temperature: t.temperature,
|
Temperature: t.temperature,
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,12 @@ func (t *managerSnapshotTool) Execute(ctx context.Context, args map[string]any)
|
||||||
|
|
||||||
type recordingSpawner struct {
|
type recordingSpawner struct {
|
||||||
toolNames []string
|
toolNames []string
|
||||||
|
toolsNil bool
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordingSpawner) SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*ToolResult, error) {
|
func (s *recordingSpawner) SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*ToolResult, error) {
|
||||||
|
s.toolsNil = cfg.Tools == nil
|
||||||
for _, tool := range cfg.Tools {
|
for _, tool := range cfg.Tools {
|
||||||
if tool != nil {
|
if tool != nil {
|
||||||
s.toolNames = append(s.toolNames, tool.Name())
|
s.toolNames = append(s.toolNames, tool.Name())
|
||||||
|
|
@ -140,7 +142,7 @@ func TestSpawnTool_Execute_NilManager(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpawnTool_Execute_PassesManagerToolsToSubTurn(t *testing.T) {
|
func TestSpawnTool_Execute_LeavesToolsUnsetForRuntimeInheritance(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
manager.RegisterTool(&managerSnapshotTool{name: "snapshot_tool"})
|
manager.RegisterTool(&managerSnapshotTool{name: "snapshot_tool"})
|
||||||
|
|
@ -166,7 +168,7 @@ func TestSpawnTool_Execute_PassesManagerToolsToSubTurn(t *testing.T) {
|
||||||
t.Fatal("timed out waiting for async spawn execution")
|
t.Fatal("timed out waiting for async spawn execution")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(spawner.toolNames) != 1 || spawner.toolNames[0] != "snapshot_tool" {
|
if !spawner.toolsNil {
|
||||||
t.Fatalf("expected tool snapshot [snapshot_tool], got %v", spawner.toolNames)
|
t.Fatalf("expected cfg.Tools to be nil for runtime inheritance, got %v", spawner.toolNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,21 +115,6 @@ func (sm *SubagentManager) RegisterTool(tool Tool) {
|
||||||
sm.tools.Register(tool)
|
sm.tools.Register(tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshotTools(toolsRegistry *ToolRegistry) []Tool {
|
|
||||||
if toolsRegistry == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
names := toolsRegistry.List()
|
|
||||||
snapshot := make([]Tool, 0, len(names))
|
|
||||||
for _, name := range names {
|
|
||||||
if tool, ok := toolsRegistry.Get(name); ok {
|
|
||||||
snapshot = append(snapshot, tool)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SubagentManager) Spawn(
|
func (sm *SubagentManager) Spawn(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
task, label, agentID, originChannel, originChatID string,
|
task, label, agentID, originChannel, originChatID string,
|
||||||
|
|
@ -337,7 +322,6 @@ type SubagentTool struct {
|
||||||
defaultModel string
|
defaultModel string
|
||||||
maxTokens int
|
maxTokens int
|
||||||
temperature float64
|
temperature float64
|
||||||
tools *ToolRegistry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubagentTool(manager *SubagentManager) *SubagentTool {
|
func NewSubagentTool(manager *SubagentManager) *SubagentTool {
|
||||||
|
|
@ -348,7 +332,6 @@ func NewSubagentTool(manager *SubagentManager) *SubagentTool {
|
||||||
defaultModel: manager.defaultModel,
|
defaultModel: manager.defaultModel,
|
||||||
maxTokens: manager.maxTokens,
|
maxTokens: manager.maxTokens,
|
||||||
temperature: manager.temperature,
|
temperature: manager.temperature,
|
||||||
tools: manager.tools,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -412,7 +395,7 @@ Task: %s`,
|
||||||
if t.spawner != nil {
|
if t.spawner != nil {
|
||||||
result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
|
result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
|
||||||
Model: t.defaultModel,
|
Model: t.defaultModel,
|
||||||
Tools: snapshotTools(t.tools),
|
Tools: nil, // Inherit from the parent turn registry at runtime.
|
||||||
SystemPrompt: systemPrompt,
|
SystemPrompt: systemPrompt,
|
||||||
MaxTokens: t.maxTokens,
|
MaxTokens: t.maxTokens,
|
||||||
Temperature: t.temperature,
|
Temperature: t.temperature,
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubagentTool_Execute_PassesManagerToolsToSubTurn(t *testing.T) {
|
func TestSubagentTool_Execute_LeavesToolsUnsetForRuntimeInheritance(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
manager.RegisterTool(&managerSnapshotTool{name: "snapshot_tool"})
|
manager.RegisterTool(&managerSnapshotTool{name: "snapshot_tool"})
|
||||||
|
|
@ -344,7 +344,7 @@ func TestSubagentTool_Execute_PassesManagerToolsToSubTurn(t *testing.T) {
|
||||||
t.Fatalf("Expected success, got error: %s", result.ForLLM)
|
t.Fatalf("Expected success, got error: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(spawner.toolNames) != 1 || spawner.toolNames[0] != "snapshot_tool" {
|
if !spawner.toolsNil {
|
||||||
t.Fatalf("expected tool snapshot [snapshot_tool], got %v", spawner.toolNames)
|
t.Fatalf("expected cfg.Tools to be nil for runtime inheritance, got %v", spawner.toolNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue