初始化仓库
This commit is contained in:
@@ -0,0 +1,905 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionIDRe = regexp.MustCompile(`"session-id"\s*:\s*"([^"]+)"`)
|
||||
ansiCSIRe = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
||||
ansiOSCRe = regexp.MustCompile(`\x1b\][^\a]*(?:\a|\x1b\\)`)
|
||||
)
|
||||
|
||||
const (
|
||||
iflowTurnIdle = 900 * time.Millisecond
|
||||
iflowTranscriptPoll = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
var iflowPendingToolTimeout = 180 * time.Second
|
||||
var iflowPendingToolTimeoutDefaultMode = 6 * time.Second
|
||||
|
||||
// iflowSession manages multi-turn conversations with iFlow CLI.
|
||||
// Each Send() launches a fresh interactive `iflow -i` process inside a PTY,
|
||||
// then tails the transcript JSONL to recover structured assistant/tool events.
|
||||
type iflowSession struct {
|
||||
cmd string
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
toolTimeoutSec int
|
||||
extraEnv []string
|
||||
events chan core.Event
|
||||
sessionID atomic.Value // stores string
|
||||
sentOnce atomic.Bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
alive atomic.Bool
|
||||
turnActive atomic.Bool
|
||||
}
|
||||
|
||||
type iflowTurn struct {
|
||||
cancel context.CancelFunc
|
||||
startedAt time.Time
|
||||
mode string
|
||||
pendingTimeout time.Duration
|
||||
sessionDir string
|
||||
transcriptPath string
|
||||
offset int64
|
||||
partial string
|
||||
processDone chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
pendingToolIDs map[string]struct{}
|
||||
pendingTools map[string]iflowToolUse
|
||||
seenToolIDs map[string]struct{}
|
||||
doneToolIDs map[string]struct{}
|
||||
pendingTimer *time.Timer
|
||||
resultTimer *time.Timer
|
||||
resultText string
|
||||
toolFallback []string
|
||||
awaitingTool bool
|
||||
resultReady bool
|
||||
resultSent bool
|
||||
}
|
||||
|
||||
type iflowToolUse struct {
|
||||
ID string
|
||||
Name string
|
||||
Input any
|
||||
}
|
||||
|
||||
type iflowToolResult struct {
|
||||
ID string
|
||||
Output string
|
||||
}
|
||||
|
||||
func newIFlowSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, toolTimeoutSec int) (*iflowSession, error) {
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
s := &iflowSession{
|
||||
cmd: cmd,
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
toolTimeoutSec: toolTimeoutSec,
|
||||
extraEnv: extraEnv,
|
||||
events: make(chan core.Event, 64),
|
||||
ctx: sessionCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.alive.Store(true)
|
||||
|
||||
if resumeID != "" && resumeID != core.ContinueSession {
|
||||
s.sessionID.Store(resumeID)
|
||||
s.sentOnce.Store(true)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *iflowSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
if len(images) > 0 {
|
||||
slog.Warn("iflowSession: images are not supported, ignoring")
|
||||
}
|
||||
if len(files) > 0 {
|
||||
filePaths := core.SaveFilesToDisk(s.workDir, files)
|
||||
prompt = core.AppendFileRefs(prompt, filePaths)
|
||||
}
|
||||
if !s.alive.Load() {
|
||||
return fmt.Errorf("session is closed")
|
||||
}
|
||||
if !s.turnActive.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("iflow session is busy")
|
||||
}
|
||||
|
||||
turnCtx, turnCancel := context.WithCancel(s.ctx)
|
||||
turn := &iflowTurn{
|
||||
cancel: turnCancel,
|
||||
startedAt: time.Now(),
|
||||
mode: s.mode,
|
||||
pendingTimeout: s.pendingToolTimeout(),
|
||||
processDone: make(chan struct{}),
|
||||
pendingToolIDs: make(map[string]struct{}),
|
||||
pendingTools: make(map[string]iflowToolUse),
|
||||
seenToolIDs: make(map[string]struct{}),
|
||||
doneToolIDs: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if !s.turnActive.Load() {
|
||||
turnCancel()
|
||||
}
|
||||
}()
|
||||
|
||||
sessionDir, err := iflowSessionDir(s.workDir)
|
||||
if err != nil {
|
||||
s.turnActive.Store(false)
|
||||
return fmt.Errorf("iflowSession: resolve session dir: %w", err)
|
||||
}
|
||||
turn.sessionDir = sessionDir
|
||||
|
||||
args := make([]string, 0, 16)
|
||||
if s.model != "" {
|
||||
args = append(args, "-m", s.model)
|
||||
}
|
||||
|
||||
switch s.mode {
|
||||
case "yolo":
|
||||
args = append(args, "--yolo")
|
||||
case "plan":
|
||||
args = append(args, "--plan")
|
||||
case "auto-edit":
|
||||
args = append(args, "--autoEdit")
|
||||
default:
|
||||
args = append(args, "--default")
|
||||
}
|
||||
|
||||
sid := s.CurrentSessionID()
|
||||
if sid != "" {
|
||||
args = append(args, "-r", sid)
|
||||
turn.transcriptPath = filepath.Join(sessionDir, sid+".jsonl")
|
||||
turn.offset = fileSize(turn.transcriptPath)
|
||||
} else if s.sentOnce.Load() {
|
||||
args = append(args, "-c")
|
||||
}
|
||||
|
||||
args = append(args, "-i", prompt)
|
||||
slog.Debug("iflowSession: launching interactive turn", "resume", sid != "", "args", core.RedactArgs(args))
|
||||
|
||||
cmd := exec.CommandContext(turnCtx, s.cmd, args...)
|
||||
cmd.Dir = s.workDir
|
||||
env := os.Environ()
|
||||
if len(s.extraEnv) > 0 {
|
||||
env = core.MergeEnv(env, s.extraEnv)
|
||||
}
|
||||
cmd.Env = env
|
||||
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
s.turnActive.Store(false)
|
||||
return fmt.Errorf("iflowSession: start pty: %w", err)
|
||||
}
|
||||
|
||||
s.sentOnce.Store(true)
|
||||
s.wg.Add(1)
|
||||
go s.readLoop(turn, cmd, ptmx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *iflowSession) readLoop(turn *iflowTurn, cmd *exec.Cmd, ptmx *os.File) {
|
||||
defer s.wg.Done()
|
||||
defer s.turnActive.Store(false)
|
||||
defer turn.cancel()
|
||||
defer ptmx.Close()
|
||||
|
||||
var termBuf bytes.Buffer
|
||||
drainDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(&termBuf, ptmx)
|
||||
close(drainDone)
|
||||
}()
|
||||
|
||||
watchDone := make(chan struct{})
|
||||
go func() {
|
||||
s.watchTranscript(turn)
|
||||
close(watchDone)
|
||||
}()
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
close(turn.processDone)
|
||||
_ = ptmx.Close()
|
||||
<-drainDone
|
||||
<-watchDone
|
||||
turn.stopResultTimer()
|
||||
|
||||
// Clear busy state before emitting events so callers can Send() immediately
|
||||
// after receiving the event. The defer above serves as a safety net.
|
||||
s.turnActive.Store(false)
|
||||
|
||||
termText := strings.TrimSpace(stripANSI(termBuf.String()))
|
||||
if turn.readyForResult() {
|
||||
turn.markResultSent()
|
||||
s.emitEvent(core.Event{
|
||||
Type: core.EventResult,
|
||||
Content: turn.finalContent(),
|
||||
SessionID: s.CurrentSessionID(),
|
||||
Done: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if turn.resultWasSent() {
|
||||
return
|
||||
}
|
||||
|
||||
if waitErr != nil {
|
||||
if s.ctx.Err() != nil || errors.Is(waitErr, context.Canceled) {
|
||||
return
|
||||
}
|
||||
s.emitEvent(core.Event{Type: core.EventError, Error: summarizeIFlowError(termText, waitErr)})
|
||||
return
|
||||
}
|
||||
|
||||
if termText != "" && isIFlowAPIFailure(termText) {
|
||||
s.emitEvent(core.Event{Type: core.EventError, Error: summarizeIFlowError(termText, nil)})
|
||||
return
|
||||
}
|
||||
|
||||
s.emitEvent(core.Event{
|
||||
Type: core.EventResult,
|
||||
Content: turn.finalContent(),
|
||||
SessionID: s.CurrentSessionID(),
|
||||
Done: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *iflowSession) watchTranscript(turn *iflowTurn) {
|
||||
ticker := time.NewTicker(iflowTranscriptPoll)
|
||||
defer ticker.Stop()
|
||||
|
||||
processDone := false
|
||||
|
||||
for {
|
||||
if turn.transcriptPath == "" {
|
||||
if path := findIFlowTranscriptPath(turn.sessionDir, turn.startedAt); path != "" {
|
||||
turn.transcriptPath = path
|
||||
turn.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
if turn.transcriptPath != "" {
|
||||
if err := s.consumeTranscript(turn); err != nil {
|
||||
slog.Debug("iflowSession: transcript poll failed", "path", turn.transcriptPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if turn.resultWasSent() || processDone {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-turn.processDone:
|
||||
processDone = true
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *iflowSession) pendingToolTimeout() time.Duration {
|
||||
if s.toolTimeoutSec > 0 {
|
||||
return time.Duration(s.toolTimeoutSec) * time.Second
|
||||
}
|
||||
if strings.EqualFold(s.mode, "default") {
|
||||
return iflowPendingToolTimeoutDefaultMode
|
||||
}
|
||||
return iflowPendingToolTimeout
|
||||
}
|
||||
|
||||
func (s *iflowSession) consumeTranscript(turn *iflowTurn) error {
|
||||
f, err := os.Open(turn.transcriptPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi.Size() < turn.offset {
|
||||
turn.offset = fi.Size()
|
||||
turn.partial = ""
|
||||
}
|
||||
if _, err := f.Seek(turn.offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
chunk, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(chunk) == 0 {
|
||||
return nil
|
||||
}
|
||||
turn.offset += int64(len(chunk))
|
||||
|
||||
data := turn.partial + string(chunk)
|
||||
lines := strings.Split(data, "\n")
|
||||
if !strings.HasSuffix(data, "\n") {
|
||||
turn.partial = lines[len(lines)-1]
|
||||
lines = lines[:len(lines)-1]
|
||||
} else {
|
||||
turn.partial = ""
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
s.handleTranscriptLine(turn, line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *iflowSession) handleTranscriptLine(turn *iflowTurn, line string) {
|
||||
var item iflowTranscriptLine
|
||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
||||
slog.Debug("iflowSession: invalid transcript line", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if item.SessionID != "" {
|
||||
s.sessionID.Store(item.SessionID)
|
||||
}
|
||||
|
||||
switch item.Type {
|
||||
case "assistant":
|
||||
texts, toolUses := extractIFlowAssistantEvents(item.Message.Content)
|
||||
if len(toolUses) > 0 {
|
||||
newTools := turn.addPendingTools(toolUses)
|
||||
for _, tool := range newTools {
|
||||
s.emitEvent(core.Event{
|
||||
Type: core.EventToolUse,
|
||||
ToolName: tool.Name,
|
||||
ToolInput: summarizeIFlowToolInput(tool.Input),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, text := range texts {
|
||||
turn.appendText(text)
|
||||
s.emitEvent(core.Event{Type: core.EventText, Content: text, SessionID: s.CurrentSessionID()})
|
||||
}
|
||||
if len(texts) > 0 && !turn.hasPendingTools() {
|
||||
turn.scheduleResult(s)
|
||||
}
|
||||
|
||||
case "user":
|
||||
toolResults := extractIFlowToolResults(item.Message.Content)
|
||||
_ = turn.completeTools(toolResults)
|
||||
}
|
||||
}
|
||||
|
||||
func iflowSessionDir(workDir string) (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(iflowResolvedWorkDir(workDir))), nil
|
||||
}
|
||||
|
||||
func findIFlowTranscriptPath(sessionDir string, startedAt time.Time) string {
|
||||
matches, err := filepath.Glob(filepath.Join(sessionDir, "session-*.jsonl"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
cutoff := startedAt.Add(-2 * time.Second)
|
||||
var best string
|
||||
var bestMod time.Time
|
||||
for _, path := range matches {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil || fi.IsDir() {
|
||||
continue
|
||||
}
|
||||
if fi.ModTime().Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
if best == "" || fi.ModTime().After(bestMod) {
|
||||
best = path
|
||||
bestMod = fi.ModTime()
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func fileSize(path string) int64 {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return fi.Size()
|
||||
}
|
||||
|
||||
func extractIFlowAssistantEvents(content any) ([]string, []iflowToolUse) {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
text := strings.TrimSpace(v)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []string{text}, nil
|
||||
|
||||
case []any:
|
||||
var texts []string
|
||||
var tools []iflowToolUse
|
||||
for _, raw := range v {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch itemType, _ := item["type"].(string); itemType {
|
||||
case "text":
|
||||
if text, _ := item["text"].(string); strings.TrimSpace(text) != "" {
|
||||
texts = append(texts, strings.TrimSpace(text))
|
||||
}
|
||||
case "tool_use":
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
toolID, _ := item["id"].(string)
|
||||
tools = append(tools, iflowToolUse{
|
||||
ID: toolID,
|
||||
Name: name,
|
||||
Input: item["input"],
|
||||
})
|
||||
}
|
||||
}
|
||||
return texts, tools
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func extractIFlowToolResults(content any) []iflowToolResult {
|
||||
items, ok := content.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []iflowToolResult
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if itemType, _ := item["type"].(string); itemType != "tool_result" {
|
||||
continue
|
||||
}
|
||||
if toolID, _ := item["tool_use_id"].(string); toolID != "" {
|
||||
results = append(results, iflowToolResult{
|
||||
ID: toolID,
|
||||
Output: summarizeIFlowToolResult(item["content"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func summarizeIFlowToolInput(input any) string {
|
||||
m, ok := input.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
"absolute_path",
|
||||
"path",
|
||||
"file_path",
|
||||
"command",
|
||||
"query",
|
||||
"pattern",
|
||||
"prompt",
|
||||
"url",
|
||||
} {
|
||||
if v, _ := m[key].(string); v != "" {
|
||||
return truncateRunes(v, 300)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return truncateRunes(string(b), 300)
|
||||
}
|
||||
|
||||
func summarizeIFlowToolResult(content any) string {
|
||||
m, ok := content.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, path := range [][]string{
|
||||
{"functionResponse", "response", "output"},
|
||||
{"responseParts", "functionResponse", "response", "output"},
|
||||
} {
|
||||
if v := nestedString(m, path...); v != "" {
|
||||
return truncateRunes(strings.TrimSpace(v), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if v := nestedString(m, "resultDisplay"); v != "" {
|
||||
return truncateRunes(strings.TrimSpace(v), 2000)
|
||||
}
|
||||
if v := nestedString(m, "output"); v != "" {
|
||||
return truncateRunes(strings.TrimSpace(v), 2000)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return truncateRunes(string(b), 2000)
|
||||
}
|
||||
|
||||
func nestedString(v any, path ...string) string {
|
||||
cur := v
|
||||
for _, key := range path {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur, ok = m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
s, _ := cur.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func truncateRunes(s string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:maxRunes]) + "..."
|
||||
}
|
||||
|
||||
func (t *iflowTurn) addPendingTools(tools []iflowToolUse) []iflowToolUse {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
var added []iflowToolUse
|
||||
for _, tool := range tools {
|
||||
if tool.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := t.doneToolIDs[tool.ID]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := t.seenToolIDs[tool.ID]; ok {
|
||||
continue
|
||||
}
|
||||
t.seenToolIDs[tool.ID] = struct{}{}
|
||||
t.pendingToolIDs[tool.ID] = struct{}{}
|
||||
t.pendingTools[tool.ID] = tool
|
||||
added = append(added, tool)
|
||||
}
|
||||
if len(added) > 0 {
|
||||
timeout := t.pendingTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = iflowPendingToolTimeout
|
||||
}
|
||||
if t.pendingTimer != nil {
|
||||
t.pendingTimer.Stop()
|
||||
t.pendingTimer = nil
|
||||
}
|
||||
t.pendingTimer = time.AfterFunc(timeout, func() {
|
||||
t.mu.Lock()
|
||||
if t.resultSent || len(t.pendingToolIDs) == 0 {
|
||||
t.pendingTimer = nil
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, tool := range t.pendingTools {
|
||||
if tool.Name != "" {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
}
|
||||
msg := "Tool call timed out before completion."
|
||||
if len(names) > 0 {
|
||||
msg += " Pending: " + strings.Join(names, ", ") + "."
|
||||
}
|
||||
if strings.EqualFold(t.mode, "default") {
|
||||
msg += " Default mode requires interactive approval; use /mode yolo or /mode auto-edit for tool calls."
|
||||
} else {
|
||||
msg += " The tool appears stalled."
|
||||
}
|
||||
|
||||
if t.resultText == "" {
|
||||
t.resultText = msg
|
||||
} else {
|
||||
t.resultText = t.resultText + "\n\n" + msg
|
||||
}
|
||||
t.awaitingTool = false
|
||||
t.resultReady = true
|
||||
t.pendingToolIDs = make(map[string]struct{})
|
||||
t.pendingTools = make(map[string]iflowToolUse)
|
||||
t.pendingTimer = nil
|
||||
t.mu.Unlock()
|
||||
|
||||
t.cancel()
|
||||
})
|
||||
|
||||
if t.resultTimer != nil {
|
||||
t.resultTimer.Stop()
|
||||
t.resultTimer = nil
|
||||
}
|
||||
t.awaitingTool = false
|
||||
t.toolFallback = nil
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
func (t *iflowTurn) appendText(text string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.awaitingTool = false
|
||||
t.resultText += text
|
||||
}
|
||||
|
||||
func (t *iflowTurn) completeTools(results []iflowToolResult) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
changed := false
|
||||
for _, result := range results {
|
||||
if _, ok := t.doneToolIDs[result.ID]; ok {
|
||||
continue
|
||||
}
|
||||
delete(t.pendingToolIDs, result.ID)
|
||||
delete(t.pendingTools, result.ID)
|
||||
t.doneToolIDs[result.ID] = struct{}{}
|
||||
if result.Output != "" {
|
||||
t.toolFallback = append(t.toolFallback, result.Output)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if len(t.pendingToolIDs) == 0 && t.pendingTimer != nil {
|
||||
t.pendingTimer.Stop()
|
||||
t.pendingTimer = nil
|
||||
} else if changed && len(t.pendingToolIDs) > 0 && t.pendingTimer != nil {
|
||||
t.pendingTimer.Reset(t.pendingTimeout)
|
||||
}
|
||||
if len(results) == 0 || len(t.pendingToolIDs) > 0 {
|
||||
return false
|
||||
}
|
||||
t.awaitingTool = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *iflowTurn) hasPendingTools() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.pendingToolIDs) > 0
|
||||
}
|
||||
|
||||
func (t *iflowTurn) scheduleResult(s *iflowSession) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.resultSent || len(t.pendingToolIDs) > 0 {
|
||||
return
|
||||
}
|
||||
if t.pendingTimer != nil {
|
||||
t.pendingTimer.Stop()
|
||||
t.pendingTimer = nil
|
||||
}
|
||||
if t.resultTimer != nil {
|
||||
t.resultTimer.Stop()
|
||||
t.resultTimer = nil
|
||||
}
|
||||
|
||||
t.resultTimer = time.AfterFunc(iflowTurnIdle, func() {
|
||||
t.mu.Lock()
|
||||
if t.resultSent || len(t.pendingToolIDs) > 0 {
|
||||
t.resultTimer = nil
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.resultReady = true
|
||||
t.resultTimer = nil
|
||||
t.mu.Unlock()
|
||||
|
||||
t.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
func (t *iflowTurn) stopResultTimer() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.resultTimer != nil {
|
||||
t.resultTimer.Stop()
|
||||
t.resultTimer = nil
|
||||
}
|
||||
if t.pendingTimer != nil {
|
||||
t.pendingTimer.Stop()
|
||||
t.pendingTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *iflowTurn) resultWasSent() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.resultSent
|
||||
}
|
||||
|
||||
func (t *iflowTurn) readyForResult() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.resultReady
|
||||
}
|
||||
|
||||
func (t *iflowTurn) markResultSent() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.resultSent = true
|
||||
}
|
||||
|
||||
func (t *iflowTurn) finalContent() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if !t.awaitingTool || len(t.toolFallback) == 0 {
|
||||
return t.resultText
|
||||
}
|
||||
|
||||
fallback := strings.Join(t.toolFallback, "\n\n")
|
||||
if t.resultText == "" {
|
||||
return fallback
|
||||
}
|
||||
if strings.Contains(t.resultText, fallback) {
|
||||
return t.resultText
|
||||
}
|
||||
return t.resultText + "\n\n" + fallback
|
||||
}
|
||||
|
||||
func (s *iflowSession) emitEvent(evt core.Event) {
|
||||
select {
|
||||
case s.events <- evt:
|
||||
case <-s.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func readExecutionInfoSessionID(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload struct {
|
||||
SessionID string `json:"session-id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(payload.SessionID), nil
|
||||
}
|
||||
|
||||
func extractSessionIDFromExecutionInfo(stderrText string) string {
|
||||
m := sessionIDRe.FindStringSubmatch(stderrText)
|
||||
if len(m) == 2 {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripANSI(s string) string {
|
||||
s = ansiOSCRe.ReplaceAllString(s, "")
|
||||
s = ansiCSIRe.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
return s
|
||||
}
|
||||
|
||||
func isIFlowAPIFailure(stderrText string) bool {
|
||||
if stderrText == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(stderrText)
|
||||
if strings.Contains(lower, "error when talking to iflow api") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "generate data error") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "retrying with backoff") && strings.Contains(lower, "fetch failed") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func summarizeIFlowError(stderrText string, waitErr error) error {
|
||||
if stderrText != "" {
|
||||
for _, line := range strings.Split(stderrText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "<Execution Info>") || strings.HasPrefix(line, "</Execution Info>") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "{") || strings.HasPrefix(line, "}") || strings.HasPrefix(line, "\"") {
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(line) > 300 {
|
||||
line = string([]rune(line)[:300]) + "..."
|
||||
}
|
||||
return fmt.Errorf("%s", line)
|
||||
}
|
||||
}
|
||||
if waitErr != nil {
|
||||
return fmt.Errorf("iflow process failed: %w", waitErr)
|
||||
}
|
||||
return fmt.Errorf("iflow API request failed")
|
||||
}
|
||||
|
||||
func (s *iflowSession) RespondPermission(_ string, _ core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *iflowSession) Events() <-chan core.Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *iflowSession) CurrentSessionID() string {
|
||||
v, _ := s.sessionID.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *iflowSession) Alive() bool {
|
||||
return s.alive.Load()
|
||||
}
|
||||
|
||||
func (s *iflowSession) Close() error {
|
||||
s.alive.Store(false)
|
||||
s.cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
close(s.events)
|
||||
case <-time.After(8 * time.Second):
|
||||
slog.Warn("iflowSession: close timed out, abandoning wg.Wait")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user