From 00853530567a6beec4f12137d3bd3b02a16498e5 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 29 Dec 2025 18:04:14 -0800 Subject: [PATCH] Add await-signal molecule step type with backoff support (gt-l6ro3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Type and Backoff fields to MoleculeStep for patrol agents to implement cost-saving await-signal patterns: - Type field: "task" (default), "wait" (await-signal), etc. - BackoffConfig: base interval, multiplier, max cap - Parsing for "Type:" and "Backoff:" lines in step definitions - Comprehensive tests for new parsing functionality Step definition format: ## Step: await-signal Type: wait Backoff: base=30s, multiplier=2, max=10m Agents interpret these declaratively, implementing backoff behavior at runtime. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/molecule.go | 90 ++++++++++++++++++-- internal/beads/molecule_test.go | 145 ++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 6 deletions(-) diff --git a/internal/beads/molecule.go b/internal/beads/molecule.go index 9e06619a..ba4ecde9 100644 --- a/internal/beads/molecule.go +++ b/internal/beads/molecule.go @@ -4,17 +4,28 @@ package beads import ( "fmt" "regexp" + "strconv" "strings" ) // MoleculeStep represents a parsed step from a molecule definition. type MoleculeStep struct { - Ref string // Step reference (from "## Step: ") - Title string // Step title (first non-empty line or ref) - Instructions string // Prose instructions for this step - Needs []string // Step refs this step depends on - WaitsFor []string // Dynamic wait conditions (e.g., "all-children") - Tier string // Optional tier hint: haiku, sonnet, opus + Ref string // Step reference (from "## Step: ") + Title string // Step title (first non-empty line or ref) + Instructions string // Prose instructions for this step + Needs []string // Step refs this step depends on + WaitsFor []string // Dynamic wait conditions (e.g., "all-children") + Tier string // Optional tier hint: haiku, sonnet, opus + Type string // Step type: "task" (default), "wait", etc. + Backoff *BackoffConfig // Backoff configuration for wait-type steps +} + +// BackoffConfig defines exponential backoff parameters for wait-type steps. +// Used by patrol agents to implement cost-saving await-signal patterns. +type BackoffConfig struct { + Base string // Base interval (e.g., "30s") + Multiplier int // Multiplier for exponential growth (default: 2) + Max string // Maximum interval cap (e.g., "10m") } // stepHeaderRegex matches "## Step: " with optional whitespace. @@ -30,6 +41,14 @@ var tierLineRegex = regexp.MustCompile(`(?i)^Tier:\s*(haiku|sonnet|opus)\s*$`) // Common conditions: "all-children" (fanout gate for dynamically bonded children) var waitsForLineRegex = regexp.MustCompile(`(?i)^WaitsFor:\s*(.+)$`) +// typeLineRegex matches "Type: task|wait|..." lines. +// Common types: "task" (default), "wait" (await-signal with backoff) +var typeLineRegex = regexp.MustCompile(`(?i)^Type:\s*(\w+)\s*$`) + +// backoffLineRegex matches "Backoff: base=30s, multiplier=2, max=10m" lines. +// Parses backoff configuration for wait-type steps. +var backoffLineRegex = regexp.MustCompile(`(?i)^Backoff:\s*(.+)$`) + // templateVarRegex matches {{variable}} placeholders. var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`) @@ -41,6 +60,8 @@ var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`) // // Needs: , # optional // Tier: haiku|sonnet|opus # optional +// Type: task|wait # optional, default is "task" +// Backoff: base=30s, multiplier=2, max=10m # optional, for wait-type steps // // Returns an empty slice if no steps are found. func ParseMoleculeSteps(description string) ([]MoleculeStep, error) { @@ -94,6 +115,18 @@ func ParseMoleculeSteps(description string) ([]MoleculeStep, error) { continue } + // Check for Type: line + if matches := typeLineRegex.FindStringSubmatch(trimmed); matches != nil { + currentStep.Type = strings.ToLower(matches[1]) + continue + } + + // Check for Backoff: line + if matches := backoffLineRegex.FindStringSubmatch(trimmed); matches != nil { + currentStep.Backoff = parseBackoffConfig(matches[1]) + continue + } + // Regular instruction line instructionLines = append(instructionLines, line) } @@ -141,6 +174,51 @@ func ParseMoleculeSteps(description string) ([]MoleculeStep, error) { return steps, nil } +// parseBackoffConfig parses a backoff configuration string. +// Expected format: "base=30s, multiplier=2, max=10m" +// Returns nil if parsing fails. +func parseBackoffConfig(configStr string) *BackoffConfig { + cfg := &BackoffConfig{ + Multiplier: 2, // Default multiplier + } + + // Split by comma and parse key=value pairs + parts := strings.Split(configStr, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // Split by = to get key and value + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + + key := strings.TrimSpace(strings.ToLower(kv[0])) + value := strings.TrimSpace(kv[1]) + + switch key { + case "base": + cfg.Base = value + case "multiplier": + if m, err := strconv.Atoi(value); err == nil { + cfg.Multiplier = m + } + case "max": + cfg.Max = value + } + } + + // Return nil if no base was specified (incomplete config) + if cfg.Base == "" { + return nil + } + + return cfg +} + // ExpandTemplateVars replaces {{variable}} placeholders in text using the provided context map. // Unknown variables are left as-is. func ExpandTemplateVars(text string, ctx map[string]string) string { diff --git a/internal/beads/molecule_test.go b/internal/beads/molecule_test.go index 792adcb8..c82fefac 100644 --- a/internal/beads/molecule_test.go +++ b/internal/beads/molecule_test.go @@ -642,3 +642,148 @@ Needs: b`, t.Error("ValidateMolecule() = nil, want error for cycle in subgraph") } } + +func TestParseMoleculeSteps_WithType(t *testing.T) { + desc := `## Step: await-signal +Wait for a wake signal before proceeding. +Type: wait + +## Step: check-reality +Check for work to do. +Type: task +Needs: await-signal + +## Step: work +Do the actual work (default type). +Needs: check-reality` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(steps)) + } + + // await-signal has type wait + if steps[0].Type != "wait" { + t.Errorf("step[0].Type = %q, want wait", steps[0].Type) + } + + // check-reality has explicit type task + if steps[1].Type != "task" { + t.Errorf("step[1].Type = %q, want task", steps[1].Type) + } + + // work has no type specified (empty string, default) + if steps[2].Type != "" { + t.Errorf("step[2].Type = %q, want empty (default)", steps[2].Type) + } +} + +func TestParseMoleculeSteps_WithBackoff(t *testing.T) { + desc := `## Step: await-signal +Wait for a wake signal with exponential backoff. +Type: wait +Backoff: base=30s, multiplier=2, max=10m + +## Step: check-reality +Check for work. +Needs: await-signal` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(steps)) + } + + // await-signal has backoff config + if steps[0].Backoff == nil { + t.Fatal("step[0].Backoff is nil, want BackoffConfig") + } + if steps[0].Backoff.Base != "30s" { + t.Errorf("step[0].Backoff.Base = %q, want 30s", steps[0].Backoff.Base) + } + if steps[0].Backoff.Multiplier != 2 { + t.Errorf("step[0].Backoff.Multiplier = %d, want 2", steps[0].Backoff.Multiplier) + } + if steps[0].Backoff.Max != "10m" { + t.Errorf("step[0].Backoff.Max = %q, want 10m", steps[0].Backoff.Max) + } + + // check-reality has no backoff + if steps[1].Backoff != nil { + t.Errorf("step[1].Backoff = %+v, want nil", steps[1].Backoff) + } +} + +func TestParseMoleculeSteps_BackoffDefaultMultiplier(t *testing.T) { + desc := `## Step: wait-step +Simple wait. +Type: wait +Backoff: base=1m, max=30m` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(steps)) + } + + if steps[0].Backoff == nil { + t.Fatal("step[0].Backoff is nil, want BackoffConfig") + } + // Default multiplier is 2 + if steps[0].Backoff.Multiplier != 2 { + t.Errorf("step[0].Backoff.Multiplier = %d, want 2 (default)", steps[0].Backoff.Multiplier) + } +} + +func TestParseMoleculeSteps_BackoffIncomplete(t *testing.T) { + desc := `## Step: bad-backoff +Missing base. +Backoff: multiplier=3, max=1h` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(steps)) + } + + // Backoff without base should be nil + if steps[0].Backoff != nil { + t.Errorf("step[0].Backoff = %+v, want nil (missing base)", steps[0].Backoff) + } +} + +func TestParseMoleculeSteps_TypeCaseInsensitive(t *testing.T) { + desc := `## Step: step1 +First step. +TYPE: WAIT + +## Step: step2 +Second step. +type: Task +Needs: step1` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(steps)) + } + + // Type is normalized to lowercase + if steps[0].Type != "wait" { + t.Errorf("step[0].Type = %q, want wait", steps[0].Type) + } + if steps[1].Type != "task" { + t.Errorf("step[1].Type = %q, want task", steps[1].Type) + } +}