yao/agent/sandbox/v2/stream.go
Max 89e5840490 feat(sandbox): update sandbox status messages and improve loading feedback
- Modified Docker run commands in CI workflows to include the `-direct` flag for enhanced server operation.
- Removed outdated loading status message during sandbox preparation and added new messages for improved user feedback.
- Introduced a loading message for waiting on AI responses, enhancing the user experience during sandbox execution.
- Refactored identity handling in stream processing to streamline message management.

Made-with: Cursor
2026-03-15 13:52:38 +08:00

181 lines
4.4 KiB
Go

package sandboxv2
import (
"context"
"errors"
"fmt"
"log"
"time"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2"
)
// ExecuteRequest consolidates all parameters for ExecuteSandboxStream.
type ExecuteRequest struct {
Computer infra.Computer
Runner types.Runner
Config *types.SandboxConfig
StreamReq *types.StreamRequest
Manager *infra.Manager
LoadingMsgID string
}
// ExecuteSandboxStream is the V2 replacement for executeSandboxStream.
// It calls runner.Stream, handles interrupts, and performs cleanup/lifecycle
// in defer.
func ExecuteSandboxStream(
ctx *agentContext.Context,
req *ExecuteRequest,
handler message.StreamFunc,
) (*agentContext.CompletionResponse, error) {
if req.Runner == nil || req.Computer == nil {
return nil, fmt.Errorf("runner and computer are required")
}
stdCtx := ctx.Context
panicked := true // Assume panic; set false on normal exit.
// Resolve stop timeout from config (default 2s).
stopTimeout := 2 * time.Second
if req.Config != nil && req.Config.StopTimeout != "" {
if d, err := time.ParseDuration(req.Config.StopTimeout); err == nil {
stopTimeout = d
}
}
// Panic recovery (registered first, executes last in LIFO order).
defer func() {
if r := recover(); r != nil {
log.Printf("[sandbox/v2] panic in stream: %v", r)
cleanCtx, cancel := context.WithTimeout(context.Background(), stopTimeout)
defer cancel()
req.Runner.Cleanup(cleanCtx, req.Computer)
LifecycleAction(cleanCtx, req.Config, req.Computer, req.Manager)
}
}()
// Lifecycle action (registered second, executes second-to-last).
defer func() {
if !panicked {
LifecycleAction(stdCtx, req.Config, req.Computer, req.Manager)
}
}()
// Runner cleanup (registered last, executes first).
defer func() {
if !panicked {
cleanCtx, cancel := context.WithTimeout(context.Background(), stopTimeout)
defer cancel()
req.Runner.Cleanup(cleanCtx, req.Computer)
}
}()
// Build a cancellable runnerCtx that bridges agentContext interrupts.
runnerCtx, cancelRunner := context.WithCancel(stdCtx)
defer cancelRunner() // Prevent goroutine leak.
done := make(chan struct{})
defer close(done)
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if ctx.Interrupt != nil {
if sig := ctx.Interrupt.Peek(); sig != nil {
cancelRunner()
return
}
if ctx.Interrupt.IsInterrupted() {
cancelRunner()
return
}
}
case <-stdCtx.Done():
cancelRunner()
return
}
}
}()
if req.LoadingMsgID != "" {
waitMsg := &message.Message{
MessageID: req.LoadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]any{
"message": i18n.T(ctx.Locale, "sandbox.waiting_response"),
},
}
ctx.Send(waitMsg)
}
var textContent []byte
loadingClosed := false
wrappedHandler := func(chunkType message.StreamChunkType, data []byte) int {
if !loadingClosed && req.LoadingMsgID != "" {
if chunkType == message.ChunkText || chunkType == message.ChunkToolCall || chunkType == message.ChunkMessageStart {
closeLoading(ctx, req.LoadingMsgID)
loadingClosed = true
}
}
if chunkType == message.ChunkText {
textContent = append(textContent, data...)
}
if handler != nil {
return handler(chunkType, data)
}
return 0
}
err := req.Runner.Stream(runnerCtx, req.StreamReq, wrappedHandler)
if !loadingClosed && req.LoadingMsgID != "" {
closeLoading(ctx, req.LoadingMsgID)
}
panicked = false // Normal exit reached.
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
}
return nil, fmt.Errorf("runner.Stream: %w", err)
}
resp := &agentContext.CompletionResponse{
Role: "assistant",
FinishReason: agentContext.FinishReasonStop,
}
if len(textContent) > 0 {
resp.Content = string(textContent)
}
return resp, nil
}
func closeLoading(ctx *agentContext.Context, loadingMsgID string) {
if loadingMsgID == "" || ctx == nil {
return
}
msg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]any{
"done": true,
"message": "",
},
}
ctx.Send(msg)
}