Skip to content
Open
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
33 changes: 33 additions & 0 deletions internal/app/mcp_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"

"github.com/usewhale/whale/internal/core"
whalemcp "github.com/usewhale/whale/internal/mcp"
Expand Down Expand Up @@ -103,6 +104,38 @@ func (a *App) setupDeferredToolSearchLocked(catalog *whalemcp.DeferredToolCatalo
a.toolset.SetDeferredToolSearch(catAdapter, promoter, renderer)
}

const availableDeferredToolsMaxChars = 4000

// renderDeferredToolsBlock returns the <available-deferred-tools> block for
// injection into the system prompt. Returns empty string when no MCP tools are
// registered. The output is capped at availableDeferredToolsMaxChars to avoid
// blowing up the system prompt when many MCP tools are registered.
func (a *App) renderDeferredToolsBlock() string {
if a.mcpManager == nil {
return ""
}
catalog := a.mcpManager.BuildDeferredCatalog()
block := whalemcp.RenderAvailableDeferredTools(catalog)
if len(block) <= availableDeferredToolsMaxChars {
return block
}
// Truncate at a newline boundary near the limit.
truncated := block[:availableDeferredToolsMaxChars]
if idx := strings.LastIndex(truncated, "\n"); idx > 0 {
truncated = truncated[:idx]
}
// Count how many tools we omitted.
allTools := catalog.Names()
shownCount := 0
for _, name := range allTools {
if strings.Contains(truncated, name) {
shownCount++
}
}
omitted := len(allTools) - shownCount
return truncated + fmt.Sprintf("\n... %d more tool(s) omitted\n</available-deferred-tools>", omitted)
}

// makeDeferredPromoter returns a function that builds full Tool objects for given names,
// adds them to registries, and returns their specs.
func (a *App) makeDeferredPromoter() tools.DeferredToolPromoter {
Expand Down
83 changes: 83 additions & 0 deletions internal/app/mcp_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,86 @@ func TestRestorePromotedToolsBuildToolsError(t *testing.T) {
t.Fatal("expected error when BuildTools fails for non-existent tool, got nil")
}
}

// --- renderDeferredToolsBlock tests ---

func TestRenderDeferredToolsBlockNilManager(t *testing.T) {
app := &App{} // mcpManager is nil
if got := app.renderDeferredToolsBlock(); got != "" {
t.Fatalf("expected empty string for nil mcpManager, got %q", got)
}
}

func TestRenderDeferredToolsBlockWithTools(t *testing.T) {
mgr := newMCPRuntimeTestManager(t, "echoes a message")
app := &App{mcpManager: mgr}

block := app.renderDeferredToolsBlock()
if block == "" {
t.Fatal("expected non-empty block for populated catalog")
}
if !strings.Contains(block, "<available-deferred-tools>") {
t.Fatalf("expected opening tag, got %q", block)
}
if !strings.Contains(block, "</available-deferred-tools>") {
t.Fatalf("expected closing tag, got %q", block)
}
if !strings.Contains(block, "mcp__runtime__echo") {
t.Fatalf("expected tool name in block, got %q", block)
}
}

func TestRenderDeferredToolsBlockFormat(t *testing.T) {
mgr := newMCPRuntimeTestManager(t, "echoes a message")
app := &App{mcpManager: mgr}

block := app.renderDeferredToolsBlock()
if block == "" {
t.Fatal("expected non-empty block")
}
if !strings.HasPrefix(block, "<available-deferred-tools>\n") {
t.Fatalf("expected block to start with opening tag on its own line, got %q", block)
}
if !strings.HasSuffix(block, "</available-deferred-tools>") {
t.Fatalf("expected block to end with closing tag, got %q", block)
}
if !strings.Contains(block, "[server: ") {
t.Fatalf("expected server section, got %q", block)
}
if !strings.Contains(block, " — ") {
t.Fatalf("expected tool name/description separator, got %q", block)
}
}

func TestRenderDeferredToolsBlockTruncation(t *testing.T) {
// Simulate truncation by verifying that a block exceeding the limit
// is handled correctly. We test the truncation behaviour directly
// rather than through the full MCP pipeline (which has description
// length limits in the protocol).
longLine := strings.Repeat("x", availableDeferredToolsMaxChars+100)
block := "<available-deferred-tools>\n[server: test]\n mcp__test__tool — " + longLine + "\n</available-deferred-tools>"

// Apply the same truncation logic as renderDeferredToolsBlock.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test duplicates the truncation algorithm instead of calling renderDeferredToolsBlock, so it can stay green even if the production implementation regresses. Could we build a catalog whose rendered output exceeds the limit and assert against the actual method result? That would also let the test verify the reported omitted count and the real output-size behavior.

truncated := block
if len(truncated) > availableDeferredToolsMaxChars {
truncated = block[:availableDeferredToolsMaxChars]
if idx := strings.LastIndex(truncated, "\n"); idx > 0 {
truncated = truncated[:idx]
}
truncated += "\n... more tool(s) omitted\n</available-deferred-tools>"
}

if !strings.HasPrefix(truncated, "<available-deferred-tools>") {
t.Fatalf("truncated block should start with opening tag, got %q", truncated)
}
if !strings.Contains(truncated, "more tool(s) omitted") {
t.Fatalf("truncated block should contain omission notice, got %q", truncated)
}
if !strings.HasSuffix(truncated, "</available-deferred-tools>") {
t.Fatalf("truncated block should end with closing tag, got %q", truncated)
}
// Truncation should have removed content.
if strings.Contains(truncated, "xxx") {
t.Fatal("truncated block should not contain the overflow content")
}
}
1 change: 1 addition & 0 deletions internal/app/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func (a *App) ensureAgent() (*agent.Agent, error) {
agent.WithHookRunner(a.hookRunner),
agent.WithExtraSystemBlocks(pluginBlocks...),
agent.WithDynamicSystemBlocksForTurn(a.workflowDynamicSystemBlock),
agent.WithDynamicSystemBlocks(func() string { return a.renderDeferredToolsBlock() }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WithDynamicSystemBlocks replaces a.dynamicSystemBlocks instead of appending to it, so this call drops the workflowDynamicSystemBlock registered on the previous line. That removes the workflow runtime guidance, authoring rules, and prompt catalog from every turn. I reproduced this with an option-composition test: the deferred-tools block remains, but the workflow block is missing. Could we register both renderers in a single WithDynamicSystemBlocksForTurn call, or otherwise make the option composition append safely, and add a regression test that asserts both blocks are present?

agent.WithProjectMemory(a.cfg.MemoryEnabled, a.cfg.MemoryMaxChars, parseCSVList(a.cfg.MemoryFileOrder), a.workspaceRoot),
agent.WithWorktreeContext(a.worktree.Path, a.worktree.OriginalWorkspace),
agent.WithMaxParallelSubagents(a.cfg.MaxParallelSubagents),
Expand Down
3 changes: 0 additions & 3 deletions internal/mcp/deferred.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,6 @@ func (c *DeferredToolCatalog) selectSearch(names string) []DeferredToolMeta {
for _, t := range c.tools {
if wanted[t.Name] {
results = append(results, t)
if len(results) >= maxSearchResults {
break
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes exact select: queries to return an unbounded number of explicitly requested tools, but the tool_search schema still says that the tool returns up to five matching tools. Could we update that description to clarify that only keyword and must-have searches are capped, while select: returns every requested match?

}
return results
Expand Down
23 changes: 23 additions & 0 deletions internal/mcp/deferred_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,29 @@ func TestSearchSelectFormEmptyNames(t *testing.T) {
}
}

func TestSearchSelectFormNoCap(t *testing.T) {
// select: queries should not be capped at maxSearchResults — the user
// explicitly requested those names, so all should be returned.
tools := make([]DeferredToolMeta, 0, maxSearchResults+5)
for i := range maxSearchResults + 5 {
tools = append(tools, testCatalogMeta(
"mcp__s__tool_"+string(rune('a'+i%26))+string(rune('a'+i/26)),
"s",
"",
))
}
c := NewDeferredToolCatalog(tools)
// Build a select: query with all tool names.
names := make([]string, len(tools))
for i, t := range tools {
names[i] = t.Name
}
results := c.Search("select:" + strings.Join(names, ","))
if len(results) != len(tools) {
t.Fatalf("select should return all %d requested tools, got %d", len(tools), len(results))
}
}

func TestSearchMustHaveForm(t *testing.T) {
c := NewDeferredToolCatalog([]DeferredToolMeta{
testCatalogMeta("mcp__github__search_code", "github", "search code in repos"),
Expand Down
6 changes: 1 addition & 5 deletions internal/tools/catalog_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,7 @@ Returns up to 5 matching tools and activates them for subsequent calls.`,
names := catalog.Names()
hint := ""
if len(names) > 0 {
sample := names
if len(sample) > 5 {
sample = sample[:5]
}
hint = fmt.Sprintf(" No match. Available deferred tools (showing first 5 of %d): %s", len(names), strings.Join(sample, ", "))
hint = fmt.Sprintf(" No match. Available deferred tools (%d total): %s", len(names), strings.Join(names, ", "))
}
return core.ToolResult{
ToolCallID: call.ID,
Expand Down
3 changes: 3 additions & 0 deletions internal/tools/catalog_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ func TestToolSearchNoMatch(t *testing.T) {
if !strings.Contains(result.ModelText, "mcp__s__alpha") {
t.Fatalf("expected hint with available tool names, got %q", result.ModelText)
}
if !strings.Contains(result.ModelText, "(1 total)") {
t.Fatalf("expected hint with total count, got %q", result.ModelText)
}
}

func TestToolSearchWithoutPromoterReturnsInfo(t *testing.T) {
Expand Down
Loading