Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Boatman is an AI-powered autonomous development system that:
- **Supports resume** — pick up a failed execution from the review/refactor stage without re-doing the work
- **Routes model calls through provider adapters** so Claude CLI, OpenAI Responses, and future providers can be adopted per workflow role
- **Models workflows as provider-neutral templates** with explicit stages, gates, previews, skips, and validation loops
- **Runs repeatable project routines** from `.boatman/routines.json` or `.boatman/routines/*.json`, including the built-in Datadog GraphQL slow-query investigation
- **Runs repeatable project routines** from `.boatman/routines.json` or `.boatman/routines/*.json`, including the built-in Datadog GraphQL slow-query investigation, with each desktop run opening a continuable chat session
- **Evaluates side-effecting actions through approval policy rules** before humans, chat clients, or future services render durable approvals
- **Runs independent verifier checks** so code review, runtime recordings, and future central-plane quality gates share one contract
- **Records inspectable runtime runs** with normalized events, original requests, artifacts, usage, raw provider payloads, integration status, and memory-load events
Expand Down Expand Up @@ -119,7 +119,7 @@ A cross-platform desktop application built with Wails that provides a GUI for th
- **Firefighter mode** for production incident investigation
- **Agent logs panel** for real-time visibility into AI actions
- **Runtime tab** for inspecting `.boatman/runs` events, artifacts, and `.boatman/memory` documents
- **Routines tab** for running built-in and project-local routines from the desktop app
- **Routines tab** for running built-in and project-local routines from the desktop app as new continuable agent sessions
- **Integration health** for MCP-backed services before starting incident or autonomous workflows
- **Onboarding wizard** for first-time setup
- **MCP server management** via UI dialog
Expand Down
1 change: 1 addition & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ See [TRIAGE.md](./TRIAGE.md) for full documentation.
- **Datadog GraphQL Slow Queries**: Run the saved slow-query investigation from the desktop app
- **Datadog MCP Auth**: Open Claude Code's Datadog MCP auth flow in an interactive terminal from the Routines tab, creating `boatman-datadog-mcp` on Datadog's current `/v1/mcp` endpoint when needed
- **Dry-Run Checks**: Verify Datadog readiness and prompt shape before model execution
- **Continuable Sessions**: Every desktop routine run creates a chat session with the same ID as the runtime run
- **Markdown Reports**: Save daily investigation output under project-local `.boatman/routines`

### 🔍 Advanced UI Features
Expand Down
85 changes: 85 additions & 0 deletions desktop/agent/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"sync"

"github.com/google/uuid"
agentruntime "github.com/philjestin/boatman-ecosystem/shared/agentruntime"
"github.com/wailsapp/wails/v2/pkg/runtime"
)

Expand Down Expand Up @@ -41,6 +42,20 @@ type Manager struct {
configGetter ConfigGetter
}

// RoutineSessionOptions describes a routine-backed chat session. Routine
// executions use ordinary sessions so users can continue the same runtime agent.
type RoutineSessionOptions struct {
RoutineID string
RoutineName string
Profile string
Provider string
Model string
ReasoningEffort string
Instructions string
Values map[string]string
MCPServers []agentruntime.MCPServerRef
}

// NewManager creates a new agent manager
func NewManager() *Manager {
return &Manager{
Expand Down Expand Up @@ -236,6 +251,76 @@ func (m *Manager) CreateTriageSession(projectPath string) (*Session, error) {
return session, nil
}

// CreateRoutineSession creates a new session tied to a routine execution.
func (m *Manager) CreateRoutineSession(projectPath string, sessionID string, opts RoutineSessionOptions) (*Session, error) {
m.mu.Lock()
defer m.mu.Unlock()

if sessionID == "" {
sessionID = uuid.New().String()
}
if _, exists := m.sessions[sessionID]; exists {
return nil, fmt.Errorf("session already exists: %s", sessionID)
}

model := opts.Model
if model == "" {
model = m.defaultModel
}
effort := opts.ReasoningEffort
if effort == "" {
effort = "medium"
}
profile := opts.Profile
if profile == "" {
profile = "desktop-routine"
}

session := NewSession(sessionID, projectPath)
session.Model = model
session.ReasoningEffort = effort
session.Mode = "routine"
session.ModeConfig = map[string]interface{}{
"routineId": opts.RoutineID,
"routineName": opts.RoutineName,
"profile": profile,
"provider": opts.Provider,
"mcpServers": opts.MCPServers,
"values": cloneStringMap(opts.Values),
}
session.systemPrompt = opts.Instructions
session.Tags = append(session.Tags, "routine", opts.RoutineID)

m.setupSessionHandlers(session, sessionID)

if m.configGetter != nil {
maxMessages := m.configGetter.GetMaxMessagesPerSession()
if maxMessages < 1000 {
maxMessages = 1000
}
archive := m.configGetter.GetArchiveOldMessages()
session.SetTrimSettings(maxMessages, archive)

maxAgents := m.configGetter.GetMaxAgentsPerSession()
keepCompleted := m.configGetter.GetKeepCompletedAgents()
session.SetAgentCleanupSettings(maxAgents, keepCompleted)
}

m.sessions[sessionID] = session
return session, nil
}

func cloneStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
out[key] = value
}
return out
}

// setupSessionHandlers sets up event handlers for a session
func (m *Manager) setupSessionHandlers(session *Session, sessionID string) {
session.SetMessageHandler(func(msg Message) {
Expand Down
125 changes: 125 additions & 0 deletions desktop/agent/runtime_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ func (s *Session) buildRuntimeRequest(prompt string, authConfig AuthConfig) agen
actualPrompt = GetFirefighterPrompt(scope, mcpNames...) + "\n\n" + prompt
}
}
if mode == "routine" {
role = agentruntime.RoleRoutine
profile = stringFromModeConfig(modeConfig, "profile", "desktop-routine")
mcpServers = mcpRefsFromModeConfig(modeConfig)
}

metadata := map[string]string{
"outputFormat": "stream-json",
Expand Down Expand Up @@ -134,3 +139,123 @@ func mcpNamesFromModeConfig(modeConfig map[string]interface{}) []string {
}
return nil
}

func stringFromModeConfig(modeConfig map[string]interface{}, key, fallback string) string {
if modeConfig != nil {
if value, ok := modeConfig[key].(string); ok {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
}
return fallback
}

func mcpRefsFromModeConfig(modeConfig map[string]interface{}) []agentruntime.MCPServerRef {
if modeConfig == nil {
return nil
}
raw, ok := modeConfig["mcpServers"]
if !ok {
return nil
}
switch refs := raw.(type) {
case []agentruntime.MCPServerRef:
return refs
case []interface{}:
out := make([]agentruntime.MCPServerRef, 0, len(refs))
for _, value := range refs {
if ref, ok := mcpRefFromAny(value); ok {
out = append(out, ref)
}
}
return out
case []map[string]interface{}:
out := make([]agentruntime.MCPServerRef, 0, len(refs))
for _, value := range refs {
if ref, ok := mcpRefFromMap(value); ok {
out = append(out, ref)
}
}
return out
default:
return nil
}
}

func mcpRefFromAny(value interface{}) (agentruntime.MCPServerRef, bool) {
switch typed := value.(type) {
case agentruntime.MCPServerRef:
return typed, strings.TrimSpace(typed.Label) != ""
case map[string]interface{}:
return mcpRefFromMap(typed)
default:
return agentruntime.MCPServerRef{}, false
}
}

func mcpRefFromMap(value map[string]interface{}) (agentruntime.MCPServerRef, bool) {
ref := agentruntime.MCPServerRef{
Label: stringMapValue(value, "label"),
Command: stringMapValue(value, "command"),
URL: stringMapValue(value, "url"),
Description: stringMapValue(value, "description"),
Args: stringSliceMapValue(value, "args"),
Env: stringMapMapValue(value, "env"),
}
return ref, strings.TrimSpace(ref.Label) != ""
}

func stringMapValue(value map[string]interface{}, key string) string {
raw, ok := value[key]
if !ok {
return ""
}
text, _ := raw.(string)
return text
}

func stringSliceMapValue(value map[string]interface{}, key string) []string {
raw, ok := value[key]
if !ok {
return nil
}
if items, ok := raw.([]string); ok {
return items
}
items, ok := raw.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
if text, ok := item.(string); ok {
out = append(out, text)
}
}
return out
}

func stringMapMapValue(value map[string]interface{}, key string) map[string]string {
raw, ok := value[key]
if !ok {
return nil
}
if items, ok := raw.(map[string]string); ok {
return items
}
items, ok := raw.(map[string]interface{})
if !ok {
return nil
}
out := make(map[string]string, len(items))
for itemKey, itemValue := range items {
if text, ok := itemValue.(string); ok {
out[itemKey] = text
}
}
if len(out) == 0 {
return nil
}
return out
}
52 changes: 52 additions & 0 deletions desktop/agent/runtime_request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,55 @@ func TestBuildRuntimeRequestFirefighterFullAuto(t *testing.T) {
t.Fatalf("args = %v, should not include auto-edit tool allowlist", args)
}
}

func TestBuildRuntimeRequestRoutineKeepsRoutineContext(t *testing.T) {
session := NewSession("routine-run-1", "/repo")
session.Mode = "routine"
session.Model = "claude-sonnet"
session.systemPrompt = "Investigate the slow GraphQL operations."
session.ModeConfig = map[string]interface{}{
"profile": "datadog-gql-slow-queries",
"provider": "claude-cli",
"mcpServers": []interface{}{
map[string]interface{}{
"label": "datadog",
"command": "npx",
"args": []interface{}{"-y", "@datadog/mcp-server"},
"env": map[string]interface{}{
"DD_SITE": "datadoghq.com",
},
},
},
}

req := session.buildRuntimeRequest("what should we fix first?", AuthConfig{
Method: "anthropic-api",
ApprovalMode: "suggest",
})

if req.Role != agentruntime.RoleRoutine {
t.Fatalf("Role = %q, want routine", req.Role)
}
if req.Profile != "datadog-gql-slow-queries" {
t.Fatalf("Profile = %q, want routine profile", req.Profile)
}
if req.Provider != "claude-cli" {
t.Fatalf("Provider = %q, want claude-cli", req.Provider)
}
if req.Instructions != "Investigate the slow GraphQL operations." {
t.Fatalf("Instructions = %q, want routine instructions", req.Instructions)
}
if len(req.MCPServers) != 1 {
t.Fatalf("MCPServers = %#v, want one Datadog ref", req.MCPServers)
}
ref := req.MCPServers[0]
if ref.Label != "datadog" || ref.Command != "npx" || len(ref.Args) != 2 || ref.Args[1] != "@datadog/mcp-server" {
t.Fatalf("MCP ref = %#v, want parsed Datadog command", ref)
}
if ref.Env["DD_SITE"] != "datadoghq.com" {
t.Fatalf("MCP env = %#v, want DD_SITE", ref.Env)
}
if req.Metadata["phaseId"] != "datadog-gql-slow-queries" {
t.Fatalf("phaseId = %q, want routine profile", req.Metadata["phaseId"])
}
}
Loading
Loading