Skip to content

Commit

Permalink
Adds Chat Message History and Connects to the NPC Chat API (#3679)
Browse files Browse the repository at this point in the history
* Adds NPC chat endpoint to the Simple GenAI Game Server example
* Adds example NPC gameserver.yaml
* Adds message history to the GenAIRequest
Separates out the NPC Request from the GenAIRequest. Updates the NPC Request to include the now required FromID and ToID fields.
* Minor formatting changes
* Updates ENV variables to new formatting
  • Loading branch information
igooch committed Mar 8, 2024
1 parent 1fcda51 commit 7beb4d2
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 32 deletions.
15 changes: 15 additions & 0 deletions examples/simple-genai-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,21 @@ each request as part of the GenAIRequest structure. The default values for `GenA
to start the chat. The default values for the prompt is an empty string. `NumChats` is the number of
requests made to the `SimEndpoint` and `GenAiEndpoint`. The default value for is `NumChats` is `1`.

If you want to set up the chat with the npc-chat-api from the [Google for Games GenAI](https://github.com/googleforgames/GenAI-quickstart/genai/api/npc_chat_api)
you will need the Game Servers on the same cluster as the GenAI Inference Server. Set either the
`GenAiEndpoint` or `SimEndpoint` to the NPC service `"http://npc-chat-api.genai.svc.cluster.local:80"`.
Set whichever endpoint the pointing to the NPC service to be, either the `GenAiNpc` or `SimNpc`,
to be `"true"`. The `GenAIRequest` to the NPC endpoint only sends the message (prompt), so any
additional context outside of the prompt is ignored. `FromID` is the entity sending messages to NPC,
and `ToID` is the entity receiving the message (the NPC ID).
```
type NPCRequest struct {
Msg string `json:"message,omitempty"`
FromId int `json:"from_id,omitempty"`
ToId int `json:"to_id,omitempty"`
}
```

## Running the Game Server

Once you have modified the `gameserver_autochat.yaml` or `gameserver_manualchat.yaml` to use your
Expand Down
12 changes: 6 additions & 6 deletions examples/simple-genai-server/gameserver_autochat.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,20 @@ spec:
image: us-docker.pkg.dev/agones-images/examples/simple-genai-game-server:0.1
# imagePullPolicy: Always # add for development
env:
- name: GenAiEndpoint
- name: GEN_AI_ENDPOINT
# Replace with your GenAI and Sim inference servers' endpoint addresses. If the game
# server is in the same cluster as your inference server you can also use the k8s
# service discovery such as value: "http://vertex-chat-api.genai.svc.cluster.local:80"
value: "http://192.1.1.2/genai/chat"
- name: SimEndpoint
- name: SIM_ENDPOINT
value: "http://192.1.1.2/genai/chat"
- name: SimContext
- name: SIM_CONTEXT
value: "You are buying a car"
- name: GenAiContext
- name: GEN_AI_CONTEXT
value: "You are a car salesperson"
- name: Prompt
- name: PROMPT
value: "I would like to buy a car"
- name: NumChats
- name: NUM_CHATS
value: "50"
resources:
requests:
Expand Down
4 changes: 2 additions & 2 deletions examples/simple-genai-server/gameserver_manualchat.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ spec:
image: us-docker.pkg.dev/agones-images/examples/simple-genai-game-server:0.1
# imagePullPolicy: Always # add for development
env:
- name: GenAiEndpoint
- name: GEN_AI_ENDPOINT
# Replace with your GenAI server's endpoint address. If the game server is in the
# same cluster as your inference server you can also use the k8s service discovery
# such as value: "http://vertex-chat-api.genai.svc.cluster.local:80"
value: "http://192.1.1.2/genai/chat"
- name: GenAiContext
- name: GEN_AI_CONTEXT
# Context is optional, and will be sent along with each post request
value: "You are a car salesperson"
resources:
Expand Down
66 changes: 66 additions & 0 deletions examples/simple-genai-server/gameserver_npcchat.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
# Copyright 2024 Google LLC All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: agones.dev/v1
kind: GameServer
metadata:
name: gen-ai-server-npc
spec:
ports:
- name: default
portPolicy: Dynamic
containerPort: 7654
protocol: TCP
template:
spec:
containers:
- name: simple-genai-game-server
image: us-docker.pkg.dev/agones-images/examples/simple-genai-game-server:0.1
# imagePullPolicy: Always # add for development
env:
- name: GEN_AI_ENDPOINT
# Use the service endpoint address when running in the same cluster as the inference server.
# TODO (igooch): Change this to the `/genai/npc-chat` endpoint when it's properly plumbed in the inference server
value: "http://npc-chat-api.genai.svc.cluster.local:80"
# GenAiContext is not passed to the npc-chat-api endpoint.
- name: GEN_AI_NPC # False by default. Use GEN_AI_NPC "true" when using the npc-chat-api as the GEN_AI_ENDPOINT.
value: "true"
- name: FROM_ID # Default is "2".
value: "2"
- name: TO_ID # Default is "1".
value: "1"
- name: SIM_ENDPOINT
value: "http://192.1.1.2/genai/chat"
- name: SIM_CONTEXT
value: "Ask questions about one of the following: What happened here? Where were you during the earthquake? Do you have supplies?"
- name: SIM_NPC
value: "false" # False by default. Use SIM_NPC "true" when using the npc-chat-api as the SIM_ENDPOINT.
- name: PROMPT
value: "Hello"
- name: NUM_CHATS
value: "50"
resources:
requests:
memory: 64Mi
cpu: 20m
limits:
memory: 64Mi
cpu: 20m
# Schedule onto the game server node pool when running in the same cluster as the inference server.
# tolerations:
# - key: "agones.dev/role"
# value: "gameserver"
# effect: "NoExecute"
# nodeSelector:
# agones.dev/role: gameserver
131 changes: 107 additions & 24 deletions examples/simple-genai-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,33 +45,65 @@ func main() {
simEndpoint := flag.String("SimEndpoint", "", "The full base URL to send API requests to simulate user input")
simContext := flag.String("SimContext", "", "Context for the Sim endpoint")
numChats := flag.Int("NumChats", 1, "Number of back and forth chats between the sim and genAI")
genAiNpc := flag.Bool("GenAiNpc", false, "Set to true if the GenAIEndpoint is the npc-chat-api endpoint")
simNpc := flag.Bool("SimNpc", false, "Set to true if the SimEndpoint is the npc-chat-api endpoint")
fromId := flag.Int("FromID", 2, "Entity sending messages to the npc-chat-api")
toId := flag.Int("ToID", 1, "Entity receiving messages on the npc-chat-api (the NPC's ID)")

flag.Parse()
if ep := os.Getenv("PORT"); ep != "" {
port = &ep
}
if sc := os.Getenv("SimContext"); sc != "" {
if sc := os.Getenv("SIM_CONTEXT"); sc != "" {
simContext = &sc
}
if gac := os.Getenv("GenAiContext"); gac != "" {
if gac := os.Getenv("GEN_AI_CONTEXT"); gac != "" {
genAiContext = &gac
}
if p := os.Getenv("Prompt"); p != "" {
if p := os.Getenv("PROMPT"); p != "" {
prompt = &p
}
if se := os.Getenv("SimEndpoint"); se != "" {
if se := os.Getenv("SIM_ENDPOINT"); se != "" {
simEndpoint = &se
}
if gae := os.Getenv("GenAiEndpoint"); gae != "" {
if gae := os.Getenv("GEN_AI_ENDPOINT"); gae != "" {
genAiEndpoint = &gae
}
if nc := os.Getenv("NumChats"); nc != "" {
if nc := os.Getenv("NUM_CHATS"); nc != "" {
num, err := strconv.Atoi(nc)
if err != nil {
log.Fatalf("Could not parse NumChats: %v", err)
}
numChats = &num
}
if gan := os.Getenv("GEN_AI_NPC"); gan != "" {
gnpc, err := strconv.ParseBool(gan)
if err != nil {
log.Fatalf("Could parse GenAiNpc: %v", err)
}
genAiNpc = &gnpc
}
if sn := os.Getenv("SIM_NPC"); sn != "" {
snpc, err := strconv.ParseBool(sn)
if err != nil {
log.Fatalf("Could parse GenAiNpc: %v", err)
}
simNpc = &snpc
}
if fid := os.Getenv("FROM_ID"); fid != "" {
num, err := strconv.Atoi(fid)
if err != nil {
log.Fatalf("Could not parse FromId: %v", err)
}
fromId = &num
}
if tid := os.Getenv("TO_ID"); tid != "" {
num, err := strconv.Atoi(tid)
if err != nil {
log.Fatalf("Could not parse ToId: %v", err)
}
toId = &num
}

log.Print("Creating SDK instance")
s, err := sdk.NewSDK()
Expand All @@ -85,14 +117,14 @@ func main() {
var simConn *connection
if *simEndpoint != "" {
log.Printf("Creating Sim Client at endpoint %s", *simEndpoint)
simConn = initClient(*simEndpoint, *simContext, "Sim")
simConn = initClient(*simEndpoint, *simContext, "Sim", *simNpc, *fromId, *toId)
}

if *genAiEndpoint == "" {
log.Fatalf("GenAiEndpoint must be specified")
}
log.Printf("Creating GenAI Client at endpoint %s", *genAiEndpoint)
genAiConn := initClient(*genAiEndpoint, *genAiContext, "GenAI")
genAiConn := initClient(*genAiEndpoint, *genAiContext, "GenAI", *genAiNpc, *fromId, *toId)

log.Print("Marking this server as ready")
if err := s.Ready(); err != nil {
Expand All @@ -108,7 +140,8 @@ func main() {
var wg sync.WaitGroup
// TODO: Add flag for creating X number of chats
wg.Add(1)
go autonomousChat(*prompt, genAiConn, simConn, *numChats, &wg, sigCtx)
chatHistory := []Message{{Author: simConn.name, Content: *prompt}}
go autonomousChat(*prompt, genAiConn, simConn, *numChats, &wg, sigCtx, chatHistory)
wg.Wait()
}

Expand All @@ -120,31 +153,68 @@ func main() {
os.Exit(0)
}

func initClient(endpoint string, context string, name string) *connection {
func initClient(endpoint string, context string, name string, npc bool, fromID int, toID int) *connection {
// TODO: create option for a client certificate
client := &http.Client{}
return &connection{client: client, endpoint: endpoint, context: context, name: name}
return &connection{client: client, endpoint: endpoint, context: context, name: name, npc: npc, fromId: fromID, toId: toID}
}

type connection struct {
client *http.Client
endpoint string // full base URL for API requests
endpoint string // Full base URL for API requests
context string
name string // human readable name for the connection
name string // Human readable name for the connection
npc bool // True if the endpoint is the NPC API
fromId int // For use with NPC API, sender ID
toId int // For use with NPC API, receiver ID
// TODO: create options for routes off the base URL
}

// For use with Vertex APIs
type GenAIRequest struct {
Context string `json:"context,omitempty"`
Prompt string `json:"prompt"`
Context string `json:"context,omitempty"` // Optional
Prompt string `json:"prompt,omitempty"`
ChatHistory []Message `json:"messages,omitempty"` // Optional, stores chat history for use with Vertex Chat API
}

func handleGenAIRequest(prompt string, clientConn *connection) (string, error) {
jsonRequest := GenAIRequest{
Context: clientConn.context,
Prompt: prompt,
// For use with NPC API
type NPCRequest struct {
Msg string `json:"message,omitempty"`
FromId int `json:"from_id,omitempty"`
ToId int `json:"to_id,omitempty"`
}

// Expected format for the NPC endpoint response
type NPCResponse struct {
Response string `json:"response"`
}

// Conversation history provided to the model in a structured alternate-author form.
// https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text-chat
type Message struct {
Author string `json:"author"`
Content string `json:"content"`
}

func handleGenAIRequest(prompt string, clientConn *connection, chatHistory []Message) (string, error) {
var jsonStr []byte
var err error
// If the endpoint is the NPC API, use the json request format specifc to that API
if clientConn.npc {
npcRequest := NPCRequest{
Msg: prompt,
FromId: clientConn.fromId,
ToId: clientConn.toId,
}
jsonStr, err = json.Marshal(npcRequest)
} else {
genAIRequest := GenAIRequest{
Context: clientConn.context,
Prompt: prompt,
ChatHistory: chatHistory,
}
jsonStr, err = json.Marshal(genAIRequest)
}
jsonStr, err := json.Marshal(jsonRequest)
if err != nil {
return "", fmt.Errorf("unable to marshal json request: %v", err)
}
Expand Down Expand Up @@ -175,7 +245,7 @@ func handleGenAIRequest(prompt string, clientConn *connection) (string, error) {
}

// Two AIs (connection endpoints) talking to each other
func autonomousChat(prompt string, conn1 *connection, conn2 *connection, numChats int, wg *sync.WaitGroup, sigCtx context.Context) {
func autonomousChat(prompt string, conn1 *connection, conn2 *connection, numChats int, wg *sync.WaitGroup, sigCtx context.Context, chatHistory []Message) {
select {
case <-sigCtx.Done():
wg.Done()
Expand All @@ -186,15 +256,27 @@ func autonomousChat(prompt string, conn1 *connection, conn2 *connection, numChat
return
}

response, err := handleGenAIRequest(prompt, conn1)
response, err := handleGenAIRequest(prompt, conn1, chatHistory)
if err != nil {
log.Fatalf("Could not send request: %v", err)
}
// If we sent the request to the NPC endpoint we need to parse the json response {response: "response"}
if conn1.npc {
npcResponse := NPCResponse{}
err = json.Unmarshal([]byte(response), &npcResponse)
if err != nil {
log.Fatalf("Unable to unmarshal NPC endpoint response: %v", err)
}
response = npcResponse.Response
}
log.Printf("%d %s RESPONSE: %s\n", numChats, conn1.name, response)

chat := Message{Author: conn1.name, Content: response}
chatHistory = append(chatHistory, chat)

numChats -= 1
// Flip between the connection that the response is sent to.
autonomousChat(response, conn2, conn1, numChats, wg, sigCtx)
autonomousChat(response, conn2, conn1, numChats, wg, sigCtx, chatHistory)
}
}

Expand Down Expand Up @@ -225,7 +307,8 @@ func tcpHandleConnection(conn net.Conn, genAiConn *connection) {
txt := scanner.Text()
log.Printf("TCP txt: %v", txt)

response, err := handleGenAIRequest(txt, genAiConn)
// TODO: update with chathistroy
response, err := handleGenAIRequest(txt, genAiConn, nil)
if err != nil {
response = "ERROR: " + err.Error() + "\n"
}
Expand Down

0 comments on commit 7beb4d2

Please sign in to comment.