From 75739cbaaf98a2d4c1cdc75b44c776aa1542953c Mon Sep 17 00:00:00 2001 From: mayor Date: Sun, 25 Jan 2026 16:00:47 -0800 Subject: [PATCH] fix(mail): handle hq- prefixed agent IDs in recipient validation agentBeadToAddress() expected gt- prefixed IDs but actual agent beads use hq- prefix (e.g., hq-mayor instead of gt-mayor). This caused "no agent found" errors when sending mail to valid addresses like mayor/. - Add CreatedBy field to agentBead struct - Handle hq-mayor and hq-deacon as town-level agents - Use created_by field for rig-level agents (e.g., beads/crew/emma) - Fall back to parsing description for role_type/rig fields - Keep legacy gt- prefix handling for backwards compatibility Closes: gt-1309e2 Co-Authored-By: Claude Opus 4.5 --- internal/mail/router.go | 70 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/internal/mail/router.go b/internal/mail/router.go index b679a227..d2bb790b 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -281,21 +281,48 @@ type agentBead struct { Title string `json:"title"` Description string `json:"description"` Status string `json:"status"` + CreatedBy string `json:"created_by"` } // agentBeadToAddress converts an agent bead to a mail address. -// Uses the agent bead ID to derive the address: -// - gt-mayor → mayor/ -// - gt-deacon → deacon/ -// - gt-gastown-witness → gastown/witness -// - gt-gastown-crew-max → gastown/max -// - gt-gastown-polecat-Toast → gastown/Toast +// Handles both legacy gt- prefixed IDs and current hq- prefixed IDs: +// - hq-mayor → mayor/ +// - hq-deacon → deacon/ +// - hq-{hash} → uses created_by field or parses title/description +// - gt-mayor → mayor/ (legacy) +// - gt-gastown-crew-max → gastown/max (legacy) func agentBeadToAddress(bead *agentBead) string { if bead == nil { return "" } id := bead.ID + + // Handle hq- prefixed IDs (current format) + if strings.HasPrefix(id, "hq-") { + // Well-known town-level agents + if id == "hq-mayor" { + return "mayor/" + } + if id == "hq-deacon" { + return "deacon/" + } + + // For rig-level agents, created_by often contains the agent address + // e.g., "beads/crew/emma" for an agent that self-registered + if bead.CreatedBy != "" && strings.Contains(bead.CreatedBy, "/") { + // Validate it looks like an agent address (rig/role/name or rig/name) + parts := strings.Split(bead.CreatedBy, "/") + if len(parts) >= 2 { + return bead.CreatedBy + } + } + + // Fall back to parsing description for role_type and rig + return parseAgentAddressFromDescription(bead.Description) + } + + // Handle gt- prefixed IDs (legacy format) if !strings.HasPrefix(id, "gt-") { return "" // Not a valid agent bead ID } @@ -323,6 +350,37 @@ func agentBeadToAddress(bead *agentBead) string { } } +// parseAgentAddressFromDescription extracts agent address from description metadata. +// Looks for "role_type: X" and "rig: Y" patterns in the description. +func parseAgentAddressFromDescription(desc string) string { + var roleType, rig string + + for _, line := range strings.Split(desc, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "role_type:") { + roleType = strings.TrimSpace(strings.TrimPrefix(line, "role_type:")) + } else if strings.HasPrefix(line, "rig:") { + rig = strings.TrimSpace(strings.TrimPrefix(line, "rig:")) + } + } + + // Handle null values from description + if rig == "null" || rig == "" { + rig = "" + } + if roleType == "null" || roleType == "" { + return "" + } + + // Town-level agents (no rig) + if rig == "" { + return roleType + "/" + } + + // Rig-level agents: rig/name (role_type is the agent name for crew/polecat) + return rig + "/" + roleType +} + // ResolveGroupAddress resolves a @group address to individual recipient addresses. // Returns the list of resolved addresses and any error. // This is the public entry point for group resolution.