Merge pull request #1430 from trheyi/main
Enhance Agent and LLM APIs with Agent-to-Agent Communication
This commit is contained in:
commit
38bf5fa22b
32 changed files with 7354 additions and 30 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"
|
||||
|
|
@ -25,6 +26,12 @@ func init() {
|
|||
return &agentCallerWrapper{ast: ast}, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
|
|
|||
278
agent/caller/integration_test.go
Normal file
278
agent/caller/integration_test.go
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
package caller_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/caller"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
func TestIntegration_Call_RealAgent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the simple-greeting agent
|
||||
ast, err := assistant.Get("tests.simple-greeting")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Create authorized info for the context
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
// Create a context with authorization
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-integration")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
|
||||
// Create JSAPI
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Call the simple-greeting agent
|
||||
messages := []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello!",
|
||||
},
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
}
|
||||
|
||||
result := api.Call("tests.simple-greeting", messages, opts)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*caller.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "tests.simple-greeting", r.AgentID)
|
||||
|
||||
// Should either have content or error
|
||||
if r.Error != "" {
|
||||
t.Logf("Agent call error: %s", r.Error)
|
||||
} else {
|
||||
t.Logf("Agent response content: %s", r.Content)
|
||||
assert.NotEmpty(t, r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_All_RealAgents(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 with authorization
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-all")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
|
||||
// Create JSAPI
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Call multiple agents in parallel
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from test 1!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from test 2!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := api.All(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
for i, result := range results {
|
||||
r, ok := result.(*caller.Result)
|
||||
require.True(t, ok, "result %d should be *caller.Result", i)
|
||||
assert.Equal(t, "tests.simple-greeting", r.AgentID)
|
||||
t.Logf("Result[%d]: content=%s, error=%s", i, r.Content, r.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Any_RealAgents(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 with authorization
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-any")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
|
||||
// Create JSAPI
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Call multiple agents - return when any succeeds
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from any test 1!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from any test 2!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := api.Any(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// At least one should have a result
|
||||
hasResult := false
|
||||
for i, result := range results {
|
||||
if result != nil {
|
||||
r, ok := result.(*caller.Result)
|
||||
if ok && r != nil && r.Error == "" {
|
||||
hasResult = true
|
||||
t.Logf("Any Result[%d]: content=%s", i, r.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult, "At least one result should succeed")
|
||||
}
|
||||
|
||||
func TestIntegration_Race_RealAgents(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 with authorization
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-race")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
|
||||
// Create JSAPI
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Call multiple agents - return when any completes
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from race test 1!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from race test 2!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := api.Race(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// At least one should have completed
|
||||
hasResult := false
|
||||
for i, result := range results {
|
||||
if result != nil {
|
||||
r, ok := result.(*caller.Result)
|
||||
if ok && r != nil {
|
||||
hasResult = true
|
||||
t.Logf("Race Result[%d]: content=%s, error=%s", i, r.Content, r.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult, "At least one result should complete")
|
||||
}
|
||||
300
agent/caller/jsapi.go
Normal file
300
agent/caller/jsapi.go
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
package caller
|
||||
|
||||
import (
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
||||
// JSAPI implements context.AgentAPI and context.AgentAPIWithCallback interfaces
|
||||
// Provides ctx.agent.Call(), ctx.agent.All(), ctx.agent.Any(), ctx.agent.Race()
|
||||
// and their *WithHandler variants for streaming callback support
|
||||
type JSAPI struct {
|
||||
ctx *agentContext.Context
|
||||
orchestrator *Orchestrator
|
||||
}
|
||||
|
||||
// Ensure JSAPI implements AgentAPIWithCallback
|
||||
var _ agentContext.AgentAPIWithCallback = (*JSAPI)(nil)
|
||||
|
||||
// NewJSAPI creates a new agent JSAPI instance
|
||||
func NewJSAPI(ctx *agentContext.Context) *JSAPI {
|
||||
return &JSAPI{
|
||||
ctx: ctx,
|
||||
orchestrator: NewOrchestrator(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
// Call executes a single agent call
|
||||
// Usage: ctx.agent.Call("assistant-id", messages, options?)
|
||||
// Returns: { agent_id, response, content, error }
|
||||
func (api *JSAPI) Call(agentID string, messages []interface{}, opts map[string]interface{}) interface{} {
|
||||
req := api.buildRequest(agentID, messages, opts)
|
||||
result := api.orchestrator.callAgent(req)
|
||||
return result
|
||||
}
|
||||
|
||||
// All executes all agent calls and waits for all to complete (like Promise.all)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
func (api *JSAPI) All(requests []interface{}) []interface{} {
|
||||
reqs := api.parseRequests(requests)
|
||||
results := api.orchestrator.All(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// Any returns as soon as any agent call succeeds (like Promise.any)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
func (api *JSAPI) Any(requests []interface{}) []interface{} {
|
||||
reqs := api.parseRequests(requests)
|
||||
results := api.orchestrator.Any(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// Race returns as soon as any agent call completes (like Promise.race)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
func (api *JSAPI) Race(requests []interface{}) []interface{} {
|
||||
reqs := api.parseRequests(requests)
|
||||
results := api.orchestrator.Race(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentAPIWithCallback Implementation
|
||||
// ============================================================================
|
||||
|
||||
// CallWithHandler executes a single agent call with an OnMessage handler
|
||||
func (api *JSAPI) CallWithHandler(agentID string, messages []interface{}, opts map[string]interface{}, handler agentContext.OnMessageFunc) interface{} {
|
||||
req := api.buildRequest(agentID, messages, opts)
|
||||
req.Handler = handler
|
||||
result := api.orchestrator.callAgent(req)
|
||||
return result
|
||||
}
|
||||
|
||||
// AllWithHandler executes all agent calls with handlers
|
||||
func (api *JSAPI) AllWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
|
||||
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
|
||||
results := api.orchestrator.All(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// AnyWithHandler executes agent calls and returns on first success, with handlers
|
||||
func (api *JSAPI) AnyWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
|
||||
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
|
||||
results := api.orchestrator.Any(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// RaceWithHandler executes agent calls and returns on first completion, with handlers
|
||||
func (api *JSAPI) RaceWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
|
||||
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
|
||||
results := api.orchestrator.Race(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// parseRequestsWithHandlers parses requests and attaches handlers
|
||||
// It checks for per-request _handler fields and wraps globalHandler with agentID/index
|
||||
func (api *JSAPI) parseRequestsWithHandlers(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []*Request {
|
||||
reqs := make([]*Request, 0, len(requests))
|
||||
|
||||
for i, r := range requests {
|
||||
reqMap, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get agent ID
|
||||
agentID, ok := reqMap["agent"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get messages
|
||||
messages, ok := reqMap["messages"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get options (optional)
|
||||
var opts map[string]interface{}
|
||||
if o, ok := reqMap["options"].(map[string]interface{}); ok {
|
||||
opts = o
|
||||
}
|
||||
|
||||
req := api.buildRequest(agentID, messages, opts)
|
||||
|
||||
// Check for per-request handler first (takes precedence)
|
||||
if handler, ok := reqMap["_handler"].(agentContext.OnMessageFunc); ok && handler != nil {
|
||||
req.Handler = handler
|
||||
} else if globalHandler != nil {
|
||||
// Wrap global handler with agentID and index
|
||||
idx := i // Capture index for closure
|
||||
aid := agentID
|
||||
req.Handler = func(msg *message.Message) int {
|
||||
return globalHandler(aid, idx, msg)
|
||||
}
|
||||
}
|
||||
|
||||
reqs = append(reqs, req)
|
||||
}
|
||||
|
||||
return reqs
|
||||
}
|
||||
|
||||
// buildRequest builds a Request from agentID, messages, and options
|
||||
func (api *JSAPI) buildRequest(agentID string, messages []interface{}, opts map[string]interface{}) *Request {
|
||||
req := &Request{
|
||||
AgentID: agentID,
|
||||
Messages: api.parseMessages(messages),
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
req.Options = api.parseCallOptions(opts)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// parseMessages converts []interface{} to []agentContext.Message
|
||||
func (api *JSAPI) parseMessages(messages []interface{}) []agentContext.Message {
|
||||
result := make([]agentContext.Message, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
msg, ok := m.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ctxMsg := agentContext.Message{}
|
||||
|
||||
// Parse role
|
||||
if role, ok := msg["role"].(string); ok {
|
||||
ctxMsg.Role = agentContext.MessageRole(role)
|
||||
}
|
||||
|
||||
// Parse content (can be string or array)
|
||||
ctxMsg.Content = msg["content"]
|
||||
|
||||
// Parse name
|
||||
if name, ok := msg["name"].(string); ok {
|
||||
ctxMsg.Name = &name
|
||||
}
|
||||
|
||||
// Parse tool_call_id
|
||||
if toolCallID, ok := msg["tool_call_id"].(string); ok {
|
||||
ctxMsg.ToolCallID = &toolCallID
|
||||
}
|
||||
|
||||
// Parse tool_calls
|
||||
if toolCalls, ok := msg["tool_calls"].([]interface{}); ok {
|
||||
ctxMsg.ToolCalls = api.parseToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
// Parse refusal
|
||||
if refusal, ok := msg["refusal"].(string); ok {
|
||||
ctxMsg.Refusal = &refusal
|
||||
}
|
||||
|
||||
result = append(result, ctxMsg)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseToolCalls converts []interface{} to []agentContext.ToolCall
|
||||
func (api *JSAPI) 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 tcType, ok := tcMap["type"].(string); ok {
|
||||
toolCall.Type = agentContext.ToolCallType(tcType)
|
||||
}
|
||||
if fn, ok := tcMap["function"].(map[string]interface{}); ok {
|
||||
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
|
||||
}
|
||||
|
||||
// parseCallOptions converts map to CallOptions
|
||||
func (api *JSAPI) parseCallOptions(opts map[string]interface{}) *CallOptions {
|
||||
callOpts := &CallOptions{}
|
||||
|
||||
if connector, ok := opts["connector"].(string); ok {
|
||||
callOpts.Connector = connector
|
||||
}
|
||||
if mode, ok := opts["mode"].(string); ok {
|
||||
callOpts.Mode = mode
|
||||
}
|
||||
if metadata, ok := opts["metadata"].(map[string]interface{}); ok {
|
||||
callOpts.Metadata = metadata
|
||||
}
|
||||
|
||||
// Parse skip configuration
|
||||
if skip, ok := opts["skip"].(map[string]interface{}); ok {
|
||||
callOpts.Skip = &agentContext.Skip{}
|
||||
if history, ok := skip["history"].(bool); ok {
|
||||
callOpts.Skip.History = history
|
||||
}
|
||||
if trace, ok := skip["trace"].(bool); ok {
|
||||
callOpts.Skip.Trace = trace
|
||||
}
|
||||
if output, ok := skip["output"].(bool); ok {
|
||||
callOpts.Skip.Output = output
|
||||
}
|
||||
if keyword, ok := skip["keyword"].(bool); ok {
|
||||
callOpts.Skip.Keyword = keyword
|
||||
}
|
||||
if search, ok := skip["search"].(bool); ok {
|
||||
callOpts.Skip.Search = search
|
||||
}
|
||||
if contentParsing, ok := skip["content_parsing"].(bool); ok {
|
||||
callOpts.Skip.ContentParsing = contentParsing
|
||||
}
|
||||
}
|
||||
|
||||
return callOpts
|
||||
}
|
||||
|
||||
// parseRequests parses an array of request objects into typed Requests
|
||||
func (api *JSAPI) parseRequests(requests []interface{}) []*Request {
|
||||
return api.parseRequestsWithHandlers(requests, nil)
|
||||
}
|
||||
|
||||
// convertResults converts typed Results to interface slice for JS
|
||||
func (api *JSAPI) convertResults(results []*Result) []interface{} {
|
||||
out := make([]interface{}, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetJSAPIFactory sets the factory function for creating AgentAPI instances
|
||||
// Called by assistant package during initialization
|
||||
func SetJSAPIFactory() {
|
||||
agentContext.AgentAPIFactory = func(ctx *agentContext.Context) agentContext.AgentAPI {
|
||||
return NewJSAPI(ctx)
|
||||
}
|
||||
}
|
||||
145
agent/caller/jsapi_test.go
Normal file
145
agent/caller/jsapi_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package caller_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/caller"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
func TestNewJSAPI(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
require.NotNil(t, api)
|
||||
}
|
||||
|
||||
func TestJSAPI_Call_NoAgentGetter(t *testing.T) {
|
||||
// Reset AgentGetterFunc
|
||||
originalGetter := caller.AgentGetterFunc
|
||||
caller.AgentGetterFunc = nil
|
||||
defer func() { caller.AgentGetterFunc = originalGetter }()
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
messages := []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello",
|
||||
},
|
||||
}
|
||||
|
||||
result := api.Call("test-agent", messages, nil)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*caller.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "test-agent", r.AgentID)
|
||||
assert.Contains(t, r.Error, "agent getter not initialized")
|
||||
}
|
||||
|
||||
func TestJSAPI_All_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
results := api.All([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Any_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
results := api.Any([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Race_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
results := api.Race([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_All_InvalidRequests(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Mix of invalid and valid requests
|
||||
requests := []interface{}{
|
||||
"invalid", // Not a map
|
||||
map[string]interface{}{
|
||||
"messages": []interface{}{}, // Missing agent
|
||||
},
|
||||
map[string]interface{}{
|
||||
"agent": "test-agent", // Missing messages
|
||||
},
|
||||
}
|
||||
|
||||
results := api.All(requests)
|
||||
// None should produce a result (all invalid)
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Call_WithOptions(t *testing.T) {
|
||||
// Reset AgentGetterFunc
|
||||
originalGetter := caller.AgentGetterFunc
|
||||
caller.AgentGetterFunc = nil
|
||||
defer func() { caller.AgentGetterFunc = originalGetter }()
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
messages := []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello",
|
||||
},
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"connector": "gpt4",
|
||||
"mode": "chat",
|
||||
"metadata": map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
"trace": true,
|
||||
},
|
||||
}
|
||||
|
||||
result := api.Call("test-agent", messages, opts)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*caller.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "test-agent", r.AgentID)
|
||||
// Still errors because AgentGetterFunc is nil
|
||||
assert.Contains(t, r.Error, "agent getter not initialized")
|
||||
}
|
||||
|
||||
func TestSetJSAPIFactory(t *testing.T) {
|
||||
// Reset factory
|
||||
context.AgentAPIFactory = nil
|
||||
|
||||
// Set factory
|
||||
caller.SetJSAPIFactory()
|
||||
|
||||
// Verify factory is set
|
||||
require.NotNil(t, context.AgentAPIFactory)
|
||||
|
||||
// Create a mock context
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
|
||||
// Get agent API
|
||||
agentAPI := context.AgentAPIFactory(ctx)
|
||||
require.NotNil(t, agentAPI)
|
||||
}
|
||||
|
||||
func TestJSAPI_ImplementsAgentAPI(t *testing.T) {
|
||||
// Verify JSAPI implements context.AgentAPI interface
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
var _ context.AgentAPI = caller.NewJSAPI(ctx)
|
||||
}
|
||||
307
agent/caller/orchestrator.go
Normal file
307
agent/caller/orchestrator.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
package caller
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// Orchestrator handles parallel agent calls with different concurrency patterns
|
||||
// Modeled after JavaScript Promise patterns (all, any, race)
|
||||
type Orchestrator struct {
|
||||
ctx *agentContext.Context
|
||||
}
|
||||
|
||||
// NewOrchestrator creates a new Orchestrator for parallel agent calls
|
||||
func NewOrchestrator(ctx *agentContext.Context) *Orchestrator {
|
||||
return &Orchestrator{ctx: ctx}
|
||||
}
|
||||
|
||||
// callResult is used internally to pass results through channels
|
||||
type callResult struct {
|
||||
idx int
|
||||
result *Result
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
|
||||
results := make([]*Result, len(reqs))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i, req := range reqs {
|
||||
wg.Add(1)
|
||||
go func(idx int, r *Request) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
mu.Lock()
|
||||
results[idx] = &Result{
|
||||
AgentID: r.AgentID,
|
||||
Error: "agent call panic recovered",
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Use forked context to avoid race conditions
|
||||
result := o.callAgentWithForkedContext(r)
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
}(i, req)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
|
||||
results := make([]*Result, len(reqs))
|
||||
resultChan := make(chan callResult, len(reqs))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
|
||||
for i, req := range reqs {
|
||||
wg.Add(1)
|
||||
go func(idx int, r *Request) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// Send panic result through channel
|
||||
select {
|
||||
case <-done:
|
||||
case resultChan <- callResult{idx: idx, result: &Result{
|
||||
AgentID: r.AgentID,
|
||||
Error: "agent call panic recovered",
|
||||
}}:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Check if done before starting
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Use forked context to avoid race conditions
|
||||
result := o.callAgentWithForkedContext(r)
|
||||
|
||||
// Try to send result
|
||||
select {
|
||||
case <-done:
|
||||
// Already found a successful result
|
||||
case resultChan <- callResult{idx: idx, result: result}:
|
||||
}
|
||||
}(i, req)
|
||||
}
|
||||
|
||||
// Close channel when all goroutines complete
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
// Collect results until we find one with success (no error and has content)
|
||||
var foundSuccess bool
|
||||
for res := range resultChan {
|
||||
results[res.idx] = res.result
|
||||
// Check if this result is successful (no error)
|
||||
if !foundSuccess && res.result != nil && res.result.Error == "" {
|
||||
foundSuccess = true
|
||||
close(done) // Signal other goroutines to stop
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
|
||||
results := make([]*Result, len(reqs))
|
||||
resultChan := make(chan callResult, len(reqs))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
|
||||
for i, req := range reqs {
|
||||
wg.Add(1)
|
||||
go func(idx int, r *Request) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// Send panic result through channel
|
||||
select {
|
||||
case <-done:
|
||||
case resultChan <- callResult{idx: idx, result: &Result{
|
||||
AgentID: r.AgentID,
|
||||
Error: "agent call panic recovered",
|
||||
}}:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Check if done before starting
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Use forked context to avoid race conditions
|
||||
result := o.callAgentWithForkedContext(r)
|
||||
|
||||
// Try to send result
|
||||
select {
|
||||
case <-done:
|
||||
// Already got first result
|
||||
case resultChan <- callResult{idx: idx, result: result}:
|
||||
}
|
||||
}(i, req)
|
||||
}
|
||||
|
||||
// Close channel when all goroutines complete
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
// Get first result and signal others to stop
|
||||
var gotFirst bool
|
||||
for res := range resultChan {
|
||||
results[res.idx] = res.result
|
||||
if !gotFirst {
|
||||
gotFirst = true
|
||||
close(done) // Signal other goroutines to stop
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// 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"}
|
||||
}
|
||||
|
||||
result := &Result{
|
||||
AgentID: req.AgentID,
|
||||
}
|
||||
|
||||
// Get the agent using the getter function
|
||||
if AgentGetterFunc == nil {
|
||||
result.Error = "agent getter not initialized"
|
||||
return result
|
||||
}
|
||||
|
||||
agent, err := AgentGetterFunc(req.AgentID)
|
||||
if err != nil {
|
||||
result.Error = "failed to get agent: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
// Build context options for the call
|
||||
var ctxOpts *agentContext.Options
|
||||
if req.Options != nil {
|
||||
ctxOpts = req.Options.ToContextOptions()
|
||||
} else {
|
||||
ctxOpts = &agentContext.Options{}
|
||||
}
|
||||
|
||||
// If request has a handler, set OnMessage callback
|
||||
if req.Handler != nil {
|
||||
if ctxOpts == nil {
|
||||
ctxOpts = &agentContext.Options{}
|
||||
}
|
||||
// Set OnMessage to receive SSE messages
|
||||
ctxOpts.OnMessage = req.Handler
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
result.Response = resp
|
||||
|
||||
// Extract content from completion if available
|
||||
if resp != nil && resp.Completion != nil {
|
||||
result.Content = extractContentFromCompletion(resp.Completion)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractContentFromCompletion extracts the text content from a completion response
|
||||
func extractContentFromCompletion(completion *agentContext.CompletionResponse) string {
|
||||
if completion == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Content can be string or []ContentPart
|
||||
switch content := completion.Content.(type) {
|
||||
case string:
|
||||
return content
|
||||
case []interface{}:
|
||||
// Handle array of content parts - extract text parts
|
||||
var texts []string
|
||||
for _, part := range content {
|
||||
if partMap, ok := part.(map[string]interface{}); ok {
|
||||
if partType, ok := partMap["type"].(string); ok && partType == "text" {
|
||||
if text, ok := partMap["text"].(string); ok {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(texts) > 0 {
|
||||
return texts[0] // Return first text content
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
162
agent/caller/orchestrator_test.go
Normal file
162
agent/caller/orchestrator_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package caller_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/caller"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
func TestNewOrchestrator(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
orch := caller.NewOrchestrator(ctx)
|
||||
require.NotNil(t, orch)
|
||||
}
|
||||
|
||||
func TestOrchestrator_All_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
orch := caller.NewOrchestrator(ctx)
|
||||
|
||||
results := orch.All([]*caller.Request{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestOrchestrator_Any_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
orch := caller.NewOrchestrator(ctx)
|
||||
|
||||
results := orch.Any([]*caller.Request{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestOrchestrator_Race_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
orch := caller.NewOrchestrator(ctx)
|
||||
|
||||
results := orch.Race([]*caller.Request{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestOrchestrator_All_NoGetter(t *testing.T) {
|
||||
// Reset AgentGetterFunc
|
||||
originalGetter := caller.AgentGetterFunc
|
||||
caller.AgentGetterFunc = nil
|
||||
defer func() { caller.AgentGetterFunc = originalGetter }()
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
orch := caller.NewOrchestrator(ctx)
|
||||
|
||||
reqs := []*caller.Request{
|
||||
{
|
||||
AgentID: "agent1",
|
||||
Messages: []context.Message{{Role: "user", Content: "Hello"}},
|
||||
},
|
||||
{
|
||||
AgentID: "agent2",
|
||||
Messages: []context.Message{{Role: "user", Content: "World"}},
|
||||
},
|
||||
}
|
||||
|
||||
results := orch.All(reqs)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// All should have errors because no getter
|
||||
for i, r := range results {
|
||||
require.NotNil(t, r, "result %d should not be nil", i)
|
||||
assert.Contains(t, r.Error, "agent getter not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestrator_Any_NoGetter(t *testing.T) {
|
||||
// Reset AgentGetterFunc
|
||||
originalGetter := caller.AgentGetterFunc
|
||||
caller.AgentGetterFunc = nil
|
||||
defer func() { caller.AgentGetterFunc = originalGetter }()
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
orch := caller.NewOrchestrator(ctx)
|
||||
|
||||
reqs := []*caller.Request{
|
||||
{
|
||||
AgentID: "agent1",
|
||||
Messages: []context.Message{{Role: "user", Content: "Hello"}},
|
||||
},
|
||||
{
|
||||
AgentID: "agent2",
|
||||
Messages: []context.Message{{Role: "user", Content: "World"}},
|
||||
},
|
||||
}
|
||||
|
||||
results := orch.Any(reqs)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// At least one result should exist
|
||||
hasResult := false
|
||||
for _, r := range results {
|
||||
if r != nil {
|
||||
hasResult = true
|
||||
assert.Contains(t, r.Error, "agent getter not initialized")
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult)
|
||||
}
|
||||
|
||||
func TestOrchestrator_Race_NoGetter(t *testing.T) {
|
||||
// Reset AgentGetterFunc
|
||||
originalGetter := caller.AgentGetterFunc
|
||||
caller.AgentGetterFunc = nil
|
||||
defer func() { caller.AgentGetterFunc = originalGetter }()
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
orch := caller.NewOrchestrator(ctx)
|
||||
|
||||
reqs := []*caller.Request{
|
||||
{
|
||||
AgentID: "agent1",
|
||||
Messages: []context.Message{{Role: "user", Content: "Hello"}},
|
||||
},
|
||||
{
|
||||
AgentID: "agent2",
|
||||
Messages: []context.Message{{Role: "user", Content: "World"}},
|
||||
},
|
||||
}
|
||||
|
||||
results := orch.Race(reqs)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// At least one result should exist (first to complete)
|
||||
hasResult := false
|
||||
for _, r := range results {
|
||||
if r != nil {
|
||||
hasResult = true
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult)
|
||||
}
|
||||
|
||||
func TestOrchestrator_All_NilRequest(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
orch := caller.NewOrchestrator(ctx)
|
||||
|
||||
reqs := []*caller.Request{
|
||||
nil,
|
||||
{
|
||||
AgentID: "agent1",
|
||||
Messages: []context.Message{{Role: "user", Content: "Hello"}},
|
||||
},
|
||||
}
|
||||
|
||||
// Reset AgentGetterFunc
|
||||
originalGetter := caller.AgentGetterFunc
|
||||
caller.AgentGetterFunc = nil
|
||||
defer func() { caller.AgentGetterFunc = originalGetter }()
|
||||
|
||||
results := orch.All(reqs)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// First result should have "nil request" error
|
||||
assert.Contains(t, results[0].Error, "nil request")
|
||||
}
|
||||
44
agent/caller/types.go
Normal file
44
agent/caller/types.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Package caller provides types and utilities for agent-to-agent calls
|
||||
package caller
|
||||
|
||||
import (
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// Request represents a request to call an agent
|
||||
type Request struct {
|
||||
AgentID string `json:"agent"` // Target agent ID
|
||||
Messages []agentContext.Message `json:"messages"` // Messages to send
|
||||
Options *CallOptions `json:"options,omitempty"` // Call options
|
||||
Handler agentContext.OnMessageFunc `json:"-"` // OnMessage handler for this request (not serialized)
|
||||
}
|
||||
|
||||
// CallOptions represents options for an agent call
|
||||
type CallOptions struct {
|
||||
Connector string `json:"connector,omitempty"` // Override connector
|
||||
Mode string `json:"mode,omitempty"` // Agent mode (chat, etc.)
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"` // Custom metadata passed to hooks
|
||||
Skip *agentContext.Skip `json:"skip,omitempty"` // Skip configuration (history, trace, output, etc.)
|
||||
}
|
||||
|
||||
// Result represents the result of an agent call
|
||||
type Result struct {
|
||||
AgentID string `json:"agent_id"` // Agent ID that was called
|
||||
Response *agentContext.Response `json:"response,omitempty"` // Full response from agent
|
||||
Content string `json:"content,omitempty"` // Final text content (extracted from completion)
|
||||
Error string `json:"error,omitempty"` // Error message if call failed
|
||||
}
|
||||
|
||||
// ToContextOptions converts CallOptions to context.Options for the agent call
|
||||
func (o *CallOptions) ToContextOptions() *agentContext.Options {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &agentContext.Options{
|
||||
Connector: o.Connector,
|
||||
Mode: o.Mode,
|
||||
Metadata: o.Metadata,
|
||||
Skip: o.Skip,
|
||||
}
|
||||
}
|
||||
86
agent/caller/types_test.go
Normal file
86
agent/caller/types_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package caller_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/caller"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
func TestCallOptions_ToContextOptions_Nil(t *testing.T) {
|
||||
var opts *caller.CallOptions
|
||||
ctxOpts := opts.ToContextOptions()
|
||||
assert.Nil(t, ctxOpts)
|
||||
}
|
||||
|
||||
func TestCallOptions_ToContextOptions_Empty(t *testing.T) {
|
||||
opts := &caller.CallOptions{}
|
||||
ctxOpts := opts.ToContextOptions()
|
||||
require.NotNil(t, ctxOpts)
|
||||
assert.Empty(t, ctxOpts.Connector)
|
||||
assert.Empty(t, ctxOpts.Mode)
|
||||
assert.Nil(t, ctxOpts.Metadata)
|
||||
assert.Nil(t, ctxOpts.Skip)
|
||||
}
|
||||
|
||||
func TestCallOptions_ToContextOptions_Full(t *testing.T) {
|
||||
opts := &caller.CallOptions{
|
||||
Connector: "gpt4",
|
||||
Mode: "chat",
|
||||
Metadata: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
Skip: &context.Skip{
|
||||
History: true,
|
||||
Trace: true,
|
||||
Output: false,
|
||||
},
|
||||
}
|
||||
|
||||
ctxOpts := opts.ToContextOptions()
|
||||
require.NotNil(t, ctxOpts)
|
||||
assert.Equal(t, "gpt4", ctxOpts.Connector)
|
||||
assert.Equal(t, "chat", ctxOpts.Mode)
|
||||
assert.Equal(t, "value", ctxOpts.Metadata["key"])
|
||||
require.NotNil(t, ctxOpts.Skip)
|
||||
assert.True(t, ctxOpts.Skip.History)
|
||||
assert.True(t, ctxOpts.Skip.Trace)
|
||||
assert.False(t, ctxOpts.Skip.Output)
|
||||
}
|
||||
|
||||
func TestRequest_Basic(t *testing.T) {
|
||||
req := &caller.Request{
|
||||
AgentID: "test-agent",
|
||||
Messages: []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-agent", req.AgentID)
|
||||
assert.Len(t, req.Messages, 1)
|
||||
assert.Equal(t, context.MessageRole("user"), req.Messages[0].Role)
|
||||
}
|
||||
|
||||
func TestResult_Basic(t *testing.T) {
|
||||
result := &caller.Result{
|
||||
AgentID: "test-agent",
|
||||
Content: "Hello response",
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-agent", result.AgentID)
|
||||
assert.Equal(t, "Hello response", result.Content)
|
||||
assert.Empty(t, result.Error)
|
||||
}
|
||||
|
||||
func TestResult_WithError(t *testing.T) {
|
||||
result := &caller.Result{
|
||||
AgentID: "test-agent",
|
||||
Error: "something went wrong",
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-agent", result.AgentID)
|
||||
assert.Equal(t, "something went wrong", result.Error)
|
||||
assert.Empty(t, result.Content)
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ interface Context {
|
|||
memory: Memory; // Agent memory with four namespaces: user, team, chat, context
|
||||
trace: Trace; // Trace object for debugging and monitoring
|
||||
mcp: MCP; // MCP object for external tool/resource access
|
||||
agent: Agent; // Agent-to-Agent calls (A2A)
|
||||
llm: LLM; // Direct LLM connector calls
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1795,6 +1797,524 @@ const sample = ctx.mcp.GetSample("echo", "tool", "ping", 0);
|
|||
console.log(sample.name, sample.input); // Sample name and input data
|
||||
```
|
||||
|
||||
## Agent API
|
||||
|
||||
The `ctx.agent` object provides methods to call other agents from within hooks, enabling agent-to-agent communication (A2A). This allows building complex multi-agent workflows where agents can delegate tasks, consult specialists, or orchestrate parallel operations.
|
||||
|
||||
### Methods Summary
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------- | ---------------------------------------- |
|
||||
| `Call(agentID, messages, opts)` | Call a single agent |
|
||||
| `All(requests, opts?)` | Call multiple agents, wait for all |
|
||||
| `Any(requests, opts?)` | Call multiple agents, first success wins |
|
||||
| `Race(requests, opts?)` | Call multiple agents, first complete wins|
|
||||
|
||||
### Single Agent Call
|
||||
|
||||
#### `ctx.agent.Call(agentID, messages, options?)`
|
||||
|
||||
Calls a single agent and streams the response to the current context's output.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `agentID`: String - The target agent/assistant ID
|
||||
- `messages`: Array - Messages to send to the agent
|
||||
- `options`: Object (optional) - Call options including callback
|
||||
|
||||
**Options:**
|
||||
|
||||
```typescript
|
||||
interface AgentCallOptions {
|
||||
connector?: string; // Override LLM connector
|
||||
mode?: string; // Agent mode ("chat", "task", etc.)
|
||||
metadata?: Record<string, any>; // Custom metadata passed to hooks
|
||||
skip?: {
|
||||
history?: boolean; // Skip loading chat history
|
||||
trace?: boolean; // Skip trace recording
|
||||
output?: boolean; // Skip output to client
|
||||
keyword?: boolean; // Skip keyword extraction
|
||||
search?: boolean; // Skip search
|
||||
content_parsing?: boolean; // Skip content parsing
|
||||
};
|
||||
onChunk?: (msg: Message) => number; // Callback for each message chunk
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Basic call
|
||||
const result = ctx.agent.Call("specialist.agent", [
|
||||
{ role: "user", content: "Analyze this data" }
|
||||
]);
|
||||
|
||||
// With callback
|
||||
const result = ctx.agent.Call("specialist.agent", messages, {
|
||||
connector: "gpt-4o",
|
||||
onChunk: (msg) => {
|
||||
console.log("Received:", msg.type, msg.props?.content);
|
||||
return 0; // 0 = continue, non-zero = stop
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
```typescript
|
||||
interface AgentResult {
|
||||
agent_id: string; // Agent ID that was called
|
||||
response?: Response; // Full agent response
|
||||
content?: string; // Extracted text content
|
||||
error?: string; // Error message if failed
|
||||
}
|
||||
```
|
||||
|
||||
**Message Object (received in onChunk callback):**
|
||||
|
||||
The `onChunk` callback receives a `Message` object with the following structure:
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
type: string; // Message type: "text", "thinking", "tool_call", "error", etc.
|
||||
props?: Record<string, any>; // Message properties (e.g., { content: "Hello" })
|
||||
|
||||
// Streaming identifiers
|
||||
chunk_id?: string; // Unique chunk ID (C1, C2, ...)
|
||||
message_id?: string; // Logical message ID (M1, M2, ...)
|
||||
block_id?: string; // Output block ID (B1, B2, ...)
|
||||
thread_id?: string; // Thread ID for concurrent calls (T1, T2, ...)
|
||||
|
||||
// Delta control
|
||||
delta?: boolean; // Whether this is an incremental update
|
||||
delta_path?: string; // Update path (e.g., "content")
|
||||
delta_action?: string; // Update action: "append", "replace", "merge", "set"
|
||||
}
|
||||
```
|
||||
|
||||
Common message types:
|
||||
- `"text"` - Text content (`props.content` contains the text)
|
||||
- `"thinking"` - Reasoning/thinking content (o1, DeepSeek R1 models)
|
||||
- `"tool_call"` - Tool/function call
|
||||
- `"error"` - Error message (`props.error` contains error details)
|
||||
|
||||
### Parallel Agent Calls
|
||||
|
||||
The parallel methods allow calling multiple agents concurrently, similar to JavaScript Promise patterns.
|
||||
|
||||
#### `ctx.agent.All(requests, options?)`
|
||||
|
||||
Executes all agent calls and waits for all to complete (like `Promise.all`).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `requests`: Array of request objects
|
||||
- `options`: Object (optional) - Global options including callback
|
||||
|
||||
**Request Structure:**
|
||||
|
||||
```typescript
|
||||
interface AgentRequest {
|
||||
agent: string; // Target agent ID
|
||||
messages: Message[]; // Messages to send
|
||||
options?: AgentCallOptions; // Per-request options (excluding onChunk)
|
||||
}
|
||||
|
||||
// Note: Per-request onChunk is NOT supported in batch calls.
|
||||
// Use the global onChunk callback in the second argument instead.
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Call multiple agents in parallel
|
||||
const results = ctx.agent.All([
|
||||
{ agent: "analyzer", messages: [{ role: "user", content: "Analyze X" }] },
|
||||
{ agent: "summarizer", messages: [{ role: "user", content: "Summarize Y" }] }
|
||||
]);
|
||||
|
||||
// Results array matches request order
|
||||
results.forEach((r, i) => {
|
||||
if (r.error) {
|
||||
console.log(`Agent ${r.agent_id} failed:`, r.error);
|
||||
} else {
|
||||
console.log(`Agent ${r.agent_id} response:`, r.content);
|
||||
}
|
||||
});
|
||||
|
||||
// With global callback for all responses
|
||||
const results = ctx.agent.All([
|
||||
{ agent: "agent-1", messages: [...] },
|
||||
{ agent: "agent-2", messages: [...] }
|
||||
], {
|
||||
onChunk: (agentId, index, msg) => {
|
||||
console.log(`Agent ${agentId} [${index}]:`, msg.type, msg.props?.content);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### `ctx.agent.Any(requests, options?)`
|
||||
|
||||
Returns as soon as any agent call succeeds (like `Promise.any`). Other calls continue in background.
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Try multiple agents, use first successful response
|
||||
const results = ctx.agent.Any([
|
||||
{ agent: "primary.agent", messages: [...] },
|
||||
{ agent: "fallback.agent", messages: [...] }
|
||||
]);
|
||||
|
||||
// First successful result is returned
|
||||
const success = results.find(r => !r.error);
|
||||
if (success) {
|
||||
console.log("Got response from:", success.agent_id);
|
||||
}
|
||||
```
|
||||
|
||||
#### `ctx.agent.Race(requests, options?)`
|
||||
|
||||
Returns as soon as any agent call completes, regardless of success/failure (like `Promise.race`).
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Race multiple agents for fastest response
|
||||
const results = ctx.agent.Race([
|
||||
{ agent: "fast.agent", messages: [...] },
|
||||
{ agent: "slow.agent", messages: [...] }
|
||||
]);
|
||||
|
||||
// First completed result (may be error or success)
|
||||
const first = results.find(r => r !== null);
|
||||
console.log("Fastest agent:", first.agent_id);
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
```javascript
|
||||
// Use case 1: Specialist consultation
|
||||
function Next(ctx, payload) {
|
||||
const { completion } = payload;
|
||||
|
||||
if (completion?.content?.includes("complex analysis")) {
|
||||
// Delegate to specialist
|
||||
const result = ctx.agent.Call("specialist.analyzer", [
|
||||
{ role: "user", content: completion.content }
|
||||
]);
|
||||
|
||||
return {
|
||||
data: {
|
||||
status: "delegated",
|
||||
specialist_response: result.content
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use case 2: Parallel processing
|
||||
function Create(ctx, messages) {
|
||||
const userQuery = messages[messages.length - 1]?.content;
|
||||
|
||||
// Query multiple knowledge sources in parallel
|
||||
const results = ctx.agent.All([
|
||||
{ agent: "kb.technical", messages: [{ role: "user", content: userQuery }] },
|
||||
{ agent: "kb.business", messages: [{ role: "user", content: userQuery }] },
|
||||
{ agent: "kb.legal", messages: [{ role: "user", content: userQuery }] }
|
||||
]);
|
||||
|
||||
// Combine results
|
||||
const combinedKnowledge = results
|
||||
.filter(r => !r.error)
|
||||
.map(r => r.content)
|
||||
.join("\n\n");
|
||||
|
||||
// Add to messages
|
||||
return {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: "system", content: `Relevant knowledge:\n${combinedKnowledge}` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Use case 3: Fallback strategy
|
||||
function Next(ctx, payload) {
|
||||
if (payload.error) {
|
||||
// Try backup agents
|
||||
const results = ctx.agent.Any([
|
||||
{ agent: "backup.gpt4", messages: payload.messages },
|
||||
{ agent: "backup.claude", messages: payload.messages }
|
||||
]);
|
||||
|
||||
const success = results.find(r => !r.error);
|
||||
if (success) {
|
||||
return { data: { recovered: true, content: success.content } };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## LLM API
|
||||
|
||||
The `ctx.llm` object provides direct access to LLM connectors for streaming completions. This allows calling LLM models directly without going through the full agent pipeline, useful for quick completions, model comparisons, or building custom workflows.
|
||||
|
||||
### Methods Summary
|
||||
|
||||
| Method | Description |
|
||||
| --------------------------------- | -------------------------------------- |
|
||||
| `Stream(connector, messages, opts)` | Stream LLM completion |
|
||||
| `All(requests, opts?)` | Call multiple LLMs, wait for all |
|
||||
| `Any(requests, opts?)` | Call multiple LLMs, first success wins |
|
||||
| `Race(requests, opts?)` | Call multiple LLMs, first complete wins|
|
||||
|
||||
### Single LLM Call
|
||||
|
||||
#### `ctx.llm.Stream(connector, messages, options?)`
|
||||
|
||||
Calls an LLM connector with streaming output to the current context's writer.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `connector`: String - The LLM connector ID (e.g., "gpt-4o", "claude-3")
|
||||
- `messages`: Array - Messages to send to the LLM
|
||||
- `options`: Object (optional) - LLM options including callback
|
||||
|
||||
**Options:**
|
||||
|
||||
```typescript
|
||||
interface LlmOptions {
|
||||
temperature?: number; // Sampling temperature (0-2)
|
||||
max_tokens?: number; // Max tokens (legacy, use max_completion_tokens)
|
||||
max_completion_tokens?: number; // Max completion tokens
|
||||
top_p?: number; // Nucleus sampling
|
||||
presence_penalty?: number; // Presence penalty (-2 to 2)
|
||||
frequency_penalty?: number; // Frequency penalty (-2 to 2)
|
||||
stop?: string | string[]; // Stop sequences
|
||||
user?: string; // User identifier for tracking
|
||||
seed?: number; // Random seed for reproducibility
|
||||
tools?: object[]; // Function/tool definitions
|
||||
tool_choice?: string | object; // Tool choice strategy
|
||||
response_format?: { // Response format
|
||||
type: string; // "text" | "json_object" | "json_schema"
|
||||
json_schema?: {
|
||||
name: string;
|
||||
description?: string;
|
||||
schema: object;
|
||||
strict?: boolean;
|
||||
};
|
||||
};
|
||||
reasoning_effort?: string; // For reasoning models (e.g., "low", "medium", "high")
|
||||
onChunk?: (msg: Message) => number; // Callback for each chunk
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Basic streaming call
|
||||
const result = ctx.llm.Stream("gpt-4o", [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Explain quantum computing" }
|
||||
]);
|
||||
|
||||
// With options and callback
|
||||
const result = ctx.llm.Stream("gpt-4o", messages, {
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
onChunk: (msg) => {
|
||||
console.log("Chunk:", msg.type, msg.props?.content);
|
||||
return 0; // 0 = continue, non-zero = stop
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Full response:", result.content);
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
```typescript
|
||||
interface LlmResult {
|
||||
connector: string; // Connector ID used
|
||||
response?: CompletionResponse; // Full completion response
|
||||
content?: string; // Extracted text content
|
||||
error?: string; // Error message if failed
|
||||
}
|
||||
```
|
||||
|
||||
### Parallel LLM Calls
|
||||
|
||||
The parallel methods allow calling multiple LLM connectors concurrently, useful for model comparison, ensemble methods, or fallback strategies.
|
||||
|
||||
#### `ctx.llm.All(requests, options?)`
|
||||
|
||||
Executes all LLM calls and waits for all to complete (like `Promise.all`).
|
||||
|
||||
**Request Structure:**
|
||||
|
||||
```typescript
|
||||
interface LlmRequest {
|
||||
connector: string; // LLM connector ID
|
||||
messages: Message[]; // Messages to send
|
||||
options?: LlmOptions; // Per-request options (excluding onChunk)
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Compare responses from multiple models
|
||||
const results = ctx.llm.All([
|
||||
{ connector: "gpt-4o", messages: [...], options: { temperature: 0.7 } },
|
||||
{ connector: "claude-3", messages: [...], options: { temperature: 0.7 } },
|
||||
{ connector: "gemini-pro", messages: [...] }
|
||||
]);
|
||||
|
||||
results.forEach((r) => {
|
||||
console.log(`${r.connector}: ${r.content?.substring(0, 100)}...`);
|
||||
});
|
||||
|
||||
// With global callback
|
||||
const results = ctx.llm.All([
|
||||
{ connector: "gpt-4o", messages: [...] },
|
||||
{ connector: "claude-3", messages: [...] }
|
||||
], {
|
||||
onChunk: (connectorId, index, msg) => {
|
||||
console.log(`LLM ${connectorId} [${index}]:`, msg.props?.content);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### `ctx.llm.Any(requests, options?)`
|
||||
|
||||
Returns as soon as any LLM call succeeds (like `Promise.any`).
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Use first successful response from any model
|
||||
const results = ctx.llm.Any([
|
||||
{ connector: "gpt-4o", messages: [...] },
|
||||
{ connector: "gpt-4o-mini", messages: [...] }
|
||||
]);
|
||||
|
||||
const success = results.find(r => !r.error);
|
||||
if (success) {
|
||||
ctx.Send(success.content);
|
||||
}
|
||||
```
|
||||
|
||||
#### `ctx.llm.Race(requests, options?)`
|
||||
|
||||
Returns as soon as any LLM call completes (like `Promise.race`).
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
// Get fastest response
|
||||
const results = ctx.llm.Race([
|
||||
{ connector: "gpt-4o-mini", messages: [...] }, // Usually faster
|
||||
{ connector: "gpt-4o", messages: [...] } // Usually slower
|
||||
]);
|
||||
|
||||
const first = results.find(r => r !== null);
|
||||
console.log("Fastest model:", first.connector);
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
```javascript
|
||||
// Use case 1: Quick classification without full agent pipeline
|
||||
function Create(ctx, messages) {
|
||||
const userMessage = messages[messages.length - 1]?.content;
|
||||
|
||||
// Quick intent classification
|
||||
const result = ctx.llm.Stream("gpt-4o-mini", [
|
||||
{ role: "system", content: "Classify intent as: question, command, or chat" },
|
||||
{ role: "user", content: userMessage }
|
||||
], { temperature: 0, max_tokens: 10 });
|
||||
|
||||
const intent = result.content?.toLowerCase();
|
||||
ctx.memory.context.Set("intent", intent);
|
||||
|
||||
return { messages };
|
||||
}
|
||||
|
||||
// Use case 2: Model comparison for quality assurance
|
||||
function Next(ctx, payload) {
|
||||
const { completion } = payload;
|
||||
|
||||
// Get second opinion from different model
|
||||
const results = ctx.llm.All([
|
||||
{ connector: "gpt-4o", messages: payload.messages },
|
||||
{ connector: "claude-3-opus", messages: payload.messages }
|
||||
]);
|
||||
|
||||
// Compare responses
|
||||
const gptResponse = results[0].content;
|
||||
const claudeResponse = results[1].content;
|
||||
|
||||
return {
|
||||
data: {
|
||||
primary: completion.content,
|
||||
comparisons: {
|
||||
gpt4o: gptResponse,
|
||||
claude: claudeResponse
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Use case 3: Ensemble with voting
|
||||
function Create(ctx, messages) {
|
||||
// Get multiple model opinions for important decisions
|
||||
const results = ctx.llm.All([
|
||||
{ connector: "gpt-4o", messages: [...] },
|
||||
{ connector: "claude-3", messages: [...] },
|
||||
{ connector: "gemini-pro", messages: [...] }
|
||||
]);
|
||||
|
||||
// Simple majority voting (in real use, implement proper consensus)
|
||||
const responses = results.filter(r => !r.error).map(r => r.content);
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...messages,
|
||||
{
|
||||
role: "system",
|
||||
content: `Multiple model opinions:\n${responses.map((r, i) => `Model ${i+1}: ${r}`).join('\n')}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Use case 4: Fallback with latency optimization
|
||||
function Next(ctx, payload) {
|
||||
if (payload.error) {
|
||||
// Race multiple fallback models
|
||||
const results = ctx.llm.Race([
|
||||
{ connector: "gpt-4o-mini", messages: payload.messages },
|
||||
{ connector: "claude-3-haiku", messages: payload.messages }
|
||||
]);
|
||||
|
||||
const fastest = results.find(r => r !== null);
|
||||
if (fastest && !fastest.error) {
|
||||
ctx.Send(fastest.content);
|
||||
return { data: { recovered: true, model: fastest.connector } };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
The Agent system supports two hooks that can be defined in the assistant's `index.ts` file: `Create` and `Next`.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
|||
// Set search object
|
||||
jsObject.Set("search", ctx.newSearchObject(v8ctx.Isolate()))
|
||||
|
||||
// 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
|
||||
|
|
|
|||
507
agent/context/jsapi_agent.go
Normal file
507
agent/context/jsapi_agent.go
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"rogchap.com/v8go"
|
||||
)
|
||||
|
||||
// AgentAPI defines the agent JSAPI interface for ctx.agent.*
|
||||
// This interface is defined here to avoid circular dependency between context and caller packages.
|
||||
// The actual implementation is in agent/caller/jsapi.go
|
||||
type AgentAPI interface {
|
||||
// Call executes a single agent call
|
||||
// Returns *caller.Result or error information
|
||||
Call(agentID string, messages []interface{}, opts map[string]interface{}) interface{}
|
||||
|
||||
// Parallel agent call methods - inspired by JavaScript Promise
|
||||
// All waits for all agent calls to complete (like Promise.all)
|
||||
All(requests []interface{}) []interface{}
|
||||
// Any returns when any agent call succeeds (like Promise.any)
|
||||
Any(requests []interface{}) []interface{}
|
||||
// Race returns when any agent call completes (like Promise.race)
|
||||
Race(requests []interface{}) []interface{}
|
||||
}
|
||||
|
||||
// AgentAPIWithCallback extends AgentAPI with callback support
|
||||
// This interface provides methods that accept OnMessage handlers for real-time message processing
|
||||
type AgentAPIWithCallback interface {
|
||||
AgentAPI
|
||||
|
||||
// CallWithHandler executes a single agent call with an OnMessage handler
|
||||
// handler receives SSE messages: func(msg *message.Message) int
|
||||
CallWithHandler(agentID string, messages []interface{}, opts map[string]interface{}, handler OnMessageFunc) interface{}
|
||||
|
||||
// AllWithHandler executes all agent calls with handlers
|
||||
// globalHandler receives messages with agentID and index: func(agentID, index, msg) int
|
||||
// Individual request handlers (if set) take precedence over globalHandler
|
||||
AllWithHandler(requests []interface{}, globalHandler BatchOnMessageFunc) []interface{}
|
||||
|
||||
// AnyWithHandler executes agent calls and returns on first success, with handlers
|
||||
AnyWithHandler(requests []interface{}, globalHandler BatchOnMessageFunc) []interface{}
|
||||
|
||||
// RaceWithHandler executes agent calls and returns on first completion, with handlers
|
||||
RaceWithHandler(requests []interface{}, globalHandler BatchOnMessageFunc) []interface{}
|
||||
}
|
||||
|
||||
// BatchOnMessageFunc is the OnMessage function for batch calls
|
||||
// It includes agentID and index to identify the source of each message
|
||||
type BatchOnMessageFunc func(agentID string, index int, msg *message.Message) int
|
||||
|
||||
// AgentAPIFactory is a function type that creates an AgentAPI for a context
|
||||
// This is set by the caller package during initialization
|
||||
var AgentAPIFactory func(ctx *Context) AgentAPI
|
||||
|
||||
// Agent returns the agent API for this context
|
||||
// Returns nil if AgentAPIFactory is not set
|
||||
func (ctx *Context) Agent() AgentAPI {
|
||||
if AgentAPIFactory == nil {
|
||||
return nil
|
||||
}
|
||||
return AgentAPIFactory(ctx)
|
||||
}
|
||||
|
||||
// newAgentObject creates a new agent object with all agent methods
|
||||
// This is called from jsapi.go NewObject() to mount ctx.agent
|
||||
func (ctx *Context) newAgentObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
|
||||
agentObj := v8go.NewObjectTemplate(iso)
|
||||
|
||||
// Single agent call method
|
||||
agentObj.Set("Call", ctx.agentCallMethod(iso))
|
||||
|
||||
// Parallel agent call methods - inspired by JavaScript Promise
|
||||
agentObj.Set("All", ctx.agentAllMethod(iso))
|
||||
agentObj.Set("Any", ctx.agentAnyMethod(iso))
|
||||
agentObj.Set("Race", ctx.agentRaceMethod(iso))
|
||||
|
||||
return agentObj
|
||||
}
|
||||
|
||||
// agentCallMethod implements ctx.agent.Call(agentID, messages, options?)
|
||||
// Usage: const result = ctx.agent.Call("assistant-id", [{ role: "user", content: "Hello" }], { connector: "gpt4", onChunk: (type, data) => 0 })
|
||||
// Returns: { agent_id, response, content, error }
|
||||
func (ctx *Context) agentCallMethod(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, "Call requires agentID and messages parameters")
|
||||
}
|
||||
|
||||
// Get agent ID (first argument)
|
||||
if !args[0].IsString() {
|
||||
return bridge.JsException(v8ctx, "agentID must be a string")
|
||||
}
|
||||
agentID := 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 agent API
|
||||
agentAPI := ctx.Agent()
|
||||
if agentAPI == nil {
|
||||
return bridge.JsException(v8ctx, "agent API not available")
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
|
||||
// If onChunk callback is provided and API supports it, use CallWithHandler
|
||||
if onChunkFn != nil {
|
||||
if apiWithCb, ok := agentAPI.(AgentAPIWithCallback); ok {
|
||||
// Create Go StreamFunc that calls JS callback
|
||||
handler := createJSStreamHandler(v8ctx, onChunkFn)
|
||||
result = apiWithCb.CallWithHandler(agentID, messages, opts, handler)
|
||||
} else {
|
||||
// Fallback: ignore callback if API doesn't support it
|
||||
result = agentAPI.Call(agentID, messages, opts)
|
||||
}
|
||||
} else {
|
||||
// No callback, use regular Call
|
||||
result = agentAPI.Call(agentID, 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
|
||||
})
|
||||
}
|
||||
|
||||
// createJSOnMessageHandler creates a Go OnMessageFunc that calls a JS callback
|
||||
// JS callback signature: (msg: object) => number
|
||||
// msg contains: type, props, delta, message_id, chunk_id, etc.
|
||||
func createJSStreamHandler(v8ctx *v8go.Context, callback *v8go.Function) OnMessageFunc {
|
||||
return func(msg *message.Message) int {
|
||||
if callback == nil || v8ctx == nil || msg == nil {
|
||||
return 0 // Continue if no callback
|
||||
}
|
||||
|
||||
// Convert message to JS value
|
||||
jsMsg, err := bridge.JsValue(v8ctx, msg)
|
||||
if err != nil {
|
||||
return 1 // Stop on error
|
||||
}
|
||||
|
||||
// Call the JS callback with the message object
|
||||
result, err := callback.Call(v8ctx.Global(), jsMsg)
|
||||
if err != nil {
|
||||
return 1 // Stop on error
|
||||
}
|
||||
|
||||
// Check return value (0 = continue, non-zero = stop)
|
||||
if result != nil && result.IsNumber() {
|
||||
ret := result.Integer()
|
||||
if ret != 0 {
|
||||
return int(ret)
|
||||
}
|
||||
}
|
||||
|
||||
return 0 // Continue
|
||||
}
|
||||
}
|
||||
|
||||
// agentAllMethod implements ctx.agent.All(requests, options?)
|
||||
// Waits for all agent calls to complete (like Promise.all)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
//
|
||||
// Global options (second argument):
|
||||
// - onChunk?: (agentID, index, msg) => number - callback for all messages (uses channel for V8 safety)
|
||||
func (ctx *Context) agentAllMethod(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) < 1 {
|
||||
return bridge.JsException(v8ctx, "All requires requests parameter")
|
||||
}
|
||||
|
||||
// Parse requests and extract global callback
|
||||
requests, globalCallback := ctx.parseRequestsForBatch(args, v8ctx)
|
||||
|
||||
// Get agent API
|
||||
agentAPI := ctx.Agent()
|
||||
if agentAPI == nil {
|
||||
return bridge.JsException(v8ctx, "agent API not available")
|
||||
}
|
||||
|
||||
// Execute with channel-based callback handling
|
||||
results := ctx.executeBatchWithCallback(BatchMethodAll, requests, globalCallback, v8ctx)
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
// agentAnyMethod implements ctx.agent.Any(requests, options?)
|
||||
// Returns when any agent call succeeds (like Promise.any)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
//
|
||||
// Global options (second argument):
|
||||
// - onChunk?: (agentID, index, msg) => number - callback for all messages (uses channel for V8 safety)
|
||||
func (ctx *Context) agentAnyMethod(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) < 1 {
|
||||
return bridge.JsException(v8ctx, "Any requires requests parameter")
|
||||
}
|
||||
|
||||
// Parse requests and extract global callback
|
||||
requests, globalCallback := ctx.parseRequestsForBatch(args, v8ctx)
|
||||
|
||||
// Get agent API
|
||||
agentAPI := ctx.Agent()
|
||||
if agentAPI == nil {
|
||||
return bridge.JsException(v8ctx, "agent API not available")
|
||||
}
|
||||
|
||||
// Execute with channel-based callback handling
|
||||
results := ctx.executeBatchWithCallback(BatchMethodAny, requests, globalCallback, v8ctx)
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
// agentRaceMethod implements ctx.agent.Race(requests, options?)
|
||||
// Returns when any agent call completes (like Promise.race)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
//
|
||||
// Global options (second argument):
|
||||
// - onChunk?: (agentID, index, msg) => number - callback for all messages (uses channel for V8 safety)
|
||||
func (ctx *Context) agentRaceMethod(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) < 1 {
|
||||
return bridge.JsException(v8ctx, "Race requires requests parameter")
|
||||
}
|
||||
|
||||
// Parse requests and extract global callback
|
||||
requests, globalCallback := ctx.parseRequestsForBatch(args, v8ctx)
|
||||
|
||||
// Get agent API
|
||||
agentAPI := ctx.Agent()
|
||||
if agentAPI == nil {
|
||||
return bridge.JsException(v8ctx, "agent API not available")
|
||||
}
|
||||
|
||||
// Execute with channel-based callback handling
|
||||
results := ctx.executeBatchWithCallback(BatchMethodRace, requests, globalCallback, v8ctx)
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
// batchMessage represents a message from a batch call for channel-based callback handling
|
||||
type batchMessage struct {
|
||||
AgentID string // Agent ID that generated this message
|
||||
Index int // Index of the request in the batch
|
||||
Message *message.Message // The message object
|
||||
}
|
||||
|
||||
// parseRequestsForBatch parses the requests array and extracts global callback for batch calls
|
||||
// Returns the requests array and the global JS callback function (if any)
|
||||
func (ctx *Context) parseRequestsForBatch(args []*v8go.Value, v8ctx *v8go.Context) ([]interface{}, *v8go.Function) {
|
||||
var globalCallback *v8go.Function
|
||||
|
||||
// Parse global options (second argument) for global onChunk
|
||||
if len(args) >= 2 && !args[1].IsUndefined() && !args[1].IsNull() {
|
||||
globalOptsObj, err := args[1].AsObject()
|
||||
if err == nil && globalOptsObj != nil {
|
||||
onChunkVal, _ := globalOptsObj.Get("onChunk")
|
||||
if onChunkVal != nil && onChunkVal.IsFunction() {
|
||||
globalCallback, _ = onChunkVal.AsFunction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse requests array
|
||||
if len(args) < 1 || args[0].IsUndefined() || args[0].IsNull() {
|
||||
return []interface{}{}, globalCallback
|
||||
}
|
||||
|
||||
requestsObj, err := args[0].AsObject()
|
||||
if err != nil {
|
||||
return []interface{}{}, globalCallback
|
||||
}
|
||||
|
||||
// Get array length
|
||||
lengthVal, err := requestsObj.Get("length")
|
||||
if err != nil {
|
||||
return []interface{}{}, globalCallback
|
||||
}
|
||||
|
||||
length := int(lengthVal.Integer())
|
||||
requests := make([]interface{}, 0, length)
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
itemVal, err := requestsObj.GetIdx(uint32(i))
|
||||
if err != nil || itemVal.IsUndefined() || itemVal.IsNull() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert to Go map
|
||||
goVal, err := bridge.GoValue(itemVal, v8ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
reqMap, ok := goVal.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove onChunk from per-request options (only global callback is supported)
|
||||
if opts, ok := reqMap["options"].(map[string]interface{}); ok {
|
||||
delete(opts, "onChunk")
|
||||
}
|
||||
|
||||
requests = append(requests, reqMap)
|
||||
}
|
||||
|
||||
return requests, globalCallback
|
||||
}
|
||||
|
||||
// BatchMethod represents the type of batch operation
|
||||
type BatchMethod int
|
||||
|
||||
const (
|
||||
BatchMethodAll BatchMethod = iota
|
||||
BatchMethodAny
|
||||
BatchMethodRace
|
||||
)
|
||||
|
||||
// executeBatchWithCallback executes a batch operation with channel-based callback handling
|
||||
// This ensures V8 thread safety by processing all callbacks in the main goroutine
|
||||
func (ctx *Context) executeBatchWithCallback(
|
||||
method BatchMethod,
|
||||
requests []interface{},
|
||||
callback *v8go.Function,
|
||||
v8ctx *v8go.Context,
|
||||
) []interface{} {
|
||||
// Get agent API
|
||||
agentAPI := ctx.Agent()
|
||||
if agentAPI == nil {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// If no callback, just execute directly
|
||||
if callback == nil {
|
||||
switch method {
|
||||
case BatchMethodAll:
|
||||
return agentAPI.All(requests)
|
||||
case BatchMethodAny:
|
||||
return agentAPI.Any(requests)
|
||||
case BatchMethodRace:
|
||||
return agentAPI.Race(requests)
|
||||
}
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// Check if API supports callbacks
|
||||
apiWithCb, ok := agentAPI.(AgentAPIWithCallback)
|
||||
if !ok {
|
||||
switch method {
|
||||
case BatchMethodAll:
|
||||
return agentAPI.All(requests)
|
||||
case BatchMethodAny:
|
||||
return agentAPI.Any(requests)
|
||||
case BatchMethodRace:
|
||||
return agentAPI.Race(requests)
|
||||
}
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// Create message channel for callback handling
|
||||
// Use a large buffer (1000) to reduce blocking, with blocking send to guarantee no message loss
|
||||
msgChan := make(chan batchMessage, 1000)
|
||||
doneChan := make(chan []interface{}, 1)
|
||||
|
||||
// Create Go handler that sends messages to channel
|
||||
// Blocking send ensures no message is lost (natural backpressure)
|
||||
goHandler := func(agentID string, index int, msg *message.Message) int {
|
||||
msgChan <- batchMessage{AgentID: agentID, Index: index, Message: msg}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Start batch execution in background goroutine
|
||||
go func() {
|
||||
defer close(msgChan)
|
||||
var results []interface{}
|
||||
|
||||
switch method {
|
||||
case BatchMethodAll:
|
||||
results = apiWithCb.AllWithHandler(requests, goHandler)
|
||||
case BatchMethodAny:
|
||||
results = apiWithCb.AnyWithHandler(requests, goHandler)
|
||||
case BatchMethodRace:
|
||||
results = apiWithCb.RaceWithHandler(requests, goHandler)
|
||||
}
|
||||
|
||||
doneChan <- results
|
||||
}()
|
||||
|
||||
// Process messages in main goroutine (V8 thread-safe)
|
||||
for msg := range msgChan {
|
||||
callJSBatchCallback(v8ctx, callback, msg.AgentID, msg.Index, msg.Message)
|
||||
}
|
||||
|
||||
// Wait for results
|
||||
return <-doneChan
|
||||
}
|
||||
|
||||
// callJSBatchCallback calls the JS callback with batch message parameters
|
||||
// Must be called from the main V8 goroutine
|
||||
func callJSBatchCallback(v8ctx *v8go.Context, callback *v8go.Function, agentID string, index int, msg *message.Message) {
|
||||
if callback == nil || v8ctx == nil || msg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
iso := v8ctx.Isolate()
|
||||
|
||||
agentIDVal, err := v8go.NewValue(iso, agentID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
indexVal, err := v8go.NewValue(iso, int32(index))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert message to JS value
|
||||
jsMsg, err := bridge.JsValue(v8ctx, msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
callback.Call(v8ctx.Global(), agentIDVal, indexVal, jsMsg)
|
||||
}
|
||||
57
agent/context/jsapi_agent_test.go
Normal file
57
agent/context/jsapi_agent_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package context_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
func TestContext_Agent_NilFactory(t *testing.T) {
|
||||
// Reset factory
|
||||
context.AgentAPIFactory = nil
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
agentAPI := ctx.Agent()
|
||||
assert.Nil(t, agentAPI)
|
||||
}
|
||||
|
||||
func TestContext_Agent_WithFactory(t *testing.T) {
|
||||
// Set up a mock factory
|
||||
var capturedCtx *context.Context
|
||||
context.AgentAPIFactory = func(ctx *context.Context) context.AgentAPI {
|
||||
capturedCtx = ctx
|
||||
return &mockAgentAPI{}
|
||||
}
|
||||
defer func() { context.AgentAPIFactory = nil }()
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
agentAPI := ctx.Agent()
|
||||
|
||||
require.NotNil(t, agentAPI)
|
||||
assert.Equal(t, ctx, capturedCtx)
|
||||
}
|
||||
|
||||
// mockAgentAPI implements context.AgentAPI for testing
|
||||
type mockAgentAPI struct{}
|
||||
|
||||
func (m *mockAgentAPI) Call(agentID string, messages []interface{}, opts map[string]interface{}) interface{} {
|
||||
return map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"content": "mock response",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockAgentAPI) All(requests []interface{}) []interface{} {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
func (m *mockAgentAPI) Any(requests []interface{}) []interface{} {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
func (m *mockAgentAPI) Race(requests []interface{}) []interface{} {
|
||||
return []interface{}{}
|
||||
}
|
||||
669
agent/context/jsapi_agent_v8_test.go
Normal file
669
agent/context/jsapi_agent_v8_test.go
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
package context_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"strings"
|
||||
"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 AgentAPIFactory
|
||||
_ "github.com/yaoapp/yao/agent/assistant"
|
||||
)
|
||||
|
||||
// TestAgent_Call_V8 tests basic ctx.agent.Call() functionality with real V8 execution
|
||||
func TestAgent_Call_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",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, "test-chat-v8-call")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const result = ctx.agent.Call(
|
||||
"tests.simple-greeting",
|
||||
[{ role: "user", content: "Hello" }]
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agent_id: result.agent_id,
|
||||
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)
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "Result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Fatalf("Test failed: %v", result["error"])
|
||||
}
|
||||
|
||||
assert.Equal(t, "tests.simple-greeting", result["agent_id"])
|
||||
|
||||
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")
|
||||
|
||||
errorStr, _ := result["error"].(string)
|
||||
assert.Empty(t, errorStr, "Should not have error")
|
||||
}
|
||||
|
||||
// TestAgent_Call_WithOptions_V8 tests ctx.agent.Call() with options
|
||||
func TestAgent_Call_WithOptions_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-v8-options")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const result = ctx.agent.Call(
|
||||
"tests.simple-greeting",
|
||||
[{ role: "user", content: "Hi there!" }],
|
||||
{
|
||||
skip: {
|
||||
history: true,
|
||||
trace: true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agent_id: result.agent_id,
|
||||
content: result.content || "",
|
||||
error: result.error || ""
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result := res.(map[string]interface{})
|
||||
|
||||
if !result["success"].(bool) {
|
||||
t.Fatalf("Test failed: %v", result["error"])
|
||||
}
|
||||
|
||||
assert.Equal(t, "tests.simple-greeting", result["agent_id"])
|
||||
assert.NotEmpty(t, result["content"], "Should have content")
|
||||
}
|
||||
|
||||
// TestAgent_All_V8 tests ctx.agent.All() for parallel execution
|
||||
func TestAgent_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-v8-all")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const results = ctx.agent.All([
|
||||
{
|
||||
agent: "tests.simple-greeting",
|
||||
messages: [{ role: "user", content: "Hello from request 1" }]
|
||||
},
|
||||
{
|
||||
agent: "tests.simple-greeting",
|
||||
messages: [{ role: "user", content: "Hello from request 2" }]
|
||||
}
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.length,
|
||||
first_agent: results[0] ? results[0].agent_id : "",
|
||||
second_agent: results[1] ? results[1].agent_id : "",
|
||||
first_has_content: results[0] && results[0].content && results[0].content.length > 0,
|
||||
second_has_content: results[1] && results[1].content && results[1].content.length > 0,
|
||||
first_error: results[0] ? (results[0].error || "") : "no result",
|
||||
second_error: results[1] ? (results[1].error || "") : "no result"
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result := res.(map[string]interface{})
|
||||
|
||||
if !result["success"].(bool) {
|
||||
t.Fatalf("Test failed: %v", result["error"])
|
||||
}
|
||||
|
||||
assert.Equal(t, float64(2), result["count"])
|
||||
assert.Equal(t, "tests.simple-greeting", result["first_agent"])
|
||||
assert.Equal(t, "tests.simple-greeting", result["second_agent"])
|
||||
assert.True(t, result["first_has_content"].(bool), "First result should have content")
|
||||
assert.True(t, result["second_has_content"].(bool), "Second result should have content")
|
||||
assert.Empty(t, result["first_error"], "First result should not have error")
|
||||
assert.Empty(t, result["second_error"], "Second result should not have error")
|
||||
}
|
||||
|
||||
// TestAgent_Any_V8 tests ctx.agent.Any() returns on first success
|
||||
func TestAgent_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-v8-any")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const results = ctx.agent.Any([
|
||||
{
|
||||
agent: "tests.simple-greeting",
|
||||
messages: [{ role: "user", content: "Hello" }]
|
||||
},
|
||||
{
|
||||
agent: "tests.simple-greeting",
|
||||
messages: [{ role: "user", content: "Hi" }]
|
||||
}
|
||||
]);
|
||||
|
||||
// At least one result should be successful
|
||||
let hasSuccess = false;
|
||||
for (const r of results) {
|
||||
if (r && r.content && !r.error) {
|
||||
hasSuccess = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.length,
|
||||
has_successful_result: hasSuccess
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result := res.(map[string]interface{})
|
||||
|
||||
if !result["success"].(bool) {
|
||||
t.Fatalf("Test failed: %v", result["error"])
|
||||
}
|
||||
|
||||
assert.Equal(t, float64(2), result["count"])
|
||||
assert.True(t, result["has_successful_result"].(bool), "Should have at least one successful result")
|
||||
}
|
||||
|
||||
// TestAgent_Race_V8 tests ctx.agent.Race() returns on first completion
|
||||
func TestAgent_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-v8-race")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const results = ctx.agent.Race([
|
||||
{
|
||||
agent: "tests.simple-greeting",
|
||||
messages: [{ role: "user", content: "Hello" }]
|
||||
},
|
||||
{
|
||||
agent: "tests.simple-greeting",
|
||||
messages: [{ role: "user", content: "Hi" }]
|
||||
}
|
||||
]);
|
||||
|
||||
// At least one result should exist (first to complete)
|
||||
let hasResult = false;
|
||||
for (const r of results) {
|
||||
if (r && (r.content || r.error)) {
|
||||
hasResult = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.length,
|
||||
has_result: hasResult
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result := res.(map[string]interface{})
|
||||
|
||||
if !result["success"].(bool) {
|
||||
t.Fatalf("Test failed: %v", result["error"])
|
||||
}
|
||||
|
||||
assert.Equal(t, float64(2), result["count"])
|
||||
assert.True(t, result["has_result"].(bool), "Should have at least one result")
|
||||
}
|
||||
|
||||
// TestAgent_ErrorHandling_V8 tests error handling when calling non-existent agent
|
||||
func TestAgent_ErrorHandling_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-v8-error")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const result = ctx.agent.Call(
|
||||
"non-existent-agent",
|
||||
[{ role: "user", content: "Hello" }]
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
has_error: result.error && result.error.length > 0,
|
||||
error_message: result.error || ""
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result := res.(map[string]interface{})
|
||||
|
||||
// The call should succeed (no JS exception), but result should contain error
|
||||
assert.True(t, result["success"].(bool), "JS execution should succeed")
|
||||
assert.True(t, result["has_error"].(bool), "Result should have error for non-existent agent")
|
||||
assert.True(t, strings.Contains(result["error_message"].(string), "failed to get agent"), "Error should mention failed to get agent")
|
||||
}
|
||||
|
||||
// TestAgent_EmptyRequests_V8 tests handling of empty requests array
|
||||
func TestAgent_EmptyRequests_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-v8-empty")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const results = ctx.agent.All([]);
|
||||
return {
|
||||
success: true,
|
||||
count: results.length
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result := res.(map[string]interface{})
|
||||
|
||||
assert.True(t, result["success"].(bool))
|
||||
assert.Equal(t, float64(0), result["count"])
|
||||
}
|
||||
|
||||
// TestAgent_InvalidArguments_V8 tests error handling for invalid arguments
|
||||
func TestAgent_InvalidArguments_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-v8-invalid")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
// Test missing arguments
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
// Call with no arguments should throw
|
||||
ctx.agent.Call();
|
||||
return { success: false, error: "Should have thrown" };
|
||||
} catch (error) {
|
||||
return { success: true, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result := res.(map[string]interface{})
|
||||
assert.True(t, result["success"].(bool), "Should catch the error")
|
||||
assert.Contains(t, result["error"].(string), "requires")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Callback Tests
|
||||
// ============================================================================
|
||||
|
||||
// TestAgent_Call_WithCallback_V8 tests ctx.agent.Call() with onChunk callback
|
||||
func TestAgent_Call_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-v8-callback")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const messages = [];
|
||||
let messageCount = 0;
|
||||
|
||||
const result = ctx.agent.Call(
|
||||
"tests.simple-greeting",
|
||||
[{ role: "user", content: "Hello" }],
|
||||
{
|
||||
onChunk: (msg) => {
|
||||
// msg is the SSE message object
|
||||
messageCount++;
|
||||
messages.push({
|
||||
type: msg.type,
|
||||
has_props: msg.props !== undefined
|
||||
});
|
||||
return 0; // Continue
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agent_id: result.agent_id,
|
||||
has_content: result.content && result.content.length > 0,
|
||||
message_count: messageCount,
|
||||
received_messages: messages.slice(0, 5), // First 5 messages
|
||||
error: result.error || ""
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "Result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Fatalf("Test failed: %v", result["error"])
|
||||
}
|
||||
|
||||
assert.Equal(t, "tests.simple-greeting", result["agent_id"])
|
||||
|
||||
// Should have received some messages via callback
|
||||
messageCount, _ := result["message_count"].(float64)
|
||||
t.Logf("Received %v messages via callback", messageCount)
|
||||
assert.Greater(t, messageCount, float64(0), "Should have received messages via callback")
|
||||
|
||||
// Check that we received message objects with type and props
|
||||
receivedMsgs, _ := result["received_messages"].([]interface{})
|
||||
if len(receivedMsgs) > 0 {
|
||||
firstMsg := receivedMsgs[0].(map[string]interface{})
|
||||
t.Logf("First message type: %v", firstMsg["type"])
|
||||
assert.NotEmpty(t, firstMsg["type"], "Message should have type")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgent_Call_WithCallback_Stop_V8 tests that callback can stop streaming
|
||||
func TestAgent_Call_WithCallback_Stop_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-v8-callback-stop")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
let messageCount = 0;
|
||||
|
||||
const result = ctx.agent.Call(
|
||||
"tests.simple-greeting",
|
||||
[{ role: "user", content: "Hello" }],
|
||||
{
|
||||
onChunk: (msg) => {
|
||||
messageCount++;
|
||||
// Stop after receiving 3 messages
|
||||
if (messageCount >= 3) {
|
||||
return 1; // Stop
|
||||
}
|
||||
return 0; // Continue
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message_count: messageCount,
|
||||
stopped_early: messageCount <= 5 // Should have stopped early
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "Result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Fatalf("Test failed: %v", result["error"])
|
||||
}
|
||||
|
||||
messageCount, _ := result["message_count"].(float64)
|
||||
t.Logf("Received %v messages before stopping", messageCount)
|
||||
// Note: The exact count may vary based on when the stop is processed
|
||||
}
|
||||
|
||||
// TestAgent_All_WithGlobalCallback_V8 tests ctx.agent.All() with global onChunk callback
|
||||
// Uses channel-based callback handling for V8 thread safety
|
||||
func TestAgent_All_WithGlobalCallback_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-v8-all-callback")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
defer ctx.Release()
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
const messagesByAgent = {};
|
||||
|
||||
const results = ctx.agent.All(
|
||||
[
|
||||
{
|
||||
agent: "tests.simple-greeting",
|
||||
messages: [{ role: "user", content: "Hello from 1" }]
|
||||
},
|
||||
{
|
||||
agent: "tests.simple-greeting",
|
||||
messages: [{ role: "user", content: "Hello from 2" }]
|
||||
}
|
||||
],
|
||||
{
|
||||
// Global callback receives agentID, index, and message
|
||||
onChunk: (agentID, index, msg) => {
|
||||
const key = agentID + "_" + index;
|
||||
if (!messagesByAgent[key]) {
|
||||
messagesByAgent[key] = 0;
|
||||
}
|
||||
messagesByAgent[key]++;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result_count: results.length,
|
||||
messages_by_agent: messagesByAgent
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
result, ok := res.(map[string]interface{})
|
||||
require.True(t, ok, "Result should be a map")
|
||||
|
||||
success, _ := result["success"].(bool)
|
||||
if !success {
|
||||
t.Fatalf("Test failed: %v", result["error"])
|
||||
}
|
||||
|
||||
assert.Equal(t, float64(2), result["result_count"])
|
||||
|
||||
// Should have received messages from both agents
|
||||
messagesByAgent, _ := result["messages_by_agent"].(map[string]interface{})
|
||||
t.Logf("Messages by agent: %v", messagesByAgent)
|
||||
|
||||
// At least one agent should have sent messages
|
||||
assert.Greater(t, len(messagesByAgent), 0, "Should have received messages from agents")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
|
@ -15,6 +15,13 @@ import (
|
|||
// - Sends block_start event when a new BlockID is first encountered
|
||||
// - Records metadata for all sent messages to enable delta inheritance
|
||||
func (ctx *Context) Send(msg *message.Message) error {
|
||||
// Call OnMessage callback if provided (for ctx.agent.Call with onChunk)
|
||||
if ctx.Stack != nil && ctx.Stack.Options != nil && ctx.Stack.Options.OnMessage != nil {
|
||||
if ret := ctx.Stack.Options.OnMessage(msg); ret != 0 {
|
||||
return nil // Callback requested stop
|
||||
}
|
||||
}
|
||||
|
||||
out, err := ctx.getOutput()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -308,8 +308,18 @@ type Options struct {
|
|||
|
||||
// Metadata for passing custom data to hooks (e.g., scenario selection)
|
||||
Metadata map[string]any `json:"metadata,omitempty"` // Custom metadata passed to Create/Next hooks
|
||||
|
||||
// OnMessage is called for each message sent via ctx.Send()
|
||||
// Used by ctx.agent.Call with onChunk callback to receive SSE messages
|
||||
// Returns: 0 = continue, non-zero = stop
|
||||
OnMessage OnMessageFunc `json:"-"`
|
||||
}
|
||||
|
||||
// OnMessageFunc is a callback function for receiving output messages
|
||||
// Called for each message sent via ctx.Send() - same as SSE messages to client
|
||||
// Returns: 0 = continue, non-zero = stop sending
|
||||
type OnMessageFunc func(msg *message.Message) int
|
||||
|
||||
// Stack represents the call stack node for tracing agent-to-agent calls
|
||||
// Uses a flat structure to avoid circular references and memory overhead
|
||||
type Stack struct {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ interface Context {
|
|||
trace: Trace; // Tracing API
|
||||
mcp: MCP; // MCP operations
|
||||
search: Search; // Search API
|
||||
agent: Agent; // Agent-to-Agent calls (A2A)
|
||||
llm: LLM; // Direct LLM calls
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -312,3 +314,188 @@ interface SearchResult {
|
|||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Agent API
|
||||
|
||||
The `ctx.agent` object provides methods to call other agents from within hooks, enabling agent-to-agent communication (A2A).
|
||||
|
||||
### Single Agent Call
|
||||
|
||||
```typescript
|
||||
// Basic call
|
||||
const result = ctx.agent.Call("assistant-id", messages);
|
||||
|
||||
// With options and callback
|
||||
const result = ctx.agent.Call("assistant-id", messages, {
|
||||
connector: "gpt-4o",
|
||||
mode: "chat",
|
||||
metadata: { source: "hook" },
|
||||
skip: { history: false, trace: false, output: false },
|
||||
onChunk: (msg) => {
|
||||
console.log("Received:", msg.type, msg.props);
|
||||
return 0; // 0 = continue, non-zero = stop
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Agent Options
|
||||
|
||||
```typescript
|
||||
interface AgentCallOptions {
|
||||
connector?: string; // Override LLM connector
|
||||
mode?: string; // Agent mode ("chat", "task")
|
||||
metadata?: Record<string, any>; // Custom metadata passed to hooks
|
||||
skip?: {
|
||||
history?: boolean; // Skip loading chat history
|
||||
trace?: boolean; // Skip trace recording
|
||||
output?: boolean; // Skip output to client
|
||||
keyword?: boolean; // Skip keyword extraction
|
||||
search?: boolean; // Skip search
|
||||
content_parsing?: boolean; // Skip content parsing
|
||||
};
|
||||
onChunk?: (msg: Message) => number; // Callback (0=continue, non-zero=stop)
|
||||
}
|
||||
```
|
||||
|
||||
### Parallel Agent Calls
|
||||
|
||||
```typescript
|
||||
// Wait for all agents to complete (like Promise.all)
|
||||
const results = ctx.agent.All([
|
||||
{ agent: "agent-1", messages: [...] },
|
||||
{ agent: "agent-2", messages: [...] }
|
||||
]);
|
||||
|
||||
// Return first successful result (like Promise.any)
|
||||
const results = ctx.agent.Any([
|
||||
{ agent: "agent-1", messages: [...] },
|
||||
{ agent: "agent-2", messages: [...] }
|
||||
]);
|
||||
|
||||
// Return first completed result (like Promise.race)
|
||||
const results = ctx.agent.Race([
|
||||
{ agent: "agent-1", messages: [...] },
|
||||
{ agent: "agent-2", messages: [...] }
|
||||
]);
|
||||
|
||||
// With global callback for all responses
|
||||
const results = ctx.agent.All([
|
||||
{ agent: "agent-1", messages: [...] },
|
||||
{ agent: "agent-2", messages: [...] }
|
||||
], {
|
||||
onChunk: (agentId, index, msg) => {
|
||||
console.log(`Agent ${agentId} [${index}]:`, msg.type);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Result Structure
|
||||
|
||||
```typescript
|
||||
interface AgentResult {
|
||||
agent_id: string;
|
||||
response?: Response;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Message Object (onChunk callback)
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
type: string; // "text", "thinking", "tool_call", "error"
|
||||
props?: Record<string, any>; // e.g., { content: "Hello" }
|
||||
chunk_id?: string; // C1, C2, ...
|
||||
message_id?: string; // M1, M2, ...
|
||||
delta?: boolean; // Incremental update flag
|
||||
}
|
||||
```
|
||||
|
||||
## LLM API
|
||||
|
||||
The `ctx.llm` object provides direct access to LLM connectors for streaming completions.
|
||||
|
||||
### Single LLM Call
|
||||
|
||||
```typescript
|
||||
// Basic streaming call
|
||||
const result = ctx.llm.Stream("gpt-4o", [
|
||||
{ role: "user", content: "Hello" }
|
||||
]);
|
||||
|
||||
// With options and callback
|
||||
const result = ctx.llm.Stream("gpt-4o", messages, {
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
onChunk: (msg) => {
|
||||
console.log("Chunk:", msg.props?.content);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Parallel LLM Calls
|
||||
|
||||
```typescript
|
||||
// Wait for all LLM calls (like Promise.all)
|
||||
const results = ctx.llm.All([
|
||||
{ connector: "gpt-4o", messages: [...] },
|
||||
{ connector: "claude-3", messages: [...] }
|
||||
]);
|
||||
|
||||
// Return first successful result (like Promise.any)
|
||||
const results = ctx.llm.Any([
|
||||
{ connector: "gpt-4o", messages: [...] },
|
||||
{ connector: "claude-3", messages: [...] }
|
||||
]);
|
||||
|
||||
// Return first completed result (like Promise.race)
|
||||
const results = ctx.llm.Race([
|
||||
{ connector: "gpt-4o", messages: [...] },
|
||||
{ connector: "claude-3", messages: [...] }
|
||||
]);
|
||||
|
||||
// With global callback
|
||||
const results = ctx.llm.All([
|
||||
{ connector: "gpt-4o", messages: [...] },
|
||||
{ connector: "claude-3", messages: [...] }
|
||||
], {
|
||||
onChunk: (connectorId, index, msg) => {
|
||||
console.log(`LLM ${connectorId} [${index}]:`, msg.type);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### LLM Options
|
||||
|
||||
```typescript
|
||||
interface LlmOptions {
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
max_completion_tokens?: number;
|
||||
top_p?: number;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
stop?: string | string[];
|
||||
user?: string;
|
||||
seed?: number;
|
||||
tools?: object[];
|
||||
tool_choice?: string | object;
|
||||
response_format?: { type: string; json_schema?: object };
|
||||
reasoning_effort?: string;
|
||||
onChunk?: (msg: Message) => number;
|
||||
}
|
||||
```
|
||||
|
||||
### Result Structure
|
||||
|
||||
```typescript
|
||||
interface LlmResult {
|
||||
connector: string;
|
||||
response?: CompletionResponse;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
|
|||
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
|
||||
|
|
@ -1039,13 +1039,19 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
|
|||
return nil, fmt.Errorf("options are required")
|
||||
}
|
||||
|
||||
// Get model from connector settings
|
||||
// Get model and other settings from connector
|
||||
setting := p.Connector.Setting()
|
||||
model, ok := setting["model"].(string)
|
||||
if !ok || model == "" {
|
||||
return nil, fmt.Errorf("model is not set in connector")
|
||||
}
|
||||
|
||||
// Get thinking setting from connector (for models that support reasoning/thinking mode)
|
||||
var thinkingSetting interface{}
|
||||
if thinking, exists := setting["thinking"]; exists {
|
||||
thinkingSetting = thinking
|
||||
}
|
||||
|
||||
// Convert messages to API format
|
||||
apiMessages := make([]map[string]interface{}, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
|
|
@ -1202,6 +1208,11 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
|
|||
body["audio"] = options.Audio
|
||||
}
|
||||
|
||||
// Add thinking parameter for models that support reasoning/thinking mode
|
||||
if thinkingSetting != nil {
|
||||
body["thinking"] = thinkingSetting
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -790,3 +790,252 @@ fmt.Printf("Retrieved text: %s\n", savedText)
|
|||
#### `RegisterDefault(name string) (*Manager, error)`
|
||||
|
||||
Registers a default attachment manager with sensible defaults for common file types.
|
||||
|
||||
## Process API
|
||||
|
||||
The attachment package provides a set of Yao Process APIs for file management with built-in permission support.
|
||||
|
||||
### Available Processes
|
||||
|
||||
| Process | Description |
|
||||
|---------|-------------|
|
||||
| `attachment.Save` | Save a file from base64 data URI |
|
||||
| `attachment.Read` | Read file content as base64 data URI |
|
||||
| `attachment.Info` | Get file metadata |
|
||||
| `attachment.List` | List files with pagination and filtering |
|
||||
| `attachment.Delete` | Delete a file |
|
||||
| `attachment.Exists` | Check if file exists |
|
||||
| `attachment.URL` | Get file URL |
|
||||
| `attachment.SaveText` | Save parsed text content for a file |
|
||||
| `attachment.GetText` | Get parsed text content for a file |
|
||||
|
||||
### Permission Model
|
||||
|
||||
The Process API integrates with Yao's `process.Authorized` mechanism:
|
||||
|
||||
- **Authorized Info**: Reads `UserID`, `TeamID`, `TenantID` from `process.Authorized` (set by OAuth guard)
|
||||
- **Auto Permission Storage**: On save, automatically stores `__yao_created_by`, `__yao_team_id`, `__yao_tenant_id` from `process.Authorized`
|
||||
- **Data Constraints**: Respects `Constraints.OwnerOnly` and `Constraints.TeamOnly` from ACL enforcement
|
||||
- **Owner Access**: When `OwnerOnly` is set, only file creator (`__yao_created_by`) can access their files
|
||||
- **Team Access**: When `TeamOnly` is set, team members can access files with `share: "team"`
|
||||
- **Public Access**: Files with `public: true` are readable by everyone regardless of constraints
|
||||
- **No Constraints**: If no constraints are set, all authenticated users can access all files
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### JavaScript (Yao Scripts)
|
||||
|
||||
```javascript
|
||||
// Save a file from base64 data URI
|
||||
const file = Process("attachment.Save", "default",
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...",
|
||||
"photo.png",
|
||||
{ share: "team" }
|
||||
);
|
||||
console.log("Saved file ID:", file.file_id);
|
||||
|
||||
// Save text file
|
||||
const textFile = Process("attachment.Save", "default",
|
||||
"data:text/plain;base64,SGVsbG8gV29ybGQh",
|
||||
"hello.txt"
|
||||
);
|
||||
|
||||
// Read file content as data URI
|
||||
const dataURI = Process("attachment.Read", "default", file.file_id);
|
||||
// Returns: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..."
|
||||
|
||||
// Get file info
|
||||
const info = Process("attachment.Info", "default", file.file_id);
|
||||
|
||||
// List files with pagination
|
||||
const result = Process("attachment.List", "default", {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
filters: { status: "uploaded", content_type: "image/*" },
|
||||
order_by: "created_at desc"
|
||||
});
|
||||
|
||||
// Check if file exists
|
||||
const exists = Process("attachment.Exists", "default", file.file_id);
|
||||
|
||||
// Get file URL
|
||||
const url = Process("attachment.URL", "default", file.file_id);
|
||||
|
||||
// Save parsed text content (e.g., OCR result, PDF text)
|
||||
Process("attachment.SaveText", "default", file.file_id, "Extracted text content...");
|
||||
|
||||
// Get text content (preview by default)
|
||||
const preview = Process("attachment.GetText", "default", file.file_id);
|
||||
|
||||
// Get full text content
|
||||
const fullText = Process("attachment.GetText", "default", file.file_id, true);
|
||||
|
||||
// Delete file
|
||||
Process("attachment.Delete", "default", file.file_id);
|
||||
```
|
||||
|
||||
#### Flow DSL
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Save Image",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "save",
|
||||
"process": "attachment.Save",
|
||||
"args": [
|
||||
"default",
|
||||
"{{$in.dataURI}}",
|
||||
"{{$in.filename}}",
|
||||
{ "share": "team" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"output": "{{$res.save}}"
|
||||
}
|
||||
```
|
||||
|
||||
### Process Reference
|
||||
|
||||
#### `attachment.Save`
|
||||
|
||||
Save a file from base64 data URI. Automatically parses content type from data URI header and stores permission fields from `process.Authorized`.
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `content` (string) - Base64 data URI (e.g., `"data:image/png;base64,xxxx"`) or plain base64
|
||||
3. `filename` (string, optional) - Original filename (auto-generated if not provided)
|
||||
4. `option` (map, optional) - Upload options:
|
||||
- `groups` ([]string) - Directory groups for organization
|
||||
- `gzip` (bool) - Enable gzip compression
|
||||
- `compress_image` (bool) - Enable image compression
|
||||
- `compress_size` (int) - Target image size in pixels
|
||||
- `public` (bool) - Make file publicly accessible
|
||||
- `share` (string) - Share scope: "private" or "team"
|
||||
|
||||
**Returns:** `*File` - Saved file information
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// With data URI (auto-detect content type)
|
||||
Process("attachment.Save", "default", "data:image/png;base64,iVBORw0KGgo...", "photo.png")
|
||||
|
||||
// With plain base64 (defaults to application/octet-stream)
|
||||
Process("attachment.Save", "default", "SGVsbG8gV29ybGQh", "hello.txt")
|
||||
|
||||
// With options
|
||||
Process("attachment.Save", "default", "data:application/pdf;base64,...", "doc.pdf", {
|
||||
groups: ["documents"],
|
||||
share: "team",
|
||||
public: false
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `attachment.Read`
|
||||
|
||||
Read file content as base64 data URI.
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `fileID` (string) - The file ID
|
||||
|
||||
**Returns:** `string` - Base64 data URI (e.g., `"data:image/png;base64,xxxx"`)
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
const dataURI = Process("attachment.Read", "default", "abc123")
|
||||
// Returns: "data:image/png;base64,iVBORw0KGgo..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `attachment.Info`
|
||||
|
||||
Get file metadata.
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `fileID` (string) - The file ID
|
||||
|
||||
**Returns:** `*File` - File metadata
|
||||
|
||||
---
|
||||
|
||||
#### `attachment.List`
|
||||
|
||||
List files with pagination and filtering.
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `option` (map, optional) - List options:
|
||||
- `page` (int) - Page number (default: 1)
|
||||
- `page_size` (int) - Items per page (default: 20, max: 100)
|
||||
- `filters` (map) - Filter conditions (e.g., `{"status": "uploaded"}`)
|
||||
- `order_by` (string) - Sort order (e.g., "created_at desc")
|
||||
- `select` ([]string) - Fields to return
|
||||
|
||||
**Returns:** `*ListResult` - Paginated file list
|
||||
|
||||
---
|
||||
|
||||
#### `attachment.Delete`
|
||||
|
||||
Delete a file. Requires write permission (owner only).
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `fileID` (string) - The file ID
|
||||
|
||||
**Returns:** `bool` - Success status
|
||||
|
||||
---
|
||||
|
||||
#### `attachment.Exists`
|
||||
|
||||
Check if a file exists.
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `fileID` (string) - The file ID
|
||||
|
||||
**Returns:** `bool` - Whether file exists
|
||||
|
||||
---
|
||||
|
||||
#### `attachment.URL`
|
||||
|
||||
Get the URL of a file.
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `fileID` (string) - The file ID
|
||||
|
||||
**Returns:** `string` - File URL
|
||||
|
||||
---
|
||||
|
||||
#### `attachment.SaveText`
|
||||
|
||||
Save parsed text content for a file (e.g., OCR result, PDF extracted text).
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `fileID` (string) - The file ID
|
||||
3. `text` (string) - Text content to save
|
||||
|
||||
**Returns:** `bool` - Success status
|
||||
|
||||
---
|
||||
|
||||
#### `attachment.GetText`
|
||||
|
||||
Get parsed text content for a file.
|
||||
|
||||
**Arguments:**
|
||||
1. `uploaderID` (string) - The uploader/manager ID
|
||||
2. `fileID` (string) - The file ID
|
||||
3. `fullContent` (bool, optional) - Whether to get full content (default: false, returns preview)
|
||||
|
||||
**Returns:** `string` - Text content
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ var systemUploaders = map[string]string{
|
|||
|
||||
// Load load uploaders
|
||||
func Load(cfg config.Config) error {
|
||||
// Register attachment processes
|
||||
Init()
|
||||
|
||||
messages := []string{}
|
||||
|
||||
// Load system uploaders
|
||||
|
|
|
|||
658
attachment/process.go
Normal file
658
attachment/process.go
Normal file
|
|
@ -0,0 +1,658 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/kun/any"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
)
|
||||
|
||||
// Init registers all attachment processes
|
||||
func Init() {
|
||||
process.RegisterGroup("attachment", map[string]process.Handler{
|
||||
"Save": processSave,
|
||||
"Read": processRead,
|
||||
"Info": processInfo,
|
||||
"List": processList,
|
||||
"Delete": processDelete,
|
||||
"Exists": processExists,
|
||||
"URL": processURL,
|
||||
"SaveText": processSaveText,
|
||||
"GetText": processGetText,
|
||||
})
|
||||
}
|
||||
|
||||
// processSave saves a file from base64 data URI
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - content: string - base64 data URI (e.g., "data:image/png;base64,xxxx") or plain base64
|
||||
// - filename: string (optional) - original filename
|
||||
// - option: map (optional) - upload options (groups, gzip, compress_image, public, share)
|
||||
//
|
||||
// Returns: *File - uploaded file info
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// Process("attachment.Save", "default", "data:image/png;base64,iVBORw0KGgo...", "photo.png")
|
||||
// Process("attachment.Save", "default", "data:text/plain;base64,SGVsbG8=", "hello.txt", {"share": "team"})
|
||||
func processSave(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
content := p.ArgsString(1)
|
||||
|
||||
// Get manager
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
// Parse data URI and decode content
|
||||
contentType, data, err := parseDataURI(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse content: %v", err)
|
||||
}
|
||||
|
||||
// Get filename from args or generate from content type
|
||||
filename := ""
|
||||
if p.NumOfArgs() > 2 {
|
||||
filename = p.ArgsString(2)
|
||||
}
|
||||
if filename == "" {
|
||||
filename = generateFilename(contentType)
|
||||
}
|
||||
|
||||
// Create file header
|
||||
header := createFileHeader(filename, contentType, int64(len(data)))
|
||||
|
||||
// Create upload options
|
||||
option := createUploadOption(p, filename)
|
||||
|
||||
// Upload
|
||||
ctx := context.Background()
|
||||
file, err := manager.Upload(ctx, header, strings.NewReader(string(data)), option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save file: %v", err)
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
// processRead reads file content as base64 data URI
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - fileID: string - the file ID
|
||||
//
|
||||
// Returns: string - base64 data URI (e.g., "data:image/png;base64,xxxx")
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// const dataURI = Process("attachment.Read", "default", "abc123")
|
||||
func processRead(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
fileID := p.ArgsString(1)
|
||||
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get file info for content type and permission check
|
||||
fileInfo, err := manager.Info(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Check permission
|
||||
if err := checkFilePermission(p, fileInfo, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read content as base64
|
||||
base64Data, err := manager.ReadBase64(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file: %v", err)
|
||||
}
|
||||
|
||||
// Return as data URI
|
||||
return fmt.Sprintf("data:%s;base64,%s", fileInfo.ContentType, base64Data)
|
||||
}
|
||||
|
||||
// processInfo gets file information
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - fileID: string - the file ID
|
||||
//
|
||||
// Returns: *File - file info
|
||||
func processInfo(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
fileID := p.ArgsString(1)
|
||||
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
fileInfo, err := manager.Info(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Check permission
|
||||
if err := checkFilePermission(p, fileInfo, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fileInfo
|
||||
}
|
||||
|
||||
// processList lists files with pagination and filtering
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - option: map (optional) - list options (page, page_size, filters, order_by, select)
|
||||
//
|
||||
// Returns: *ListResult - paginated file list
|
||||
func processList(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(1)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
// Parse list options
|
||||
listOption := ListOption{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
}
|
||||
|
||||
if p.NumOfArgs() > 1 {
|
||||
optionRaw := p.ArgsMap(1)
|
||||
option := maps.MapOf(optionRaw).Dot()
|
||||
|
||||
if page := any.Of(option.Get("page")).CInt(); page > 0 {
|
||||
listOption.Page = page
|
||||
}
|
||||
if pageSize := any.Of(option.Get("page_size")).CInt(); pageSize > 0 && pageSize <= 100 {
|
||||
listOption.PageSize = pageSize
|
||||
}
|
||||
if filters, ok := option.Get("filters").(map[string]interface{}); ok {
|
||||
listOption.Filters = filters
|
||||
}
|
||||
if orderBy, ok := option.Get("order_by").(string); ok {
|
||||
listOption.OrderBy = orderBy
|
||||
}
|
||||
if selectFields, ok := option.Get("select").([]interface{}); ok {
|
||||
for _, field := range selectFields {
|
||||
if f, ok := field.(string); ok {
|
||||
listOption.Select = append(listOption.Select, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always filter by uploader
|
||||
if listOption.Filters == nil {
|
||||
listOption.Filters = make(map[string]interface{})
|
||||
}
|
||||
listOption.Filters["uploader"] = uploaderID
|
||||
|
||||
// Add permission-based filtering
|
||||
listOption.Wheres = append(listOption.Wheres, model.QueryWhere{
|
||||
Column: "uploader",
|
||||
Value: uploaderID,
|
||||
})
|
||||
listOption.Wheres = append(listOption.Wheres, buildPermissionWheres(p)...)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.List(ctx, listOption)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list files: %v", err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// processDelete deletes a file
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - fileID: string - the file ID
|
||||
//
|
||||
// Returns: bool - success
|
||||
func processDelete(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
fileID := p.ArgsString(1)
|
||||
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get file info first
|
||||
fileInfo, err := manager.Info(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Check write permission
|
||||
if err := checkFilePermission(p, fileInfo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete file
|
||||
if err := manager.Delete(ctx, fileID); err != nil {
|
||||
return fmt.Errorf("failed to delete file: %v", err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// processExists checks if file exists
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - fileID: string - the file ID
|
||||
//
|
||||
// Returns: bool
|
||||
func processExists(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
fileID := p.ArgsString(1)
|
||||
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return manager.Exists(ctx, fileID)
|
||||
}
|
||||
|
||||
// processURL gets file URL
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - fileID: string - the file ID
|
||||
//
|
||||
// Returns: string - file URL
|
||||
func processURL(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
fileID := p.ArgsString(1)
|
||||
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get file info for permission check
|
||||
fileInfo, err := manager.Info(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Check permission
|
||||
if err := checkFilePermission(p, fileInfo, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return manager.storage.URL(ctx, fileID)
|
||||
}
|
||||
|
||||
// processSaveText saves parsed text content for a file
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - fileID: string - the file ID
|
||||
// - text: string - the text content to save
|
||||
//
|
||||
// Returns: bool - success
|
||||
func processSaveText(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(3)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
fileID := p.ArgsString(1)
|
||||
text := p.ArgsString(2)
|
||||
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get file info first to check write permission
|
||||
fileInfo, err := manager.Info(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Check write permission
|
||||
if err := checkFilePermission(p, fileInfo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manager.SaveText(ctx, fileID, text); err != nil {
|
||||
return fmt.Errorf("failed to save text: %v", err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// processGetText gets parsed text content for a file
|
||||
// Args:
|
||||
// - uploaderID: string - the uploader/manager ID
|
||||
// - fileID: string - the file ID
|
||||
// - fullContent: bool (optional) - whether to get full content (default: false, returns preview)
|
||||
//
|
||||
// Returns: string - text content
|
||||
func processGetText(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
|
||||
uploaderID := p.ArgsString(0)
|
||||
fileID := p.ArgsString(1)
|
||||
|
||||
fullContent := false
|
||||
if p.NumOfArgs() > 2 {
|
||||
fullContent = p.ArgsBool(2)
|
||||
}
|
||||
|
||||
manager, exists := Managers[uploaderID]
|
||||
if !exists {
|
||||
return fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get file info for permission check
|
||||
fileInfo, err := manager.Info(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Check permission
|
||||
if err := checkFilePermission(p, fileInfo, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
text, err := manager.GetText(ctx, fileID, fullContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get text: %v", err)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// ============ Helper Functions ============
|
||||
|
||||
// parseDataURI parses content as either:
|
||||
// 1. Data URI format: data:image/png;base64,xxxxx (decoded from base64)
|
||||
// 2. Plain text: stored as-is with text/plain content type
|
||||
//
|
||||
// Returns content type, data bytes, and error
|
||||
func parseDataURI(content string) (string, []byte, error) {
|
||||
// Handle data URI format: data:image/png;base64,xxxxx
|
||||
if strings.HasPrefix(content, "data:") {
|
||||
// Split by comma to get the data part
|
||||
parts := strings.SplitN(content, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", nil, fmt.Errorf("invalid data URI format")
|
||||
}
|
||||
|
||||
// Parse the header: data:image/png;base64
|
||||
header := parts[0]
|
||||
base64Content := parts[1]
|
||||
|
||||
// Extract content type from header
|
||||
contentType := "application/octet-stream"
|
||||
header = strings.TrimPrefix(header, "data:")
|
||||
headerParts := strings.Split(header, ";")
|
||||
if len(headerParts) > 0 && headerParts[0] != "" {
|
||||
contentType = headerParts[0]
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
data, err := base64.StdEncoding.DecodeString(base64Content)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to decode base64: %v", err)
|
||||
}
|
||||
|
||||
return contentType, data, nil
|
||||
}
|
||||
|
||||
// Plain text content - store as-is
|
||||
return "text/plain", []byte(content), nil
|
||||
}
|
||||
|
||||
// generateFilename generates a filename based on content type
|
||||
func generateFilename(contentType string) string {
|
||||
// Get extension from content type
|
||||
exts, err := mime.ExtensionsByType(contentType)
|
||||
if err == nil && len(exts) > 0 {
|
||||
return "file" + exts[0]
|
||||
}
|
||||
|
||||
// Fallback for common types
|
||||
switch contentType {
|
||||
case "image/png":
|
||||
return "file.png"
|
||||
case "image/jpeg":
|
||||
return "file.jpg"
|
||||
case "image/gif":
|
||||
return "file.gif"
|
||||
case "image/webp":
|
||||
return "file.webp"
|
||||
case "text/plain":
|
||||
return "file.txt"
|
||||
case "application/pdf":
|
||||
return "file.pdf"
|
||||
case "application/json":
|
||||
return "file.json"
|
||||
default:
|
||||
return "file.bin"
|
||||
}
|
||||
}
|
||||
|
||||
// createUploadOption creates UploadOption from process args
|
||||
func createUploadOption(p *process.Process, filename string) UploadOption {
|
||||
option := UploadOption{
|
||||
OriginalFilename: filename,
|
||||
}
|
||||
|
||||
// Parse option from fourth argument if provided
|
||||
if p.NumOfArgs() > 3 {
|
||||
optionRaw := p.ArgsMap(3)
|
||||
optionMap := maps.MapOf(optionRaw).Dot()
|
||||
|
||||
// Groups
|
||||
if groups, ok := optionMap.Get("groups").([]interface{}); ok {
|
||||
for _, g := range groups {
|
||||
if gs, ok := g.(string); ok {
|
||||
option.Groups = append(option.Groups, gs)
|
||||
}
|
||||
}
|
||||
} else if groupsStr, ok := optionMap.Get("groups").(string); ok {
|
||||
option.Groups = strings.Split(groupsStr, ",")
|
||||
for i := range option.Groups {
|
||||
option.Groups[i] = strings.TrimSpace(option.Groups[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Gzip
|
||||
if gzip, ok := optionMap.Get("gzip").(bool); ok {
|
||||
option.Gzip = gzip
|
||||
}
|
||||
|
||||
// Compress image
|
||||
if compress, ok := optionMap.Get("compress_image").(bool); ok {
|
||||
option.CompressImage = compress
|
||||
}
|
||||
if size := any.Of(optionMap.Get("compress_size")).CInt(); size > 0 {
|
||||
option.CompressSize = size
|
||||
}
|
||||
|
||||
// Public/Share
|
||||
if public, ok := optionMap.Get("public").(bool); ok {
|
||||
option.Public = public
|
||||
}
|
||||
if share, ok := optionMap.Get("share").(string); ok {
|
||||
option.Share = share
|
||||
}
|
||||
}
|
||||
|
||||
// Set permission fields from process.Authorized
|
||||
if p.Authorized != nil {
|
||||
option.YaoCreatedBy = p.Authorized.UserID
|
||||
option.YaoTeamID = p.Authorized.TeamID
|
||||
option.YaoTenantID = p.Authorized.TenantID
|
||||
}
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
// createFileHeader creates a FileHeader from parameters
|
||||
func createFileHeader(filename, contentType string, size int64) *FileHeader {
|
||||
header := &multipart.FileHeader{
|
||||
Filename: filename,
|
||||
Size: size,
|
||||
Header: make(textproto.MIMEHeader),
|
||||
}
|
||||
header.Header.Set("Content-Type", contentType)
|
||||
|
||||
// Set extension from filename
|
||||
if ext := filepath.Ext(filename); ext != "" {
|
||||
header.Header.Set("Content-Extension", ext)
|
||||
}
|
||||
|
||||
return &FileHeader{FileHeader: header}
|
||||
}
|
||||
|
||||
// checkFilePermission checks if user has permission to access the file
|
||||
// readable: true for read permission, false for write permission
|
||||
func checkFilePermission(p *process.Process, fileInfo *File, readable bool) error {
|
||||
auth := p.Authorized
|
||||
|
||||
// No auth info - allow access (for non-authenticated operations)
|
||||
if auth == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// No constraints - allow access
|
||||
if !auth.Constraints.TeamOnly && !auth.Constraints.OwnerOnly {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Public files are readable by everyone
|
||||
if readable && fileInfo.Public {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Combined Team and Owner permission validation
|
||||
if auth.Constraints.TeamOnly && auth.Constraints.OwnerOnly {
|
||||
if fileInfo.YaoCreatedBy == auth.UserID && fileInfo.YaoTeamID == auth.TeamID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Owner only permission validation
|
||||
if auth.Constraints.OwnerOnly {
|
||||
if fileInfo.YaoCreatedBy != "" && fileInfo.YaoCreatedBy == auth.UserID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Team only permission validation
|
||||
if auth.Constraints.TeamOnly {
|
||||
switch fileInfo.Share {
|
||||
case "team":
|
||||
if fileInfo.YaoTeamID == auth.TeamID {
|
||||
return nil
|
||||
}
|
||||
case "private":
|
||||
if fileInfo.YaoCreatedBy == auth.UserID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("forbidden: no permission to access file")
|
||||
}
|
||||
|
||||
// buildPermissionWheres builds where clauses for permission filtering
|
||||
func buildPermissionWheres(p *process.Process) []model.QueryWhere {
|
||||
auth := p.Authorized
|
||||
if auth == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// No constraints - no additional filtering needed
|
||||
if !auth.Constraints.TeamOnly && !auth.Constraints.OwnerOnly {
|
||||
return nil
|
||||
}
|
||||
|
||||
var wheres []model.QueryWhere
|
||||
|
||||
// Team only - User can access:
|
||||
// 1. Public files (public = true)
|
||||
// 2. Files in their team where:
|
||||
// - They uploaded the file (__yao_created_by matches)
|
||||
// - OR the file is shared with team (share = "team")
|
||||
if auth.Constraints.TeamOnly && auth.TeamID != "" {
|
||||
wheres = append(wheres, model.QueryWhere{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "public", Value: true, Method: "orwhere"},
|
||||
{Wheres: []model.QueryWhere{
|
||||
{Column: "__yao_team_id", Value: auth.TeamID},
|
||||
{Wheres: []model.QueryWhere{
|
||||
{Column: "__yao_created_by", Value: auth.UserID},
|
||||
{Column: "share", Value: "team", Method: "orwhere"},
|
||||
}},
|
||||
}, Method: "orwhere"},
|
||||
},
|
||||
})
|
||||
return wheres
|
||||
}
|
||||
|
||||
// Owner only - User can access:
|
||||
// 1. Public files (public = true)
|
||||
// 2. Files they uploaded where:
|
||||
// - __yao_team_id is null (not team files)
|
||||
// - __yao_created_by matches their user ID
|
||||
if auth.Constraints.OwnerOnly && auth.UserID != "" {
|
||||
wheres = append(wheres, model.QueryWhere{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "public", Value: true, Method: "orwhere"},
|
||||
{Wheres: []model.QueryWhere{
|
||||
{Column: "__yao_team_id", OP: "null"},
|
||||
{Column: "__yao_created_by", Value: auth.UserID},
|
||||
}, Method: "orwhere"},
|
||||
},
|
||||
})
|
||||
return wheres
|
||||
}
|
||||
|
||||
return wheres
|
||||
}
|
||||
1132
attachment/process_test.go
Normal file
1132
attachment/process_test.go
Normal file
File diff suppressed because it is too large
Load diff
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=
|
||||
|
|
|
|||
|
|
@ -150,37 +150,92 @@ func (agent *Agent) WithSid(sid string) {
|
|||
agent.DSL.Sid = sid
|
||||
}
|
||||
|
||||
// getAssistants get all assistant directories that have pages
|
||||
// getAssistants get all assistant directories that have pages (supports nested assistants)
|
||||
// Returns assistant IDs like: ["expense", "tasks", "tests.nested.demo"]
|
||||
// Nested paths are joined with "." to form the assistant ID
|
||||
func (agent *Agent) getAssistants() ([]string, error) {
|
||||
if !agent.fs.IsDir(agent.assistantsRoot) {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
dirs, err := agent.fs.ReadDir(agent.assistantsRoot, false)
|
||||
assistants := []string{}
|
||||
err := agent.scanAssistantsRecursive(agent.assistantsRoot, "", &assistants)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assistants := []string{}
|
||||
for _, dir := range dirs {
|
||||
if !agent.fs.IsDir(dir) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this assistant has a pages directory
|
||||
pagesDir := filepath.Join(dir, "pages")
|
||||
if agent.fs.IsDir(pagesDir) {
|
||||
name := filepath.Base(dir)
|
||||
assistants = append(assistants, name)
|
||||
}
|
||||
}
|
||||
|
||||
return assistants, nil
|
||||
}
|
||||
|
||||
// scanAssistantsRecursive recursively scans directories for assistants with pages
|
||||
// prefix is the accumulated path prefix (e.g., "tests.nested")
|
||||
func (agent *Agent) scanAssistantsRecursive(dir string, prefix string, assistants *[]string) error {
|
||||
dirs, err := agent.fs.ReadDir(dir, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, subdir := range dirs {
|
||||
if !agent.fs.IsDir(subdir) {
|
||||
continue
|
||||
}
|
||||
|
||||
name := filepath.Base(subdir)
|
||||
// Skip hidden directories and special directories
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "__") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build the assistant ID with prefix
|
||||
assistantID := name
|
||||
if prefix != "" {
|
||||
assistantID = prefix + "." + name
|
||||
}
|
||||
|
||||
// Check if this directory has a pages subdirectory
|
||||
pagesDir := filepath.Join(subdir, "pages")
|
||||
if agent.fs.IsDir(pagesDir) {
|
||||
*assistants = append(*assistants, assistantID)
|
||||
}
|
||||
|
||||
// Recursively scan subdirectories for nested assistants
|
||||
// Only scan if there's no pages directory (to avoid scanning inside pages/)
|
||||
// or if there are other subdirectories that might contain nested assistants
|
||||
if !agent.fs.IsDir(pagesDir) {
|
||||
err := agent.scanAssistantsRecursive(subdir, assistantID, assistants)
|
||||
if err != nil {
|
||||
log.Warn("[Agent] Error scanning subdirectory %s: %v", subdir, err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Even if this has pages, check for nested assistants in other subdirectories
|
||||
subdirs, _ := agent.fs.ReadDir(subdir, false)
|
||||
for _, nested := range subdirs {
|
||||
nestedName := filepath.Base(nested)
|
||||
if agent.fs.IsDir(nested) && nestedName != "pages" &&
|
||||
!strings.HasPrefix(nestedName, ".") &&
|
||||
!strings.HasPrefix(nestedName, "__") {
|
||||
err := agent.scanAssistantsRecursive(nested, assistantID+"."+nestedName, assistants)
|
||||
if err != nil {
|
||||
log.Warn("[Agent] Error scanning nested directory %s: %v", nested, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAssistantPagesRoot get the pages root for an assistant
|
||||
// assistantID can be "expense" or "tests.nested.demo"
|
||||
// Returns the actual filesystem path like "/assistants/tests/nested/demo/pages"
|
||||
func (agent *Agent) getAssistantPagesRoot(assistantID string) string {
|
||||
return filepath.Join(agent.assistantsRoot, assistantID, "pages")
|
||||
// Convert dot notation to path: "tests.nested.demo" -> "tests/nested/demo"
|
||||
pathParts := strings.Split(assistantID, ".")
|
||||
assistantPath := filepath.Join(pathParts...)
|
||||
return filepath.Join(agent.assistantsRoot, assistantPath, "pages")
|
||||
}
|
||||
|
||||
// Exists check if the agent storage is available
|
||||
|
|
@ -192,7 +247,7 @@ func Exists() bool {
|
|||
return appFS.IsDir("/agent/template")
|
||||
}
|
||||
|
||||
// HasAssistantPages check if any assistant has pages
|
||||
// HasAssistantPages check if any assistant has pages (supports nested assistants)
|
||||
func HasAssistantPages() bool {
|
||||
appFS, err := fs.Get("app")
|
||||
if err != nil {
|
||||
|
|
@ -203,19 +258,37 @@ func HasAssistantPages() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
dirs, err := appFS.ReadDir("/assistants", false)
|
||||
return hasAssistantPagesRecursive(appFS, "/assistants")
|
||||
}
|
||||
|
||||
// hasAssistantPagesRecursive recursively checks for assistants with pages
|
||||
func hasAssistantPagesRecursive(appFS fs.FileSystem, dir string) bool {
|
||||
dirs, err := appFS.ReadDir(dir, false)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if !appFS.IsDir(dir) {
|
||||
for _, subdir := range dirs {
|
||||
if !appFS.IsDir(subdir) {
|
||||
continue
|
||||
}
|
||||
pagesDir := filepath.Join(dir, "pages")
|
||||
|
||||
name := filepath.Base(subdir)
|
||||
// Skip hidden directories and special directories
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "__") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this directory has a pages subdirectory
|
||||
pagesDir := filepath.Join(subdir, "pages")
|
||||
if appFS.IsDir(pagesDir) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Recursively check subdirectories
|
||||
if hasAssistantPagesRecursive(appFS, subdir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
|
|
|||
214
sui/storages/agent/agent_test.go
Normal file
214
sui/storages/agent/agent_test.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/sui/core"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
func TestAgentExists(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
exists := Exists()
|
||||
assert.True(t, exists, "Agent template should exist")
|
||||
}
|
||||
|
||||
func TestHasAssistantPages(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
hasPages := HasAssistantPages()
|
||||
assert.True(t, hasPages, "Should have assistant pages")
|
||||
}
|
||||
|
||||
func TestGetAssistants(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
agent := createAgent(t)
|
||||
assistants, err := agent.getAssistants()
|
||||
assert.Nil(t, err)
|
||||
assert.NotEmpty(t, assistants)
|
||||
|
||||
// Sort for consistent comparison
|
||||
sort.Strings(assistants)
|
||||
|
||||
// Should include both direct and nested assistants
|
||||
// Direct: tests.sui-pages (has pages directly)
|
||||
// Nested: tests.nested.demo (nested assistant with pages)
|
||||
found := map[string]bool{}
|
||||
for _, ast := range assistants {
|
||||
found[ast] = true
|
||||
}
|
||||
|
||||
assert.True(t, found["tests.sui-pages"], "Should find tests.sui-pages assistant")
|
||||
assert.True(t, found["tests.nested.demo"], "Should find tests.nested.demo assistant")
|
||||
}
|
||||
|
||||
func TestGetAssistantPagesRoot(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
agent := createAgent(t)
|
||||
|
||||
// Test direct assistant
|
||||
root := agent.getAssistantPagesRoot("tests.sui-pages")
|
||||
assert.Equal(t, "/assistants/tests/sui-pages/pages", root)
|
||||
|
||||
// Test nested assistant
|
||||
root = agent.getAssistantPagesRoot("tests.nested.demo")
|
||||
assert.Equal(t, "/assistants/tests/nested/demo/pages", root)
|
||||
}
|
||||
|
||||
func TestGetTemplate(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
agent := createAgent(t)
|
||||
tmpl, err := agent.GetTemplate("agent")
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, tmpl)
|
||||
assert.Equal(t, "agent", tmpl.(*Template).ID)
|
||||
}
|
||||
|
||||
func TestTemplatePages(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
agent := createAgent(t)
|
||||
tmpl, err := agent.GetTemplate("agent")
|
||||
assert.Nil(t, err)
|
||||
|
||||
pages, err := tmpl.Pages()
|
||||
assert.Nil(t, err)
|
||||
assert.NotEmpty(t, pages)
|
||||
|
||||
// Check that we have pages from nested assistants
|
||||
routes := map[string]bool{}
|
||||
for _, page := range pages {
|
||||
routes[page.Get().Route] = true
|
||||
}
|
||||
|
||||
// Should have pages from:
|
||||
// 1. Agent global pages (/index)
|
||||
// 2. Direct assistant (tests.sui-pages) -> /tests.sui-pages/dashboard
|
||||
// 3. Nested assistant (tests.nested.demo) -> /tests.nested.demo/article
|
||||
assert.True(t, routes["/index"], "Should have agent global page /index")
|
||||
assert.True(t, routes["/tests.sui-pages/dashboard"], "Should have direct assistant page /tests.sui-pages/dashboard")
|
||||
assert.True(t, routes["/tests.nested.demo/article"], "Should have nested assistant page /tests.nested.demo/article")
|
||||
}
|
||||
|
||||
func TestTemplatePage(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
agent := createAgent(t)
|
||||
tmpl, err := agent.GetTemplate("agent")
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Test getting agent global page
|
||||
page, err := tmpl.Page("/index")
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, page)
|
||||
assert.Equal(t, "/index", page.Get().Route)
|
||||
|
||||
// Test getting direct assistant page
|
||||
page, err = tmpl.Page("/tests.sui-pages/dashboard")
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, page)
|
||||
assert.Equal(t, "/tests.sui-pages/dashboard", page.Get().Route)
|
||||
assert.Equal(t, "tests.sui-pages", page.(*Page).assistantID)
|
||||
|
||||
// Test getting nested assistant page
|
||||
page, err = tmpl.Page("/tests.nested.demo/article")
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, page)
|
||||
assert.Equal(t, "/tests.nested.demo/article", page.Get().Route)
|
||||
assert.Equal(t, "tests.nested.demo", page.(*Page).assistantID)
|
||||
|
||||
// Test page not found
|
||||
_, err = tmpl.Page("/non-existent/page")
|
||||
assert.NotNil(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestPageLoad(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
agent := createAgent(t)
|
||||
tmpl, err := agent.GetTemplate("agent")
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Test loading nested assistant page
|
||||
page, err := tmpl.Page("/tests.nested.demo/article")
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = page.Load()
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Check that content was loaded
|
||||
p := page.Get()
|
||||
assert.NotEmpty(t, p.Codes.HTML.Code, "HTML code should be loaded")
|
||||
assert.NotEmpty(t, p.Codes.CSS.Code, "CSS code should be loaded")
|
||||
}
|
||||
|
||||
func TestPageBuild(t *testing.T) {
|
||||
prepare(t)
|
||||
defer clean()
|
||||
|
||||
agent := createAgent(t)
|
||||
|
||||
// Register the agent SUI so page build can find it
|
||||
core.SUIs["agent"] = agent
|
||||
|
||||
tmpl, err := agent.GetTemplate("agent")
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Test building nested assistant page
|
||||
page, err := tmpl.Page("/tests.nested.demo/article")
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = page.Load()
|
||||
assert.Nil(t, err)
|
||||
|
||||
ctx := core.NewGlobalBuildContext(tmpl)
|
||||
warnings, err := page.Build(ctx, &core.BuildOption{
|
||||
PublicRoot: "/agents",
|
||||
AssetRoot: "/agents/assets",
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
|
||||
func prepare(t *testing.T) {
|
||||
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
|
||||
}
|
||||
|
||||
func clean() {
|
||||
test.Clean()
|
||||
}
|
||||
|
||||
func createAgent(t *testing.T) *Agent {
|
||||
dsl := &core.DSL{
|
||||
ID: "agent",
|
||||
Name: "Agent",
|
||||
Public: &core.Public{
|
||||
Root: "/agents",
|
||||
Host: "/",
|
||||
Index: "/index",
|
||||
},
|
||||
}
|
||||
|
||||
agent, err := New(dsl)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create agent: %v", err)
|
||||
}
|
||||
|
||||
return agent
|
||||
}
|
||||
|
|
@ -142,6 +142,11 @@ func (tmpl *Template) getPageBase(route string) string {
|
|||
}
|
||||
|
||||
// Page get a specific page by route
|
||||
// Route format: "/assistant-id/page-path" where assistant-id can contain dots for nested assistants
|
||||
// Examples:
|
||||
// - "/expense/test" -> assistant "expense", page "/test"
|
||||
// - "/tests.nested.demo/article" -> assistant "tests.nested.demo", page "/article"
|
||||
// - "/index" -> agent page (no assistant prefix)
|
||||
func (tmpl *Template) Page(route string) (core.IPage, error) {
|
||||
// Parse the route to determine if it's an assistant page or agent page
|
||||
parts := strings.Split(strings.Trim(route, "/"), "/")
|
||||
|
|
@ -150,7 +155,7 @@ func (tmpl *Template) Page(route string) (core.IPage, error) {
|
|||
return nil, fmt.Errorf("Invalid route: %s", route)
|
||||
}
|
||||
|
||||
// Check if first part is an assistant ID
|
||||
// Check if first part is an assistant ID (may contain dots for nested assistants)
|
||||
assistants, err := tmpl.agent.getAssistants()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -160,6 +165,8 @@ func (tmpl *Template) Page(route string) (core.IPage, error) {
|
|||
pageRoute := route
|
||||
pagesRoot := filepath.Join(tmpl.agent.root, "pages")
|
||||
|
||||
// The first part of the route might be an assistant ID
|
||||
// Assistant IDs can contain dots (e.g., "tests.nested.demo")
|
||||
for _, ast := range assistants {
|
||||
if parts[0] == ast {
|
||||
assistantID = ast
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue