Files
fn_registry/functions/core/claude_stream.go
T
egutierrez 4881eeb7de feat(registry): claude_stream + mcp_server_stdio para chat con tool-use
- 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>
2026-05-09 14:54:56 +02:00

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
}