Compare commits

...

15 Commits

Author SHA1 Message Date
dependabot[bot] 8ffb8ae8d2 build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 17:13:55 +00:00
Mauro 85dcfccad6 Merge pull request #3226 from ACMYuechen/fix/writefile-overwrite-guidance
fix(tools): stop write_file from coaching destructive overwrite (#3150)
2026-07-09 13:31:41 +02:00
Yue_chen 9a0efd7bab style(tools): wrap write_file guidance Sprintf for golines 2026-07-07 21:04:16 +08:00
Yue_chen 0132837d22 Merge branch 'main' into fix/writefile-overwrite-guidance 2026-07-07 20:46:53 +08:00
Yue_chen a680d8fcfa fix(tools): gate write_file overwrite guidance on available tools
#3150 reworded write_file's description, overwrite parameter, and guard
error to prefer append_file/edit_file. But those tools register via their
own enable flags and the per-agent allowlist, so a config exposing only
write_file was steered toward tools it does not have — a dead end in the
most restrictive setups.

WriteFileTool now tracks which non-destructive alternatives are available
and names only the ones present; when none are, the guard still blocks the
overwrite without pointing anywhere. The agent wiring registers the editors
before write_file and resolves availability via registry.HasRegistered,
which reflects both the enable flag and the allowlist.

Tests: conditional copy at the tool level (none/one/both) and agent wiring
across the enable-flag and allowlist dimensions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:40:27 +08:00
Mauro c87b154b61 Merge pull request #3227 from AayushGupta16/fix/anthropic-tool-use-function-fallback
fix(providers): resolve tool_use name/args from Function on reloaded history
2026-07-06 13:57:51 +02:00
AayushGupta16 994c0aea11 fix(providers): resolve tool_use name/args from Function on reloaded history
ToolCall.Name and .Arguments are json:"-" (runtime-only), so after chat
history round-trips through the session store only ToolCall.Function
survives. The anthropic-messages and anthropic (SDK) providers emitted
tool_use blocks from tc.Name alone, silently skipping every historical
tool call while still emitting the matching tool_result — orphaned
tool_results 400 at the API ("unexpected tool_use_id found in
tool_result blocks"), killing every turn on agents with tool history.

Fall back to Function.Name / json-parsed Function.Arguments (the same
pattern the bedrock and openai_compat providers already use), and cover
the deserialized-history shape with table tests in both providers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:26:34 -07:00
Mauro b6e6d25d75 Merge pull request #3189 from chengzhichao-xydt/codex/line-body-close
fix(line): explicitly ignore resp.Body.Close() errors in Send and classifySDKError
2026-07-05 09:35:40 +02:00
Yue_chen 8f891d5dd0 fix(tools): stop write_file from coaching destructive overwrite (#3150)
The overwrite guard error ("Set overwrite=true to replace") steered the
model to clobber files like MEMORY.md. Reword write_file's description,
overwrite param, and guard error to prefer append_file/edit_file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:13:31 +08:00
Mauro 0f4a997b47 Merge pull request #3224 from Ethan1918/fix/clear-routed-agent-session
fix(agent): clear routed agent session
2026-07-04 12:57:35 +02:00
Ethan1918 79c075fba0 fix(openai_compat): remove unused log import 2026-07-04 14:34:58 +08:00
Ethan1918 ffffb6a0cb refactor(agent): route /clear through ContextManager.Clear for all agents
Address review feedback: the routed-agent /clear path bypassed the
public ContextManager.Clear contract via an unexported hook. Restore
the single Clear call in the command path and resolve the session's
owning agent inside the built-in implementations instead:

- legacy: Clear resolves the owning agent via agentForSession instead
  of assuming the default agent
- seahorse: drop ClearContextStore; Clear wipes the engine state and
  the owning agent's session store
- command path: persist session scope metadata before Clear so
  ownership resolves even when /clear is the session's first message
- add coverage that a custom ContextManager receives Clear for routed
  agents

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:04:37 +08:00
Ethan1918 c4fb7a2001 fix(agent): clear routed agent session 2026-07-04 04:14:03 +08:00
程智超0668000959 9068fde274 fix: gofumpt formatting on line.go 2026-06-27 10:20:37 +08:00
程智超0668000959 ce4a6c9bba fix(line): explicitly ignore resp.Body.Close() errors in Send and classifySDKError 2026-06-27 09:19:13 +08:00
18 changed files with 736 additions and 31 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+9
View File
@@ -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)
}
+99
View File
@@ -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 {
+3 -1
View File
@@ -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")
}
+95 -1
View File
@@ -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.
+14 -4
View File
@@ -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
View File
@@ -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)
+96
View File
@@ -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()
+4 -3
View File
@@ -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
+9 -2
View File
@@ -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 {
+119
View File
@@ -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{
{
+17 -3
View File
@@ -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 {
-1
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"maps"
"net/http"
"net/url"
+57 -5
View File
@@ -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,
),
)
}
}
+73
View File
@@ -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) {