yao/agent/sandbox/v2/claude/parse.go
Max f230f1e90c feat(workspace): enhance attachment handling and execution context
- Added support for reading files from workspace URIs in the delivery process, allowing for more flexible attachment management.
- Introduced a new `convertWorkspaceAttachment` function to handle workspace-based file retrieval and integration into messenger attachments.
- Updated the `AgentCaller` to include execution mode in the context, improving task execution tracking.
- Enhanced the `RunDelivery` method to utilize workspace manifests for delivery input, reducing token usage and improving efficiency.
- Implemented locale handling in various request structures to support multi-language capabilities in user interfaces.
2026-05-07 19:32:48 +08:00

655 lines
16 KiB
Go

package claude
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
)
// streamParser is an explicit state machine for Claude CLI stream-json output.
//
// Each tool call gets its own message lifecycle:
//
// content_block_start -> message_start(id=exec-N-xxx) + ChunkExecute{tool, status:running}
// input_json_delta -> ChunkExecute{input_delta:...} (same message group)
// content_block_stop -> message_end(exec-N-xxx) (streaming phase ends, tool kept in buffer)
// ...later...
// user/tool_result -> message_start(id=exec-N-xxx, reuse!) + ChunkExecute{status:completed, output:...} + message_end
//
// For parallel tool calls, multiple tools may be in-flight simultaneously.
// The tools buffer keeps each tool's state until its tool_result arrives.
type streamParser struct {
handler message.StreamFunc
completed bool
textActive bool
toolIndex int
activeToolID string // tool currently receiving content_block_delta
tools map[string]*toolState // tool_id -> buffered tool state
toolNames map[string]string // tool_id -> tool_name
toolMsgIDs map[string]string // tool_id -> message_id (for result reuse)
toolInputs map[string]string // tool_id -> full input JSON (for result replay)
toolSummaries map[string]string // tool_id -> summary (for result replay)
}
type toolState struct {
id string
name string
msgID string
index int
inputJSON strings.Builder
}
func newStreamParser(handler message.StreamFunc) *streamParser {
return &streamParser{
handler: handler,
tools: make(map[string]*toolState),
toolNames: make(map[string]string),
toolMsgIDs: make(map[string]string),
toolInputs: make(map[string]string),
toolSummaries: make(map[string]string),
}
}
func (p *streamParser) activeTool() *toolState {
if p.activeToolID == "" {
return nil
}
return p.tools[p.activeToolID]
}
func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
doneParsing := make(chan struct{})
defer close(doneParsing)
go func() {
select {
case <-ctx.Done():
stdout.Close()
case <-doneParsing:
}
}()
reader := bufio.NewReaderSize(stdout, 64*1024)
startTime := time.Now()
lineCount := 0
lastHeartbeat := time.Now()
lastEventType := ""
log.Trace("[claude-parse] stream started")
for {
line, skipped, err := shared.ReadJSONLine(reader)
if err == io.EOF {
break
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
if skipped {
log.Warn("[claude-parse] skipped oversized JSONL line (>%dMB)", shared.MaxLineSize/1024/1024)
continue
}
if len(line) == 0 {
continue
}
lineCount++
if time.Since(lastHeartbeat) > 30*time.Second {
builderLen := 0
if t := p.activeTool(); t != nil {
builderLen = t.inputJSON.Len()
}
log.Trace("[claude-parse] heartbeat: lines=%d elapsed=%v lastEvent=%s toolBuilderLen=%d",
lineCount, time.Since(startTime).Round(time.Second), lastEventType, builderLen)
lastHeartbeat = time.Now()
}
var msg map[string]any
if err := json.Unmarshal(line, &msg); err != nil {
if len(line) > 200 {
log.Trace("[claude-parse] JSON unmarshal error: %v (line len=%d, prefix=%q)", err, len(line), string(line[:200]))
} else {
log.Trace("[claude-parse] JSON unmarshal error: %v (line=%q)", err, string(line))
}
continue
}
msgType, _ := msg["type"].(string)
lastEventType = msgType
var stopped bool
switch msgType {
case "system":
stopped = p.handleSystem(msg)
case "stream_event":
stopped = p.handleStreamEvent(msg)
case "assistant":
stopped = p.handleAssistant(msg)
case "user":
stopped = p.handleUser(msg)
case "result":
log.Trace("[claude-parse] stream ended: lines=%d elapsed=%v completed=true", lineCount, time.Since(startTime).Round(time.Second))
return p.handleResult(msg)
case "error":
log.Trace("[claude-parse] stream ended with error: lines=%d elapsed=%v", lineCount, time.Since(startTime).Round(time.Second))
return p.handleError(msg)
}
if stopped {
log.Trace("[claude-parse] stream stopped by handler: lines=%d elapsed=%v", lineCount, time.Since(startTime).Round(time.Second))
return nil
}
}
log.Trace("[claude-parse] stream ended: lines=%d elapsed=%v completed=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed)
return nil
}
// --- Message lifecycle helpers ---
func (p *streamParser) beginMessageWithID(id, msgType string) (stopped bool) {
startData := message.EventMessageStartData{
MessageID: id,
Type: msgType,
Timestamp: time.Now().UnixMilli(),
}
sd, _ := json.Marshal(startData)
return p.handler != nil && p.handler(message.ChunkMessageStart, sd) != 0
}
func (p *streamParser) beginMessage(msgType string) (messageID string, stopped bool) {
id := fmt.Sprintf("sandbox-%s-%s", msgType, message.GenerateNanoID())
return id, p.beginMessageWithID(id, msgType)
}
func (p *streamParser) endMessage() {
if p.handler != nil {
p.handler(message.ChunkMessageEnd, nil)
}
}
func (p *streamParser) closeTextMessage() {
if p.textActive {
p.endMessage()
p.textActive = false
}
}
// closeStreamingTool closes the currently streaming tool's message group,
// flushing accumulated input and emitting message_end. The tool remains
// in p.tools so handleUser can later reuse its msgID for the completed phase.
func (p *streamParser) closeStreamingTool() {
t := p.activeTool()
if t == nil {
return
}
inputStr := t.inputJSON.String()
if inputStr != "" {
p.toolInputs[t.id] = inputStr
summary := extractSummary(t.name, inputStr)
if summary != "" {
p.toolSummaries[t.id] = summary
p.emitExecute(map[string]any{
"summary": summary,
})
}
}
p.endMessage()
p.activeToolID = ""
}
// suspendStreamingTool temporarily closes the active tool's message group
// (emits message_end) so another message group can be opened. The tool
// remains in p.tools and p.activeToolID is cleared. Call resumeStreamingTool
// to reopen it.
func (p *streamParser) suspendStreamingTool() {
t := p.activeTool()
if t == nil {
return
}
p.endMessage()
p.activeToolID = ""
}
// resumeStreamingTool reopens a previously suspended tool's message group
// by emitting a new message_start with the same msgID, and restores it as
// the active streaming tool.
func (p *streamParser) resumeStreamingTool(toolID string) {
t, ok := p.tools[toolID]
if !ok {
return
}
p.beginMessageWithID(t.msgID, "execute")
p.activeToolID = toolID
}
func (p *streamParser) ensureTextMessage() (stopped bool) {
if !p.textActive {
_, stopped = p.beginMessage("text")
if stopped {
return true
}
p.textActive = true
}
return false
}
func (p *streamParser) emitText(text string) (stopped bool) {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
return p.handler != nil && p.handler(message.ChunkText, []byte(text)) != 0
}
func (p *streamParser) emitExecute(props map[string]any) (stopped bool) {
data, _ := json.Marshal(props)
return p.handler != nil && p.handler(message.ChunkExecute, data) != 0
}
func (p *streamParser) emitMetadata(data map[string]any) {
if p.handler == nil {
return
}
encoded, _ := json.Marshal(data)
p.handler(message.ChunkMetadata, encoded)
}
// extractSummary builds a short human-readable summary from the tool input JSON.
func extractSummary(toolName string, inputJSON string) string {
if inputJSON == "" {
return ""
}
var obj map[string]any
if err := json.Unmarshal([]byte(inputJSON), &obj); err != nil {
return ""
}
switch strings.ToLower(toolName) {
case "bash", "execute":
if cmd, ok := obj["command"].(string); ok {
return truncate(cmd, 80)
}
case "write", "create":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
case "read":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
case "edit":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
}
for _, key := range []string{"path", "file_path", "command", "url", "query"} {
if v, ok := obj[key].(string); ok {
return truncate(v, 80)
}
}
return ""
}
func truncate(s string, max int) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > max {
return s[:max] + "..."
}
return s
}
// --- Event handlers ---
func (p *streamParser) handleSystem(msg map[string]any) (stopped bool) {
if p.handler != nil {
data, _ := json.Marshal(msg)
return p.handler(message.ChunkMetadata, data) != 0
}
return false
}
func (p *streamParser) handleStreamEvent(msg map[string]any) (stopped bool) {
event, _ := msg["event"].(map[string]any)
if event == nil {
return false
}
eventType, _ := event["type"].(string)
switch eventType {
case "content_block_start":
return p.onContentBlockStart(event)
case "content_block_delta":
return p.onContentBlockDelta(event)
case "content_block_stop":
return p.onContentBlockStop()
}
return false
}
func (p *streamParser) onContentBlockStart(event map[string]any) (stopped bool) {
cb, ok := event["content_block"].(map[string]any)
if !ok {
return false
}
blockType, _ := cb["type"].(string)
if blockType != "tool_use" {
return false
}
p.closeTextMessage()
toolName, _ := cb["name"].(string)
toolID, _ := cb["id"].(string)
if toolID == "" {
toolID = fmt.Sprintf("tool_%d_%d", p.toolIndex, time.Now().UnixNano())
}
msgID, stopped := p.beginMessage("execute")
if stopped {
return true
}
ts := &toolState{id: toolID, name: toolName, msgID: msgID, index: p.toolIndex}
p.tools[toolID] = ts
p.activeToolID = toolID
p.toolIndex++
p.toolNames[toolID] = toolName
p.toolMsgIDs[toolID] = msgID
if p.handler == nil {
return false
}
return p.emitExecute(map[string]any{
"tool": toolName,
"tool_id": toolID,
"status": "running",
"runner": "claude-cli",
})
}
func (p *streamParser) onContentBlockStop() (stopped bool) {
p.closeStreamingTool()
return false
}
func (p *streamParser) onContentBlockDelta(event map[string]any) (stopped bool) {
delta, ok := event["delta"].(map[string]any)
if !ok {
return false
}
deltaType, _ := delta["type"].(string)
switch deltaType {
case "text_delta":
text, _ := delta["text"].(string)
if text == "" {
return false
}
if !p.textActive && strings.TrimSpace(text) == "" {
return false
}
if p.ensureTextMessage() {
return true
}
return p.emitText(text)
case "input_json_delta":
t := p.activeTool()
if t == nil {
return false
}
partial, _ := delta["partial_json"].(string)
if partial == "" {
return false
}
t.inputJSON.WriteString(partial)
builderLen := t.inputJSON.Len()
if builderLen > 0 && builderLen%100000 < len(partial) {
log.Trace("[claude-parse] WARN: tool %s inputJSON growing: %d bytes", t.name, builderLen)
}
if p.handler != nil {
return p.emitExecute(map[string]any{
"input_delta": t.inputJSON.String(),
})
}
}
return false
}
func (p *streamParser) handleAssistant(msg map[string]any) (stopped bool) {
msgData, _ := msg["message"].(map[string]any)
if msgData == nil {
return false
}
if usage, ok := msgData["usage"].(map[string]any); ok {
p.emitMetadata(map[string]any{
"usage": usage,
})
}
stopReason, _ := msgData["stop_reason"].(string)
if stopReason == "" {
return false
}
contentArr, _ := msgData["content"].([]any)
for _, item := range contentArr {
ci, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := ci["type"].(string)
if itemType == "tool_use" && p.handler != nil {
toolID, _ := ci["id"].(string)
if _, alreadyStreamed := p.toolNames[toolID]; alreadyStreamed && toolID != "" {
continue
}
p.closeTextMessage()
p.closeStreamingTool()
toolName, _ := ci["name"].(string)
if toolID == "" {
toolID = fmt.Sprintf("tool_%d_%d", p.toolIndex, time.Now().UnixNano())
}
msgID, stopped := p.beginMessage("execute")
if stopped {
return true
}
p.toolIndex++
p.toolNames[toolID] = toolName
p.toolMsgIDs[toolID] = msgID
inputRaw, _ := json.Marshal(ci["input"])
inputStr := string(inputRaw)
p.toolInputs[toolID] = inputStr
summary := extractSummary(toolName, inputStr)
p.toolSummaries[toolID] = summary
if p.emitExecute(map[string]any{
"tool": toolName,
"tool_id": toolID,
"input": json.RawMessage(inputRaw),
"summary": summary,
"status": "running",
"runner": "claude-cli",
}) {
p.endMessage()
return true
}
p.endMessage()
}
if itemType == "text" {
text, _ := ci["text"].(string)
if text == "" || p.handler == nil {
continue
}
if p.ensureTextMessage() {
return true
}
if p.emitText(text) {
return true
}
}
}
p.closeTextMessage()
return false
}
func (p *streamParser) handleUser(msg map[string]any) (stopped bool) {
msgData, _ := msg["message"].(map[string]any)
if msgData == nil {
return false
}
contentArr, _ := msgData["content"].([]interface{})
for _, item := range contentArr {
ci, ok := item.(map[string]any)
if !ok {
continue
}
ciType, _ := ci["type"].(string)
if ciType != "tool_result" {
continue
}
toolUseID, _ := ci["tool_use_id"].(string)
// If the result belongs to the actively streaming tool, close its
// streaming phase (emits message_end for the running group).
if p.activeToolID == toolUseID {
p.closeStreamingTool()
}
// If a DIFFERENT tool is currently streaming, we must suspend its
// message group before opening the completed-result group, because
// the downstream handler only tracks one currentGroupID at a time.
suspendedToolID := ""
if p.activeToolID != "" && p.activeToolID != toolUseID {
suspendedToolID = p.activeToolID
p.suspendStreamingTool()
}
p.closeTextMessage()
content := ci["content"]
isError, _ := ci["is_error"].(bool)
status := "completed"
if isError {
status = "error"
}
execProps := map[string]any{
"tool_id": toolUseID,
"output": content,
"status": status,
"is_error": isError,
}
if name, ok := p.toolNames[toolUseID]; ok {
execProps["tool"] = name
}
if input, ok := p.toolInputs[toolUseID]; ok {
execProps["input"] = json.RawMessage(input)
}
if summary, ok := p.toolSummaries[toolUseID]; ok {
execProps["summary"] = summary
}
if reuseMsgID, ok := p.toolMsgIDs[toolUseID]; ok {
if p.beginMessageWithID(reuseMsgID, "execute") {
return true
}
} else {
if _, stopped := p.beginMessage("execute"); stopped {
return true
}
}
if p.emitExecute(execProps) {
p.endMessage()
return true
}
p.endMessage()
delete(p.tools, toolUseID)
// Resume the suspended tool's message group so subsequent
// content_block_delta events land in the correct group.
if suspendedToolID != "" {
p.resumeStreamingTool(suspendedToolID)
}
}
return false
}
func (p *streamParser) handleResult(msg map[string]any) error {
isError, _ := msg["is_error"].(bool)
if isError {
if result, ok := msg["result"].(string); ok {
if p.handler != nil {
p.handler(message.ChunkError, []byte(result))
}
return fmt.Errorf("Claude CLI error: %s", result)
}
}
p.closeTextMessage()
if p.handler != nil {
p.emitMetadata(map[string]any{
"result_summary": map[string]any{
"total_cost_usd": msg["total_cost_usd"],
"duration_ms": msg["duration_ms"],
"num_turns": msg["num_turns"],
"usage": msg["usage"],
},
})
}
p.completed = true
return nil
}
func (p *streamParser) handleError(msg map[string]any) error {
var errMsg string
switch e := msg["error"].(type) {
case string:
errMsg = e
case map[string]any:
errMsg, _ = e["message"].(string)
}
if errMsg != "" {
if p.handler != nil {
p.handler(message.ChunkError, []byte(errMsg))
}
return fmt.Errorf("Claude CLI error: %s", errMsg)
}
return nil
}