Merge branch 'upstream-main' into feat/subturn-poc

This commit is contained in:
Administrator
2026-03-19 09:16:38 +08:00
16 changed files with 1247 additions and 40 deletions
+63 -8
View File
@@ -188,15 +188,48 @@ func (r *ToolRegistry) ExecuteWithContext(
// The callback is a call parameter, not mutable state on the tool instance.
var result *ToolResult
start := time.Now()
if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {
logger.DebugCF("tool", "Executing async tool via ExecuteAsync",
map[string]any{
"tool": name,
})
result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
} else {
result = tool.Execute(ctx, args)
// Use recover to catch any panics during tool execution
// This prevents tool crashes from killing the entire agent
func() {
defer func() {
if re := recover(); re != nil {
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
logger.ErrorCF("tool", "Tool execution panic recovered",
map[string]any{
"tool": name,
"panic": fmt.Sprintf("%v", re),
})
result = &ToolResult{
ForLLM: errMsg,
ForUser: errMsg,
IsError: true,
Err: fmt.Errorf("panic: %v", re),
}
}
}()
if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {
logger.DebugCF("tool", "Executing async tool via ExecuteAsync",
map[string]any{
"tool": name,
})
result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
} else {
result = tool.Execute(ctx, args)
}
}()
// Handle nil result (should not happen, but defensive)
if result == nil {
result = &ToolResult{
ForLLM: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name),
ForUser: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name),
IsError: true,
Err: fmt.Errorf("nil result from tool"),
}
}
duration := time.Since(start)
// Log based on result type
@@ -303,6 +336,28 @@ func (r *ToolRegistry) List() []string {
return r.sortedToolNames()
}
// Clone creates an independent copy of the registry containing the same tool
// entries (shallow copy of each ToolEntry). This is used to give subagents a
// snapshot of the parent agent's tools without sharing the same registry —
// tools registered on the parent after cloning (e.g. spawn, spawn_status)
// will NOT be visible to the clone, preventing recursive subagent spawning.
// The version counter is reset to 0 in the clone as it's a new independent registry.
func (r *ToolRegistry) Clone() *ToolRegistry {
r.mu.RLock()
defer r.mu.RUnlock()
clone := &ToolRegistry{
tools: make(map[string]*ToolEntry, len(r.tools)),
}
for name, entry := range r.tools {
clone.tools[name] = &ToolEntry{
Tool: entry.Tool,
IsCore: entry.IsCore,
TTL: entry.TTL,
}
}
return clone
}
// Count returns the number of registered tools.
func (r *ToolRegistry) Count() int {
r.mu.RLock()
+263
View File
@@ -2,6 +2,7 @@ package tools
import (
"context"
"errors"
"strings"
"sync"
"testing"
@@ -335,6 +336,96 @@ func TestToolToSchema(t *testing.T) {
}
}
func TestToolRegistry_Clone(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("read_file", "reads files"))
r.Register(newMockTool("exec", "runs commands"))
r.Register(newMockTool("web_search", "searches the web"))
clone := r.Clone()
// Clone should have the same tools
if clone.Count() != 3 {
t.Errorf("expected clone to have 3 tools, got %d", clone.Count())
}
for _, name := range []string{"read_file", "exec", "web_search"} {
if _, ok := clone.Get(name); !ok {
t.Errorf("expected clone to have tool %q", name)
}
}
// Registering on parent should NOT affect clone
r.Register(newMockTool("spawn", "spawns subagent"))
if r.Count() != 4 {
t.Errorf("expected parent to have 4 tools, got %d", r.Count())
}
if clone.Count() != 3 {
t.Errorf("expected clone to still have 3 tools after parent mutation, got %d", clone.Count())
}
if _, ok := clone.Get("spawn"); ok {
t.Error("expected clone NOT to have 'spawn' tool registered on parent after cloning")
}
// Registering on clone should NOT affect parent
clone.Register(newMockTool("custom", "custom tool"))
if clone.Count() != 4 {
t.Errorf("expected clone to have 4 tools, got %d", clone.Count())
}
if _, ok := r.Get("custom"); ok {
t.Error("expected parent NOT to have 'custom' tool registered on clone")
}
}
func TestToolRegistry_Clone_Empty(t *testing.T) {
r := NewToolRegistry()
clone := r.Clone()
if clone.Count() != 0 {
t.Errorf("expected empty clone, got count %d", clone.Count())
}
}
func TestToolRegistry_Clone_PreservesHiddenToolState(t *testing.T) {
r := NewToolRegistry()
r.RegisterHidden(newMockTool("mcp_tool", "dynamic MCP tool"))
clone := r.Clone()
// Hidden tools with TTL=0 should not be gettable (same behavior as parent)
if _, ok := clone.Get("mcp_tool"); ok {
t.Error("expected hidden tool with TTL=0 to be invisible in clone")
}
// But the entry should exist (count includes hidden tools)
if clone.Count() != 1 {
t.Errorf("expected clone count 1 (hidden entry exists), got %d", clone.Count())
}
}
func TestToolRegistry_Clone_PreservesTTLValue(t *testing.T) {
r := NewToolRegistry()
r.RegisterHidden(newMockTool("ttl_tool", "tool with TTL"))
// Manually set a non-zero TTL on the entry
r.mu.RLock()
if entry, ok := r.tools["ttl_tool"]; ok {
entry.TTL = 5
}
r.mu.RUnlock()
clone := r.Clone()
// Verify TTL value is preserved in the clone
clone.mu.RLock()
defer clone.mu.RUnlock()
entry, ok := clone.tools["ttl_tool"]
if !ok {
t.Fatal("expected ttl_tool to exist in clone")
}
if entry.TTL != 5 {
t.Errorf("expected TTL=5 in clone, got %d", entry.TTL)
}
}
func TestToolRegistry_ConcurrentAccess(t *testing.T) {
r := NewToolRegistry()
var wg sync.WaitGroup
@@ -358,3 +449,175 @@ func TestToolRegistry_ConcurrentAccess(t *testing.T) {
t.Error("expected tools to be registered after concurrent access")
}
}
// --- Panic and abnormal exit tests ---
// mockPanicTool is a tool that panics during execution
type mockPanicTool struct {
name string
panicValue any
}
func (m *mockPanicTool) Name() string { return m.name }
func (m *mockPanicTool) Description() string { return "a tool that panics" }
func (m *mockPanicTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
func (m *mockPanicTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
panic(m.panicValue)
}
// mockNilResultTool is a tool that returns nil
type mockNilResultTool struct {
name string
}
func (m *mockNilResultTool) Name() string { return m.name }
func (m *mockNilResultTool) Description() string { return "a tool that returns nil" }
func (m *mockNilResultTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
func (m *mockNilResultTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
return nil
}
func TestToolRegistry_Execute_PanicRecovery(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockPanicTool{
name: "panic_tool",
panicValue: "something went terribly wrong",
})
// Should not panic, should return error result
result := r.Execute(context.Background(), "panic_tool", nil)
if result == nil {
t.Fatal("expected non-nil result after panic recovery")
}
if !result.IsError {
t.Error("expected IsError=true after panic")
}
if !strings.Contains(result.ForLLM, "panic") {
t.Errorf("expected 'panic' in error message, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "panic_tool") {
t.Errorf("expected tool name in error message, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "something went terribly wrong") {
t.Errorf("expected panic value in error message, got %q", result.ForLLM)
}
if result.Err == nil {
t.Error("expected Err to be set")
}
}
func TestToolRegistry_Execute_PanicRecovery_ErrorType(t *testing.T) {
r := NewToolRegistry()
// Test with error type panic
r.Register(&mockPanicTool{
name: "error_panic_tool",
panicValue: errors.New("custom error panic"),
})
result := r.Execute(context.Background(), "error_panic_tool", nil)
if !result.IsError {
t.Error("expected IsError=true")
}
if !strings.Contains(result.ForLLM, "custom error panic") {
t.Errorf("expected error message in ForLLM, got %q", result.ForLLM)
}
}
func TestToolRegistry_Execute_PanicRecovery_IntType(t *testing.T) {
r := NewToolRegistry()
// Test with int type panic
r.Register(&mockPanicTool{
name: "int_panic_tool",
panicValue: 42,
})
result := r.Execute(context.Background(), "int_panic_tool", nil)
if !result.IsError {
t.Error("expected IsError=true")
}
if !strings.Contains(result.ForLLM, "42") {
t.Errorf("expected panic value '42' in ForLLM, got %q", result.ForLLM)
}
}
func TestToolRegistry_Execute_NilResultHandling(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockNilResultTool{name: "nil_tool"})
result := r.Execute(context.Background(), "nil_tool", nil)
if result == nil {
t.Fatal("expected non-nil result when tool returns nil")
}
if !result.IsError {
t.Error("expected IsError=true for nil result")
}
if !strings.Contains(result.ForLLM, "nil_tool") {
t.Errorf("expected tool name in error message, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "nil result") {
t.Errorf("expected 'nil result' in error message, got %q", result.ForLLM)
}
if result.Err == nil {
t.Error("expected Err to be set")
}
}
func TestToolRegistry_ExecuteWithContext_PanicRecovery(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockPanicTool{
name: "ctx_panic_tool",
panicValue: "context panic test",
})
// Should not panic even with context
result := r.ExecuteWithContext(
context.Background(),
"ctx_panic_tool",
map[string]any{"key": "value"},
"telegram",
"chat-123",
nil,
)
if result == nil {
t.Fatal("expected non-nil result")
}
if !result.IsError {
t.Error("expected IsError=true")
}
if !strings.Contains(result.ForLLM, "context panic test") {
t.Errorf("expected panic message, got %q", result.ForLLM)
}
}
func TestToolRegistry_Execute_PanicDoesNotAffectOtherTools(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockPanicTool{name: "bad_tool", panicValue: "boom"})
r.Register(&mockRegistryTool{
name: "good_tool",
desc: "works fine",
params: map[string]any{},
result: SilentResult("success"),
})
// First, trigger the panic
result1 := r.Execute(context.Background(), "bad_tool", nil)
if !result1.IsError {
t.Error("expected error from panic tool")
}
// Then, verify the good tool still works
result2 := r.Execute(context.Background(), "good_tool", nil)
if result2.IsError {
t.Errorf("expected success from good tool, got error: %s", result2.ForLLM)
}
if result2.ForLLM != "success" {
t.Errorf("expected 'success', got %q", result2.ForLLM)
}
}
+18 -1
View File
@@ -311,13 +311,30 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
if err != nil {
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
msg := fmt.Sprintf("Command timed out after %v", t.timeout)
if output != "" {
msg += "\n\nPartial output before timeout:\n" + output
}
return &ToolResult{
ForLLM: msg,
ForUser: msg,
IsError: true,
Err: fmt.Errorf("command timeout: %w", err),
}
}
output += fmt.Sprintf("\nExit code: %v", err)
// Extract detailed exit information
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode := exitErr.ExitCode()
output += fmt.Sprintf("\n\n[Command exited with code %d]", exitCode)
// Add signal information if killed by signal (Unix)
if exitCode == -1 {
output += " (killed by signal)"
}
} else {
output += fmt.Sprintf("\n\n[Command failed: %v]", err)
}
}
if output == "" {
+63
View File
@@ -489,6 +489,69 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) {
}
}
// TestShellTool_ExitCodeDetails verifies that exit codes are captured with details
func TestShellTool_ExitCodeDetails(t *testing.T) {
tool, err := NewExecTool("", false)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
ctx := context.Background()
args := map[string]any{
"command": "sh -c 'exit 42'",
}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Error("expected error for non-zero exit code")
}
// Should contain the exit code in the message (new format: "exited with code 42")
if !strings.Contains(result.ForLLM, "42") {
t.Errorf("expected exit code 42 in error message, got: %s", result.ForLLM)
}
// Verify the new detailed message format
if !strings.Contains(result.ForLLM, "exited with code") {
t.Errorf("expected 'exited with code' in message, got: %s", result.ForLLM)
}
// Err field is set by the exec system (may or may not be set depending on implementation)
// The important thing is that IsError=true
t.Logf("Exit code result: %s", result.ForLLM)
}
// TestShellTool_TimeoutWithPartialOutput verifies timeout includes partial output
func TestShellTool_TimeoutWithPartialOutput(t *testing.T) {
tool, err := NewExecTool("", false)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
tool.SetTimeout(1 * time.Second) // Give more time for echo to complete
ctx := context.Background()
// Use a command that outputs immediately then sleeps
args := map[string]any{
"command": "echo 'partial output before timeout' && sleep 30",
}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Error("expected error for timeout")
}
// Should mention timeout
if !strings.Contains(result.ForLLM, "timed out") {
t.Errorf("expected 'timed out' in message, got: %s", result.ForLLM)
}
// Log the result for debugging (partial output depends on shell behavior)
t.Logf("Timeout result: %s", result.ForLLM)
}
// TestShellTool_CustomAllowPatterns verifies that custom allow patterns exempt
// commands from deny pattern checks.
func TestShellTool_CustomAllowPatterns(t *testing.T) {