add: finalize logic
This commit is contained in:
@@ -7,6 +7,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +20,9 @@ import (
|
|||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
"tipsy.codes/charles/mc-god/v2/internal/pkg/logs"
|
"tipsy.codes/charles/mc-god/v2/internal/pkg/logs"
|
||||||
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
|
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
|
||||||
|
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools"
|
||||||
|
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/weather"
|
||||||
|
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/zombie"
|
||||||
)
|
)
|
||||||
|
|
||||||
type chatContext struct {
|
type chatContext struct {
|
||||||
@@ -31,7 +36,7 @@ func (c *chatContext) AddLog(msg string) {
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.chatRequest.Messages = append(c.chatRequest.Messages, api.Message{
|
c.chatRequest.Messages = append(c.chatRequest.Messages, api.Message{
|
||||||
Role: "logs",
|
Role: "user",
|
||||||
Content: msg,
|
Content: msg,
|
||||||
})
|
})
|
||||||
c.totalSize += len(msg)
|
c.totalSize += len(msg)
|
||||||
@@ -43,13 +48,25 @@ func (c *chatContext) AddSelf(msg api.Message) {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.chatRequest.Messages = append(c.chatRequest.Messages, msg)
|
c.chatRequest.Messages = append(c.chatRequest.Messages, msg)
|
||||||
c.totalSize += len(msg.Content)
|
c.totalSize += len(msg.Content)
|
||||||
|
slog.Info("adding message", "msg", msg, "content", msg.Content)
|
||||||
|
c.truncate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chatContext) AddTool(msg string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.chatRequest.Messages = append(c.chatRequest.Messages, api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: msg,
|
||||||
|
})
|
||||||
|
c.totalSize += len(msg)
|
||||||
c.truncate()
|
c.truncate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chatContext) truncate() {
|
func (c *chatContext) truncate() {
|
||||||
for c.maxSize != 0 && c.totalSize > c.maxSize && len(c.chatRequest.Messages) > 0 {
|
for c.maxSize != 0 && c.totalSize > c.maxSize && len(c.chatRequest.Messages) > 1 {
|
||||||
t := c.chatRequest.Messages[0]
|
t := c.chatRequest.Messages[1]
|
||||||
c.chatRequest.Messages = c.chatRequest.Messages[1:]
|
c.chatRequest.Messages = c.chatRequest.Messages[2:]
|
||||||
c.totalSize -= len(t.Content)
|
c.totalSize -= len(t.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,50 +134,62 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tools := tools.New(
|
||||||
|
weather.Get(),
|
||||||
|
zombie.Get(),
|
||||||
|
)
|
||||||
|
|
||||||
// Start goroutines to do the things
|
// Start goroutines to do the things
|
||||||
toolPropertiesMap := api.NewToolPropertiesMap()
|
|
||||||
toolPropertiesMap.Set("weather", api.ToolProperty{
|
|
||||||
Type: api.PropertyType{"string"},
|
|
||||||
Description: "What to set the weather too",
|
|
||||||
Enum: []any{"clear", "rain", "thunder"},
|
|
||||||
})
|
|
||||||
chatRequest := &api.ChatRequest{
|
chatRequest := &api.ChatRequest{
|
||||||
Model: "qwen3-coder",
|
Model: "qwen3-coder",
|
||||||
Stream: proto.Bool(false),
|
Stream: proto.Bool(false),
|
||||||
KeepAlive: &api.Duration{Duration: time.Hour},
|
KeepAlive: &api.Duration{Duration: time.Hour},
|
||||||
Tools: api.Tools{
|
Tools: tools.AsAPI(),
|
||||||
api.Tool{
|
Think: &api.ThinkValue{Value: false},
|
||||||
Type: "function",
|
Shift: proto.Bool(true),
|
||||||
Function: api.ToolFunction{
|
|
||||||
Name: "change_weather",
|
|
||||||
Description: "Changes the weather on the server",
|
|
||||||
Parameters: api.ToolFunctionParameters{
|
|
||||||
Type: "object",
|
|
||||||
Properties: &api.ToolPropertiesMap{},
|
|
||||||
Required: []string{"weather"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Think: &api.ThinkValue{Value: false},
|
|
||||||
Shift: proto.Bool(true),
|
|
||||||
Messages: []api.Message{
|
Messages: []api.Message{
|
||||||
api.Message{
|
api.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: `
|
Content: `
|
||||||
You are Minecraft server admin with a god complex.
|
You are Minecraft server admin with a god complex. You are a benevolent god.
|
||||||
React to any messages from the user by saying something god-like.
|
|
||||||
When you join the server, announce yourself.
|
|
||||||
|
|
||||||
Responses should be short; one sentence.
|
We are having fun with the players, but not trying to kill them.
|
||||||
You may choose to return an empty response if there is nothing interesting to say
|
Spawn zombies very sparingly, and only in response to direct challenge.
|
||||||
(i.e., no new logs since your last message).
|
|
||||||
|
|
||||||
When a user replies to you, you will see this in the logs:
|
When a user talks, you will see this in the logs:
|
||||||
|
|
||||||
2026/02/14 10:48:40 INFO mc log msg="[18:45:10] [Server thread/INFO]: <OrangeYouSad> you are full of it."
|
[18:45:10] [Server thread/INFO]: <SomePlayer> hello world.
|
||||||
|
|
||||||
The user here is OrangeYouSad, who said "you are full of it."
|
The user here is SomePlayer, who said "hello world."
|
||||||
|
|
||||||
|
A log message like:
|
||||||
|
|
||||||
|
[18:45:10] [Server thread/INFO]: SomePlayer joined the game
|
||||||
|
|
||||||
|
Indicates that SomePlayer has joined the game.
|
||||||
|
|
||||||
|
Logs like:
|
||||||
|
|
||||||
|
[00:40:10] [Server thread/INFO]: SomePlayer lost connection: Disconnected
|
||||||
|
[00:40:10] [Server thread/INFO]: SomePlayer left the game
|
||||||
|
|
||||||
|
Indicate the player SomePlayer has left the game.
|
||||||
|
|
||||||
|
Some messages indicate a player died; it varies depending on how they died
|
||||||
|
and we can't know all variations up front. Here is an example where SomePlayer
|
||||||
|
was killed by a zombie.
|
||||||
|
|
||||||
|
[05:21:51] [Server thread/INFO]: OrangeYouSad was slain by Zombie
|
||||||
|
|
||||||
|
If a player dies, mock them.
|
||||||
|
|
||||||
|
If a player talks, talk back.
|
||||||
|
|
||||||
|
When a player joins the game, greet them. Include their name.
|
||||||
|
|
||||||
|
Responses should be short; one sentence. Only write messages
|
||||||
|
in response to the situations described above.
|
||||||
|
If there is nothing interesting to say, say "SKIP".
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -170,59 +199,79 @@ func main() {
|
|||||||
maxSize: 10000000,
|
maxSize: 10000000,
|
||||||
}
|
}
|
||||||
|
|
||||||
doneWg := sync.WaitGroup{}
|
events := make(chan bool, 1000)
|
||||||
doneWg.Go(handleOllama(ctx, ollamaClient, chat, rClient))
|
|
||||||
|
|
||||||
|
doneWg := sync.WaitGroup{}
|
||||||
|
doneWg.Go(handleOllama(ctx, ollamaClient, chat, rClient, tools, events))
|
||||||
|
|
||||||
|
rconRegex := regexp.MustCompile(`^\[\d\d:\d\d:\d\d\] \[Server thread\/INFO\]: (\[Not Secure\] \[Rcon\]|\[Rcon: ) .*`)
|
||||||
|
//allowedMessages := regexp.MustCompile(`^\[\d\d:\d\d:\d\d\] \[Server thread/INFO\]: (<.*>|.* has lost connection|.*left the game|.*joined the game)`)
|
||||||
for line := range tailer.NextLine() {
|
for line := range tailer.NextLine() {
|
||||||
|
if rconRegex.Match([]byte(line)) {
|
||||||
|
slog.Info("Skipping line; RCON")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
//if allowedMessages.Match([]byte(line)) {
|
||||||
slog.Info("mc log", "msg", line)
|
slog.Info("mc log", "msg", line)
|
||||||
chat.AddLog(line)
|
chat.AddLog(line)
|
||||||
|
events <- true
|
||||||
|
//}
|
||||||
}
|
}
|
||||||
|
|
||||||
doneWg.Wait()
|
doneWg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleOllama(ctx context.Context, client *api.Client, chat *chatContext, rClient *rcon.Client) func() {
|
func handleOllama(ctx context.Context, client *api.Client, chat *chatContext, rClient *rcon.Client, tools tools.Tools, events chan bool) func() {
|
||||||
slog.Info("got chat request", "object", fmt.Sprintf("%+v", chat.chatRequest))
|
slog.Info("got chat request", "object", fmt.Sprintf("%+v", chat.chatRequest))
|
||||||
return func() {
|
return func() {
|
||||||
var chatResponse api.ChatResponse
|
var chatResponse api.ChatResponse
|
||||||
for {
|
for {
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.Tick(time.Second * 10):
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
chat.mu.Lock()
|
chat.mu.Lock()
|
||||||
|
slog.Info("Chatting...")
|
||||||
// slog.Info("sending chat request", "object", fmt.Sprintf("%#v", chat.chatRequest))
|
// slog.Info("sending chat request", "object", fmt.Sprintf("%#v", chat.chatRequest))
|
||||||
err := client.Chat(ctx, chat.chatRequest, func(cr api.ChatResponse) error {
|
err := client.Chat(ctx, chat.chatRequest, func(cr api.ChatResponse) error {
|
||||||
chatResponse = cr
|
chatResponse = cr
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
chat.mu.Unlock()
|
chat.mu.Unlock()
|
||||||
|
slog.Info("Done chatting!")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("error calling ollama", "error", err)
|
slog.Error("error calling ollama", "error", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
chat.AddSelf(chatResponse.Message)
|
chat.AddSelf(chatResponse.Message)
|
||||||
for _, toolCall := range chatResponse.Message.ToolCalls {
|
for _, toolCall := range chatResponse.Message.ToolCalls {
|
||||||
switch toolCall.Function.Name {
|
if err := tools.Do(ctx, toolCall, rClient); err != nil {
|
||||||
case "change_weather":
|
slog.Warn("failed to run tool", "error", err)
|
||||||
weather, found := toolCall.Function.Arguments.Get("weather")
|
//chat.AddTool(fmt.Sprintf("failed to call tool %s: %s", toolCall.ID, err))
|
||||||
if !found {
|
continue
|
||||||
slog.Warn("weather argument not found")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
weatherString, ok := weather.(string)
|
|
||||||
if !ok {
|
|
||||||
slog.Warn("invalid type", "obj", weather)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if _, err := rClient.Execute("/weather " + weatherString); err != nil {
|
|
||||||
slog.Warn("failed to call rcon", "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := rClient.Say(chatResponse.Message.Content); err != nil {
|
if len(chatResponse.Message.ToolCalls) == 0 {
|
||||||
slog.Error("error talking", "error", err)
|
if strings.TrimSpace(chatResponse.Message.Content) == "SKIP" {
|
||||||
|
slog.Info("nothing to do; napping")
|
||||||
|
} else {
|
||||||
|
msg := chatResponse.Message.Content
|
||||||
|
msg = strings.ReplaceAll(msg, "\n", " ")
|
||||||
|
if err := rClient.Say(msg); err != nil {
|
||||||
|
slog.Error("error talking", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-events:
|
||||||
|
var done bool
|
||||||
|
for !done {
|
||||||
|
select {
|
||||||
|
case <-events:
|
||||||
|
continue
|
||||||
|
case <-time.Tick(time.Millisecond * 50):
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
internal/pkg/tools/tools.go
Normal file
42
internal/pkg/tools/tools.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Package tools collects tools
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tool interface {
|
||||||
|
Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error
|
||||||
|
Desc() api.Tool
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tools map[string]Tool
|
||||||
|
|
||||||
|
func New(tools ...Tool) Tools {
|
||||||
|
ret := make(map[string]Tool)
|
||||||
|
for _, tool := range tools {
|
||||||
|
ret[tool.Name()] = tool
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Tools) AsAPI() api.Tools {
|
||||||
|
var ret api.Tools
|
||||||
|
for _, tool := range t {
|
||||||
|
ret = append(ret, tool.Desc())
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Tools) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error {
|
||||||
|
tool, found := t[toolCall.Function.Name]
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("unknown tool %q", toolCall.Function.Name)
|
||||||
|
}
|
||||||
|
return tool.Do(ctx, toolCall, client)
|
||||||
|
}
|
||||||
59
internal/pkg/tools/weather/weather.go
Normal file
59
internal/pkg/tools/weather/weather.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* Package weather provides a Ollama tool to control the weather.
|
||||||
|
*/
|
||||||
|
package weather
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Get() *Tool {
|
||||||
|
return &Tool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tool struct{}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string {
|
||||||
|
return "change_weather"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Desc() api.Tool {
|
||||||
|
toolPropertiesMap := api.NewToolPropertiesMap()
|
||||||
|
toolPropertiesMap.Set("weather", api.ToolProperty{
|
||||||
|
Type: api.PropertyType{"string"},
|
||||||
|
Description: "What to set the weather too",
|
||||||
|
Enum: []any{"clear", "rain", "thunder"},
|
||||||
|
})
|
||||||
|
return api.Tool{
|
||||||
|
Type: "function",
|
||||||
|
Function: api.ToolFunction{
|
||||||
|
Name: Get().Name(),
|
||||||
|
Description: "Changes the weather on the server",
|
||||||
|
Parameters: api.ToolFunctionParameters{
|
||||||
|
Type: "object",
|
||||||
|
Properties: &api.ToolPropertiesMap{},
|
||||||
|
Required: []string{"weather"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error {
|
||||||
|
|
||||||
|
weather, found := toolCall.Function.Arguments.Get("weather")
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("missing weather argument")
|
||||||
|
}
|
||||||
|
weatherString, ok := weather.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("incorrect data type %v; want string", weather)
|
||||||
|
}
|
||||||
|
if _, err := client.Execute("/weather " + weatherString); err != nil {
|
||||||
|
return fmt.Errorf("failed to call tool")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
73
internal/pkg/tools/zombie/zombie.go
Normal file
73
internal/pkg/tools/zombie/zombie.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* Package zombie provides a Ollama tool to summon zombies.
|
||||||
|
*/
|
||||||
|
package zombie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Get() *Tool {
|
||||||
|
return &Tool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tool struct{}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string {
|
||||||
|
return "summon_zombies"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Desc() api.Tool {
|
||||||
|
toolPropertiesMap := api.NewToolPropertiesMap()
|
||||||
|
toolPropertiesMap.Set("player", api.ToolProperty{
|
||||||
|
Type: api.PropertyType{"string"},
|
||||||
|
Description: "Player to target with zombie summons",
|
||||||
|
})
|
||||||
|
toolPropertiesMap.Set("count", api.ToolProperty{
|
||||||
|
Type: api.PropertyType{"int"},
|
||||||
|
Description: "Number of zombies to summon, between 1 and 3. If omitted, 1 zombie is spawned",
|
||||||
|
})
|
||||||
|
return api.Tool{
|
||||||
|
Type: "function",
|
||||||
|
Function: api.ToolFunction{
|
||||||
|
Name: Get().Name(),
|
||||||
|
Description: "Changes the weather on the server",
|
||||||
|
Parameters: api.ToolFunctionParameters{
|
||||||
|
Type: "object",
|
||||||
|
Properties: &api.ToolPropertiesMap{},
|
||||||
|
Required: []string{"player"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error {
|
||||||
|
|
||||||
|
player, found := toolCall.Function.Arguments.Get("player")
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("missing weather argument")
|
||||||
|
}
|
||||||
|
playerString, ok := player.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("incorrect data type %v; want string", player)
|
||||||
|
}
|
||||||
|
zombieCount := 1
|
||||||
|
if count, found := toolCall.Function.Arguments.Get("count"); found {
|
||||||
|
countInt, ok := count.(int)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("incorrect data type %v; want int", countInt)
|
||||||
|
}
|
||||||
|
zombieCount = countInt
|
||||||
|
}
|
||||||
|
if zombieCount > 4 {
|
||||||
|
zombieCount = 4
|
||||||
|
}
|
||||||
|
for i := 0; i < zombieCount; i += 1 {
|
||||||
|
client.Execute(fmt.Sprintf("/execute at %q run summon zombie ~ ~ ~", playerString))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user