- 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.
487 lines
14 KiB
Go
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
|
|
}
|