191 lines
5.5 KiB
Go
191 lines
5.5 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/daniel/cursor-adapter/internal/sanitize"
|
|
"github.com/daniel/cursor-adapter/internal/types"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
|
|
if hints := renderMountHints(extractMountHints(req)); hints != "" {
|
|
prompt.WriteString(hints)
|
|
prompt.WriteString("\n")
|
|
}
|
|
|
|
if toolsBlock := renderToolsForBrain(req.Tools); toolsBlock != "" {
|
|
prompt.WriteString(toolsBlock)
|
|
prompt.WriteString("\n")
|
|
}
|
|
|
|
for _, msg := range req.Messages {
|
|
text := renderMessageBlocks(msg.Role, msg.Content)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
switch msg.Role {
|
|
case "assistant":
|
|
prompt.WriteString("Assistant: ")
|
|
default:
|
|
prompt.WriteString("User: ")
|
|
}
|
|
prompt.WriteString(text)
|
|
prompt.WriteString("\n\n")
|
|
}
|
|
|
|
prompt.WriteString("Assistant:")
|
|
return prompt.String()
|
|
}
|
|
|
|
// 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 {
|
|
var parts []string
|
|
for _, block := range content {
|
|
switch block.Type {
|
|
case "text":
|
|
if block.Text == "" {
|
|
continue
|
|
}
|
|
cleaned := systemReminderRe.ReplaceAllString(block.Text, "")
|
|
cleaned = sanitize.Text(cleaned)
|
|
cleaned = strings.TrimSpace(cleaned)
|
|
if cleaned != "" {
|
|
parts = append(parts, cleaned)
|
|
}
|
|
case "tool_use":
|
|
parts = append(parts, fmt.Sprintf(
|
|
"[tool_call name=%q input=%s]",
|
|
block.Name, compactJSON(block.Input),
|
|
))
|
|
case "tool_result":
|
|
status := "ok"
|
|
if block.IsError {
|
|
status = "error"
|
|
}
|
|
body := renderToolResultContent(block.Content)
|
|
if body == "" {
|
|
body = "(empty)"
|
|
}
|
|
parts = append(parts, fmt.Sprintf(
|
|
"[tool_result for=%s status=%s]\n%s",
|
|
block.ToolUseID, status, body,
|
|
))
|
|
case "image", "document":
|
|
parts = append(parts, fmt.Sprintf("[%s attached]", block.Type))
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
// 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 {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
var blocks []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(raw, &blocks); err == nil {
|
|
var out []string
|
|
for _, b := range blocks {
|
|
if b.Type == "text" && b.Text != "" {
|
|
out = append(out, b.Text)
|
|
}
|
|
}
|
|
return strings.TrimSpace(strings.Join(out, "\n"))
|
|
}
|
|
return strings.TrimSpace(string(raw))
|
|
}
|
|
|
|
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)
|
|
}
|
|
out, err := json.Marshal(v)
|
|
if err != nil {
|
|
return string(raw)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func singleLine(s string) string {
|
|
s = strings.ReplaceAll(s, "\r", " ")
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
for strings.Contains(s, " ") {
|
|
s = strings.ReplaceAll(s, " ", " ")
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|