171 lines
5.0 KiB
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")
|
|
}
|