opencode-cursor-agent/internal/server/anthropic.go

171 lines
5.0 KiB
Go

package server
import (
"encoding/json"
"fmt"
"strings"
"github.com/daniel/cursor-adapter/internal/sanitize"
"github.com/daniel/cursor-adapter/internal/types"
)
// buildPromptFromAnthropicMessages flattens an Anthropic Messages request into
// a single prompt string suitable for `agent --print`. It:
// - renders tool_use / tool_result blocks as readable pseudo-XML so the
// model can follow the trajectory of previous tool calls
// - embeds the `tools` schema as part of the System block via
// toolsToSystemText, so the model knows what tools the outer agent (e.g.
// Claude Code) has available
// - runs every piece of free text through sanitize.Text to strip Claude Code
// branding and telemetry headers that would confuse the Cursor agent
func buildPromptFromAnthropicMessages(req types.AnthropicMessagesRequest) string {
var systemParts []string
for _, block := range req.System {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
systemParts = append(systemParts, sanitize.Text(block.Text))
}
}
if tools := toolsToSystemText(req.Tools); tools != "" {
systemParts = append(systemParts, tools)
}
var convo []string
for _, msg := range req.Messages {
text := anthropicContentToText(msg.Content)
if text == "" {
continue
}
switch msg.Role {
case "assistant":
convo = append(convo, "Assistant: "+text)
default:
convo = append(convo, "User: "+text)
}
}
var prompt strings.Builder
if len(systemParts) > 0 {
prompt.WriteString("System:\n")
prompt.WriteString(strings.Join(systemParts, "\n\n"))
prompt.WriteString("\n\n")
}
prompt.WriteString(strings.Join(convo, "\n\n"))
prompt.WriteString("\n\nAssistant:")
return prompt.String()
}
// anthropicContentToText renders a single message's content blocks as a
// single string. Unlike the old implementation, this one preserves tool_use
// and tool_result blocks so the model sees the full conversation trajectory
// rather than mysterious gaps.
func anthropicContentToText(content types.AnthropicContent) string {
var parts []string
for _, block := range content {
switch block.Type {
case "text":
if block.Text != "" {
parts = append(parts, sanitize.Text(block.Text))
}
case "tool_use":
input := strings.TrimSpace(string(block.Input))
if input == "" {
input = "{}"
}
parts = append(parts, fmt.Sprintf(
"<tool_use id=%q name=%q>\n%s\n</tool_use>",
block.ID, block.Name, input,
))
case "tool_result":
body := toolResultBody(block.Content)
errAttr := ""
if block.IsError {
errAttr = ` is_error="true"`
}
parts = append(parts, fmt.Sprintf(
"<tool_result tool_use_id=%q%s>\n%s\n</tool_result>",
block.ToolUseID, errAttr, body,
))
case "image":
parts = append(parts, "[Image]")
case "document":
title := block.Title
if title == "" {
title = "Document"
}
parts = append(parts, "[Document: "+title+"]")
}
}
return strings.Join(parts, "\n")
}
// toolResultBody flattens the `content` field of a tool_result block, which
// can be either a plain string or an array of `{type, text}` content parts.
func toolResultBody(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
return sanitize.Text(asString)
}
var parts []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &parts); err == nil {
var out []string
for _, p := range parts {
if p.Type == "text" && p.Text != "" {
out = append(out, sanitize.Text(p.Text))
}
}
return strings.Join(out, "\n")
}
return string(raw)
}
// toolsToSystemText renders a tools schema array into a system-prompt chunk
// describing each tool. The idea (from cursor-api-proxy) is that since the
// Cursor CLI does not expose native tool_call deltas over the proxy, we tell
// the model what tools exist so it can reference them in its text output.
//
// NOTE: This is a one-way passthrough. The proxy cannot turn the model's
// textual "I would call Write with {...}" back into structured tool_use
// blocks. Callers that need real tool-use routing (e.g. Claude Code's coding
// agent) should run tools client-side and feed tool_result back in.
func toolsToSystemText(tools []types.AnthropicTool) string {
if len(tools) == 0 {
return ""
}
var lines []string
lines = append(lines,
"Available tools (they belong to the caller, not to you; describe your",
"intended call in plain text and the caller will execute it):",
"",
)
for _, t := range tools {
schema := strings.TrimSpace(string(t.InputSchema))
if schema == "" {
schema = "{}"
} else {
var pretty any
if err := json.Unmarshal(t.InputSchema, &pretty); err == nil {
if out, err := json.MarshalIndent(pretty, "", " "); err == nil {
schema = string(out)
}
}
}
lines = append(lines,
"Function: "+t.Name,
"Description: "+sanitize.Text(t.Description),
"Parameters: "+schema,
"",
)
}
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
}