Update Dependencies and Enhance Agent Context with Forked Context Support
- Updated `logrus` dependency from v1.9.3 to v1.9.4 and `golang.org/x/sys` from v0.38.0 to v0.40.0 for improved functionality and security. - Introduced a `Fork` method in the agent context to create child contexts for concurrent agent and LLM calls, preventing race conditions on shared state during batch operations. - Enhanced the `Orchestrator` methods to utilize forked contexts, ensuring independent execution of agent calls without interference.
This commit is contained in:
parent
3a9f32af12
commit
02e813af0b
12 changed files with 1732 additions and 20 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/yaoapp/yao/agent/caller"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/llm"
|
||||
"github.com/yaoapp/yao/agent/search"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
|
|
@ -28,6 +29,9 @@ func init() {
|
|||
// Initialize Agent JSAPI factory for ctx.agent.* methods
|
||||
caller.SetJSAPIFactory()
|
||||
|
||||
// Initialize LLM JSAPI factory for ctx.llm.* methods
|
||||
llm.SetJSAPIFactory()
|
||||
|
||||
// Initialize Search JSAPI factory with config getter
|
||||
search.SetJSAPIFactory(func(assistantID string) (*searchTypes.Config, *search.Uses) {
|
||||
ast, err := Get(assistantID)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type callResult struct {
|
|||
|
||||
// All executes all agent calls and waits for all to complete (like Promise.all)
|
||||
// Returns results in the same order as requests, regardless of completion order
|
||||
// Each call uses a forked context to avoid race conditions on shared state
|
||||
func (o *Orchestrator) All(reqs []*Request) []*Result {
|
||||
if len(reqs) == 0 {
|
||||
return []*Result{}
|
||||
|
|
@ -49,7 +50,8 @@ func (o *Orchestrator) All(reqs []*Request) []*Result {
|
|||
}
|
||||
}()
|
||||
|
||||
result := o.callAgent(r)
|
||||
// Use forked context to avoid race conditions
|
||||
result := o.callAgentWithForkedContext(r)
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
|
|
@ -63,6 +65,7 @@ func (o *Orchestrator) All(reqs []*Request) []*Result {
|
|||
// Any returns as soon as any agent call succeeds (has non-error result) (like Promise.any)
|
||||
// Other calls continue in background but results are discarded after first success
|
||||
// Returns all results received so far when first success is found
|
||||
// Each call uses a forked context to avoid race conditions on shared state
|
||||
func (o *Orchestrator) Any(reqs []*Request) []*Result {
|
||||
if len(reqs) == 0 {
|
||||
return []*Result{}
|
||||
|
|
@ -98,7 +101,8 @@ func (o *Orchestrator) Any(reqs []*Request) []*Result {
|
|||
default:
|
||||
}
|
||||
|
||||
result := o.callAgent(r)
|
||||
// Use forked context to avoid race conditions
|
||||
result := o.callAgentWithForkedContext(r)
|
||||
|
||||
// Try to send result
|
||||
select {
|
||||
|
|
@ -132,6 +136,7 @@ func (o *Orchestrator) Any(reqs []*Request) []*Result {
|
|||
// Race returns as soon as any agent call completes (like Promise.race)
|
||||
// Returns immediately when first result arrives, regardless of success/failure
|
||||
// Note: Still waits for all goroutines to complete before returning to avoid resource leaks
|
||||
// Each call uses a forked context to avoid race conditions on shared state
|
||||
func (o *Orchestrator) Race(reqs []*Request) []*Result {
|
||||
if len(reqs) == 0 {
|
||||
return []*Result{}
|
||||
|
|
@ -167,7 +172,8 @@ func (o *Orchestrator) Race(reqs []*Request) []*Result {
|
|||
default:
|
||||
}
|
||||
|
||||
result := o.callAgent(r)
|
||||
// Use forked context to avoid race conditions
|
||||
result := o.callAgentWithForkedContext(r)
|
||||
|
||||
// Try to send result
|
||||
select {
|
||||
|
|
@ -200,6 +206,21 @@ func (o *Orchestrator) Race(reqs []*Request) []*Result {
|
|||
// callAgent executes a single agent call using the AgentGetterFunc
|
||||
// This method handles context sharing and result extraction
|
||||
func (o *Orchestrator) callAgent(req *Request) *Result {
|
||||
return o.callAgentWithContext(o.ctx, req)
|
||||
}
|
||||
|
||||
// callAgentWithForkedContext executes a single agent call with a forked context
|
||||
// This is used by batch operations (All/Any/Race) to avoid race conditions
|
||||
// when multiple goroutines modify shared context state (Stack, Logger, etc.)
|
||||
func (o *Orchestrator) callAgentWithForkedContext(req *Request) *Result {
|
||||
// Fork the context to get independent Stack and Logger
|
||||
forkedCtx := o.ctx.Fork()
|
||||
return o.callAgentWithContext(forkedCtx, req)
|
||||
}
|
||||
|
||||
// callAgentWithContext executes a single agent call with the given context
|
||||
// This is the core implementation used by both callAgent and callAgentWithForkedContext
|
||||
func (o *Orchestrator) callAgentWithContext(ctx *agentContext.Context, req *Request) *Result {
|
||||
if req == nil {
|
||||
return &Result{Error: "nil request"}
|
||||
}
|
||||
|
|
@ -237,9 +258,9 @@ func (o *Orchestrator) callAgent(req *Request) *Result {
|
|||
ctxOpts.OnMessage = req.Handler
|
||||
}
|
||||
|
||||
// Execute the agent call with shared context
|
||||
// The agent.Stream method will use the parent context's Writer for output
|
||||
resp, err := agent.Stream(o.ctx, req.Messages, ctxOpts)
|
||||
// Execute the agent call with the provided context
|
||||
// The agent.Stream method will use the context's Writer for output
|
||||
resp, err := agent.Stream(ctx, req.Messages, ctxOpts)
|
||||
if err != nil {
|
||||
result.Error = "agent call failed: " + err.Error()
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -132,6 +132,62 @@ func (ctx *Context) GetAuthorizedMap() map[string]interface{} {
|
|||
return ctx.Authorized.AuthorizedToMap()
|
||||
}
|
||||
|
||||
// Fork creates a child context for concurrent agent/LLM calls
|
||||
// The forked context shares read-only resources (Memory, Authorized, Cache, Writer)
|
||||
// but has its own independent Stack and Logger to avoid race conditions
|
||||
//
|
||||
// This is essential for batch operations (All/Any/Race) where multiple goroutines
|
||||
// need to execute concurrently without interfering with each other's Stack state.
|
||||
//
|
||||
// The forked context does NOT need to be released separately - the parent context
|
||||
// manages shared resources. However, the child's Stack will be collected in parent's Stacks map.
|
||||
func (ctx *Context) Fork() *Context {
|
||||
childID := generateContextID()
|
||||
|
||||
child := &Context{
|
||||
// Inherit parent's standard context
|
||||
Context: ctx.Context,
|
||||
|
||||
// New unique ID for this forked context
|
||||
ID: childID,
|
||||
|
||||
// Share read-only/thread-safe resources with parent
|
||||
Memory: ctx.Memory, // Memory is designed to be shared
|
||||
Cache: ctx.Cache, // Cache store is thread-safe
|
||||
Writer: ctx.Writer, // Output writer is thread-safe (output module handles concurrency)
|
||||
Authorized: ctx.Authorized, // Read-only auth info
|
||||
Capabilities: ctx.Capabilities, // Read-only model capabilities
|
||||
|
||||
// Share reference to parent's Stacks map for trace collection
|
||||
// Child stacks will be added here by EnterStack
|
||||
Stacks: ctx.Stacks,
|
||||
|
||||
// Create independent resources to avoid race conditions
|
||||
Stack: nil, // Will be set by EnterStack
|
||||
IDGenerator: message.NewIDGenerator(),
|
||||
Logger: NewRequestLogger(ctx.AssistantID, ctx.ChatID, childID),
|
||||
messageMetadata: newMessageMetadataStore(),
|
||||
|
||||
// Inherit context metadata
|
||||
ChatID: ctx.ChatID,
|
||||
AssistantID: ctx.AssistantID,
|
||||
Locale: ctx.Locale,
|
||||
Theme: ctx.Theme,
|
||||
Client: ctx.Client,
|
||||
Referer: ctx.Referer,
|
||||
Accept: ctx.Accept,
|
||||
Route: ctx.Route,
|
||||
Metadata: ctx.Metadata,
|
||||
|
||||
// Don't inherit these - they are request-specific
|
||||
Buffer: nil, // Buffer belongs to root context
|
||||
Interrupt: nil, // Interrupt controller belongs to root context
|
||||
trace: nil, // Trace will be inherited via TraceID in Stack
|
||||
}
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
// Send sends data to the context's writer
|
||||
// This is used by the output module to send messages to the client
|
||||
// func (ctx *Context) Send(data []byte) error {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
|||
// Set agent object for calling other agents
|
||||
jsObject.Set("agent", ctx.newAgentObject(v8ctx.Isolate()))
|
||||
|
||||
// Set llm object for direct LLM calls
|
||||
jsObject.Set("llm", ctx.newLlmObject(v8ctx.Isolate()))
|
||||
|
||||
// Note: Space object will be set after instance creation (requires v8ctx)
|
||||
|
||||
// Create instance
|
||||
|
|
|
|||
360
agent/context/jsapi_llm.go
Normal file
360
agent/context/jsapi_llm.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"rogchap.com/v8go"
|
||||
)
|
||||
|
||||
// LlmAPI defines the LLM JSAPI interface for ctx.llm.*
|
||||
// This interface is defined here to avoid circular dependency between context and llm packages.
|
||||
// The actual implementation is in agent/llm/jsapi.go
|
||||
type LlmAPI interface {
|
||||
// Stream calls LLM with streaming output to ctx.Writer
|
||||
// Returns *llm.Result or error information
|
||||
Stream(connector string, messages []interface{}, opts map[string]interface{}) interface{}
|
||||
|
||||
// Parallel LLM call methods - inspired by JavaScript Promise
|
||||
// All waits for all LLM calls to complete (like Promise.all)
|
||||
All(requests []interface{}) []interface{}
|
||||
// Any returns when any LLM call succeeds (like Promise.any)
|
||||
Any(requests []interface{}) []interface{}
|
||||
// Race returns when any LLM call completes (like Promise.race)
|
||||
Race(requests []interface{}) []interface{}
|
||||
}
|
||||
|
||||
// LlmAPIWithCallback extends LlmAPI with callback support
|
||||
// This interface provides methods that accept OnMessage handlers for real-time message processing
|
||||
type LlmAPIWithCallback interface {
|
||||
LlmAPI
|
||||
|
||||
// StreamWithHandler calls LLM with an OnMessage handler
|
||||
// handler receives SSE messages: func(msg *message.Message) int
|
||||
StreamWithHandler(connector string, messages []interface{}, opts map[string]interface{}, handler OnMessageFunc) interface{}
|
||||
|
||||
// AllWithHandler executes all LLM calls with handlers
|
||||
// globalHandler receives messages with connectorID and index: func(connectorID, index, msg) int
|
||||
AllWithHandler(requests []interface{}, globalHandler LlmBatchOnMessageFunc) []interface{}
|
||||
|
||||
// AnyWithHandler executes LLM calls and returns on first success, with handlers
|
||||
AnyWithHandler(requests []interface{}, globalHandler LlmBatchOnMessageFunc) []interface{}
|
||||
|
||||
// RaceWithHandler executes LLM calls and returns on first completion, with handlers
|
||||
RaceWithHandler(requests []interface{}, globalHandler LlmBatchOnMessageFunc) []interface{}
|
||||
}
|
||||
|
||||
// LlmBatchOnMessageFunc is the OnMessage function for batch LLM calls
|
||||
// It includes connectorID and index to identify the source of each message
|
||||
type LlmBatchOnMessageFunc func(connectorID string, index int, msg *message.Message) int
|
||||
|
||||
// LlmAPIFactory is a function type that creates a LlmAPI for a context
|
||||
// This is set by the llm package during initialization
|
||||
var LlmAPIFactory func(ctx *Context) LlmAPI
|
||||
|
||||
// Llm returns the LLM API for this context
|
||||
// Returns nil if LlmAPIFactory is not set
|
||||
func (ctx *Context) Llm() LlmAPI {
|
||||
if LlmAPIFactory == nil {
|
||||
return nil
|
||||
}
|
||||
return LlmAPIFactory(ctx)
|
||||
}
|
||||
|
||||
// newLlmObject creates a new llm object with all llm methods
|
||||
// This is called from jsapi.go NewObject() to mount ctx.llm
|
||||
func (ctx *Context) newLlmObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
|
||||
llmObj := v8go.NewObjectTemplate(iso)
|
||||
|
||||
// Single LLM call method
|
||||
llmObj.Set("Stream", ctx.llmStreamMethod(iso))
|
||||
|
||||
// Parallel LLM call methods - inspired by JavaScript Promise
|
||||
llmObj.Set("All", ctx.llmAllMethod(iso))
|
||||
llmObj.Set("Any", ctx.llmAnyMethod(iso))
|
||||
llmObj.Set("Race", ctx.llmRaceMethod(iso))
|
||||
|
||||
return llmObj
|
||||
}
|
||||
|
||||
// llmStreamMethod implements ctx.llm.Stream(connector, messages, options?)
|
||||
// Usage: const result = ctx.llm.Stream("gpt-4o", [{ role: "user", content: "Hello" }], { temperature: 0.7, onChunk: (msg) => 0 })
|
||||
// Returns: { connector, response, content, error }
|
||||
func (ctx *Context) llmStreamMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
// Validate arguments
|
||||
if len(args) < 2 {
|
||||
return bridge.JsException(v8ctx, "Stream requires connector and messages parameters")
|
||||
}
|
||||
|
||||
// Get connector ID (first argument)
|
||||
if !args[0].IsString() {
|
||||
return bridge.JsException(v8ctx, "connector must be a string")
|
||||
}
|
||||
connector := args[0].String()
|
||||
|
||||
// Parse messages (second argument)
|
||||
messagesVal, err := bridge.GoValue(args[1], v8ctx)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid messages: "+err.Error())
|
||||
}
|
||||
messages, ok := messagesVal.([]interface{})
|
||||
if !ok {
|
||||
return bridge.JsException(v8ctx, "messages must be an array")
|
||||
}
|
||||
|
||||
// Parse options (optional third argument) - extract onChunk separately
|
||||
var opts map[string]interface{}
|
||||
var onChunkFn *v8go.Function
|
||||
|
||||
if len(args) >= 3 && !args[2].IsUndefined() && !args[2].IsNull() {
|
||||
optsObj, err := args[2].AsObject()
|
||||
if err == nil && optsObj != nil {
|
||||
// Extract onChunk callback before converting to Go value
|
||||
onChunkVal, _ := optsObj.Get("onChunk")
|
||||
if onChunkVal != nil && onChunkVal.IsFunction() {
|
||||
onChunkFn, _ = onChunkVal.AsFunction()
|
||||
}
|
||||
|
||||
// Convert the rest of options to Go map
|
||||
goVal, err := bridge.GoValue(args[2], v8ctx)
|
||||
if err == nil {
|
||||
if optsMap, ok := goVal.(map[string]interface{}); ok {
|
||||
// Remove onChunk from the map (it's handled separately)
|
||||
delete(optsMap, "onChunk")
|
||||
opts = optsMap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get LLM API
|
||||
llmAPI := ctx.Llm()
|
||||
if llmAPI == nil {
|
||||
return bridge.JsException(v8ctx, "LLM API not available")
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
|
||||
// If onChunk callback is provided and API supports it, use StreamWithHandler
|
||||
if onChunkFn != nil {
|
||||
if apiWithCb, ok := llmAPI.(LlmAPIWithCallback); ok {
|
||||
// Create Go OnMessageFunc that calls JS callback
|
||||
handler := createJSStreamHandler(v8ctx, onChunkFn)
|
||||
result = apiWithCb.StreamWithHandler(connector, messages, opts, handler)
|
||||
} else {
|
||||
// Fallback: ignore callback if API doesn't support it
|
||||
result = llmAPI.Stream(connector, messages, opts)
|
||||
}
|
||||
} else {
|
||||
// No callback, use regular Stream
|
||||
result = llmAPI.Stream(connector, messages, opts)
|
||||
}
|
||||
|
||||
// Convert result to JS value
|
||||
jsVal, err := bridge.JsValue(v8ctx, result)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
||||
// llmAllMethod implements ctx.llm.All(requests, options?)
|
||||
// Usage: const results = ctx.llm.All([
|
||||
//
|
||||
// { connector: "gpt-4o", messages: [...], options: {...} },
|
||||
// { connector: "claude-3", messages: [...] }
|
||||
//
|
||||
// ], { onChunk: (connectorID, index, msg) => 0 })
|
||||
// Returns: [{ connector, response, content, error }, ...]
|
||||
func (ctx *Context) llmAllMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
return ctx.executeLlmBatchMethod(info, LlmBatchMethodAll)
|
||||
})
|
||||
}
|
||||
|
||||
// llmAnyMethod implements ctx.llm.Any(requests, options?)
|
||||
// Returns first successful result
|
||||
func (ctx *Context) llmAnyMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
return ctx.executeLlmBatchMethod(info, LlmBatchMethodAny)
|
||||
})
|
||||
}
|
||||
|
||||
// llmRaceMethod implements ctx.llm.Race(requests, options?)
|
||||
// Returns first completed result (success or failure)
|
||||
func (ctx *Context) llmRaceMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
return ctx.executeLlmBatchMethod(info, LlmBatchMethodRace)
|
||||
})
|
||||
}
|
||||
|
||||
// LlmBatchMethod represents the type of batch LLM operation
|
||||
type LlmBatchMethod int
|
||||
|
||||
const (
|
||||
LlmBatchMethodAll LlmBatchMethod = iota
|
||||
LlmBatchMethodAny
|
||||
LlmBatchMethodRace
|
||||
)
|
||||
|
||||
// executeLlmBatchMethod handles All/Any/Race batch LLM calls
|
||||
func (ctx *Context) executeLlmBatchMethod(info *v8go.FunctionCallbackInfo, method LlmBatchMethod) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
// Validate arguments
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "requires requests array parameter")
|
||||
}
|
||||
|
||||
// Parse requests array (first argument)
|
||||
requestsVal, err := bridge.GoValue(args[0], v8ctx)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid requests: "+err.Error())
|
||||
}
|
||||
requests, ok := requestsVal.([]interface{})
|
||||
if !ok {
|
||||
return bridge.JsException(v8ctx, "requests must be an array")
|
||||
}
|
||||
|
||||
// Get LLM API
|
||||
llmAPI := ctx.Llm()
|
||||
if llmAPI == nil {
|
||||
return bridge.JsException(v8ctx, "LLM API not available")
|
||||
}
|
||||
|
||||
// Parse optional global callback from second argument (options object)
|
||||
var globalCallback *v8go.Function
|
||||
if len(args) >= 2 && !args[1].IsUndefined() && !args[1].IsNull() {
|
||||
optsObj, err := args[1].AsObject()
|
||||
if err == nil && optsObj != nil {
|
||||
onChunkVal, _ := optsObj.Get("onChunk")
|
||||
if onChunkVal != nil && onChunkVal.IsFunction() {
|
||||
globalCallback, _ = onChunkVal.AsFunction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var results []interface{}
|
||||
|
||||
// If callback is provided and API supports it, use channel-based execution
|
||||
if globalCallback != nil {
|
||||
if apiWithCb, ok := llmAPI.(LlmAPIWithCallback); ok {
|
||||
results = ctx.executeLlmBatchWithCallback(method, requests, globalCallback, v8ctx, apiWithCb)
|
||||
} else {
|
||||
// Fallback: execute without callback
|
||||
results = ctx.executeLlmBatchWithoutCallback(method, requests, llmAPI)
|
||||
}
|
||||
} else {
|
||||
// No callback, use regular batch methods
|
||||
results = ctx.executeLlmBatchWithoutCallback(method, requests, llmAPI)
|
||||
}
|
||||
|
||||
// Convert results to JS value
|
||||
jsVal, err := bridge.JsValue(v8ctx, results)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "failed to convert results: "+err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
}
|
||||
|
||||
// executeLlmBatchWithoutCallback executes batch LLM calls without callback
|
||||
func (ctx *Context) executeLlmBatchWithoutCallback(method LlmBatchMethod, requests []interface{}, llmAPI LlmAPI) []interface{} {
|
||||
switch method {
|
||||
case LlmBatchMethodAll:
|
||||
return llmAPI.All(requests)
|
||||
case LlmBatchMethodAny:
|
||||
return llmAPI.Any(requests)
|
||||
case LlmBatchMethodRace:
|
||||
return llmAPI.Race(requests)
|
||||
default:
|
||||
return llmAPI.All(requests)
|
||||
}
|
||||
}
|
||||
|
||||
// llmBatchMessage is used for channel communication in batch LLM calls
|
||||
type llmBatchMessage struct {
|
||||
ConnectorID string
|
||||
Index int
|
||||
Message *message.Message
|
||||
}
|
||||
|
||||
// executeLlmBatchWithCallback executes batch LLM calls with callback using channel
|
||||
// This ensures V8 thread safety by serializing callback invocations
|
||||
func (ctx *Context) executeLlmBatchWithCallback(method LlmBatchMethod, requests []interface{}, callback *v8go.Function, v8ctx *v8go.Context, apiWithCb LlmAPIWithCallback) []interface{} {
|
||||
// Create a buffered channel for messages
|
||||
// Using blocking send to ensure all messages are delivered
|
||||
msgChan := make(chan llmBatchMessage, 1000)
|
||||
doneChan := make(chan []interface{}, 1)
|
||||
|
||||
// Create Go handler that sends to channel
|
||||
goHandler := func(connectorID string, index int, msg *message.Message) int {
|
||||
msgChan <- llmBatchMessage{
|
||||
ConnectorID: connectorID,
|
||||
Index: index,
|
||||
Message: msg,
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Execute batch calls in background goroutine
|
||||
go func() {
|
||||
defer close(msgChan)
|
||||
|
||||
var results []interface{}
|
||||
switch method {
|
||||
case LlmBatchMethodAll:
|
||||
results = apiWithCb.AllWithHandler(requests, goHandler)
|
||||
case LlmBatchMethodAny:
|
||||
results = apiWithCb.AnyWithHandler(requests, goHandler)
|
||||
case LlmBatchMethodRace:
|
||||
results = apiWithCb.RaceWithHandler(requests, goHandler)
|
||||
default:
|
||||
results = apiWithCb.AllWithHandler(requests, goHandler)
|
||||
}
|
||||
doneChan <- results
|
||||
}()
|
||||
|
||||
// Process messages in main goroutine (V8 thread)
|
||||
for msg := range msgChan {
|
||||
callJSLlmBatchCallback(v8ctx, callback, msg.ConnectorID, msg.Index, msg.Message)
|
||||
}
|
||||
|
||||
// Wait for results
|
||||
return <-doneChan
|
||||
}
|
||||
|
||||
// callJSLlmBatchCallback calls the JS callback function for batch LLM calls
|
||||
func callJSLlmBatchCallback(v8ctx *v8go.Context, callback *v8go.Function, connectorID string, index int, msg *message.Message) {
|
||||
if callback == nil || v8ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
iso := v8ctx.Isolate()
|
||||
|
||||
// Create arguments: connectorID, index, message
|
||||
connectorVal, err := v8go.NewValue(iso, connectorID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
indexVal, err := v8go.NewValue(iso, int32(index))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert message to JS object
|
||||
msgVal, err := bridge.JsValue(v8ctx, msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Call the callback
|
||||
_, _ = callback.Call(v8go.Undefined(iso), connectorVal, indexVal, msgVal)
|
||||
}
|
||||
497
agent/context/jsapi_llm_v8_test.go
Normal file
497
agent/context/jsapi_llm_v8_test.go
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
package context_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
|
||||
// Import assistant package to register LlmAPIFactory
|
||||
_ "github.com/yaoapp/yao/agent/assistant"
|
||||
)
|
||||
|
||||
// TestLlm_Stream_V8 tests basic ctx.llm.Stream functionality with real V8 execution
|
||||
func TestLlm_Stream_V8(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Create authorized info for the context
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
// Create a context
|
||||
ctx := context.New(stdContext.Background(), authorized, "test-chat-llm-stream")
|
||||
ctx.AssistantID = "tests.simple-greeting"
|
||||
defer ctx.Release()
|
||||
|
||||
// Test basic Stream call with real connector
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const result = ctx.llm.Stream("gpt-4o-mini", [
|
||||
{ role: "user", content: "Say hello in one word" }
|
||||
], {
|
||||
temperature: 0.1,
|
||||
max_tokens: 10
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
connector: result.connector,
|
||||
has_content: result.content && result.content.length > 0,
|
||||
has_response: result.response !== undefined,
|
||||
error: result.error || ""
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Logf("Test result: %v", result)
|
||||
}
|
||||
require.True(t, success, "Test should succeed, error: %v", result["error"])
|
||||
|
||||
assert.Equal(t, "gpt-4o-mini", result["connector"])
|
||||
|
||||
hasContent, _ := result["has_content"].(bool)
|
||||
assert.True(t, hasContent, "Should have content in response")
|
||||
|
||||
hasResponse, _ := result["has_response"].(bool)
|
||||
assert.True(t, hasResponse, "Should have response object")
|
||||
}
|
||||
|
||||
// TestLlm_Stream_WithCallback_V8 tests ctx.llm.Stream with onChunk callback
|
||||
func TestLlm_Stream_WithCallback_V8(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, "test-chat-llm-callback")
|
||||
ctx.AssistantID = "tests.simple-greeting"
|
||||
defer ctx.Release()
|
||||
|
||||
// Test Stream call with callback
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
let callbackCount = 0;
|
||||
let receivedTypes = [];
|
||||
|
||||
const result = ctx.llm.Stream("gpt-4o-mini", [
|
||||
{ role: "user", content: "Say hi" }
|
||||
], {
|
||||
temperature: 0.1,
|
||||
max_tokens: 10,
|
||||
onChunk: function(msg) {
|
||||
callbackCount++;
|
||||
if (msg && msg.type) {
|
||||
receivedTypes.push(msg.type);
|
||||
}
|
||||
return 0; // Continue
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
connector: result.connector,
|
||||
callbackCount: callbackCount,
|
||||
receivedTypes: receivedTypes,
|
||||
has_content: result.content && result.content.length > 0,
|
||||
error: result.error || ""
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Logf("Test result: %v", result)
|
||||
}
|
||||
require.True(t, success, "Test should succeed, error: %v", result["error"])
|
||||
|
||||
// Callback should have been called at least once
|
||||
callbackCount, _ := result["callbackCount"].(float64)
|
||||
assert.Greater(t, callbackCount, float64(0), "Callback should be called at least once")
|
||||
}
|
||||
|
||||
// TestLlm_All_V8 tests ctx.llm.All with multiple connectors
|
||||
func TestLlm_All_V8(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, "test-chat-llm-all")
|
||||
ctx.AssistantID = "tests.simple-greeting"
|
||||
defer ctx.Release()
|
||||
|
||||
// Test All with multiple requests to same connector (different prompts)
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const results = ctx.llm.All([
|
||||
{
|
||||
connector: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say 'one'" }],
|
||||
options: { temperature: 0.1, max_tokens: 5 }
|
||||
},
|
||||
{
|
||||
connector: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say 'two'" }],
|
||||
options: { temperature: 0.1, max_tokens: 5 }
|
||||
}
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.length,
|
||||
results: results.map(r => ({
|
||||
connector: r.connector,
|
||||
has_content: r.content && r.content.length > 0,
|
||||
error: r.error || ""
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Logf("Test result: %v", result)
|
||||
}
|
||||
require.True(t, success, "Test should succeed, error: %v", result["error"])
|
||||
|
||||
// Should have 2 results
|
||||
count, _ := result["count"].(float64)
|
||||
assert.Equal(t, float64(2), count, "Should have 2 results")
|
||||
|
||||
// Check individual results
|
||||
results, _ := result["results"].([]interface{})
|
||||
require.Len(t, results, 2)
|
||||
|
||||
for i, r := range results {
|
||||
rMap, _ := r.(map[string]interface{})
|
||||
hasContent, _ := rMap["has_content"].(bool)
|
||||
assert.True(t, hasContent, "Result %d should have content", i)
|
||||
errorStr, _ := rMap["error"].(string)
|
||||
assert.Empty(t, errorStr, "Result %d should not have error", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLlm_All_WithCallback_V8 tests ctx.llm.All with global callback
|
||||
func TestLlm_All_WithCallback_V8(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, "test-chat-llm-all-callback")
|
||||
ctx.AssistantID = "tests.simple-greeting"
|
||||
defer ctx.Release()
|
||||
|
||||
// Test All with global callback
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
let callbackCount = 0;
|
||||
let indexesSeen = new Set();
|
||||
|
||||
const results = ctx.llm.All([
|
||||
{
|
||||
connector: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say 'A'" }],
|
||||
options: { temperature: 0.1, max_tokens: 5 }
|
||||
},
|
||||
{
|
||||
connector: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say 'B'" }],
|
||||
options: { temperature: 0.1, max_tokens: 5 }
|
||||
}
|
||||
], {
|
||||
onChunk: function(connectorID, index, msg) {
|
||||
callbackCount++;
|
||||
indexesSeen.add(index);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.length,
|
||||
callbackCount: callbackCount,
|
||||
indexesSeen: indexesSeen.size
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Logf("Test result: %v", result)
|
||||
}
|
||||
require.True(t, success, "Test should succeed, error: %v", result["error"])
|
||||
|
||||
// Callback should have been called
|
||||
callbackCount, _ := result["callbackCount"].(float64)
|
||||
assert.Greater(t, callbackCount, float64(0), "Callback should be called")
|
||||
|
||||
// Should have seen at least one index (both requests may complete so fast that only one is tracked)
|
||||
// Note: Due to V8 thread safety with channel-based approach, callbacks are serialized
|
||||
indexesSeen, _ := result["indexesSeen"].(float64)
|
||||
assert.GreaterOrEqual(t, indexesSeen, float64(1), "Should have seen callbacks from at least one request")
|
||||
}
|
||||
|
||||
// TestLlm_Any_V8 tests ctx.llm.Any - returns first success
|
||||
func TestLlm_Any_V8(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, "test-chat-llm-any")
|
||||
ctx.AssistantID = "tests.simple-greeting"
|
||||
defer ctx.Release()
|
||||
|
||||
// Test Any - should return first successful result
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const results = ctx.llm.Any([
|
||||
{
|
||||
connector: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say 'hello'" }],
|
||||
options: { temperature: 0.1, max_tokens: 5 }
|
||||
},
|
||||
{
|
||||
connector: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say 'world'" }],
|
||||
options: { temperature: 0.1, max_tokens: 5 }
|
||||
}
|
||||
]);
|
||||
|
||||
// Any returns array with single successful result
|
||||
return {
|
||||
success: true,
|
||||
count: results.length,
|
||||
first_has_content: results[0] && results[0].content && results[0].content.length > 0,
|
||||
first_error: results[0] ? (results[0].error || "") : "no result"
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Logf("Test result: %v", result)
|
||||
}
|
||||
require.True(t, success, "Test should succeed, error: %v", result["error"])
|
||||
|
||||
// Any returns single result on success
|
||||
count, _ := result["count"].(float64)
|
||||
assert.Equal(t, float64(1), count, "Should have 1 result (first success)")
|
||||
|
||||
firstHasContent, _ := result["first_has_content"].(bool)
|
||||
assert.True(t, firstHasContent, "First result should have content")
|
||||
|
||||
firstError, _ := result["first_error"].(string)
|
||||
assert.Empty(t, firstError, "First result should not have error")
|
||||
}
|
||||
|
||||
// TestLlm_Race_V8 tests ctx.llm.Race - returns first completion
|
||||
func TestLlm_Race_V8(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, "test-chat-llm-race")
|
||||
ctx.AssistantID = "tests.simple-greeting"
|
||||
defer ctx.Release()
|
||||
|
||||
// Test Race - should return first completed result
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const results = ctx.llm.Race([
|
||||
{
|
||||
connector: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say 'fast'" }],
|
||||
options: { temperature: 0.1, max_tokens: 5 }
|
||||
},
|
||||
{
|
||||
connector: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say 'slow'" }],
|
||||
options: { temperature: 0.1, max_tokens: 5 }
|
||||
}
|
||||
]);
|
||||
|
||||
// Race returns array with single result (first to complete)
|
||||
return {
|
||||
success: true,
|
||||
count: results.length,
|
||||
has_result: results[0] !== undefined,
|
||||
first_connector: results[0] ? results[0].connector : "",
|
||||
first_has_content: results[0] && results[0].content && results[0].content.length > 0
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Logf("Test result: %v", result)
|
||||
}
|
||||
require.True(t, success, "Test should succeed, error: %v", result["error"])
|
||||
|
||||
// Race returns single result
|
||||
count, _ := result["count"].(float64)
|
||||
assert.Equal(t, float64(1), count, "Should have 1 result (first to complete)")
|
||||
|
||||
hasResult, _ := result["has_result"].(bool)
|
||||
assert.True(t, hasResult, "Should have a result")
|
||||
|
||||
firstConnector, _ := result["first_connector"].(string)
|
||||
assert.Equal(t, "gpt-4o-mini", firstConnector, "First result should have connector")
|
||||
}
|
||||
|
||||
// TestLlm_Stream_InvalidConnector_V8 tests error handling for invalid connector
|
||||
func TestLlm_Stream_InvalidConnector_V8(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, "test-chat-llm-invalid")
|
||||
ctx.AssistantID = "tests.simple-greeting"
|
||||
defer ctx.Release()
|
||||
|
||||
// Test Stream with invalid connector
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const result = ctx.llm.Stream("invalid-connector-that-does-not-exist", [
|
||||
{ role: "user", content: "Hello" }
|
||||
]);
|
||||
|
||||
return {
|
||||
has_error: result.error && result.error.length > 0,
|
||||
error: result.error || ""
|
||||
};
|
||||
} catch (error) {
|
||||
return { has_error: true, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "result should be a map")
|
||||
|
||||
hasError, _ := result["has_error"].(bool)
|
||||
assert.True(t, hasError, "Should have error for invalid connector")
|
||||
}
|
||||
737
agent/llm/jsapi.go
Normal file
737
agent/llm/jsapi.go
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
// Package llm provides the LLM JSAPI implementation
|
||||
package llm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/connector"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
||||
// JSAPI implements LlmAPI interface for ctx.llm.* methods
|
||||
type JSAPI struct {
|
||||
ctx *agentContext.Context
|
||||
}
|
||||
|
||||
// Ensure JSAPI implements both interfaces
|
||||
var _ agentContext.LlmAPI = (*JSAPI)(nil)
|
||||
var _ agentContext.LlmAPIWithCallback = (*JSAPI)(nil)
|
||||
|
||||
// NewJSAPI creates a new JSAPI for the given context
|
||||
func NewJSAPI(ctx *agentContext.Context) *JSAPI {
|
||||
return &JSAPI{ctx: ctx}
|
||||
}
|
||||
|
||||
// SetJSAPIFactory registers the JSAPI factory with the context package
|
||||
// This should be called during initialization
|
||||
func SetJSAPIFactory() {
|
||||
agentContext.LlmAPIFactory = func(ctx *agentContext.Context) agentContext.LlmAPI {
|
||||
return NewJSAPI(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Stream implements LlmAPI.Stream - calls LLM with streaming output to ctx.Writer
|
||||
func (api *JSAPI) Stream(connectorID string, messages []interface{}, opts map[string]interface{}) interface{} {
|
||||
return api.StreamWithHandler(connectorID, messages, opts, nil)
|
||||
}
|
||||
|
||||
// StreamWithHandler implements LlmAPIWithCallback.StreamWithHandler - calls LLM with OnMessage handler
|
||||
func (api *JSAPI) StreamWithHandler(connectorID string, messages []interface{}, opts map[string]interface{}, handler agentContext.OnMessageFunc) interface{} {
|
||||
result := &Result{
|
||||
Connector: connectorID,
|
||||
}
|
||||
|
||||
// Validate context
|
||||
if api.ctx == nil {
|
||||
result.Error = "context is nil"
|
||||
return result
|
||||
}
|
||||
|
||||
// Get connector
|
||||
conn, err := connector.Select(connectorID)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to select connector %s: %v", connectorID, err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Parse messages to context.Message format
|
||||
ctxMessages, err := parseMessages(messages)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to parse messages: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Build CompletionOptions from opts
|
||||
completionOptions := buildCompletionOptions(conn, opts)
|
||||
|
||||
// Create LLM instance
|
||||
llmInstance, err := New(conn, completionOptions)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create LLM instance: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Create stream handler with the provided callback
|
||||
// Note: We pass handler directly to the stream handler instead of setting ctx.Stack.Options.OnMessage
|
||||
// This avoids race conditions in concurrent batch calls where multiple goroutines
|
||||
// would otherwise overwrite the same ctx.Stack.Options.OnMessage
|
||||
streamHandler := createStreamHandlerWithCallback(api.ctx, handler)
|
||||
|
||||
// Execute LLM stream call
|
||||
response, err := llmInstance.Stream(api.ctx, ctxMessages, completionOptions, streamHandler)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("LLM stream failed: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Set response
|
||||
result.Response = response
|
||||
|
||||
// Extract text content from response
|
||||
if response != nil {
|
||||
result.Content = extractContent(response)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// createStreamHandlerWithCallback creates a stream handler that uses the provided callback directly
|
||||
// This is used instead of setting ctx.Stack.Options.OnMessage to avoid race conditions
|
||||
// in concurrent batch calls
|
||||
func createStreamHandlerWithCallback(ctx *agentContext.Context, handler agentContext.OnMessageFunc) message.StreamFunc {
|
||||
// Handle nil context
|
||||
if ctx == nil {
|
||||
return func(chunkType message.StreamChunkType, data []byte) int {
|
||||
return 0 // No-op handler when context is nil
|
||||
}
|
||||
}
|
||||
|
||||
// Stream state for tracking message groups
|
||||
state := &streamState{
|
||||
ctx: ctx,
|
||||
buffer: []byte{},
|
||||
handler: handler, // Store the handler directly in state
|
||||
}
|
||||
|
||||
return func(chunkType message.StreamChunkType, data []byte) int {
|
||||
return state.handleChunk(chunkType, data)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMessages converts JS message array to context.Message slice
|
||||
func parseMessages(messages []interface{}) ([]agentContext.Message, error) {
|
||||
result := make([]agentContext.Message, 0, len(messages))
|
||||
|
||||
for i, msg := range messages {
|
||||
msgMap, ok := msg.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("message %d is not an object", i)
|
||||
}
|
||||
|
||||
ctxMsg := agentContext.Message{}
|
||||
|
||||
// Required: role
|
||||
if role, ok := msgMap["role"].(string); ok {
|
||||
ctxMsg.Role = agentContext.MessageRole(role)
|
||||
} else {
|
||||
return nil, fmt.Errorf("message %d missing role", i)
|
||||
}
|
||||
|
||||
// Optional: content (can be string or array for multimodal)
|
||||
if content, ok := msgMap["content"]; ok {
|
||||
ctxMsg.Content = content
|
||||
}
|
||||
|
||||
// Optional: name
|
||||
if name, ok := msgMap["name"].(string); ok {
|
||||
ctxMsg.Name = &name
|
||||
}
|
||||
|
||||
// Optional: tool_calls
|
||||
if toolCalls, ok := msgMap["tool_calls"]; ok {
|
||||
if tcArray, ok := toolCalls.([]interface{}); ok {
|
||||
ctxMsg.ToolCalls = parseToolCalls(tcArray)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: tool_call_id (for tool response messages)
|
||||
if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
|
||||
ctxMsg.ToolCallID = &toolCallID
|
||||
}
|
||||
|
||||
result = append(result, ctxMsg)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseToolCalls converts JS tool_calls array to context.ToolCall slice
|
||||
func parseToolCalls(toolCalls []interface{}) []agentContext.ToolCall {
|
||||
result := make([]agentContext.ToolCall, 0, len(toolCalls))
|
||||
|
||||
for _, tc := range toolCalls {
|
||||
tcMap, ok := tc.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
toolCall := agentContext.ToolCall{}
|
||||
|
||||
if id, ok := tcMap["id"].(string); ok {
|
||||
toolCall.ID = id
|
||||
}
|
||||
if typ, ok := tcMap["type"].(string); ok {
|
||||
toolCall.Type = agentContext.ToolCallType(typ)
|
||||
}
|
||||
if fn, ok := tcMap["function"].(map[string]interface{}); ok {
|
||||
toolCall.Function = agentContext.Function{}
|
||||
if name, ok := fn["name"].(string); ok {
|
||||
toolCall.Function.Name = name
|
||||
}
|
||||
if args, ok := fn["arguments"].(string); ok {
|
||||
toolCall.Function.Arguments = args
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, toolCall)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// buildCompletionOptions creates CompletionOptions from JS opts map
|
||||
func buildCompletionOptions(conn connector.Connector, opts map[string]interface{}) *agentContext.CompletionOptions {
|
||||
// Get capabilities from connector
|
||||
capabilities := GetCapabilitiesFromConn(conn, nil)
|
||||
|
||||
completionOptions := &agentContext.CompletionOptions{
|
||||
Capabilities: capabilities,
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
return completionOptions
|
||||
}
|
||||
|
||||
// Temperature
|
||||
if temp, ok := opts["temperature"].(float64); ok {
|
||||
completionOptions.Temperature = &temp
|
||||
}
|
||||
|
||||
// Max tokens
|
||||
if maxTokens, ok := opts["max_tokens"].(float64); ok {
|
||||
mt := int(maxTokens)
|
||||
completionOptions.MaxTokens = &mt
|
||||
}
|
||||
if maxCompletionTokens, ok := opts["max_completion_tokens"].(float64); ok {
|
||||
mct := int(maxCompletionTokens)
|
||||
completionOptions.MaxCompletionTokens = &mct
|
||||
}
|
||||
|
||||
// Top P
|
||||
if topP, ok := opts["top_p"].(float64); ok {
|
||||
completionOptions.TopP = &topP
|
||||
}
|
||||
|
||||
// Presence penalty
|
||||
if presencePenalty, ok := opts["presence_penalty"].(float64); ok {
|
||||
completionOptions.PresencePenalty = &presencePenalty
|
||||
}
|
||||
|
||||
// Frequency penalty
|
||||
if frequencyPenalty, ok := opts["frequency_penalty"].(float64); ok {
|
||||
completionOptions.FrequencyPenalty = &frequencyPenalty
|
||||
}
|
||||
|
||||
// Stop sequences
|
||||
if stop, ok := opts["stop"]; ok {
|
||||
completionOptions.Stop = stop
|
||||
}
|
||||
|
||||
// User
|
||||
if user, ok := opts["user"].(string); ok {
|
||||
completionOptions.User = user
|
||||
}
|
||||
|
||||
// Seed
|
||||
if seed, ok := opts["seed"].(float64); ok {
|
||||
s := int(seed)
|
||||
completionOptions.Seed = &s
|
||||
}
|
||||
|
||||
// Tools
|
||||
if tools, ok := opts["tools"].([]interface{}); ok {
|
||||
completionOptions.Tools = make([]map[string]interface{}, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if toolMap, ok := tool.(map[string]interface{}); ok {
|
||||
completionOptions.Tools = append(completionOptions.Tools, toolMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool choice
|
||||
if toolChoice, ok := opts["tool_choice"]; ok {
|
||||
completionOptions.ToolChoice = toolChoice
|
||||
}
|
||||
|
||||
// Response format
|
||||
if responseFormat, ok := opts["response_format"].(map[string]interface{}); ok {
|
||||
rf := &agentContext.ResponseFormat{}
|
||||
if rfType, ok := responseFormat["type"].(string); ok {
|
||||
rf.Type = agentContext.ResponseFormatType(rfType)
|
||||
}
|
||||
if jsonSchema, ok := responseFormat["json_schema"].(map[string]interface{}); ok {
|
||||
rf.JSONSchema = &agentContext.JSONSchema{}
|
||||
if name, ok := jsonSchema["name"].(string); ok {
|
||||
rf.JSONSchema.Name = name
|
||||
}
|
||||
if desc, ok := jsonSchema["description"].(string); ok {
|
||||
rf.JSONSchema.Description = desc
|
||||
}
|
||||
if schema, ok := jsonSchema["schema"]; ok {
|
||||
rf.JSONSchema.Schema = schema
|
||||
}
|
||||
if strict, ok := jsonSchema["strict"].(bool); ok {
|
||||
rf.JSONSchema.Strict = &strict
|
||||
}
|
||||
}
|
||||
completionOptions.ResponseFormat = rf
|
||||
}
|
||||
|
||||
// Reasoning effort (for reasoning models)
|
||||
if reasoningEffort, ok := opts["reasoning_effort"].(string); ok {
|
||||
completionOptions.ReasoningEffort = &reasoningEffort
|
||||
}
|
||||
|
||||
return completionOptions
|
||||
}
|
||||
|
||||
// streamState manages stream handler state
|
||||
type streamState struct {
|
||||
ctx *agentContext.Context
|
||||
inMessage bool
|
||||
currentMsgID string
|
||||
currentMsgType string
|
||||
buffer []byte
|
||||
msgCounter int // Counter for generating message IDs when IDGenerator is nil
|
||||
chunkCounter int // Counter for generating chunk IDs when IDGenerator is nil
|
||||
handler agentContext.OnMessageFunc // Direct handler reference (avoids race condition via ctx.Stack.Options)
|
||||
}
|
||||
|
||||
// generateMessageID generates a unique message ID
|
||||
func (s *streamState) generateMessageID() string {
|
||||
if s.ctx != nil && s.ctx.IDGenerator != nil {
|
||||
return s.ctx.IDGenerator.GenerateMessageID()
|
||||
}
|
||||
s.msgCounter++
|
||||
return fmt.Sprintf("M%d", s.msgCounter)
|
||||
}
|
||||
|
||||
// generateChunkID generates a unique chunk ID
|
||||
func (s *streamState) generateChunkID() string {
|
||||
if s.ctx != nil && s.ctx.IDGenerator != nil {
|
||||
return s.ctx.IDGenerator.GenerateChunkID()
|
||||
}
|
||||
s.chunkCounter++
|
||||
return fmt.Sprintf("C%d", s.chunkCounter)
|
||||
}
|
||||
|
||||
// handleChunk processes a single stream chunk
|
||||
func (s *streamState) handleChunk(chunkType message.StreamChunkType, data []byte) int {
|
||||
switch chunkType {
|
||||
case message.ChunkMessageStart:
|
||||
s.inMessage = true
|
||||
s.currentMsgID = s.generateMessageID()
|
||||
s.buffer = []byte{}
|
||||
return 0
|
||||
|
||||
case message.ChunkText:
|
||||
if !s.inMessage {
|
||||
s.inMessage = true
|
||||
s.currentMsgID = s.generateMessageID()
|
||||
}
|
||||
s.currentMsgType = message.TypeText
|
||||
s.buffer = append(s.buffer, data...)
|
||||
|
||||
// Create message
|
||||
msg := &message.Message{
|
||||
ChunkID: s.generateChunkID(),
|
||||
MessageID: s.currentMsgID,
|
||||
Type: message.TypeText,
|
||||
Delta: true,
|
||||
Props: map[string]interface{}{
|
||||
"content": string(data),
|
||||
},
|
||||
}
|
||||
|
||||
// Call handler directly if provided (for batch calls and single calls with callback)
|
||||
// We use direct handler instead of ctx.Stack.Options.OnMessage to avoid race conditions
|
||||
// in concurrent batch calls where multiple goroutines would overwrite the shared OnMessage
|
||||
if s.handler != nil {
|
||||
if ret := s.handler(msg); ret != 0 {
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
// Send to output for actual message delivery to client
|
||||
// Note: ctx.Send may also call ctx.Stack.Options.OnMessage if set (for agent calls),
|
||||
// but for LLM calls we don't set OnMessage, so no double callback occurs
|
||||
if err := s.ctx.Send(msg); err != nil {
|
||||
// Log error but continue streaming
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
|
||||
case message.ChunkThinking:
|
||||
if !s.inMessage {
|
||||
s.inMessage = true
|
||||
s.currentMsgID = s.generateMessageID()
|
||||
}
|
||||
s.currentMsgType = message.TypeThinking
|
||||
s.buffer = append(s.buffer, data...)
|
||||
|
||||
msg := &message.Message{
|
||||
ChunkID: s.generateChunkID(),
|
||||
MessageID: s.currentMsgID,
|
||||
Type: message.TypeThinking,
|
||||
Delta: true,
|
||||
Props: map[string]interface{}{
|
||||
"content": string(data),
|
||||
},
|
||||
}
|
||||
|
||||
// Call handler directly if provided
|
||||
if s.handler != nil {
|
||||
if ret := s.handler(msg); ret != 0 {
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ctx.Send(msg); err != nil {
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
|
||||
case message.ChunkToolCall:
|
||||
if !s.inMessage {
|
||||
s.inMessage = true
|
||||
s.currentMsgID = s.generateMessageID()
|
||||
}
|
||||
s.currentMsgType = message.TypeToolCall
|
||||
s.buffer = append(s.buffer, data...)
|
||||
|
||||
// Tool call chunks are more complex - parse and forward
|
||||
msg := &message.Message{
|
||||
ChunkID: s.generateChunkID(),
|
||||
MessageID: s.currentMsgID,
|
||||
Type: message.TypeToolCall,
|
||||
Delta: true,
|
||||
Props: map[string]interface{}{
|
||||
"raw": string(data),
|
||||
},
|
||||
}
|
||||
|
||||
// Call handler directly if provided
|
||||
if s.handler != nil {
|
||||
if ret := s.handler(msg); ret != 0 {
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ctx.Send(msg); err != nil {
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
|
||||
case message.ChunkMessageEnd:
|
||||
if s.inMessage {
|
||||
s.inMessage = false
|
||||
s.currentMsgID = ""
|
||||
s.buffer = []byte{}
|
||||
}
|
||||
return 0
|
||||
|
||||
case message.ChunkError:
|
||||
// Send error and stop
|
||||
msg := &message.Message{
|
||||
Type: message.TypeError,
|
||||
Props: map[string]interface{}{
|
||||
"error": string(data),
|
||||
},
|
||||
}
|
||||
|
||||
// Call handler directly if provided
|
||||
if s.handler != nil {
|
||||
s.handler(msg)
|
||||
}
|
||||
|
||||
_ = s.ctx.Send(msg) // Ignore error on error message
|
||||
return 1 // Stop on error
|
||||
|
||||
default:
|
||||
// Other chunk types (stream_start, stream_end, metadata) - ignore
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// extractContent extracts text content from CompletionResponse
|
||||
func extractContent(response *agentContext.CompletionResponse) string {
|
||||
if response == nil || response.Content == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch content := response.Content.(type) {
|
||||
case string:
|
||||
return content
|
||||
case []interface{}:
|
||||
// Multimodal response - extract text parts
|
||||
var text string
|
||||
for _, part := range content {
|
||||
if partMap, ok := part.(map[string]interface{}); ok {
|
||||
if partMap["type"] == "text" {
|
||||
if t, ok := partMap["text"].(string); ok {
|
||||
text += t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return text
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Batch LLM Methods: All, Any, Race
|
||||
// ============================================================================
|
||||
|
||||
// All executes all LLM requests concurrently and returns all results
|
||||
func (api *JSAPI) All(requests []interface{}) []interface{} {
|
||||
return api.AllWithHandler(requests, nil)
|
||||
}
|
||||
|
||||
// Any executes LLM requests concurrently and returns first successful result
|
||||
func (api *JSAPI) Any(requests []interface{}) []interface{} {
|
||||
return api.AnyWithHandler(requests, nil)
|
||||
}
|
||||
|
||||
// Race executes LLM requests concurrently and returns first completed result
|
||||
func (api *JSAPI) Race(requests []interface{}) []interface{} {
|
||||
return api.RaceWithHandler(requests, nil)
|
||||
}
|
||||
|
||||
// AllWithHandler executes all LLM requests with global handler
|
||||
func (api *JSAPI) AllWithHandler(requests []interface{}, globalHandler agentContext.LlmBatchOnMessageFunc) []interface{} {
|
||||
parsedRequests := api.parseRequests(requests, globalHandler)
|
||||
return api.executeAll(parsedRequests)
|
||||
}
|
||||
|
||||
// AnyWithHandler executes LLM requests and returns first success with handler
|
||||
func (api *JSAPI) AnyWithHandler(requests []interface{}, globalHandler agentContext.LlmBatchOnMessageFunc) []interface{} {
|
||||
parsedRequests := api.parseRequests(requests, globalHandler)
|
||||
return api.executeAny(parsedRequests)
|
||||
}
|
||||
|
||||
// RaceWithHandler executes LLM requests and returns first completion with handler
|
||||
func (api *JSAPI) RaceWithHandler(requests []interface{}, globalHandler agentContext.LlmBatchOnMessageFunc) []interface{} {
|
||||
parsedRequests := api.parseRequests(requests, globalHandler)
|
||||
return api.executeRace(parsedRequests)
|
||||
}
|
||||
|
||||
// parseRequests converts JS request array to internal Request slice
|
||||
func (api *JSAPI) parseRequests(requests []interface{}, globalHandler agentContext.LlmBatchOnMessageFunc) []*Request {
|
||||
result := make([]*Request, 0, len(requests))
|
||||
|
||||
for i, req := range requests {
|
||||
reqMap, ok := req.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
request := &Request{}
|
||||
|
||||
// Required: connector
|
||||
if connector, ok := reqMap["connector"].(string); ok {
|
||||
request.Connector = connector
|
||||
} else {
|
||||
continue // Skip invalid request
|
||||
}
|
||||
|
||||
// Required: messages
|
||||
if messages, ok := reqMap["messages"].([]interface{}); ok {
|
||||
request.Messages = messages
|
||||
} else {
|
||||
continue // Skip invalid request
|
||||
}
|
||||
|
||||
// Optional: options
|
||||
if options, ok := reqMap["options"].(map[string]interface{}); ok {
|
||||
// Remove onChunk from options if present (handled via globalHandler)
|
||||
delete(options, "onChunk")
|
||||
request.Options = options
|
||||
}
|
||||
|
||||
// Set handler based on globalHandler
|
||||
if globalHandler != nil {
|
||||
index := i
|
||||
connectorID := request.Connector
|
||||
request.Handler = func(msg *message.Message) int {
|
||||
return globalHandler(connectorID, index, msg)
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, request)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// executeAll executes all requests concurrently and waits for all to complete
|
||||
// Each request uses a forked context to avoid race conditions on shared state
|
||||
func (api *JSAPI) executeAll(requests []*Request) []interface{} {
|
||||
if len(requests) == 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
results := make([]interface{}, len(requests))
|
||||
done := make(chan struct{})
|
||||
remaining := len(requests)
|
||||
|
||||
for i, req := range requests {
|
||||
go func(index int, request *Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
results[index] = &Result{
|
||||
Connector: request.Connector,
|
||||
Error: fmt.Sprintf("panic: %v", err),
|
||||
}
|
||||
}
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
// Use forked context to avoid race conditions
|
||||
results[index] = api.executeSingleRequestWithForkedContext(request)
|
||||
}(i, req)
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
for remaining > 0 {
|
||||
<-done
|
||||
remaining--
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// executeAny executes requests and returns first successful result
|
||||
// Each request uses a forked context to avoid race conditions on shared state
|
||||
func (api *JSAPI) executeAny(requests []*Request) []interface{} {
|
||||
if len(requests) == 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
type indexedResult struct {
|
||||
index int
|
||||
result *Result
|
||||
}
|
||||
|
||||
resultChan := make(chan indexedResult, len(requests))
|
||||
remaining := len(requests)
|
||||
|
||||
for i, req := range requests {
|
||||
go func(index int, request *Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
resultChan <- indexedResult{
|
||||
index: index,
|
||||
result: &Result{
|
||||
Connector: request.Connector,
|
||||
Error: fmt.Sprintf("panic: %v", err),
|
||||
},
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Use forked context to avoid race conditions
|
||||
res := api.executeSingleRequestWithForkedContext(request)
|
||||
resultChan <- indexedResult{index: index, result: res.(*Result)}
|
||||
}(i, req)
|
||||
}
|
||||
|
||||
// Wait for first success or all failures
|
||||
var firstSuccess *indexedResult
|
||||
errors := make([]*indexedResult, 0)
|
||||
|
||||
for remaining > 0 {
|
||||
ir := <-resultChan
|
||||
remaining--
|
||||
|
||||
if ir.result.Error == "" {
|
||||
// Success!
|
||||
firstSuccess = &ir
|
||||
break
|
||||
}
|
||||
errors = append(errors, &ir)
|
||||
}
|
||||
|
||||
if firstSuccess != nil {
|
||||
return []interface{}{firstSuccess.result}
|
||||
}
|
||||
|
||||
// All failed - return all errors
|
||||
results := make([]interface{}, len(errors))
|
||||
for i, e := range errors {
|
||||
results[i] = e.result
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// executeRace executes requests and returns first completed result (success or failure)
|
||||
// Each request uses a forked context to avoid race conditions on shared state
|
||||
func (api *JSAPI) executeRace(requests []*Request) []interface{} {
|
||||
if len(requests) == 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
resultChan := make(chan *Result, len(requests))
|
||||
|
||||
for _, req := range requests {
|
||||
go func(request *Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
resultChan <- &Result{
|
||||
Connector: request.Connector,
|
||||
Error: fmt.Sprintf("panic: %v", err),
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Use forked context to avoid race conditions
|
||||
res := api.executeSingleRequestWithForkedContext(request)
|
||||
resultChan <- res.(*Result)
|
||||
}(req)
|
||||
}
|
||||
|
||||
// Return first result
|
||||
result := <-resultChan
|
||||
return []interface{}{result}
|
||||
}
|
||||
|
||||
// executeSingleRequest executes a single LLM request using the original context
|
||||
// This is used for single calls (not batch)
|
||||
func (api *JSAPI) executeSingleRequest(request *Request) interface{} {
|
||||
return api.StreamWithHandler(request.Connector, request.Messages, request.Options, request.Handler)
|
||||
}
|
||||
|
||||
// executeSingleRequestWithForkedContext executes a single LLM request with a forked context
|
||||
// This is used by batch operations (All/Any/Race) to avoid race conditions
|
||||
// when multiple goroutines access shared context state
|
||||
func (api *JSAPI) executeSingleRequestWithForkedContext(request *Request) interface{} {
|
||||
// Fork the context to get independent resources (IDGenerator, Logger, etc.)
|
||||
forkedCtx := api.ctx.Fork()
|
||||
|
||||
// Create a temporary JSAPI with the forked context
|
||||
forkedAPI := &JSAPI{ctx: forkedCtx}
|
||||
|
||||
return forkedAPI.StreamWithHandler(request.Connector, request.Messages, request.Options, request.Handler)
|
||||
}
|
||||
25
agent/llm/jsapi_types.go
Normal file
25
agent/llm/jsapi_types.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Package llm provides types and utilities for LLM JSAPI
|
||||
package llm
|
||||
|
||||
import (
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// Request represents a request to call an LLM connector
|
||||
type Request struct {
|
||||
Connector string `json:"connector"` // Target connector ID
|
||||
Messages []interface{} `json:"messages"` // Messages to send
|
||||
Options map[string]interface{} `json:"options,omitempty"` // LLM call options (temperature, max_tokens, etc.)
|
||||
Handler agentContext.OnMessageFunc `json:"-"` // OnMessage handler for this request (not serialized)
|
||||
}
|
||||
|
||||
// Result represents the result of a LLM call via JSAPI
|
||||
type Result struct {
|
||||
Connector string `json:"connector"` // Connector ID that was used
|
||||
Response *agentContext.CompletionResponse `json:"response,omitempty"` // Full LLM response
|
||||
Content string `json:"content,omitempty"` // Extracted text content
|
||||
Error string `json:"error,omitempty"` // Error message if call failed
|
||||
}
|
||||
|
||||
// Note: LlmBatchOnMessageFunc is defined in agent/context/jsapi_llm.go
|
||||
// to avoid circular dependencies
|
||||
|
|
@ -10,9 +10,14 @@ import (
|
|||
_ "github.com/yaoapp/gou/text"
|
||||
"github.com/yaoapp/xun/capsule"
|
||||
"github.com/yaoapp/yao/agent"
|
||||
"github.com/yaoapp/yao/agent/caller"
|
||||
"github.com/yaoapp/yao/agent/llm"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/kb"
|
||||
"github.com/yaoapp/yao/test"
|
||||
|
||||
// Import assistant to trigger init() which registers AgentGetterFunc
|
||||
_ "github.com/yaoapp/yao/agent/assistant"
|
||||
)
|
||||
|
||||
// Prepare prepare the test environment with optional V8 mode configuration
|
||||
|
|
@ -35,6 +40,11 @@ func Prepare(t *testing.T, opts ...interface{}) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Ensure JSAPI factories are registered (may be called multiple times, idempotent)
|
||||
// This is needed because Go's init() order is not guaranteed across packages
|
||||
caller.SetJSAPIFactory()
|
||||
llm.SetJSAPIFactory()
|
||||
|
||||
// Register default query engine (required for DB search)
|
||||
// capsule.Global is initialized by test.Prepare
|
||||
if _, has := query.Engines["default"]; !has && capsule.Global != nil {
|
||||
|
|
|
|||
|
|
@ -1034,18 +1034,17 @@ func TestParseDataURI(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
// Test 2: Plain base64 (no data URI header)
|
||||
t.Run("PlainBase64", func(t *testing.T) {
|
||||
content := "Plain base64"
|
||||
base64Content := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
// Test 2: Plain text (no data URI header) - treated as plain text, not base64
|
||||
t.Run("PlainText", func(t *testing.T) {
|
||||
content := "Plain text content"
|
||||
|
||||
contentType, data, err := parseDataURI(base64Content)
|
||||
contentType, data, err := parseDataURI(content)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse plain base64: %v", err)
|
||||
t.Fatalf("Failed to parse plain text: %v", err)
|
||||
}
|
||||
|
||||
if contentType != "application/octet-stream" {
|
||||
t.Errorf("Expected content type 'application/octet-stream', got '%s'", contentType)
|
||||
if contentType != "text/plain" {
|
||||
t.Errorf("Expected content type 'text/plain', got '%s'", contentType)
|
||||
}
|
||||
|
||||
if string(data) != content {
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -125,7 +125,7 @@ require (
|
|||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||
github.com/tidwall/btree v1.7.0 // indirect
|
||||
|
|
@ -153,7 +153,7 @@ require (
|
|||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/grpc v1.72.1 // indirect
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -274,8 +274,8 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
|
|||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
|
|
@ -427,8 +427,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue