Skip to content
Draft
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
5 changes: 1 addition & 4 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,7 @@ func (r *LocalRuntime) finalizeEventChannel(ctx context.Context, sess *session.S

// RunStream starts the agent's interaction loop and returns a channel of events
func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-chan Event {
slog.Debug("Starting runtime stream", "agent", r.CurrentAgentName(), "session_id", sess.ID)
slog.Debug("Starting runtime stream", "agent", r.CurrentAgentName(), "session_agent", sess.AgentName, "session_id", sess.ID)
events := make(chan Event, 128)

go func() {
Expand Down Expand Up @@ -1039,9 +1039,6 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
runtimeMaxIterations := sess.MaxIterations

for {
// Set elicitation handler on all MCP toolsets before getting tools
a := r.CurrentAgent()

r.emitAgentWarnings(a, events)
r.configureToolsetHandlers(a, events)

Expand Down
25 changes: 23 additions & 2 deletions pkg/teamloader/teamloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,9 @@ func getFallbackModelsForAgent(ctx context.Context, cfg *latest.Config, a *lates
// getToolsForAgent returns the tool definitions for an agent based on its configuration
func getToolsForAgent(ctx context.Context, a *latest.AgentConfig, parentDir string, runConfig *config.RuntimeConfig, registry *ToolsetRegistry) ([]tools.ToolSet, []string) {
var (
toolSets []tools.ToolSet
warnings []string
toolSets []tools.ToolSet
warnings []string
lspBackends []builtin.LSPBackend
)

deferredToolset := builtin.NewDeferredToolset()
Expand Down Expand Up @@ -456,9 +457,29 @@ func getToolsForAgent(ctx context.Context, a *latest.AgentConfig, parentDir stri
}
}

// Collect LSP backends for multiplexing when there are multiple.
// Instead of adding them individually (which causes duplicate tool names),
// they are combined into a single LSPMultiplexer after the loop.
if toolset.Type == "lsp" {
if lspTool, ok := tool.(*builtin.LSPTool); ok {
lspBackends = append(lspBackends, builtin.LSPBackend{LSP: lspTool, Toolset: wrapped})
continue
}
slog.Warn("Toolset configured as type 'lsp' but registry returned unexpected type; treating as regular toolset",
"type", fmt.Sprintf("%T", tool), "command", toolset.Command)
}

toolSets = append(toolSets, wrapped)
}

// Merge LSP backends: if there are multiple, combine them into a single
// multiplexer so the LLM sees one set of lsp_* tools instead of duplicates.
if len(lspBackends) > 1 {
toolSets = append(toolSets, builtin.NewLSPMultiplexer(lspBackends))
} else if len(lspBackends) == 1 {
toolSets = append(toolSets, lspBackends[0].Toolset)
}

if deferredToolset.HasSources() {
toolSets = append(toolSets, deferredToolset)
}
Expand Down
79 changes: 79 additions & 0 deletions pkg/teamloader/teamloader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,85 @@ agents:
assert.Equal(t, expected, rootAgent.AddPromptFiles())
}

func TestGetToolsForAgent_MultipleLSPToolsetsAreCombined(t *testing.T) {
t.Parallel()

a := &latest.AgentConfig{
Instruction: "test",
Toolsets: []latest.Toolset{
{
Type: "lsp",
Command: "gopls",
FileTypes: []string{".go"},
},
{
Type: "lsp",
Command: "gopls",
FileTypes: []string{".mod"},
},
},
}

runConfig := config.RuntimeConfig{
EnvProviderForTests: &noEnvProvider{},
}

got, warnings := getToolsForAgent(t.Context(), a, ".", &runConfig, NewDefaultToolsetRegistry())
require.Empty(t, warnings)

// Should have exactly one toolset (the multiplexer)
require.Len(t, got, 1)

// Verify that we get no duplicate tool names
allTools, err := got[0].Tools(t.Context())
require.NoError(t, err)

seen := make(map[string]bool)
for _, tool := range allTools {
assert.False(t, seen[tool.Name], "duplicate tool name: %s", tool.Name)
seen[tool.Name] = true
}

// Verify LSP tools are present
assert.True(t, seen["lsp_hover"])
assert.True(t, seen["lsp_definition"])
}

func TestGetToolsForAgent_SingleLSPToolsetNotWrapped(t *testing.T) {
t.Parallel()

a := &latest.AgentConfig{
Instruction: "test",
Toolsets: []latest.Toolset{
{
Type: "lsp",
Command: "gopls",
FileTypes: []string{".go"},
},
},
}

runConfig := config.RuntimeConfig{
EnvProviderForTests: &noEnvProvider{},
}

got, warnings := getToolsForAgent(t.Context(), a, ".", &runConfig, NewDefaultToolsetRegistry())
require.Empty(t, warnings)

// Should have exactly one toolset that provides LSP tools.
require.Len(t, got, 1)

allTools, err := got[0].Tools(t.Context())
require.NoError(t, err)

var names []string
for _, tool := range allTools {
names = append(names, tool.Name)
}
assert.Contains(t, names, "lsp_hover")
assert.Contains(t, names, "lsp_definition")
}

func TestExternalDepthContext(t *testing.T) {
t.Parallel()

Expand Down
27 changes: 19 additions & 8 deletions pkg/tools/builtin/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type lspHandler struct {
stdout *bufio.Reader
initialized atomic.Bool
requestID atomic.Int64
done chan struct{} // closed by stop() to signal background goroutines

// Configuration
command string
Expand Down Expand Up @@ -501,12 +502,22 @@ func (h *lspHandler) start(ctx context.Context) error {
h.mu.Lock()
defer h.mu.Unlock()

return h.startLocked(ctx)
}

// startLocked starts the LSP server process. The caller must hold h.mu.
func (h *lspHandler) startLocked(ctx context.Context) error {
if h.cmd != nil {
return errors.New("LSP server already running")
return nil
}

slog.Debug("Starting LSP server", "command", h.command, "args", h.args)

// Detach from the caller's context so the LSP process outlives the
// request or sub-session that triggered the start. The process is
// explicitly terminated by stop().
ctx = context.WithoutCancel(ctx)

cmd := exec.CommandContext(ctx, h.command, h.args...)
cmd.Env = append(os.Environ(), h.env...)
cmd.Dir = h.workingDir
Expand All @@ -533,8 +544,9 @@ func (h *lspHandler) start(ctx context.Context) error {
h.cmd = cmd
h.stdin = stdin
h.stdout = bufio.NewReader(stdout)
h.done = make(chan struct{})

go h.readNotifications(ctx, &stderrBuf)
go h.readNotifications(h.done, &stderrBuf)

slog.Debug("LSP server started successfully")
return nil
Expand All @@ -550,6 +562,8 @@ func (h *lspHandler) stop(_ context.Context) error {

slog.Debug("Stopping LSP server")

close(h.done)

if h.initialized.Load() {
_, _ = h.sendRequestLocked("shutdown", nil)
_ = h.sendNotificationLocked("exit", nil)
Expand Down Expand Up @@ -590,12 +604,9 @@ func (h *lspHandler) ensureInitialized(ctx context.Context) error {
}

if h.cmd == nil {
h.mu.Unlock()
if err := h.start(ctx); err != nil {
h.mu.Lock()
if err := h.startLocked(ctx); err != nil {
return fmt.Errorf("failed to start LSP server: %w", err)
}
h.mu.Lock()
}

if !h.initialized.Load() {
Expand Down Expand Up @@ -1455,13 +1466,13 @@ func (h *lspHandler) readMessageLocked() ([]byte, error) {
return body, nil
}

func (h *lspHandler) readNotifications(ctx context.Context, stderrBuf *bytes.Buffer) {
func (h *lspHandler) readNotifications(done <-chan struct{}, stderrBuf *bytes.Buffer) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
case <-done:
return
case <-ticker.C:
if stderrBuf.Len() > 0 {
Expand Down
Loading
Loading