mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ffb8ae8d2 | |||
| 85dcfccad6 | |||
| 9a0efd7bab | |||
| 0132837d22 | |||
| a680d8fcfa | |||
| c87b154b61 | |||
| 994c0aea11 | |||
| b6e6d25d75 | |||
| 8f891d5dd0 | |||
| 0f4a997b47 | |||
| 79c075fba0 | |||
| ffffb6a0cb | |||
| c4fb7a2001 | |||
| 9068fde274 | |||
| ce4a6c9bba |
@@ -30,7 +30,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -333,6 +333,15 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||
if opts == nil {
|
||||
return fmt.Errorf("process options not available")
|
||||
}
|
||||
// /clear can arrive before any turn has persisted session scope
|
||||
// metadata (runAgentLoop records it per turn), so record it here to
|
||||
// let the ContextManager resolve which agent owns the session.
|
||||
ensureSessionMetadata(
|
||||
agent.Sessions,
|
||||
opts.Dispatch.SessionKey,
|
||||
opts.Dispatch.SessionScope,
|
||||
opts.Dispatch.SessionAliases,
|
||||
)
|
||||
return al.contextManager.Clear(ctx, opts.SessionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -3365,6 +3365,105 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_ClearCommandClearsRoutedAgentSession(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: filepath.Join(workspace, "default"),
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
List: []config.AgentConfig{
|
||||
{
|
||||
ID: "main",
|
||||
Default: true,
|
||||
Workspace: filepath.Join(workspace, "main"),
|
||||
},
|
||||
{
|
||||
ID: "support",
|
||||
Workspace: filepath.Join(workspace, "support"),
|
||||
},
|
||||
},
|
||||
Dispatch: &config.DispatchConfig{
|
||||
Rules: []config.DispatchRule{
|
||||
{
|
||||
Name: "support-dingtalk",
|
||||
Agent: "support",
|
||||
When: config.DispatchSelector{
|
||||
Channel: "dingtalk",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Session: config.SessionConfig{
|
||||
Dimensions: []string{"chat"},
|
||||
},
|
||||
}
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &countingMockProvider{response: "LLM reply"})
|
||||
mainAgent, ok := al.registry.GetAgent("main")
|
||||
if !ok {
|
||||
t.Fatal("expected main agent")
|
||||
}
|
||||
supportAgent, ok := al.registry.GetAgent("support")
|
||||
if !ok {
|
||||
t.Fatal("expected support agent")
|
||||
}
|
||||
|
||||
msg := testInboundMessage(bus.InboundMessage{
|
||||
Context: bus.InboundContext{
|
||||
Channel: "dingtalk",
|
||||
ChatID: "chat1",
|
||||
ChatType: "direct",
|
||||
SenderID: "user1",
|
||||
},
|
||||
Content: "/clear",
|
||||
})
|
||||
route, routedAgent, err := al.resolveMessageRoute(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||
}
|
||||
if routedAgent != supportAgent {
|
||||
t.Fatalf("routed agent = %s, want support", routedAgent.ID)
|
||||
}
|
||||
sessionKey := al.allocateRouteSession(route, msg).SessionKey
|
||||
|
||||
mainHistory := []providers.Message{{Role: "user", Content: "main history"}}
|
||||
supportHistory := []providers.Message{{Role: "user", Content: "support history"}}
|
||||
mainAgent.Sessions.SetHistory(sessionKey, mainHistory)
|
||||
mainAgent.Sessions.SetSummary(sessionKey, "main summary")
|
||||
supportAgent.Sessions.SetHistory(sessionKey, supportHistory)
|
||||
supportAgent.Sessions.SetSummary(sessionKey, "support summary")
|
||||
|
||||
response, err := al.processMessage(context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "Chat history cleared!" {
|
||||
t.Fatalf("response = %q, want clear confirmation", response)
|
||||
}
|
||||
|
||||
if got := supportAgent.Sessions.GetHistory(sessionKey); len(got) != 0 {
|
||||
t.Fatalf("support history len = %d, want 0", len(got))
|
||||
}
|
||||
if got := supportAgent.Sessions.GetSummary(sessionKey); got != "" {
|
||||
t.Fatalf("support summary = %q, want empty", got)
|
||||
}
|
||||
if got := mainAgent.Sessions.GetHistory(sessionKey); len(got) != len(mainHistory) {
|
||||
t.Fatalf("main history len = %d, want %d", len(got), len(mainHistory))
|
||||
} else if got[0].Role != mainHistory[0].Role {
|
||||
t.Fatalf("main history[0].Role = %q, want %q", got[0].Role, mainHistory[0].Role)
|
||||
} else if got[0].Content != mainHistory[0].Content {
|
||||
t.Fatalf("main history[0].Content = %q, want %q", got[0].Content, mainHistory[0].Content)
|
||||
}
|
||||
if got := mainAgent.Sessions.GetSummary(sessionKey); got != "main summary" {
|
||||
t.Fatalf("main summary = %q, want %q", got, "main summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
|
||||
@@ -63,7 +63,9 @@ func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error
|
||||
}
|
||||
|
||||
func (m *legacyContextManager) Clear(_ context.Context, sessionKey string) error {
|
||||
agent := m.al.registry.GetDefaultAgent()
|
||||
// Routed (non-default) agents keep history in their own session store,
|
||||
// so resolve the owning agent instead of assuming the default one.
|
||||
agent := m.al.agentForSession(sessionKey)
|
||||
if agent == nil || agent.Sessions == nil {
|
||||
return fmt.Errorf("sessions not initialized")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -658,6 +659,91 @@ func TestIngestCalledDuringTurn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCommandRoutedAgentCallsContextManagerClear(t *testing.T) {
|
||||
cleanup := resetCMRegistry()
|
||||
defer cleanup()
|
||||
|
||||
mock := &trackingContextManager{}
|
||||
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||
return mock, nil
|
||||
}
|
||||
if err := RegisterContextManager("clear_track_cm", factory); err != nil {
|
||||
t.Fatalf("register failed: %v", err)
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: filepath.Join(workspace, "default"),
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
ContextManager: "clear_track_cm",
|
||||
},
|
||||
List: []config.AgentConfig{
|
||||
{
|
||||
ID: "main",
|
||||
Default: true,
|
||||
Workspace: filepath.Join(workspace, "main"),
|
||||
},
|
||||
{
|
||||
ID: "support",
|
||||
Workspace: filepath.Join(workspace, "support"),
|
||||
},
|
||||
},
|
||||
Dispatch: &config.DispatchConfig{
|
||||
Rules: []config.DispatchRule{
|
||||
{
|
||||
Name: "support-dingtalk",
|
||||
Agent: "support",
|
||||
When: config.DispatchSelector{
|
||||
Channel: "dingtalk",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Session: config.SessionConfig{
|
||||
Dimensions: []string{"chat"},
|
||||
},
|
||||
}
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &simpleMockProvider{response: "done"})
|
||||
if al.contextManager != mock {
|
||||
t.Fatalf("expected mock context manager, got %T", al.contextManager)
|
||||
}
|
||||
|
||||
msg := testInboundMessage(bus.InboundMessage{
|
||||
Context: bus.InboundContext{
|
||||
Channel: "dingtalk",
|
||||
ChatID: "chat1",
|
||||
ChatType: "direct",
|
||||
SenderID: "user1",
|
||||
},
|
||||
Content: "/clear",
|
||||
})
|
||||
route, _, err := al.resolveMessageRoute(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||
}
|
||||
sessionKey := al.allocateRouteSession(route, msg).SessionKey
|
||||
|
||||
if _, err := al.processMessage(context.Background(), msg); err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
|
||||
if got := mock.clearCalls.Load(); got != 1 {
|
||||
t.Fatalf("Clear calls = %d, want 1", got)
|
||||
}
|
||||
mock.mu.Lock()
|
||||
gotKey := mock.lastClearKey
|
||||
mock.mu.Unlock()
|
||||
if gotKey != sessionKey {
|
||||
t.Fatalf("Clear session key = %q, want %q", gotKey, sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forceCompression edge cases (via legacy Compact)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -712,10 +798,12 @@ type trackingContextManager struct {
|
||||
assembleCalls atomic.Int64
|
||||
compactCalls atomic.Int64
|
||||
ingestCalls atomic.Int64
|
||||
clearCalls atomic.Int64
|
||||
mu sync.Mutex
|
||||
lastAssemble *AssembleRequest
|
||||
lastCompact *CompactRequest
|
||||
lastIngest *IngestRequest
|
||||
lastClearKey string
|
||||
}
|
||||
|
||||
func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
||||
@@ -742,7 +830,13 @@ func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *trackingContextManager) Clear(_ context.Context, _ string) error { return nil }
|
||||
func (m *trackingContextManager) Clear(_ context.Context, sessionKey string) error {
|
||||
m.clearCalls.Add(1)
|
||||
m.mu.Lock()
|
||||
m.lastClearKey = sessionKey
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetCMRegistry clears the global factory registry and returns a cleanup
|
||||
// function that restores the original state after the test.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
type seahorseContextManager struct {
|
||||
engine *seahorse.Engine
|
||||
sessions session.SessionStore // for startup bootstrap
|
||||
al *AgentLoop // for resolving the agent that owns a session
|
||||
}
|
||||
|
||||
// newSeahorseContextManager creates a seahorse-backed ContextManager.
|
||||
@@ -47,6 +48,7 @@ func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager
|
||||
mgr := &seahorseContextManager{
|
||||
engine: engine,
|
||||
sessions: agent.Sessions,
|
||||
al: al,
|
||||
}
|
||||
|
||||
// Register seahorse tools with the agent's tool registry
|
||||
@@ -160,10 +162,18 @@ func (m *seahorseContextManager) Clear(ctx context.Context, sessionKey string) e
|
||||
if err := m.engine.ClearSession(ctx, sessionKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.sessions != nil {
|
||||
m.sessions.SetHistory(sessionKey, []providers.Message{})
|
||||
m.sessions.SetSummary(sessionKey, "")
|
||||
return m.sessions.Save(sessionKey)
|
||||
// The session may belong to a routed (non-default) agent whose JSONL
|
||||
// store differs from the bootstrap store, so clear the owner's store.
|
||||
sessions := m.sessions
|
||||
if m.al != nil {
|
||||
if agent := m.al.agentForSession(sessionKey); agent != nil && agent.Sessions != nil {
|
||||
sessions = agent.Sessions
|
||||
}
|
||||
}
|
||||
if sessions != nil {
|
||||
sessions.SetHistory(sessionKey, []providers.Message{})
|
||||
sessions.SetSummary(sessionKey, "")
|
||||
return sessions.Save(sessionKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+18
-8
@@ -103,8 +103,25 @@ func NewAgentInstance(
|
||||
toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||
}
|
||||
}
|
||||
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||
}
|
||||
if cfg.Tools.IsToolEnabled("append_file") {
|
||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||
}
|
||||
// Build write_file's copy from the registered editors so it steers the agent
|
||||
// to edit_file/append_file only when those tools are actually available.
|
||||
if cfg.Tools.IsToolEnabled("write_file") {
|
||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||
writeTool := tools.NewWriteFileTool(workspace, restrict, allowWritePaths)
|
||||
var altTools []string
|
||||
if toolsRegistry.HasRegistered("append_file") {
|
||||
altTools = append(altTools, "append_file")
|
||||
}
|
||||
if toolsRegistry.HasRegistered("edit_file") {
|
||||
altTools = append(altTools, "edit_file")
|
||||
}
|
||||
writeTool.SetAlternativeTools(altTools)
|
||||
toolsRegistry.Register(writeTool)
|
||||
}
|
||||
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||
@@ -119,13 +136,6 @@ func NewAgentInstance(
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||
}
|
||||
if cfg.Tools.IsToolEnabled("append_file") {
|
||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||
}
|
||||
|
||||
sessionsDir := filepath.Join(workspace, "sessions")
|
||||
sessions := initSessionStore(sessionsDir)
|
||||
|
||||
|
||||
@@ -584,6 +584,102 @@ func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// write_file copy names append_file/edit_file only when they are registered.
|
||||
func TestNewAgentInstance_WriteFileCopyReflectsAvailableAltTools(t *testing.T) {
|
||||
newCfg := func(editEnabled, appendEnabled bool) *config.Config {
|
||||
return &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: t.TempDir(),
|
||||
ModelName: "test-model",
|
||||
},
|
||||
},
|
||||
Tools: config.ToolsConfig{
|
||||
WriteFile: config.ToolConfig{Enabled: true},
|
||||
EditFile: config.ToolConfig{Enabled: editEnabled},
|
||||
AppendFile: config.ToolConfig{Enabled: appendEnabled},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
writeToolDesc := func(cfg *config.Config) string {
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||
writeTool, ok := agent.Tools.Get("write_file")
|
||||
if !ok {
|
||||
t.Fatal("write_file tool not registered")
|
||||
}
|
||||
return writeTool.Description()
|
||||
}
|
||||
|
||||
t.Run("only write_file exposed", func(t *testing.T) {
|
||||
desc := writeToolDesc(newCfg(false, false))
|
||||
if strings.Contains(desc, "append_file") || strings.Contains(desc, "edit_file") {
|
||||
t.Fatalf("write_file must not reference unavailable tools, got: %q", desc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only append_file exposed", func(t *testing.T) {
|
||||
desc := writeToolDesc(newCfg(false, true))
|
||||
if !strings.Contains(desc, "append_file") {
|
||||
t.Fatalf("expected write_file to reference append_file, got: %q", desc)
|
||||
}
|
||||
if strings.Contains(desc, "edit_file") {
|
||||
t.Fatalf("write_file must not reference disabled edit_file, got: %q", desc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both exposed", func(t *testing.T) {
|
||||
desc := writeToolDesc(newCfg(true, true))
|
||||
if !strings.Contains(desc, "append_file") || !strings.Contains(desc, "edit_file") {
|
||||
t.Fatalf("expected write_file to reference both alternatives, got: %q", desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Availability follows the per-agent allowlist, not just the enable flag:
|
||||
// editors enabled globally but hidden by frontmatter must not be named.
|
||||
func TestNewAgentInstance_WriteFileCopyExcludesAllowlistHiddenAltTools(t *testing.T) {
|
||||
workspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": "---\ntools: [write_file]\n---\n# Agent\n",
|
||||
})
|
||||
defer cleanupWorkspace(t, workspace)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: workspace,
|
||||
ModelName: "test-model",
|
||||
},
|
||||
},
|
||||
Tools: config.ToolsConfig{
|
||||
WriteFile: config.ToolConfig{Enabled: true},
|
||||
EditFile: config.ToolConfig{Enabled: true},
|
||||
AppendFile: config.ToolConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
agent := NewAgentInstance(&config.AgentConfig{
|
||||
ID: "restricted",
|
||||
Workspace: workspace,
|
||||
}, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||
|
||||
if _, ok := agent.Tools.Get("edit_file"); ok {
|
||||
t.Fatal("edit_file should be blocked by the allowlist")
|
||||
}
|
||||
if _, ok := agent.Tools.Get("append_file"); ok {
|
||||
t.Fatal("append_file should be blocked by the allowlist")
|
||||
}
|
||||
|
||||
writeTool, ok := agent.Tools.Get("write_file")
|
||||
if !ok {
|
||||
t.Fatal("write_file tool not registered")
|
||||
}
|
||||
if desc := writeTool.Description(); strings.Contains(desc, "append_file") ||
|
||||
strings.Contains(desc, "edit_file") {
|
||||
t.Fatalf("write_file must not name allowlist-hidden tools, got: %q", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
|
||||
@@ -69,7 +69,8 @@ func NewLINEChannel(
|
||||
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err)
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
|
||||
base := channels.NewBaseChannel(
|
||||
"line", cfg, messageBus, bc.AllowFrom,
|
||||
channels.WithMaxMessageLength(5000),
|
||||
channels.WithGroupTrigger(bc.GroupTrigger),
|
||||
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
||||
@@ -482,7 +483,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||
Messages: []messaging_api.MessageInterface{&textMsg},
|
||||
})
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
||||
@@ -588,7 +589,7 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e
|
||||
// classifySDKError maps an SDK HTTP response to the project's sentinel errors.
|
||||
func classifySDKError(resp *http.Response, err error) error {
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -181,8 +181,15 @@ func buildParams(
|
||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
// Resolve tool name: prefer tc.Name, fallback to tc.Function.Name
|
||||
// (tc.Name/tc.Arguments are json:"-" and may be empty when
|
||||
// history is reloaded from the session store)
|
||||
toolName := tc.Name
|
||||
if toolName == "" && tc.Function != nil {
|
||||
toolName = tc.Function.Name
|
||||
}
|
||||
// Skip tool calls with empty names to avoid API errors
|
||||
if tc.Name == "" {
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
args := tc.Arguments
|
||||
@@ -194,7 +201,7 @@ func buildParams(
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name))
|
||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, toolName))
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -77,6 +78,124 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildParams_ToolCallFunctionFallback verifies that tool calls whose
|
||||
// runtime-only fields were lost in a JSON round-trip through the session store
|
||||
// (ToolCall.Name/Arguments are json:"-"; only ToolCall.Function survives) fall
|
||||
// back to Function.Name / Function.Arguments, so the tool_use block is still
|
||||
// emitted and its tool_result pair stays intact. Without the fallback the
|
||||
// tool_use is skipped and the orphaned tool_result 400s at the API
|
||||
// ("unexpected tool_use_id found in tool_result blocks").
|
||||
func TestBuildParams_ToolCallFunctionFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolCall ToolCall
|
||||
wantSkipped bool
|
||||
wantToolName string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
name: "deserialized history shape falls back to Function fields",
|
||||
toolCall: ToolCall{
|
||||
ID: "toolu-fallback-1",
|
||||
Name: "",
|
||||
Arguments: nil,
|
||||
Function: &FunctionCall{Name: "x", Arguments: `{"a":1}`},
|
||||
},
|
||||
wantToolName: "x",
|
||||
wantInput: map[string]any{"a": float64(1)},
|
||||
},
|
||||
{
|
||||
name: "runtime shape with Name set and Function nil still works",
|
||||
toolCall: ToolCall{
|
||||
ID: "toolu-runtime-1",
|
||||
Name: "y",
|
||||
Arguments: map[string]any{"b": 2},
|
||||
},
|
||||
wantToolName: "y",
|
||||
wantInput: map[string]any{"b": 2},
|
||||
},
|
||||
{
|
||||
name: "both Name and Function.Name empty is skipped",
|
||||
toolCall: ToolCall{
|
||||
ID: "toolu-empty-1",
|
||||
Name: "",
|
||||
Function: &FunctionCall{Name: "", Arguments: `{"c":3}`},
|
||||
},
|
||||
wantSkipped: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "run the tool"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{tt.toolCall}},
|
||||
{Role: "tool", ToolCallID: tt.toolCall.ID, Content: "result"},
|
||||
}
|
||||
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
if len(params.Messages) != 3 {
|
||||
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
||||
}
|
||||
|
||||
assistantMsg := params.Messages[1]
|
||||
var toolUses []*anthropic.ToolUseBlockParam
|
||||
for _, block := range assistantMsg.Content {
|
||||
if block.OfToolUse != nil {
|
||||
toolUses = append(toolUses, block.OfToolUse)
|
||||
}
|
||||
}
|
||||
|
||||
// The tool_result in the following user message always carries the
|
||||
// original ID; look it up once for both branches.
|
||||
toolResultMsg := params.Messages[2]
|
||||
if len(toolResultMsg.Content) != 1 || toolResultMsg.Content[0].OfToolResult == nil {
|
||||
t.Fatalf("message after assistant = %+v, want single tool_result block", toolResultMsg.Content)
|
||||
}
|
||||
toolResult := toolResultMsg.Content[0].OfToolResult
|
||||
|
||||
if tt.wantSkipped {
|
||||
if len(toolUses) != 0 {
|
||||
t.Fatalf("tool_use blocks = %d, want 0 (tool call skipped)", len(toolUses))
|
||||
}
|
||||
// Note: matching current behavior, the orphaned tool_result is
|
||||
// still emitted even though its tool_use block was skipped.
|
||||
if toolResult.ToolUseID != tt.toolCall.ID {
|
||||
t.Fatalf("orphaned tool_result ToolUseID = %q, want %q",
|
||||
toolResult.ToolUseID, tt.toolCall.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// (a) tool_use block emitted with resolved name, id, and parsed input.
|
||||
if len(toolUses) != 1 {
|
||||
t.Fatalf("tool_use blocks = %d, want 1", len(toolUses))
|
||||
}
|
||||
toolUse := toolUses[0]
|
||||
if toolUse.Name != tt.wantToolName {
|
||||
t.Errorf("tool_use Name = %q, want %q", toolUse.Name, tt.wantToolName)
|
||||
}
|
||||
if toolUse.ID != tt.toolCall.ID {
|
||||
t.Errorf("tool_use ID = %q, want %q", toolUse.ID, tt.toolCall.ID)
|
||||
}
|
||||
gotInput, ok := toolUse.Input.(map[string]any)
|
||||
if !ok || !reflect.DeepEqual(gotInput, tt.wantInput) {
|
||||
t.Errorf("tool_use Input = %#v, want %#v", toolUse.Input, tt.wantInput)
|
||||
}
|
||||
|
||||
// (b) the following user message's tool_result references the same
|
||||
// id as the tool_use block — the pair is intact.
|
||||
if toolResult.ToolUseID != toolUse.ID {
|
||||
t.Errorf("tool_result ToolUseID = %q, want %q (paired with tool_use)",
|
||||
toolResult.ToolUseID, toolUse.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParams_WithTools(t *testing.T) {
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
|
||||
@@ -233,12 +233,26 @@ func buildRequestBody(
|
||||
|
||||
// Add tool_use blocks
|
||||
for _, tc := range msg.ToolCalls {
|
||||
if strings.TrimSpace(tc.Name) == "" {
|
||||
// Resolve tool name: prefer tc.Name, fallback to tc.Function.Name
|
||||
// (tc.Name/tc.Arguments are json:"-" and may be empty when
|
||||
// history is reloaded from the session store)
|
||||
toolName := tc.Name
|
||||
if toolName == "" && tc.Function != nil {
|
||||
toolName = tc.Function.Name
|
||||
}
|
||||
if strings.TrimSpace(toolName) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle nil Arguments (GLM-4 may return null input)
|
||||
// Resolve arguments: prefer tc.Arguments, fallback to parsing
|
||||
// tc.Function.Arguments
|
||||
input := tc.Arguments
|
||||
if input == nil && tc.Function != nil && tc.Function.Arguments != "" {
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
}
|
||||
// Handle nil Arguments (GLM-4 may return null input)
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
@@ -246,7 +260,7 @@ func buildRequestBody(
|
||||
toolUse := map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": tc.ID,
|
||||
"name": tc.Name,
|
||||
"name": toolName,
|
||||
"input": input,
|
||||
}
|
||||
content = append(content, toolUse)
|
||||
|
||||
@@ -614,6 +614,126 @@ func TestBuildRequestBody_UserToolResultsMerged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRequestBody_ToolCallFunctionFallback verifies that tool calls whose
|
||||
// runtime-only fields were lost in a JSON round-trip through the session store
|
||||
// (ToolCall.Name/Arguments are json:"-"; only ToolCall.Function survives) fall
|
||||
// back to Function.Name / Function.Arguments, so the tool_use block is still
|
||||
// emitted and its tool_result pair stays intact. Without the fallback the
|
||||
// tool_use is skipped and the orphaned tool_result 400s at the API
|
||||
// ("unexpected tool_use_id found in tool_result blocks").
|
||||
func TestBuildRequestBody_ToolCallFunctionFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolCall ToolCall
|
||||
wantSkipped bool
|
||||
wantToolName string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
name: "deserialized history shape falls back to Function fields",
|
||||
toolCall: ToolCall{
|
||||
ID: "toolu-fallback-1",
|
||||
Name: "",
|
||||
Arguments: nil,
|
||||
Function: &FunctionCall{Name: "x", Arguments: `{"a":1}`},
|
||||
},
|
||||
wantToolName: "x",
|
||||
wantInput: map[string]any{"a": float64(1)},
|
||||
},
|
||||
{
|
||||
name: "runtime shape with Name set and Function nil still works",
|
||||
toolCall: ToolCall{
|
||||
ID: "toolu-runtime-1",
|
||||
Name: "y",
|
||||
Arguments: map[string]any{"b": 2},
|
||||
},
|
||||
wantToolName: "y",
|
||||
wantInput: map[string]any{"b": 2},
|
||||
},
|
||||
{
|
||||
name: "both Name and Function.Name empty is skipped",
|
||||
toolCall: ToolCall{
|
||||
ID: "toolu-empty-1",
|
||||
Name: "",
|
||||
Function: &FunctionCall{Name: "", Arguments: `{"c":3}`},
|
||||
},
|
||||
wantSkipped: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "run the tool"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{tt.toolCall}},
|
||||
{Role: "tool", ToolCallID: tt.toolCall.ID, Content: "result"},
|
||||
}
|
||||
|
||||
got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192})
|
||||
if err != nil {
|
||||
t.Fatalf("buildRequestBody() error: %v", err)
|
||||
}
|
||||
|
||||
apiMessages := got["messages"].([]any)
|
||||
if len(apiMessages) != 3 {
|
||||
t.Fatalf("expected 3 API messages, got %d", len(apiMessages))
|
||||
}
|
||||
|
||||
assistantMsg := apiMessages[1].(map[string]any)
|
||||
content := assistantMsg["content"].([]any)
|
||||
|
||||
if tt.wantSkipped {
|
||||
if len(content) != 0 {
|
||||
t.Fatalf("assistant content = %#v, want empty (tool call skipped)", content)
|
||||
}
|
||||
// Note: matching current behavior, the orphaned tool_result is
|
||||
// still emitted in the following user message even though its
|
||||
// tool_use block was skipped.
|
||||
toolResultMsg := apiMessages[2].(map[string]any)
|
||||
blocks := toolResultMsg["content"].([]map[string]any)
|
||||
if len(blocks) != 1 || blocks[0]["tool_use_id"] != tt.toolCall.ID {
|
||||
t.Fatalf("orphaned tool_result = %#v, want single block with tool_use_id %q",
|
||||
blocks, tt.toolCall.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// (a) tool_use block emitted with resolved name, id, and parsed input.
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("assistant content length = %d, want 1 tool_use block", len(content))
|
||||
}
|
||||
toolUse := content[0].(map[string]any)
|
||||
if toolUse["type"] != "tool_use" {
|
||||
t.Fatalf("block type = %v, want tool_use", toolUse["type"])
|
||||
}
|
||||
if toolUse["name"] != tt.wantToolName {
|
||||
t.Errorf("tool_use name = %v, want %q", toolUse["name"], tt.wantToolName)
|
||||
}
|
||||
if toolUse["id"] != tt.toolCall.ID {
|
||||
t.Errorf("tool_use id = %v, want %q", toolUse["id"], tt.toolCall.ID)
|
||||
}
|
||||
if !reflect.DeepEqual(toolUse["input"], tt.wantInput) {
|
||||
t.Errorf("tool_use input = %#v, want %#v", toolUse["input"], tt.wantInput)
|
||||
}
|
||||
|
||||
// (b) the following user message's tool_result references the same
|
||||
// id as the tool_use block — the pair is intact.
|
||||
toolResultMsg := apiMessages[2].(map[string]any)
|
||||
if toolResultMsg["role"] != "user" {
|
||||
t.Fatalf("message after assistant role = %v, want user", toolResultMsg["role"])
|
||||
}
|
||||
blocks := toolResultMsg["content"].([]map[string]any)
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("tool_result blocks = %d, want 1", len(blocks))
|
||||
}
|
||||
if blocks[0]["tool_use_id"] != toolUse["id"] {
|
||||
t.Errorf("tool_result tool_use_id = %v, want %v (paired with tool_use)",
|
||||
blocks[0]["tool_use_id"], toolUse["id"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.
|
||||
func TestParseResponseBodyEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
@@ -862,7 +862,8 @@ func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, erro
|
||||
}
|
||||
|
||||
type WriteFileTool struct {
|
||||
fs fileSystem
|
||||
fs fileSystem
|
||||
altTools []string
|
||||
}
|
||||
|
||||
func NewWriteFileTool(
|
||||
@@ -874,7 +875,32 @@ func NewWriteFileTool(
|
||||
if len(allowPaths) > 0 {
|
||||
patterns = allowPaths[0]
|
||||
}
|
||||
return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)}
|
||||
// Default to both alternatives so standalone callers keep the full guidance;
|
||||
// the agent wiring narrows this to the tools actually registered.
|
||||
return &WriteFileTool{
|
||||
fs: buildFs(workspace, restrict, patterns),
|
||||
altTools: []string{"append_file", "edit_file"},
|
||||
}
|
||||
}
|
||||
|
||||
// SetAlternativeTools limits which alternatives the copy names, so it never
|
||||
// directs the model to tools that are not available.
|
||||
func (t *WriteFileTool) SetAlternativeTools(names []string) {
|
||||
present := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
present[name] = true
|
||||
}
|
||||
ordered := make([]string, 0, 2)
|
||||
for _, name := range []string{"append_file", "edit_file"} {
|
||||
if present[name] {
|
||||
ordered = append(ordered, name)
|
||||
}
|
||||
}
|
||||
t.altTools = ordered
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) altToolsPhrase() string {
|
||||
return strings.Join(t.altTools, " or ")
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Name() string {
|
||||
@@ -882,10 +908,24 @@ func (t *WriteFileTool) Name() string {
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Description() string {
|
||||
return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it."
|
||||
desc := "Write content to a file, replacing any existing content. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists you must set overwrite=true, which replaces the ENTIRE file."
|
||||
if phrase := t.altToolsPhrase(); phrase != "" {
|
||||
desc += fmt.Sprintf(
|
||||
" To add to or change part of an existing file without losing its current contents, use %s instead.",
|
||||
phrase,
|
||||
)
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Parameters() map[string]any {
|
||||
overwriteDesc := "Set to true to replace an existing file in full. This discards the file's current contents."
|
||||
if phrase := t.altToolsPhrase(); phrase != "" {
|
||||
overwriteDesc = fmt.Sprintf(
|
||||
"Set to true to replace an existing file in full. This discards the file's current contents — to preserve them, use %s instead of write_file.",
|
||||
phrase,
|
||||
)
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -899,7 +939,7 @@ func (t *WriteFileTool) Parameters() map[string]any {
|
||||
},
|
||||
"overwrite": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Must be set to true to overwrite an existing file.",
|
||||
"description": overwriteDesc,
|
||||
"default": false,
|
||||
},
|
||||
},
|
||||
@@ -922,8 +962,20 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
||||
|
||||
if !overwrite {
|
||||
if _, err := t.fs.Open(path); err == nil {
|
||||
if phrase := t.altToolsPhrase(); phrase != "" {
|
||||
return ErrorResult(
|
||||
fmt.Sprintf(
|
||||
"file: %s already exists. To add to it or change part of it without losing the current contents, use %s. Only set overwrite=true if you intend to replace the entire file.",
|
||||
path,
|
||||
phrase,
|
||||
),
|
||||
)
|
||||
}
|
||||
return ErrorResult(
|
||||
fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path),
|
||||
fmt.Sprintf(
|
||||
"file: %s already exists. Set overwrite=true only if you intend to replace the entire file, which discards its current contents.",
|
||||
path,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,9 @@ func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {
|
||||
assert.True(t, result.IsError, "expected error when overwriting without overwrite=true")
|
||||
assert.Contains(t, result.ForLLM, "already exists")
|
||||
assert.Contains(t, result.ForLLM, "overwrite=true")
|
||||
// The guard must steer toward non-destructive tools rather than only coaching overwrite.
|
||||
assert.Contains(t, result.ForLLM, "append_file")
|
||||
assert.Contains(t, result.ForLLM, "edit_file")
|
||||
|
||||
// Original content must be untouched
|
||||
data, err := os.ReadFile(testFile)
|
||||
@@ -257,6 +260,76 @@ func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {
|
||||
assert.Equal(t, "original", string(data))
|
||||
}
|
||||
|
||||
// Copy (description, overwrite param, guard) only names available alternatives.
|
||||
func TestFilesystemTool_WriteFile_AltToolsConditionalCopy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "existing.txt")
|
||||
os.WriteFile(testFile, []byte("original"), 0o644)
|
||||
|
||||
overwriteParamDesc := func(tool *WriteFileTool) string {
|
||||
props := tool.Parameters()["properties"].(map[string]any)
|
||||
return props["overwrite"].(map[string]any)["description"].(string)
|
||||
}
|
||||
|
||||
t.Run("no alternatives available", func(t *testing.T) {
|
||||
tool := NewWriteFileTool("", false)
|
||||
tool.SetAlternativeTools(nil)
|
||||
|
||||
assert.NotContains(t, tool.Description(), "append_file")
|
||||
assert.NotContains(t, tool.Description(), "edit_file")
|
||||
assert.NotContains(t, overwriteParamDesc(tool), "append_file")
|
||||
assert.NotContains(t, overwriteParamDesc(tool), "edit_file")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"path": testFile,
|
||||
"content": "new content",
|
||||
})
|
||||
assert.True(t, result.IsError, "expected overwrite guard to still block")
|
||||
assert.Contains(t, result.ForLLM, "already exists")
|
||||
assert.Contains(t, result.ForLLM, "overwrite=true")
|
||||
assert.NotContains(t, result.ForLLM, "append_file")
|
||||
assert.NotContains(t, result.ForLLM, "edit_file")
|
||||
})
|
||||
|
||||
t.Run("only append_file available", func(t *testing.T) {
|
||||
tool := NewWriteFileTool("", false)
|
||||
tool.SetAlternativeTools([]string{"append_file"})
|
||||
|
||||
assert.Contains(t, tool.Description(), "append_file")
|
||||
assert.NotContains(t, tool.Description(), "edit_file")
|
||||
assert.Contains(t, overwriteParamDesc(tool), "append_file")
|
||||
assert.NotContains(t, overwriteParamDesc(tool), "edit_file")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"path": testFile,
|
||||
"content": "new content",
|
||||
})
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "append_file")
|
||||
assert.NotContains(t, result.ForLLM, "edit_file")
|
||||
})
|
||||
|
||||
t.Run("both available uses canonical order", func(t *testing.T) {
|
||||
tool := NewWriteFileTool("", false)
|
||||
// Reversed input to confirm the order is normalized.
|
||||
tool.SetAlternativeTools([]string{"edit_file", "append_file"})
|
||||
|
||||
assert.Contains(t, tool.Description(), "append_file or edit_file")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"path": testFile,
|
||||
"content": "new content",
|
||||
})
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "append_file or edit_file")
|
||||
})
|
||||
|
||||
// Blocked writes must leave the original untouched.
|
||||
data, err := os.ReadFile(testFile)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original", string(data))
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting
|
||||
// overwrite=true replaces the existing file.
|
||||
func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user