ant: delete
Somehow I managed to miss this in 4f84ab729ddbf428b0e891940f08f70b4edee05c.
diff --git a/ant/ant.go b/ant/ant.go
deleted file mode 100644
index 0829286..0000000
--- a/ant/ant.go
+++ /dev/null
@@ -1,1033 +0,0 @@
-package ant
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "maps"
- "math/rand/v2"
- "net/http"
- "slices"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/oklog/ulid/v2"
- "github.com/richardlehane/crock32"
- "sketch.dev/skribe"
-)
-
-const (
- DefaultModel = Claude37Sonnet
- // See https://docs.anthropic.com/en/docs/about-claude/models/all-models for
- // current maximums. There's currently a flag to enable 128k output (output-128k-2025-02-19)
- DefaultMaxTokens = 8192
- DefaultURL = "https://api.anthropic.com/v1/messages"
-)
-
-const (
- Claude35Sonnet = "claude-3-5-sonnet-20241022"
- Claude35Haiku = "claude-3-5-haiku-20241022"
- Claude37Sonnet = "claude-3-7-sonnet-20250219"
-)
-
-const (
- MessageRoleUser = "user"
- MessageRoleAssistant = "assistant"
-
- ContentTypeText = "text"
- ContentTypeThinking = "thinking"
- ContentTypeRedactedThinking = "redacted_thinking"
- ContentTypeToolUse = "tool_use"
- ContentTypeToolResult = "tool_result"
-
- StopReasonStopSequence = "stop_sequence"
- StopReasonMaxTokens = "max_tokens"
- StopReasonEndTurn = "end_turn"
- StopReasonToolUse = "tool_use"
-)
-
-type Listener interface {
- // TODO: Content is leaking an anthropic API; should we avoid it?
- // TODO: Where should we include start/end time and usage?
- OnToolCall(ctx context.Context, convo *Convo, toolCallID string, toolName string, toolInput json.RawMessage, content Content)
- OnToolResult(ctx context.Context, convo *Convo, toolCallID string, toolName string, toolInput json.RawMessage, content Content, result *string, err error)
- OnRequest(ctx context.Context, convo *Convo, requestID string, msg *Message)
- OnResponse(ctx context.Context, convo *Convo, requestID string, msg *MessageResponse)
-}
-
-type NoopListener struct{}
-
-func (n *NoopListener) OnToolCall(ctx context.Context, convo *Convo, id string, toolName string, toolInput json.RawMessage, content Content) {
-}
-
-func (n *NoopListener) OnToolResult(ctx context.Context, convo *Convo, id string, toolName string, toolInput json.RawMessage, content Content, result *string, err error) {
-}
-
-func (n *NoopListener) OnResponse(ctx context.Context, convo *Convo, id string, msg *MessageResponse) {
-}
-func (n *NoopListener) OnRequest(ctx context.Context, convo *Convo, id string, msg *Message) {}
-
-type Content struct {
- // TODO: image support?
- // https://docs.anthropic.com/en/api/messages
- ID string `json:"id,omitempty"`
- Type string `json:"type,omitempty"`
- Text string `json:"text,omitempty"`
-
- // for thinking
- Thinking string `json:"thinking,omitempty"`
- Data string `json:"data,omitempty"` // for redacted_thinking
- Signature string `json:"signature,omitempty"` // for thinking
-
- // for tool_use
- ToolName string `json:"name,omitempty"`
- ToolInput json.RawMessage `json:"input,omitempty"`
-
- // for tool_result
- ToolUseID string `json:"tool_use_id,omitempty"`
- ToolError bool `json:"is_error,omitempty"`
- ToolResult string `json:"content,omitempty"`
-
- // timing information for tool_result; not sent to Claude
- StartTime *time.Time `json:"-"`
- EndTime *time.Time `json:"-"`
-
- CacheControl json.RawMessage `json:"cache_control,omitempty"`
-}
-
-func StringContent(s string) Content {
- return Content{Type: ContentTypeText, Text: s}
-}
-
-// Message represents a message in the conversation.
-type Message struct {
- Role string `json:"role"`
- Content []Content `json:"content"`
- ToolUse *ToolUse `json:"tool_use,omitempty"` // use to control whether/which tool to use
-}
-
-// ToolUse represents a tool use in the message content.
-type ToolUse struct {
- ID string `json:"id"`
- Name string `json:"name"`
-}
-
-// Tool represents a tool available to Claude.
-type Tool struct {
- Name string `json:"name"`
- // Type is used by the text editor tool; see
- // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/text-editor-tool
- Type string `json:"type,omitempty"`
- Description string `json:"description,omitempty"`
- InputSchema json.RawMessage `json:"input_schema,omitempty"`
-
- // The Run function is automatically called when the tool is used.
- // Run functions may be called concurrently with each other and themselves.
- // The input to Run function is the input to the tool, as provided by Claude, in compliance with the input schema.
- // The outputs from Run will be sent back to Claude.
- // If you do not want to respond to the tool call request from Claude, return ErrDoNotRespond.
- // ctx contains extra (rarely used) tool call information; retrieve it with ToolCallInfoFromContext.
- Run func(ctx context.Context, input json.RawMessage) (string, error) `json:"-"`
-}
-
-var ErrDoNotRespond = errors.New("do not respond")
-
-// Usage represents the billing and rate-limit usage.
-type Usage struct {
- InputTokens uint64 `json:"input_tokens"`
- CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
- CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
- OutputTokens uint64 `json:"output_tokens"`
- CostUSD float64 `json:"cost_usd"`
-}
-
-func (u *Usage) Add(other Usage) {
- u.InputTokens += other.InputTokens
- u.CacheCreationInputTokens += other.CacheCreationInputTokens
- u.CacheReadInputTokens += other.CacheReadInputTokens
- u.OutputTokens += other.OutputTokens
- u.CostUSD += other.CostUSD
-}
-
-func (u *Usage) String() string {
- return fmt.Sprintf("in: %d, out: %d", u.InputTokens, u.OutputTokens)
-}
-
-func (u *Usage) IsZero() bool {
- return *u == Usage{}
-}
-
-func (u *Usage) Attr() slog.Attr {
- return slog.Group("usage",
- slog.Uint64("input_tokens", u.InputTokens),
- slog.Uint64("output_tokens", u.OutputTokens),
- slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
- slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
- )
-}
-
-type ErrorResponse struct {
- Type string `json:"type"`
- Message string `json:"message"`
-}
-
-// MessageResponse represents the response from the message API.
-type MessageResponse struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Role string `json:"role"`
- Model string `json:"model"`
- Content []Content `json:"content"`
- StopReason string `json:"stop_reason"`
- StopSequence *string `json:"stop_sequence,omitempty"`
- Usage Usage `json:"usage"`
- StartTime *time.Time `json:"start_time,omitempty"`
- EndTime *time.Time `json:"end_time,omitempty"`
-}
-
-func (m *MessageResponse) ToMessage() Message {
- return Message{
- Role: m.Role,
- Content: m.Content,
- }
-}
-
-func (m *MessageResponse) StopSequenceString() string {
- if m.StopSequence == nil {
- return ""
- }
- return *m.StopSequence
-}
-
-const (
- ToolChoiceTypeAuto = "auto" // default
- ToolChoiceTypeAny = "any" // any tool, but must use one
- ToolChoiceTypeNone = "none" // no tools allowed
- ToolChoiceTypeTool = "tool" // must use the tool specified in the Name field
-)
-
-type ToolChoice struct {
- Type string `json:"type"`
- Name string `json:"name,omitempty"`
-}
-
-// https://docs.anthropic.com/en/api/messages#body-system
-type SystemContent struct {
- Text string `json:"text,omitempty"`
- Type string `json:"type,omitempty"`
- CacheControl json.RawMessage `json:"cache_control,omitempty"`
-}
-
-// MessageRequest represents the request payload for creating a message.
-type MessageRequest struct {
- Model string `json:"model"`
- Messages []Message `json:"messages"`
- ToolChoice *ToolChoice `json:"tool_choice,omitempty"`
- MaxTokens int `json:"max_tokens"`
- Tools []*Tool `json:"tools,omitempty"`
- Stream bool `json:"stream,omitempty"`
- System []SystemContent `json:"system,omitempty"`
- Temperature float64 `json:"temperature,omitempty"`
- TopK int `json:"top_k,omitempty"`
- TopP float64 `json:"top_p,omitempty"`
- StopSequences []string `json:"stop_sequences,omitempty"`
-
- TokenEfficientToolUse bool `json:"-"` // DO NOT USE, broken on Anthropic's side as of 2025-02-28
-}
-
-const dumpText = false // debugging toggle to see raw communications with Claude
-
-// createMessage sends a request to the Anthropic message API to create a message.
-func createMessage(ctx context.Context, httpc *http.Client, url, apiKey string, request *MessageRequest) (*MessageResponse, error) {
- var payload []byte
- var err error
- if dumpText || testing.Testing() {
- payload, err = json.MarshalIndent(request, "", " ")
- } else {
- payload, err = json.Marshal(request)
- payload = append(payload, '\n')
- }
- if err != nil {
- return nil, err
- }
-
- if false {
- fmt.Printf("claude request payload:\n%s\n", payload)
- }
-
- backoff := []time.Duration{15 * time.Second, 30 * time.Second, time.Minute}
- largerMaxTokens := false
- var partialUsage Usage
-
- // retry loop
- for attempts := 0; ; attempts++ {
- if dumpText {
- fmt.Printf("RAW REQUEST:\n%s\n\n", payload)
- }
- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("X-API-Key", apiKey)
- req.Header.Set("Anthropic-Version", "2023-06-01")
-
- features := []string{}
-
- if request.TokenEfficientToolUse {
- features = append(features, "token-efficient-tool-use-2025-02-19")
- }
- if largerMaxTokens {
- features = append(features, "output-128k-2025-02-19")
- request.MaxTokens = 128 * 1024
- }
- if len(features) > 0 {
- req.Header.Set("anthropic-beta", strings.Join(features, ","))
- }
-
- resp, err := httpc.Do(req)
- if err != nil {
- return nil, err
- }
- buf, _ := io.ReadAll(resp.Body)
- resp.Body.Close()
-
- switch {
- case resp.StatusCode == http.StatusOK:
- if dumpText {
- fmt.Printf("RAW RESPONSE:\n%s\n\n", buf)
- }
- var response MessageResponse
- err = json.NewDecoder(bytes.NewReader(buf)).Decode(&response)
- if err != nil {
- return nil, err
- }
- if response.StopReason == StopReasonMaxTokens && !largerMaxTokens {
- fmt.Printf("Retrying Anthropic API call with larger max tokens size.")
- // Retry with more output tokens.
- largerMaxTokens = true
- response.Usage.CostUSD = response.TotalDollars()
- partialUsage = response.Usage
- continue
- }
-
- // Calculate and set the cost_usd field
- if largerMaxTokens {
- response.Usage.Add(partialUsage)
- }
- response.Usage.CostUSD = response.TotalDollars()
-
- return &response, nil
- case resp.StatusCode >= 500 && resp.StatusCode < 600:
- // overloaded or unhappy, in one form or another
- sleep := backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
- slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "sleep", sleep)
- time.Sleep(sleep)
- case resp.StatusCode == 429:
- // rate limited. wait 1 minute as a starting point, because that's the rate limiting window.
- // and then add some additional time for backoff.
- sleep := time.Minute + backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
- slog.WarnContext(ctx, "anthropic_request_rate_limited", "response", string(buf), "sleep", sleep)
- time.Sleep(sleep)
- // case resp.StatusCode == 400:
- // TODO: parse ErrorResponse, make (*ErrorResponse) implement error
- default:
- return nil, fmt.Errorf("API request failed with status %s\n%s", resp.Status, buf)
- }
- }
-}
-
-// A Convo is a managed conversation with Claude.
-// It automatically manages the state of the conversation,
-// including appending messages send/received,
-// calling tools and sending their results,
-// tracking usage, etc.
-//
-// Exported fields must not be altered concurrently with calling any method on Convo.
-// Typical usage is to configure a Convo once before using it.
-type Convo struct {
- // ID is a unique ID for the conversation
- ID string
- // Ctx is the context for the entire conversation.
- Ctx context.Context
- // HTTPC is the HTTP client for the conversation.
- HTTPC *http.Client
- // URL is the remote messages URL to dial.
- URL string
- // APIKey is the API key for the conversation.
- APIKey string
- // Model is the model for the conversation.
- Model string
- // MaxTokens is the max tokens for each response in the conversation.
- MaxTokens int
- // Tools are the tools available during the conversation.
- Tools []*Tool
- // SystemPrompt is the system prompt for the conversation.
- SystemPrompt string
- // PromptCaching indicates whether to use Anthropic's prompt caching.
- // See https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#continuing-a-multi-turn-conversation
- // for the documentation. At request send time, we set the cache_control field on the
- // last message. We also cache the system prompt.
- // Default: true.
- PromptCaching bool
- // ToolUseOnly indicates whether Claude may only use tools during this conversation.
- // TODO: add more fine-grained control over tool use?
- ToolUseOnly bool
- // Parent is the parent conversation, if any.
- // It is non-nil for "subagent" calls.
- // It is set automatically when calling SubConvo,
- // and usually should not be set manually.
- Parent *Convo
- // Budget is the budget for this conversation (and all sub-conversations).
- // The Conversation DOES NOT automatically enforce the budget.
- // It is up to the caller to call OverBudget() as appropriate.
- Budget Budget
-
- // messages tracks the messages so far in the conversation.
- messages []Message
-
- // Listener receives messages being sent.
- Listener Listener
-
- muToolUseCancel *sync.Mutex
- toolUseCancel map[string]context.CancelCauseFunc
-
- // Protects usage. This is used for subconversations (that share part of CumulativeUsage) as well.
- mu *sync.Mutex
- // usage tracks usage for this conversation and all sub-conversations.
- usage *CumulativeUsage
-}
-
-// newConvoID generates a new 8-byte random id.
-// The uniqueness/collision requirements here are very low.
-// They are not global identifiers,
-// just enough to distinguish different convos in a single session.
-func newConvoID() string {
- u1 := rand.Uint32()
- s := crock32.Encode(uint64(u1))
- if len(s) < 7 {
- s += strings.Repeat("0", 7-len(s))
- }
- return s[:3] + "-" + s[3:]
-}
-
-// NewConvo creates a new conversation with Claude with sensible defaults.
-// ctx is the context for the entire conversation.
-func NewConvo(ctx context.Context, apiKey string) *Convo {
- id := newConvoID()
- return &Convo{
- Ctx: skribe.ContextWithAttr(ctx, slog.String("convo_id", id)),
- HTTPC: http.DefaultClient,
- URL: DefaultURL,
- APIKey: apiKey,
- Model: DefaultModel,
- MaxTokens: DefaultMaxTokens,
- PromptCaching: true,
- usage: newUsage(),
- Listener: &NoopListener{},
- ID: id,
- muToolUseCancel: &sync.Mutex{},
- toolUseCancel: map[string]context.CancelCauseFunc{},
- mu: &sync.Mutex{},
- }
-}
-
-// SubConvo creates a sub-conversation with the same configuration as the parent conversation.
-// (This propagates context for cancellation, HTTP client, API key, etc.)
-// The sub-conversation shares no messages with the parent conversation.
-// It does not inherit tools from the parent conversation.
-func (c *Convo) SubConvo() *Convo {
- id := newConvoID()
- return &Convo{
- Ctx: skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
- HTTPC: c.HTTPC,
- URL: c.URL,
- APIKey: c.APIKey,
- Model: c.Model,
- MaxTokens: c.MaxTokens,
- PromptCaching: c.PromptCaching,
- Parent: c,
- // For convenience, sub-convo usage shares tool uses map with parent,
- // all other fields separate, propagated in AddResponse
- usage: newUsageWithSharedToolUses(c.usage),
- mu: c.mu,
- Listener: c.Listener,
- ID: id,
- // Do not copy Budget. Each budget is independent,
- // and OverBudget checks whether any ancestor is over budget.
- }
-}
-
-func (c *Convo) SubConvoWithHistory() *Convo {
- id := newConvoID()
- return &Convo{
- Ctx: skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
- HTTPC: c.HTTPC,
- URL: c.URL,
- APIKey: c.APIKey,
- Model: c.Model,
- MaxTokens: c.MaxTokens,
- PromptCaching: c.PromptCaching,
- Parent: c,
- // For convenience, sub-convo usage shares tool uses map with parent,
- // all other fields separate, propagated in AddResponse
- usage: newUsageWithSharedToolUses(c.usage),
- mu: c.mu,
- Listener: c.Listener,
- ID: id,
- // Do not copy Budget. Each budget is independent,
- // and OverBudget checks whether any ancestor is over budget.
- messages: slices.Clone(c.messages),
- }
-}
-
-// Depth reports how many "sub-conversations" deep this conversation is.
-// That it, it walks up parents until it finds a root.
-func (c *Convo) Depth() int {
- x := c
- var depth int
- for x.Parent != nil {
- x = x.Parent
- depth++
- }
- return depth
-}
-
-// SendUserTextMessage sends a text message to Claude in this conversation.
-// otherContents contains additional contents to send with the message, usually tool results.
-func (c *Convo) SendUserTextMessage(s string, otherContents ...Content) (*MessageResponse, error) {
- contents := slices.Clone(otherContents)
- if s != "" {
- contents = append(contents, StringContent(s))
- }
- msg := Message{
- Role: MessageRoleUser,
- Content: contents,
- }
- return c.SendMessage(msg)
-}
-
-func (c *Convo) messageRequest(msg Message) *MessageRequest {
- system := []SystemContent{}
- if c.SystemPrompt != "" {
- var d SystemContent
- d = SystemContent{Type: ContentTypeText, Text: c.SystemPrompt}
- if c.PromptCaching {
- d.CacheControl = json.RawMessage(`{"type":"ephemeral"}`)
- }
- system = []SystemContent{d}
- }
-
- // Claude is happy to return an empty response in response to our Done() call,
- // and, if so, you'll see something like:
- // API request failed with status 400 Bad Request
- // {"type":"error","error": {"type":"invalid_request_error",
- // "message":"messages.5: all messages must have non-empty content except for the optional final assistant message"}}
- // So, we filter out those empty messages.
- var nonEmptyMessages []Message
- for _, m := range c.messages {
- if len(m.Content) > 0 {
- nonEmptyMessages = append(nonEmptyMessages, m)
- }
- }
-
- mr := &MessageRequest{
- Model: c.Model,
- Messages: append(nonEmptyMessages, msg), // not yet committed to keeping msg
- System: system,
- Tools: c.Tools,
- MaxTokens: c.MaxTokens,
- }
- if c.ToolUseOnly {
- mr.ToolChoice = &ToolChoice{Type: ToolChoiceTypeAny}
- }
- return mr
-}
-
-func (c *Convo) findTool(name string) (*Tool, error) {
- for _, tool := range c.Tools {
- if tool.Name == name {
- return tool, nil
- }
- }
- return nil, fmt.Errorf("tool %q not found", name)
-}
-
-// insertMissingToolResults adds error results for tool uses that were requested
-// but not included in the message, which can happen in error paths like "out of budget."
-// We only insert these if there were no tool responses at all, since an incorrect
-// number of tool results would be a programmer error. Mutates inputs.
-func (c *Convo) insertMissingToolResults(mr *MessageRequest, msg *Message) {
- if len(mr.Messages) < 2 {
- return
- }
- prev := mr.Messages[len(mr.Messages)-2]
- var toolUsePrev int
- for _, c := range prev.Content {
- if c.Type == ContentTypeToolUse {
- toolUsePrev++
- }
- }
- if toolUsePrev == 0 {
- return
- }
- var toolUseCurrent int
- for _, c := range msg.Content {
- if c.Type == ContentTypeToolResult {
- toolUseCurrent++
- }
- }
- if toolUseCurrent != 0 {
- return
- }
- var prefix []Content
- for _, part := range prev.Content {
- if part.Type != ContentTypeToolUse {
- continue
- }
- content := Content{
- Type: ContentTypeToolResult,
- ToolUseID: part.ID,
- ToolError: true,
- ToolResult: "not executed; retry possible",
- }
- prefix = append(prefix, content)
- msg.Content = append(prefix, msg.Content...)
- mr.Messages[len(mr.Messages)-1].Content = msg.Content
- }
- slog.DebugContext(c.Ctx, "inserted missing tool results")
-}
-
-// SendMessage sends a message to Claude.
-// The conversation records (internally) all messages succesfully sent and received.
-func (c *Convo) SendMessage(msg Message) (*MessageResponse, error) {
- id := ulid.Make().String()
- mr := c.messageRequest(msg)
- var lastMessage *Message
- if c.PromptCaching {
- lastMessage = &mr.Messages[len(mr.Messages)-1]
- if len(lastMessage.Content) > 0 {
- lastMessage.Content[len(lastMessage.Content)-1].CacheControl = json.RawMessage(`{"type":"ephemeral"}`)
- }
- }
- defer func() {
- if lastMessage == nil {
- return
- }
- if len(lastMessage.Content) > 0 {
- lastMessage.Content[len(lastMessage.Content)-1].CacheControl = []byte{}
- }
- }()
- c.insertMissingToolResults(mr, &msg)
- c.Listener.OnRequest(c.Ctx, c, id, &msg)
-
- startTime := time.Now()
- resp, err := createMessage(c.Ctx, c.HTTPC, c.URL, c.APIKey, mr)
- if resp != nil {
- resp.StartTime = &startTime
- endTime := time.Now()
- resp.EndTime = &endTime
- }
-
- if err != nil {
- c.Listener.OnResponse(c.Ctx, c, id, nil)
- return nil, err
- }
- c.messages = append(c.messages, msg, resp.ToMessage())
- // Propagate usage to all ancestors (including us).
- for x := c; x != nil; x = x.Parent {
- x.usage.AddResponse(resp)
- }
- c.Listener.OnResponse(c.Ctx, c, id, resp)
- return resp, err
-}
-
-type toolCallInfoKeyType string
-
-var toolCallInfoKey toolCallInfoKeyType
-
-type ToolCallInfo struct {
- ToolUseID string
- Convo *Convo
-}
-
-func ToolCallInfoFromContext(ctx context.Context) ToolCallInfo {
- v := ctx.Value(toolCallInfoKey)
- i, _ := v.(ToolCallInfo)
- return i
-}
-
-func (c *Convo) ToolResultCancelContents(resp *MessageResponse) ([]Content, error) {
- if resp.StopReason != StopReasonToolUse {
- return nil, nil
- }
- var toolResults []Content
-
- for _, part := range resp.Content {
- if part.Type != ContentTypeToolUse {
- continue
- }
- c.incrementToolUse(part.ToolName)
-
- content := Content{
- Type: ContentTypeToolResult,
- ToolUseID: part.ID,
- }
-
- content.ToolError = true
- content.ToolResult = "user canceled this too_use"
- toolResults = append(toolResults, content)
- }
- return toolResults, nil
-}
-
-// GetID returns the conversation ID
-func (c *Convo) GetID() string {
- return c.ID
-}
-
-func (c *Convo) CancelToolUse(toolUseID string, err error) error {
- c.muToolUseCancel.Lock()
- defer c.muToolUseCancel.Unlock()
- cancel, ok := c.toolUseCancel[toolUseID]
- if !ok {
- return fmt.Errorf("cannot cancel %s: no cancel function registered for this tool_use_id. All I have is %+v", toolUseID, c.toolUseCancel)
- }
- delete(c.toolUseCancel, toolUseID)
- cancel(err)
- return nil
-}
-
-func (c *Convo) newToolUseContext(ctx context.Context, toolUseID string) (context.Context, context.CancelFunc) {
- c.muToolUseCancel.Lock()
- defer c.muToolUseCancel.Unlock()
- ctx, cancel := context.WithCancelCause(ctx)
- c.toolUseCancel[toolUseID] = cancel
- return ctx, func() { c.CancelToolUse(toolUseID, nil) }
-}
-
-// ToolResultContents runs all tool uses requested by the response and returns their results.
-// Cancelling ctx will cancel any running tool calls.
-func (c *Convo) ToolResultContents(ctx context.Context, resp *MessageResponse) ([]Content, error) {
- if resp.StopReason != StopReasonToolUse {
- return nil, nil
- }
- // Extract all tool calls from the response, call the tools, and gather the results.
- var wg sync.WaitGroup
- toolResultC := make(chan Content, len(resp.Content))
- for _, part := range resp.Content {
- if part.Type != ContentTypeToolUse {
- continue
- }
- c.incrementToolUse(part.ToolName)
- startTime := time.Now()
-
- c.Listener.OnToolCall(ctx, c, part.ID, part.ToolName, part.ToolInput, Content{
- Type: ContentTypeToolUse,
- ToolUseID: part.ID,
- StartTime: &startTime,
- })
-
- wg.Add(1)
- go func() {
- defer wg.Done()
-
- content := Content{
- Type: ContentTypeToolResult,
- ToolUseID: part.ID,
- StartTime: &startTime,
- }
- sendErr := func(err error) {
- // Record end time
- endTime := time.Now()
- content.EndTime = &endTime
-
- content.ToolError = true
- content.ToolResult = err.Error()
- c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, nil, err)
- toolResultC <- content
- }
- sendRes := func(res string) {
- // Record end time
- endTime := time.Now()
- content.EndTime = &endTime
-
- content.ToolResult = res
- c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, &res, nil)
- toolResultC <- content
- }
-
- tool, err := c.findTool(part.ToolName)
- if err != nil {
- sendErr(err)
- return
- }
- // Create a new context for just this tool_use call, and register its
- // cancel function so that it can be canceled individually.
- toolUseCtx, cancel := c.newToolUseContext(ctx, part.ID)
- defer cancel()
- // TODO: move this into newToolUseContext?
- toolUseCtx = context.WithValue(toolUseCtx, toolCallInfoKey, ToolCallInfo{ToolUseID: part.ID, Convo: c})
- toolResult, err := tool.Run(toolUseCtx, part.ToolInput)
- if errors.Is(err, ErrDoNotRespond) {
- return
- }
- if toolUseCtx.Err() != nil {
- sendErr(context.Cause(toolUseCtx))
- return
- }
-
- if err != nil {
- sendErr(err)
- return
- }
- sendRes(toolResult)
- }()
- }
- wg.Wait()
- close(toolResultC)
- var toolResults []Content
- for toolResult := range toolResultC {
- toolResults = append(toolResults, toolResult)
- }
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
- return toolResults, nil
-}
-
-func (c *Convo) incrementToolUse(name string) {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- c.usage.ToolUses[name]++
-}
-
-// ContentsAttr returns contents as a slog.Attr.
-// It is meant for logging.
-func ContentsAttr(contents []Content) slog.Attr {
- var contentAttrs []any // slog.Attr
- for _, content := range contents {
- var attrs []any // slog.Attr
- switch content.Type {
- case ContentTypeText:
- attrs = append(attrs, slog.String("text", content.Text))
- case ContentTypeToolUse:
- attrs = append(attrs, slog.String("tool_name", content.ToolName))
- attrs = append(attrs, slog.String("tool_input", string(content.ToolInput)))
- case ContentTypeToolResult:
- attrs = append(attrs, slog.String("tool_result", content.ToolResult))
- attrs = append(attrs, slog.Bool("tool_error", content.ToolError))
- case ContentTypeThinking:
- attrs = append(attrs, slog.String("thinking", content.Text))
- default:
- attrs = append(attrs, slog.String("unknown_content_type", content.Type))
- attrs = append(attrs, slog.Any("text", content)) // just log it all raw, better to have too much than not enough
- }
- contentAttrs = append(contentAttrs, slog.Group(content.ID, attrs...))
- }
- return slog.Group("contents", contentAttrs...)
-}
-
-// MustSchema validates that schema is a valid JSON schema and returns it as a json.RawMessage.
-// It panics if the schema is invalid.
-func MustSchema(schema string) json.RawMessage {
- // TODO: validate schema, for now just make sure it's valid JSON
- schema = strings.TrimSpace(schema)
- bytes := []byte(schema)
- if !json.Valid(bytes) {
- panic("invalid JSON schema: " + schema)
- }
- return json.RawMessage(bytes)
-}
-
-// cents per million tokens
-// (not dollars because i'm twitchy about using floats for money)
-type centsPer1MTokens struct {
- Input uint64
- Output uint64
- CacheRead uint64
- CacheCreation uint64
-}
-
-// https://www.anthropic.com/pricing#anthropic-api
-var modelCost = map[string]centsPer1MTokens{
- Claude37Sonnet: {
- Input: 300, // $3
- Output: 1500, // $15
- CacheRead: 30, // $0.30
- CacheCreation: 375, // $3.75
- },
- Claude35Haiku: {
- Input: 80, // $0.80
- Output: 400, // $4.00
- CacheRead: 8, // $0.08
- CacheCreation: 100, // $1.00
- },
- Claude35Sonnet: {
- Input: 300, // $3
- Output: 1500, // $15
- CacheRead: 30, // $0.30
- CacheCreation: 375, // $3.75
- },
-}
-
-// TotalDollars returns the total cost to obtain this response, in dollars.
-func (mr *MessageResponse) TotalDollars() float64 {
- cpm, ok := modelCost[mr.Model]
- if !ok {
- panic(fmt.Sprintf("no pricing info for model: %s", mr.Model))
- }
- use := mr.Usage
- megaCents := use.InputTokens*cpm.Input +
- use.OutputTokens*cpm.Output +
- use.CacheReadInputTokens*cpm.CacheRead +
- use.CacheCreationInputTokens*cpm.CacheCreation
- cents := float64(megaCents) / 1_000_000.0
- return cents / 100.0
-}
-
-func newUsage() *CumulativeUsage {
- return &CumulativeUsage{ToolUses: make(map[string]int), StartTime: time.Now()}
-}
-
-func newUsageWithSharedToolUses(parent *CumulativeUsage) *CumulativeUsage {
- return &CumulativeUsage{ToolUses: parent.ToolUses, StartTime: time.Now()}
-}
-
-// CumulativeUsage represents cumulative usage across a Convo, including all sub-conversations.
-type CumulativeUsage struct {
- StartTime time.Time `json:"start_time"`
- Responses uint64 `json:"messages"` // count of responses
- InputTokens uint64 `json:"input_tokens"`
- OutputTokens uint64 `json:"output_tokens"`
- CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
- CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
- TotalCostUSD float64 `json:"total_cost_usd"`
- ToolUses map[string]int `json:"tool_uses"` // tool name -> number of uses
-}
-
-func (u *CumulativeUsage) Clone() CumulativeUsage {
- v := *u
- v.ToolUses = maps.Clone(u.ToolUses)
- return v
-}
-
-func (c *Convo) CumulativeUsage() CumulativeUsage {
- if c == nil {
- return CumulativeUsage{}
- }
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.usage.Clone()
-}
-
-func (u *CumulativeUsage) WallTime() time.Duration {
- return time.Since(u.StartTime)
-}
-
-func (u *CumulativeUsage) DollarsPerHour() float64 {
- hours := u.WallTime().Hours()
- if hours == 0 {
- return 0
- }
- return u.TotalCostUSD / hours
-}
-
-func (u *CumulativeUsage) AddResponse(resp *MessageResponse) {
- usage := resp.Usage
- u.Responses++
- u.InputTokens += usage.InputTokens
- u.OutputTokens += usage.OutputTokens
- u.CacheReadInputTokens += usage.CacheReadInputTokens
- u.CacheCreationInputTokens += usage.CacheCreationInputTokens
- u.TotalCostUSD += resp.TotalDollars()
-}
-
-// TotalInputTokens returns the grand total cumulative input tokens in u.
-func (u *CumulativeUsage) TotalInputTokens() uint64 {
- return u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens
-}
-
-// Attr returns the cumulative usage as a slog.Attr with key "usage".
-func (u CumulativeUsage) Attr() slog.Attr {
- elapsed := time.Since(u.StartTime)
- return slog.Group("usage",
- slog.Duration("wall_time", elapsed),
- slog.Uint64("responses", u.Responses),
- slog.Uint64("input_tokens", u.InputTokens),
- slog.Uint64("output_tokens", u.OutputTokens),
- slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
- slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
- slog.Float64("total_cost_usd", u.TotalCostUSD),
- slog.Float64("dollars_per_hour", u.TotalCostUSD/elapsed.Hours()),
- slog.Any("tool_uses", maps.Clone(u.ToolUses)),
- )
-}
-
-// A Budget represents the maximum amount of resources that may be spent on a conversation.
-// Note that the default (zero) budget is unlimited.
-type Budget struct {
- MaxResponses uint64 // if > 0, max number of iterations (=responses)
- MaxDollars float64 // if > 0, max dollars that may be spent
- MaxWallTime time.Duration // if > 0, max wall time that may be spent
-}
-
-// OverBudget returns an error if the convo (or any of its parents) has exceeded its budget.
-// TODO: document parent vs sub budgets, multiple errors, etc, once we know the desired behavior.
-func (c *Convo) OverBudget() error {
- for x := c; x != nil; x = x.Parent {
- if err := x.overBudget(); err != nil {
- return err
- }
- }
- return nil
-}
-
-// ResetBudget sets the budget to the passed in budget and
-// adjusts it by what's been used so far.
-func (c *Convo) ResetBudget(budget Budget) {
- c.Budget = budget
- if c.Budget.MaxDollars > 0 {
- c.Budget.MaxDollars += c.CumulativeUsage().TotalCostUSD
- }
- if c.Budget.MaxResponses > 0 {
- c.Budget.MaxResponses += c.CumulativeUsage().Responses
- }
- if c.Budget.MaxWallTime > 0 {
- c.Budget.MaxWallTime += c.usage.WallTime()
- }
-}
-
-func (c *Convo) overBudget() error {
- usage := c.CumulativeUsage()
- // TODO: stop before we exceed the budget instead of after?
- // Top priority is money, then time, then response count.
- var err error
- cont := "Continuing to chat will reset the budget."
- if c.Budget.MaxDollars > 0 && usage.TotalCostUSD >= c.Budget.MaxDollars {
- err = errors.Join(err, fmt.Errorf("$%.2f spent, budget is $%.2f. %s", usage.TotalCostUSD, c.Budget.MaxDollars, cont))
- }
- if c.Budget.MaxWallTime > 0 && usage.WallTime() >= c.Budget.MaxWallTime {
- err = errors.Join(err, fmt.Errorf("%v elapsed, budget is %v. %s", usage.WallTime().Truncate(time.Second), c.Budget.MaxWallTime.Truncate(time.Second), cont))
- }
- if c.Budget.MaxResponses > 0 && usage.Responses >= c.Budget.MaxResponses {
- err = errors.Join(err, fmt.Errorf("%d responses received, budget is %d. %s", usage.Responses, c.Budget.MaxResponses, cont))
- }
- return err
-}
-
-// UserStringMessage creates a user message with a single text content item.
-func UserStringMessage(text string) Message {
- return Message{
- Role: MessageRoleUser,
- Content: []Content{StringContent(text)},
- }
-}