add: finalize logic

This commit is contained in:
2026-02-15 21:25:17 -08:00
parent 6ad52121d3
commit 964544bd82
4 changed files with 284 additions and 61 deletions

View File

@@ -7,6 +7,8 @@ import (
"log/slog"
"os"
"os/signal"
"regexp"
"strings"
"sync"
"syscall"
"time"
@@ -18,6 +20,9 @@ import (
"k8s.io/client-go/tools/clientcmd"
"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/tools"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/weather"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/zombie"
)
type chatContext struct {
@@ -31,7 +36,7 @@ func (c *chatContext) AddLog(msg string) {
c.mu.Lock()
defer c.mu.Unlock()
c.chatRequest.Messages = append(c.chatRequest.Messages, api.Message{
Role: "logs",
Role: "user",
Content: msg,
})
c.totalSize += len(msg)
@@ -43,13 +48,25 @@ func (c *chatContext) AddSelf(msg api.Message) {
defer c.mu.Unlock()
c.chatRequest.Messages = append(c.chatRequest.Messages, msg)
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()
}
func (c *chatContext) truncate() {
for c.maxSize != 0 && c.totalSize > c.maxSize && len(c.chatRequest.Messages) > 0 {
t := c.chatRequest.Messages[0]
c.chatRequest.Messages = c.chatRequest.Messages[1:]
for c.maxSize != 0 && c.totalSize > c.maxSize && len(c.chatRequest.Messages) > 1 {
t := c.chatRequest.Messages[1]
c.chatRequest.Messages = c.chatRequest.Messages[2:]
c.totalSize -= len(t.Content)
}
}
@@ -117,50 +134,62 @@ func main() {
return
}
tools := tools.New(
weather.Get(),
zombie.Get(),
)
// 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{
Model: "qwen3-coder",
Stream: proto.Bool(false),
KeepAlive: &api.Duration{Duration: time.Hour},
Tools: api.Tools{
api.Tool{
Type: "function",
Function: api.ToolFunction{
Name: "change_weather",
Description: "Changes the weather on the server",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: &api.ToolPropertiesMap{},
Required: []string{"weather"},
},
},
},
},
Tools: tools.AsAPI(),
Think: &api.ThinkValue{Value: false},
Shift: proto.Bool(true),
Messages: []api.Message{
api.Message{
Role: "system",
Content: `
You are Minecraft server admin with a god complex.
React to any messages from the user by saying something god-like.
When you join the server, announce yourself.
You are Minecraft server admin with a god complex. You are a benevolent god.
Responses should be short; one sentence.
You may choose to return an empty response if there is nothing interesting to say
(i.e., no new logs since your last message).
We are having fun with the players, but not trying to kill them.
Spawn zombies very sparingly, and only in response to direct challenge.
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,61 +199,81 @@ func main() {
maxSize: 10000000,
}
doneWg := sync.WaitGroup{}
doneWg.Go(handleOllama(ctx, ollamaClient, chat, rClient))
events := make(chan bool, 1000)
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() {
if rconRegex.Match([]byte(line)) {
slog.Info("Skipping line; RCON")
continue
}
//if allowedMessages.Match([]byte(line)) {
slog.Info("mc log", "msg", line)
chat.AddLog(line)
events <- true
//}
}
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))
return func() {
var chatResponse api.ChatResponse
for {
select {
case <-ctx.Done():
return
case <-time.Tick(time.Second * 10):
// do nothing
}
chat.mu.Lock()
slog.Info("Chatting...")
// slog.Info("sending chat request", "object", fmt.Sprintf("%#v", chat.chatRequest))
err := client.Chat(ctx, chat.chatRequest, func(cr api.ChatResponse) error {
chatResponse = cr
return nil
})
chat.mu.Unlock()
slog.Info("Done chatting!")
if err != nil {
slog.Error("error calling ollama", "error", err)
return
}
chat.AddSelf(chatResponse.Message)
for _, toolCall := range chatResponse.Message.ToolCalls {
switch toolCall.Function.Name {
case "change_weather":
weather, found := toolCall.Function.Arguments.Get("weather")
if !found {
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 := tools.Do(ctx, toolCall, rClient); err != nil {
slog.Warn("failed to run tool", "error", err)
//chat.AddTool(fmt.Sprintf("failed to call tool %s: %s", toolCall.ID, err))
continue
}
}
}
if err := rClient.Say(chatResponse.Message.Content); err != nil {
if len(chatResponse.Message.ToolCalls) == 0 {
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
}
}
}
}

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

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

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