- Introduced a new `workspace` field across various robot-related structures, including `CreateRobotRequest`, `UpdateRobotRequest`, and `RobotResponse`, allowing for better organization and management of robots within specific workspaces. - Updated database queries and response mappings to accommodate the new workspace field, ensuring seamless integration with existing functionalities. - Enhanced agent execution context to include workspace information, improving the contextual awareness of agents during operations. - Added tests to validate the creation and updating of robots with workspace data, ensuring robust functionality and backward compatibility.
673 lines
20 KiB
Go
673 lines
20 KiB
Go
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
|
|
}
|
|
}
|
|
}
|