4881eeb7de
- claude_stream_go_core: lanza claude -p --output-format stream-json --verbose, decodifica NDJSON y emite eventos sinteticos (text_delta, tool_use, tool_result, result, error) por canal Go. 10 tests con fake claude bash. - mcp_server_stdio_go_infra: scaffold de MCP server JSON-RPC 2.0 sobre stdio (initialize, tools/list, tools/call, ping). Usuario registra tool defs y handler unico. 9 tests. Usadas por apps/kanban backend para reemplazar el chat HTTP one-shot con XML actions por WebSocket streaming + tool-use nativa. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
335 lines
9.4 KiB
Go
335 lines
9.4 KiB
Go
package core
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// ClaudeEventType es el tipo discriminador de eventos del stream-json de claude -p.
|
|
type ClaudeEventType string
|
|
|
|
const (
|
|
ClaudeEventSystem ClaudeEventType = "system"
|
|
ClaudeEventAssistant ClaudeEventType = "assistant"
|
|
ClaudeEventUser ClaudeEventType = "user" // tool_result
|
|
ClaudeEventResult ClaudeEventType = "result" // final
|
|
ClaudeEventToolUse ClaudeEventType = "tool_use" // sintetico
|
|
ClaudeEventToolResult ClaudeEventType = "tool_result" // sintetico
|
|
ClaudeEventTextDelta ClaudeEventType = "text_delta" // sintetico (porcion legible)
|
|
ClaudeEventError ClaudeEventType = "error" // sintetico
|
|
)
|
|
|
|
// ClaudeEvent es un evento decodificado del stream. Raw siempre contiene la
|
|
// linea NDJSON original para casos no contemplados. Para los tipos comunes,
|
|
// los campos especificos vienen rellenos.
|
|
type ClaudeEvent struct {
|
|
Type ClaudeEventType `json:"type"`
|
|
Raw json.RawMessage `json:"raw,omitempty"`
|
|
|
|
// Para system/init
|
|
Subtype string `json:"subtype,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
|
|
// Para text_delta (sintetico): porcion textual del mensaje del asistente
|
|
Text string `json:"text,omitempty"`
|
|
|
|
// Para tool_use (sintetico)
|
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
|
ToolName string `json:"tool_name,omitempty"`
|
|
ToolInput json.RawMessage `json:"tool_input,omitempty"`
|
|
|
|
// Para tool_result (sintetico)
|
|
ToolResultID string `json:"tool_result_id,omitempty"`
|
|
ToolResultContent string `json:"tool_result_content,omitempty"`
|
|
ToolResultIsError bool `json:"tool_result_is_error,omitempty"`
|
|
|
|
// Para result (final)
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
Result string `json:"result,omitempty"`
|
|
|
|
// Para error
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// ClaudeStreamOpts configura el lanzamiento.
|
|
type ClaudeStreamOpts struct {
|
|
Bin string // default "claude" si vacio
|
|
Args []string // args extra (NO incluyas -p ni --output-format ni --verbose; se añaden automaticamente)
|
|
Stdin io.Reader // prompt user (puede ser nil si Args lleva el prompt en posicional)
|
|
Workdir string // CWD del subprocess
|
|
Env map[string]string // env extra (se mergea con os.Environ())
|
|
Stderr io.Writer // si != nil, recibe stderr del subprocess en vivo
|
|
}
|
|
|
|
// streamRawLine es la estructura minima para detectar el tipo de una linea NDJSON.
|
|
type streamRawLine struct {
|
|
Type ClaudeEventType `json:"type"`
|
|
Subtype string `json:"subtype,omitempty"`
|
|
|
|
// system
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
|
|
// result
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
Result string `json:"result,omitempty"`
|
|
|
|
// assistant / user
|
|
Message *streamMessage `json:"message,omitempty"`
|
|
}
|
|
|
|
type streamMessage struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
|
|
type contentBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
|
Content json.RawMessage `json:"content,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
}
|
|
|
|
// extractToolResultContent extrae el texto de un tool_result content que puede
|
|
// ser string o array de bloques [{type:text,text:"..."}].
|
|
func extractToolResultContent(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
// Intentar como string
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return s
|
|
}
|
|
// Intentar como array de bloques
|
|
var blocks []contentBlock
|
|
if err := json.Unmarshal(raw, &blocks); err != nil {
|
|
return string(raw)
|
|
}
|
|
var sb strings.Builder
|
|
for _, b := range blocks {
|
|
if b.Type == "text" {
|
|
sb.WriteString(b.Text)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// StreamClaude lanza `claude -p --output-format stream-json --verbose <args...>`
|
|
// y retorna un canal de eventos. El canal se cierra cuando termina el subprocess
|
|
// (EOF en stdout). El cancel del ctx mata al subprocess (SIGTERM, luego SIGKILL).
|
|
//
|
|
// La goroutine interna se encarga de:
|
|
// - Leer stdout linea a linea (NDJSON, buffer 4MB).
|
|
// - Decodificar cada linea a un evento del protocolo claude.
|
|
// - Para mensajes "assistant" expandir el array message.content emitiendo
|
|
// ClaudeEventTextDelta por cada bloque text y ClaudeEventToolUse por cada
|
|
// bloque tool_use.
|
|
// - Para mensajes "user" detectar tool_result y emitir ClaudeEventToolResult.
|
|
// - Si stdout emite linea no-JSON, emite ClaudeEventError con el contenido.
|
|
// - Capturar el exit code del subprocess; si != 0 emite ClaudeEventError final.
|
|
//
|
|
// Retorna error si el spawn falla. Si retorna chan != nil, el caller DEBE leerlo
|
|
// hasta que se cierre o cancelar el ctx.
|
|
func StreamClaude(ctx context.Context, opts ClaudeStreamOpts) (<-chan ClaudeEvent, error) {
|
|
bin := opts.Bin
|
|
if bin == "" {
|
|
var err error
|
|
bin, err = exec.LookPath("claude")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claude binary not found: %w", err)
|
|
}
|
|
}
|
|
|
|
args := append([]string{"-p", "--output-format", "stream-json", "--verbose"}, opts.Args...)
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
|
|
if opts.Stdin != nil {
|
|
cmd.Stdin = opts.Stdin
|
|
}
|
|
|
|
if opts.Workdir != "" {
|
|
cmd.Dir = opts.Workdir
|
|
}
|
|
|
|
// Merge env
|
|
if len(opts.Env) > 0 {
|
|
env := os.Environ()
|
|
for k, v := range opts.Env {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
cmd.Env = env
|
|
}
|
|
|
|
// Stderr
|
|
stderrWriter := io.Discard
|
|
if opts.Stderr != nil {
|
|
stderrWriter = opts.Stderr
|
|
}
|
|
|
|
// Capturar stderr para reportar en error final
|
|
var stderrBuf strings.Builder
|
|
cmd.Stderr = io.MultiWriter(stderrWriter, &stderrBuf)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("start claude: %w", err)
|
|
}
|
|
|
|
ch := make(chan ClaudeEvent, 64)
|
|
|
|
go func() {
|
|
defer close(ch)
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
|
|
|
send := func(ev ClaudeEvent) {
|
|
select {
|
|
case ch <- ev:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
|
|
var raw streamRawLine
|
|
if err := json.Unmarshal(line, &raw); err != nil {
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventError,
|
|
Error: fmt.Sprintf("non-json line: %s", string(line)),
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
continue
|
|
}
|
|
|
|
switch raw.Type {
|
|
case ClaudeEventSystem:
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventSystem,
|
|
Subtype: raw.Subtype,
|
|
SessionID: raw.SessionID,
|
|
Model: raw.Model,
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
|
|
case ClaudeEventAssistant:
|
|
if raw.Message != nil && len(raw.Message.Content) > 0 {
|
|
var blocks []contentBlock
|
|
if err := json.Unmarshal(raw.Message.Content, &blocks); err == nil {
|
|
for _, b := range blocks {
|
|
switch b.Type {
|
|
case "text":
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventTextDelta,
|
|
Text: b.Text,
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
case "tool_use":
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventToolUse,
|
|
ToolUseID: b.ID,
|
|
ToolName: b.Name,
|
|
ToolInput: b.Input,
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Emitir tambien el evento assistant crudo
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventAssistant,
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
|
|
case ClaudeEventUser:
|
|
if raw.Message != nil && len(raw.Message.Content) > 0 {
|
|
var blocks []contentBlock
|
|
if err := json.Unmarshal(raw.Message.Content, &blocks); err == nil {
|
|
for _, b := range blocks {
|
|
if b.Type == "tool_result" {
|
|
content := extractToolResultContent(b.Content)
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventToolResult,
|
|
ToolResultID: b.ToolUseID,
|
|
ToolResultContent: content,
|
|
ToolResultIsError: b.IsError,
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventUser,
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
|
|
case ClaudeEventResult:
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventResult,
|
|
Subtype: raw.Subtype,
|
|
SessionID: raw.SessionID,
|
|
StopReason: raw.StopReason,
|
|
IsError: raw.IsError,
|
|
Result: raw.Result,
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
|
|
default:
|
|
send(ClaudeEvent{
|
|
Type: raw.Type,
|
|
Raw: json.RawMessage(line),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Esperar a que termine el subprocess
|
|
if err := cmd.Wait(); err != nil {
|
|
if ctx.Err() != nil {
|
|
// Cancelado por contexto — salida limpia
|
|
return
|
|
}
|
|
stderr := strings.TrimSpace(stderrBuf.String())
|
|
errMsg := fmt.Sprintf("claude exit error: %v", err)
|
|
if stderr != "" {
|
|
// Solo las ultimas lineas para no saturar
|
|
lines := strings.Split(stderr, "\n")
|
|
tail := lines
|
|
if len(lines) > 5 {
|
|
tail = lines[len(lines)-5:]
|
|
}
|
|
errMsg = fmt.Sprintf("claude exit error: %v: %s", err, strings.Join(tail, "; "))
|
|
}
|
|
send(ClaudeEvent{
|
|
Type: ClaudeEventError,
|
|
Error: errMsg,
|
|
})
|
|
}
|
|
}()
|
|
|
|
return ch, nil
|
|
}
|