179 lines
6.6 KiB
Go
179 lines
6.6 KiB
Go
// Package comm implements agent-to-agent communication for the Cyrene v2
|
|
// orchestration layer (DevDocs/design/05-子代理.md §4). Subagents and the main
|
|
// agent exchange messages over the in-process bus (internal/bus) using a
|
|
// request/answer protocol built on three message kinds: ask, reply and event.
|
|
//
|
|
// # Protocol
|
|
//
|
|
// A Probe is bound to one agent id. It can ask another agent a question and
|
|
// wait for its reply (ask/reply), or fire events at another agent or to anyone
|
|
// listening (event). Messages are published to the bus with dot-separated
|
|
// subjects so the bus's NATS-like wildcard matching routes them to the right
|
|
// agent; see the "Subject convention" section below.
|
|
//
|
|
// # Subject convention
|
|
//
|
|
// Subjects are built from sanitized agent ids (any rune outside [A-Za-z0-9_-]
|
|
// is replaced by "_", so an id never breaks a subject token):
|
|
//
|
|
// ask: cyrene.agent.<from>.<to>.ask
|
|
// reply: cyrene.agent.<from>.<to>.reply.<askId>
|
|
// event (to): cyrene.agent.<from>.<to>.event
|
|
// event (bc): cyrene.agent.<from>.event
|
|
//
|
|
// The reply subject carries the ask id as its last token, giving each in-flight
|
|
// ask a private reply address so concurrent asks from the same agent never
|
|
// cross replies. To receive asks addressed to it, an agent subscribes to
|
|
// cyrene.agent.*.<me>.ask. To receive events (both broadcasts and events
|
|
// addressed to it) it subscribes to cyrene.agent.*.event and to
|
|
// cyrene.agent.*.<me>.event.
|
|
//
|
|
// # Delivery semantics
|
|
//
|
|
// Ask blocks until a reply arrives, an ask-timeout elapses, or the passed
|
|
// context is canceled; it returns ErrTimeout on the former and the wrapped
|
|
// context error on the latter. Reply and the event senders are fire-and-forget
|
|
// publishes that return the bus error (if any). Handler callbacks are invoked
|
|
// on the bus's per-subscription worker goroutines, so an EventHandler may be
|
|
// called concurrently from two subscriptions and must be safe for concurrent
|
|
// use; an AskHandler for one agent is serialized by the bus.
|
|
package comm
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Kind is the wire kind of an agent-to-agent message.
|
|
type Kind string
|
|
|
|
const (
|
|
// KindAsk is a question from one agent to another, answered by a reply.
|
|
KindAsk Kind = "ask"
|
|
// KindReply is the answer to a previously published ask.
|
|
KindReply Kind = "reply"
|
|
// KindEvent is a one-directional notification, broadcast or addressed.
|
|
KindEvent Kind = "event"
|
|
)
|
|
|
|
// subjectPrefix is the fixed first two tokens of every agent-to-agent subject.
|
|
const subjectPrefix = "cyrene.agent"
|
|
|
|
// DefaultAskTimeout caps how long Ask waits for a reply when the caller's
|
|
// context carries no shorter deadline. It bounds a request/answer round so a
|
|
// silent agent never hangs its caller.
|
|
const DefaultAskTimeout = 30 * time.Second
|
|
|
|
// Sentinel errors returned by the package. Use errors.Is to test for them; they
|
|
// are returned directly or wrapped with %w so the underlying cause stays
|
|
// available.
|
|
var (
|
|
// ErrTimeout is returned by Ask when no reply arrives before the ask
|
|
// deadline (the caller's context deadline or DefaultAskTimeout) elapses.
|
|
ErrTimeout = errors.New("comm: reply timeout")
|
|
|
|
// ErrNoReply is returned by Ask when the ask cannot be delivered (the bus
|
|
// rejected the publish/reply subscription), so no reply is expected.
|
|
ErrNoReply = errors.New("comm: no reply")
|
|
)
|
|
|
|
// Ask is a question sent from one agent to another.
|
|
type Ask struct {
|
|
// ID uniquely identifies this ask within the answering exchange.
|
|
ID string
|
|
// From is the id of the asking agent (the sender).
|
|
From string
|
|
// To is the id of the agent that should answer (the receiver).
|
|
To string
|
|
// Question is the human-readable question text.
|
|
Question string
|
|
// Data carries an optional structured payload alongside the question.
|
|
Data any
|
|
}
|
|
|
|
// Reply is the answer to a previously published ask.
|
|
type Reply struct {
|
|
// AskID is the ID of the ask this reply answers.
|
|
AskID string
|
|
// From is the id of the replying agent (the sender).
|
|
From string
|
|
// To is the id of the asking agent (the receiver).
|
|
To string
|
|
// Text is the human-readable answer text.
|
|
Text string
|
|
// Data carries an optional structured payload alongside the answer.
|
|
Data any
|
|
}
|
|
|
|
// Event is a one-directional notification, either broadcast or addressed to one
|
|
// agent. It has no reply.
|
|
type Event struct {
|
|
// From is the id of the emitting agent.
|
|
From string
|
|
// Name is a short event name, e.g. "task.progress" or "tool.used".
|
|
Name string
|
|
// Data carries an optional structured payload.
|
|
Data any
|
|
}
|
|
|
|
// token sanitizes an agent id so it can be used as a single subject token: any
|
|
// rune outside [A-Za-z0-9_-] is replaced with "_". This keeps the id useful for
|
|
// routing while guaranteeing dots (and the other subject metacharacters) never
|
|
// leak into a token.
|
|
func token(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// subjectFor builds the base subject for a from/to message of the given kind.
|
|
func subjectFor(from, to string, kind Kind) string {
|
|
return subjectPrefix + "." + token(from) + "." + token(to) + "." + string(kind)
|
|
}
|
|
|
|
// askSubject is the subject an ask from→to is published to.
|
|
func askSubject(from, to string) string {
|
|
return subjectFor(from, to, KindAsk)
|
|
}
|
|
|
|
// replySubject is the private subject a reply from→to answering askID is
|
|
// published to. The trailing askID token gives each in-flight ask its own
|
|
// address so replies never cross between concurrent asks.
|
|
func replySubject(from, to, askID string) string {
|
|
return subjectPrefix + "." + token(from) + "." + token(to) + "." + string(KindReply) + "." + token(askID)
|
|
}
|
|
|
|
// eventSubject is the subject of an event addressed from→to.
|
|
func eventSubject(from, to string) string {
|
|
return subjectFor(from, to, KindEvent)
|
|
}
|
|
|
|
// broadcastEventSubject is the subject of a broadcast event emitted by from.
|
|
func broadcastEventSubject(from string) string {
|
|
return subjectPrefix + "." + token(from) + "." + string(KindEvent)
|
|
}
|
|
|
|
// askReceivePattern is the subscription pattern an agent named me uses to
|
|
// receive asks addressed to it from any sender.
|
|
func askReceivePattern(me string) string {
|
|
return subjectPrefix + ".*." + token(me) + "." + string(KindAsk)
|
|
}
|
|
|
|
// eventReceivePatterns are the subscription patterns an agent named me uses to
|
|
// receive every event it should see: broadcasts from any sender and events
|
|
// addressed specifically to it.
|
|
func eventReceivePatterns(me string) []string {
|
|
return []string{
|
|
subjectPrefix + ".*." + string(KindEvent),
|
|
subjectPrefix + ".*." + token(me) + "." + string(KindEvent),
|
|
}
|
|
}
|