yao/agent/assistant/agent.go
Max f2099babd9 Update Go module dependencies and enhance assistant context management
- Upgraded Go version to 1.25 and updated several dependencies, including `testify` to v1.11.1 and added new indirect dependencies for JSON schema validation.
- Refactored the assistant's context management to utilize a new `context.Uses` structure, improving the handling of vision, audio, search, and fetch configurations.
- Enhanced the assistant's request building process to support new response formats, including JSON schema validation, ensuring better integration with various tools and services.
2025-11-15 10:05:57 +08:00

487 lines
14 KiB
Go

package assistant
import (
"fmt"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/utils/jsonschema"
)
// Stream stream the agent
// handler is optional, if not provided, a default handler will be used
func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Message, handler ...context.StreamFunc) (*context.Response, error) {
var err error
// Initialize stack and auto-handle completion/failure/restore
_, traceID, done := context.EnterStack(ctx, ast.ID, ctx.Referer)
defer done()
_ = traceID // traceID is available for trace logging
// Full input messages with chat history
fullMessages, err := ast.WithHistory(ctx, inputMessages)
if err != nil {
return nil, err
}
// Request Create hook ( Optional )
var createResponse *context.HookCreateResponse
if ast.Script != nil {
var err error
createResponse, err = ast.Script.Create(ctx, fullMessages)
if err != nil {
return nil, err
}
}
var completionOptions *context.CompletionOptions // default is nil
// LLM Call Stream ( Optional )
var completionMessages []context.Message
var completionResponse *context.CompletionResponse
if ast.Prompts != nil || ast.MCP != nil {
// Build the LLM request first
completionMessages, completionOptions, err = ast.BuildRequest(ctx, inputMessages, createResponse)
if err != nil {
return nil, err
}
// Get connector object and capabilities
conn, capabilities, err := ast.GetConnector(ctx)
if err != nil {
return nil, err
}
// Set capabilities in options if not already set
if completionOptions.Capabilities == nil && capabilities != nil {
completionOptions.Capabilities = capabilities
}
// Create LLM instance with connector and options
llmInstance, err := llm.New(conn, completionOptions)
if err != nil {
return nil, err
}
// Use provided handler or default handler
streamHandler := llm.DefaultStreamHandler(ctx)
if len(handler) > 0 && handler[0] != nil {
streamHandler = handler[0]
}
// Call the LLM Completion Stream
completionResponse, err = llmInstance.Stream(ctx, completionMessages, completionOptions, streamHandler)
if err != nil {
return nil, err
}
}
// Request MCP hook ( Optional )
var mcpResponse *context.ResponseHookMCP
if ast.MCP != nil {
_ = mcpResponse // mcpResponse is available for further processing
// MCP Execution Loop
}
// Request Done hook ( Optional )
var doneResponse *context.ResponseHookDone
if ast.Script != nil {
var err error
doneResponse, err = ast.Script.Done(ctx, fullMessages, completionResponse, mcpResponse)
if err != nil {
return nil, err
}
}
_ = doneResponse // doneResponse is available for further processing
return &context.Response{Create: createResponse, Done: doneResponse, Completion: completionResponse}, nil
}
// GetConnector get the connector object, capabilities, and error with priority: createResponse > ctx > ast
// Note: createResponse.Connector is already applied to ctx.Connector by applyContextAdjustments in create.go
// Returns: (connector, capabilities, error)
func (ast *Assistant) GetConnector(ctx *context.Context) (connector.Connector, *context.ModelCapabilities, error) {
// Determine connector ID with priority
connectorID := ast.Connector
if ctx.Connector != "" {
connectorID = ctx.Connector
}
// If empty, return error
if connectorID == "" {
return nil, nil, fmt.Errorf("connector not specified")
}
// Load gou connector
conn, err := connector.Select(connectorID)
if err != nil {
return nil, nil, err
}
// Get connector capabilities from settings
capabilities := ast.getConnectorCapabilities(connectorID)
return conn, capabilities, nil
}
// getConnectorCapabilities get the capabilities of a connector from settings
func (ast *Assistant) getConnectorCapabilities(connectorID string) *context.ModelCapabilities {
// Get connector setting from global settings
setting, exists := connectorSettings[connectorID]
if !exists {
return nil
}
// Convert ConnectorSetting to ModelCapabilities
capabilities := &context.ModelCapabilities{}
if setting.Vision {
v := true
capabilities.Vision = &v
}
// Handle both Tools (deprecated) and ToolCalls
if setting.ToolCalls || setting.Tools {
v := true
capabilities.ToolCalls = &v
}
if setting.Audio {
v := true
capabilities.Audio = &v
}
if setting.Reasoning {
v := true
capabilities.Reasoning = &v
}
if setting.Streaming {
v := true
capabilities.Streaming = &v
}
if setting.JSON {
v := true
capabilities.JSON = &v
}
if setting.Multimodal {
v := true
capabilities.Multimodal = &v
}
return capabilities
}
// BuildRequest build the LLM request
func (ast *Assistant) BuildRequest(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse) ([]context.Message, *context.CompletionOptions, error) {
// Build final messages with proper priority
finalMessages, err := ast.buildMessages(ctx, messages, createResponse)
if err != nil {
return nil, nil, err
}
// Build completion options from createResponse and ctx
options, err := ast.buildCompletionOptions(ctx, createResponse)
if err != nil {
return nil, nil, err
}
return finalMessages, options, nil
}
// buildMessages builds the final message list with proper priority
// Priority: createResponse.Messages > input messages
// If createResponse is nil or has no messages, use input messages
func (ast *Assistant) buildMessages(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse) ([]context.Message, error) {
// If createResponse is nil or has no messages, return input messages as-is
if createResponse == nil || len(createResponse.Messages) == 0 {
return messages, nil
}
// createResponse.Messages takes highest priority
// Return them directly as they override everything
return createResponse.Messages, nil
}
// buildCompletionOptions builds completion options from multiple sources
// Priority (lowest to highest, later overrides earlier): ast > ctx > createResponse
// The priority means: if createResponse has a value, use it; else use ctx; else use ast
func (ast *Assistant) buildCompletionOptions(ctx *context.Context, createResponse *context.HookCreateResponse) (*context.CompletionOptions, error) {
options := &context.CompletionOptions{}
// Layer 1 (base): Apply ast - Assistant configuration
if err := ast.applyAssistantOptions(options); err != nil {
return nil, err
}
// Layer 2 (middle): Apply ctx - Context configuration (overrides ast)
ast.applyContextOptions(options, ctx)
// Layer 3 (highest): Apply createResponse - Hook configuration (overrides all)
if createResponse != nil {
ast.applyCreateResponseOptions(options, createResponse)
}
return options, nil
}
// applyAssistantOptions applies options from ast.Options to CompletionOptions
// ast.Options can contain any OpenAI API parameters (temperature, top_p, stop, etc.)
// Returns error if any option validation fails (e.g., invalid JSON Schema)
func (ast *Assistant) applyAssistantOptions(options *context.CompletionOptions) error {
if ast.Options == nil {
return nil
}
// Temperature
if v, ok := ast.Options["temperature"].(float64); ok {
options.Temperature = &v
}
// MaxTokens
if v, ok := ast.Options["max_tokens"].(float64); ok {
intVal := int(v)
options.MaxTokens = &intVal
} else if v, ok := ast.Options["max_tokens"].(int); ok {
options.MaxTokens = &v
}
// MaxCompletionTokens
if v, ok := ast.Options["max_completion_tokens"].(float64); ok {
intVal := int(v)
options.MaxCompletionTokens = &intVal
} else if v, ok := ast.Options["max_completion_tokens"].(int); ok {
options.MaxCompletionTokens = &v
}
// TopP
if v, ok := ast.Options["top_p"].(float64); ok {
options.TopP = &v
}
// N (number of choices)
if v, ok := ast.Options["n"].(float64); ok {
intVal := int(v)
options.N = &intVal
} else if v, ok := ast.Options["n"].(int); ok {
options.N = &v
}
// Stop sequences (can be string or []string)
if v, ok := ast.Options["stop"]; ok {
options.Stop = v
}
// PresencePenalty
if v, ok := ast.Options["presence_penalty"].(float64); ok {
options.PresencePenalty = &v
}
// FrequencyPenalty
if v, ok := ast.Options["frequency_penalty"].(float64); ok {
options.FrequencyPenalty = &v
}
// LogitBias
if v, ok := ast.Options["logit_bias"].(map[string]interface{}); ok {
logitBias := make(map[string]float64)
for key, val := range v {
if fval, ok := val.(float64); ok {
logitBias[key] = fval
}
}
if len(logitBias) > 0 {
options.LogitBias = logitBias
}
}
// User
if v, ok := ast.Options["user"].(string); ok {
options.User = v
}
// ResponseFormat
// @todo: Assistant should have a default response format
if v, ok := ast.Options["response_format"]; ok {
// Try to convert to *context.ResponseFormat
if rf, ok := v.(*context.ResponseFormat); ok {
// Validate JSONSchema if present - reject if invalid
if rf.JSONSchema != nil && rf.JSONSchema.Schema != nil {
if _, err := jsonschema.New(rf.JSONSchema.Schema); err != nil {
return fmt.Errorf("invalid JSON Schema in response_format: %w", err)
}
}
options.ResponseFormat = rf
} else if rfMap, ok := v.(map[string]interface{}); ok {
// Handle legacy map[string]interface{} format
// Try to parse into ResponseFormat struct
rf := &context.ResponseFormat{}
// Parse type
if typeStr, ok := rfMap["type"].(string); ok {
rf.Type = context.ResponseFormatType(typeStr)
}
// Parse json_schema if present
if jsonSchemaMap, ok := rfMap["json_schema"].(map[string]interface{}); ok {
jsonSchema := &context.JSONSchema{}
if name, ok := jsonSchemaMap["name"].(string); ok {
jsonSchema.Name = name
}
if desc, ok := jsonSchemaMap["description"].(string); ok {
jsonSchema.Description = desc
}
if schema, ok := jsonSchemaMap["schema"]; ok {
// Validate schema format - reject if invalid
if _, err := jsonschema.New(schema); err != nil {
return fmt.Errorf("invalid JSON Schema in response_format: %w", err)
}
jsonSchema.Schema = schema
}
if strict, ok := jsonSchemaMap["strict"].(bool); ok {
jsonSchema.Strict = &strict
}
rf.JSONSchema = jsonSchema
}
options.ResponseFormat = rf
}
}
// Seed
if v, ok := ast.Options["seed"].(float64); ok {
intVal := int(v)
options.Seed = &intVal
} else if v, ok := ast.Options["seed"].(int); ok {
options.Seed = &v
}
// Tools
if v, ok := ast.Options["tools"].([]interface{}); ok {
tools := make([]map[string]interface{}, 0, len(v))
for _, tool := range v {
if toolMap, ok := tool.(map[string]interface{}); ok {
tools = append(tools, toolMap)
}
}
if len(tools) > 0 {
options.Tools = tools
}
}
// ToolChoice
if v, ok := ast.Options["tool_choice"]; ok {
options.ToolChoice = v
}
// Stream
if v, ok := ast.Options["stream"].(bool); ok {
options.Stream = &v
}
return nil
}
// applyContextOptions applies options from ctx to CompletionOptions
// ctx provides Route and Metadata for CUI context
func (ast *Assistant) applyContextOptions(options *context.CompletionOptions, ctx *context.Context) {
// Set Route and Metadata from ctx
options.Route = ctx.Route
options.Metadata = ctx.Metadata
// Set Uses configurations (assistant.Uses has priority over global settings)
// These can be overridden by createResponse
options.Uses = ast.getUses()
}
// applyCreateResponseOptions applies options from createResponse to CompletionOptions
// createResponse takes highest priority and overrides any previous settings
func (ast *Assistant) applyCreateResponseOptions(options *context.CompletionOptions, createResponse *context.HookCreateResponse) {
// Audio configuration
if createResponse.Audio != nil {
options.Audio = createResponse.Audio
}
// Temperature
if createResponse.Temperature != nil {
options.Temperature = createResponse.Temperature
}
// MaxTokens
if createResponse.MaxTokens != nil {
options.MaxTokens = createResponse.MaxTokens
}
// MaxCompletionTokens
if createResponse.MaxCompletionTokens != nil {
options.MaxCompletionTokens = createResponse.MaxCompletionTokens
}
// Route
if createResponse.Route != "" {
options.Route = createResponse.Route
}
// Metadata (merge with existing)
if createResponse.Metadata != nil {
if options.Metadata == nil {
options.Metadata = createResponse.Metadata
} else {
// Merge: createResponse.Metadata overrides existing
for key, value := range createResponse.Metadata {
options.Metadata[key] = value
}
}
}
}
// getUses get the Uses configuration with priority: assistant.Uses > global settings
func (ast *Assistant) getUses() *context.Uses {
// Priority 1: Assistant-specific Uses configuration
if ast.Uses != nil {
// Create a merged Uses by starting with global, then override with assistant-specific
merged := &context.Uses{}
// Start with global settings
if globalUses != nil {
merged.Vision = globalUses.Vision
merged.Audio = globalUses.Audio
merged.Search = globalUses.Search
merged.Fetch = globalUses.Fetch
}
// Override with assistant-specific settings (only if not empty)
if ast.Uses.Vision != "" {
merged.Vision = ast.Uses.Vision
}
if ast.Uses.Audio != "" {
merged.Audio = ast.Uses.Audio
}
if ast.Uses.Search != "" {
merged.Search = ast.Uses.Search
}
if ast.Uses.Fetch != "" {
merged.Fetch = ast.Uses.Fetch
}
return merged
}
// Priority 2: Global settings only
return globalUses
}
// WithHistory with the history messages
func (ast *Assistant) WithHistory(ctx *context.Context, messages []context.Message) ([]context.Message, error) {
return messages, nil
}