Add await-signal molecule step type with backoff support (gt-l6ro3.3)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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: <ref>")
|
||||
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: <ref>")
|
||||
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: <ref>" 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+)\}\}`)
|
||||
// <prose instructions>
|
||||
// Needs: <step>, <step> # 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user