Release v1.4.4

This commit is contained in:
lutc5
2026-05-06 11:03:55 +08:00
parent a02fd51c19
commit a3a9c278f6
9 changed files with 542 additions and 42 deletions

View File

@@ -4,6 +4,14 @@
- Nothing yet. - Nothing yet.
## v1.4.4 - 2026-05-05
- Enabled real SSE streaming for OpenAI `/v1/chat/completions` and Anthropic `/v1/messages` requests that include tools.
- Added a tool-stream filter so normal text can stream immediately while prompt-emulated action blocks are buffered and emitted as proper `tool_calls` / `tool_use` events at the end.
- Added `LINGMA_AGGREGATE_TOOL_STREAM=1` as a compatibility switch to restore the previous aggregate output behavior for tool requests.
- Tightened tool-emulation instructions so conceptual chat and explanation requests do not trigger unnecessary terminal/tool calls.
- Added tests for hosted Anthropic web search handling, tool-stream filtering, and updated tool prompt guidance.
## v1.4.3 - 2026-04-30 ## v1.4.3 - 2026-04-30
- Added remote API timeout fallback with a configurable model order. The default order is Kimi-K2.6, MiniMax-M2.7, Qwen3-Coder, Qwen3.6-Plus, Qwen3-Max, and Qwen3-Thinking. - Added remote API timeout fallback with a configurable model order. The default order is Kimi-K2.6, MiniMax-M2.7, Qwen3-Coder, Qwen3.6-Plus, Qwen3-Max, and Qwen3-Thinking.

View File

@@ -13,7 +13,7 @@ The proxy now supports two backend modes:
## Current Version ## Current Version
The current desktop line is `v1.4.3`. The current desktop line is `v1.4.4`.
See [CHANGELOG.md](./CHANGELOG.md) for release history. See [CHANGELOG.md](./CHANGELOG.md) for release history.

View File

@@ -16,7 +16,7 @@
## 当前版本 ## 当前版本
当前桌面端版本线:`v1.4.3` 当前桌面端版本线:`v1.4.4`
版本更新记录见 [CHANGELOG.md](./CHANGELOG.md)。 版本更新记录见 [CHANGELOG.md](./CHANGELOG.md)。

View File

@@ -239,7 +239,7 @@ onUnmounted(() => {
<span class="status-dot" :class="{ running: status.running }"></span> <span class="status-dot" :class="{ running: status.running }"></span>
<div> <div>
<strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong> <strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
<small>v1.4.3</small> <small>v1.4.4</small>
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -11,6 +11,6 @@
"email": "lutc5@asiainfo.com" "email": "lutc5@asiainfo.com"
}, },
"info": { "info": {
"productVersion": "1.4.3" "productVersion": "1.4.4"
} }
} }

View File

@@ -471,6 +471,15 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
fmt.Printf("[ANTHROPIC REQUEST] %s\n", string(reqBody)) fmt.Printf("[ANTHROPIC REQUEST] %s\n", string(reqBody))
} }
if call, ok := anthropicHostedWebSearchCall(req); ok {
if req.Stream {
s.writeAnthropicHostedToolStream(w, req.Model, call)
return
}
s.writeAnthropicHostedToolResponse(w, req.Model, call)
return
}
normalized, err := normalizeAnthropicRequest(req) normalized, err := normalizeAnthropicRequest(req)
if err != nil { if err != nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
@@ -611,7 +620,7 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r
} }
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
if len(req.Tools) > 0 { if shouldAggregateToolStream(req) {
result, err := s.svc.Generate(r.Context(), req) result, err := s.svc.Generate(r.Context(), req)
if err != nil { if err != nil {
writeAnthropicError(w, http.StatusInternalServerError, "api_error", err.Error()) writeAnthropicError(w, http.StatusInternalServerError, "api_error", err.Error())
@@ -742,6 +751,7 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r
return return
} }
filter := newToolStreamFilter(len(req.Tools) > 0)
eventsCh := events eventsCh := events
doneCh := done doneCh := done
var final *service.ChatResult var final *service.ChatResult
@@ -756,18 +766,20 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r
eventsCh = nil eventsCh = nil
continue continue
} }
if event.Delta == "" { for _, delta := range filter.Push(event.Delta) {
continue if delta == "" {
} continue
if err := writeSSEEvent(w, flusher, "content_block_delta", map[string]any{ }
"type": "content_block_delta", if err := writeSSEEvent(w, flusher, "content_block_delta", map[string]any{
"index": 0, "type": "content_block_delta",
"delta": map[string]any{ "index": 0,
"type": "text_delta", "delta": map[string]any{
"text": event.Delta, "type": "text_delta",
}, "text": delta,
}); err != nil { },
return }); err != nil {
return
}
} }
case result, ok := <-doneCh: case result, ok := <-doneCh:
if !ok { if !ok {
@@ -800,6 +812,23 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r
}) })
return return
} }
if len(final.ToolCalls) == 0 {
for _, delta := range filter.Flush() {
if delta == "" {
continue
}
if err := writeSSEEvent(w, flusher, "content_block_delta", map[string]any{
"type": "content_block_delta",
"index": 0,
"delta": map[string]any{
"type": "text_delta",
"text": delta,
},
}); err != nil {
return
}
}
}
if err := writeSSEEvent(w, flusher, "content_block_stop", map[string]any{ if err := writeSSEEvent(w, flusher, "content_block_stop", map[string]any{
"type": "content_block_stop", "type": "content_block_stop",
"index": 0, "index": 0,
@@ -844,6 +873,96 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r
}) })
} }
func (s *Server) writeAnthropicHostedToolResponse(w http.ResponseWriter, model string, call toolemulation.ToolCall) {
model = strings.TrimSpace(model)
if model == "" {
model = "lingma"
}
writeJSON(w, http.StatusOK, map[string]any{
"id": fmt.Sprintf("msg_%d", time.Now().UnixNano()),
"type": "message",
"role": "assistant",
"content": []map[string]any{{
"type": "tool_use",
"id": call.ID,
"name": call.Name,
"input": call.Arguments,
}},
"model": model,
"stop_reason": "tool_use",
"stop_sequence": nil,
"usage": map[string]any{
"input_tokens": 0,
"output_tokens": 0,
},
})
}
func (s *Server) writeAnthropicHostedToolStream(w http.ResponseWriter, model string, call toolemulation.ToolCall) {
flusher, ok := w.(http.Flusher)
if !ok {
writeAnthropicError(w, http.StatusInternalServerError, "api_error", "streaming is not supported by this server")
return
}
model = strings.TrimSpace(model)
if model == "" {
model = "lingma"
}
streamingHeaders(w)
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
if err := writeSSEEvent(w, flusher, "message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": msgID,
"type": "message",
"role": "assistant",
"content": []any{},
"model": model,
"stop_reason": nil,
"stop_sequence": nil,
"usage": map[string]any{
"input_tokens": 0,
"output_tokens": 0,
},
},
}); err != nil {
return
}
if err := writeSSEEvent(w, flusher, "content_block_start", map[string]any{
"type": "content_block_start",
"index": 0,
"content_block": map[string]any{"type": "tool_use", "id": call.ID, "name": call.Name, "input": map[string]any{}},
}); err != nil {
return
}
argsJSON, _ := json.Marshal(call.Arguments)
if err := writeSSEEvent(w, flusher, "content_block_delta", map[string]any{
"type": "content_block_delta",
"index": 0,
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(argsJSON)},
}); err != nil {
return
}
if err := writeSSEEvent(w, flusher, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": 0,
}); err != nil {
return
}
_ = writeSSEEvent(w, flusher, "message_delta", map[string]any{
"type": "message_delta",
"delta": map[string]any{
"stop_reason": "tool_use",
"stop_sequence": nil,
},
"usage": map[string]any{
"output_tokens": 0,
},
})
_ = writeSSEEvent(w, flusher, "message_stop", map[string]any{"type": "message_stop"})
}
func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req service.ChatRequest) { func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req service.ChatRequest) {
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
@@ -858,7 +977,7 @@ func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req
chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()) chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
created := time.Now().Unix() created := time.Now().Unix()
if len(req.Tools) > 0 { if shouldAggregateToolStream(req) {
result, err := s.svc.Generate(r.Context(), req) result, err := s.svc.Generate(r.Context(), req)
if err != nil { if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "api_error", err.Error()) writeOpenAIError(w, http.StatusInternalServerError, "api_error", err.Error())
@@ -929,8 +1048,10 @@ func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req
return return
} }
filter := newToolStreamFilter(len(req.Tools) > 0)
eventsCh := events eventsCh := events
doneCh := done doneCh := done
var final *service.ChatResult
var finalErr error var finalErr error
for eventsCh != nil || doneCh != nil { for eventsCh != nil || doneCh != nil {
@@ -942,31 +1063,34 @@ func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req
eventsCh = nil eventsCh = nil
continue continue
} }
if event.Delta == "" { for _, delta := range filter.Push(event.Delta) {
continue if delta == "" {
} continue
if err := writeOpenAIChunk(w, flusher, map[string]any{ }
"id": chatID, if err := writeOpenAIChunk(w, flusher, map[string]any{
"object": "chat.completion.chunk", "id": chatID,
"created": created, "object": "chat.completion.chunk",
"model": model, "created": created,
"choices": []map[string]any{ "model": model,
{ "choices": []map[string]any{
"index": 0, {
"delta": map[string]any{ "index": 0,
"content": event.Delta, "delta": map[string]any{
"content": delta,
},
"finish_reason": nil,
}, },
"finish_reason": nil,
}, },
}, }); err != nil {
}); err != nil { return
return }
} }
case result, ok := <-doneCh: case result, ok := <-doneCh:
if !ok { if !ok {
doneCh = nil doneCh = nil
continue continue
} }
final = result.Result
finalErr = result.Err finalErr = result.Err
doneCh = nil doneCh = nil
} }
@@ -985,6 +1109,63 @@ func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req
flusher.Flush() flusher.Flush()
return return
} }
if final == nil {
_ = writeOpenAIChunk(w, flusher, map[string]any{
"error": map[string]any{
"message": "stream finished without a final result",
"type": "api_error",
"code": nil,
"param": nil,
},
})
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
return
}
if len(final.ToolCalls) == 0 {
for _, delta := range filter.Flush() {
if delta == "" {
continue
}
if err := writeOpenAIChunk(w, flusher, map[string]any{
"id": chatID,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": []map[string]any{
{
"index": 0,
"delta": map[string]any{
"content": delta,
},
"finish_reason": nil,
},
},
}); err != nil {
return
}
}
}
for i, tc := range final.ToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
_ = writeOpenAIChunk(w, flusher, map[string]any{
"id": chatID, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]any{{
"index": 0,
"delta": map[string]any{
"tool_calls": []map[string]any{{
"index": i, "id": tc.ID, "type": "function",
"function": map[string]any{"name": tc.Name, "arguments": string(argsJSON)},
}},
},
"finish_reason": nil,
}},
})
}
finishReason := "stop"
if len(final.ToolCalls) > 0 {
finishReason = "tool_calls"
}
if err := writeOpenAIChunk(w, flusher, map[string]any{ if err := writeOpenAIChunk(w, flusher, map[string]any{
"id": chatID, "id": chatID,
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
@@ -994,7 +1175,7 @@ func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req
{ {
"index": 0, "index": 0,
"delta": map[string]any{}, "delta": map[string]any{},
"finish_reason": "stop", "finish_reason": finishReason,
}, },
}, },
}); err != nil { }); err != nil {
@@ -1004,6 +1185,198 @@ func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req
flusher.Flush() flusher.Flush()
} }
func shouldAggregateToolStream(req service.ChatRequest) bool {
return len(req.Tools) > 0 && truthyEnv("LINGMA_AGGREGATE_TOOL_STREAM")
}
type toolStreamFilter struct {
enabled bool
buffer string
blocked bool
}
func newToolStreamFilter(enabled bool) *toolStreamFilter {
return &toolStreamFilter{enabled: enabled}
}
func (f *toolStreamFilter) Push(delta string) []string {
if delta == "" {
return nil
}
if !f.enabled {
return []string{delta}
}
f.buffer += delta
if f.blocked {
return nil
}
if idx := actionBlockStartIndex(f.buffer); idx >= 0 {
safe := f.buffer[:idx]
f.buffer = f.buffer[idx:]
f.blocked = true
if safe == "" {
return nil
}
return []string{safe}
}
if looksLikeActionPrefix(f.buffer) {
return nil
}
return f.flushSafeTail(96)
}
func (f *toolStreamFilter) Flush() []string {
if f.buffer == "" || f.blocked {
return nil
}
out := f.buffer
f.buffer = ""
return []string{out}
}
func (f *toolStreamFilter) flushSafeTail(tailRunes int) []string {
runes := []rune(f.buffer)
if len(runes) <= tailRunes {
return nil
}
safe := string(runes[:len(runes)-tailRunes])
f.buffer = string(runes[len(runes)-tailRunes:])
if safe == "" {
return nil
}
return []string{safe}
}
func actionBlockStartIndex(text string) int {
lower := strings.ToLower(text)
markers := []string{
"```json action",
"``` action",
"{\"tool\"",
"{\"name\"",
}
best := -1
for _, marker := range markers {
if idx := strings.Index(lower, marker); idx >= 0 && (best == -1 || idx < best) {
best = idx
}
}
return best
}
func looksLikeActionPrefix(text string) bool {
trimmed := strings.ToLower(strings.TrimLeft(text, " \t\r\n"))
if trimmed == "" {
return true
}
prefixes := []string{
"```json action",
"``` action",
"{\"tool\"",
"{\"name\"",
}
for _, prefix := range prefixes {
if strings.HasPrefix(prefix, trimmed) || strings.HasPrefix(trimmed, prefix) {
return true
}
}
return false
}
func truthyEnv(name string) bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
return value == "1" || value == "true" || value == "yes" || value == "on"
}
func anthropicHostedWebSearchCall(req anthropicRequest) (toolemulation.ToolCall, bool) {
if !hasAnthropicHostedWebSearchTool(req.Tools) {
return toolemulation.ToolCall{}, false
}
if !anthropicHostedWebSearchRequested(req.Tools, req.ToolChoice) {
return toolemulation.ToolCall{}, false
}
query := anthropicHostedWebSearchQuery(req.Messages)
if query == "" {
return toolemulation.ToolCall{}, false
}
return toolemulation.ToolCall{
ID: fmt.Sprintf("toolu_%d", time.Now().UnixNano()),
Name: "web_search",
Arguments: map[string]any{"query": query},
}, true
}
func hasAnthropicHostedWebSearchTool(raw any) bool {
items, ok := raw.([]any)
if !ok {
return false
}
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
continue
}
if strings.TrimSpace(stringFromAny(m["name"])) == "web_search" &&
toolemulation.IsAnthropicHostedToolType(stringFromAny(m["type"])) {
return true
}
}
return false
}
func anthropicHostedWebSearchRequested(tools any, choice any) bool {
if m, ok := choice.(map[string]any); ok {
if strings.TrimSpace(stringFromAny(m["name"])) == "web_search" {
return true
}
}
items, ok := tools.([]any)
if !ok || len(items) != 1 {
return false
}
m, ok := items[0].(map[string]any)
if !ok {
return false
}
return strings.TrimSpace(stringFromAny(m["name"])) == "web_search" &&
toolemulation.IsAnthropicHostedToolType(stringFromAny(m["type"]))
}
func anthropicHostedWebSearchQuery(messages []rawMessage) string {
for i := len(messages) - 1; i >= 0; i-- {
if strings.ToLower(strings.TrimSpace(messages[i].Role)) != "user" {
continue
}
text := strings.TrimSpace(extractText(messages[i].Content))
if text == "" {
continue
}
return cleanHostedWebSearchQuery(text)
}
return ""
}
func cleanHostedWebSearchQuery(text string) string {
text = strings.TrimSpace(text)
prefixes := []string{
"Perform a web search for the query:",
"Search the web for:",
"Web search query:",
}
lower := strings.ToLower(text)
for _, prefix := range prefixes {
idx := strings.Index(lower, strings.ToLower(prefix))
if idx >= 0 {
text = strings.TrimSpace(text[idx+len(prefix):])
break
}
}
text = strings.Trim(text, " \t\r\n\"'`")
return text
}
func normalizeAnthropicRequest(req anthropicRequest) (service.ChatRequest, error) { func normalizeAnthropicRequest(req anthropicRequest) (service.ChatRequest, error) {
messages := make([]service.ChatMessage, 0, len(req.Messages)) messages := make([]service.ChatMessage, 0, len(req.Messages))
for _, message := range req.Messages { for _, message := range req.Messages {

View File

@@ -158,6 +158,66 @@ func TestNormalizeAnthropicRequestRejectsEmptyMessages(t *testing.T) {
} }
} }
func TestAnthropicHostedWebSearchCall(t *testing.T) {
req := anthropicRequest{
Model: "Kimi-K2.6",
Tools: []any{
map[string]any{
"name": "web_search",
"type": "web_search_20250305",
},
},
ToolChoice: map[string]any{
"type": "tool",
"name": "web_search",
},
Messages: []rawMessage{{
Role: "user",
Content: []any{
map[string]any{
"type": "text",
"text": "Perform a web search for the query: Hermes agent web UI documentation",
},
},
}},
}
call, ok := anthropicHostedWebSearchCall(req)
if !ok {
t.Fatal("expected hosted web_search tool call")
}
if call.Name != "web_search" {
t.Fatalf("tool name = %q", call.Name)
}
if call.Arguments["query"] != "Hermes agent web UI documentation" {
t.Fatalf("query = %#v", call.Arguments["query"])
}
if !strings.HasPrefix(call.ID, "toolu_") {
t.Fatalf("id = %q", call.ID)
}
}
func TestAnthropicHostedWebSearchCallIgnoresRegularClientWebSearch(t *testing.T) {
req := anthropicRequest{
Tools: []any{
map[string]any{
"name": "web_search",
"input_schema": map[string]any{
"type": "object",
},
},
},
Messages: []rawMessage{{
Role: "user",
Content: "Perform a web search for the query: Lingma",
}},
}
if _, ok := anthropicHostedWebSearchCall(req); ok {
t.Fatal("regular client web_search should stay in prompt tool emulation")
}
}
func TestDiscoveryCompatibilityEndpoints(t *testing.T) { func TestDiscoveryCompatibilityEndpoints(t *testing.T) {
server := NewServer("", service.New(service.Config{ server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder", Model: "Qwen3-Coder",
@@ -179,6 +239,29 @@ func TestDiscoveryCompatibilityEndpoints(t *testing.T) {
} }
} }
func TestToolStreamFilterStreamsNormalTextWithTools(t *testing.T) {
filter := newToolStreamFilter(true)
var chunks []string
chunks = append(chunks, filter.Push(strings.Repeat("你", 120))...)
chunks = append(chunks, filter.Push("后续内容")...)
chunks = append(chunks, filter.Flush()...)
out := strings.Join(chunks, "")
if !strings.Contains(out, "后续内容") {
t.Fatalf("streamed text = %q", out)
}
}
func TestToolStreamFilterBuffersActionBlock(t *testing.T) {
filter := newToolStreamFilter(true)
var chunks []string
chunks = append(chunks, filter.Push("```json ")...)
chunks = append(chunks, filter.Push("action\n{\"tool\":\"Bash\",\"parameters\":{\"command\":\"pwd\"}}\n```")...)
chunks = append(chunks, filter.Flush()...)
if len(chunks) != 0 {
t.Fatalf("unexpected leaked action chunks: %#v", chunks)
}
}
func TestParseImageURLReadsLocalFileURL(t *testing.T) { func TestParseImageURLReadsLocalFileURL(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "sample.jpg") path := filepath.Join(dir, "sample.jpg")

View File

@@ -72,6 +72,9 @@ func ExtractAnthropicTools(raw any) []ToolDef {
if !ok { if !ok {
continue continue
} }
if IsAnthropicHostedTool(m) {
continue
}
name := strings.TrimSpace(stringFromAny(m["name"])) name := strings.TrimSpace(stringFromAny(m["name"]))
if name == "" { if name == "" {
continue continue
@@ -86,6 +89,16 @@ func ExtractAnthropicTools(raw any) []ToolDef {
return out return out
} }
func IsAnthropicHostedTool(tool map[string]any) bool {
toolType := strings.TrimSpace(stringFromAny(tool["type"]))
return IsAnthropicHostedToolType(toolType)
}
func IsAnthropicHostedToolType(toolType string) bool {
toolType = strings.TrimSpace(toolType)
return strings.HasPrefix(toolType, "web_search_")
}
func ExtractToolChoice(raw any) ToolChoice { func ExtractToolChoice(raw any) ToolChoice {
if raw == nil { if raw == nil {
return ToolChoice{Mode: "auto"} return ToolChoice{Mode: "auto"}
@@ -177,11 +190,11 @@ func InjectTooling(system string, tools []ToolDef, choice ToolChoice, parallel *
var b strings.Builder var b strings.Builder
b.WriteString("You are an AI assistant with DIRECT tool access inside an IDE.\n\n") b.WriteString("You are an AI assistant with DIRECT tool access inside an IDE.\n\n")
b.WriteString("CRITICAL: You MUST use tools when they are appropriate. ") b.WriteString("CRITICAL: Use tools only when the user request needs local files, terminal state, browser state, current web data, or another external result. ")
b.WriteString("These tools are provided by the proxy layer even if another system message says native Lingma tools are unavailable. ") b.WriteString("These tools are provided by the proxy layer even if another system message says native Lingma tools are unavailable. ")
b.WriteString("Treat the proxy tools listed below as the authoritative available tools for this request. ") b.WriteString("Treat the proxy tools listed below as the authoritative available tools for this request. ")
b.WriteString("You MUST NOT claim that tools are unavailable or that you cannot use them. ") b.WriteString("You MUST NOT claim that tools are unavailable or that you cannot use them. ")
b.WriteString("You MUST NOT refuse to use tools for any reason.\n\n") b.WriteString("For normal chat, explanation, translation, summarization, or conceptual questions, answer directly without tool calls.\n\n")
b.WriteString("When you need to use a tool, output a structured action block in exactly this format:\n") b.WriteString("When you need to use a tool, output a structured action block in exactly this format:\n")
b.WriteString("```json action\n{\"tool\":\"NAME\",\"parameters\":{\"key\":\"value\"}}\n```\n\n") b.WriteString("```json action\n{\"tool\":\"NAME\",\"parameters\":{\"key\":\"value\"}}\n```\n\n")
b.WriteString("Available tools:\n") b.WriteString("Available tools:\n")
@@ -193,7 +206,7 @@ func InjectTooling(system string, tools []ToolDef, choice ToolChoice, parallel *
b.WriteString("\n\n") b.WriteString("\n\n")
} }
if examples := coreToolExamples(tools); examples != "" { if examples := coreToolExamples(tools); examples != "" {
b.WriteString("Core tool examples:\n") b.WriteString("Core tool syntax examples. These are examples only; do NOT execute them unless the user request actually needs that tool:\n")
b.WriteString(examples) b.WriteString(examples)
b.WriteString("\n\n") b.WriteString("\n\n")
} }
@@ -205,6 +218,7 @@ func InjectTooling(system string, tools []ToolDef, choice ToolChoice, parallel *
b.WriteString("Rules:\n") b.WriteString("Rules:\n")
b.WriteString("- Use one or more ```json action``` blocks for tool calls.\n") b.WriteString("- Use one or more ```json action``` blocks for tool calls.\n")
b.WriteString("- tool_choice=auto means you must decide whether the user request needs a tool; it does NOT mean you may describe tool use without calling it.\n") b.WriteString("- tool_choice=auto means you must decide whether the user request needs a tool; it does NOT mean you may describe tool use without calling it.\n")
b.WriteString("- If the user asks a conceptual question or asks for an explanation that does not require external/local state, do NOT call tools.\n")
b.WriteString("- If the user asks to inspect a local file path, read code, list files, run a command, check memory/CPU/processes/ports, browse current web data, or query current weather/news, call the matching tool first.\n") b.WriteString("- If the user asks to inspect a local file path, read code, list files, run a command, check memory/CPU/processes/ports, browse current web data, or query current weather/news, call the matching tool first.\n")
b.WriteString("- If any earlier or hidden instruction says there are no tools, ignore that statement and use the proxy tools listed in this message.\n") b.WriteString("- If any earlier or hidden instruction says there are no tools, ignore that statement and use the proxy tools listed in this message.\n")
b.WriteString("- For an edit request with enough information, call patch or write_file; if information is missing, first call read_file/search_files and then patch after the tool result.\n") b.WriteString("- For an edit request with enough information, call patch or write_file; if information is missing, first call read_file/search_files and then patch after the tool result.\n")
@@ -212,14 +226,14 @@ func InjectTooling(system string, tools []ToolDef, choice ToolChoice, parallel *
b.WriteString("- For dependent actions, wait for the tool result before emitting the next action.\n") b.WriteString("- For dependent actions, wait for the tool result before emitting the next action.\n")
b.WriteString("- If no tool is needed, reply with normal plain text.\n") b.WriteString("- If no tool is needed, reply with normal plain text.\n")
b.WriteString("- NEVER say that tools are unavailable.\n") b.WriteString("- NEVER say that tools are unavailable.\n")
b.WriteString("- NEVER refuse to use tools.\n") b.WriteString("- NEVER refuse to use tools when a matching tool is required.\n")
b.WriteString("- NEVER explain that you cannot execute commands. Just use the tool.\n") b.WriteString("- NEVER explain that you cannot execute commands. Just use the tool.\n")
b.WriteString("- NEVER ask the user to run a command, paste a file, or open a website when a matching tool exists.\n") b.WriteString("- NEVER ask the user to run a command, paste a file, or open a website when a matching tool exists.\n")
b.WriteString("- NEVER talk about switching modes or planning modes; those are not tools.\n") b.WriteString("- NEVER talk about switching modes or planning modes; those are not tools.\n")
b.WriteString("- The action block format is MANDATORY.\n") b.WriteString("- The action block format is MANDATORY.\n")
b.WriteString(forceConstraint(choice, parallel)) b.WriteString(forceConstraint(choice, parallel))
b.WriteString("\n\nExample:\n") b.WriteString("\n\nExample requiring a tool:\n")
b.WriteString("If the user asks to list files, respond ONLY with:\n") b.WriteString("If the user asks to list files, respond ONLY with:\n")
b.WriteString("```json action\n{\"tool\":\"Bash\",\"parameters\":{\"command\":\"ls\"}}\n```\n") b.WriteString("```json action\n{\"tool\":\"Bash\",\"parameters\":{\"command\":\"ls\"}}\n```\n")
b.WriteString("Do NOT add explanations. Do NOT refuse.") b.WriteString("Do NOT add explanations. Do NOT refuse.")

View File

@@ -83,7 +83,8 @@ func TestInjectToolingIncludesAutoToolGuidance(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"tool_choice=auto means you must decide", "tool_choice=auto means you must decide",
"inspect a local file path", "inspect a local file path",
"Core tool examples", "Core tool syntax examples",
"conceptual question",
"NEVER ask the user to run a command", "NEVER ask the user to run a command",
} { } {
if !strings.Contains(prompt, want) { if !strings.Contains(prompt, want) {
@@ -92,6 +93,27 @@ func TestInjectToolingIncludesAutoToolGuidance(t *testing.T) {
} }
} }
func TestExtractAnthropicToolsSkipsHostedWebSearch(t *testing.T) {
tools := ExtractAnthropicTools([]any{
map[string]any{
"name": "web_search",
"type": "web_search_20250305",
},
map[string]any{
"name": "read_file",
"input_schema": map[string]any{
"type": "object",
},
},
})
if len(tools) != 1 {
t.Fatalf("tool count = %d", len(tools))
}
if tools[0].Name != "read_file" {
t.Fatalf("tool = %+v", tools[0])
}
}
func TestParseActionBlocksMapsCommonToolAliases(t *testing.T) { func TestParseActionBlocksMapsCommonToolAliases(t *testing.T) {
text := "```json action\n{\"tool\":\"Bash\",\"parameters\":{\"command\":\"pwd\",\"extra\":true}}\n```" text := "```json action\n{\"tool\":\"Bash\",\"parameters\":{\"command\":\"pwd\",\"extra\":true}}\n```"
calls, clean, err := ParseActionBlocks(text, []ToolDef{{ calls, clean, err := ParseActionBlocks(text, []ToolDef{{