509 lines
12 KiB
Go
509 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
)
|
|
|
|
// lineEmitter parses one stdout line (already trimmed of trailing newline) and
|
|
// returns the resulting RuntimeEvents to push to the sink. Empty events are
|
|
// skipped. Returning a non-nil error aborts the run.
|
|
type lineEmitter interface {
|
|
Name() string
|
|
Emit(line string) ([]runtime.RuntimeEvent, error)
|
|
}
|
|
|
|
// registeredEmitter wraps a lineEmitter with its maximum scanner buffer size.
|
|
type registeredEmitter struct {
|
|
emitter lineEmitter
|
|
scanBufMax int
|
|
}
|
|
|
|
// jsonEmitters maps OutputFormat names to their registered emitter + buffer config.
|
|
var jsonEmitters = map[string]registeredEmitter{
|
|
"stream-json": {emitter: streamJSONEmitter{}, scanBufMax: 4 * 1024 * 1024},
|
|
"claude-json": {emitter: claudeJSONEmitter{}, scanBufMax: 8 * 1024 * 1024},
|
|
"codex-json": {emitter: codexJSONEmitter{}, scanBufMax: 4 * 1024 * 1024},
|
|
"opencode-json": {emitter: opencodeJSONEmitter{}, scanBufMax: 4 * 1024 * 1024},
|
|
"cline-json": {emitter: clineJSONEmitter{}, scanBufMax: 8 * 1024 * 1024},
|
|
}
|
|
|
|
// driveJSONLines runs a shared scanner loop over stdout, accumulates raw output
|
|
// into outBuf, dispatches each line through the emitter, and forwards events
|
|
// to sink. Returns total OutputTokens approximation.
|
|
func driveJSONLines(
|
|
ctx context.Context,
|
|
stdout io.Reader,
|
|
sink runtime.EventSink,
|
|
runID string,
|
|
outBuf *strings.Builder,
|
|
emitter lineEmitter,
|
|
scanBufMax int,
|
|
) (int, error) {
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 64*1024), scanBufMax)
|
|
outputTokens := 0
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
outBuf.Write(line)
|
|
outBuf.WriteByte('\n')
|
|
|
|
trimmed := strings.TrimSpace(string(line))
|
|
if trimmed == "" || trimmed[0] != '{' {
|
|
continue
|
|
}
|
|
|
|
events, err := emitter.Emit(trimmed)
|
|
if err != nil {
|
|
return outputTokens, err
|
|
}
|
|
|
|
for _, ev := range events {
|
|
ev.RunID = runID
|
|
if ev.Timestamp.IsZero() {
|
|
ev.Timestamp = time.Now()
|
|
}
|
|
if ev.Type == runtime.EventTypeDelta {
|
|
outputTokens += len(strings.Fields(ev.Delta))
|
|
}
|
|
_ = sink.Emit(ctx, ev)
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return outputTokens, err
|
|
}
|
|
return outputTokens, nil
|
|
}
|
|
|
|
// --- stream-json emitter ---
|
|
|
|
type streamJSONEmitter struct{}
|
|
|
|
func (streamJSONEmitter) Name() string { return "stream-json" }
|
|
|
|
func (e streamJSONEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) {
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
Error any `json:"error"`
|
|
Message string `json:"message"`
|
|
Status string `json:"status"`
|
|
ToolName string `json:"tool_name"`
|
|
ToolID string `json:"tool_id"`
|
|
Parameters map[string]any `json:"parameters"`
|
|
Output string `json:"output"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
return nil, nil
|
|
}
|
|
switch ev.Type {
|
|
case "message":
|
|
if ev.Role != "assistant" || ev.Content == "" {
|
|
return nil, nil
|
|
}
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: ev.Content,
|
|
}}, nil
|
|
case "tool_use":
|
|
if delta := geminiToolUseDelta(ev.ToolName, ev.Parameters); delta != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: delta,
|
|
Metadata: map[string]string{
|
|
"kind": "tool_use",
|
|
"tool_name": ev.ToolName,
|
|
"tool_id": ev.ToolID,
|
|
},
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
case "tool_result":
|
|
if delta := geminiToolResultDelta(ev.Status, ev.Output); delta != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: delta,
|
|
Metadata: map[string]string{
|
|
"kind": "tool_result",
|
|
"tool_id": ev.ToolID,
|
|
},
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
case "result":
|
|
if ev.Status != "" && ev.Status != "success" {
|
|
msg := ev.Message
|
|
if msg == "" {
|
|
msg = geminiErrorString(ev.Error)
|
|
}
|
|
if msg == "" {
|
|
msg = ev.Status
|
|
}
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: msg,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
case "error":
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: geminiErrorString(ev.Error),
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func geminiToolUseDelta(toolName string, params map[string]any) string {
|
|
if toolName == "" {
|
|
return ""
|
|
}
|
|
summary := geminiToolParamSummary(params)
|
|
if summary == "" {
|
|
return fmt.Sprintf("[tool] %s\n", toolName)
|
|
}
|
|
return fmt.Sprintf("[tool] %s: %s\n", toolName, summary)
|
|
}
|
|
|
|
func geminiToolParamSummary(params map[string]any) string {
|
|
if params == nil {
|
|
return ""
|
|
}
|
|
for _, key := range []string{"command", "description", "title", "summary"} {
|
|
if s, ok := params[key].(string); ok && strings.TrimSpace(s) != "" {
|
|
return truncateGeminiToolText(strings.TrimSpace(s), 180)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func geminiToolResultDelta(status, output string) string {
|
|
status = strings.TrimSpace(status)
|
|
output = strings.TrimSpace(output)
|
|
if status == "" && output == "" {
|
|
return ""
|
|
}
|
|
if status == "" {
|
|
status = "done"
|
|
}
|
|
if output == "" {
|
|
return fmt.Sprintf("[tool-result] %s\n", status)
|
|
}
|
|
return fmt.Sprintf("[tool-result] %s: %s\n", status, truncateGeminiToolText(output, 180))
|
|
}
|
|
|
|
func geminiErrorString(v any) string {
|
|
switch err := v.(type) {
|
|
case string:
|
|
return err
|
|
case map[string]any:
|
|
if msg, ok := err["message"].(string); ok {
|
|
return msg
|
|
}
|
|
if data, ok := err["data"].(map[string]any); ok {
|
|
if msg, ok := data["message"].(string); ok {
|
|
return msg
|
|
}
|
|
}
|
|
if name, ok := err["name"].(string); ok {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func truncateGeminiToolText(s string, max int) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
if max <= 3 {
|
|
return s[:max]
|
|
}
|
|
return s[:max-3] + "..."
|
|
}
|
|
|
|
// --- claude-json emitter ---
|
|
|
|
type claudeJSONEmitter struct{}
|
|
|
|
func (claudeJSONEmitter) Name() string { return "claude-json" }
|
|
|
|
func (e claudeJSONEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) {
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Event struct {
|
|
Type string `json:"type"`
|
|
Delta struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"delta"`
|
|
} `json:"event"`
|
|
IsError bool `json:"is_error"`
|
|
Result string `json:"result"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
return nil, nil
|
|
}
|
|
switch ev.Type {
|
|
case "stream_event":
|
|
if ev.Event.Type == "content_block_delta" &&
|
|
ev.Event.Delta.Type == "text_delta" &&
|
|
ev.Event.Delta.Text != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: ev.Event.Delta.Text,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
case "result":
|
|
if ev.IsError && ev.Result != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: ev.Result,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// --- codex-json emitter ---
|
|
|
|
type codexJSONEmitter struct{}
|
|
|
|
func (codexJSONEmitter) Name() string { return "codex-json" }
|
|
|
|
func (e codexJSONEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) {
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Item struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
Delta string `json:"delta"`
|
|
Message struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"message"`
|
|
} `json:"item"`
|
|
Delta string `json:"delta"`
|
|
Text string `json:"text"`
|
|
Message string `json:"message"`
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
return nil, nil
|
|
}
|
|
switch ev.Type {
|
|
case "item.delta", "item.updated", "response.output_text.delta", "output_text.delta":
|
|
if delta := codexDeltaText(ev); delta != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: delta,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
case "item.completed":
|
|
if ev.Item.Type == "agent_message" && ev.Item.Text != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: ev.Item.Text,
|
|
}}, nil
|
|
}
|
|
if delta := codexDeltaText(ev); delta != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: delta,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
case "error":
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: ev.Message,
|
|
}}, nil
|
|
case "turn.failed":
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: ev.Error.Message,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func codexDeltaText(ev struct {
|
|
Type string `json:"type"`
|
|
Item struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
Delta string `json:"delta"`
|
|
Message struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"message"`
|
|
} `json:"item"`
|
|
Delta string `json:"delta"`
|
|
Text string `json:"text"`
|
|
Message string `json:"message"`
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}) string {
|
|
if ev.Item.Delta != "" {
|
|
return ev.Item.Delta
|
|
}
|
|
if ev.Delta != "" {
|
|
return ev.Delta
|
|
}
|
|
if ev.Text != "" {
|
|
return ev.Text
|
|
}
|
|
for _, part := range ev.Item.Message.Content {
|
|
if part.Type == "output_text" && part.Text != "" {
|
|
return part.Text
|
|
}
|
|
}
|
|
for _, part := range ev.Content {
|
|
if part.Type == "output_text" && part.Text != "" {
|
|
return part.Text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- opencode-json emitter ---
|
|
|
|
type opencodeJSONEmitter struct{}
|
|
|
|
func (opencodeJSONEmitter) Name() string { return "opencode-json" }
|
|
|
|
func (e opencodeJSONEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) {
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Error struct {
|
|
Name string `json:"name"`
|
|
Data struct {
|
|
Message string `json:"message"`
|
|
} `json:"data"`
|
|
} `json:"error"`
|
|
Part struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"part"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
return nil, nil
|
|
}
|
|
switch ev.Type {
|
|
case "text":
|
|
if ev.Part.Type == "text" && ev.Part.Text != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: ev.Part.Text,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
case "error":
|
|
msg := ev.Error.Data.Message
|
|
if msg == "" {
|
|
msg = ev.Error.Name
|
|
}
|
|
if msg != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: msg,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// --- cline-json emitter ---
|
|
|
|
type clineJSONEmitter struct{}
|
|
|
|
func (clineJSONEmitter) Name() string { return "cline-json" }
|
|
|
|
func (e clineJSONEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) {
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Say string `json:"say"`
|
|
Ask string `json:"ask"`
|
|
Text string `json:"text"`
|
|
Message string `json:"message"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
return nil, nil
|
|
}
|
|
switch {
|
|
case ev.Type == "say" && ev.Say == "text" && ev.Text != "":
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: ev.Text,
|
|
}}, nil
|
|
case ev.Type == "say" && ev.Say == "error":
|
|
msg := ev.Text
|
|
if msg == "" {
|
|
msg = ev.Message
|
|
}
|
|
if msg != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: msg,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
case ev.Type == "completion" && ev.Status == "error":
|
|
msg := ev.Error
|
|
if msg == "" {
|
|
msg = ev.Message
|
|
}
|
|
if msg == "" {
|
|
msg = "cline task failed"
|
|
}
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: msg,
|
|
}}, nil
|
|
case ev.Type == "ask" && ev.Ask == "api_req_failed":
|
|
msg := ev.Text
|
|
if msg == "" {
|
|
msg = ev.Message
|
|
}
|
|
if msg != "" {
|
|
return []runtime.RuntimeEvent{{
|
|
Type: runtime.EventTypeError,
|
|
Error: msg,
|
|
}}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
return nil, nil
|
|
}
|