From 3bfd40b701c2c60d337bf1ecb6c07dea95bc2755 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 29 Nov 2025 22:52:06 +0800 Subject: [PATCH] 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. --- agent/assistant/agent.go | 116 +++--- agent/assistant/agent_next_test.go | 243 +++++++++++ agent/assistant/hook/done.go | 10 - agent/assistant/hook/failback.go | 10 - agent/assistant/hook/next.go | 53 +++ agent/assistant/hook/next_test.go | 440 ++++++++++++++++++++ agent/assistant/hook/realworld_next_test.go | 418 +++++++++++++++++++ agent/assistant/hook/script.go | 15 +- agent/assistant/next.go | 80 ++++ agent/assistant/trace.go | 4 +- agent/assistant/types.go | 13 + agent/context/types.go | 78 +++- 12 files changed, 1396 insertions(+), 84 deletions(-) create mode 100644 agent/assistant/agent_next_test.go delete mode 100644 agent/assistant/hook/done.go delete mode 100644 agent/assistant/hook/failback.go create mode 100644 agent/assistant/hook/next.go create mode 100644 agent/assistant/hook/next_test.go create mode 100644 agent/assistant/hook/realworld_next_test.go create mode 100644 agent/assistant/next.go diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index bbb315bb..e32b02e4 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -7,7 +7,6 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/yaoapp/gou/connector" "github.com/yaoapp/kun/log" - "github.com/yaoapp/kun/utils" "github.com/yaoapp/yao/agent/assistant/handlers" "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/i18n" @@ -16,7 +15,7 @@ import ( // Stream stream the agent // handler is optional, if not provided, a default handler will be used -func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Message, handler ...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) 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) 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 // ================================================ + var toolCallResponses []context.ToolCallResponse = 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 currentMessages := completionMessages currentResponse := completionResponse for attempt := 0; attempt < maxToolRetries; attempt++ { - fmt.Println("attempt", attempt) // Execute all tool calls 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 !hasErrors { 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 } @@ -218,23 +209,55 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa 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 { 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 { ast.traceAgentFail(agentNode, err) - // Send error stream_end for root stack ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, 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 - 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) // 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{ - ContextID: ctx.ID, - RequestID: ctx.RequestID(), - ChatID: ctx.ChatID, - AssistantID: ast.ID, - Create: createResponse, Done: doneResponse, Completion: completionResponse}, nil + // Return finalResponse which could be: + // 1. Result from delegated agent call (already a Response) + // 2. Custom data from Next hook (wrapped in standard Response) + // 3. Standard response + return finalResponse, nil } // 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 // This is called by the interrupt listener when a signal is received 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 switch signal.Type { case context.InterruptForce: - fmt.Println("Force interrupt: stopping current operations immediately...") // Force interrupt: context is already cancelled in handleSignal // LLM streaming will detect ctx.Interrupt.Context().Done() and stop + log.Trace("[AGENT] Force interrupt: stopping current operations immediately") 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 // The signal is stored in current/pending, can be checked at checkpoints } diff --git a/agent/assistant/agent_next_test.go b/agent/assistant/agent_next_test.go new file mode 100644 index 00000000..0d13aba2 --- /dev/null +++ b/agent/assistant/agent_next_test.go @@ -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") +} diff --git a/agent/assistant/hook/done.go b/agent/assistant/hook/done.go deleted file mode 100644 index 21545227..00000000 --- a/agent/assistant/hook/done.go +++ /dev/null @@ -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 -} diff --git a/agent/assistant/hook/failback.go b/agent/assistant/hook/failback.go deleted file mode 100644 index 1e37fabb..00000000 --- a/agent/assistant/hook/failback.go +++ /dev/null @@ -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 -} diff --git a/agent/assistant/hook/next.go b/agent/assistant/hook/next.go new file mode 100644 index 00000000..7cb1c51b --- /dev/null +++ b/agent/assistant/hook/next.go @@ -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 +} diff --git a/agent/assistant/hook/next_test.go b/agent/assistant/hook/next_test.go new file mode 100644 index 00000000..c9f527ed --- /dev/null +++ b/agent/assistant/hook/next_test.go @@ -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") + }) +} diff --git a/agent/assistant/hook/realworld_next_test.go b/agent/assistant/hook/realworld_next_test.go new file mode 100644 index 00000000..8b438435 --- /dev/null +++ b/agent/assistant/hook/realworld_next_test.go @@ -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") +} diff --git a/agent/assistant/hook/script.go b/agent/assistant/hook/script.go index 18b3cbe9..6970e8b5 100644 --- a/agent/assistant/hook/script.go +++ b/agent/assistant/hook/script.go @@ -1,6 +1,8 @@ package hook import ( + "strings" + "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 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 } diff --git a/agent/assistant/next.go b/agent/assistant/next.go new file mode 100644 index 00000000..6ad95648 --- /dev/null +++ b/agent/assistant/next.go @@ -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, + } +} diff --git a/agent/assistant/trace.go b/agent/assistant/trace.go index f025d8a6..934f4972 100644 --- a/agent/assistant/trace.go +++ b/agent/assistant/trace.go @@ -84,14 +84,14 @@ func (ast *Assistant) traceLLMComplete(ctx *context.Context, completionResponse } // 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 { return } output := context.Response{ Create: createResponse, - Done: doneResponse, + Next: nextResponse, Completion: completionResponse, } diff --git a/agent/assistant/types.go b/agent/assistant/types.go index 3768cd46..cd85bb7c 100644 --- a/agent/assistant/types.go +++ b/agent/assistant/types.go @@ -9,6 +9,7 @@ import ( "github.com/yaoapp/yao/agent/assistant/hook" chatctx "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/message" + outputMessage "github.com/yaoapp/yao/agent/output/message" store "github.com/yaoapp/yao/agent/store/types" api "github.com/yaoapp/yao/openai" ) @@ -201,6 +202,18 @@ func (r *ToolCallResult) Tool() string { 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 // According to MCP protocol: // - Content is []ToolContent array diff --git a/agent/context/types.go b/agent/context/types.go index 8cdefe61..fd982a3e 100644 --- a/agent/context/types.go +++ b/agent/context/types.go @@ -274,15 +274,14 @@ type Stack struct { // Response the response // 100% compatible with the OpenAI API type Response struct { - RequestID string `json:"request_id"` // Request ID for the response - ContextID string `json:"context_id"` // Context ID for the response - ChatID string `json:"chat_id"` // Chat ID for the response - AssistantID string `json:"assistant_id"` // Assistant ID for the response - Create *HookCreateResponse `json:"create,omitempty"` - MCP *ResponseHookMCP `json:"mcp,omitempty"` - Done *ResponseHookDone `json:"done,omitempty"` - Failback *ResponseHookFailback `json:"failback,omitempty"` - Completion *CompletionResponse `json:"completion,omitempty"` + RequestID string `json:"request_id"` // Request ID for the response + ContextID string `json:"context_id"` // Context ID for the response + TraceID string `json:"trace_id"` // Trace ID for the response + ChatID string `json:"chat_id"` // Chat ID for the response + AssistantID string `json:"assistant_id"` // Assistant ID for the response + Create *HookCreateResponse `json:"create,omitempty"` // Create response from the create hook + Next interface{} `json:"next,omitempty"` // Next response from the next hook + Completion *CompletionResponse `json:"completion,omitempty"` // Completion response from the completion 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 } -// ResponseHookDone the response of the done hook -type ResponseHookDone struct{} +// NextHookPayload payload for the next hook +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 type ResponseHookMCP struct{}