Files
kandev/apps/backend/pkg/amp/client.go
T

289 lines
7.4 KiB
Go

// Package amp provides a client wrapper for the Sourcegraph Amp CLI.
// This package handles communication with the Amp CLI via stream-json protocol.
package amp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"sync"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Message types for Amp stream-json protocol.
const (
MessageTypeSystem = "system"
MessageTypeAssistant = "assistant"
MessageTypeResult = "result"
MessageTypeUser = "user"
)
// Content block types.
const (
ContentTypeText = "text"
ContentTypeThinking = "thinking"
ContentTypeToolUse = "tool_use"
ContentTypeToolResult = "tool_result"
)
// Client wraps communication with Amp CLI via stdin/stdout.
type Client struct {
stdin io.Writer
stdout io.Reader
logger *logger.Logger
scanner *bufio.Scanner
// Message handler
messageHandler func(*Message)
handlerMu sync.RWMutex
// Context for lifecycle
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// State
threadID string
mu sync.RWMutex
}
// Message represents a message from Amp.
type Message struct {
Type string `json:"type"`
ThreadID string `json:"thread_id,omitempty"`
Message *Content `json:"message,omitempty"`
// Result fields (when Type == "result")
IsError bool `json:"is_error,omitempty"`
Error string `json:"error,omitempty"` // Error message when is_error is true
Subtype string `json:"subtype,omitempty"` // e.g., "error_during_execution"
CostUSD float64 `json:"cost_usd,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
TotalInputTokens int64 `json:"total_input_tokens,omitempty"`
TotalOutputTokens int64 `json:"total_output_tokens,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ModelUsage map[string]*Usage `json:"model_usage,omitempty"`
}
// Content represents the message content.
type Content struct {
Model string `json:"model,omitempty"`
Content []ContentBlock `json:"content,omitempty"`
Usage *TokenUsage `json:"usage,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
StopSequence string `json:"stop_sequence,omitempty"`
}
// ContentBlock represents a content block (text, tool_use, etc.).
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Thinking string `json:"thinking,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]any `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content any `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
}
// TokenUsage represents token usage information.
type TokenUsage struct {
InputTokens int64 `json:"input_tokens,omitempty"`
OutputTokens int64 `json:"output_tokens,omitempty"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"`
}
// Usage represents model-specific usage stats.
type Usage struct {
InputTokens int64 `json:"input_tokens,omitempty"`
OutputTokens int64 `json:"output_tokens,omitempty"`
ContextWindow *int64 `json:"context_window,omitempty"`
}
// ResultData represents the result data structure.
type ResultData struct {
SessionID string `json:"session_id,omitempty"`
ThreadID string `json:"thread_id,omitempty"`
Text string `json:"text,omitempty"`
}
// UserMessage represents a user message to send (Claude Code-compatible format).
type UserMessage struct {
Type string `json:"type"` // "user"
Message UserMessageBody `json:"message"`
}
// UserMessageBody is the nested message body for user messages.
type UserMessageBody struct {
Role string `json:"role"` // "user"
Content []UserContentBlock `json:"content"`
}
// UserContentBlock represents a content block in user messages.
type UserContentBlock struct {
Type string `json:"type"` // "text"
Text string `json:"text"`
}
// NewClient creates a new Amp client.
func NewClient(stdin io.Writer, stdout io.Reader, log *logger.Logger) *Client {
ctx, cancel := context.WithCancel(context.Background())
return &Client{
stdin: stdin,
stdout: stdout,
logger: log.WithFields(zap.String("component", "amp-client")),
ctx: ctx,
cancel: cancel,
}
}
// SetMessageHandler sets the handler for incoming messages.
func (c *Client) SetMessageHandler(handler func(*Message)) {
c.handlerMu.Lock()
defer c.handlerMu.Unlock()
c.messageHandler = handler
}
// Start begins reading from stdout and dispatching messages.
func (c *Client) Start(ctx context.Context) {
c.scanner = bufio.NewScanner(c.stdout)
// Allow larger buffer for JSON messages
c.scanner.Buffer(make([]byte, 64*1024), 1024*1024)
c.wg.Add(1)
go c.readLoop(ctx)
}
// Stop stops the client and waits for goroutines to finish.
func (c *Client) Stop() {
c.cancel()
c.wg.Wait()
}
// readLoop reads JSON lines from stdout and dispatches them.
func (c *Client) readLoop(ctx context.Context) {
defer c.wg.Done()
for {
select {
case <-ctx.Done():
return
case <-c.ctx.Done():
return
default:
}
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
c.logger.Error("scanner error", zap.Error(err))
}
return
}
line := c.scanner.Text()
if line == "" {
continue
}
c.handleLine(line)
}
}
// handleLine parses and dispatches a JSON line.
func (c *Client) handleLine(line string) {
var msg Message
if err := json.Unmarshal([]byte(line), &msg); err != nil {
c.logger.Debug("failed to parse message", zap.Error(err), zap.String("line", line))
return
}
// Update thread ID if provided
if msg.ThreadID != "" {
c.mu.Lock()
c.threadID = msg.ThreadID
c.mu.Unlock()
}
c.handlerMu.RLock()
handler := c.messageHandler
c.handlerMu.RUnlock()
if handler != nil {
handler(&msg)
}
}
// SendUserMessage sends a user message to Amp.
func (c *Client) SendUserMessage(message string) error {
msg := &UserMessage{
Type: "user",
Message: UserMessageBody{
Role: "user",
Content: []UserContentBlock{
{Type: "text", Text: message},
},
},
}
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
if _, err := c.stdin.Write(append(data, '\n')); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
return nil
}
// GetThreadID returns the current thread ID.
func (c *Client) GetThreadID() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.threadID
}
// SetThreadID sets the current thread ID.
func (c *Client) SetThreadID(threadID string) {
c.mu.Lock()
defer c.mu.Unlock()
c.threadID = threadID
}
// GetResultData extracts ResultData from a result message.
func (m *Message) GetResultData() *ResultData {
if len(m.Result) == 0 {
return nil
}
var data ResultData
if err := json.Unmarshal(m.Result, &data); err != nil {
return nil
}
return &data
}
// GetResultString extracts a string result (for error messages).
func (m *Message) GetResultString() string {
if len(m.Result) == 0 {
return ""
}
var s string
if err := json.Unmarshal(m.Result, &s); err != nil {
return ""
}
return s
}