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

191 lines
5.5 KiB
Go
Raw Permalink Normal View History

2026-04-18 14:08:01 +00:00
package server
import (
"encoding/json"
"fmt"
2026-04-25 13:18:22 +00:00
"regexp"
2026-04-18 14:08:01 +00:00
"strings"
"github.com/daniel/cursor-adapter/internal/sanitize"
"github.com/daniel/cursor-adapter/internal/types"
)
2026-04-25 13:18:22 +00:00
// systemReminderRe matches <system-reminder>...</system-reminder> blocks
// that Claude Desktop embeds inside user messages.
var systemReminderRe = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>\s*`)
// buildPromptFromAnthropicMessages flattens an Anthropic Messages request
// into a single prompt string suitable for `agent --print`.
//
// "Pure brain + remote executors" design:
// - DROP all client system messages (mode descriptions / sandbox warnings
// that make the model refuse).
// - USE ONLY the adapter's injected system prompt.
// - RENDER req.Tools as a plain-text inventory of executors that the
// caller (Claude Desktop / Claude Code / opencode) owns. The brain must
// know it has remote hands.
// - RENDER assistant tool_use and user tool_result blocks as readable
// transcript, so multi-turn ReAct loops keep working.
// - STRIP <system-reminder> blocks embedded in user messages.
func buildPromptFromAnthropicMessages(req types.AnthropicMessagesRequest, injectedSystemPrompt string) string {
var prompt strings.Builder
if injectedSystemPrompt != "" {
prompt.WriteString("System:\n")
prompt.WriteString(injectedSystemPrompt)
prompt.WriteString("\n\n")
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
if hints := renderMountHints(extractMountHints(req)); hints != "" {
prompt.WriteString(hints)
prompt.WriteString("\n")
}
if toolsBlock := renderToolsForBrain(req.Tools); toolsBlock != "" {
prompt.WriteString(toolsBlock)
prompt.WriteString("\n")
2026-04-18 14:08:01 +00:00
}
for _, msg := range req.Messages {
2026-04-25 13:18:22 +00:00
text := renderMessageBlocks(msg.Role, msg.Content)
2026-04-18 14:08:01 +00:00
if text == "" {
continue
}
switch msg.Role {
case "assistant":
2026-04-25 13:18:22 +00:00
prompt.WriteString("Assistant: ")
2026-04-18 14:08:01 +00:00
default:
2026-04-25 13:18:22 +00:00
prompt.WriteString("User: ")
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
prompt.WriteString(text)
2026-04-18 14:08:01 +00:00
prompt.WriteString("\n\n")
}
2026-04-25 13:18:22 +00:00
prompt.WriteString("Assistant:")
2026-04-18 14:08:01 +00:00
return prompt.String()
}
2026-04-25 13:18:22 +00:00
// renderToolsForBrain converts the Anthropic tools[] array into a readable
// inventory the brain can reason about. The brain is told it MUST emit
// <tool_call>{...}</tool_call> sentinels when it wants to invoke one; the
// proxy translates that into real Anthropic tool_use blocks for the caller.
func renderToolsForBrain(tools []types.AnthropicTool) string {
if len(tools) == 0 {
return ""
}
var b strings.Builder
b.WriteString("Available executors (the caller will run these for you):\n")
for _, t := range tools {
b.WriteString("- ")
b.WriteString(t.Name)
if desc := strings.TrimSpace(t.Description); desc != "" {
b.WriteString(": ")
b.WriteString(singleLine(desc))
}
if len(t.InputSchema) > 0 {
b.WriteString("\n input_schema: ")
b.WriteString(compactJSON(t.InputSchema))
}
b.WriteString("\n")
}
b.WriteString("\nTo invoke a tool, output EXACTLY one fenced block (and nothing else for that turn):\n")
b.WriteString("<tool_call>\n")
b.WriteString(`{"name":"<tool_name>","input":{...}}` + "\n")
b.WriteString("</tool_call>\n")
b.WriteString("If you do NOT need a tool, just answer in plain text.\n")
return b.String()
}
// renderMessageBlocks renders a single message's content blocks into a
// transcript snippet. Text blocks are sanitised; tool_use blocks render as
// `[tool_call name=... input=...]`; tool_result blocks render as
// `[tool_result for=... ok|error] ...`.
func renderMessageBlocks(role string, content types.AnthropicContent) string {
2026-04-18 14:08:01 +00:00
var parts []string
for _, block := range content {
switch block.Type {
case "text":
2026-04-25 13:18:22 +00:00
if block.Text == "" {
continue
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
cleaned := systemReminderRe.ReplaceAllString(block.Text, "")
cleaned = sanitize.Text(cleaned)
cleaned = strings.TrimSpace(cleaned)
if cleaned != "" {
parts = append(parts, cleaned)
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
case "tool_use":
2026-04-18 14:08:01 +00:00
parts = append(parts, fmt.Sprintf(
2026-04-25 13:18:22 +00:00
"[tool_call name=%q input=%s]",
block.Name, compactJSON(block.Input),
2026-04-18 14:08:01 +00:00
))
case "tool_result":
2026-04-25 13:18:22 +00:00
status := "ok"
2026-04-18 14:08:01 +00:00
if block.IsError {
2026-04-25 13:18:22 +00:00
status = "error"
}
body := renderToolResultContent(block.Content)
if body == "" {
body = "(empty)"
2026-04-18 14:08:01 +00:00
}
parts = append(parts, fmt.Sprintf(
2026-04-25 13:18:22 +00:00
"[tool_result for=%s status=%s]\n%s",
block.ToolUseID, status, body,
2026-04-18 14:08:01 +00:00
))
2026-04-25 13:18:22 +00:00
case "image", "document":
parts = append(parts, fmt.Sprintf("[%s attached]", block.Type))
2026-04-18 14:08:01 +00:00
}
}
return strings.Join(parts, "\n")
}
2026-04-25 13:18:22 +00:00
// renderToolResultContent flattens a tool_result.content payload (which can
// be a string or an array of {type:"text",text:...} blocks) to plain text.
func renderToolResultContent(raw json.RawMessage) string {
2026-04-18 14:08:01 +00:00
if len(raw) == 0 {
return ""
}
2026-04-25 13:18:22 +00:00
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.TrimSpace(s)
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
var blocks []struct {
2026-04-18 14:08:01 +00:00
Type string `json:"type"`
Text string `json:"text"`
}
2026-04-25 13:18:22 +00:00
if err := json.Unmarshal(raw, &blocks); err == nil {
2026-04-18 14:08:01 +00:00
var out []string
2026-04-25 13:18:22 +00:00
for _, b := range blocks {
if b.Type == "text" && b.Text != "" {
out = append(out, b.Text)
2026-04-18 14:08:01 +00:00
}
}
2026-04-25 13:18:22 +00:00
return strings.TrimSpace(strings.Join(out, "\n"))
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
return strings.TrimSpace(string(raw))
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
func compactJSON(raw json.RawMessage) string {
if len(raw) == 0 {
return "{}"
}
var v interface{}
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
out, err := json.Marshal(v)
if err != nil {
return string(raw)
}
return string(out)
}
2026-04-18 14:08:01 +00:00
2026-04-25 13:18:22 +00:00
func singleLine(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
2026-04-18 14:08:01 +00:00
}
2026-04-25 13:18:22 +00:00
return strings.TrimSpace(s)
2026-04-18 14:08:01 +00:00
}