Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .toad.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,9 @@ issue_tracker:
log:
level: "info" # debug, info, warn, error
# file: "~/.toad/toad.log"

# Vacation mode: disable all autonomous behavior (passive monitoring, Toad King,
# PR watching). Direct interactions get a polite decline and are saved.
# Toggle from Slack: "@toad go on vacation" / "@toad time to come back!".
# vacation_mode: false # force vacation on (locks out the Slack toggle)
# vacation_admins: [] # Slack user IDs allowed to toggle; empty = anyone
31 changes: 31 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The complete guide to installing, configuring, and running toad — from zero to
- [log](#log)
- [mcp](#mcp)
- [personality](#personality)
- [vacation_mode](#vacation_mode)
- [Environment Variables](#environment-variables)
- [CLI Commands](#cli-commands)
- [Interacting with Toad](#interacting-with-toad)
Expand Down Expand Up @@ -636,6 +637,29 @@ personality:

The radar chart in `toad status` and the kiosk view visualize current trait values.

### `vacation_mode`

Turn off all autonomous behavior without uninstalling toad.

```yaml
vacation_mode: false
vacation_admins: []
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `vacation_mode` | bool | `false` | Force vacation mode on; it cannot be disabled from Slack while set |
| `vacation_admins` | list | `[]` | Slack user IDs allowed to toggle vacation from Slack; empty = anyone |

When on vacation, toad stays connected to Slack but does nothing on its own: no message triage, no digest analysis, no PR review or CI fix tadpoles. Anyone who directly addresses toad (an @mention, keyword or emoji trigger, or button click) gets a polite decline, and their message is saved to the `vacation_messages` table in the state database for review when toad returns.

Vacation can also be toggled at runtime from Slack, no restart needed:

- A mention containing **"go on vacation"** or **"vacation time"** starts the vacation
- A mention containing **"come back"** (or "back from vacation", "vacation is over") ends it

The runtime state is persisted in the state database and survives restarts. The `vacation_mode` config flag forces vacation on; while set, the Slack toggle cannot end it.

---

## Environment Variables
Expand All @@ -648,6 +672,7 @@ Environment variables override config file values.
| `TOAD_SLACK_BOT_TOKEN` | `slack.bot_token` | Slack bot OAuth token (`xoxb-...`) |
| `TOAD_LINEAR_API_TOKEN` | `issue_tracker.api_token` | Linear API token for issue tracking |
| `TOAD_GITLAB_HOST` | `vcs.host` | Self-hosted GitLab hostname |
| `TOAD_VACATION_MODE` | `vacation_mode` | Set to `1` or `true` to enable vacation mode |
| `SUPERVISED` | — | Set to `1` when running under a process supervisor (see [Running Under a Process Supervisor](#running-under-a-process-supervisor)) |

You can also use `${ENV_VAR}` syntax in YAML values to reference environment variables:
Expand Down Expand Up @@ -1178,4 +1203,10 @@ personality:
enabled: false # opt-in adaptive personality
learning_enabled: true # allow traits to adapt
file_path: ~/.toad/personality.yaml # personality state file

# Vacation mode: disable all autonomous behavior. Direct interactions get a
# polite decline and the message is saved for later review. Toggle from Slack
# with "@toad go on vacation" / "@toad back from vacation".
vacation_mode: false
vacation_admins: [] # Slack user IDs allowed to toggle; empty = anyone
```
87 changes: 87 additions & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"slices"
"strings"
"time"

Expand All @@ -27,6 +28,7 @@ func handleMessage(
ribbitEngine *ribbit.Engine,
slackClient *islack.Client,
stateManager *state.Manager,
vacationState *state.VacationState,
ribbitSem chan struct{},
tadpolePool *tadpole.Pool,
digestEngine *digest.Engine,
Expand All @@ -37,6 +39,22 @@ func handleMessage(
// Resolve channel name for context
channelName := slackClient.ResolveChannelName(msg.Channel)

if msg.IsMention && !msg.IsBot && canToggleVacation(cfg.VacationAdmins, msg.User) {
if vacationState.Active() && isVacationEndPhrase(msg.Text) {
handleVacationEnd(msg, slackClient, vacationState, channelName)
return
}
if !vacationState.Active() && isVacationStartPhrase(msg.Text) {
handleVacationStart(msg, slackClient, vacationState, channelName)
return
}
}

if vacationState.Active() {
handleVacationMode(msg, slackClient, stateManager, channelName)
return
}

// TADPOLE REQUEST: :frog: reaction on a toad reply
// Must be checked BEFORE the bot filter — tadpole requests are reactions on
// toad's own (bot) messages, so the fetched message will have IsBot=true.
Expand Down Expand Up @@ -586,3 +604,72 @@ func handleTadpoleRequest(
// Don't unclaim on defer.
claimed = false
}

const vacationReply = ":frog: Sorry I am not allowed to do things anymore, because I'm an idiot :(. But I have saved your message! If there is something else you want me to save, e.g. ideas on how to improve me, let me know."

const vacationReplySaveFailed = ":frog: Sorry I am not allowed to do things anymore, because I'm an idiot :(. I tried to save your message but even that failed — please tell a human."

const vacationStartReply = ":frog: :palm_tree: Thanks! I hop away to rest a spell,\nto come back energized and well,\nand when I leap back to my pond again,\nI'll bless you with my findings then! :sparkles:"

const vacationEndReply = ":frog: I'm back! Rested, energized, and ready to bless you with findings again. :sparkles:"

const vacationLockedReply = ":frog: My vacation is locked on by config (`vacation_mode: true`), so I can't come back until that changes."

func handleVacationStart(msg *islack.IncomingMessage, slackClient *islack.Client, vacationState *state.VacationState, channelName string) {
slog.Info("vacation mode enabled via slack", "channel", channelName, "user", msg.User)
if err := vacationState.Set(true); err != nil {
slog.Warn("failed to persist vacation state", "error", err)
}
slackClient.ReplyInThread(msg.Channel, msg.ThreadTS(), vacationStartReply)
}

func handleVacationEnd(msg *islack.IncomingMessage, slackClient *islack.Client, vacationState *state.VacationState, channelName string) {
if vacationState.Forced() {
slackClient.ReplyInThread(msg.Channel, msg.ThreadTS(), vacationLockedReply)
return
}
slog.Info("vacation mode disabled via slack", "channel", channelName, "user", msg.User)
if err := vacationState.Set(false); err != nil {
slog.Warn("failed to persist vacation state", "error", err)
}
slackClient.ReplyInThread(msg.Channel, msg.ThreadTS(), vacationEndReply)
}

func isVacationStartPhrase(text string) bool {
t := strings.ToLower(text)
return strings.Contains(t, "go on vacation") || strings.Contains(t, "vacation time")
}

func isVacationEndPhrase(text string) bool {
t := strings.ToLower(text)
return strings.Contains(t, "come back") || strings.Contains(t, "back from vacation") || strings.Contains(t, "vacation is over")
}

func canToggleVacation(admins []string, userID string) bool {
if len(admins) == 0 {
return true
}
return slices.Contains(admins, userID)
}

func handleVacationMode(msg *islack.IncomingMessage, slackClient *islack.Client, stateManager *state.Manager, channelName string) {
if !isDirectInteraction(msg) {
return
}

slog.Info("vacation mode: declining interaction", "channel", channelName, "user", msg.User)

reply := vacationReply
if err := stateManager.SaveVacationMessage(msg.Channel, channelName, msg.User, msg.Text, msg.ThreadTS()); err != nil {
slog.Warn("vacation mode: failed to save message", "error", err)
reply = vacationReplySaveFailed
}
slackClient.ReplyInThread(msg.Channel, msg.ThreadTS(), reply)
}

func isDirectInteraction(msg *islack.IncomingMessage) bool {
if msg.IsTadpoleRequest {
return true
}
return (msg.IsMention || msg.IsTriggered) && !msg.IsBot
}
76 changes: 76 additions & 0 deletions cmd/handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cmd

import (
"testing"

islack "github.com/scaler-tech/toad/internal/slack"
)

func TestIsDirectInteraction(t *testing.T) {
tests := []struct {
name string
msg *islack.IncomingMessage
want bool
}{
{"mention", &islack.IncomingMessage{IsMention: true, IsTriggered: true}, true},
{"keyword trigger", &islack.IncomingMessage{IsTriggered: true}, true},
{"tadpole request on bot message", &islack.IncomingMessage{IsTadpoleRequest: true, IsTriggered: true, IsBot: true}, true},
{"bot mention", &islack.IncomingMessage{IsMention: true, IsTriggered: true, IsBot: true}, false},
{"plain message", &islack.IncomingMessage{}, false},
{"bot message", &islack.IncomingMessage{IsBot: true}, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isDirectInteraction(tt.msg); got != tt.want {
t.Errorf("isDirectInteraction() = %v, want %v", got, tt.want)
}
})
}
}

func TestVacationPhrases(t *testing.T) {
start := []string{
"<@U0TOAD> you can now go on vacation",
"Hey toad, VACATION TIME!",
}
for _, text := range start {
if !isVacationStartPhrase(text) {
t.Errorf("expected start phrase match: %q", text)
}
if isVacationEndPhrase(text) {
t.Errorf("start phrase must not match end: %q", text)
}
}

end := []string{
"<@U0TOAD> time to come back!",
"<@U0TOAD> welcome back from vacation!",
"toad your vacation is over",
}
for _, text := range end {
if !isVacationEndPhrase(text) {
t.Errorf("expected end phrase match: %q", text)
}
if isVacationStartPhrase(text) {
t.Errorf("end phrase must not match start: %q", text)
}
}

neutral := "can you fix the export bug?"
if isVacationStartPhrase(neutral) || isVacationEndPhrase(neutral) {
t.Errorf("neutral text must not match: %q", neutral)
}
}

func TestCanToggleVacation(t *testing.T) {
if !canToggleVacation(nil, "U1") {
t.Error("empty admin list should allow anyone")
}
if !canToggleVacation([]string{"U1", "U2"}, "U2") {
t.Error("listed admin should be allowed")
}
if canToggleVacation([]string{"U1"}, "U3") {
t.Error("unlisted user should be denied")
}
}
15 changes: 13 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ func runDaemon(cmd *cobra.Command, args []string) error {
return fmt.Errorf("hydrating state: %w", err)
}

vacationState := state.NewVacationState(stateDB, cfg.VacationMode)

// Initialize personality manager
var personalityMgr *personality.Manager
if cfg.Personality.Enabled {
Expand Down Expand Up @@ -250,7 +252,11 @@ func runDaemon(cmd *cobra.Command, args []string) error {
digestEngine = digest.New(&cfg.Digest, digest.EngineOpts{
AgentProvider: agentProvider,
TriageModel: cfg.Triage.Model,
Paused: vacationState.Active,
Spawn: func(ctx context.Context, task tadpole.Task) error {
if vacationState.Active() {
return fmt.Errorf("vacation mode active")
}
return tadpolePool.Spawn(ctx, task)
},
Notify: func(channel, threadTS, text string) {
Expand Down Expand Up @@ -379,7 +385,7 @@ func runDaemon(cmd *cobra.Command, args []string) error {
messageWg.Add(1)
go func() {
defer messageWg.Done()
handleMessage(ctx, msg, cfg, agentProvider, triageEngine, ribbitEngine, slackClient, stateManager, ribbitSem, tadpolePool, digestEngine, tracker, resolver, repoPaths)
handleMessage(ctx, msg, cfg, agentProvider, triageEngine, ribbitEngine, slackClient, stateManager, vacationState, ribbitSem, tadpolePool, digestEngine, tracker, resolver, repoPaths)
}()
})

Expand Down Expand Up @@ -425,6 +431,10 @@ func runDaemon(cmd *cobra.Command, args []string) error {
"triggers", fmt.Sprintf("emoji=%s keywords=%v", cfg.Slack.Triggers.Emoji, cfg.Slack.Triggers.Keywords),
)

if vacationState.Active() {
slog.Warn("vacation mode enabled — passive monitoring, digest, and PR watching are disabled; direct interactions get a decline reply")
}

// Start MCP server if enabled
if mcpSrv != nil {
mcpSrv.Health().Version = Version
Expand All @@ -436,6 +446,7 @@ func runDaemon(cmd *cobra.Command, args []string) error {
}

// Start PR review watcher
prWatcher.SetPaused(vacationState.Active)
go prWatcher.Run(ctx)

// Start periodic repo sync if enabled
Expand All @@ -453,7 +464,7 @@ func runDaemon(cmd *cobra.Command, args []string) error {
if digestEngine != nil {
go digestEngine.Run(ctx)
// Resume any investigations that were interrupted by a previous crash.
if recovery != nil && len(recovery.StaleOpportunities) > 0 {
if recovery != nil && len(recovery.StaleOpportunities) > 0 && !vacationState.Active() {
staleOpps := recovery.StaleOpportunities
go digestEngine.ResumeInvestigations(ctx, staleOpps)
}
Expand Down
29 changes: 17 additions & 12 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@ import (
)

type Config struct {
Slack SlackConfig `yaml:"slack"`
Repos ReposConfig `yaml:"repos"`
Limits LimitsConfig `yaml:"limits"`
Triage TriageConfig `yaml:"triage"`
Claude ClaudeConfig `yaml:"claude"` // Deprecated: use Agent.Model and Agent.AppendSystemPrompt
Digest DigestConfig `yaml:"digest"`
IssueTracker IssueTrackerConfig `yaml:"issue_tracker"`
VCS VCSConfig `yaml:"vcs"`
Agent AgentConfig `yaml:"agent"`
Log LogConfig `yaml:"log"`
MCP MCPConfig `yaml:"mcp"`
Personality PersonalityConfig `yaml:"personality"`
Slack SlackConfig `yaml:"slack"`
Repos ReposConfig `yaml:"repos"`
Limits LimitsConfig `yaml:"limits"`
Triage TriageConfig `yaml:"triage"`
Claude ClaudeConfig `yaml:"claude"` // Deprecated: use Agent.Model and Agent.AppendSystemPrompt
Digest DigestConfig `yaml:"digest"`
IssueTracker IssueTrackerConfig `yaml:"issue_tracker"`
VCS VCSConfig `yaml:"vcs"`
Agent AgentConfig `yaml:"agent"`
Log LogConfig `yaml:"log"`
MCP MCPConfig `yaml:"mcp"`
Personality PersonalityConfig `yaml:"personality"`
VacationMode bool `yaml:"vacation_mode"`
VacationAdmins []string `yaml:"vacation_admins"`
}

type SlackConfig struct {
Expand Down Expand Up @@ -284,6 +286,9 @@ func applyEnv(cfg *Config) {
if v := os.Getenv("TOAD_LOG_LEVEL"); v != "" {
cfg.Log.Level = v
}
if v := os.Getenv("TOAD_VACATION_MODE"); v == "1" || strings.EqualFold(v, "true") {
cfg.VacationMode = true
}
}

// Validate checks that required configuration is present.
Expand Down
Loading
Loading