package standard import ( "fmt" "time" "github.com/yaoapp/gou/text" kunlog "github.com/yaoapp/kun/log" "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 // Workspace is the workspace ID bound to the Robot. // When non-empty, injected into agentCtx.Metadata["workspace_id"] for sandbox node resolution. Workspace 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, } agentCtx := c.buildAgentContext(ctx, assistantID) defer func() { kunlog.Trace("[robot-agent] releasing context: assistantID=%s chatID=%s", assistantID, c.ChatID) agentCtx.Release() }() callStart := time.Now() kunlog.Trace("[robot-agent] Call started: assistantID=%s chatID=%s", assistantID, c.ChatID) response, err := ast.Stream(agentCtx, messages, opts) if err != nil { kunlog.Trace("[robot-agent] Call failed: assistantID=%s elapsed=%v err=%v", assistantID, time.Since(callStart).Round(time.Second), err) return nil, fmt.Errorf("assistant call failed: %w", err) } kunlog.Trace("[robot-agent] Call completed: assistantID=%s elapsed=%v", assistantID, time.Since(callStart).Round(time.Second)) 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 } // 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, assistantID) defer func() { kunlog.Trace("[robot-agent] releasing context (CallStream): assistantID=%s chatID=%s", assistantID, c.ChatID) agentCtx.Release() }() callStart := time.Now() kunlog.Trace("[robot-agent] CallStream started: assistantID=%s chatID=%s", assistantID, c.ChatID) response, err := ast.Stream(agentCtx, messages, opts) if err != nil { kunlog.Trace("[robot-agent] CallStream failed: assistantID=%s elapsed=%v err=%v", assistantID, time.Since(callStart).Round(time.Second), err) return nil, fmt.Errorf("assistant call failed: %w", err) } kunlog.Trace("[robot-agent] CallStream completed: assistantID=%s elapsed=%v", assistantID, time.Since(callStart).Round(time.Second)) 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, assistantID) defer func() { kunlog.Trace("[robot-agent] releasing context (CallStreamRaw): assistantID=%s chatID=%s", assistantID, c.ChatID) agentCtx.Release() }() callStart := time.Now() kunlog.Trace("[robot-agent] CallStreamRaw started: assistantID=%s chatID=%s", assistantID, c.ChatID) response, err := ast.Stream(agentCtx, messages, opts) if err != nil { kunlog.Trace("[robot-agent] CallStreamRaw failed: assistantID=%s elapsed=%v err=%v", assistantID, time.Since(callStart).Round(time.Second), err) return nil, fmt.Errorf("assistant call failed: %w", err) } kunlog.Trace("[robot-agent] CallStreamRaw completed: assistantID=%s elapsed=%v", assistantID, time.Since(callStart).Round(time.Second)) 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, assistantID string) *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) agentCtx.AssistantID = assistantID // Propagate locale to agent context; fall back to "en" so that // i18n.Tr / buildBoxDisplayName always resolve {{name}} templates. if ctx.Locale != "" { agentCtx.Locale = ctx.Locale } else { agentCtx.Locale = "en" } // 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() if c.Workspace != "" { if agentCtx.Metadata == nil { agentCtx.Metadata = map[string]interface{}{} } agentCtx.Metadata["workspace_id"] = c.Workspace } kunlog.Trace("[robot-agent] context built: assistantID=%s chatID=%s contextID=%s", assistantID, c.ChatID, agentCtx.ID) 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 } } }