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:
Steve Yegge
2025-12-29 18:04:14 -08:00
parent 03775c6fc7
commit 0085353056
2 changed files with 229 additions and 6 deletions

View File

@@ -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 {

View File

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