Merge pull request #1430 from trheyi/main

Enhance Agent and LLM APIs with Agent-to-Agent Communication
This commit is contained in:
Max 2026-01-25 20:57:33 +08:00 committed by GitHub
commit 38bf5fa22b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 7354 additions and 30 deletions

View file

@ -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)

View 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
View 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
View 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)
}

View 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 ""
}

View 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
View 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,
}
}

View 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)
}

View file

@ -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`.

View file

@ -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 {

View file

@ -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

View 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)
}

View 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{}{}
}

View 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
View 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)
}

View 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")
}

View file

@ -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

View file

@ -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 {

View file

@ -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
View 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
View 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

View file

@ -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
}

View file

@ -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 {

View file

@ -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

View file

@ -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
View 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

File diff suppressed because it is too large Load diff

4
go.mod
View file

@ -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
View file

@ -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=

View file

@ -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

View 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
}

View file

@ -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