- add console_events.go for edge console event handling - add codex_exec.go for CLI codex execution - add codex_exec_test.go for persistent CLI tests - update console.go and oneshot.go with new features - update config and edge.yaml for new settings - update README files for edge and node apps
351 lines
9.6 KiB
Go
351 lines
9.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/packages/config"
|
|
)
|
|
|
|
func (c *CLI) executeOneShot(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
|
|
prompt := extractPrompt(spec.Input)
|
|
args := append(append([]string{}, profile.Args...), prompt)
|
|
_, err := c.executeCommand(ctx, spec, profile, args, prompt, sink)
|
|
return err
|
|
}
|
|
|
|
func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, args []string, prompt string, sink runtime.EventSink) (string, error) {
|
|
cmd := exec.CommandContext(ctx, profile.Command, args...)
|
|
|
|
if len(profile.Env) > 0 {
|
|
cmd.Env = append(cmd.Environ(), profile.Env...)
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return "", fmt.Errorf("cli adapter: stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return "", fmt.Errorf("cli adapter: stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return "", fmt.Errorf("cli adapter: start %q: %w", profile.Command, err)
|
|
}
|
|
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeStart,
|
|
Timestamp: time.Now(),
|
|
})
|
|
|
|
var errBuf strings.Builder
|
|
var outBuf strings.Builder
|
|
combinedOutput := func() string {
|
|
return outBuf.String() + errBuf.String()
|
|
}
|
|
stderrDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.Copy(&errBuf, stderr)
|
|
stderrDone <- err
|
|
}()
|
|
|
|
var (
|
|
outputTokens int
|
|
readErr error
|
|
)
|
|
switch profile.OutputFormat {
|
|
case "stream-json":
|
|
outputTokens, readErr = emitStreamJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
|
|
case "codex-json":
|
|
outputTokens, readErr = emitCodexJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
|
|
case "claude-json":
|
|
outputTokens, readErr = emitClaudeJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
|
|
default:
|
|
outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf)
|
|
}
|
|
if readErr != nil {
|
|
_ = <-stderrDone
|
|
_ = cmd.Wait()
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeError,
|
|
Error: fmt.Sprintf("read stdout: %v", readErr),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), fmt.Errorf("cli adapter: read stdout: %w", readErr)
|
|
}
|
|
|
|
waitErr := cmd.Wait()
|
|
stderrErr := <-stderrDone
|
|
if waitErr != nil {
|
|
if ctx.Err() != nil {
|
|
_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeCancelled,
|
|
Message: "cli execution cancelled",
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), runtime.ErrRunCancelled
|
|
}
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeError,
|
|
Error: fmt.Sprintf("command failed: %v — %s", waitErr, errBuf.String()),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), fmt.Errorf("cli adapter: command exited with error: %w", waitErr)
|
|
}
|
|
if stderrErr != nil && !errors.Is(stderrErr, os.ErrClosed) {
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeError,
|
|
Error: fmt.Sprintf("read stderr: %v", stderrErr),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), fmt.Errorf("cli adapter: read stderr: %w", stderrErr)
|
|
}
|
|
|
|
return combinedOutput(), sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeComplete,
|
|
Message: "cli execution complete",
|
|
Usage: &runtime.UsageStats{
|
|
InputTokens: len(strings.Fields(prompt)),
|
|
OutputTokens: outputTokens,
|
|
},
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
|
|
func emitStdoutChunks(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) {
|
|
buf := make([]byte, 4096)
|
|
outputTokens := 0
|
|
|
|
for {
|
|
n, err := stdout.Read(buf)
|
|
if n > 0 {
|
|
delta := string(buf[:n])
|
|
outBuf.WriteString(delta)
|
|
outputTokens += len(strings.Fields(delta))
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: delta,
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
if errors.Is(err, io.EOF) {
|
|
return outputTokens, nil
|
|
}
|
|
if err != nil {
|
|
return outputTokens, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// emitStreamJSONLines reads JSON Lines from stdout (one JSON object per line)
|
|
// and emits assistant content (delta=true) as RuntimeEvent deltas. Other
|
|
// event types are written to outBuf so they remain available for diagnostics
|
|
// but are not pushed to the sink.
|
|
func emitStreamJSONLines(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) {
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
|
outputTokens := 0
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
outBuf.Write(line)
|
|
outBuf.WriteByte('\n')
|
|
|
|
trimmed := strings.TrimSpace(string(line))
|
|
if trimmed == "" || trimmed[0] != '{' {
|
|
continue
|
|
}
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
Delta bool `json:"delta"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(line, &ev); err != nil {
|
|
continue
|
|
}
|
|
switch ev.Type {
|
|
case "message":
|
|
if ev.Role != "assistant" || ev.Content == "" {
|
|
continue
|
|
}
|
|
outputTokens += len(strings.Fields(ev.Content))
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: ev.Content,
|
|
Timestamp: time.Now(),
|
|
})
|
|
case "error":
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeError,
|
|
Error: ev.Error,
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return outputTokens, err
|
|
}
|
|
return outputTokens, nil
|
|
}
|
|
|
|
// emitClaudeJSONLines parses `claude -p --output-format stream-json
|
|
// --include-partial-messages` JSONL output. Token-level text arrives as
|
|
// stream_event with event.type == "content_block_delta" and
|
|
// event.delta.type == "text_delta". Other event types (system, assistant,
|
|
// result, rate_limit_event, ...) are written to outBuf for diagnostics but
|
|
// not pushed to the sink to avoid duplicate text.
|
|
func emitClaudeJSONLines(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) {
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 64*1024), 8*1024*1024)
|
|
outputTokens := 0
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
outBuf.Write(line)
|
|
outBuf.WriteByte('\n')
|
|
|
|
trimmed := strings.TrimSpace(string(line))
|
|
if trimmed == "" || trimmed[0] != '{' {
|
|
continue
|
|
}
|
|
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(line, &ev); err != nil {
|
|
continue
|
|
}
|
|
switch ev.Type {
|
|
case "stream_event":
|
|
if ev.Event.Type != "content_block_delta" || ev.Event.Delta.Type != "text_delta" || ev.Event.Delta.Text == "" {
|
|
continue
|
|
}
|
|
outputTokens += len(strings.Fields(ev.Event.Delta.Text))
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: ev.Event.Delta.Text,
|
|
Timestamp: time.Now(),
|
|
})
|
|
case "result":
|
|
if ev.IsError && ev.Result != "" {
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeError,
|
|
Error: ev.Result,
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return outputTokens, err
|
|
}
|
|
return outputTokens, nil
|
|
}
|
|
|
|
// emitCodexJSONLines parses `codex exec --json` JSONL output. Codex emits
|
|
// one event per line; assistant text arrives in a single `item.completed`
|
|
// event with item.type == "agent_message" rather than token-by-token.
|
|
func emitCodexJSONLines(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) {
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
|
outputTokens := 0
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
outBuf.Write(line)
|
|
outBuf.WriteByte('\n')
|
|
|
|
trimmed := strings.TrimSpace(string(line))
|
|
if trimmed == "" || trimmed[0] != '{' {
|
|
continue
|
|
}
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Item struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"item"`
|
|
Message string `json:"message"`
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(line, &ev); err != nil {
|
|
continue
|
|
}
|
|
switch ev.Type {
|
|
case "item.completed":
|
|
if ev.Item.Type != "agent_message" || ev.Item.Text == "" {
|
|
continue
|
|
}
|
|
outputTokens += len(strings.Fields(ev.Item.Text))
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: ev.Item.Text,
|
|
Timestamp: time.Now(),
|
|
})
|
|
case "error":
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeError,
|
|
Error: ev.Message,
|
|
Timestamp: time.Now(),
|
|
})
|
|
case "turn.failed":
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeError,
|
|
Error: ev.Error.Message,
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return outputTokens, err
|
|
}
|
|
return outputTokens, nil
|
|
}
|
|
|
|
func extractPrompt(input map[string]any) string {
|
|
if input == nil {
|
|
return ""
|
|
}
|
|
if v, ok := input["prompt"]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
return fmt.Sprintf("%v", input)
|
|
}
|