Refactor Assistant's Stream method to support Next hook responses
- Updated the Stream method to return a flexible final response, accommodating both standard and Next hook responses. - Introduced NextProcessContext to encapsulate context for processing Next hook responses, enhancing clarity and maintainability. - Removed deprecated Done and Failback hooks, streamlining the hook management process. - Enhanced error handling and logging for improved traceability during streaming operations.
This commit is contained in:
parent
9acf6b64e1
commit
3bfd40b701
12 changed files with 1396 additions and 84 deletions
|
|
@ -7,7 +7,6 @@ import (
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/kun/utils"
|
|
||||||
"github.com/yaoapp/yao/agent/assistant/handlers"
|
"github.com/yaoapp/yao/agent/assistant/handlers"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -16,7 +15,7 @@ import (
|
||||||
|
|
||||||
// Stream stream the agent
|
// Stream stream the agent
|
||||||
// handler is optional, if not provided, a default handler will be used
|
// handler is optional, if not provided, a default handler will be used
|
||||||
func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Message, handler ...message.StreamFunc) (*context.Response, error) {
|
func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Message, handler ...message.StreamFunc) (interface{}, error) {
|
||||||
|
|
||||||
log.Trace("[AGENT] Stream started: assistant=%s, contextID=%s", ast.ID, ctx.ID)
|
log.Trace("[AGENT] Stream started: assistant=%s, contextID=%s", ast.ID, ctx.ID)
|
||||||
defer log.Trace("[AGENT] Stream ended: assistant=%s, contextID=%s", ast.ID, ctx.ID)
|
defer log.Trace("[AGENT] Stream ended: assistant=%s, contextID=%s", ast.ID, ctx.ID)
|
||||||
|
|
@ -113,51 +112,43 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Debug Completion Response ===
|
|
||||||
fmt.Println("--- Debug Completion Response ----------------------")
|
|
||||||
fmt.Printf("completionResponse: %+v\n", completionResponse)
|
|
||||||
if completionResponse != nil {
|
|
||||||
fmt.Printf("ToolCalls: %+v\n", completionResponse.ToolCalls)
|
|
||||||
}
|
|
||||||
fmt.Println("----------------------------------------------------")
|
|
||||||
// === End Debug ===
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================================================
|
// ================================================
|
||||||
// Execute tool calls with retry
|
// Execute tool calls with retry
|
||||||
// ================================================
|
// ================================================
|
||||||
|
var toolCallResponses []context.ToolCallResponse = nil
|
||||||
if completionResponse != nil && completionResponse.ToolCalls != nil {
|
if completionResponse != nil && completionResponse.ToolCalls != nil {
|
||||||
|
|
||||||
// === Debug Tool Calls ===
|
|
||||||
fmt.Println("--- Debug Tool Calls --------------------------------")
|
|
||||||
utils.Dump(completionResponse.ToolCalls)
|
|
||||||
|
|
||||||
// === End Debug Tool Calls ===
|
|
||||||
|
|
||||||
maxToolRetries := 3
|
maxToolRetries := 3
|
||||||
currentMessages := completionMessages
|
currentMessages := completionMessages
|
||||||
currentResponse := completionResponse
|
currentResponse := completionResponse
|
||||||
|
|
||||||
for attempt := 0; attempt < maxToolRetries; attempt++ {
|
for attempt := 0; attempt < maxToolRetries; attempt++ {
|
||||||
|
|
||||||
fmt.Println("attempt", attempt)
|
|
||||||
// Execute all tool calls
|
// Execute all tool calls
|
||||||
toolResults, hasErrors := ast.executeToolCalls(ctx, currentResponse.ToolCalls, attempt)
|
toolResults, hasErrors := ast.executeToolCalls(ctx, currentResponse.ToolCalls, attempt)
|
||||||
|
|
||||||
|
// Convert toolResults to toolCallResponses
|
||||||
|
toolCallResponses = make([]context.ToolCallResponse, len(toolResults))
|
||||||
|
for i, result := range toolResults {
|
||||||
|
parsedContent, _ := result.ParsedContent()
|
||||||
|
toolCallResponses[i] = context.ToolCallResponse{
|
||||||
|
ToolCallID: result.ToolCallID,
|
||||||
|
Server: result.Server(),
|
||||||
|
Tool: result.Tool(),
|
||||||
|
Arguments: nil,
|
||||||
|
Result: parsedContent,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
if result.Error != nil {
|
||||||
|
toolCallResponses[i].Error = result.Error.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If all successful, break out
|
// If all successful, break out
|
||||||
if !hasErrors {
|
if !hasErrors {
|
||||||
log.Trace("[AGENT] All tool calls succeeded (attempt %d)", attempt)
|
log.Trace("[AGENT] All tool calls succeeded (attempt %d)", attempt)
|
||||||
for _, result := range toolResults {
|
|
||||||
fmt.Println("--")
|
|
||||||
fmt.Printf("Result :%s %s %s\n", result.ToolCallID, result.Server(), result.Tool())
|
|
||||||
res, err := result.ParsedContent()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error: ", err)
|
|
||||||
}
|
|
||||||
utils.Dump(res)
|
|
||||||
fmt.Println("--")
|
|
||||||
}
|
|
||||||
fmt.Println("--------------------------------")
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,23 +209,55 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
completionResponse = currentResponse
|
completionResponse = currentResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request Done hook ( Optional )
|
// ================================================
|
||||||
var doneResponse *context.ResponseHookDone
|
// Execute Next Hook and Process Response
|
||||||
|
// ================================================
|
||||||
|
var finalResponse interface{}
|
||||||
|
var nextResponse *context.NextHookResponse = nil
|
||||||
|
|
||||||
if ast.Script != nil {
|
if ast.Script != nil {
|
||||||
var err error
|
var err error
|
||||||
doneResponse, err = ast.Script.Done(ctx, fullMessages, completionResponse, nil)
|
nextResponse, err = ast.Script.Next(ctx, &context.NextHookPayload{
|
||||||
|
Messages: fullMessages,
|
||||||
|
Completion: completionResponse,
|
||||||
|
Tools: toolCallResponses,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ast.traceAgentFail(agentNode, err)
|
ast.traceAgentFail(agentNode, err)
|
||||||
// Send error stream_end for root stack
|
|
||||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process Next hook response
|
||||||
|
finalResponse, err = ast.processNextResponse(&NextProcessContext{
|
||||||
|
Context: ctx,
|
||||||
|
NextResponse: nextResponse,
|
||||||
|
CompletionResponse: completionResponse,
|
||||||
|
FullMessages: fullMessages,
|
||||||
|
ToolCallResponses: toolCallResponses,
|
||||||
|
StreamHandler: streamHandler,
|
||||||
|
CreateResponse: createResponse,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ast.traceAgentFail(agentNode, err)
|
||||||
|
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No Next hook: use standard response
|
||||||
|
finalResponse = ast.buildStandardResponse(&NextProcessContext{
|
||||||
|
Context: ctx,
|
||||||
|
NextResponse: nil,
|
||||||
|
CompletionResponse: completionResponse,
|
||||||
|
FullMessages: fullMessages,
|
||||||
|
ToolCallResponses: toolCallResponses,
|
||||||
|
StreamHandler: streamHandler,
|
||||||
|
CreateResponse: createResponse,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = doneResponse // doneResponse is available for further processing
|
|
||||||
|
|
||||||
// Set the output of the agent node
|
// Set the output of the agent node
|
||||||
ast.traceAgentOutput(agentNode, createResponse, doneResponse, completionResponse)
|
ast.traceAgentOutput(agentNode, createResponse, nextResponse, completionResponse)
|
||||||
|
|
||||||
// Only close output and send stream_end if this is the root call (entry point)
|
// Only close output and send stream_end if this is the root call (entry point)
|
||||||
// Nested calls (from MCP, hooks, etc.) should not close the output or send stream_end
|
// Nested calls (from MCP, hooks, etc.) should not close the output or send stream_end
|
||||||
|
|
@ -270,12 +293,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &context.Response{
|
// Return finalResponse which could be:
|
||||||
ContextID: ctx.ID,
|
// 1. Result from delegated agent call (already a Response)
|
||||||
RequestID: ctx.RequestID(),
|
// 2. Custom data from Next hook (wrapped in standard Response)
|
||||||
ChatID: ctx.ChatID,
|
// 3. Standard response
|
||||||
AssistantID: ast.ID,
|
return finalResponse, nil
|
||||||
Create: createResponse, Done: doneResponse, Completion: completionResponse}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConnector get the connector object, capabilities, and error with priority: createResponse > ctx > ast
|
// GetConnector get the connector object, capabilities, and error with priority: createResponse > ctx > ast
|
||||||
|
|
@ -453,21 +475,15 @@ func (ast *Assistant) sendStreamEndOnError(ctx *context.Context, handler message
|
||||||
// handleInterrupt handles the interrupt signal
|
// handleInterrupt handles the interrupt signal
|
||||||
// This is called by the interrupt listener when a signal is received
|
// This is called by the interrupt listener when a signal is received
|
||||||
func (ast *Assistant) handleInterrupt(ctx *context.Context, signal *context.InterruptSignal) error {
|
func (ast *Assistant) handleInterrupt(ctx *context.Context, signal *context.InterruptSignal) error {
|
||||||
fmt.Printf("=== Interrupt Received ===\n")
|
|
||||||
fmt.Printf("Assistant: %s\n", ast.ID)
|
|
||||||
fmt.Printf("Type: %s\n", signal.Type)
|
|
||||||
fmt.Printf("Messages: %d\n", len(signal.Messages))
|
|
||||||
fmt.Printf("Timestamp: %d\n", signal.Timestamp)
|
|
||||||
|
|
||||||
// Handle based on interrupt type
|
// Handle based on interrupt type
|
||||||
switch signal.Type {
|
switch signal.Type {
|
||||||
case context.InterruptForce:
|
case context.InterruptForce:
|
||||||
fmt.Println("Force interrupt: stopping current operations immediately...")
|
|
||||||
// Force interrupt: context is already cancelled in handleSignal
|
// Force interrupt: context is already cancelled in handleSignal
|
||||||
// LLM streaming will detect ctx.Interrupt.Context().Done() and stop
|
// LLM streaming will detect ctx.Interrupt.Context().Done() and stop
|
||||||
|
log.Trace("[AGENT] Force interrupt: stopping current operations immediately")
|
||||||
|
|
||||||
case context.InterruptGraceful:
|
case context.InterruptGraceful:
|
||||||
fmt.Println("Graceful interrupt: will process after current step completes...")
|
log.Trace("[AGENT] Graceful interrupt: will process after current step completes")
|
||||||
// Graceful interrupt: let current operation complete
|
// Graceful interrupt: let current operation complete
|
||||||
// The signal is stored in current/pending, can be checked at checkpoints
|
// The signal is stored in current/pending, can be checked at checkpoints
|
||||||
}
|
}
|
||||||
|
|
|
||||||
243
agent/assistant/agent_next_test.go
Normal file
243
agent/assistant/agent_next_test.go
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
package assistant_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/gou/plan"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAgentNextTestContext creates a test context
|
||||||
|
func newAgentNextTestContext(chatID, assistantID string) *context.Context {
|
||||||
|
return &context.Context{
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
ID: chatID,
|
||||||
|
Space: plan.NewMemorySharedSpace(),
|
||||||
|
ChatID: chatID,
|
||||||
|
AssistantID: assistantID,
|
||||||
|
Locale: "en-us",
|
||||||
|
Client: context.Client{
|
||||||
|
Type: "web",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
},
|
||||||
|
Referer: context.RefererAPI,
|
||||||
|
Accept: context.AcceptWebCUI,
|
||||||
|
IDGenerator: message.NewIDGenerator(), // Initialize ID generator
|
||||||
|
Metadata: make(map[string]interface{}),
|
||||||
|
Authorized: &types.AuthorizedInfo{
|
||||||
|
Subject: "test-user",
|
||||||
|
UserID: "test-123",
|
||||||
|
TenantID: "test-tenant",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentNextStandard tests agent with Next Hook returning nil (standard response)
|
||||||
|
func TestAgentNextStandard(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := newAgentNextTestContext("test-standard", "tests.realworld-next")
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: standard - Hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Stream(ctx, messages)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, response)
|
||||||
|
|
||||||
|
resp := response.(*context.Response)
|
||||||
|
assert.NotNil(t, resp.Completion)
|
||||||
|
assert.Nil(t, resp.Next)
|
||||||
|
|
||||||
|
// Verify response structure
|
||||||
|
assert.Equal(t, "tests.realworld-next", resp.AssistantID)
|
||||||
|
assert.NotEmpty(t, resp.ContextID)
|
||||||
|
assert.NotEmpty(t, resp.RequestID)
|
||||||
|
assert.NotEmpty(t, resp.TraceID)
|
||||||
|
assert.NotEmpty(t, resp.ChatID)
|
||||||
|
|
||||||
|
// Verify completion has content
|
||||||
|
assert.NotNil(t, resp.Completion.Content)
|
||||||
|
|
||||||
|
t.Log("✓ Standard response test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentNextCustomData tests agent with Next Hook returning custom data
|
||||||
|
func TestAgentNextCustomData(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := newAgentNextTestContext("test-custom", "tests.realworld-next")
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: custom_data - Give me info"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Stream(ctx, messages)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, response)
|
||||||
|
|
||||||
|
resp := response.(*context.Response)
|
||||||
|
assert.NotNil(t, resp.Completion)
|
||||||
|
assert.NotNil(t, resp.Next)
|
||||||
|
|
||||||
|
// Verify response structure
|
||||||
|
assert.Equal(t, "tests.realworld-next", resp.AssistantID)
|
||||||
|
assert.NotEmpty(t, resp.ContextID)
|
||||||
|
assert.NotEmpty(t, resp.RequestID)
|
||||||
|
assert.NotEmpty(t, resp.TraceID)
|
||||||
|
|
||||||
|
// Verify custom data structure (from scenarioCustomData)
|
||||||
|
// resp.Next contains the "data" field value from NextHookResponse
|
||||||
|
nextData, ok := resp.Next.(map[string]interface{})
|
||||||
|
assert.True(t, ok, "Next should be a map")
|
||||||
|
assert.Equal(t, "custom_response", nextData["type"])
|
||||||
|
assert.Equal(t, "This is a custom response from Next Hook", nextData["message"])
|
||||||
|
assert.NotEmpty(t, nextData["timestamp"])
|
||||||
|
assert.NotNil(t, nextData["message_count"])
|
||||||
|
|
||||||
|
t.Log("✓ Custom data test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentNextDelegate tests agent with Next Hook delegating to another agent
|
||||||
|
func TestAgentNextDelegate(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := newAgentNextTestContext("test-delegate", "tests.realworld-next")
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: delegate - Forward this"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Stream(ctx, messages)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, response)
|
||||||
|
|
||||||
|
resp := response.(*context.Response)
|
||||||
|
|
||||||
|
// Verify response structure
|
||||||
|
assert.NotEmpty(t, resp.AssistantID)
|
||||||
|
assert.NotEmpty(t, resp.ContextID)
|
||||||
|
assert.NotEmpty(t, resp.RequestID)
|
||||||
|
assert.NotEmpty(t, resp.TraceID)
|
||||||
|
|
||||||
|
// Verify completion (delegated agent should have returned completion)
|
||||||
|
assert.NotNil(t, resp.Completion)
|
||||||
|
assert.NotNil(t, resp.Completion.Content)
|
||||||
|
|
||||||
|
// Next should be from the delegated agent
|
||||||
|
// If delegated agent also has Next hook, it will be present
|
||||||
|
t.Logf("✓ Delegation test passed (delegated to: %s)", resp.AssistantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentNextConditional tests agent with conditional logic in Next Hook
|
||||||
|
func TestAgentNextConditional(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := newAgentNextTestContext("test-conditional", "tests.realworld-next")
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: conditional - Task completed"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Stream(ctx, messages)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, response)
|
||||||
|
|
||||||
|
resp := response.(*context.Response)
|
||||||
|
assert.NotNil(t, resp.Next)
|
||||||
|
|
||||||
|
// Verify response structure
|
||||||
|
assert.Equal(t, "tests.realworld-next", resp.AssistantID)
|
||||||
|
assert.NotEmpty(t, resp.ContextID)
|
||||||
|
assert.NotEmpty(t, resp.RequestID)
|
||||||
|
assert.NotEmpty(t, resp.TraceID)
|
||||||
|
|
||||||
|
// Verify conditional response structure (from scenarioConditional)
|
||||||
|
// resp.Next contains the "data" field value from NextHookResponse
|
||||||
|
nextData, ok := resp.Next.(map[string]interface{})
|
||||||
|
assert.True(t, ok, "Next should be a map")
|
||||||
|
assert.Equal(t, "Conditional analysis complete", nextData["message"])
|
||||||
|
assert.Contains(t, nextData, "action")
|
||||||
|
assert.Contains(t, nextData, "reason")
|
||||||
|
assert.Contains(t, nextData, "conditions")
|
||||||
|
|
||||||
|
// Verify action is one of the expected values
|
||||||
|
action, ok := nextData["action"].(string)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Contains(t, []string{"continue", "flag_for_review", "confirm_success", "summarize", "delegate"}, action)
|
||||||
|
|
||||||
|
t.Log("✓ Conditional logic test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentWithoutNextHook tests agent without Next Hook
|
||||||
|
func TestAgentWithoutNextHook(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.create")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := newAgentNextTestContext("test-no-next", "tests.create")
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "Hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Stream(ctx, messages)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, response)
|
||||||
|
|
||||||
|
resp := response.(*context.Response)
|
||||||
|
assert.Nil(t, resp.Next)
|
||||||
|
|
||||||
|
// Verify response structure
|
||||||
|
assert.Equal(t, "tests.create", resp.AssistantID)
|
||||||
|
assert.NotEmpty(t, resp.ContextID)
|
||||||
|
assert.NotEmpty(t, resp.RequestID)
|
||||||
|
assert.NotEmpty(t, resp.TraceID)
|
||||||
|
assert.NotEmpty(t, resp.ChatID)
|
||||||
|
|
||||||
|
// Verify completion
|
||||||
|
assert.NotNil(t, resp.Completion)
|
||||||
|
assert.NotNil(t, resp.Completion.Content)
|
||||||
|
|
||||||
|
t.Log("✓ No Next Hook test passed")
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
package hook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/yaoapp/yao/agent/context"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Done done hook
|
|
||||||
func (s *Script) Done(ctx *context.Context, inputMessages []context.Message, completionResponse *context.CompletionResponse, mcpResponse *context.ResponseHookMCP) (*context.ResponseHookDone, error) {
|
|
||||||
return &context.ResponseHookDone{}, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
package hook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/yaoapp/yao/agent/context"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Failback failback hook
|
|
||||||
func (s *Script) Failback(ctx *context.Context, inputMessages []context.Message, completionResponse *context.CompletionResponse) (*context.ResponseHookFailback, error) {
|
|
||||||
return &context.ResponseHookFailback{}, nil
|
|
||||||
}
|
|
||||||
53
agent/assistant/hook/next.go
Normal file
53
agent/assistant/hook/next.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Next next hook for the next action after the completion
|
||||||
|
func (s *Script) Next(ctx *context.Context, payload *context.NextHookPayload) (*context.NextHookResponse, error) {
|
||||||
|
// Convert payload to map for JS (use JSON tag names)
|
||||||
|
payloadMap := map[string]interface{}{
|
||||||
|
"messages": payload.Messages,
|
||||||
|
"completion": payload.Completion,
|
||||||
|
"tools": payload.Tools,
|
||||||
|
"error": payload.Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.Execute(ctx, "Next", payloadMap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.getNextHookResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNextHookResponse convert the result to a NextHookResponse
|
||||||
|
func (s *Script) getNextHookResponse(res interface{}) (*context.NextHookResponse, error) {
|
||||||
|
// Handle nil result
|
||||||
|
if res == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle undefined result (treat as nil)
|
||||||
|
if _, ok := res.(bridge.UndefinedT); ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON and unmarshal to NextHookResponse
|
||||||
|
raw, err := json.Marshal(res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal Next hook result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response context.NextHookResponse
|
||||||
|
if err := json.Unmarshal(raw, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal to NextHookResponse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
440
agent/assistant/hook/next_test.go
Normal file
440
agent/assistant/hook/next_test.go
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
package hook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/plan"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestContextForNext creates a Context for testing Next Hook with commonly used fields pre-populated.
|
||||||
|
// You can override any fields after creation as needed for specific test scenarios.
|
||||||
|
func newTestContextForNext(chatID, assistantID string) *context.Context {
|
||||||
|
return &context.Context{
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Space: plan.NewMemorySharedSpace(),
|
||||||
|
ChatID: chatID,
|
||||||
|
AssistantID: assistantID,
|
||||||
|
Connector: "",
|
||||||
|
Locale: "en-us",
|
||||||
|
Theme: "light",
|
||||||
|
Client: context.Client{
|
||||||
|
Type: "web",
|
||||||
|
UserAgent: "TestAgent/1.0",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
},
|
||||||
|
Referer: context.RefererAPI,
|
||||||
|
Accept: context.AcceptWebCUI,
|
||||||
|
Route: "",
|
||||||
|
Metadata: make(map[string]interface{}),
|
||||||
|
Authorized: &types.AuthorizedInfo{
|
||||||
|
Subject: "test-user",
|
||||||
|
ClientID: "test-client-id",
|
||||||
|
Scope: "openid profile email",
|
||||||
|
SessionID: "test-session-id",
|
||||||
|
UserID: "test-user-123",
|
||||||
|
TeamID: "test-team-456",
|
||||||
|
TenantID: "test-tenant-789",
|
||||||
|
RememberMe: true,
|
||||||
|
Constraints: types.DataConstraints{
|
||||||
|
OwnerOnly: false,
|
||||||
|
CreatorOnly: false,
|
||||||
|
EditorOnly: false,
|
||||||
|
TeamOnly: true,
|
||||||
|
Extra: map[string]interface{}{
|
||||||
|
"department": "engineering",
|
||||||
|
"region": "us-west",
|
||||||
|
"project": "yao",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNext tests the Next hook
|
||||||
|
func TestNext(t *testing.T) {
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get the tests.next assistant: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent.Script == nil {
|
||||||
|
t.Fatalf("The tests.next assistant has no script")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the helper function to create a test context
|
||||||
|
ctx := newTestContextForNext("chat-test-next-hook", "tests.next")
|
||||||
|
|
||||||
|
// Test scenario 1: Return null (should get nil response)
|
||||||
|
t.Run("ReturnNull", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "return_null"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion",
|
||||||
|
},
|
||||||
|
Tools: nil,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook with null return: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res != nil {
|
||||||
|
t.Errorf("Expected nil response for null return, got: %v", res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test scenario 2: Return undefined (should get nil response)
|
||||||
|
t.Run("ReturnUndefined", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "return_undefined"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook with undefined return: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res != nil {
|
||||||
|
t.Errorf("Expected nil response for undefined return, got: %v", res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test scenario 3: Return empty object (should get empty NextHookResponse)
|
||||||
|
t.Run("ReturnEmpty", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "return_empty"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook with empty return: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("Expected non-nil response for empty object, got nil")
|
||||||
|
}
|
||||||
|
if res.Delegate != nil {
|
||||||
|
t.Errorf("Expected nil Delegate, got: %v", res.Delegate)
|
||||||
|
}
|
||||||
|
if res.Data != nil {
|
||||||
|
t.Errorf("Expected nil Data, got: %v", res.Data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test scenario 4: Return custom data
|
||||||
|
t.Run("ReturnCustomData", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "return_custom_data"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook with custom data: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("Expected non-nil response, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Data is present
|
||||||
|
if res.Data == nil {
|
||||||
|
t.Fatalf("Expected Data to be present, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data should be a map
|
||||||
|
dataMap, ok := res.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify custom data fields
|
||||||
|
if message, ok := dataMap["message"].(string); !ok || message != "Custom response from Next Hook" {
|
||||||
|
t.Errorf("Expected custom message, got: %v", dataMap["message"])
|
||||||
|
}
|
||||||
|
if test, ok := dataMap["test"].(bool); !ok || !test {
|
||||||
|
t.Errorf("Expected test=true, got: %v", dataMap["test"])
|
||||||
|
}
|
||||||
|
if _, ok := dataMap["timestamp"]; !ok {
|
||||||
|
t.Errorf("Expected timestamp field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Delegate is nil
|
||||||
|
if res.Delegate != nil {
|
||||||
|
t.Errorf("Expected nil Delegate, got: %v", res.Delegate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test scenario 5: Return data with metadata
|
||||||
|
t.Run("ReturnDataWithMetadata", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "return_data_with_metadata"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("Expected non-nil response, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Data
|
||||||
|
if res.Data == nil {
|
||||||
|
t.Fatalf("Expected Data to be present, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataMap, ok := res.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result, ok := dataMap["result"].(string); !ok || result != "success" {
|
||||||
|
t.Errorf("Expected result='success', got: %v", dataMap["result"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Metadata
|
||||||
|
if res.Metadata == nil {
|
||||||
|
t.Fatalf("Expected Metadata to be present, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hook, ok := res.Metadata["hook"].(string); !ok || hook != "next" {
|
||||||
|
t.Errorf("Expected hook='next', got: %v", res.Metadata["hook"])
|
||||||
|
}
|
||||||
|
if processed, ok := res.Metadata["processed"].(bool); !ok || !processed {
|
||||||
|
t.Errorf("Expected processed=true, got: %v", res.Metadata["processed"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test scenario 6: Return delegate
|
||||||
|
t.Run("ReturnDelegate", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "return_delegate"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook with delegate: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("Expected non-nil response, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Delegate is present
|
||||||
|
if res.Delegate == nil {
|
||||||
|
t.Fatalf("Expected Delegate to be present, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify delegate fields
|
||||||
|
if res.Delegate.AgentID != "tests.create" {
|
||||||
|
t.Errorf("Expected AgentID='tests.create', got: %s", res.Delegate.AgentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res.Delegate.Messages) != 1 {
|
||||||
|
t.Errorf("Expected 1 message, got: %d", len(res.Delegate.Messages))
|
||||||
|
} else {
|
||||||
|
if res.Delegate.Messages[0].Role != context.RoleUser {
|
||||||
|
t.Errorf("Expected user role, got: %s", res.Delegate.Messages[0].Role)
|
||||||
|
}
|
||||||
|
if content, ok := res.Delegate.Messages[0].Content.(string); !ok || content != "Hello from delegated agent" {
|
||||||
|
t.Errorf("Expected specific content, got: %v", res.Delegate.Messages[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Data is nil (only delegate, no custom data)
|
||||||
|
if res.Data != nil {
|
||||||
|
t.Logf("Note: Data is present alongside Delegate: %v", res.Data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test scenario 7: Verify payload structure
|
||||||
|
t.Run("VerifyPayload", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleSystem, Content: "System message"},
|
||||||
|
{Role: context.RoleUser, Content: "verify_payload"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion content",
|
||||||
|
Usage: &message.UsageInfo{
|
||||||
|
PromptTokens: 10,
|
||||||
|
CompletionTokens: 20,
|
||||||
|
TotalTokens: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: []context.ToolCallResponse{
|
||||||
|
{
|
||||||
|
ToolCallID: "call_123",
|
||||||
|
Server: "test-server",
|
||||||
|
Tool: "test-tool",
|
||||||
|
Result: map[string]interface{}{"success": true},
|
||||||
|
Error: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("Expected non-nil response, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Data contains validation results
|
||||||
|
if res.Data == nil {
|
||||||
|
t.Fatalf("Expected Data with validation results, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataMap, ok := res.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if validation, ok := dataMap["validation"].(string); !ok || validation != "success" {
|
||||||
|
t.Errorf("Expected validation='success', got: %v", dataMap["validation"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if checks, ok := dataMap["checks"].([]interface{}); !ok {
|
||||||
|
t.Errorf("Expected checks array, got: %T", dataMap["checks"])
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ Payload validation checks: %d items", len(checks))
|
||||||
|
for i, check := range checks {
|
||||||
|
t.Logf(" [%d] %v", i, check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test scenario 8: Verify tools processing
|
||||||
|
t.Run("VerifyTools", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "verify_tools"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion",
|
||||||
|
},
|
||||||
|
Tools: []context.ToolCallResponse{
|
||||||
|
{
|
||||||
|
ToolCallID: "call_1",
|
||||||
|
Server: "server1",
|
||||||
|
Tool: "tool1",
|
||||||
|
Result: map[string]interface{}{"value": 42},
|
||||||
|
Error: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ToolCallID: "call_2",
|
||||||
|
Server: "server2",
|
||||||
|
Tool: "tool2",
|
||||||
|
Result: nil,
|
||||||
|
Error: "Tool execution failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("Expected non-nil response, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Data
|
||||||
|
if res.Data == nil {
|
||||||
|
t.Fatalf("Expected Data, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataMap, ok := res.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected Data to be map, got: %T", res.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tool statistics
|
||||||
|
if totalTools, ok := dataMap["total_tools"].(float64); !ok || int(totalTools) != 2 {
|
||||||
|
t.Errorf("Expected total_tools=2, got: %v", dataMap["total_tools"])
|
||||||
|
}
|
||||||
|
if successful, ok := dataMap["successful"].(float64); !ok || int(successful) != 1 {
|
||||||
|
t.Errorf("Expected successful=1, got: %v", dataMap["successful"])
|
||||||
|
}
|
||||||
|
if failed, ok := dataMap["failed"].(float64); !ok || int(failed) != 1 {
|
||||||
|
t.Errorf("Expected failed=1, got: %v", dataMap["failed"])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Tools processing validated successfully")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test scenario 9: Handle error
|
||||||
|
t.Run("HandleError", func(t *testing.T) {
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "handle_error"},
|
||||||
|
},
|
||||||
|
Completion: &context.CompletionResponse{
|
||||||
|
Content: "Test completion",
|
||||||
|
},
|
||||||
|
Error: "Tool execution failed: timeout",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute Next hook: %s", err.Error())
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("Expected non-nil response, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify error handling
|
||||||
|
if res.Data == nil {
|
||||||
|
t.Fatalf("Expected Data, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataMap, ok := res.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected Data to be map, got: %T", res.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errorMsg, ok := dataMap["error"].(string); !ok || errorMsg != "Tool execution failed: timeout" {
|
||||||
|
t.Errorf("Expected error message, got: %v", dataMap["error"])
|
||||||
|
}
|
||||||
|
if recovered, ok := dataMap["recovered"].(bool); !ok || !recovered {
|
||||||
|
t.Errorf("Expected recovered=true, got: %v", dataMap["recovered"])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Error handling validated successfully")
|
||||||
|
})
|
||||||
|
}
|
||||||
418
agent/assistant/hook/realworld_next_test.go
Normal file
418
agent/assistant/hook/realworld_next_test.go
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
package hook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/gou/plan"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newRealWorldNextContext creates a Context for real world Next Hook testing
|
||||||
|
func newRealWorldNextContext(chatID, assistantID string) *context.Context {
|
||||||
|
return &context.Context{
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Space: plan.NewMemorySharedSpace(),
|
||||||
|
ChatID: chatID,
|
||||||
|
AssistantID: assistantID,
|
||||||
|
Connector: "",
|
||||||
|
Locale: "en-us",
|
||||||
|
Theme: "light",
|
||||||
|
Client: context.Client{
|
||||||
|
Type: "web",
|
||||||
|
UserAgent: "RealWorldTest/1.0",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
},
|
||||||
|
Referer: context.RefererAPI,
|
||||||
|
Accept: context.AcceptWebCUI,
|
||||||
|
Route: "",
|
||||||
|
Metadata: make(map[string]interface{}),
|
||||||
|
Authorized: &types.AuthorizedInfo{
|
||||||
|
Subject: "realworld-test-user",
|
||||||
|
ClientID: "realworld-test-client",
|
||||||
|
Scope: "openid profile",
|
||||||
|
SessionID: "realworld-test-session",
|
||||||
|
UserID: "realworld-user-123",
|
||||||
|
TeamID: "realworld-team-456",
|
||||||
|
TenantID: "realworld-tenant-789",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealWorldNextStandard tests standard response (nil return)
|
||||||
|
func TestRealWorldNextStandard(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real world Next Hook test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := newRealWorldNextContext("test-next-standard", "tests.realworld-next")
|
||||||
|
|
||||||
|
// Simulate completion with scenario marker
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: standard"},
|
||||||
|
{Role: context.RoleAssistant, Content: "I'll process your request using standard response."},
|
||||||
|
}
|
||||||
|
|
||||||
|
completion := &context.CompletionResponse{
|
||||||
|
Content: "Processing complete. Standard response will be used.",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: messages,
|
||||||
|
Completion: completion,
|
||||||
|
Tools: nil,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next hook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return nil for standard response
|
||||||
|
assert.Nil(t, response, "Standard scenario should return nil")
|
||||||
|
t.Log("✓ Standard response scenario passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealWorldNextCustomData tests custom data response
|
||||||
|
func TestRealWorldNextCustomData(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real world Next Hook test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := newRealWorldNextContext("test-next-custom", "tests.realworld-next")
|
||||||
|
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: custom_data"},
|
||||||
|
{Role: context.RoleAssistant, Content: "Here's some information for you."},
|
||||||
|
}
|
||||||
|
|
||||||
|
completion := &context.CompletionResponse{
|
||||||
|
Content: "This is the LLM completion that will be summarized.",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: messages,
|
||||||
|
Completion: completion,
|
||||||
|
Tools: nil,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next hook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, response, "Custom data scenario should return response")
|
||||||
|
assert.NotNil(t, response.Data, "Response should have Data")
|
||||||
|
|
||||||
|
dataMap, ok := response.Data.(map[string]interface{})
|
||||||
|
assert.True(t, ok, "Data should be a map")
|
||||||
|
assert.Equal(t, "custom_response", dataMap["type"])
|
||||||
|
assert.Contains(t, dataMap, "timestamp")
|
||||||
|
|
||||||
|
t.Log("✓ Custom data response scenario passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealWorldNextDelegate tests agent delegation
|
||||||
|
func TestRealWorldNextDelegate(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real world Next Hook test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := newRealWorldNextContext("test-next-delegate", "tests.realworld-next")
|
||||||
|
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: delegate"},
|
||||||
|
}
|
||||||
|
|
||||||
|
completion := &context.CompletionResponse{
|
||||||
|
Content: "I should delegate this request to another agent.",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: messages,
|
||||||
|
Completion: completion,
|
||||||
|
Tools: nil,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next hook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, response, "Delegate scenario should return response")
|
||||||
|
assert.NotNil(t, response.Delegate, "Response should have Delegate")
|
||||||
|
assert.Equal(t, "tests.create", response.Delegate.AgentID)
|
||||||
|
assert.NotEmpty(t, response.Delegate.Messages)
|
||||||
|
|
||||||
|
t.Log("✓ Delegation scenario passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealWorldNextProcessTools tests tool result processing
|
||||||
|
func TestRealWorldNextProcessTools(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real world Next Hook test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := newRealWorldNextContext("test-next-tools", "tests.realworld-next")
|
||||||
|
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: process_tools"},
|
||||||
|
}
|
||||||
|
|
||||||
|
completion := &context.CompletionResponse{
|
||||||
|
Content: "Tool calls have been executed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate tool call results
|
||||||
|
tools := []context.ToolCallResponse{
|
||||||
|
{
|
||||||
|
ToolCallID: "call_1",
|
||||||
|
Server: "test-server",
|
||||||
|
Tool: "test-tool-1",
|
||||||
|
Result: map[string]interface{}{"status": "success"},
|
||||||
|
Error: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ToolCallID: "call_2",
|
||||||
|
Server: "test-server",
|
||||||
|
Tool: "test-tool-2",
|
||||||
|
Result: nil,
|
||||||
|
Error: "Tool execution failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: messages,
|
||||||
|
Completion: completion,
|
||||||
|
Tools: tools,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next hook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, response, "Process tools scenario should return response")
|
||||||
|
assert.NotNil(t, response.Data, "Response should have Data")
|
||||||
|
|
||||||
|
dataMap, ok := response.Data.(map[string]interface{})
|
||||||
|
assert.True(t, ok, "Data should be a map")
|
||||||
|
assert.Equal(t, "Tool execution summary", dataMap["message"])
|
||||||
|
|
||||||
|
// Check summary
|
||||||
|
summary, ok := dataMap["summary"].(map[string]interface{})
|
||||||
|
assert.True(t, ok, "Should have summary")
|
||||||
|
assert.Equal(t, float64(2), summary["total"])
|
||||||
|
assert.Equal(t, float64(1), summary["successful"])
|
||||||
|
assert.Equal(t, float64(1), summary["failed"])
|
||||||
|
|
||||||
|
t.Log("✓ Process tools scenario passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealWorldNextErrorRecovery tests error handling and recovery
|
||||||
|
func TestRealWorldNextErrorRecovery(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real world Next Hook test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := newRealWorldNextContext("test-next-error", "tests.realworld-next")
|
||||||
|
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: error_recovery"},
|
||||||
|
}
|
||||||
|
|
||||||
|
completion := &context.CompletionResponse{
|
||||||
|
Content: "An error occurred during processing.",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: messages,
|
||||||
|
Completion: completion,
|
||||||
|
Tools: nil,
|
||||||
|
Error: "System error: Database connection timeout",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next hook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, response, "Error recovery scenario should return response")
|
||||||
|
assert.NotNil(t, response.Data, "Response should have Data")
|
||||||
|
|
||||||
|
dataMap, ok := response.Data.(map[string]interface{})
|
||||||
|
assert.True(t, ok, "Data should be a map")
|
||||||
|
assert.Equal(t, "Error was handled by Next Hook", dataMap["message"])
|
||||||
|
assert.Contains(t, dataMap, "error")
|
||||||
|
assert.Contains(t, dataMap, "recovery_action")
|
||||||
|
|
||||||
|
t.Log("✓ Error recovery scenario passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealWorldNextConditional tests conditional logic based on completion
|
||||||
|
func TestRealWorldNextConditional(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real world Next Hook test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := newRealWorldNextContext("test-next-conditional", "tests.realworld-next")
|
||||||
|
|
||||||
|
t.Run("ConditionalSuccess", func(t *testing.T) {
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: conditional"},
|
||||||
|
}
|
||||||
|
|
||||||
|
completion := &context.CompletionResponse{
|
||||||
|
Content: "The operation completed successfully. All tasks are done.",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: messages,
|
||||||
|
Completion: completion,
|
||||||
|
Tools: nil,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next hook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, response, "Conditional scenario should return response")
|
||||||
|
assert.NotNil(t, response.Data, "Response should have Data")
|
||||||
|
|
||||||
|
dataMap, ok := response.Data.(map[string]interface{})
|
||||||
|
assert.True(t, ok, "Data should be a map")
|
||||||
|
assert.Equal(t, "Conditional analysis complete", dataMap["message"])
|
||||||
|
assert.Contains(t, dataMap, "action")
|
||||||
|
assert.Contains(t, dataMap, "conditions")
|
||||||
|
|
||||||
|
t.Log("✓ Conditional (success) scenario passed")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ConditionalDelegate", func(t *testing.T) {
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "scenario: conditional"},
|
||||||
|
}
|
||||||
|
|
||||||
|
completion := &context.CompletionResponse{
|
||||||
|
Content: "I should delegate this request to another service for better handling.",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: messages,
|
||||||
|
Completion: completion,
|
||||||
|
Tools: nil,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next hook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, response, "Conditional delegate should return response")
|
||||||
|
assert.NotNil(t, response.Delegate, "Should delegate based on condition")
|
||||||
|
assert.Equal(t, "tests.create", response.Delegate.AgentID)
|
||||||
|
|
||||||
|
t.Log("✓ Conditional (delegate) scenario passed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealWorldNextDefault tests default behavior
|
||||||
|
func TestRealWorldNextDefault(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real world Next Hook test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
agent, err := assistant.Get("tests.realworld-next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := newRealWorldNextContext("test-next-default", "tests.realworld-next")
|
||||||
|
|
||||||
|
messages := []context.Message{
|
||||||
|
{Role: context.RoleUser, Content: "Just a normal request"},
|
||||||
|
}
|
||||||
|
|
||||||
|
completion := &context.CompletionResponse{
|
||||||
|
Content: "Here's the response to your request.",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &context.NextHookPayload{
|
||||||
|
Messages: messages,
|
||||||
|
Completion: completion,
|
||||||
|
Tools: nil,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agent.Script.Next(ctx, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next hook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default behavior should return nil
|
||||||
|
assert.Nil(t, response, "Default scenario should return nil for standard response")
|
||||||
|
|
||||||
|
t.Log("✓ Default scenario passed")
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package hook
|
package hook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,5 +20,16 @@ func (s *Script) Execute(ctx *context.Context, method string, args ...interface{
|
||||||
|
|
||||||
// The first argument is the context
|
// The first argument is the context
|
||||||
args = append([]interface{}{ctx}, args...)
|
args = append([]interface{}{ctx}, args...)
|
||||||
return scriptCtx.CallWith(ctx.Context, method, args...)
|
|
||||||
|
// Try to call the method
|
||||||
|
result, err := scriptCtx.CallWith(ctx.Context, method, args...)
|
||||||
|
|
||||||
|
// If method doesn't exist (ReferenceError or similar), return nil without error
|
||||||
|
if err != nil && (strings.Contains(err.Error(), "is not defined") ||
|
||||||
|
strings.Contains(err.Error(), "is not a function") ||
|
||||||
|
strings.Contains(err.Error(), "is not a Function")) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
agent/assistant/next.go
Normal file
80
agent/assistant/next.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processNextResponse processes the Next hook's response and handles agent delegation or custom data
|
||||||
|
func (ast *Assistant) processNextResponse(npc *NextProcessContext) (interface{}, error) {
|
||||||
|
// If no Next hook response, return standard response
|
||||||
|
if npc.NextResponse == nil {
|
||||||
|
return ast.buildStandardResponse(npc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Delegate: call another agent
|
||||||
|
if npc.NextResponse.Delegate != nil {
|
||||||
|
return ast.handleDelegation(npc.Context, npc.NextResponse.Delegate, npc.StreamHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle custom Data: return as-is wrapped in standard Response
|
||||||
|
if npc.NextResponse.Data != nil {
|
||||||
|
return &agentContext.Response{
|
||||||
|
ContextID: npc.Context.ID,
|
||||||
|
RequestID: npc.Context.RequestID(),
|
||||||
|
TraceID: npc.Context.TraceID(),
|
||||||
|
ChatID: npc.Context.ChatID,
|
||||||
|
AssistantID: ast.ID,
|
||||||
|
Create: npc.CreateResponse,
|
||||||
|
Next: npc.NextResponse.Data, // Put custom data in Next field
|
||||||
|
Completion: npc.CompletionResponse,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No delegate or data, return standard response
|
||||||
|
return ast.buildStandardResponse(npc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDelegation handles calling another agent based on DelegateConfig
|
||||||
|
func (ast *Assistant) handleDelegation(
|
||||||
|
ctx *agentContext.Context,
|
||||||
|
delegate *agentContext.DelegateConfig,
|
||||||
|
streamHandler func(message.StreamChunkType, []byte) int,
|
||||||
|
) (interface{}, error) {
|
||||||
|
// Load the target assistant
|
||||||
|
targetAssistant, err := Get(delegate.AgentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load delegated assistant '%s': %w", delegate.AgentID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new context for the delegated call
|
||||||
|
// Copy relevant fields from the parent context
|
||||||
|
delegatedCtx := &agentContext.Context{
|
||||||
|
Context: ctx.Context,
|
||||||
|
Locale: ctx.Locale,
|
||||||
|
Sid: ctx.Sid,
|
||||||
|
Stack: ctx.Stack, // Maintain the call stack
|
||||||
|
Authorized: ctx.Authorized,
|
||||||
|
Metadata: ctx.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the delegated assistant with provided messages
|
||||||
|
// The delegated assistant's Stream method will handle the Next hook recursively
|
||||||
|
return targetAssistant.Stream(delegatedCtx, delegate.Messages, streamHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildStandardResponse builds the standard agent response when no custom Next hook processing is needed
|
||||||
|
func (ast *Assistant) buildStandardResponse(npc *NextProcessContext) interface{} {
|
||||||
|
return &agentContext.Response{
|
||||||
|
ContextID: npc.Context.ID,
|
||||||
|
RequestID: npc.Context.RequestID(),
|
||||||
|
TraceID: npc.Context.TraceID(),
|
||||||
|
ChatID: npc.Context.ChatID,
|
||||||
|
AssistantID: ast.ID,
|
||||||
|
Create: npc.CreateResponse,
|
||||||
|
Next: npc.NextResponse,
|
||||||
|
Completion: npc.CompletionResponse,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,14 +84,14 @@ func (ast *Assistant) traceLLMComplete(ctx *context.Context, completionResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// traceAgentOutput sets the output of the agent trace node
|
// traceAgentOutput sets the output of the agent trace node
|
||||||
func (ast *Assistant) traceAgentOutput(agentNode types.Node, createResponse *context.HookCreateResponse, doneResponse *context.ResponseHookDone, completionResponse *context.CompletionResponse) {
|
func (ast *Assistant) traceAgentOutput(agentNode types.Node, createResponse *context.HookCreateResponse, nextResponse interface{}, completionResponse *context.CompletionResponse) {
|
||||||
if agentNode == nil {
|
if agentNode == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
output := context.Response{
|
output := context.Response{
|
||||||
Create: createResponse,
|
Create: createResponse,
|
||||||
Done: doneResponse,
|
Next: nextResponse,
|
||||||
Completion: completionResponse,
|
Completion: completionResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/yaoapp/yao/agent/assistant/hook"
|
"github.com/yaoapp/yao/agent/assistant/hook"
|
||||||
chatctx "github.com/yaoapp/yao/agent/context"
|
chatctx "github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/message"
|
"github.com/yaoapp/yao/agent/message"
|
||||||
|
outputMessage "github.com/yaoapp/yao/agent/output/message"
|
||||||
store "github.com/yaoapp/yao/agent/store/types"
|
store "github.com/yaoapp/yao/agent/store/types"
|
||||||
api "github.com/yaoapp/yao/openai"
|
api "github.com/yaoapp/yao/openai"
|
||||||
)
|
)
|
||||||
|
|
@ -201,6 +202,18 @@ func (r *ToolCallResult) Tool() string {
|
||||||
return toolName
|
return toolName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NextProcessContext encapsulates all the context needed to process Next hook responses
|
||||||
|
// This simplifies function signatures and makes it easier to add new fields in the future
|
||||||
|
type NextProcessContext struct {
|
||||||
|
Context *chatctx.Context // Agent context
|
||||||
|
NextResponse *chatctx.NextHookResponse // Response from Next hook (already converted from JS)
|
||||||
|
CompletionResponse *chatctx.CompletionResponse // LLM completion response
|
||||||
|
FullMessages []chatctx.Message // Full conversation history
|
||||||
|
ToolCallResponses []chatctx.ToolCallResponse // Tool call results (if any)
|
||||||
|
StreamHandler outputMessage.StreamFunc // Stream handler for output
|
||||||
|
CreateResponse *chatctx.HookCreateResponse // Create hook response
|
||||||
|
}
|
||||||
|
|
||||||
// ParsedContent extracts the actual tool return value from MCP ToolContent array
|
// ParsedContent extracts the actual tool return value from MCP ToolContent array
|
||||||
// According to MCP protocol:
|
// According to MCP protocol:
|
||||||
// - Content is []ToolContent array
|
// - Content is []ToolContent array
|
||||||
|
|
|
||||||
|
|
@ -274,15 +274,14 @@ type Stack struct {
|
||||||
// Response the response
|
// Response the response
|
||||||
// 100% compatible with the OpenAI API
|
// 100% compatible with the OpenAI API
|
||||||
type Response struct {
|
type Response struct {
|
||||||
RequestID string `json:"request_id"` // Request ID for the response
|
RequestID string `json:"request_id"` // Request ID for the response
|
||||||
ContextID string `json:"context_id"` // Context ID for the response
|
ContextID string `json:"context_id"` // Context ID for the response
|
||||||
ChatID string `json:"chat_id"` // Chat ID for the response
|
TraceID string `json:"trace_id"` // Trace ID for the response
|
||||||
AssistantID string `json:"assistant_id"` // Assistant ID for the response
|
ChatID string `json:"chat_id"` // Chat ID for the response
|
||||||
Create *HookCreateResponse `json:"create,omitempty"`
|
AssistantID string `json:"assistant_id"` // Assistant ID for the response
|
||||||
MCP *ResponseHookMCP `json:"mcp,omitempty"`
|
Create *HookCreateResponse `json:"create,omitempty"` // Create response from the create hook
|
||||||
Done *ResponseHookDone `json:"done,omitempty"`
|
Next interface{} `json:"next,omitempty"` // Next response from the next hook
|
||||||
Failback *ResponseHookFailback `json:"failback,omitempty"`
|
Completion *CompletionResponse `json:"completion,omitempty"` // Completion response from the completion hook
|
||||||
Completion *CompletionResponse `json:"completion,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HookCreateResponse the response of the create hook
|
// HookCreateResponse the response of the create hook
|
||||||
|
|
@ -311,8 +310,65 @@ type HookCreateResponse struct {
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"` // Override or merge metadata
|
Metadata map[string]interface{} `json:"metadata,omitempty"` // Override or merge metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResponseHookDone the response of the done hook
|
// NextHookPayload payload for the next hook
|
||||||
type ResponseHookDone struct{}
|
type NextHookPayload struct {
|
||||||
|
Messages []Message `json:"messages,omitempty"` // Messages to be sent to the assistant
|
||||||
|
Completion *CompletionResponse `json:"completion,omitempty"` // Completion response from the completion hook
|
||||||
|
Tools []ToolCallResponse `json:"tools,omitempty"` // Tools results from the assistant
|
||||||
|
Error string `json:"error,omitempty"` // Error message if failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCallResponse the response of a tool call
|
||||||
|
type ToolCallResponse struct {
|
||||||
|
ToolCallID string `json:"toolcall_id"`
|
||||||
|
Server string `json:"server"`
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Arguments interface{} `json:"arguments,omitempty"`
|
||||||
|
Result interface{} `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextHookResponse represents the response from Next hook
|
||||||
|
type NextHookResponse struct {
|
||||||
|
// Delegate: if provided, delegate to another agent (recursive call)
|
||||||
|
Delegate *DelegateConfig `json:"delegate,omitempty"`
|
||||||
|
|
||||||
|
// Data: custom response data to return to user
|
||||||
|
// If both Delegate and Data are nil, use standard CompletionResponse
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
|
||||||
|
// Metadata: for debugging and logging
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelegateConfig configuration for delegating to another agent
|
||||||
|
type DelegateConfig struct {
|
||||||
|
AgentID string `json:"agent_id"` // Required: target agent ID
|
||||||
|
Messages []Message `json:"messages"` // Messages to send to target agent
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextAction defines the action determined by Next hook response
|
||||||
|
type NextAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// NextActionReturn returns data to user (standard or custom)
|
||||||
|
NextActionReturn NextAction = "return"
|
||||||
|
|
||||||
|
// NextActionDelegate delegates to another agent
|
||||||
|
NextActionDelegate NextAction = "delegate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action returns the determined action based on NextHookResponse fields
|
||||||
|
func (n *NextHookResponse) Action() NextAction {
|
||||||
|
if n.Delegate != nil {
|
||||||
|
return NextActionDelegate
|
||||||
|
}
|
||||||
|
return NextActionReturn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseHookNext the response of the next hook
|
||||||
|
type ResponseHookNext interface{}
|
||||||
|
|
||||||
// ResponseHookMCP the response of the mcp hook
|
// ResponseHookMCP the response of the mcp hook
|
||||||
type ResponseHookMCP struct{}
|
type ResponseHookMCP struct{}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue