yao/agent/robot/executor/standard/agent.go
Max 3fee0e3fc3 Enhance execution listing and interaction with pagination and streaming support
- Refactor execution listing to support pagination with `Page` and `PageSize` options, replacing previous `Limit` and `Offset` parameters.
- Introduce `ExcludeStatuses` in execution queries to filter out specific execution statuses.
- Implement streaming interaction methods in the manager, allowing real-time responses from the host agent during interactions.
- Update API endpoints to accommodate new query parameters and enhance interaction capabilities with streaming support.
- Modify tests to ensure coverage for new pagination and streaming functionalities.
2026-02-27 16:38:30 +08:00

633 lines
18 KiB
Go

package standard
import (
"fmt"
"github.com/yaoapp/gou/text"
"github.com/yaoapp/yao/agent/assistant"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
robottypes "github.com/yaoapp/yao/agent/robot/types"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
)
// StreamCallback receives text chunks during streaming agent calls.
// Return 0 to continue, non-zero to stop.
type StreamCallback func(chunk *StreamChunk) int
// StreamChunk represents a single chunk in a streaming response.
type StreamChunk struct {
Type string // "text", "thinking", "event"
Content string
Delta bool
}
// AgentCaller provides unified interface for calling AI assistants
// It wraps the Yao Assistant framework and handles:
// - Getting assistant by ID
// - Single call with messages (streaming)
// - Multi-turn conversation with session state
// - Parsing responses (text, JSON, Next hook data)
type AgentCaller struct {
// SkipOutput skips sending output to client (for internal calls)
SkipOutput bool
// SkipHistory skips saving to chat history (default: true for robot)
// Set to false to enable multi-turn conversation with history
SkipHistory bool
// SkipSearch skips auto search
SkipSearch bool
// ChatID is used for multi-turn conversations to maintain session state
// If empty, each call is independent (no history)
ChatID string
// Connector overrides the assistant's default LLM connector (from Robot.LanguageModel).
// When non-empty, passed as opts.Connector to ast.Stream so the agent uses the Robot's model.
Connector string
// log is an optional structured logger; when set, Call emits agent-call logs.
log *execLogger
}
// NewAgentCaller creates a new AgentCaller with default settings (single-call mode)
func NewAgentCaller() *AgentCaller {
return &AgentCaller{
SkipOutput: true, // Robot executions don't send to UI
SkipHistory: true, // Robot executions don't save to chat history
SkipSearch: true, // Robot executions don't trigger auto search
}
}
// NewConversationCaller creates an AgentCaller for multi-turn conversations
// chatID is used to maintain session state across calls
// This is useful for:
// - P2 (Tasks): Iterative task refinement with user feedback
// - P3 (Run): Multi-step task execution with intermediate results
func NewConversationCaller(chatID string) *AgentCaller {
return &AgentCaller{
SkipOutput: true,
SkipHistory: false, // Enable history for multi-turn
SkipSearch: true,
ChatID: chatID,
}
}
// CallResult holds the result of an agent call
type CallResult struct {
// Content is the raw text content from LLM completion
Content string
// Next is the data returned from Next hook (if any)
// This is typically a structured response from the assistant
Next interface{}
// Response is the full response object (for advanced use)
Response *agentcontext.Response
}
// IsEmpty returns true if the result has no content
func (r *CallResult) IsEmpty() bool {
return r.Content == "" && r.Next == nil
}
// GetText returns the text content, preferring Content over Next
func (r *CallResult) GetText() string {
if r.Content != "" {
return r.Content
}
if s, ok := r.Next.(string); ok {
return s
}
if m, ok := r.Next.(map[string]interface{}); ok {
if content, ok := m["content"].(string); ok {
return content
}
if data, ok := m["data"].(map[string]interface{}); ok {
if content, ok := data["content"].(string); ok {
return content
}
}
}
return ""
}
// GetJSON attempts to parse the result as JSON
// It tries in order:
// 1. Next hook data (already structured)
// 2. Content parsed using gou/text.ExtractJSON (fault-tolerant)
// Returns the parsed data and any error
func (r *CallResult) GetJSON() (map[string]interface{}, error) {
if r.Next != nil {
if m, ok := r.Next.(map[string]interface{}); ok {
if data, ok := m["data"].(map[string]interface{}); ok {
return data, nil
}
return m, nil
}
}
if r.Content != "" {
data := text.ExtractJSON(r.Content)
if data != nil {
if m, ok := data.(map[string]interface{}); ok {
return m, nil
}
}
return nil, fmt.Errorf("content is not a JSON object")
}
return nil, fmt.Errorf("no content to parse")
}
// GetJSONArray attempts to parse the result as JSON array
// Similar to GetJSON but for array responses
func (r *CallResult) GetJSONArray() ([]interface{}, error) {
// Try Next hook data first
if r.Next != nil {
if arr, ok := r.Next.([]interface{}); ok {
return arr, nil
}
if m, ok := r.Next.(map[string]interface{}); ok {
// Check for "data" wrapper
if data, ok := m["data"].([]interface{}); ok {
return data, nil
}
}
}
// Try parsing Content using gou/text (handles markdown blocks, JSON, YAML)
if r.Content != "" {
data := text.ExtractJSON(r.Content)
if data != nil {
if arr, ok := data.([]interface{}); ok {
return arr, nil
}
}
return nil, fmt.Errorf("content is not a JSON array")
}
return nil, fmt.Errorf("no content to parse")
}
// Call calls an assistant with messages and returns the result
// This is the main entry point for agent calls
func (c *AgentCaller) Call(ctx *robottypes.Context, assistantID string, messages []agentcontext.Message) (*CallResult, error) {
// Get assistant
ast, err := assistant.Get(assistantID)
if err != nil {
return nil, fmt.Errorf("assistant not found: %s: %w", assistantID, err)
}
// Build options
opts := &agentcontext.Options{
Skip: &agentcontext.Skip{
Output: c.SkipOutput,
History: c.SkipHistory,
Search: c.SkipSearch,
},
Connector: c.Connector,
}
// Convert robot context to agent context
agentCtx := c.buildAgentContext(ctx)
defer agentCtx.Release() // IMPORTANT: Release agent context to prevent resource leaks
// Call assistant with streaming
response, err := ast.Stream(agentCtx, messages, opts)
if err != nil {
return nil, fmt.Errorf("assistant call failed: %w", err)
}
// Build result
result := &CallResult{
Response: response,
}
// Extract Next hook data
if response.Next != nil {
result.Next = response.Next
}
// Extract Content from Completion
if response.Completion != nil {
if content, ok := response.Completion.Content.(string); ok {
result.Content = content
}
}
if c.log != nil {
c.log.logAgentCall(assistantID, result)
}
return result, nil
}
// CallWithMessages is a convenience method that builds messages from a single user input
func (c *AgentCaller) CallWithMessages(ctx *robottypes.Context, assistantID string, userContent string) (*CallResult, error) {
messages := []agentcontext.Message{
{
Role: agentcontext.RoleUser,
Content: userContent,
},
}
return c.Call(ctx, assistantID, messages)
}
// CallWithSystemAndUser calls with both system and user messages
func (c *AgentCaller) CallWithSystemAndUser(ctx *robottypes.Context, assistantID string, systemContent, userContent string) (*CallResult, error) {
messages := []agentcontext.Message{
{
Role: agentcontext.RoleSystem,
Content: systemContent,
},
{
Role: agentcontext.RoleUser,
Content: userContent,
},
}
return c.Call(ctx, assistantID, messages)
}
// CallStream calls an assistant with messages and streams text chunks via callback.
// The callback receives each text delta in real-time while the response is being generated.
// After streaming completes, the full CallResult is returned.
func (c *AgentCaller) CallStream(ctx *robottypes.Context, assistantID string, messages []agentcontext.Message, streamFn StreamCallback) (*CallResult, error) {
ast, err := assistant.Get(assistantID)
if err != nil {
return nil, fmt.Errorf("assistant not found: %s: %w", assistantID, err)
}
opts := &agentcontext.Options{
Skip: &agentcontext.Skip{
Output: c.SkipOutput,
History: c.SkipHistory,
Search: c.SkipSearch,
},
Connector: c.Connector,
}
// Hook OnMessage to intercept streaming chunks and forward to callback
if streamFn != nil {
opts.OnMessage = func(msg *message.Message) int {
if msg == nil {
return 0
}
switch msg.Type {
case message.TypeText:
if msg.Delta {
content, _ := msg.Props["content"].(string)
if content != "" {
return streamFn(&StreamChunk{Type: "text", Content: content, Delta: true})
}
}
case message.TypeThinking:
if msg.Delta {
content, _ := msg.Props["content"].(string)
if content != "" {
return streamFn(&StreamChunk{Type: "thinking", Content: content, Delta: true})
}
}
}
return 0
}
}
agentCtx := c.buildAgentContext(ctx)
defer agentCtx.Release()
response, err := ast.Stream(agentCtx, messages, opts)
if err != nil {
return nil, fmt.Errorf("assistant call failed: %w", err)
}
result := &CallResult{Response: response}
if response.Next != nil {
result.Next = response.Next
}
if response.Completion != nil {
if content, ok := response.Completion.Content.(string); ok {
result.Content = content
}
}
if c.log != nil {
c.log.logAgentCall(assistantID, result)
}
return result, nil
}
// CallWithMessagesStream is a convenience method that streams a single user input.
func (c *AgentCaller) CallWithMessagesStream(ctx *robottypes.Context, assistantID string, userContent string, streamFn StreamCallback) (*CallResult, error) {
messages := []agentcontext.Message{
{
Role: agentcontext.RoleUser,
Content: userContent,
},
}
return c.CallStream(ctx, assistantID, messages, streamFn)
}
// CallStreamRaw calls an assistant with streaming, passing raw message.Message objects
// to the callback without any type filtering or degradation. This preserves all CUI
// message protocol fields (chunk_id, message_id, block_id, delta_path, etc.)
// for direct SSE passthrough to the frontend.
func (c *AgentCaller) CallStreamRaw(ctx *robottypes.Context, assistantID string, messages []agentcontext.Message, onMessage agentcontext.OnMessageFunc) (*CallResult, error) {
ast, err := assistant.Get(assistantID)
if err != nil {
return nil, fmt.Errorf("assistant not found: %s: %w", assistantID, err)
}
opts := &agentcontext.Options{
Skip: &agentcontext.Skip{
Output: c.SkipOutput,
History: c.SkipHistory,
Search: c.SkipSearch,
},
Connector: c.Connector,
}
if onMessage != nil {
opts.OnMessage = onMessage
}
agentCtx := c.buildAgentContext(ctx)
defer agentCtx.Release()
response, err := ast.Stream(agentCtx, messages, opts)
if err != nil {
return nil, fmt.Errorf("assistant call failed: %w", err)
}
result := &CallResult{Response: response}
if response.Next != nil {
result.Next = response.Next
}
if response.Completion != nil {
if content, ok := response.Completion.Content.(string); ok {
result.Content = content
}
}
if c.log != nil {
c.log.logAgentCall(assistantID, result)
}
return result, nil
}
// CallWithMessagesStreamRaw is a convenience method that streams raw messages for a single user input.
func (c *AgentCaller) CallWithMessagesStreamRaw(ctx *robottypes.Context, assistantID string, userContent string, onMessage agentcontext.OnMessageFunc) (*CallResult, error) {
messages := []agentcontext.Message{
{
Role: agentcontext.RoleUser,
Content: userContent,
},
}
return c.CallStreamRaw(ctx, assistantID, messages, onMessage)
}
// buildAgentContext converts robot context to agent context
func (c *AgentCaller) buildAgentContext(ctx *robottypes.Context) *agentcontext.Context {
// Build authorized info for agent context
var authorized *oauthtypes.AuthorizedInfo
if ctx.Auth != nil {
authorized = &oauthtypes.AuthorizedInfo{
UserID: ctx.Auth.UserID,
TeamID: ctx.Auth.TeamID,
}
}
// Create a new agent context
// Use ChatID for multi-turn conversations, empty for single calls
agentCtx := agentcontext.New(ctx.Context, authorized, c.ChatID)
// Set locale if available
if ctx.Locale != "" {
agentCtx.Locale = ctx.Locale
}
// Use noop logger to suppress LLM debug output for robot executions
// Robot executions run in background and don't need console output
if agentCtx.Logger != nil {
agentCtx.Logger.Close()
}
agentCtx.Logger = agentcontext.Noop()
return agentCtx
}
// ExtractCodeBlock extracts the first code block from content using gou/text
// Returns the CodeBlock with type, content, and parsed data (for JSON/YAML)
func ExtractCodeBlock(content string) *text.CodeBlock {
return text.ExtractFirst(content)
}
// ExtractAllCodeBlocks extracts all code blocks from content using gou/text
func ExtractAllCodeBlocks(content string) []text.CodeBlock {
return text.Extract(content)
}
// ============================================================================
// Conversation - Multi-turn dialogue support
// ============================================================================
// Conversation manages a multi-turn dialogue with an assistant
// Useful for:
// - P2 (Tasks): Iterative task planning with clarification
// - P3 (Run): Multi-step execution with intermediate validation
// - Complex reasoning that requires back-and-forth
type Conversation struct {
caller *AgentCaller
assistantID string
messages []agentcontext.Message
maxTurns int
}
// TurnResult holds the result of a single conversation turn
type TurnResult struct {
Turn int // Turn number (1-based)
Input string // User input for this turn
Result *CallResult // Agent response
Messages []agentcontext.Message // Full message history after this turn
}
// NewConversation creates a new multi-turn conversation
// assistantID: the assistant to converse with
// chatID: session ID for maintaining state (use exec.ID for robot executions)
// maxTurns: maximum number of turns (0 = unlimited)
func NewConversation(assistantID, chatID string, maxTurns int) *Conversation {
return &Conversation{
caller: NewConversationCaller(chatID),
assistantID: assistantID,
messages: make([]agentcontext.Message, 0),
maxTurns: maxTurns,
}
}
// WithCaller sets a custom AgentCaller for the conversation
// Useful for customizing SkipSearch or other options
func (c *Conversation) WithCaller(caller *AgentCaller) *Conversation {
c.caller = caller
return c
}
// WithSystemPrompt adds a system prompt at the beginning of the conversation
func (c *Conversation) WithSystemPrompt(systemPrompt string) *Conversation {
if systemPrompt != "" {
c.messages = append([]agentcontext.Message{{
Role: agentcontext.RoleSystem,
Content: systemPrompt,
}}, c.messages...)
}
return c
}
// WithHistory initializes the conversation with existing message history
// Note: Message structs are copied, but Content (interface{}) is a shallow copy
func (c *Conversation) WithHistory(messages []agentcontext.Message) *Conversation {
c.messages = append(c.messages, messages...)
return c
}
// Turn executes a single turn in the conversation
// userInput: the user's message for this turn
// Returns the turn result with agent response
func (c *Conversation) Turn(ctx *robottypes.Context, userInput string) (*TurnResult, error) {
// Check max turns
turnNum := c.TurnCount() + 1
if c.maxTurns > 0 && turnNum > c.maxTurns {
return nil, fmt.Errorf("max turns (%d) exceeded", c.maxTurns)
}
// Build messages with user input (don't modify history yet)
userMsg := agentcontext.Message{
Role: agentcontext.RoleUser,
Content: userInput,
}
// Create a new slice to avoid modifying c.messages if capacity allows append in-place
messagesWithInput := make([]agentcontext.Message, len(c.messages)+1)
copy(messagesWithInput, c.messages)
messagesWithInput[len(c.messages)] = userMsg
// Call assistant with full history
result, err := c.caller.Call(ctx, c.assistantID, messagesWithInput)
if err != nil {
return nil, fmt.Errorf("turn %d failed: %w", turnNum, err)
}
// Only update history after successful call
c.messages = append(c.messages, userMsg)
// Add assistant response to history
if result.Content != "" {
c.messages = append(c.messages, agentcontext.Message{
Role: agentcontext.RoleAssistant,
Content: result.Content,
})
}
// Return a copy of messages to prevent external modification
messagesCopy := make([]agentcontext.Message, len(c.messages))
copy(messagesCopy, c.messages)
return &TurnResult{
Turn: turnNum,
Input: userInput,
Result: result,
Messages: messagesCopy,
}, nil
}
// TurnCount returns the number of user turns so far
func (c *Conversation) TurnCount() int {
count := 0
for _, msg := range c.messages {
if msg.Role == agentcontext.RoleUser {
count++
}
}
return count
}
// Messages returns a copy of the current message history
func (c *Conversation) Messages() []agentcontext.Message {
messagesCopy := make([]agentcontext.Message, len(c.messages))
copy(messagesCopy, c.messages)
return messagesCopy
}
// LastResponse returns a copy of the last assistant response, or nil if none
func (c *Conversation) LastResponse() *agentcontext.Message {
for i := len(c.messages) - 1; i >= 0; i-- {
if c.messages[i].Role == agentcontext.RoleAssistant {
// Return a copy to prevent external modification
msg := c.messages[i]
return &msg
}
}
return nil
}
// Reset clears the conversation history (keeps system prompt if any)
func (c *Conversation) Reset() {
// Keep system prompt if present
var systemPrompt *agentcontext.Message
if len(c.messages) > 0 && c.messages[0].Role == agentcontext.RoleSystem {
systemPrompt = &c.messages[0]
}
c.messages = make([]agentcontext.Message, 0)
if systemPrompt != nil {
c.messages = append(c.messages, *systemPrompt)
}
}
// RunUntil runs the conversation until a condition is met
// checkFn: called after each turn, returns (done, error)
// Returns all turn results
func (c *Conversation) RunUntil(
ctx *robottypes.Context,
inputFn func(turn int, lastResult *CallResult) (string, error),
checkFn func(turn int, result *CallResult) (done bool, err error),
) ([]*TurnResult, error) {
var results []*TurnResult
for {
turnNum := c.TurnCount() + 1
// Check max turns
if c.maxTurns > 0 && turnNum > c.maxTurns {
return results, fmt.Errorf("max turns (%d) exceeded without completion", c.maxTurns)
}
// Get input for this turn
var lastResult *CallResult
if len(results) > 0 {
lastResult = results[len(results)-1].Result
}
input, err := inputFn(turnNum, lastResult)
if err != nil {
return results, fmt.Errorf("input generation failed at turn %d: %w", turnNum, err)
}
// Execute turn
turnResult, err := c.Turn(ctx, input)
if err != nil {
return results, err
}
results = append(results, turnResult)
// Check completion condition
done, err := checkFn(turnNum, turnResult.Result)
if err != nil {
return results, fmt.Errorf("check failed at turn %d: %w", turnNum, err)
}
if done {
return results, nil
}
}
}