okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfeat: multiple images, collapsed pastes, and an attachment strip in the prompt6e82811

main
6.3 KiB208 linesraw
1package app
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"slices"
8	"time"
9
10	"albedo/cli/internal/daemon"
11)
12
13type PromptOptions struct {
14	Prompt, SessionID, Workspace, Model string
15	Timeout                             time.Duration
16}
17type PromptResult struct{ SessionID, Answer string }
18
19// RunPrompt waits for the reply to this invocation's turn. Zero timeout is unlimited.
20func (s *Service) RunPrompt(ctx context.Context, options PromptOptions) (PromptResult, error) {
21	prompt, sessionID, cwd, model, timeout := options.Prompt, options.SessionID, options.Workspace, options.Model, options.Timeout
22	if timeout > 0 {
23		var cancel context.CancelFunc
24		ctx, cancel = context.WithTimeout(ctx, timeout)
25		defer cancel()
26	}
27	conn, err := s.Connect(ctx)
28	if err != nil {
29		return PromptResult{}, err
30	}
31	var choice modelChoice
32	if model != "" {
33		profiles, profileErr := daemon.ProviderProfiles(ctx, conn)
34		if profileErr != nil {
35			return PromptResult{}, profileErr
36		}
37		if choice, err = chooseModel(configuredModels(ctx, conn, profiles), profiles.Active, model); err != nil {
38			return PromptResult{}, err
39		}
40	}
41	if sessionID != "" {
42		if sessionID, err = resolveSession(ctx, conn, sessionID); err != nil {
43			return PromptResult{}, err
44		}
45	}
46	if sessionID == "" {
47		create := daemon.CreateSessionRequest{Workspace: cwd}
48		if model != "" {
49			create.Provider, create.Model = choice.provider, choice.model
50		}
51		session, createErr := daemon.CreateSession(ctx, conn, create)
52		if createErr != nil {
53			return PromptResult{}, createErr
54		}
55		sessionID = session.ID
56	} else if model != "" {
57		if switchErr := switchModel(ctx, conn, sessionID, choice); switchErr != nil {
58			return PromptResult{}, switchErr
59		}
60	}
61
62	client := daemon.NewChatClient(conn, sessionID)
63	answer, err := awaitReply(ctx, client, prompt)
64	if err == nil {
65		return PromptResult{SessionID: sessionID, Answer: answer}, nil
66	}
67	switch {
68	case errors.Is(ctx.Err(), context.DeadlineExceeded):
69		return PromptResult{}, fmt.Errorf("timed out after %s; %w; the session is %s", timeout, err, sessionID)
70	case ctx.Err() != nil:
71		return PromptResult{}, fmt.Errorf("interrupted; %w; the session is %s", err, sessionID)
72	}
73	if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](err); uncertain {
74		return PromptResult{}, fmt.Errorf("%w; the session is %s", err, sessionID)
75	}
76	return PromptResult{}, err
77}
78
79// awaitReply follows the logical turn containing this submission. The actor
80// publishes membership before any worker events, and completion after them.
81func awaitReply(ctx context.Context, client *daemon.ChatClient, prompt string) (answer string, failure error) {
82	handle, err := client.PrepareTurn(prompt, nil, nil, false)
83	if err != nil {
84		return "", err
85	}
86	submissionID := handle.ID()
87	defer func() {
88		if failure != nil && ctx.Err() != nil {
89			if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](failure); !uncertain {
90				uncertain := &daemon.UncertainOutcomeError{Operation: "await turn", Handle: handle, Cause: failure}
91				failure = fmt.Errorf("%w; %v", uncertain, failure)
92			}
93		}
94	}()
95	streamCtx, cancel := context.WithCancel(ctx)
96	defer cancel()
97	submitted := false
98	defer func() {
99		if failure == nil || !submitted {
100			return
101		}
102		cleanupCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
103		defer stop()
104		outcome, err := client.CancelSubmission(cleanupCtx, submissionID)
105		if err != nil {
106			failure = fmt.Errorf("%w; cancellation unconfirmed: %v", failure, err)
107			return
108		}
109		switch outcome {
110		case "cancelled_queued":
111			failure = fmt.Errorf("%w; queued submission cancelled", failure)
112		case "interrupt_requested":
113			failure = fmt.Errorf("%w; interruption requested for the turn", failure)
114		case "shared_running":
115			failure = fmt.Errorf("%w; the shared turn is still running", failure)
116		case "not_pending":
117			failure = fmt.Errorf("%w; submission is no longer pending", failure)
118		}
119	}()
120	ready := make(chan struct{})
121	events := make(chan daemon.StreamEvent, 64)
122	streamDone := make(chan error, 1)
123	go func() {
124		streamDone <- client.Stream(streamCtx, 0, func(event daemon.StreamEvent) error {
125			if event.Type == daemon.EventReset {
126				select {
127				case <-ready:
128				default:
129					close(ready)
130				}
131			}
132			if event.Replayed {
133				return nil
134			}
135			select {
136			case events <- event:
137				return nil
138			case <-streamCtx.Done():
139				return streamCtx.Err()
140			}
141		})
142	}()
143	select {
144	case <-ready:
145	case err := <-streamDone:
146		return "", fmt.Errorf("could not follow the session: %w", err)
147	case <-ctx.Done():
148		return "", ctx.Err()
149	}
150	// A failed request can already have reached the actor. Cleanup always uses
151	// its identity, so it cannot interrupt a different client's turn.
152	submitted = true
153	if _, err := client.SubmitOperation(ctx, handle); err != nil {
154		return "", err
155	}
156	turnID := ""
157	// Discard the initial reset already acknowledged before submission.
158	initialReset := true
159	for {
160		select {
161		case <-ctx.Done():
162			return "", ctx.Err()
163		case err := <-streamDone:
164			return "", &daemon.UncertainOutcomeError{Operation: "await turn", Handle: handle, Cause: fmt.Errorf("lost the session stream before the turn finished: %v", err)}
165		case event := <-events:
166			if event.Type == daemon.EventReset && initialReset {
167				initialReset = false
168				continue
169			}
170			if event.Type == daemon.EventReset {
171				return "", &daemon.UncertainOutcomeError{Operation: "await turn", Handle: handle, Cause: errors.New("session stream reset before the submission completed")}
172			}
173			if event.Type == daemon.EventTurnMembership && slices.Contains(event.SubmissionIDs, submissionID) {
174				turnID = event.TurnID
175			}
176			if turnID == "" || event.TurnID != "" && event.TurnID != turnID {
177				continue
178			}
179			switch event.Type {
180			case daemon.EventMessage:
181				answer, failure = event.Text, nil
182			case daemon.EventError:
183				failure = errors.New(event.Text)
184			case daemon.EventInterrupted:
185				failure = errors.New("the turn was interrupted")
186			case daemon.EventRetry:
187				failure = nil
188			case daemon.EventTurnCompleted:
189				if event.TurnID != turnID {
190					continue
191				}
192				if event.Source == "interrupted" {
193					return "", errors.New("the turn was interrupted")
194				}
195				if event.Source == "failed" && failure == nil {
196					failure = errors.New("the turn failed")
197				}
198				if failure != nil {
199					return "", failure
200				}
201				if answer == "" {
202					return "", errors.New("the turn ended without an assistant reply")
203				}
204				return answer, nil
205			}
206		}
207	}
208}