docs(comments): 注释中文化 · internal/runtime/orchestration(29 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 裁决编号与 design 节号(02.4.3 / 11.4.2 / design/05 §9 等)逐字保留
- 复核:go build 绿;orchestration 及其子包(audit/comm)测试绿;gofmt 干净
This commit is contained in:
AskaEth committed 2026-10-04 13:30:04 +08:00
1 parent fe90f64a56
commit eb1828ee7a
29 files changed
+751 -949

No files matched your search

@@ -11,43 +11,40 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Sentinel errors returned by Approver. Use errors.Is to test for them.
// Approver 返回的哨兵错误。用 errors.Is 判断它们。
var (
// ErrActionMissing is returned when the action is blank.
// ErrActionMissing 在 action 为空时返回。
ErrActionMissing = errors.New("audit: action is missing")
// ErrUnknownActor is returned when the acting role name is blank. This
// package has no role registry, so a blank name is the only "unknown" it
// can detect; whether a role is truly authorized is decided upstream.
// ErrUnknownActor 在扮演角色的名字为空时返回。本包没有角色注册表,因此空名字是它
// 唯一能检出的「未知」;某个角色是否真的获授权由上游决定。
ErrUnknownActor = errors.New("audit: actor is unknown")
// ErrExplainMissing is returned when the explanation is blank.
// ErrExplainMissing 在说明为空时返回。
ErrExplainMissing = errors.New("audit: explanation is missing")
)
// reviewerMain is the name recorded for the main agent that performs the review.
// reviewerMain 是执行该审查的主 agent 所记录的名字。
const reviewerMain = "main"
// Approval is the immutable record of a single main-agent review-and-approve of
// a subagent's sensitive action.
// Approval 是对子代理敏感操作的一次主代理代审的不可变记录。
type Approval struct {
// Actor is the acting role name (角色名).
// Actor 是扮演角色的名字(角色名)。
Actor string
// Action is the sensitive action being approved.
// Action 是被批准的敏感操作。
Action string
// Description is the explanation of what the actor wants to do (what the
// action means). It is named Description rather than Explain because the
// Explain method (below) returns the frontend notice.
// Description 是对扮演者想做什么的说明(该操作意味着什么)。它命名为
// Description 而非 Explain,因为 Explain 方法(见下)返回的是前端提示短句。
Description string
// Allowed reports whether the action was granted.
// Allowed 报告该操作是否获准。
Allowed bool
// Reviewer is the main agent that performed the review.
// Reviewer 是执行该审查的主 agent。
Reviewer string
// At is when the review-and-approve happened.
// At 是代审发生的时刻。
At time.Time
}
// Explain returns the short frontend notice describing the review outcome. For
// an allowed action it is "「<actor> 想要干什么,我审查后批准了」"; for a denied
// action it uses "我审查后拒绝了". Actor is interpolated in place of <actor>.
// Explain 返回描述审查结果的前端提示短句。获准时是
// "「<actor> 想要干什么,我审查后批准了」";被拒时用 "我审查后拒绝了"。<actor> 处会
// 代入 Actor。
func (a Approval) Explain() string {
if a.Allowed {
return "「" + a.Actor + " 想要干什么,我审查后批准了」"
@@ -55,26 +52,23 @@ func (a Approval) Explain() string {
return "「" + a.Actor + " 想要干什么,我审查后拒绝了」"
}
// Approver records main-agent review-and-approve events and their audit logs.
// Approver 记录主代理代审事件及其审计日志。
type Approver struct {
log *slog.Logger
}
// NewApprover builds an Approver that writes audit logs through log. The logger
// is tagged with source "orchestration/audit". A nil logger is allowed, making
// the approver record silently (no log output).
// NewApprover 构建一个经 log 写入审计日志的 Approver。该 logger 会被标注来源
// "orchestration/audit"。允许传入 nil logger,此时 approver 静默记录(无日志输出)。
func NewApprover(log *slog.Logger) *Approver {
return &Approver{log: logger.WithSource(log, "orchestration/audit")}
}
// ReviewAndApprove validates the request, checks ctx, writes a structured audit
// log (who / what / when / result), and returns the recorded approval with
// Allowed set to true.
// ReviewAndApprove 校验请求、检查 ctx、写入结构化审计日志(谁 / 什么 / 何时 / 结果),
// 并返回记录下来的 approval,其 Allowed 置为 true。
//
// Whether an action is actually sensitive and whether it should be granted is
// decided by the upper policy layer; this method only records, narrates and
// returns the result model. It returns ErrUnknownActor / ErrActionMissing /
// ErrExplainMissing for blank inputs and wraps ctx.Err() when ctx is canceled.
// 某项操作是否真的敏感、是否应予批准由上层策略决定;本方法只记录、叙述并返回结果模型。
// 入参为空时返回 ErrUnknownActor / ErrActionMissing / ErrExplainMissing,ctx 被取消时
// 包装 ctx.Err()。
func (a *Approver) ReviewAndApprove(ctx context.Context, actor, action, describe string) (*Approval, error) {
if err := validateApproval(actor, action, describe); err != nil {
return nil, err
@@ -94,7 +88,7 @@ func (a *Approver) ReviewAndApprove(ctx context.Context, actor, action, describe
return ap, nil
}
// logAudit writes the structured who/what/when/result audit record.
// logAudit 写入结构化的「谁/什么/何时/结果」审计记录。
func (a *Approver) logAudit(ap *Approval) {
if a.log == nil {
return
@@ -109,7 +103,7 @@ func (a *Approver) logAudit(ap *Approval) {
)
}
// validateApproval rejects blank inputs with the matching sentinel.
// validateApproval 以对应的哨兵错误拒绝空入参。
func validateApproval(actor, action, describe string) error {
if strings.TrimSpace(actor) == "" {
return fmt.Errorf("%w: actor is blank", ErrUnknownActor)
@@ -11,7 +11,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// newSilentApprover builds an Approver whose audit log is discarded.
// newSilentApprover 构建一个审计日志被丢弃的 Approver。
func newSilentApprover() *Approver {
return NewApprover(logger.New(logger.Options{Out: io.Discard}))
}
+14 -19
View File
@@ -1,24 +1,19 @@
// Package audit provides the subagent-auditing primitives specified by the
// Cyrene orchestration design (DevDocs/design/05-子代理.md §3 and §6) and the
// security/audit requirements in DevDocs/design/11-安全与权限.md §4 and §8.
// Package audit 提供 Cyrene 编排设计所规定的子代理审计原语
// (DevDocs/design/05-子代理.md §3 与 §6),以及
// DevDocs/design/11-安全与权限.md §4 与 §8 里的安全/审计要求。
//
// It has two halves:
// 它有两半:
//
// - ContextAuditor gives a main agent read-only, traceable access to a
// subagent's context (a []model.Message) so it can audit and pinpoint what
// the subagent did: the full context, a tail window, or a keyword hit with
// its surrounding lines. It is a pure value type over the message list the
// orchestral layer hands it; it holds no storage and no policy.
// - ContextAuditor 给主 agent 只读、可追溯地访问某个子代理的上下文
// (一个 []model.Message),使其能审计并定位子代理做过什么:整段上下文、
// 尾部窗口,或带上下行的关键词命中。它是作用于编排层交给它的消息列表之上的
// 纯值类型;不持有存储,也不持有策略。
//
// - Approver records a main-agent review-and-approve (代审授权) of a
// subagent's sensitive action. It writes a structured audit log (who / what
// / when / result) and produces the short frontend notice "「xxx 想要干什么,
// 我审查后批准了」". Whether an action is actually sensitive and whether it is
// allowed is decided by the upper policy layer; this package only records,
// narrates, and returns the result model — it never judges permission.
// - Approver 记录对子代理敏感操作的「主代理代审授权」。它写入结构化审计日志
// (谁 / 什么 / 何时 / 结果),并产出前端短句「「xxx 想要干什么,
// 我审查后批准了」」。某项操作是否真的敏感、是否获准由上层策略决定;本包只
// 记录、叙述并返回结果模型 —— 绝不判断权限。
//
// The package is a leaf: it depends only on the standard library,
// internal/runtime/model and internal/logger, and never imports the agent loop
// or other runtime packages, so it can sit inside runtime/orchestration without
// an import cycle.
// 本包是叶子:只依赖标准库、internal/runtime/model 与 internal/logger,绝不导入 agent
// 循环或其他运行时包,因此能安放在 runtime/orchestration 内而不形成导入环。
package audit
+21 -29
View File
@@ -9,52 +9,47 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// ErrQueryEmpty is returned by ContextAuditor.Keyword when the query is empty
// or blank. Use errors.Is to test for it.
// ErrQueryEmpty 在 ContextAuditor.Keyword 的 query 为空或全为空白时返回。用 errors.Is
// 判断它。
var ErrQueryEmpty = errors.New("audit: query is empty")
// contextRadius is how many messages on each side of a keyword hit are included
// in the surrounding window (one above and one below).
// contextRadius 是关键词命中处两侧各纳入多少条消息作为上下行窗口(上下各一条)。
const contextRadius = 1
// Located describes a single keyword match within a subagent context.
// Located 描述子代理上下文中的单次关键词命中。
type Located struct {
// Line is the 1-based index of the matched message in the full context.
// Line 是命中消息在完整上下文中的 1 基下标。
Line int
// Surrounding is a window centered on the matched message: the message
// immediately before it, the matched message, and the message immediately
// after it, clamped at the context boundaries. It always contains the
// matched message.
// Surrounding 是以命中消息为中心的窗口:紧邻其前的那条消息、命中消息本身,
// 以及紧随其后的一条消息,在上下文边界处截断。它始终包含命中消息。
Surrounding []model.Message
}
// ContextAuditor provides read-only, traceable access to a subagent's context.
// It snapshots the message list at construction so later mutation by the caller
// does not change what the auditor reports. It is a value type that holds no
// storage or policy; the orchestral layer decides which context to inspect.
// ContextAuditor 提供对某个子代理上下文的只读、可追溯访问。它在构造时快照消息列表,
// 因此调用方此后的修改不会改变审计器所报告的内容。它是值类型,不持有存储或策略;由
// 编排层决定要检视哪段上下文。
type ContextAuditor struct {
messages []model.Message
}
// NewContextAuditor snapshots messages so subsequent caller mutations do not
// affect the auditor. A nil input is allowed and yields an empty context.
// NewContextAuditor 快照 messages,使调用方后续的修改不影响审计器。允许传入 nil,
// 此时得到一个空上下文。
func NewContextAuditor(messages []model.Message) ContextAuditor {
cp := make([]model.Message, len(messages))
copy(cp, messages)
return ContextAuditor{messages: cp}
}
// Full returns a defensive copy of the entire context.
// Full 返回整段上下文的防御性拷贝。
func (a ContextAuditor) Full() []model.Message {
cp := make([]model.Message, len(a.messages))
copy(cp, a.messages)
return cp
}
// Tail returns the last n messages of the context in their original order.
// Tail 按原顺序返回上下文最后 n 条消息。
//
// n <= 0 yields an empty, non-nil slice; n >= len returns a copy of the full
// context. The result is a defensive copy.
// n <= 0 得到空但非 nil 的切片;n >= len 返回整段上下文的拷贝。结果是防御性拷贝。
func (a ContextAuditor) Tail(n int) []model.Message {
if n <= 0 {
return []model.Message{}
@@ -68,13 +63,11 @@ func (a ContextAuditor) Tail(n int) []model.Message {
return out
}
// Keyword locates the first message whose text contains query, using a
// case-insensitive substring match against the message's text parts (image
// payloads are ignored). It returns the message's 1-based index and a
// surrounding window, or nil, nil when there is no match.
// Keyword 定位首个文本包含 query 的消息,对消息的文本分段做大小写不敏感的子串匹配
// (图片载荷被忽略)。它返回该消息的 1 基下标与一个上下行窗口;无命中时返回
// nil, nil。
//
// It returns ErrQueryEmpty for a blank query and wraps ctx.Err() when ctx is
// canceled. Only the earliest match is reported.
// query 为空时返回 ErrQueryEmpty,ctx 被取消时包装 ctx.Err()。只报告最早的命中。
func (a ContextAuditor) Keyword(ctx context.Context, query string) (*Located, error) {
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("%w: query is blank", ErrQueryEmpty)
@@ -91,8 +84,7 @@ func (a ContextAuditor) Keyword(ctx context.Context, query string) (*Located, er
return nil, nil
}
// window returns up to 2*contextRadius+1 messages centered on index i, clamped
// to the context boundaries.
// window 返回以 index i 为中心、至多 2*contextRadius+1 条消息,并在上下文边界处截断。
func (a ContextAuditor) window(i int) []model.Message {
lo := i - contextRadius
hi := i + contextRadius
@@ -110,7 +102,7 @@ func (a ContextAuditor) window(i int) []model.Message {
return out
}
// messageText returns the concatenated text of a message's text parts.
// messageText 返回一条消息各文本分段拼接后的文本。
func messageText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
@@ -8,7 +8,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// texts builds a context of text-only messages with the given body texts.
// texts 用给定的正文文本构建一个仅含文本消息的上下文。
func texts(role model.Role, bodies ...string) []model.Message {
out := make([]model.Message, 0, len(bodies))
for _, b := range bodies {
@@ -17,8 +17,8 @@ func texts(role model.Role, bodies ...string) []model.Message {
return out
}
// sameMessage compares a message by role and concatenated text; the test
// messages are text-only, so this is sufficient for equality.
// sameMessage 按角色与拼接后的文本比较两条消息;测试消息都是纯文本,因此这足以判定
// 相等。
func sameMessage(a, b model.Message) bool {
return a.Role == b.Role && messageText(a) == messageText(b)
}
@@ -29,7 +29,7 @@ func TestNewContextAuditor_Snapshot(t *testing.T) {
msgs := texts(model.RoleUser, "alpha", "bravo", "charlie")
aud := NewContextAuditor(msgs)
// Mutating the caller's slice after construction must not affect the audit.
// 构造之后再改调用方的切片,不得影响审计结果。
msgs[0].Role = model.RoleSystem
got := aud.Full()
if got[0].Role != model.RoleUser {
+15 -18
View File
@@ -8,23 +8,22 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// AuditMode selects how a sub-agent context is shaped for the subagent_ask
// upstream WS route. It mirrors the audit.ContextAuditor surface (Full/Tail/
// Keyword) so the gateway can request exactly the window the client asks for.
// AuditMode 选择子代理上下文为 subagent_ask 上游 WS 路由裁剪成什么形状。它与
// audit.ContextAuditor 的表面(Full/Tail/Keyword)一一对应,使网关能按客户端所请求
// 的窗口精确取用。
type AuditMode string
const (
// AuditFull returns the entire conversation context.
// AuditFull 返回整段会话上下文。
AuditFull AuditMode = "full"
// AuditTail returns the last n messages of the context.
// AuditTail 返回上下文最后 n 条消息。
AuditTail AuditMode = "tail"
// AuditKeyword returns the window around the first keyword match.
// AuditKeyword 返回首个关键词命中处附近的窗口。
AuditKeyword AuditMode = "keyword"
)
// AuditResult is the read-only result of auditing a sub-agent's conversation
// context. Messages carries the selected window; Line and Match are populated for
// a keyword query. It is the wire payload for a ws.subagent_event reply.
// AuditResult 是审计一个子代理会话上下文的只读结果。Messages 承载被选中的窗口;
// Line 与 Match 仅在关键词查询时填充。它是 ws.subagent_event 应答的线上载荷。
type AuditResult struct {
AgentID string `json:"agentId"`
Status Status `json:"status"`
@@ -35,12 +34,11 @@ type AuditResult struct {
Match string `json:"match,omitempty"`
}
// AuditContext returns a read-only view of sub-agent id's conversation context
// for the subagent_ask WS route. mode selects the window: AuditFull returns the
// whole context, AuditTail returns the last n messages, AuditKeyword returns the
// window around the first query hit. It wraps ErrAgentNotFound for an unknown id
// and audit.ErrQueryEmpty for a blank keyword query, both with %w so callers can
// classify with errors.Is.
// AuditContext 返回子代理 id 的会话上下文只读视图,供 subagent_ask WS 路由使用。
// mode 选择窗口:AuditFull 返回整段上下文,AuditTail 返回最后 n 条消息,
// AuditKeyword 返回首个 query 命中处附近的窗口。未知 id 包装 ErrAgentNotFound,
// 关键词查询为空则包装 audit.ErrQueryEmpty,两者都以 %w 包装,便于调用方用
// errors.Is 归类。
func (s *Scheduler) AuditContext(ctx context.Context, id string, mode AuditMode, n int, query string) (*AuditResult, error) {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("orchestration: audit %q: %w", id, err)
@@ -77,9 +75,8 @@ func (s *Scheduler) AuditContext(ctx context.Context, id string, mode AuditMode,
return res, nil
}
// messageText concatenates the text parts of one message. It mirrors the audit
// subpackage's own helper so the result payload can surface a human-readable
// match without exporting a cross-package text extractor.
// messageText 拼接一条消息的各个文本分段。它与 audit 子包自己的同名辅助函数保持一致,
// 使结果载荷能给出人类可读的命中文本,而无需导出跨包的文本提取器。
func messageText(m model.Message) string {
var text string
for _, c := range m.Content {
@@ -9,8 +9,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// TestSchedulerAuditContext exercises the subagent_ask audit path across the full,
// tail and keyword windows, plus the not-found and blank-query errors.
// TestSchedulerAuditContext 走通 subagent_ask 审计路径的 full、tail 与 keyword 三种
// 窗口,以及未找到与查询为空两类错误。
func TestSchedulerAuditContext(t *testing.T) {
t.Parallel()
s := New(Options{MaxParallel: 2, AgentID: "orchestrator"})
+62 -75
View File
@@ -1,42 +1,36 @@
// 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.
// Package comm 为 Cyrene v2 编排层实现 agent 间通信
// (DevDocs/design/05-子代理.md §4)。子代理与主 agent 经进程内总线(internal/bus)
// 交换消息,所用请求/应答协议建立在三种消息之上:ask、reply 与 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.
// 一个 Probe 绑定到一个 agent id。它可以向另一个 agent 提问并等待其回复
// (ask/reply),也可以向另一个 agent 或任何监听者发射事件(event)。消息以点分隔的
// subject 发布到总线上,因此总线类似 NATS 的通配匹配能把它们路由到正确的 agent;
// 见下方「Subject 约定」一节。
//
// # Subject convention
// # Subject 约定
//
// 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):
// Subject 由经净化的 agent id 拼成(凡不在 [A-Za-z0-9_-] 之内的字符都被替换为 "_",
// 因此 id 绝不会破坏 subject 词元):
//
// 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.
// 回复 subject 把 ask id 作为最后一个词元,给每个在途 ask 一个私有回复地址,因此同一
// agent 的并发 ask 的回复绝不串线。要接收发给自己的 ask,agent 订阅
// cyrene.agent.*.<me>.ask。要接收事件(广播与发给自己的事件),它订阅
// cyrene.agent.*.event 与 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.
// Ask 阻塞到收到回复、ask 超时到期,或传入的上下文被取消;前一种情况返回
// ErrTimeout,后一种情况返回包装后的上下文错误。Reply 与各事件发送方是即发即忘的
// 发布,返回总线错误(若有)。处理回调在总线的按订阅工作 goroutine 上调用,因此一个
// EventHandler 可能被两个订阅并发调用,必须可安全并发使用;而某个 agent 的
// AskHandler 由总线串行化。
package comm
import (
@@ -45,82 +39,78 @@ import (
"time"
)
// Kind is the wire kind of an agent-to-agent message.
// Kind 是 agent 间消息的线上类别。
type Kind string
const (
// KindAsk is a question from one agent to another, answered by a reply.
// KindAsk 是一个 agent 向另一个 agent 提的问题,由一条 reply 作答。
KindAsk Kind = "ask"
// KindReply is the answer to a previously published ask.
// KindReply 是对此前发布的 ask 的应答。
KindReply Kind = "reply"
// KindEvent is a one-directional notification, broadcast or addressed.
// KindEvent 是单向通知,可为广播或定向。
KindEvent Kind = "event"
)
// subjectPrefix is the fixed first two tokens of every agent-to-agent subject.
// subjectPrefix 是每个 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.
// DefaultAskTimeout 限制调用方上下文未携带更短期限时 Ask 等待回复的时长。它约束一个
// 请求/应答往返,使沉默的 agent 永不吊死其调用方。
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.
// 本包返回的哨兵错误。用 errors.Is 判断它们;它们被直接返回或以 %w 包装返回,因此底层
// 原因始终可得。
var (
// ErrTimeout is returned by Ask when no reply arrives before the ask
// deadline (the caller's context deadline or DefaultAskTimeout) elapses.
// ErrTimeout 在 ask 期限(调用方上下文期限或 DefaultAskTimeout)到期前没有回复
// 到达时由 Ask 返回。
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 在 ask 无法投递(总线拒绝了发布/回复订阅)时由 Ask 返回,此时不应
// 期待任何回复。
ErrNoReply = errors.New("comm: no reply")
)
// Ask is a question sent from one agent to another.
// Ask 是一个 agent 发给另一个 agent 的问题。
type Ask struct {
// ID uniquely identifies this ask within the answering exchange.
// ID 在该问答交互内唯一标识本次 ask。
ID string
// From is the id of the asking agent (the sender).
// From 是提问 agent(发送方)的 id。
From string
// To is the id of the agent that should answer (the receiver).
// To 是应当作答的 agent(接收方)的 id。
To string
// Question is the human-readable question text.
// Question 是人类可读的问题文本。
Question string
// Data carries an optional structured payload alongside the question.
// Data 承载随问题一起发送的可选结构化载荷。
Data any
}
// Reply is the answer to a previously published ask.
// Reply 是对此前发布的 ask 的应答。
type Reply struct {
// AskID is the ID of the ask this reply answers.
// AskID 是本次回复所应答的 ask 的 ID。
AskID string
// From is the id of the replying agent (the sender).
// From 是回复 agent(发送方)的 id。
From string
// To is the id of the asking agent (the receiver).
// To 是提问 agent(接收方)的 id。
To string
// Text is the human-readable answer text.
// Text 是人类可读的应答文本。
Text string
// Data carries an optional structured payload alongside the answer.
// Data 承载随应答一起发送的可选结构化载荷。
Data any
}
// Event is a one-directional notification, either broadcast or addressed to one
// agent. It has no reply.
// Event 是单向通知,或广播,或定向给某个 agent。它没有回复。
type Event struct {
// From is the id of the emitting agent.
// From 是发射事件的 agent 的 id。
From string
// Name is a short event name, e.g. "task.progress" or "tool.used".
// Name 是简短的事件名,例如 "task.progress" 或 "tool.used"。
Name string
// Data carries an optional structured payload.
// Data 承载可选的结构化载荷。
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.
// token 净化一个 agent id,使其可用作单个 subject 词元:凡不在 [A-Za-z0-9_-] 之内的
// 字符都被替换为 "_"。这既让该 id 对路由仍然有用,又保证点号(以及其他 subject 元字符)
// 绝不会漏进词元。
func token(s string) string {
var b strings.Builder
for _, r := range s {
@@ -134,42 +124,39 @@ func token(s string) string {
return b.String()
}
// subjectFor builds the base subject for a from/to message of the given kind.
// subjectFor 构建给定类别的 from/to 消息的基础 subject。
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.
// askSubject 是 from→to 的 ask 所发布到的 subject。
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.
// replySubject 是应答 askID 的 from→to 回复所发布到的私有 subject。末尾的 askID 词元
// 给每个在途 ask 自己的地址,因此并发 ask 之间回复绝不串线。
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.
// eventSubject 是 from→to 定向事件的 subject。
func eventSubject(from, to string) string {
return subjectFor(from, to, KindEvent)
}
// broadcastEventSubject is the subject of a broadcast event emitted by from.
// broadcastEventSubject 是 from 发射的广播事件的 subject。
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.
// askReceivePattern 是名为 me 的 agent 用来接收任意发送方发给它的 ask 的订阅模式。
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.
// eventReceivePatterns 是名为 me 的 agent 用来接收它应当看到的所有事件的订阅模式:
// 任意发送方的广播,以及专门定向给它的事件。
func eventReceivePatterns(me string) []string {
return []string{
subjectPrefix + ".*." + string(KindEvent),
@@ -12,9 +12,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
// recvEvent blocks until an event arrives on ch or d elapses, failing the test
// on timeout. It makes delivery assertions deterministic instead of relying on a
// bare sleep.
// recvEvent 阻塞到 ch 上到达一个事件或 d 到期,超时则判测试失败。它让投递断言确定化,
// 而不必依赖干等的 sleep。
func recvEvent(t *testing.T, ch <-chan Event, d time.Duration) Event {
t.Helper()
select {
@@ -110,7 +109,7 @@ func TestAsk_TimeoutNoReply(t *testing.T) {
b := bus.New()
defer b.Close()
alice := New(b, "alice")
// bob has no ask handler, so nothing ever replies.
// bob 没有任何 ask 处理函数,因此永远不会有人回复。
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
@@ -233,7 +232,7 @@ func TestEventSend_AddressedAndIsolated(t *testing.T) {
t.Fatalf("bob event = %+v, want name=direct from=alice", e)
}
// An event addressed to bob must not reach carol.
// 定向给 bob 的事件不得抵达 carol。
select {
case e := <-carolEvents:
t.Fatalf("carol unexpectedly received %+v", e)
@@ -266,9 +265,8 @@ func TestEventHandler_Concurrent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Fire a mix of broadcasts and events addressed to recv so both event
// subscriptions (and their separate worker goroutines) invoke the handler
// concurrently.
// 混合发射广播与定向给 recv 的事件,使两个事件订阅(及其各自的工作 goroutine)
// 并发调用处理函数。
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
@@ -349,7 +347,7 @@ func TestSend_NoErrorAndNoReceiver(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// Publishing to a subject with no subscriber is a no-op, not an error.
// 向没有订阅者的 subject 发布是空操作,不是错误。
if err := alice.Send(ctx, "ghost", Event{Name: "nobody"}); err != nil {
t.Fatalf("Send to lost receiver: %v", err)
}
+23 -33
View File
@@ -13,10 +13,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Probe is an agent-to-agent communication endpoint bound to a single agent id.
// It is safe for concurrent use: the only mutable field is an atomic sequence
// counter used to mint unique ask ids, and every in-flight ask carries its own
// reply subscription and channel.
// Probe 是绑定到单个 agent id 的 agent 间通信端点。它可安全并发使用:唯一可变字段是
// 用于生成唯一 ask id 的原子序号计数器,且每个在途 ask 都带有自己的回复订阅与 channel。
type Probe struct {
bus bus.Bus
agentID string
@@ -25,23 +23,22 @@ type Probe struct {
log *slog.Logger
}
// Option customizes a Probe built by New.
// Option 定制由 New 构建的 Probe。
type Option func(*Probe)
// WithLogger replaces the probe's logger. The default is a discard logger so the
// probe is quiet until configured.
// WithLogger 替换探针的 logger。默认是丢弃式 logger,因此在配置之前探针是安静的。
func WithLogger(l *slog.Logger) Option {
return func(p *Probe) { p.log = newLogger(l) }
}
// WithAskTimeout sets how long Ask waits for a reply when the caller's context
// carries no shorter deadline. A non-positive value keeps DefaultAskTimeout.
// WithAskTimeout 设置调用方上下文未携带更短期限时 Ask 等待回复的时长。非正值保持
// DefaultAskTimeout。
func WithAskTimeout(d time.Duration) Option {
return func(p *Probe) { p.askTimeout = d }
}
// New builds a Probe bound to agentID. The agentID is sanitized for use in bus
// subjects (see token); an empty id panics because every probe must be routable.
// New 构建一个绑定到 agentID 的 Probe。agentID 会被净化以便用于总线 subject
// (见 token);空 id 会 panic,因为每个探针都必须可路由。
func New(b bus.Bus, agentID string, opts ...Option) *Probe {
if agentID == "" {
panic("comm: New: agentID must not be empty")
@@ -61,18 +58,15 @@ func New(b bus.Bus, agentID string, opts ...Option) *Probe {
return p
}
// AgentID returns the sanitized id this Probe is bound to.
// AgentID 返回该 Probe 所绑定到的已净化 id。
func (p *Probe) AgentID() string { return p.agentID }
// Ask sends a question to to and blocks until a matching Reply arrives, an ask
// deadline elapses, or ctx is canceled. It returns the reply on success;
// otherwise it returns ErrTimeout when the deadline elapsed without a reply,
// ErrNoReply when the ask could not be delivered at all, or the wrapped context
// error when ctx was canceled.
// Ask 向 to 提一个问题并阻塞到匹配的 Reply 到达、ask 期限到期,或 ctx 被取消。成功时
// 返回回复;否则期限到期而无回复时返回 ErrTimeout,ask 完全无法投递时返回 ErrNoReply,
// ctx 被取消时返回包装后的上下文错误。
//
// The caller's context supplies a deadline if one is set; otherwise
// p.askTimeout bounds the wait. The reply subscription is created before the
// ask is published, so a fast answerer cannot be missed.
// 调用方上下文设了期限就用它;否则由 p.askTimeout 限制等待时长。回复订阅先于 ask 发布
// 创建,因此再快的应答方也不会被错过。
func (p *Probe) Ask(ctx context.Context, to, question string, data any) (Reply, error) {
if err := ctx.Err(); err != nil {
return Reply{}, fmt.Errorf("comm: ask to %q: %w", to, err)
@@ -120,9 +114,8 @@ func (p *Probe) Ask(ctx context.Context, to, question string, data any) (Reply,
}
}
// Reply publishes an answer to the ask identified by r.AskID, addressed to to.
// The answerer defaults to this Probe's agent id when r.From is empty, so an
// answering handler can simply pass the reply text and payload.
// Reply 向 to 发布对 r.AskID 所标识 ask 的应答。r.From 为空时,应答方默认为本 Probe
// 的 agent id,因此应答处理函数只需传入回复文本与载荷。
func (p *Probe) Reply(ctx context.Context, to string, r Reply) error {
if r.From == "" {
r.From = p.agentID
@@ -136,14 +129,12 @@ func (p *Probe) Reply(ctx context.Context, to string, r Reply) error {
return nil
}
// Answer is a convenience for replying with plain text. It publishes a Reply
// for askID to the asking agent to.
// Answer 是以纯文本回复的便捷方式。它向提问 agent to 发布一条针对 askID 的 Reply。
func (p *Probe) Answer(ctx context.Context, to, askID, text string, data any) error {
return p.Reply(ctx, to, Reply{AskID: askID, From: p.agentID, To: to, Text: text, Data: data})
}
// Send delivers an event addressed specifically to to. The emitter defaults to
// this Probe's agent id when ev.From is empty.
// Send 投递一个专门发给 to 的事件。ev.From 为空时,发射方默认为本 Probe 的 agent id。
func (p *Probe) Send(ctx context.Context, to string, ev Event) error {
if ev.From == "" {
ev.From = p.agentID
@@ -154,8 +145,8 @@ func (p *Probe) Send(ctx context.Context, to string, ev Event) error {
return nil
}
// PublishEvent broadcasts an event to every agent subscribed for events. The
// emitter defaults to this Probe's agent id when ev.From is empty.
// PublishEvent 向每个订阅了事件的 agent 广播一个事件。ev.From 为空时,发射方默认为本
// Probe 的 agent id。
func (p *Probe) PublishEvent(ctx context.Context, ev Event) error {
if ev.From == "" {
ev.From = p.agentID
@@ -166,14 +157,13 @@ func (p *Probe) PublishEvent(ctx context.Context, ev Event) error {
return nil
}
// nextAskID mints a unique ask id from the agent id and an atomic counter. It is
// safe for concurrent calls.
// nextAskID 由 agent id 与一个原子计数器生成唯一的 ask id。可安全并发调用。
func (p *Probe) nextAskID() string {
return fmt.Sprintf("%s-%d", p.agentID, p.seq.Add(1))
}
// newLogger returns l tagged with the orchestrations/comm source, swapping a nil
// logger for a discard one so the probe can always log safely.
// newLogger 返回标注了来源 orchestration/comm 的 l,并把 nil logger 换成丢弃式
// logger,使探针总能安全记录日志。
func newLogger(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -8,22 +8,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
// AskHandler processes an Ask addressed to this agent. It runs on the bus's
// per-subscription worker goroutine; for a single agent asks are serialized. A
// handler should answer via (p *Probe).Reply or Answer; a non-nil returned
// error is logged by the probe and discarded.
// AskHandler 处理发给本 agent 的 Ask。它运行在总线的按订阅工作 goroutine 上;对单个
// agent 而言 ask 是串行的。处理函数应经 (p *Probe).Reply 或 Answer 作答;返回的非 nil
// 错误由探针记日志并丢弃。
type AskHandler func(ctx context.Context, a Ask) error
// EventHandler processes an Event addressed to, or broadcast to, this agent.
// Because an agent subscribes to two event patterns (broadcast and addressed),
// the bus may invoke a handler concurrently from two worker goroutines, so an
// EventHandler must be safe for concurrent use.
// EventHandler 处理定向给本 agent 或广播给本 agent 的 Event。由于一个 agent 订阅了两个
// 事件模式(广播与定向),总线可能从两个工作 goroutine 并发调用同一个处理函数,因此
// EventHandler 必须可安全并发使用。
type EventHandler func(ctx context.Context, e Event) error
// SubscribeAsk registers h to receive every Ask addressed to this agent and
// returns an unsubscribe function that removes the registration. When ctx is
// canceled the registration is removed automatically; the returned function is
// idempotent.
// SubscribeAsk 登记 h 以接收发给本 agent 的每个 Ask,并返回一个解除登记的函数。ctx 被
// 取消时登记自动移除;返回的函数是幂等的。
func (p *Probe) SubscribeAsk(ctx context.Context, h AskHandler) (func(), error) {
if h == nil {
return nil, fmt.Errorf("comm: subscribe ask: handler must not be nil")
@@ -45,14 +41,11 @@ func (p *Probe) SubscribeAsk(ctx context.Context, h AskHandler) (func(), error)
return func() { unsub() }, nil
}
// SubscribeEvent registers h to receive both broadcast events and events
// addressed to this agent. It returns an unsubscribe function that removes both
// registrations; it is idempotent and safe to call more than once. When ctx is
// canceled both registrations are removed automatically.
// SubscribeEvent 登记 h 以同时接收广播事件与定向给本 agent 的事件。它返回一个移除两处
// 登记的函数;该函数幂等,多次调用也安全。ctx 被取消时两处登记都自动移除。
//
// The handler reaches the caller from two subscription worker goroutines and
// may also run concurrently with a SubscribeAsk handler, so it must be safe for
// concurrent use.
// 处理函数会从两个订阅工作 goroutine 抵达调用方,也可能与 SubscribeAsk 的处理函数并发
// 运行,因此它必须可安全并发使用。
func (p *Probe) SubscribeEvent(ctx context.Context, h EventHandler) (func(), error) {
if h == nil {
return nil, fmt.Errorf("comm: subscribe event: handler must not be nil")
+25 -33
View File
@@ -1,43 +1,35 @@
// Package orchestration implements the sub-agent orchestration layer of the
// Cyrene v2 runtime (DevDocs/design/04-运行时.md §2 and DevDocs/design/05-子代理.md).
// Package orchestration 实现 Cyrene v2 运行时的子代理编排层
// (DevDocs/design/04-运行时.md §2 与 DevDocs/design/05-子代理.md)。
//
// When the front session (agent loop) decides a task is complex it hot-switches to
// orchestration mode and spawns background sub-agents that run concurrently
// without blocking the front session. Each sub-agent is an agent instance bound
// to a role + behavior mode, executing in an isolated context with its own
// working memory. Progress and status are streamed back to the front session via
// the agent-to-agent bus, and once all sub-agents finish their outcomes are
// merged for the front session.
// 当前台会话(agent 循环)判定任务复杂时,它会热切换到编排模式,并派发后台
// 子代理并发运行,不阻塞前台会话。每个子代理都是绑定「角色 + 行为模式」的 agent
// 实例,在隔离上下文中执行,并持有自己的工作记忆。进度与状态经 agent 间总线回流
// 前台会话;全部子代理结束后,它们的结果被合并交给前台会话。
//
// # Lifecycle
// # 生命周期
//
// A Scheduler is created once via New with its injected dependencies (a loop
// template, the bus, an optional comm probe, memory/sink, tracer, approver). A
// sub-agent is created with Spawn, which registers it, publishes a "dispatched"
// progress event and launches its execution in the background. The execution is
// gated by MaxParallel so at most that many sub-agents run at once; excess ones
// queue. Each run is bounded by PerAgentTimeout. The sub-agent moves
// pending -> running -> one of {done, failed, aborted}, and on every terminal
// transition a summary is written and a progress event is published. Abort and
// CancelAll stop a single sub-agent or every sub-agent (anti-runaway).
// Scheduler 由 New 创建一次,并注入其依赖(一个循环模板、总线、可选的 comm 探针、
// 记忆/sink、tracer、代审者)。子代理由 Spawn 创建:它登记子代理、发布一条
// 「已派发」进度事件,并在后台启动其执行。执行受 MaxParallel 约束,同时至多这么
// 多个子代理在跑,超出的排队等待。每次运行受 PerAgentTimeout 约束。子代理状态从
// pending -> running -> {done, failed, aborted} 之一流转,每次终结态转移都会写入
// 一份摘要并发布一条进度事件。Abort 与 CancelAll 分别停掉单个子代理或全部子代理
// (防失控)。
//
// # Result merging
// # 结果合并
//
// Join waits until every spawned sub-agent reaches a terminal state (or ctx is
// canceled) and returns a MergedResult aggregating each sub-agent's summary and
// loop result.
// Join 等待每个已派发的子代理进入终结态(或 ctx 被取消),返回一个 MergedResult,
// 汇总每个子代理的摘要与循环结果。
//
// # Isolation
// # 隔离
//
// Sub-agent context and working memory are isolated by construction: Spawn deep
// copies Spec.Context into SubAgent.Context and creates a fresh WorkingMemory per
// sub-agent, so a sub-agent never shares the front session's context.
// 子代理的上下文与工作记忆在构造上即隔离:Spawn 把 Spec.Context 深拷贝进
// SubAgent.Context,并为每个子代理创建全新的 WorkingMemory,因此子代理从不共享
// 前台会话的上下文。
//
// # Governance
// # 治理
//
// Sensitive operations are audited through the injected *audit.Approver (see
// Approve): policy judgement belongs to the upper layer; the scheduler only
// triggers the audit record. Task-memory sinking is available through SinkMemory,
// which delegates to the injected *memory.Store so scope classification
// (user/persona/project) stays at the call site.
// 敏感操作经注入的 *audit.Approver 审计(见 Approve):策略判断属于上层,调度器
// 只触发审计记录。任务记忆落库经 SinkMemory 提供,它委托给注入的 *memory.Store,
// 因此作用域归类(user/persona/project)留在调用点。
package orchestration
+8 -12
View File
@@ -7,19 +7,15 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// This file owns the scheduler's long-term-memory half: handing a sub-agent's task
// memory to the injected *memory.Store. It sits next to persona.go because the role
// a sub-agent runs as (design/22 §8-2) is exactly the scope its memory is filed
// under.
// 本文件持有调度器的长期记忆那一半:把子代理的任务记忆交给注入的 *memory.Store。
// 它紧挨 persona.go,因为子代理以哪个角色运行(design/22 §8-2)正是其记忆归档的
// 作用域。
// SinkMemory persists a task memory item for a sub-agent through the injected
// *memory.Store. The role the sub-agent ran as is used as the persona/role key —
// the dispatched target persona when one was named, otherwise the legacy RoleID
// binding — so a sub-agent standing in for another role writes that role's memory
// domain, never the spawning session's (design/22 §8-2/§8-5). Content and the
// scope/kind classification (user/persona/project) are supplied by the caller,
// keeping scope policy at the call site. It returns ErrNoMemory when no store was
// injected.
// SinkMemory 通过注入的 *memory.Store 为子代理持久化一条任务记忆。子代理以哪个
// 角色运行即用作 persona/角色键 —— 指名了派发目标角色时用该角色,否则沿用旧的
// RoleID 绑定 —— 因此替班另一个角色的子代理写入的是那个角色的记忆域,绝不是派发
// 会话的(design/22 §8-2/§8-5)。内容与作用域/类别归类(user/persona/project)由
// 调用方提供,使作用域策略留在调用点。未注入 store 时返回 ErrNoMemory。
func (s *Scheduler) SinkMemory(ctx context.Context, sub *SubAgent, content string, scope memory.Scope, kind memory.Kind) (memory.MemoryItem, error) {
if s.memory == nil {
return memory.MemoryItem{}, fmt.Errorf("orchestration: sink memory: %w", ErrNoMemory)
+29 -31
View File
@@ -7,60 +7,58 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// SubAgentResult is a point-in-time snapshot of a SubAgent. It is used both as
// the per-sub-agent entry in MergedResult and as the return value of
// (*SubAgent).Snapshot for monitoring.
// SubAgentResult 是 SubAgent 在某一时刻的快照。它既用作 MergedResult 中每个子代理的
// 条目,也用作 (*SubAgent).Snapshot 供监控使用的返回值。
type SubAgentResult struct {
// ID is the sub-agent id.
// ID 是子代理 id。
ID string
// Goal is the sub-agent's objective.
// Goal 是子代理的目标。
Goal string
// RoleID is the bound role/persona.
// RoleID 是绑定的角色/persona。
RoleID string
// PersonaID is the target role the sub-agent runs as (blank = the spawning
// turn's role), whose instructions and memory scope its run used (design/22 §8-2).
// PersonaID 是子代理运行时充当的目标角色(空 = 派发回合的角色),其指令与记忆
// 作用域为本次运行所用(design/22 §8-2)。
PersonaID string
// Mode is the bound behavior mode.
// Mode 是绑定的行为模式。
Mode string
// AttachInfo is the supplied attachment text.
// AttachInfo 是随派发提供的附加文本。
AttachInfo string
// Status is the terminal (or current) status.
// Status 是终结态(或当前状态)。
Status Status
// Summary is the execution summary written on finalization.
// Summary 是收尾时写入的执行摘要。
Summary string
// Text is the loop result text on a successful run.
// Text 是运行成功时的循环结果文本。
Text string
// Steps is the number of loop steps executed on a successful run.
// Steps 是运行成功时执行的循环步数。
Steps int
// Err is the error that ended a failed/aborted run (nil on success).
// Err 是结束失败/中止运行的那个错误(成功时为 nil)。
Err error
// StartedAt is when the sub-agent began running.
// StartedAt 是子代理开始运行的时间。
StartedAt time.Time
// FinishedAt is when the sub-agent finalized.
// FinishedAt 是子代理收尾的时间。
FinishedAt time.Time
}
// MergedResult aggregates the outcomes of every spawned sub-agent, produced by
// (*Scheduler).Join.
// MergedResult 聚合每个已派发子代理的结果,由 (*Scheduler).Join 产出。
type MergedResult struct {
// Agents holds one entry per spawned sub-agent, in spawn order.
// Agents 持有每个已派发子代理的一个条目,按派发顺序排列。
Agents []SubAgentResult
// Total is the number of spawned sub-agents.
// Total 是已派发子代理的数量。
Total int
// Succeeded is how many sub-agents finished with StatusDone.
// Succeeded 是以 StatusDone 结束的子代理数量。
Succeeded int
// Failed is how many sub-agents finished with StatusFailed.
// Failed 是以 StatusFailed 结束的子代理数量。
Failed int
// Aborted is how many sub-agents finished with StatusAborted.
// Aborted 是以 StatusAborted 结束的子代理数量。
Aborted int
// Summary is a one-line aggregate summary of all sub-agents.
// Summary 是全部子代理的单行聚合摘要。
Summary string
}
// summaryTextMax caps how many runes of the result text appear in a summary.
// summaryTextMax 限制摘要中出现的结论文本字符数上限。
const summaryTextMax = 80
// doneSummary builds the execution summary written when a sub-agent succeeds.
// doneSummary 构建子代理成功时写入的执行摘要。
func doneSummary(res *loop.Result) string {
if res == nil {
return "完成"
@@ -72,7 +70,7 @@ func doneSummary(res *loop.Result) string {
return fmt.Sprintf("完成 (步骤=%d): %s", res.Steps, text)
}
// failedSummary builds the summary written when a sub-agent fails.
// failedSummary 构建子代理失败时写入的摘要。
func failedSummary(err error) string {
if err == nil {
return "失败"
@@ -80,12 +78,12 @@ func failedSummary(err error) string {
return "失败: " + err.Error()
}
// canceledSummary builds the summary written when a sub-agent is aborted.
// canceledSummary 构建子代理被中止时写入的摘要。
func canceledSummary() string {
return "中止: 已取消"
}
// timeoutSummary builds the summary written when a sub-agent times out.
// timeoutSummary 构建子代理超时写入的摘要。
func timeoutSummary(d time.Duration) string {
return fmt.Sprintf("中止: 超时 (%s)", d)
}
@@ -99,7 +97,7 @@ func timeoutSummaryFor(d time.Duration) string {
return timeoutSummary(d)
}
// summarizeMerged produces the one-line aggregate summary of a merged result.
// summarizeMerged 产出合并结果的单行聚合摘要。
func summarizeMerged(r *MergedResult) string {
return fmt.Sprintf("子代理汇总: 共 %d 个, %d 成功, %d 失败, %d 中止",
r.Total, r.Succeeded, r.Failed, r.Aborted)
+50 -60
View File
@@ -12,94 +12,86 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Defaults for the orchestration layer.
// 编排层的默认值。
const (
// DefaultMaxParallel is the maximum number of sub-agents that run at once
// when Options.MaxParallel is non-positive.
// DefaultMaxParallel 是 Options.MaxParallel 为非正数时同时运行的子代理
// 数量上限。
DefaultMaxParallel = 4
// defaultAgentID is the comm probe identity used when Options.AgentID is
// blank and a probe must be built from Bus.
// defaultAgentID 是 Options.AgentID 为空、且必须由 Bus 构建探针时所用的
// comm 探针身份。
defaultAgentID = "orchestrator"
)
// Options configures a Scheduler built by New. All fields are optional; each nil
// dependency degrades gracefully (a missing Loop makes sub-agents fail fast, a
// missing Comm skips progress events, a missing Approver makes Approve error,
// and so on).
// Options 配置由 New 构建的 Scheduler。所有字段都可选;每个为 nil 的依赖都优雅
// 降级(缺 Loop 使子代理快速失败,缺 Comm 跳过进度事件,缺 Approver 使 Approve
// 报错,等等)。
type Options struct {
// MaxParallel caps how many sub-agents execute concurrently. Excess sub-agents
// queue until a slot frees. Non-positive selects DefaultMaxParallel (4).
// MaxParallel 限制同时执行的子代理数量。超出的子代理排队直到有空位。
// 非正数则选用 DefaultMaxParallel(4)。
MaxParallel int
// PerAgentTimeout bounds every sub-agent's run. When it elapses the sub-agent
// is aborted (status aborted). Non-positive means no timeout.
// PerAgentTimeout 约束每个子代理的运行时限。超时后该子代理被中止
// (状态 aborted)。非正数表示不设时限。
PerAgentTimeout time.Duration
// Loop is the execution template: a *loop.Loop used to run every sub-agent.
// It must be safe for concurrent Run calls (as loop.Loop documents); the
// template's agent/tools/guardrails/persona apply to every sub-agent. When
// nil, sub-agents fail fast with ErrNoRunner.
// Loop 是执行模板:用于运行每个子代理的 *loop.Loop。它必须能安全地并发调用
// Run(如 loop.Loop 自身文档所述);模板的 agent/tools/guardrails/persona
// 适用于每个子代理。为 nil 时子代理以 ErrNoRunner 快速失败。
Loop *loop.Loop
// Bus is the in-process event bus used for progress events and, when Comm is
// nil, to build a default comm probe. When nil no events are published.
// Bus 是进程内事件总线,用于发布进度事件;Comm 为 nil 时也由它构建默认
// comm 探针。为 nil 时不发布任何事件。
Bus bus.Bus
// Comm is the comm probe used to publish progress events. When nil, a probe
// bound to AgentID is built from Bus (when Bus is non-nil).
// Comm 是用于发布进度事件的 comm 探针。为 nil 时,用 Bus(Bus 非 nil 时)
// 构建一个绑定到 AgentID 的探针。
Comm *comm.Probe
// Memory is the long-term memory store used by SinkMemory. Optional.
// Memory 是 SinkMemory 所用的长期记忆存储。可选。
Memory *memory.Store
// Tracer emits a per-sub-agent span (kind subagent) around each run. Optional.
// Tracer 为每次运行发射一个子代理 span(kind subagent)。可选。
Tracer *trace.Tracer
// Flow, when non-nil, receives the sub-agent span transitions so a spawned
// branch appears in the flow graph of the session named by
// AgentSpec.ParentSession (design/19 §2). *loop.FlowPublisher is the
// production implementation. Optional: a nil sink leaves sub-agent branches
// unvisualized (the sub-agent's own inner spans still fold into the parent
// graph through the loop's flow branch).
// Flow 非 nil 时接收子代理 span 的状态转移,使派生出的分支出现在
// AgentSpec.ParentSession 所指会话的流程图里(design/19 §2)。
// *loop.FlowPublisher 是生产实现。可选:sink 为 nil 时子代理分支不被可视化
// (子代理自身的内部 span 仍会经循环的 flow 分支折进父图)。
Flow FlowSpanSink
// Approver records sensitive-operation review-and-approve (代审) triggered by
// Approve. Policy judgement belongs to the upper layer. Optional.
// Approver 记录由 Approve 触发的敏感操作「代审」。策略判断属于上层。可选。
Approver *audit.Approver
// PersonaExists reports whether a role id can run with its own instructions,
// i.e. whether the per-role agent pool behind Loop (loop.Options.Agents) has an
// entry for it (design/22 §8-2). It gates AgentSpec.PersonaID: a dispatch naming
// an id this returns false for is rejected with ErrUnknownPersona rather than
// running as the default role's prompt. Nil means "this deployment knows no
// role", so a dispatch that names a role is rejected too — deciding nothing
// silently is the point. Omitting the persona is unaffected by this.
// PersonaExists 报告某个角色 id 能否以自身指令运行,即 Loop 背后的按角色
// agent 池(loop.Options.Agents)里是否有它的条目(design/22 §8-2)。它把守
// AgentSpec.PersonaID:指名了一个它返回 false 的 id 的派发会被拒绝并返回
// ErrUnknownPersona,而不是按默认角色的提示词运行。为 nil 意味着「本部署不认识
// 任何角色」,因此指名角色的派发同样被拒 —— 关键是绝不悄悄替调用方做决定。
// 省略 persona 的派发不受它影响。
PersonaExists func(personaID string) bool
// Logger is the base logger. Nil is replaced with a discard logger tagged with
// source "orchestration".
// Logger 是基础 logger。为 nil 时替换为标注了来源 "orchestration" 的
// 丢弃式 logger。
Logger *slog.Logger
// AgentID is the comm probe identity. When blank and a probe must be built
// from Bus, "orchestrator" is used.
// AgentID 是 comm 探针身份。为空且必须由 Bus 构建探针时,使用
// "orchestrator"。
AgentID string
}
// FlowSpanSink folds a span transition into a session's live flow graph so the
// realtime visualization shows the work in flight (design/19 §1). *loop.FlowPublisher
// is the production implementation; tests substitute a capturing fake. The
// interface keeps orchestration from depending on the concrete publisher beyond
// the loop template it already takes.
// FlowSpanSink 把一个 span 的状态转移折进会话的实时流程图,使实时可视化展示在飞的
// 工作(design/19 §1)。*loop.FlowPublisher 是生产实现;测试替换为捕获式假实现。
// 该接口让编排层除了已有的循环模板之外,不必再依赖那个具体发布器。
type FlowSpanSink interface {
// SpanStarted registers a running placeholder node for a span that just began.
// SpanStarted 为一个刚刚开始的 span 登记运行中的占位节点。
SpanStarted(sessionID string, sp *trace.Span)
// SpanFinished folds a just-finished span into the session graph.
// SpanFinished 把一个刚结束的 span 折进会话图。
SpanFinished(sessionID string, sp *trace.Span)
}
// wrapLoop adapts a *loop.Loop into the runner interface, substituting a no-op
// runner (fail-fast) when the template is nil.
// wrapLoop 把 *loop.Loop 适配成 runner 接口;模板为 nil 时替换为空操作 runner
// (快速失败)。
func wrapLoop(l *loop.Loop) runner {
if l == nil {
return noopRunner{}
@@ -107,12 +99,11 @@ func wrapLoop(l *loop.Loop) runner {
return l
}
// scopedRunner picks the runner that executes sub: without a tool whitelist the
// configured template runs as-is(缺省全开,继承主 agent 全量工具集);with one,
// a *loop.Loop template is derived through WithToolWhitelist so only the listed
// tools pass(design/05 §7 裁决 02.4.1). A non-loop runner (tests inject mocks)
// is returned unchanged — the whitelist still rides the run context for it to
// observe via loop.ToolWhitelistFrom.
// scopedRunner 挑选执行 sub 的 runner:没有工具白名单时按原样运行已配置的模板
// (缺省全开,继承主 agent 全量工具集);有白名单时,为 *loop.Loop 模板经
// WithToolWhitelist 派生出只放行所列工具的新模板(design/05 §7 裁决 02.4.1)。
// 非 loop 的 runner(测试注入的 mock)原样返回 —— 白名单仍随运行上下文传递,它可经
// loop.ToolWhitelistFrom 自行读取。
func (s *Scheduler) scopedRunner(sub *SubAgent) runner {
if len(sub.ToolWhitelist) == 0 {
return s.runner
@@ -123,9 +114,8 @@ func (s *Scheduler) scopedRunner(sub *SubAgent) runner {
return s.runner
}
// effectiveTimeout resolves the deadline one sub-agent runs under: a positive
// per-call override wins; otherwise the scheduler's PerAgentTimeout applies
// (non-positive = no deadline).
// effectiveTimeout 解析某个子代理运行时所受的时限:正的按调用覆盖优先;否则套用
// 调度器的 PerAgentTimeout(非正 = 无期限)。
func effectiveTimeout(override, schedulerDefault time.Duration) time.Duration {
if override > 0 {
return override
@@ -99,7 +99,7 @@ func TestContextIsolation(t *testing.T) {
t.Fatalf("Spawn: %v", err)
}
// Mutating the caller's slice after Spawn must not leak into the sub-agent.
// Spawn 之后再改调用方的切片,不得泄漏进子代理。
specCtx[0].Content[0].Text = "mutated"
res, err := s.Join(context.Background())
@@ -112,8 +112,8 @@ func TestContextIsolation(t *testing.T) {
if m.total != 1 {
t.Fatalf("runner calls = %d, want 1", m.total)
}
// The runner receives the isolated caller context plus the task itself as one
// trailing user message (裁决 2026-10-04: 任务作为一条 user 消息注入).
// runner 收到的是隔离后的调用方上下文,加上任务本身作为末尾一条 user 消息
// (裁决 2026-10-04: 任务作为一条 user 消息注入).
got := m.captured(0)
if len(got) != 2 {
t.Fatalf("runner received %d messages, want the isolated context + the task message: %#v", len(got), got)
@@ -238,7 +238,7 @@ func TestRunWithRealLoop(t *testing.T) {
}
func TestNoGoroutineLeak(t *testing.T) {
// Not parallel: a goroutine-count assertion is noisy under parallel tests.
// 不并行:goroutine 计数断言在并行测试下噪声很大。
s := newScheduler(t, Options{MaxParallel: 2})
m := &mockRunner{result: &loop.Result{Text: "ok"}}
s.runner = m
+25 -33
View File
@@ -7,33 +7,28 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// This file owns the "派出去的子代理以目标角色身份运行" half of design/22 §8-2: a
// sub-agent dispatched with a target persona must answer with that role's
// instructions and use that role's memory scope, and a role this process cannot run
// must be rejected loudly instead of silently degrading into the default role.
// 本文件持有 design/22 §8-2 里「派出去的子代理以目标角色身份运行」那一半:带目标
// 角色派发的子代理必须以那个角色的指令作答、使用那个角色的记忆作用域,而本进程跑
// 不了的角色必须明确报错,不得静默降级为默认角色。
//
// The resolution itself stays in the loop (internal/runtime/loop/persona.go): every
// role-scoped read — prompt (agent pool), memory namespace, emotion, thinking knobs
// — is derived from the session env's PersonaID, one input, so the four can never
// disagree. The orchestration layer therefore has exactly two jobs: validate the id,
// and stamp it onto the sub-agent's run context. Neither touches the identity lane
// (ActorEnv / MemWritable), which is decided structurally by the channel layer and
// is never a persona's business (project red line: 身份识别靠元数据).
// 解析本身留在循环里(internal/runtime/loop/persona.go):每一处按角色作用域的读取
// —— 提示词(agent 池)、记忆命名空间、情绪、思考旋钮 —— 都派生自会话 env 的
// PersonaID 这一个输入,因此四者不可能互相矛盾。编排层因此只有两件事要做:校验
// id,并把它盖到子代理的运行上下文上。两者都不碰身份车道(ActorEnv / MemWritable),
// 那条车道由渠道层从结构上决定,从来不是角色的分内事(项目红线:身份识别靠元数据)。
// PersonaKnown reports whether personaID is a role this process can run with its
// own instructions (Options.PersonaExists). A blank id is trivially known: the
// caller is naming no role, which is the pre-existing single-role dispatch. A nil
// resolver makes every named role unknown, so a role-naming dispatch fails instead
// of quietly running as the default role.
// PersonaKnown 报告 personaID 是否为本进程能以自身指令运行的角色
// (Options.PersonaExists)。空 id 平凡已知:调用方没有指名任何角色,即既有的单角色
// 派发。resolver 为 nil 时所有具名角色都算未知,因此指名角色的派发会失败,而不是
// 悄悄按默认角色运行。
func (s *Scheduler) PersonaKnown(personaID string) bool {
return personaID == "" || (s.personaExists != nil && s.personaExists(personaID))
}
// effectiveRole returns the role to record on the sub-agent as its binding. A
// dispatched persona IS the binding (the sub-agent answers as that role), so it fills
// a blank RoleID; an explicit RoleID that names no persona is left as the caller set
// it. The recorded role is what the progress events and the ws subagent_event frame
// report, so the frontend never shows a sub-agent running as yuki with an empty role.
// effectiveRole 返回记在子代理上的绑定角色。被派发的 persona 就是绑定本身(子代理
// 以该角色身份作答),因此它填补空的 RoleID;指名了不存在的 persona 的显式 RoleID
// 则保持调用方所设。记录下来的角色正是进度事件与 ws subagent_event 帧所报告的内容,
// 所以前端不会看到「以 yuki 身份运行却角色为空」的子代理。
func effectiveRole(spec AgentSpec) string {
if spec.RoleID == "" {
return spec.PersonaID
@@ -41,8 +36,8 @@ func effectiveRole(spec AgentSpec) string {
return spec.RoleID
}
// checkPersona rejects a role the process cannot run, wrapping ErrUnknownPersona so
// a caller can classify it with errors.Is.
// checkPersona 拒绝本进程跑不了的角色,包装 ErrUnknownPersona,使调用方能以
// errors.Is 归类。
func (s *Scheduler) checkPersona(personaID string) error {
if s.PersonaKnown(personaID) {
return nil
@@ -50,10 +45,9 @@ func (s *Scheduler) checkPersona(personaID string) error {
return fmt.Errorf("orchestration: spawn: persona %q: %w", personaID, ErrUnknownPersona)
}
// CheckPersonas validates every spec's target role before a batch is spawned, so one
// unknown id rejects the whole dispatch instead of leaving the earlier sub-agents of
// the batch running with no result ever reported. The dispatch tool calls it right
// after decoding the arguments, where the ids are known but nothing has started yet.
// CheckPersonas 在整批派发前校验每个 spec 的目标角色,因此一个未知 id 会拒绝整批
// 派发,而不会留下批次里先建好的、结果永远不上报的子代理。派发工具在解码参数后立即
// 调用它:那时 id 已知,但什么都还没启动。
func (s *Scheduler) CheckPersonas(specs []AgentSpec) error {
for _, spec := range specs {
if err := s.checkPersona(spec.PersonaID); err != nil {
@@ -63,12 +57,10 @@ func (s *Scheduler) CheckPersonas(specs []AgentSpec) error {
return nil
}
// withPersonaEnv returns ctx with the target role stamped onto the session env, so
// the loop resolves that role's prompt and memory scope for every step of the
// sub-agent's run. The rest of the env (the identity lane and the memory-write gate)
// is copied unchanged — a persona decides how she answers, never what she may touch.
// A blank id returns ctx untouched, so a persona-less dispatch keeps inheriting the
// spawning turn's env exactly as before.
// withPersonaEnv 返回把目标角色盖到会话 env 上的 ctx,使循环为子代理运行的每一步
// 解析该角色的提示词与记忆作用域。env 的其余部分(身份车道与记忆写入门)原样拷贝
// —— 角色决定她怎么回答,绝不决定她能碰什么。空 id 返回未改动的 ctx,因此无角色
// 的派发同之前一样继续继承派发回合的 env。
func withPersonaEnv(ctx context.Context, personaID string) context.Context {
if personaID == "" {
return ctx
+75 -104
View File
@@ -19,22 +19,19 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// runner executes one sub-agent's loop. A *loop.Loop satisfies it; tests inject a
// mock so the scheduler can be exercised without a model backend.
// runner 执行一个子代理的循环。*loop.Loop 满足它;测试注入 mock,使调度器无需模型
// 后端即可被演练。
type runner interface {
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
}
// Scheduler creates and runs sub-agents in the background, bounding concurrency,
// applying a per-agent timeout and publishing progress over the agent-to-agent
// bus. It is safe for concurrent use.
// Scheduler 在后台创建并运行子代理:限制并发、施加按代理时限、经 agent 间总线发布
// 进度。它可安全并发使用。
//
// The lifecycle is: Spawn registers a sub-agent and launches it asynchronously;
// the sub-agent's goroutine acquires a parallel slot (gated by MaxParallel),
// transitions to running, runs the injected loop template in its isolated
// context under the per-agent timeout, writes a summary and publishes a terminal
// progress event. Abort/CancelAll interrupt running or queued sub-agents. Join
// blocks until every sub-agent is terminal and returns the merged outcome.
// 生命周期为:Spawn 登记一个子代理并异步启动它;子代理的 goroutine 取得一个并行槽位
// (受 MaxParallel 把守)、转入 running、在按代理时限下于隔离上下文中运行注入的循环
// 模板、写入摘要并发布终结态进度事件。Abort/CancelAll 中断运行中或排队中的子代理。
// Join 阻塞到每个子代理终结,返回合并结果。
type Scheduler struct {
maxParallel int
perAgentTimeout time.Duration
@@ -47,8 +44,8 @@ type Scheduler struct {
approver *audit.Approver
log *slog.Logger
agentID string
// personaExists answers "can this process run that role with its own
// instructions" (Options.PersonaExists); nil means no role can be named.
// personaExists 回答「本进程能否以自身指令运行那个角色」
// (Options.PersonaExists);nil 表示不能指名任何角色。
personaExists func(personaID string) bool
mu sync.Mutex
@@ -61,20 +58,19 @@ type Scheduler struct {
prefix string
}
// noopRunner is the fallback runner used when no *loop.Loop template was
// configured. It makes every sub-agent fail fast with ErrNoRunner rather than
// silently doing nothing.
// noopRunner 是未配置 *loop.Loop 模板时所用的兜底 runner。它使每个子代理以
// ErrNoRunner 快速失败,而不是悄悄什么都不做。
type noopRunner struct{}
// Run returns a wrapped ErrNoRunner.
// Run 返回包装后的 ErrNoRunner。
func (noopRunner) Run(context.Context, string, []model.Message) (*loop.Result, error) {
return nil, fmt.Errorf("orchestration: run sub-agent: %w", ErrNoRunner)
}
// New builds a Scheduler from opts, applying defaults. MaxParallel non-positive
// selects DefaultMaxParallel; a nil Loop is replaced by a no-op runner; a nil
// Comm is built from Bus (when present) using AgentID (default "orchestrator"); a
// nil Logger is replaced by a discard logger tagged with source "orchestration".
// New 依据 opts 构建 Scheduler 并套用默认值。MaxParallel 非正时选用
// DefaultMaxParallel;Loop 为 nil 时替换为空操作 runner;Comm 为 nil 时用 Bus(若存在)
// 配合 AgentID(默认 "orchestrator")构建;Logger 为 nil 时替换为标注了来源
// "orchestration" 的丢弃式 logger。
func New(opts Options) *Scheduler {
maxParallel := opts.MaxParallel
if maxParallel <= 0 {
@@ -110,22 +106,20 @@ func New(opts Options) *Scheduler {
return s
}
// Bus returns the injected event bus (may be nil).
// Bus 返回注入的事件总线(可能为 nil)。
func (s *Scheduler) Bus() bus.Bus { return s.bus }
// Comm returns the injected (or auto-built) comm probe (may be nil).
// Comm 返回注入的(或自动构建的)comm 探针(可能为 nil)。
func (s *Scheduler) Comm() *comm.Probe { return s.comm }
// Spawn creates a sub-agent from spec, registers it, publishes a "dispatched"
// progress event and launches its execution in the background. It returns
// immediately so the front session is not blocked; the sub-agent runs
// asynchronously and reports progress over the bus. It returns ErrMissingGoal
// when spec.Goal is blank, and a wrapped ErrUnknownPersona when spec.PersonaID
// names a role this process has no agent for (design/22 §8-2).
// Spawn 依据 spec 创建子代理、登记它、发布一条「已派发」进度事件,并在后台启动其
// 执行。它立即返回,因此前台会话不被阻塞;子代理异步运行并经总线报告进度。spec.Goal
// 为空时返回 ErrMissingGoal;spec.PersonaID 指名了本进程没有对应 agent 的角色时返回
// 包装后的 ErrUnknownPersona(design/22 §8-2)。
//
// The sub-agent's Context is a deep copy of spec.Context plus the task itself
// as one trailing user message (裁决 2026-10-04), and its WorkingMemory is
// a fresh, isolated window, so it never shares state with the front session.
// 子代理的 Context 是 spec.Context 的深拷贝加上作为末尾一条 user 消息的任务本身
// (裁决 2026-10-04),其 WorkingMemory 是全新隔离的窗口,因此它绝不与前台会话共享
// 状态。
func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error) {
if strings.TrimSpace(spec.Goal) == "" {
return nil, fmt.Errorf("orchestration: spawn: %w", ErrMissingGoal)
@@ -169,9 +163,8 @@ func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error
return sub, nil
}
// Run explicitly starts every registered sub-agent that has not started yet. It
// is idempotent and is provided for callers that prefer to defer execution; Spawn
// already auto-launches, so this is normally a no-op on freshly spawned batches.
// Run 显式启动每个尚未启动的已登记子代理。它幂等,供偏好延迟执行的调用方使用;Spawn
// 已自动启动,因此对刚派发的批次它通常是空操作。
func (s *Scheduler) Run(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
@@ -187,9 +180,8 @@ func (s *Scheduler) Run(ctx context.Context) {
}
}
// Join blocks until every spawned sub-agent is terminal (or ctx is canceled) and
// returns the merged outcome. On ctx cancellation it returns the partial merged
// outcome together with the wrapped ctx error.
// Join 阻塞到每个已派发的子代理终结(或 ctx 被取消),返回合并结果。ctx 取消时它
// 返回部分合并结果以及包装后的 ctx 错误。
func (s *Scheduler) Join(ctx context.Context) (*MergedResult, error) {
if ctx == nil {
ctx = context.Background()
@@ -206,9 +198,8 @@ func (s *Scheduler) Join(ctx context.Context) (*MergedResult, error) {
}
}
// Abort cancels the sub-agent with the given id. If it is running its loop is
// interrupted; if it is queued it is released without running; if it already
// finished the call is a no-op. It returns ErrAgentNotFound for an unknown id.
// Abort 取消给定 id 的子代理。它正在运行时中断其循环;排队中时不经运行即释放;
// 已经结束时该调用是空操作。未知 id 返回 ErrAgentNotFound。
func (s *Scheduler) Abort(ctx context.Context, id string) error {
if ctx == nil {
ctx = context.Background()
@@ -223,9 +214,8 @@ func (s *Scheduler) Abort(ctx context.Context, id string) error {
return nil
}
// CancelAll cancels every spawned sub-agent, queued or running. It is the
// anti-runaway escape hatch: after it returns each sub-agent converges to the
// aborted state.
// CancelAll 取消每个已派发的子代理,无论排队中还是运行中。它是防失控的逃生口:返回后
// 每个子代理都收敛到中止态。
func (s *Scheduler) CancelAll(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
@@ -242,8 +232,7 @@ func (s *Scheduler) CancelAll(ctx context.Context) error {
return nil
}
// Agents returns a snapshot of every registered sub-agent in spawn order. It is
// safe to read the returned snapshots at any time.
// Agents 按派发顺序返回每个已登记子代理的快照。随时读取返回的快照都是安全的。
func (s *Scheduler) Agents() []SubAgentResult {
s.mu.Lock()
subs := make([]*SubAgent, 0, len(s.agents))
@@ -259,11 +248,9 @@ func (s *Scheduler) Agents() []SubAgentResult {
return out
}
// Approve triggers a main-agent review-and-approve (代审) of a sub-agent's
// sensitive action through the injected *audit.Approver. Whether an action is
// sensitive and whether it is granted is decided by the upper policy layer; this
// method only records the audit entry. It returns ErrNoApprover when no approver
// was injected.
// Approve 经注入的 *audit.Approver 触发对某个子代理敏感操作的「主代理代审」。某项
// 操作是否敏感、是否获准由上层策略决定;本方法只记录审计条目。未注入 approver 时
// 返回 ErrNoApprover。
func (s *Scheduler) Approve(ctx context.Context, actor, action, describe string) (*audit.Approval, error) {
if s.approver == nil {
return nil, fmt.Errorf("orchestration: approve: %w", ErrNoApprover)
@@ -271,20 +258,17 @@ func (s *Scheduler) Approve(ctx context.Context, actor, action, describe string)
return s.approver.ReviewAndApprove(ctx, actor, action, describe)
}
// launch starts the sub-agent's background execution exactly once. It is called
// by Spawn (auto-launch) and by Run (explicit start); launchOnce guarantees a
// single run even if both happen.
// launch 恰好一次地启动子代理的后台执行。它由 Spawn(自动启动)与 Run(显式启动)
// 调用;launchOnce 保证即使两者都发生也只运行一次。
func (s *Scheduler) launch(ctx context.Context, sub *SubAgent) {
sub.launchOnce.Do(func() {
go s.run(ctx, sub)
})
}
// run drives one sub-agent: acquire a parallel slot, transition to running, run
// the loop in the sub-agent's isolated context under the per-agent timeout, then
// finalize with a summary and a terminal progress event. It always reaches
// exactly one finish, so active is decremented once and Join unblocks exactly
// when every sub-agent is terminal.
// run 驱动一个子代理:取得并行槽位、转入 running、在按代理时限下于子代理的隔离上下文
// 中运行循环,然后以摘要与终结态进度事件收尾。它总是恰好到达一次 finish,因此 active
// 只递减一次,Join 恰在每个子代理终结时解除阻塞。
func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
if ctx == nil {
ctx = context.Background()
@@ -308,16 +292,14 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
s.publish(EventStarted, sub.progress())
runCtx, cancel := s.subCtx(ctx, sub)
// A sub-agent must not leak its inner monologue into the caller's chat stream
// (design/05 §8: 少说话、多做事; design/04 §2: 不污染前台): muting suppresses the
// UI stream/capacity broadcast while progress still reaches the frontend over
// the bus and the flow lane. The flow branch attributes every span this Run
// emits to the spawning session's graph instead of opening a second graph.
// 子代理不得把内心独白泄漏进调用方的聊天流
// (design/05 §8: 少说话、多做事; design/04 §2: 不污染前台):静音抑制
// UI 流/容量广播,而进度仍经总线与泳道到达前端。flow 分支把本次 Run 发射的
// 每个 span 都归属到派发会话的图,而不是另开第二张图。
runCtx = loop.WithMutedBroadcast(runCtx)
runCtx = loop.WithFlowBranch(runCtx, sub.ParentSession)
// A dispatched target role decides the prompt and the memory scope for every step
// of this run (design/22 §8-2). Blank keeps the inherited env: the pre-existing
// behavior, where a sub-agent runs as the spawning turn's role.
// 被派发的目标角色决定本次运行每一步的提示词与记忆作用域(design/22 §8-2)。
// 留空则保持继承来的 env:既有行为,即子代理按派发回合的角色运行。
runCtx = withPersonaEnv(runCtx, sub.PersonaID)
// 工具白名单(design/05 §7 裁决 02.4.1)随运行上下文传递:名单非空时
// 只放行所列工具;空名单=全开,与挂载前的行为一致。
@@ -342,10 +324,9 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
s.finish(sub, StatusDone, doneSummary(res), nil)
}
// abortOrFail classifies an error after a sub-agent's run: a deadline exceeded or
// a cancellation yields the aborted status (timeout/stop), anything else is a
// real failure. The timeout notice names the deadline that actually applied to
// this sub-agent (per-call override or scheduler default), not the global one.
// abortOrFail 对子代理运行后的错误归类:期限超出或取消产出中止态(超时/停止),其余
// 都是真正的失败。超时提示点名的是实际施加于该子代理的期限(按调用覆盖或调度器默认),
// 而不是全局那个。
func (s *Scheduler) abortOrFail(sub *SubAgent, err error) {
switch {
case errors.Is(err, context.DeadlineExceeded):
@@ -357,9 +338,8 @@ func (s *Scheduler) abortOrFail(sub *SubAgent, err error) {
}
}
// finish finalizes a sub-agent exactly once: it records the terminal status and
// summary, publishes the terminal progress event and decrements the active count,
// closing the allDone channel when the last sub-agent finalizes.
// finish 恰好一次地给子代理收尾:记录终结状态与摘要、发布终结态进度事件、递减 active
// 计数,并在最后一个子代理收尾时关闭 allDone channel。
func (s *Scheduler) finish(sub *SubAgent, status Status, summary string, err error) {
sub.finishOnce.Do(func() {
sub.setOutcome(status, summary, err)
@@ -375,10 +355,9 @@ func (s *Scheduler) finish(sub *SubAgent, status Status, summary string, err err
})
}
// subCtx derives the run context, applying the sub-agent's effective timeout:
// a per-call AgentSpec.Timeout (design/05 §7 裁决 02.4.4 按调用覆盖) wins over
// the scheduler's PerAgentTimeout; neither configured means no deadline beyond
// cancellation.
// subCtx 派生运行上下文并施加该子代理的生效时限:按调用的 AgentSpec.Timeout
// (design/05 §7 裁决 02.4.4 按调用覆盖)优先于调度器的 PerAgentTimeout;两者都未配置
// 即除取消外无期限。
func (s *Scheduler) subCtx(parent context.Context, sub *SubAgent) (context.Context, context.CancelFunc) {
if sub.timeout > 0 {
return context.WithTimeout(parent, sub.timeout)
@@ -386,13 +365,11 @@ func (s *Scheduler) subCtx(parent context.Context, sub *SubAgent) (context.Conte
return context.WithCancel(parent)
}
// subSpan starts a sub-agent span when a tracer is configured, chaining it as a
// child of any span already in ctx. When the sub-agent is attributed to a flow
// session (AgentSpec.ParentSession) the span is also folded into that session's
// live graph as a subagent lane root, so the branch renders inside the graph of
// the session that spawned it rather than in a graph of its own (design/19 §2). It
// returns the ctx carrying the span and the span itself (nil when the tracer is
// nil).
// subSpan 在配置了 tracer 时开启一个子代理 span,把它串成 ctx 中已有 span 的子节点。
// 子代理被归属到某个 flow 会话(AgentSpec.ParentSession)时,该 span 还会作为子代理
// 泳道根折进那个会话的实时图,使分支渲染在派发它的会话图内,而不是自成一张图
// (design/19 §2)。它返回携带该 span 的 ctx 与 span 自身(tracer 为 nil 时 span 为
// nil)。
func (s *Scheduler) subSpan(ctx context.Context, sub *SubAgent) (context.Context, *trace.Span) {
if s.tracer == nil {
return ctx, nil
@@ -402,21 +379,20 @@ func (s *Scheduler) subSpan(ctx context.Context, sub *SubAgent) (context.Context
_ = span.SetProp("session", sess)
s.flow.SpanStarted(sess, span)
}
// The target role rides the branch node so the flow panel shows which role the
// sub-agent answered as (design/22 §8-2 可观测性), not just the sub-agent id.
// 目标角色随分支节点传播,使流程图面板展示子代理以哪个角色作答
// (design/22 §8-2 可观测性),而不只是子代理 id。
if sub.PersonaID != "" {
_ = span.SetProp("persona", sub.PersonaID)
}
return sctx, span
}
// subagentEventDone is the log event type recorded on a sub-agent span when its
// run settles, so the dashboard node carries the outcome summary (design/19 §1
// 节点实时日志).
// subagentEventDone 是子代理运行落定时记在其 span 上的日志事件类型,使面板节点带上
// 结果摘要(design/19 §1 节点实时日志)。
const subagentEventDone = "subagent.done"
// spanOK finishes a sub-agent span as ok, recording the result text and a
// desensitized outcome event, then folds the closed span into the parent flow.
// spanOK 把子代理 span 以 ok 收尾,记录结果文本与一条脱敏的结果事件,随后把已关闭的
// span 折回父 flow。
func (s *Scheduler) spanOK(sub *SubAgent, span *trace.Span, res *loop.Result) {
if span == nil {
return
@@ -433,8 +409,7 @@ func (s *Scheduler) spanOK(sub *SubAgent, span *trace.Span, res *loop.Result) {
s.foldSpanFinish(sub, span)
}
// spanErr finishes a sub-agent span as an error, recording the cause, then folds
// the closed span into the parent flow.
// spanErr 把子代理 span 以 error 收尾,记录原因,随后把已关闭的 span 折回父 flow。
func (s *Scheduler) spanErr(sub *SubAgent, span *trace.Span, err error) {
if span == nil {
return
@@ -448,10 +423,8 @@ func (s *Scheduler) spanErr(sub *SubAgent, span *trace.Span, err error) {
s.foldSpanFinish(sub, span)
}
// foldSpanFinish folds a just-closed sub-agent span into its parent session's
// flow graph, flipping the lane root to its final status. The span already
// carries the parent session as its "session" prop, so the publisher knows which
// graph owns it.
// foldSpanFinish 把一个刚关闭的子代理 span 折进其父会话的流程图,把泳道根翻转为终态。
// 该 span 已把父会话带在 "session" 属性上,因此发布器知道哪张图拥有它。
func (s *Scheduler) foldSpanFinish(sub *SubAgent, span *trace.Span) {
if span == nil || s.flow == nil || sub.ParentSession == "" {
return
@@ -459,9 +432,8 @@ func (s *Scheduler) foldSpanFinish(sub *SubAgent, span *trace.Span) {
s.flow.SpanFinished(sub.ParentSession, span)
}
// publish broadcasts a progress event over the bus. It is a no-op when no comm
// probe is configured. Publishing uses a background context so a canceled run
// still delivers its terminal event before the sub-agent is done.
// publish 经总线广播一条进度事件。未配置 comm 探针时是空操作。发布使用后台上下文,
// 因此被取消的运行仍能在子代理结束前送出它的终结态事件。
func (s *Scheduler) publish(name string, data Progress) {
if s.comm == nil {
return
@@ -471,13 +443,12 @@ func (s *Scheduler) publish(name string, data Progress) {
}
}
// nextID mints a unique sub-agent id from the scheduler's random prefix and an
// atomic sequence. It is safe for concurrent use.
// nextID 用调度器的随机前缀与一个原子序号生成唯一的子代理 id。可安全并发使用。
func (s *Scheduler) nextID() string {
return fmt.Sprintf("%s-%d", s.prefix, s.seq.Add(1))
}
// eventFor maps a terminal status to its progress event name.
// eventFor 把终结状态映射为对应的进度事件名。
func eventFor(status Status) string {
switch status {
case StatusDone:
@@ -17,10 +17,9 @@ import (
const waitTimeout = 3 * time.Second
// mockRunner is a configurable runner for scheduler tests. It counts concurrent
// invocations (for the parallel-limit test), captures the messages it receives
// (for the context-isolation test), and can block on a gate channel until closed
// or until ctx is done (to hold a sub-agent "running" for abort/timeout tests).
// mockRunner 是供调度器测试使用的可配置 runner。它统计并发调用数(供并行上限测试)、
// 捕获收到的消息(供上下文隔离测试),并可在 gate channel 上阻塞,直到它被关闭或 ctx
// 结束(供中止/超时测试把一个子代理维持在「运行中」)。
type mockRunner struct {
mu sync.Mutex
gate chan struct{}
@@ -85,8 +84,8 @@ func (m *mockRunner) captured(i int) []model.Message {
return m.calls[i]
}
// mockAdapter is a minimal model.Adapter so a real loop.Loop can be driven in
// tests without a provider. Chat returns a scripted final response.
// mockAdapter 是一个最小 model.Adapter,使真实 loop.Loop 无需供应商即可在测试中驱动。
// Chat 返回一个脚本化的最终响应。
type mockAdapter struct {
name string
resp model.Response
@@ -108,9 +107,8 @@ func (m *mockAdapter) ChatStream(context.Context, model.Request, func(model.Stre
return m.resp, m.err
}
// newScheduler builds a Scheduler over a fresh in-process bus, wiring the default
// comm probe. The bus is closed on test cleanup. Callers set sched.runner after
// the call, or pass Options.Loop to drive a real loop.
// newScheduler 在一个全新的进程内总线上构建 Scheduler,并接上默认 comm 探针。总线在
// 测试清理时关闭。调用方在调用之后再设置 sched.runner,或传 Options.Loop 驱动真实循环。
func newScheduler(t *testing.T, opts Options) *Scheduler {
t.Helper()
b := bus.New()
@@ -141,8 +139,7 @@ func countStatus(subs []*SubAgent, st Status) int {
return n
}
// collectEventNames reads events from ch until a terminal event or the deadline
// elapses, returning the event names in order.
// collectEventNames 从 ch 读取事件,直到出现终结态事件或期限到期,按顺序返回事件名。
func collectEventNames(t *testing.T, ch <-chan comm.Event, d time.Duration) []string {
t.Helper()
var names []string
@@ -180,7 +177,7 @@ func TestSpawnStatusFlow(t *testing.T) {
t.Fatal("sub-agent has no isolated working memory")
}
// pending -> running -> done.
// pending -> running -> done。
waitFor(t, func() bool { return sub.Status() == StatusRunning }, waitTimeout)
close(m.gate)
waitFor(t, func() bool { return sub.Status() == StatusDone }, waitTimeout)
@@ -227,8 +224,7 @@ func TestParallelLimit(t *testing.T) {
subs = append(subs, sub)
}
// Wait until the two parallel slots are occupied, then confirm never more
// than MaxParallel run concurrently.
// 等到两个并行槽位都被占用,再确认并发运行数从未超过 MaxParallel。
waitFor(t, func() bool { return countStatus(subs, StatusRunning) == 2 }, waitTimeout)
if got := m.maxActiveCount(); got != 2 {
t.Fatalf("max concurrent runs = %d, want 2 while gated", got)
@@ -255,7 +251,7 @@ func TestParallelLimit(t *testing.T) {
func TestPerAgentTimeout(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1, PerAgentTimeout: 50 * time.Millisecond})
m := &mockRunner{gate: make(chan struct{})} // never closed: block until timeout
m := &mockRunner{gate: make(chan struct{})} // 永不关闭:阻塞到超时
s.runner = m
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "slow"})
@@ -289,7 +285,7 @@ func TestAbort(t *testing.T) {
}
waitFor(t, func() bool { return sub.Status() == StatusAborted }, waitTimeout)
// Aborting an unknown id reports ErrAgentNotFound.
// 中止一个未知 id 报告 ErrAgentNotFound。
if err := s.Abort(context.Background(), "ghost"); !errors.Is(err, ErrAgentNotFound) {
t.Fatalf("Abort(ghost) err = %v, want ErrAgentNotFound", err)
}
@@ -14,18 +14,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// parentSession is the flow session the branch tests attribute their sub-agents
// to; it mirrors the web chat session the gateway publishes under.
// parentSession 是分支测试把自己的子代理归属到的 flow 会话;它与网关发布时所依的
// web 聊天会话一致。
const parentSession = "web"
// secretArg and secretValue are credential-shaped strings planted in a tool call's
// arguments and in its result. Neither may appear in a published flow graph.
// secretArg 与 secretValue 是植入某次工具调用参数及其结果里的凭据形状字符串。二者都
// 不得出现在已发布的流程图中。
const (
secretArg = "sk-arg-0123456789abcdef"
secretValue = "sk-live-0123456789abcdef"
)
// graphCapture records every flow graph the publisher emits, per session.
// graphCapture 按会话记录发布器发出的每张流程图。
type graphCapture struct {
mu sync.Mutex
bySession map[string]*trace.FlowGraph
@@ -81,10 +81,9 @@ func (c *graphCapture) sessionsLocked() []string {
return out
}
// toolThenFinalAdapter behaves like a model that asks for one tool call and then
// answers: it returns the scripted call until the request already carries a tool
// result. Deciding from the request (rather than a call counter) keeps it correct
// when two sub-agents drive the same instance concurrently.
// toolThenFinalAdapter 的行为像一个先请求一次工具调用、随后作答的模型:请求里已带有
// 工具结果时,它返回脚本化的最终答复。依据请求(而不是调用计数器)来判断,使两个子
// 代理并发驱动同一个实例时它依然正确。
type toolThenFinalAdapter struct {
toolName string
args map[string]any
@@ -105,16 +104,15 @@ func (a *toolThenFinalAdapter) Chat(_ context.Context, req model.Request, _ mode
}
func (a *toolThenFinalAdapter) ChatStream(ctx context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
// Emit a delta so a test can detect whether the loop forwarded the sub-agent's
// stream to the caller's UI channel.
// 发出一个增量,使测试能检出循环是否把子代理的流转发到了调用方的 UI channel。
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "内部草稿"})
}
return a.Chat(ctx, req, model.Options{})
}
// echoTool is a minimal tool that reports back a scripted result after an optional
// delay, so two branches' steps overlap in time.
// echoTool 是一个最小工具:可选延迟之后回报一个脚本化结果,使两个分支的步骤在时间上
// 重叠。
type echoTool struct {
name string
result any
@@ -136,9 +134,8 @@ func (e *echoTool) Execute(ctx context.Context, _ any) (any, error) {
return e.result, nil
}
// branchLoop builds a real loop over the given dispatcher with tracing and flow
// publication wired, plus a scheduler driving it — the production shape, minus the
// model provider.
// branchLoop 在给定 dispatcher 上构建带 tracing 与 flow 发布接线的真实循环,外加驱动
// 它的调度器 —— 即生产形状,只是少了模型供应商。
func branchLoop(t *testing.T, flow *loop.FlowPublisher, tracer *trace.Tracer, d *tool.Dispatcher, opts Options) *Scheduler {
t.Helper()
ad := &toolThenFinalAdapter{toolName: "echo", args: map[string]any{
@@ -158,7 +155,7 @@ func branchLoop(t *testing.T, flow *loop.FlowPublisher, tracer *trace.Tracer, d
return newScheduler(t, opts)
}
// flowNodeByID returns the node with the given id, failing the test when absent.
// flowNodeByID 返回给定 id 的节点,不存在时判测试失败。
func flowNodeByID(t *testing.T, g *trace.FlowGraph, id string) trace.FlowNode {
t.Helper()
for _, n := range g.Nodes {
@@ -178,8 +175,8 @@ func nodeIDs(g *trace.FlowGraph) []string {
return out
}
// flowKindNodes returns every node of the given kind, in graph insertion order
// (which is the order the spans started, i.e. the lane's step order).
// flowKindNodes 按图的插入顺序(即 span 开始的顺序,也就是泳道的步骤顺序)返回给定
// 类别的每个节点。
func flowKindNodes(g *trace.FlowGraph, kind string) []trace.FlowNode {
out := make([]trace.FlowNode, 0, len(g.Nodes))
for _, n := range g.Nodes {
@@ -190,8 +187,7 @@ func flowKindNodes(g *trace.FlowGraph, kind string) []trace.FlowNode {
return out
}
// flowLaneNodes returns the nodes inside a lane (a reachable set) in insertion
// order.
// flowLaneNodes 按插入顺序返回某条泳道(一个可达集合)内的节点。
func flowLaneNodes(g *trace.FlowGraph, lane map[string]bool) []trace.FlowNode {
out := make([]trace.FlowNode, 0, len(lane))
for _, n := range g.Nodes {
@@ -202,10 +198,9 @@ func flowLaneNodes(g *trace.FlowGraph, lane map[string]bool) []trace.FlowNode {
return out
}
// flowLaneChain returns the branch's steps ordered by insertion time and asserts
// they form one chain: the subagent root feeds the first step and each step feeds
// the next. A cross-linked lane (one step reached from two parents) or a stray node
// breaks the chain assertion.
// flowLaneChain 按插入时间返回该分支的各个步骤,并断言它们构成单条链:子代理根喂给
// 第一步,每一步喂给下一步。泳道交叉链接(某个步骤可从两个父节点到达)或游离节点都会
// 破坏链断言。
func flowLaneChain(t *testing.T, g *trace.FlowGraph, sub trace.FlowNode) []trace.FlowNode {
t.Helper()
lane := flowReachable(g, sub.ID)
@@ -224,7 +219,7 @@ func flowLaneChain(t *testing.T, g *trace.FlowGraph, sub trace.FlowNode) []trace
return chain
}
// chainKinds renders a lane chain as "llm>tool>llm" for failure messages.
// chainKinds 把一条泳道链渲染成 "llm>tool>llm",用于失败信息。
func chainKinds(chain []trace.FlowNode) string {
parts := make([]string, 0, len(chain))
for _, n := range chain {
@@ -233,8 +228,8 @@ func chainKinds(chain []trace.FlowNode) string {
return strings.Join(parts, ">")
}
// flowSubagentNode returns the branch root whose label names the given sub-agent
// id. A node is keyed by its span id, so the label is what identifies the owner.
// flowSubagentNode 返回标签点名了给定子代理 id 的分支根。节点以 span id 为键,因此标签
// 才是标识其归属者的东西。
func flowSubagentNode(t *testing.T, g *trace.FlowGraph, subID string) trace.FlowNode {
t.Helper()
want := "subagent." + subID
@@ -247,7 +242,7 @@ func flowSubagentNode(t *testing.T, g *trace.FlowGraph, subID string) trace.Flow
return trace.FlowNode{}
}
// flowReachable returns the set of node ids reachable from id, excluding id.
// flowReachable 返回从 id 可达的节点 id 集合,不含 id 自身。
func flowReachable(g *trace.FlowGraph, id string) map[string]bool {
out := make(map[string]bool)
queue := []string{id}
@@ -274,7 +269,7 @@ func hasFlowEdge(g *trace.FlowGraph, from, to string) bool {
return false
}
// flowLogEvent returns the node's log entry of the given type.
// flowLogEvent 返回该节点给定类型的日志条目。
func flowLogEvent(t *testing.T, n trace.FlowNode, typ string) trace.Event {
t.Helper()
for _, ev := range n.Log {
@@ -17,11 +17,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// TestSubagentBranchFoldsIntoParentGraph is the end-to-end wiring test for the
// branch visualization: a real loop driven by the scheduler under a parent
// session must appear in that session's graph as a subagent lane root whose inner
// llm/tool steps hang off it, must not start a round (so the frontend's single
// flowGraph is never replaced), and must not open a graph under its own id.
// TestSubagentBranchFoldsIntoParentGraph 是分支可视化的端到端接线测试:由调度器在父会话
// 之下驱动的真实循环,必须作为子代理泳道根出现在该会话的图里、其内部 llm/tool 步骤挂在
// 它下面;必须不开启新一轮(否则前端唯一的 flowGraph 会被替换);也必须不以其自身 id
// 另开一张图。
func TestSubagentBranchFoldsIntoParentGraph(t *testing.T) {
t.Parallel()
capture := &graphCapture{}
@@ -64,8 +63,7 @@ func TestSubagentBranchFoldsIntoParentGraph(t *testing.T) {
if subNode.Kind != "subagent" || subNode.Status != trace.FlowStatusDone {
t.Fatalf("subagent node = %+v, want kind subagent / status done", subNode)
}
// The branch's steps chain inside its own lane: llm (asks for the tool), tool,
// llm (answers).
// 该分支的步骤在自己的泳道内成链:llm(请求工具)、tool、llm(作答)。
chain := flowLaneChain(t, g, subNode)
if len(chain) != 3 || chain[0].Kind != "llm" || chain[1].Kind != "tool" || chain[2].Kind != "llm" {
t.Fatalf("lane chain = %s, want llm>tool>llm", chainKinds(chain))
@@ -75,7 +73,7 @@ func TestSubagentBranchFoldsIntoParentGraph(t *testing.T) {
t.Fatalf("input node label = %q, want the parent round's seed text", in.Label)
}
// Node logs: every executed step must carry a real event, and nothing else.
// 节点日志:每个已执行的步骤都必须带一条真实事件,且不带别的。
llmEvent := flowLogEvent(t, llmNode, loop.EventLLMDone)
if got := fmt.Sprint(llmEvent.Props["model"]); got != "mock-1" {
t.Fatalf("llm log model = %q, want mock-1 (props: %+v)", got, llmEvent.Props)
@@ -97,7 +95,7 @@ func TestSubagentBranchFoldsIntoParentGraph(t *testing.T) {
t.Fatalf("tool log args_keys = %q, want the argument names", got)
}
// Red line: no credential from the tool call or its result may be published.
// 红线:工具调用或其结果中的任何凭据都不得被发布。
blob, merr := json.Marshal(g)
if merr != nil {
t.Fatalf("marshal graph: %v", merr)
@@ -113,10 +111,9 @@ func TestSubagentBranchFoldsIntoParentGraph(t *testing.T) {
}
}
// TestSpawnParallelBranchesStaySeparate asserts two sub-agents running at the same
// time land in two disjoint swimlanes of the parent graph: each lane has its own
// llm→tool chain and nothing in one lane is reachable from the other (design/19 §2
// 泳道, the cross-linking failure the lane cursor exists to prevent).
// TestSpawnParallelBranchesStaySeparate 断言同时运行的两个子代理落在父图的两条互不相交
// 的泳道里:每条泳道有自己的 llm→tool 链,且一条泳道里没有任何东西可从另一条到达
// (design/19 §2 泳道,也就是泳道游标存在的意义——防交叉链接失效)。
func TestSpawnParallelBranchesStaySeparate(t *testing.T) {
t.Parallel()
capture := &graphCapture{}
@@ -169,17 +166,15 @@ func TestSpawnParallelBranchesStaySeparate(t *testing.T) {
t.Fatalf("node %s is reachable from both lanes (cross-linked edges: %+v)", id, g.Edges)
}
}
// The trunk cursor must still be the parent's seed, not the last branch node.
// 主干游标必须仍是父轮的种子,而不是最后那个分支节点。
if !hasFlowEdge(g, "input", nodeA.ID) || !hasFlowEdge(g, "input", nodeB.ID) {
t.Fatalf("edges = %+v, want both branch roots hanging off the parent's input node", g.Edges)
}
}
// TestSubagentRunDoesNotStreamToFrontSession asserts a sub-agent's inner stream is
// muted: the loop still drives the model (its llm step appears in the parent's flow
// lane), but no delta reaches the caller's OnStream, so a background sub-agent's
// monologue never leaks into the front conversation (design/05 §8, design/04 §2
// 不污染前台).
// TestSubagentRunDoesNotStreamToFrontSession 断言子代理的内部流被静音:循环仍驱动模型
// (其 llm 步骤出现在父图的泳道里),但没有任何增量抵达调用方的 OnStream,因此后台子代理
// 的独白绝不会泄漏进前台对话(design/05 §8, design/04 §2 不污染前台)。
func TestSubagentRunDoesNotStreamToFrontSession(t *testing.T) {
t.Parallel()
capture := &graphCapture{}
@@ -228,7 +223,7 @@ func TestSubagentRunDoesNotStreamToFrontSession(t *testing.T) {
if len(got) != 0 {
t.Fatalf("sub-agent pushed %d stream events into the front session: %+v", len(got), got)
}
// The work is still visible where it belongs: as a lane of the parent's graph.
// 该工作仍在它该在的地方可见:作为父图的一条泳道。
g := capture.graph(t, parentSession)
subNode := flowSubagentNode(t, g, sub.ID)
if chain := flowLaneChain(t, g, subNode); len(chain) == 0 {
@@ -236,9 +231,8 @@ func TestSubagentRunDoesNotStreamToFrontSession(t *testing.T) {
}
}
// TestSpawnWithoutParentSessionStaysOutOfEveryGraph asserts the flow attribution is
// opt-in: a spawn with no ParentSession publishes no graph at all, so an
// unattributed background agent can never surface as a stray lane.
// TestSpawnWithoutParentSessionStaysOutOfEveryGraph 断言流图归属是选择性的:未带
// ParentSession 的派发根本不发布任何图,因此未归属的后台 agent 绝不会作为游离泳道浮现。
func TestSpawnWithoutParentSessionStaysOutOfEveryGraph(t *testing.T) {
t.Parallel()
capture := &graphCapture{}
@@ -17,35 +17,33 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file locks design/22 §8-2 at the dispatch layer: `subagent_spawn` may name a
// target persona, and the sub-agent then runs AS that role — that role's instructions
// become the leading system message and that role's memory scope is what it reads and
// writes ("她以该角色身份临时替班"). Everything is exercised in process over a REAL
// *loop.Loop with a per-role agent pool and a REAL memory store, so the assertions are
// about what the sub-agent's run actually saw, not about wiring.
// 本文件在派发层锁定 design/22 §8-2:`subagent_spawn` 可以指名一个目标 persona,随后
// 子代理以那个角色身份运行 —— 该角色的指令成为前置 system 消息,该角色的记忆作用域
// 就是它读写的那一份(「她以该角色身份临时替班」)。一切都以真实 *loop.Loop 搭配按角色
// agent 池与真实记忆存储、在进程内走通,因此断言的是子代理运行实际看到了什么,而不是
// 接线。
//
// The other half of the file pins the backward-compatibility contract: omitting the
// persona must reproduce the pre-change behavior exactly (the sub-agent inherits the
// spawning turn's role), an unknown persona must be rejected instead of silently
// degrading, and naming a persona must never widen the identity/permission lane.
// 本文件的另一半钉住向后兼容契约:省略 persona 必须逐字复现改动前的行为(子代理继承
// 派发回合的角色);未知 persona 必须被拒绝,而不是静默降级;指名 persona 绝不得放宽
// 身份/权限车道。
const (
// parentRole is the role the dispatching session is bound to — deliberately NOT
// the role a persona dispatch asks for, so a leak shows up as the wrong prompt.
// parentRole 是派发会话所绑定到的角色 —— 故意不是 persona 派发所要求的角色,
// 因此一旦泄漏就会以错误的提示词暴露出来。
parentRole = "xilian"
// targetRole is the role a persona dispatch asks the sub-agent to stand in for.
// targetRole 是 persona 派发要求子代理替班的那个角色。
targetRole = "yuki"
)
// parentPrompt and targetPrompt are the two roles' instructions; a test tells which
// pool entry the loop resolved from the leading system message alone.
// parentPrompt 与 targetPrompt 是两个角色的指令;测试仅凭前置 system 消息就能判断循环
// 解析到了 agent 池里的哪一项。
const (
parentPrompt = "你是昔涟。"
targetPrompt = "你是由纪。"
)
// roleAdapter records every request the loop builds and answers with a fixed final
// text, so a test can read the exact prompt and memory block a sub-agent's run got.
// roleAdapter 记录循环构建的每次请求,并以固定最终文本作答,使测试能读到子代理运行实际
// 拿到的提示词与记忆块。
type roleAdapter struct {
mu sync.Mutex
reqs []model.Request
@@ -64,8 +62,8 @@ func (a *roleAdapter) ChatStream(ctx context.Context, req model.Request, _ func(
return a.Chat(ctx, req, model.Options{})
}
// request returns the n-th recorded request, failing the test when the run produced
// fewer. Requests are recorded in call order, i.e. one per sub-agent run.
// request 返回第 n 次记录的请求;运行产出的请求更少时判测试失败。请求按调用顺序记录,
// 即每个子代理运行一条。
func (a *roleAdapter) request(t *testing.T, n int) model.Request {
t.Helper()
a.mu.Lock()
@@ -76,8 +74,8 @@ func (a *roleAdapter) request(t *testing.T, n int) model.Request {
return a.reqs[n]
}
// leadingSystem returns the first message of the request, which is where the agent's
// instructions land (agent/request.go 首条 system 消息).
// leadingSystem 返回请求的第一条消息,agent 的指令正落在这里
// (agent/request.go 首条 system 消息)。
func (a *roleAdapter) leadingSystem(t *testing.T, n int) string {
t.Helper()
msgs := a.request(t, n).Messages
@@ -87,7 +85,7 @@ func (a *roleAdapter) leadingSystem(t *testing.T, n int) string {
return messageText(msgs[0])
}
// carries reports whether any message of the n-th request contains want.
// carries 报告第 n 次请求里是否有任何消息包含 want。
func (a *roleAdapter) carries(t *testing.T, n int, want string) bool {
t.Helper()
for _, m := range a.request(t, n).Messages {
@@ -98,10 +96,9 @@ func (a *roleAdapter) carries(t *testing.T, n int, want string) bool {
return false
}
// personaRig is the in-process e2e rig: a real loop with a two-role agent pool over
// a capturing adapter, the real scheduler on top (the production shape), the dispatch
// tool above that, and a real memory store so role scoping is asserted on persisted
// items. The turn hook records what role the loop resolved for each settled run.
// personaRig 是进程内端到端装置:一个在捕获式适配器之上、带双角色 agent 池的真实循环,
// 其上叠真实调度器(即生产形状),再往上是派发工具,另配真实记忆存储,使角色作用域能
// 针对已持久化的条目断言。turn 钩子记录循环对每次已结算运行解析出的角色。
type personaRig struct {
tool *SpawnTool
sched *Scheduler
@@ -112,8 +109,8 @@ type personaRig struct {
turns []loop.TurnEnd
}
// newPersonaRig builds the rig. personaExists is the role resolver under test: nil
// mirrors a composition root that wired no pool.
// newPersonaRig 组装装置。personaExists 是被测的角色解析器:nil 对应一个没有接 agent 池
// 的组合根。
func newPersonaRig(t *testing.T, personaExists func(string) bool) *personaRig {
t.Helper()
rig := &personaRig{ad: &roleAdapter{}, memStore: newPersonaMemoryStore(t)}
@@ -132,8 +129,8 @@ func newPersonaRig(t *testing.T, personaExists func(string) bool) *personaRig {
PersonaID: parentRole,
Memory: rig.memStore,
Retriever: memory.NewRetriever(rig.memStore),
// Built without a persona, exactly as cmd/cyrene builds it: stamping the
// turn's role onto each item is the loop's job.
// 构建时不带 persona,与 cmd/cyrene 的构建方式完全一致:把回合的角色盖到
// 每个条目上是循环的职责。
Extractor: memory.NewManualExtractor("", "owner"),
OnTurnEnd: func(end loop.TurnEnd) string {
rig.mu.Lock()
@@ -147,8 +144,7 @@ func newPersonaRig(t *testing.T, personaExists func(string) bool) *personaRig {
return rig
}
// turn returns the n-th settled turn the loop reported, failing the test when the
// rig observed fewer.
// turn 返回循环报告的第 n 个已结算回合;装置观察到的回合更少时判测试失败。
func (r *personaRig) turn(t *testing.T, n int) loop.TurnEnd {
t.Helper()
r.mu.Lock()
@@ -159,16 +155,15 @@ func (r *personaRig) turn(t *testing.T, n int) loop.TurnEnd {
return r.turns[n]
}
// parentTurnCtx is the context the dispatching turn runs under: the parent session's
// own role on the owner-full identity lane, as the gateway resolves it. A persona
// dispatch must be able to change the first without touching the second.
// parentTurnCtx 是派发回合所运行的上下文:父会话自己的角色,位于 owner 全权身份车道上,
// 即网关解析出的样子。persona 派发必须能改变前者而不触碰后者。
func parentTurnCtx() context.Context {
env := loop.OwnerEnv()
env.PersonaID = parentRole
return loop.WithSessionEnv(context.Background(), env)
}
// newPersonaMemoryStore opens an in-memory store for the rig.
// newPersonaMemoryStore 为装置打开一个内存存储。
func newPersonaMemoryStore(t *testing.T) *memory.Store {
t.Helper()
ctx := context.Background()
@@ -184,7 +179,7 @@ func newPersonaMemoryStore(t *testing.T) *memory.Store {
return store
}
// seedRoleMemory files one persona-scope memory under roleID.
// seedRoleMemory 在 roleID 下归档一条 persona 作用域的记忆。
func seedRoleMemory(t *testing.T, store *memory.Store, roleID, content string) {
t.Helper()
_, err := store.Add(context.Background(), memory.MemoryItem{
@@ -199,8 +194,7 @@ func seedRoleMemory(t *testing.T, store *memory.Store, roleID, content string) {
}
}
// storedPersonaOf returns the persona a memory with the given content was filed
// under, or "" when it is absent.
// storedPersonaOf 返回内容包含给定字符串的那条记忆所归档到的 persona;不存在时返回 ""。
func storedPersonaOf(t *testing.T, store *memory.Store, content string) string {
t.Helper()
items, err := store.List(context.Background())
@@ -215,15 +209,13 @@ func storedPersonaOf(t *testing.T, store *memory.Store, content string) string {
return ""
}
// TestSpawnToolRunsAsTargetPersona is the design/22 §8-2 acceptance, end to end in
// process: a dispatch naming a persona runs with THAT role's instructions and THAT
// role's memory, while the dispatching session's own role stays untouched. The same
// rig then proves the omitted-persona path is unchanged, which is the pre-change
// behavior: the sub-agent inherits the spawning turn's role.
// TestSpawnToolRunsAsTargetPersona 是 design/22 §8-2 的验收,进程内端到端:指名 persona
// 的派发以那个角色的指令与那个角色的记忆运行,而派发会话自己的角色保持不变。同一装置
// 随后证明省略 persona 的路径未变,即改动前的行为:子代理继承派发回合的角色。
func TestSpawnToolRunsAsTargetPersona(t *testing.T) {
t.Parallel()
rig := newPersonaRig(t, func(id string) bool { return id == parentRole || id == targetRole })
// One memory per role, so the injected block names which scope the run read from.
// 每个角色一条记忆,使注入的块能点名该次运行读的是哪个作用域。
seedRoleMemory(t, rig.memStore, targetRole, "由纪的暗号是香菜")
seedRoleMemory(t, rig.memStore, parentRole, "昔涟的暗号是乌冬")
@@ -237,38 +229,37 @@ func TestSpawnToolRunsAsTargetPersona(t *testing.T) {
if len(got.Agents) != 1 || got.Agents[0].Status != string(StatusDone) {
t.Fatalf("agents = %+v, want one settled sub-agent", got.Agents)
}
// The reported role is the target persona, so the tool result, the progress events
// and the ws subagent_event frame all name the role it really answered as.
// 报告的角色就是目标 persona,因此工具结果、进度事件与 ws subagent_event 帧都点名
// 它真正以之作答的角色。
if got.Agents[0].Role != targetRole {
t.Fatalf("reported role = %q, want the target persona %q", got.Agents[0].Role, targetRole)
}
// 1) The prompt the model actually received is the TARGET role's instructions —
// not the dispatching role's, and not a shared default.
// 1) 模型实际收到的提示词是目标角色的指令 —— 既不是派发角色的,也不是共享默认值。
if prompt := rig.ad.leadingSystem(t, 0); prompt != targetPrompt {
t.Fatalf("sub-agent system prompt = %q, want the target persona's %q", prompt, targetPrompt)
}
// 2) The role the loop resolved for the run (which scopes memory, emotion and the
// thinking knobs) is the target persona, although the dispatching env said xilian.
// 2) 循环为本次运行解析出的角色(它决定记忆、情绪与思考旋钮的作用域)是目标 persona,
// 尽管派发的 env 说的是 xilian。
if turn := rig.turn(t, 0); turn.PersonaID != targetRole {
t.Fatalf("sub-agent turn persona = %q, want %q", turn.PersonaID, targetRole)
}
// 3) Memory reads follow the target role: only yuki's memory was injected.
// 3) 记忆读取跟随目标角色:只有由纪的记忆被注入。
if !rig.ad.carries(t, 0, "由纪的暗号是香菜") {
t.Fatalf("sub-agent did not recall the target persona's memory: %+v", rig.ad.request(t, 0).Messages)
}
if rig.ad.carries(t, 0, "昔涟的暗号是乌冬") {
t.Fatalf("sub-agent read the dispatching persona's memory: %+v", rig.ad.request(t, 0).Messages)
}
// 4) The identity lane travelled unchanged: naming a persona is never a permission
// (project red line: 身份识别靠元数据 + 策略引擎).
// 4) 身份车道原样传递:指名 persona 从来不是一种权限
// (项目红线:身份识别靠元数据 + 策略引擎)。
env := rig.turn(t, 0).Env
if env.ActorEnv != restrict.ActorOwner || !env.MemWritable {
t.Fatalf("sub-agent env = %+v, want the dispatching owner lane unchanged", env)
}
// Backward compatibility: a dispatch without persona still runs as the spawning
// role (xilian) and reads xilian's memory — byte for byte the pre-change behavior.
// 向后兼容:不带 persona 的派发仍以派发角色(xilian)运行、读昔涟的记忆 —— 逐字节
// 等同改动前的行为。
raw, err = rig.tool.Execute(parentTurnCtx(), map[string]any{
"goal": "老路子", "role": "legacy-label",
})
@@ -288,17 +279,16 @@ func TestSpawnToolRunsAsTargetPersona(t *testing.T) {
if !rig.ad.carries(t, 1, "昔涟的暗号是乌冬") {
t.Fatalf("persona-less sub-agent lost the inherited role's memory: %+v", rig.ad.request(t, 1).Messages)
}
// The legacy role label still round-trips untouched when no persona is named.
// 未指名 persona 时,旧的角色标签仍原样往返。
if got.Agents[0].Role != "legacy-label" {
t.Fatalf("legacy role = %q, want it untouched when persona is omitted", got.Agents[0].Role)
}
}
// TestSpawnedSubAgentMemoryWriteScopeIsTargetPersona pins the write half of design/22
// §8-2: a memory the sub-agent's turn extracts is filed under the target persona, not
// under the role of the session that dispatched it. It also pins that a persona never
// widens the identity lane: in a restricted (read-only) env the same dispatch still
// writes nothing, even though it names a role.
// TestSpawnedSubAgentMemoryWriteScopeIsTargetPersona 钉住 design/22 §8-2 的写侧:子代理
// 回合提取出的记忆归档到目标 persona 下,而不是派发它的那个会话的角色下。它同时钉住
// persona 绝不放宽身份车道:在受限(只读)env 下,同一次派发即使指名了角色也依然什么
// 都不写。
func TestSpawnedSubAgentMemoryWriteScopeIsTargetPersona(t *testing.T) {
t.Parallel()
rig := newPersonaRig(t, func(string) bool { return true })
@@ -319,8 +309,8 @@ func TestSpawnedSubAgentMemoryWriteScopeIsTargetPersona(t *testing.T) {
t.Fatalf("extracted memory filed under %q, want the target persona %q", got, targetRole)
}
// Restricted env: the dispatching turn was a group chat (read-only). Naming a
// persona must not create a write path where the lane forbids one.
// 受限 env:派发回合来自群聊(只读)。指名 persona 绝不得在车道禁止的地方开出一条
// 写入路径。
restricted := loop.WithSessionEnv(context.Background(), loop.SessionEnv{
ActorEnv: restrict.ActorGroupMember,
MemWritable: false,
@@ -14,17 +14,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file locks the CONTRACT half of design/22 §8-2 (the behavior half lives in
// spawnpersona_test.go): the tool schema and result shape a model sees, the rejection
// of an unknown role, and the session env a sub-agent run is stamped with. The point of
// these tests is that a dispatch which names no persona stays byte-identical to the
// pre-change behavior, while a dispatch that names an unusable role fails loudly rather
// than degrading into the default role.
// 本文件锁定 design/22 §8-2 的契约那一半(行为那一半在 spawnpersona_test.go):模型
// 看到的工具 schema 与结果形状、未知角色的拒绝,以及子代理运行被盖上的会话 env。这些
// 测试的重点是:不指名 persona 的派发与改动前的行为逐字节一致,而指名一个不可用角色的
// 派发会明确报错,而不是降级为默认角色。
// TestSpawnPersonaRejectsUnknownRole is the "未知角色 → 明确报错,不静默回落" half: an id
// the process cannot run is refused before anything starts, for a single dispatch and
// for a batch (all-or-nothing, so no half-run batch is left behind), and a blank id is
// never treated as a role.
// TestSpawnPersonaRejectsUnknownRole 是「未知角色 → 明确报错,不静默回落」那一半:本
// 进程跑不了的 id 会在任何东西启动之前就被拒绝,单次派发与批次都是如此(全有或全无,
// 因此不会留下半跑的批次);空 id 也绝不会被当作角色。
func TestSpawnPersonaRejectsUnknownRole(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -84,10 +81,9 @@ func TestSpawnPersonaRejectsUnknownRole(t *testing.T) {
}
}
// TestSchedulerSpawnCarriesPersonaEnv pins the library half of the same contract for
// callers that bypass the tool: Spawn stamps the target role onto the run's env (so
// the loop resolves that role's prompt and memory scope), a blank persona leaves the
// inherited env untouched, and Spawn itself refuses an unknown role.
// TestSchedulerSpawnCarriesPersonaEnv 为绕过该工具的调用方钉住同一契约的库那一半:
// Spawn 把目标角色盖到运行的 env 上(使循环解析该角色的提示词与记忆作用域),空 persona
// 让继承来的 env 原样不动,Spawn 自身也拒绝未知角色。
func TestSchedulerSpawnCarriesPersonaEnv(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{PersonaExists: func(id string) bool { return id == targetRole }})
@@ -98,8 +94,8 @@ func TestSchedulerSpawnCarriesPersonaEnv(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
// The recorded binding names the target role too, so the progress events and the
// ws subagent_event frame report the role the sub-agent answered as.
// 记录下来的绑定也点名目标角色,因此进度事件与 ws subagent_event 帧报告的是子代理
// 以之作答的角色。
if sub.RoleID != targetRole || sub.PersonaID != targetRole {
t.Fatalf("sub-agent binding = %q/%q, want the target role on both", sub.RoleID, sub.PersonaID)
}
@@ -126,9 +122,8 @@ func TestSchedulerSpawnCarriesPersonaEnv(t *testing.T) {
}
}
// envRunner records the session env each sub-agent run starts with. The memory scope
// a run uses is decided from exactly this value, so it is the boundary under test for
// callers that go through Scheduler.Spawn directly.
// envRunner 记录每次子代理运行起步时的会话 env。一次运行使用哪个记忆作用域正是由这个值
// 决定的,因此对直接走 Scheduler.Spawn 的调用方而言,它就是被测边界。
type envRunner struct {
envs chan loop.SessionEnv
}
@@ -138,10 +133,9 @@ func (r envRunner) Run(ctx context.Context, _ string, _ []model.Message) (*loop.
return &loop.Result{Text: "ok", Steps: 1}, nil
}
// TestSpawnToolResultShapeIsUnchangedWithoutPersona pins the strict backward
// compatibility of the model-visible result: a dispatch that names no persona still
// returns exactly the same JSON keys as before this feature — no "persona" field is
// added anywhere — so nothing about the omitted-persona contract moved.
// TestSpawnToolResultShapeIsUnchangedWithoutPersona 钉住模型可见结果的严格向后兼容:
// 不指名 persona 的派发仍返回与引入本功能之前完全相同的 JSON 键 —— 任何地方都没有新增
// "persona" 字段 —— 因此省略 persona 的契约一点没动。
func TestSpawnToolResultShapeIsUnchangedWithoutPersona(t *testing.T) {
t.Parallel()
s, sp := spawnToolFor(t, Options{}, time.Second)
@@ -175,7 +169,7 @@ func TestSpawnToolResultShapeIsUnchangedWithoutPersona(t *testing.T) {
}
}
// sortedKeys returns m's keys in sorted order, for a stable comparison in tests.
// sortedKeys 按字典序返回 m 的键,供测试做稳定比较。
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
@@ -185,17 +179,16 @@ func sortedKeys(m map[string]any) []string {
return out
}
// TestSpawnSchemaKeepsPersonaOptional pins the tool contract: persona is an optional
// string at both the single-goal and the tasks level, nothing new is required, and the
// pre-existing arguments keep their meaning — so a model (or an old client) that never
// sends persona sees byte-identical validation behavior.
// TestSpawnSchemaKeepsPersonaOptional 钉住工具契约:persona 在单 goal 与 tasks 两级都是
// 可选字符串,没有新增任何必填项,既有参数含义不变 —— 因此从不发 persona 的模型(或旧
// 客户端)看到的校验行为逐字节相同。
func TestSpawnSchemaKeepsPersonaOptional(t *testing.T) {
t.Parallel()
props, err := NewSpawnTool(nil).Parameters()
if err != nil {
t.Fatalf("Parameters: %v", err)
}
// The required set is unchanged: goal or tasks, nothing else.
// 必填集合未变:goal 或 tasks,别无其他。
if got := requiredAnyOf(t, props); strings.Join(got, ",") != "goal,tasks" {
t.Fatalf("anyOf required = %v, want goal/tasks only", got)
}
@@ -216,7 +209,7 @@ func TestSpawnSchemaKeepsPersonaOptional(t *testing.T) {
}
}
// requiredAnyOf flattens the anyOf required lists of a tool schema.
// requiredAnyOf 摊平工具 schema 的各个 anyOf required 列表。
func requiredAnyOf(t *testing.T, props map[string]any) []string {
t.Helper()
anyOf, _ := props["anyOf"].([]any)
@@ -232,7 +225,7 @@ func requiredAnyOf(t *testing.T, props map[string]any) []string {
return out
}
// stringProp returns the named top-level property of a tool schema property block.
// stringProp 返回工具 schema 属性块里指名的顶层属性。
func stringProp(t *testing.T, props map[string]any, name string) map[string]any {
t.Helper()
properties, _ := props["properties"].(map[string]any)
+78 -99
View File
@@ -12,28 +12,23 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// Constants for the sub-agent dispatch tool (design/05 §1 创建, §7 并行与安全).
// 子代理派发工具(design/05 §1 创建, §7 并行与安全)的常量。
const (
// SpawnToolName is the registered name of the tool the agent calls to
// dispatch background sub-agents.
// SpawnToolName 是 agent 调用以派发后台子代理的工具的注册名。
SpawnToolName = "subagent_spawn"
// DefaultSpawnWait is how long a foreground dispatch waits for its batch
// before handing the turn back to the model. It mirrors the runtime's
// established "前台运行,超过 2 秒自动移入后台" threshold
// (DevDocs/design/04-运行时.md §8.5, internal/runtime/task). The sub-agents keep
// running either way; only whether the tool result already carries their
// output changes.
// DefaultSpawnWait 是前台派发在把回合交还模型之前等待其批次的时间。它沿用运行时
// 既定的「前台运行,超过 2 秒自动移入后台」阈值
// (DevDocs/design/04-运行时.md §8.5, internal/runtime/task)。子代理无论如何都
// 继续运行;变的只是工具结果是否已带上它们的输出。
DefaultSpawnWait = 2 * time.Second
// maxSpawnBatch caps how many sub-agents one dispatch may create. It bounds the
// queue a single model call can build (design/05 §7 防失控); the parallel
// execution limit itself stays with the scheduler's MaxParallel.
// maxSpawnBatch 限制一次派发最多创建多少个子代理。它约束单次模型调用能堆积的
// 队列长度(design/05 §7 防失控);并行执行上限本身仍由调度器的 MaxParallel 负责。
maxSpawnBatch = 8
// maxSpawnTextRunes caps how many runes of one sub-agent's result text are
// handed back to the spawning model, so a runaway sub-agent cannot flood the
// front session's context. The scheduler keeps the full text on its snapshot.
// maxSpawnTextRunes 限制交回派发模型的单个子代理结论文本的字符数,使失控的子代理
// 无法淹没前台会话的上下文。调度器在其快照上保留完整文本。
maxSpawnTextRunes = 4000
// minSpawnTimeoutSeconds 与 maxSpawnTimeoutSeconds 界定按调用覆盖
@@ -50,14 +45,12 @@ const (
SubagentResultSubject = "subagent.result"
)
// SpawnTool is the runtime entry point that lets the running agent dispatch
// sub-agents (design/05 §1). It is the only production caller of
// (*Scheduler).Spawn: the model invokes it like any other tool, the scheduler
// applies the parallel limit, per-agent timeout and abort machinery, and the tool
// hands back either the merged result or — when the batch is still running after
// the foreground wait — a dispatch acknowledgement.
// SpawnTool 是让运行中的 agent 派发子代理的运行时入口(design/05 §1)。它是
// (*Scheduler).Spawn 唯一的生产调用方:模型像调用任何其他工具一样调用它,调度器施加
// 并行上限、按代理时限与中止机制,工具则交回合并结果,或者 —— 前台窗口内批次仍在跑时
// —— 一条派发确认。
//
// It is safe for concurrent use and holds no per-call state.
// 它可安全并发使用,且不持有任何按调用的状态。
type SpawnTool struct {
sched *Scheduler
wait time.Duration
@@ -65,11 +58,10 @@ type SpawnTool struct {
log *slog.Logger
}
// SpawnOption customizes a SpawnTool built by NewSpawnTool.
// SpawnOption 定制由 NewSpawnTool 构建的 SpawnTool。
type SpawnOption func(*SpawnTool)
// WithSpawnWait overrides how long a foreground dispatch waits for its batch. A
// non-positive value keeps DefaultSpawnWait.
// WithSpawnWait 覆盖前台派发等待其批次的时间。非正值保持 DefaultSpawnWait。
func WithSpawnWait(d time.Duration) SpawnOption {
return func(t *SpawnTool) {
if d > 0 {
@@ -78,7 +70,7 @@ func WithSpawnWait(d time.Duration) SpawnOption {
}
}
// WithSpawnLogger replaces the tool's logger (tagged source "orchestration/spawn").
// WithSpawnLogger 替换该工具的 logger(标注来源 "orchestration/spawn")。
func WithSpawnLogger(l *slog.Logger) SpawnOption {
return func(t *SpawnTool) { t.log = spawnLog(l) }
}
@@ -95,8 +87,8 @@ func WithSpawnTimeout(d time.Duration) SpawnOption {
}
}
// NewSpawnTool builds the dispatch tool over sched. A nil scheduler is tolerated:
// Execute then fails fast with ErrNoScheduler instead of panicking.
// NewSpawnTool 在 sched 上构建派发工具。调度器为 nil 也被容忍:Execute 随后以
// ErrNoScheduler 快速失败,而不是 panic。
func NewSpawnTool(sched *Scheduler, opts ...SpawnOption) *SpawnTool {
t := &SpawnTool{sched: sched, wait: DefaultSpawnWait, log: spawnLog(nil)}
for _, opt := range opts {
@@ -107,12 +99,11 @@ func NewSpawnTool(sched *Scheduler, opts ...SpawnOption) *SpawnTool {
return t
}
// Name returns the tool identifier.
// Name 返回工具标识符。
func (t *SpawnTool) Name() string { return SpawnToolName }
// Description explains the tool to the model: what a sub-agent is, that it runs in
// an isolated context in parallel, and that a slow batch is handed to the
// background rather than blocking the conversation (design/05 §1/§2).
// Description 向模型解释该工具:什么是子代理、它在隔离上下文中并行运行、慢批次会被
// 交给后台而不阻塞对话(design/05 §1/§2)。
func (t *SpawnTool) Description() string {
return "Dispatch one or more sub-agents to work in parallel on independent parts of a task. " +
"Each sub-agent runs in its own isolated context and returns a summary plus its result. " +
@@ -120,11 +111,9 @@ func (t *SpawnTool) Description() string {
"results later; prefer several sub-agents over one serial chain for independent work."
}
// Parameters returns the input JSON Schema: either a single goal shorthand or a
// tasks array for a parallel batch, plus an optional persona (the role the
// sub-agent runs as, design/22 §8-2), an optional background flag, an optional
// per-dispatch tool whitelist (design/05 §7) and an optional per-call timeout
// override.
// Parameters 返回输入的 JSON Schema:单个 goal 简写,或用于并行批次的 tasks 数组,
// 外加可选的 persona(子代理运行时所充当的角色,design/22 §8-2)、可选的 background
// 标志、可选的按派发工具白名单(design/05 §7)与可选的按调用超时覆盖。
func (t *SpawnTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
@@ -149,15 +138,13 @@ func (t *SpawnTool) Parameters() (map[string]any, error) {
}, nil
}
// Execute dispatches the requested sub-agents, then either returns their merged
// results (the batch settled inside the foreground window) or a dispatch
// acknowledgement (it moved to the background, reported later on the bus).
// Execute 派发所请求的子代理,随后要么返回它们的合并结果(批次在前台窗口内结算),
// 要么返回一条派发确认(批次已转入后台,稍后经总线报告)。
//
// The sub-agents are spawned on a detached context: their lifetime is their own
// (bounded by the scheduler's per-agent timeout and Abort/CancelAll), never the
// dispatching turn's, so a slow batch is not killed the moment the tool returns
// (design/04 §2 后台不阻塞前台). Context values are kept so the sub-agent span —
// and the flow lane it roots — still chains under the step that dispatched it.
// 子代理在一个脱离的上下文上派发:它们的生命周期属于自己(受调度器的按代理时限与
// Abort/CancelAll 约束),从不是派发回合的,因此慢批次不会在工具返回的那一刻被杀
// (design/04 §2 后台不阻塞前台)。上下文值被保留,使子代理 span —— 以及它作为根的
// 泳道 —— 仍串在派发它的那一步之下。
func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
@@ -170,9 +157,8 @@ func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
if err != nil {
return nil, fmt.Errorf("%s: %w", SpawnToolName, err)
}
// Reject an unknown target role before anything starts: a batch must be
// all-or-nothing, otherwise the sub-agents already dispatched would keep running
// with their result never reported to the model (design/22 §8-2: 未知角色明确报错,
// 在任何东西启动之前就拒绝未知的目标角色:批次必须是全有或全无,否则已经派发出的
// 子代理会继续运行,而它们的结果永远不上报给模型(design/22 §8-2: 未知角色明确报错,
// 不静默回落到默认角色).
if perr := t.sched.CheckPersonas(specs); perr != nil {
return nil, fmt.Errorf("%s: %w", SpawnToolName, perr)
@@ -197,9 +183,8 @@ func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
return t.foreground(ids, specs), nil
}
// foreground waits up to t.wait for the dispatched batch to settle. A settled
// batch is merged into the tool result; a batch still running when the window
// closes is announced in the background and acknowledged immediately.
// foreground 至多等待 t.wait 让已派发的批次结算。结算的批次被合并进工具结果;窗口
// 关闭时仍在运行的批次改到后台宣告,并立即被确认。
func (t *SpawnTool) foreground(ids []string, specs []AgentSpec) any {
waitCtx, cancel := context.WithTimeout(context.Background(), t.wait)
defer cancel()
@@ -212,9 +197,8 @@ func (t *SpawnTool) foreground(ids []string, specs []AgentSpec) any {
return doneSpawnResult(res)
}
// await blocks until the batch settles and broadcasts the merged outcome as the
// design/05 §2 "结束后写执行总结 → 返回主会话" hand-off. It runs on a detached
// goroutine; a nil bus or an unknown id ends it quietly.
// await 阻塞到批次结算,并把合并结果作为 design/05 §2「结束后写执行总结 → 返回主会话」
// 的交接广播出去。它运行在脱离的 goroutine 上;总线为 nil 或 id 未知时安静结束。
// parentSession 是该批次所属的父会话,随载荷一起广播,使中继能把回填结果
// 归属到父会话的实时流,而不是发一张无归属的帧。
func (t *SpawnTool) await(ids []string, parentSession string) {
@@ -235,36 +219,35 @@ func (t *SpawnTool) await(ids []string, parentSession string) {
}
}
// SpawnResult is the payload the dispatch tool returns to the spawning model: the
// batch's merged outcome when it settled inside the foreground window, or a
// dispatch acknowledgement when it moved to the background.
// SpawnResult 是派发工具返回给派发模型的载荷:批次在前台窗口内结算时是它的合并结果,
// 转入后台时是一条派发确认。
type SpawnResult struct {
// Status is "done" (settled) or "running" (dispatched to the background).
// Status 是 "done"(已结算)或 "running"(已转入后台)。
Status string `json:"status"`
// Summary is a one-line aggregate over the batch.
// Summary 是对该批次的单行聚合。
Summary string `json:"summary"`
// Agents holds one entry per dispatched sub-agent, in dispatch order.
// Agents 持有每个已派发子代理的一个条目,按派发顺序排列。
Agents []SpawnAgentResult `json:"agents"`
}
// SpawnAgentResult is one sub-agent's entry in a SpawnResult.
// SpawnAgentResult 是 SpawnResult 中单个子代理的条目。
type SpawnAgentResult struct {
// ID is the sub-agent id (also the id the WS subagent_event frames carry).
// ID 是子代理 id(也是 WS subagent_event 帧所携带的 id)。
ID string `json:"id"`
// Goal is the objective the sub-agent was given.
// Goal 是交给该子代理的目标。
Goal string `json:"goal"`
// Role is the bound role/persona, if any: the dispatched target persona when one
// was named, otherwise the legacy role label.
// Role 是绑定的角色/persona(若有):指名了派发目标角色时用该角色,否则是旧的
// 角色标签。
Role string `json:"role,omitempty"`
// Mode is the bound behavior mode, if any.
// Mode 是绑定的行为模式(若有)。
Mode string `json:"mode,omitempty"`
// Status is the lifecycle status at return time.
// Status 是返回时刻的生命周期状态。
Status string `json:"status"`
// Summary is the execution summary written when the sub-agent settled.
// Summary 是子代理结算时写入的执行摘要。
Summary string `json:"summary,omitempty"`
// Text is the sub-agent's result text (capped at maxSpawnTextRunes).
// Text 是子代理的结果文本(上限 maxSpawnTextRunes)。
Text string `json:"text,omitempty"`
// Steps is how many loop steps the sub-agent ran.
// Steps 是子代理执行的循环步数。
Steps int `json:"steps,omitempty"`
}
@@ -275,7 +258,7 @@ type SubagentResultEvent struct {
*SpawnResult
}
// doneSpawnResult maps a settled batch onto the tool result.
// doneSpawnResult 把已结算的批次映射为工具结果。
func doneSpawnResult(res *MergedResult) *SpawnResult {
out := &SpawnResult{Status: "done", Agents: make([]SpawnAgentResult, 0)}
if res == nil {
@@ -297,7 +280,7 @@ func doneSpawnResult(res *MergedResult) *SpawnResult {
return out
}
// runningSpawnResult acknowledges a batch that is still running in the background.
// runningSpawnResult 确认一个仍在后台运行的批次。
func runningSpawnResult(ids []string, specs []AgentSpec) *SpawnResult {
out := &SpawnResult{
Status: "running",
@@ -317,10 +300,9 @@ func runningSpawnResult(ids []string, specs []AgentSpec) *SpawnResult {
return out
}
// spawnSpecs decodes the tool arguments into one AgentSpec per sub-agent. A
// non-empty tasks array is a parallel batch and takes precedence over the single
// goal shorthand. dftTimeout is the dispatch-layer default (WithSpawnTimeout);
// a non-positive value leaves the scheduler's per-agent timeout in charge.
// spawnSpecs 把工具参数解码成每个子代理一个 AgentSpec。非空的 tasks 数组是并行批次,
// 优先于单个 goal 简写。dftTimeout 是派发层默认值(WithSpawnTimeout);非正值则让
// 调度器的按代理时限说了算。
func spawnSpecs(m map[string]any, parentSession string, dftTimeout time.Duration) ([]AgentSpec, error) {
tasks, err := taskList(m["tasks"])
if err != nil {
@@ -360,8 +342,8 @@ func spawnSpecs(m map[string]any, parentSession string, dftTimeout time.Duration
return specs, nil
}
// specFromMap reads one AgentSpec out of a decoded arguments object (the top-level
// object or one tasks entry). A blank goal wraps ErrMissingGoal.
// specFromMap 从已解码的参数对象(顶层对象或 tasks 中的一个条目)读取一个 AgentSpec。
// goal 为空时包装 ErrMissingGoal。
func specFromMap(m map[string]any, parentSession string) (AgentSpec, error) {
goal := strings.TrimSpace(stringArg(m, "goal"))
if goal == "" {
@@ -369,11 +351,10 @@ func specFromMap(m map[string]any, parentSession string) (AgentSpec, error) {
}
personaID := strings.TrimSpace(stringArg(m, "persona"))
roleID := strings.TrimSpace(stringArg(m, "role"))
// A named persona is the role the sub-agent actually runs as, so it also fills
// the binding metadata: leaving role and persona disagreeing would make the tool
// result, the progress events and the ws subagent_event frame name a role the
// sub-agent is not answering as. Omitting persona keeps the pre-existing
// role-only path byte for byte (design/22 §8-2 backward compatibility).
// 被指名的 persona 就是子代理实际充当的角色,因此它也填充绑定元数据:让 role 与
// persona 互相矛盾会导致工具结果、进度事件与 ws subagent_event 帧点名一个子代理
// 并未以之作答的角色。省略 persona 则逐字节保持既有的纯 role 路径
// (design/22 §8-2 向后兼容)。
if personaID != "" {
roleID = personaID
}
@@ -389,9 +370,8 @@ func specFromMap(m map[string]any, parentSession string) (AgentSpec, error) {
}, nil
}
// taskList reads a tasks array of objects. A missing or empty array yields nil; a
// non-array or an entry that is not an object is rejected so a malformed batch
// never silently degrades into a single spawn.
// taskList 读取由对象组成的 tasks 数组。缺失或空数组产出 nil;非数组或条目不是对象则
// 被拒绝,使畸形批次绝不静默退化成单次派发。
func taskList(v any) ([]map[string]any, error) {
if v == nil {
return nil, nil
@@ -411,12 +391,12 @@ func taskList(v any) ([]map[string]any, error) {
return out, nil
}
// spawnStringProp builds a string property schema.
// spawnStringProp 构建一个字符串属性 schema。
func spawnStringProp(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
}
// spawnTasksProp builds the tasks array property schema for a parallel batch.
// spawnTasksProp 构建并行批次的 tasks 数组属性 schema。
func spawnTasksProp() map[string]any {
return map[string]any{
"type": "array",
@@ -438,8 +418,8 @@ func spawnTasksProp() map[string]any {
}
}
// spawnWhitelistProp builds the tool_whitelist property schema (design/05 §7
// 裁决 02.4.1):缺省全开,显式列出则只放行所列。
// spawnWhitelistProp 构建 tool_whitelist 属性 schema(design/05 §7
// 裁决 02.4.1):缺省全开,显式列出则只放行所列。
func spawnWhitelistProp(desc string) map[string]any {
return map[string]any{
"type": "array",
@@ -452,8 +432,8 @@ func spawnWhitelistProp(desc string) map[string]any {
}
}
// spawnTimeoutProp builds the timeout_seconds property schema (design/05 §7
// 裁决 02.4.4 的按调用覆盖)。dft 非正时描述为「沿用派发默认」。
// spawnTimeoutProp 构建 timeout_seconds 属性 schema(design/05 §7
// 裁决 02.4.4 的按调用覆盖)。dft 非正时描述为「沿用派发默认」。
func spawnTimeoutProp(dft time.Duration) map[string]any {
desc := "Per-call timeout override in seconds for the sub-agent run (optional; omitted keeps the " +
"configured sub-agent default)."
@@ -468,29 +448,28 @@ func spawnTimeoutProp(dft time.Duration) map[string]any {
}
}
// spawnPersonaProp builds the persona property schema. It is the design/22 §8-2 派发
// switch: naming a role makes the sub-agent stand in for it (that role's
// instructions and memory scope) instead of working as the spawning session's role.
// spawnPersonaProp 构建 persona 属性 schema。它是 design/22 §8-2 的派发开关:指名一个
// 角色会让子代理替班该角色(用该角色的指令与记忆作用域),而不是以派发会话的角色工作。
func spawnPersonaProp() map[string]any {
return spawnStringProp("Persona/role id the sub-agent runs AS: it answers with that role's " +
"instructions and reads/writes that role's memory scope (optional, default: the current " +
"role). A role id this runtime does not know is rejected. Supersedes role.")
}
// stringArg reads a string argument, tolerating a missing or non-string value.
// stringArg 读取一个字符串参数,容忍缺失或非字符串值。
func stringArg(m map[string]any, key string) string {
s, _ := m[key].(string)
return s
}
// boolArg reads a boolean argument, tolerating a missing or non-boolean value.
// boolArg 读取一个布尔参数,容忍缺失或非布尔值。
func boolArg(m map[string]any, key string) bool {
b, _ := m[key].(bool)
return b
}
// spawnLog returns l tagged with source "orchestration/spawn", replacing a nil
// logger with a discard one so the tool can always log.
// spawnLog 返回标注了来源 "orchestration/spawn" 的 l,并把 nil logger 换成丢弃式
// logger,使该工具总能记录日志。
func spawnLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -18,8 +18,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// countingRunner is a scheduler runner that records how many invocations overlap,
// so a test can prove the batch still goes through the scheduler's parallel gate.
// countingRunner 是一个调度器 runner,记录有多少次调用互相重叠,使测试能证明该批次
// 仍走调度器的并行闸门。
type countingRunner struct {
delay time.Duration
result *loop.Result
@@ -64,16 +64,15 @@ func (r *countingRunner) peak() int {
return r.maxActive
}
// spawnToolFor builds a dispatch tool over a scheduler with the given options and
// foreground window.
// spawnToolFor 在带给定选项与前台窗口的调度器上构建派发工具。
func spawnToolFor(t *testing.T, opts Options, wait time.Duration) (*Scheduler, *SpawnTool) {
t.Helper()
s := newScheduler(t, opts)
return s, NewSpawnTool(s, WithSpawnWait(wait))
}
// decodeSpawnResult re-reads the tool result through JSON, so a test asserts the
// shape the model actually receives rather than the in-memory struct.
// decodeSpawnResult 经 JSON 重新读取工具结果,使测试断言的是模型实际收到的形状,而不是
// 内存里的结构体。
func decodeSpawnResult(t *testing.T, raw any) SpawnResult {
t.Helper()
b, err := json.Marshal(raw)
@@ -87,7 +86,7 @@ func decodeSpawnResult(t *testing.T, raw any) SpawnResult {
return out
}
// statusOf returns the status the scheduler reports for id, or "" when unknown.
// statusOf 返回调度器为 id 报告的状态;未知时返回 ""。
func statusOf(s *Scheduler, id string) Status {
for _, a := range s.Agents() {
if a.ID == id {
@@ -159,8 +158,7 @@ func TestSpawnToolParallelBatchHonorsParallelLimit(t *testing.T) {
t.Fatalf("agent %s status = %q, want done", a.ID, a.Status)
}
}
// The batch went through the scheduler's semaphore: with MaxParallel=1 the
// three sub-agents can never run at the same time.
// 该批次确实走过了调度器的信号量:MaxParallel=1 时三个子代理绝不可能同时运行。
if peak := r.peak(); peak != 1 {
t.Fatalf("max concurrent runs = %d, want 1 (the parallel limit)", peak)
}
@@ -247,7 +245,7 @@ func TestSpawnToolMovesSlowBatchToBackground(t *testing.T) {
func TestSpawnToolKeepsPerAgentTimeout(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1, PerAgentTimeout: 40 * time.Millisecond})
s.runner = &mockRunner{gate: make(chan struct{})} // never closed: block until the timeout
s.runner = &mockRunner{gate: make(chan struct{})} // 永不关闭:阻塞到超时
sp := NewSpawnTool(s, WithSpawnWait(2*time.Second))
raw, err := sp.Execute(context.Background(), map[string]any{"goal": "hang"})
@@ -337,9 +335,8 @@ func TestSpawnToolWithoutScheduler(t *testing.T) {
}
}
// TestSpawnToolThroughDispatcher drives the tool through the real dispatcher so the
// advertised JSON Schema, the argument validation and the registry's reachability
// markers are all exercised — not just Execute.
// TestSpawnToolThroughDispatcher 让该工具走真实的 dispatcher,使对外广告的 JSON
// Schema、参数校验与注册表的可达性标记都被演练到 —— 而不只是 Execute。
func TestSpawnToolThroughDispatcher(t *testing.T) {
t.Parallel()
s, sp := spawnToolFor(t, Options{MaxParallel: 2}, time.Second)
@@ -374,8 +371,8 @@ func TestSpawnToolThroughDispatcher(t *testing.T) {
t.Fatalf("result = %+v, want one settled agent", got)
}
// Registered with the default (both false) markers: owner-only, so a group or
// stranger session can neither see nor invoke it (design/11 §3).
// 以默认标记(两个都 false)注册:仅主人可用,因此群聊或陌生人会话既看不到也
// 调不动它(design/11 §3)。
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(SpawnToolName) {
t.Fatal("owner was denied the spawn tool")
}
+59 -70
View File
@@ -9,65 +9,59 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// Status is the lifecycle state of a spawned sub-agent.
// Status 是已派发子代理的生命周期状态。
//
// The lifecycle is pending -> running -> one of the terminal states
// (done, failed, aborted). A sub-agent is terminal exactly when Terminal
// reports true.
// 生命周期为 pending -> running -> 终结态之一(done、failed、aborted)。
// 当且仅当 Terminal 报告 true 时,子代理处于终结态。
type Status string
const (
// StatusPending means the sub-agent was created but has not started.
// StatusPending 表示子代理已创建但尚未开始。
StatusPending Status = "pending"
// StatusRunning means the sub-agent's loop is executing.
// StatusRunning 表示子代理的循环正在执行。
StatusRunning Status = "running"
// StatusDone means the loop completed and produced a result.
// StatusDone 表示循环已完成并产出结果。
StatusDone Status = "done"
// StatusFailed means the loop returned an error.
// StatusFailed 表示循环返回了错误。
StatusFailed Status = "failed"
// StatusAborted means the sub-agent was canceled or timed out.
// StatusAborted 表示子代理被取消或超时。
StatusAborted Status = "aborted"
)
// Terminal reports whether s is a final state. Pending and running are not
// terminal; every sub-agent ends in one of the three terminal states.
// Terminal 报告 s 是否为终态。pending 与 running 不是终态;每个子代理都以三种终结态
// 之一收尾。
func (s Status) Terminal() bool {
return s == StatusDone || s == StatusFailed || s == StatusAborted
}
// AgentSpec describes a sub-agent to spawn. Goal is required; the rest are
// optional and bound onto the sub-agent for the upper layer to interpret.
// AgentSpec 描述一个待派发的子代理。Goal 必填;其余字段可选,绑定到子代理上供上层
// 解读。
type AgentSpec struct {
// Goal is the objective the sub-agent works toward. It is required.
// Goal 是子代理要达成的目标。必填。
Goal string
// RoleID selects the role/persona the sub-agent is bound to.
// RoleID 选择子代理绑定的角色/persona。
RoleID string
// PersonaID is the role the sub-agent actually runs as (design/22 §8-2): it
// answers with that role's instructions and reads/writes that role's memory
// scope — "她以该角色身份临时替班". Spawn stamps it onto the sub-agent run's
// session env, which is the single input the loop resolves the prompt, the
// memory namespace, the emotion and the thinking knobs from, so the four can
// never disagree. Blank keeps the pre-existing behavior byte for byte: the
// sub-agent inherits the spawning turn's env, i.e. the parent session's role.
// The id must be one this process can run with its own agent
// (*Scheduler).PersonaKnown; Spawn rejects an unknown one with
// ErrUnknownPersona instead of silently falling back to the default role.
// PersonaID 是子代理实际充当的角色(design/22 §8-2):它以该角色的指令作答,
// 读写该角色的记忆作用域 ——「她以该角色身份临时替班」。Spawn 把它盖到子代理
// 运行的会话 env 上,而那是循环解析提示词、记忆命名空间、情绪与思考旋钮的唯一
// 输入,因此四者不可能互相矛盾。留空则逐字节保持既有行为:子代理继承派发回合的
// env,即父会话的角色。该 id 必须是本进程能以自身 agent 运行的角色
// ((*Scheduler).PersonaKnown);Spawn 会拒绝未知 id 并返回 ErrUnknownPersona,
// 而不是静默回落到默认角色。
PersonaID string
// Mode selects the behavior mode (e.g. "engineer") for the role.
// Mode 选择该角色的行为模式(例如 "engineer")。
Mode string
// Context is the initial, isolated conversation context. Spawn deep-copies
// it, so later mutation by the caller does not affect the sub-agent.
// Context 是初始的、隔离的会话上下文。Spawn 会深拷贝它,因此调用方此后的修改
// 不会影响子代理。
Context []model.Message
// AttachInfo is free-form attachment/injection text supplied at spawn.
// AttachInfo 是派发时提供的自由形式附加/注入文本。
AttachInfo string
// ParentSession is the session whose live flow graph owns the branch, so the
// sub-agent's span and its inner steps render as a swimlane of the spawning
// session's graph instead of as a graph of their own (design/19 §2). The
// spawning tool takes it from loop.FlowOwnerFromContext. Blank disables the
// flow attribution; it never affects which session the sub-agent runs in, so
// its checkpoint, memory and stream isolation are unchanged.
// ParentSession 是拥有该分支的会话,其实时流程图将承载子代理的 span 及其内部
// 步骤,使它们渲染成派发会话图的一条泳道,而不是自成一张图(design/19 §2)。
// 派发工具从 loop.FlowOwnerFromContext 取得它。留空则关闭流图归属;它绝不影响
// 子代理在哪个会话里运行,因此其 checkpoint、记忆与流隔离都不变。
ParentSession string
// Data carries an optional structured payload for the caller's use.
// Data 承载供调用方使用的可选结构化载荷。
Data any
// ToolWhitelist 限定该子代理可执行的工具集合(design/05 §7 裁决 02.4.1):
// 缺省(nil/空)=继承主 agent 的全量工具集;显式列出=只放行所列工具,
@@ -78,70 +72,65 @@ type AgentSpec struct {
Timeout time.Duration
}
// Progress event names published on the agent-to-agent bus via the Scheduler's
// comm probe. External subscribers can match on these to drive a monitoring
// panel (DevDocs/design/05-子代理.md §9).
// 经 comm 探针发布到 agent 间总线上的进度事件名。外部订阅方可据此匹配,驱动一个
// 监控面板(DevDocs/design/05-子代理.md §9)。
const (
// EventDispatched is published when a sub-agent is created.
// EventDispatched 在子代理被创建时发布。
EventDispatched = "subagent.dispatched"
// EventStarted is published when a sub-agent begins running.
// EventStarted 在子代理开始运行时发布。
EventStarted = "subagent.started"
// EventDone is published when a sub-agent finishes successfully.
// EventDone 在子代理成功结束时发布。
EventDone = "subagent.done"
// EventFailed is published when a sub-agent fails.
// EventFailed 在子代理失败时发布。
EventFailed = "subagent.failed"
// EventAborted is published when a sub-agent is canceled or times out.
// EventAborted 在子代理被取消或超时时发布。
EventAborted = "subagent.aborted"
)
// Progress is the status snapshot carried by a progress event's Data payload.
// Progress 是进度事件 Data 载荷所承载的状态快照。
type Progress struct {
// AgentID is the spawned sub-agent id.
// AgentID 是已派发的子代理 id。
AgentID string
// Goal is the sub-agent's objective.
// Goal 是子代理的目标。
Goal string
// RoleID is the bound role/persona.
// RoleID 是绑定的角色/persona。
RoleID string
// Mode is the bound behavior mode.
// Mode 是绑定的行为模式。
Mode string
// Status is the sub-agent's status at publish time.
// Status 是子代理在发布时刻的状态。
Status Status
}
// Sentinel errors returned by the Scheduler. They are wrapped with %w at call
// sites so callers can test with errors.Is.
// Scheduler 返回的哨兵错误。调用点以 %w 包装它们,因此调用方可用 errors.Is 判断。
var (
// ErrMissingGoal is returned by Spawn when the spec Goal is blank.
// ErrMissingGoal 在 spec 的 Goal 为空时由 Spawn 返回。
ErrMissingGoal = errors.New("orchestration: missing goal")
// ErrNoRunner is returned when a sub-agent runs with no loop runner
// configured (Options.Loop was nil).
// ErrNoRunner 在子代理运行时未配置循环 runner(Options.Loop 为 nil)时返回。
ErrNoRunner = errors.New("orchestration: no loop runner configured")
// ErrNoApprover is returned by Approve when no *audit.Approver was injected.
// ErrNoApprover 在未注入 *audit.Approver 时由 Approve 返回。
ErrNoApprover = errors.New("orchestration: no approver configured")
// ErrNoMemory is returned by SinkMemory when no *memory.Store was injected.
// ErrNoMemory 在未注入 *memory.Store 时由 SinkMemory 返回。
ErrNoMemory = errors.New("orchestration: no memory store configured")
// ErrAgentNotFound is returned by Abort when the sub-agent id is unknown.
// ErrAgentNotFound 在子代理 id 未知时由 Abort 返回。
ErrAgentNotFound = errors.New("orchestration: sub-agent not found")
// ErrUnknownPersona is returned by Spawn when a spec names a role this process
// has no per-role agent for (design/22 §8-2). It is a rejection, not a
// fallback: running the sub-agent with the default role's prompt while the
// caller asked for another role would silently defeat the request.
// ErrUnknownPersona 在 spec 指名了一个本进程没有对应按角色 agent 的角色时由
// Spawn 返回(design/22 §8-2)。它是拒绝,不是回退:调用方要的是另一个角色,
// 却用默认角色的提示词运行子代理,那会静默地背离请求本意。
ErrUnknownPersona = errors.New("orchestration: unknown persona")
// ErrNoScheduler is returned by the spawn tool when it was built without a
// scheduler (a wiring mistake, reported instead of panicking).
// ErrNoScheduler 在派发工具被构建时未带调度器(接线错误,如实报告而不 panic)
// 时由该工具返回。
ErrNoScheduler = errors.New("orchestration: no scheduler configured")
)
// cloneMessages returns a deep copy of msgs: the message slice and each message's
// content parts are copied so the result shares no memory with the input. It is
// used at Spawn to give each sub-agent an isolated context. A nil input yields
// nil; a non-nil empty input yields a non-nil empty slice.
// cloneMessages 返回 msgs 的深拷贝:消息切片与每条消息的内容分段都被复制,因此结果
// 与入参不共享内存。它在 Spawn 处用于给每个子代理一个隔离上下文。nil 入参产出 nil;
// 非 nil 的空入参产出非 nil 的空切片。
func cloneMessages(msgs []model.Message) []model.Message {
if msgs == nil {
return nil
@@ -158,8 +147,8 @@ func cloneMessages(msgs []model.Message) []model.Message {
return out
}
// compactText shortens s to at most n runes for inclusion in a summary, appending
// an ellipsis when it was truncated. It never splits a multi-byte rune.
// compactText 把 s 截短到至多 n 个字符以用于摘要,被截断时追加省略号。它绝不切开
// 多字节字符。
func compactText(s string, n int) string {
s = strings.TrimSpace(s)
r := []rune(s)
+48 -59
View File
@@ -10,45 +10,41 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// SubAgent is a single spawned sub-agent instance. The identity fields (ID, Goal,
// RoleID, PersonaID, Mode, AttachInfo, ParentSession, Data, ToolWhitelist,
// Context, WorkingMemory) are set once at Spawn and never mutated. The lifecycle fields (Status, Summary,
// Result, Err) are written by the sub-agent's background goroutine and read through
// the synchronized accessors below, so reading them before the sub-agent finishes is
// safe. A sub-agent is terminal exactly once its Done reports true; Join returns
// once every sub-agent is terminal, Wait once the named ids are.
// SubAgent 是单个已派发的子代理实例。身份字段(ID、Goal、RoleID、PersonaID、Mode、
// AttachInfo、ParentSession、Data、ToolWhitelist、Context、WorkingMemory)在 Spawn 时
// 一次设定,此后永不改动。生命周期字段(Status、Summary、Result、Err)由子代理的后台
// goroutine 写入,并经下方同步访问器读取,因此在子代理结束前读取它们是安全的。当且
// 仅当 Done 报告 true 时子代理处于终结态;Join 在全部子代理终结后返回,Wait 在指名的
// id 全部终结后返回。
//
// SubAgent is created by (*Scheduler).Spawn; it should not be built directly.
// SubAgent 由 (*Scheduler).Spawn 创建;不应直接构造。
type SubAgent struct {
// ID uniquely identifies the sub-agent within its scheduler.
// ID 在所属调度器内唯一标识该子代理。
ID string
// Goal is the objective the sub-agent pursues.
// Goal 是子代理追求的目标。
Goal string
// RoleID is the bound role/persona.
// RoleID 是绑定的角色/persona。
RoleID string
// PersonaID is the target role the sub-agent runs as: its instructions and its
// memory scope (design/22 §8-2). Blank means "run as the spawning turn's role",
// the pre-existing behavior. The dispatch tool fills it (validated against the
// per-role agent pool) and Spawn stamps it onto the run's session env.
// PersonaID 是子代理运行时充当的目标角色:其指令与记忆作用域(design/22 §8-2)。
// 留空表示「按派发回合的角色运行」,即既有行为。派发工具填充它(已对照按角色
// agent 池校验),Spawn 把它盖到运行的会话 env 上。
PersonaID string
// Mode is the bound behavior mode.
// Mode 是绑定的行为模式。
Mode string
// AttachInfo is the free-form attachment text supplied at spawn.
// AttachInfo 是派发时提供的自由形式附加文本。
AttachInfo string
// ParentSession is the session whose flow graph owns this sub-agent's branch
// (blank when the branch is not visualized).
// ParentSession 是拥有本子代理分支的流程图所属会话(该分支不被可视化时为空)。
ParentSession string
// Data is the optional structured payload supplied at spawn.
// Data 是派发时提供的可选结构化载荷。
Data any
// ToolWhitelist 是该子代理的工具白名单(design/05 §7 裁决 02.4.1):
// nil/空=继承主 agent 全量工具集;非空=只放行所列工具。Spawn 时深拷贝。
ToolWhitelist []string
// Context is the isolated conversation context. It is a deep copy of the
// AgentSpec.Context plus the task user message, and is never shared with the
// front session.
// Context 是隔离的会话上下文。它是 AgentSpec.Context 的深拷贝加上任务 user
// 消息,绝不与前台会话共享。
Context []model.Message
// WorkingMemory is the sub-agent's own working-memory window, created fresh
// at Spawn so it never overlaps another sub-agent or the front session.
// WorkingMemory 是该子代理自己的工作记忆窗口,在 Spawn 时全新创建,因此绝不与
// 另一个子代理或前台会话重叠。
WorkingMemory *memory.WorkingMemory
// timeout 是该子代理生效的运行时限(按调用覆盖优先于调度器默认,
// design/05 §7 裁决 02.4.4);非正表示除取消外无期限。Spawn 时落定,只读。
@@ -67,74 +63,69 @@ type SubAgent struct {
abortReq bool
doneClosed bool
// finishedCh is closed exactly once when the sub-agent reaches a terminal
// state, so Wait can block per-id without polling.
// finishedCh 在子代理进入终结态时恰好关闭一次,因此 Wait 能按 id 阻塞而无需
// 轮询。
finishedCh chan struct{}
launchOnce sync.Once
finishOnce sync.Once
}
// Status returns the sub-agent's current lifecycle status.
// Status 返回子代理当前的生命周期状态。
func (s *SubAgent) Status() Status {
s.mu.RLock()
defer s.mu.RUnlock()
return s.status
}
// Summary returns the execution summary written when the sub-agent finalized:
// a completion summary on success, or a failure/abort reason otherwise. It is
// empty until the sub-agent is terminal.
// Summary 返回子代理收尾时写入的执行摘要:成功时是完成摘要,否则是失败/中止原因。
// 子代理进入终结态之前它为空。
func (s *SubAgent) Summary() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.summary
}
// Result returns the loop result on a successful run, or nil when the run failed
// or was aborted.
// Result 返回运行成功时的循环结果;运行失败或被中止时返回 nil。
func (s *SubAgent) Result() *loop.Result {
s.mu.RLock()
defer s.mu.RUnlock()
return s.result
}
// Err returns the error that ended a failed or aborted run (nil on success).
// Err 返回结束失败或中止运行的那个错误(成功时为 nil)。
func (s *SubAgent) Err() error {
s.mu.RLock()
defer s.mu.RUnlock()
return s.runErr
}
// StartedAt returns when the sub-agent transitioned to running (zero if it never
// started).
// StartedAt 返回子代理转入 running 的时间(从未启动时为零值)。
func (s *SubAgent) StartedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.started
}
// FinishedAt returns when the sub-agent reached a terminal state (zero if not
// terminal yet).
// FinishedAt 返回子代理进入终结态的时间(尚未终结时为零值)。
func (s *SubAgent) FinishedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.finished
}
// Done reports whether the sub-agent reached a terminal state.
// Done 报告子代理是否已进入终结态。
func (s *SubAgent) Done() bool { return s.Status().Terminal() }
// waitDone returns the channel closed when the sub-agent reaches a terminal
// state. It is what (*Scheduler).Wait blocks on, per-id, without polling.
// waitDone 返回子代理进入终结态时被关闭的 channel。它是 (*Scheduler).Wait 按 id 阻塞
// 时等待的对象,无需轮询。
func (s *SubAgent) waitDone() <-chan struct{} { return s.finishedCh }
// markFinished closes the completion channel exactly once. It is called from
// finish under finishOnce, so a sub-agent wakes every Wait caller exactly once.
// markFinished 恰好关闭一次完成 channel。它在 finish 里于 finishOnce 之下调用,因此
// 每个子代理恰好唤醒它的每个 Wait 调用方一次。
func (s *SubAgent) markFinished() { close(s.finishedCh) }
// Snapshot returns a point-in-time copy of the sub-agent's state, safe to read
// while the sub-agent is still running.
// Snapshot 返回子代理状态的某一时刻拷贝,在子代理仍在运行时读取也安全。
func (s *SubAgent) Snapshot() SubAgentResult {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -158,8 +149,8 @@ func (s *SubAgent) Snapshot() SubAgentResult {
return snap
}
// transitionRunning moves a pending sub-agent to running. It returns false when
// the sub-agent was already aborted (so it must not run) or is not pending.
// transitionRunning 把 pending 的子代理转入 running。子代理已被中止(因此不得运行)
// 或当前不是 pending 时返回 false。
func (s *SubAgent) transitionRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
@@ -171,7 +162,7 @@ func (s *SubAgent) transitionRunning() bool {
return true
}
// progress returns the current status snapshot for a progress event.
// progress 返回用于进度事件的当前状态快照。
func (s *SubAgent) progress() Progress {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -184,8 +175,8 @@ func (s *SubAgent) progress() Progress {
}
}
// setOutcome records the terminal status, summary and error after the run ends.
// It is called at most once per sub-agent, guarded by finishOnce.
// setOutcome 在运行结束后记录终结状态、摘要与错误。每个子代理至多调用一次,由
// finishOnce 把守。
func (s *SubAgent) setOutcome(status Status, summary string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -195,16 +186,15 @@ func (s *SubAgent) setOutcome(status Status, summary string, err error) {
s.finished = time.Now()
}
// setResult records the successful loop result.
// setResult 记录成功时的循环结果。
func (s *SubAgent) setResult(res *loop.Result) {
s.mu.Lock()
defer s.mu.Unlock()
s.result = res
}
// setCancel stores the run context's cancel function so Abort can interrupt the
// loop. If the sub-agent was already aborted (cancelRun raced ahead), the stored
// cancel is invoked immediately.
// setCancel 保存运行上下文的取消函数,使 Abort 能中断循环。若子代理已被中止
// (cancelRun 先行),则立即调用保存的 cancel。
func (s *SubAgent) setCancel(c context.CancelFunc) {
s.mu.Lock()
s.cancel = c
@@ -215,10 +205,9 @@ func (s *SubAgent) setCancel(c context.CancelFunc) {
}
}
// cancelRun requests cancellation of the sub-agent. It is idempotent and safe for
// concurrent use: the done channel is closed once and the stored cancel (when set)
// is invoked once. A pending sub-agent unblocks from its parallel slot via done; a
// running sub-agent is interrupted via the cancel func.
// cancelRun 请求取消该子代理。它幂等且可并发使用:done channel 只关闭一次,已保存的
// cancel(若已设置)只调用一次。排队中的子代理经 done 解除对并行槽位的阻塞;运行中的
// 子代理经 cancel 函数被中断。
func (s *SubAgent) cancelRun() {
s.mu.Lock()
s.abortReq = true
@@ -234,7 +223,7 @@ func (s *SubAgent) cancelRun() {
}
}
// aborted reports whether a cancellation was requested for the sub-agent.
// aborted 报告该子代理是否已被请求取消。
func (s *SubAgent) aborted() bool {
s.mu.RLock()
defer s.mu.RUnlock()
+4 -6
View File
@@ -9,8 +9,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// sourcedLog returns the base logger tagged with source "orchestration",
// replacing a nil logger with a discard one so the scheduler can always log.
// sourcedLog 返回标注了来源 "orchestration" 的基础 logger,并把 nil logger
// 换成丢弃式 logger,使调度器总能记录日志。
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -18,12 +18,10 @@ func sourcedLog(l *slog.Logger) *slog.Logger {
return logger.WithSource(l, "orchestration")
}
// prefixBytes is the number of random bytes used to build the sub-agent id
// prefix.
// prefixBytes 是构建子代理 id 前缀所用的随机字节数。
const prefixBytes = 4
// randPrefix returns a short random prefix for sub-agent ids, falling back to
// "sub" if the system entropy source is unavailable.
// randPrefix 返回子代理 id 的短随机前缀;系统熵源不可用时回退为 "sub"。
func randPrefix() string {
var b [prefixBytes]byte
if _, err := rand.Read(b[:]); err != nil {
+14 -17
View File
@@ -5,17 +5,15 @@ import (
"fmt"
)
// Wait blocks until every sub-agent named by ids reaches a terminal state (or ctx
// is canceled) and returns the merged outcome of exactly those sub-agents, in the
// order the ids were given.
// Wait 阻塞到 ids 指名的每个子代理都进入终结态(或 ctx 被取消),返回且仅返回这些
// 子代理的合并结果,顺序与给定 id 的顺序一致。
//
// Unlike Join — which covers the whole scheduler — Wait scopes both the wait and
// the merge to one caller's batch, so concurrent spawns from different sessions
// never wait on, or merge, each other's sub-agents. It is what the spawn tool uses
// to collect the results it is about to hand back.
// 与覆盖整个调度器的 Join 不同,Wait 把等待与合并都限定在某个调用方自己的批次上,
// 因此不同会话的并发派发绝不会互相等待、也不会合并到彼此的子代理。派发工具正是用
// 它收集自己即将交回的结果。
//
// It returns ErrAgentNotFound (wrapped with %w) for an unknown id, and the wrapped
// ctx error together with the partial merged outcome when ctx is canceled first.
// 未知 id 返回 ErrAgentNotFound(以 %w 包装);ctx 先行取消时返回包装后的 ctx 错误
// 与部分合并结果。
func (s *Scheduler) Wait(ctx context.Context, ids []string) (*MergedResult, error) {
if ctx == nil {
ctx = context.Background()
@@ -34,8 +32,8 @@ func (s *Scheduler) Wait(ctx context.Context, ids []string) (*MergedResult, erro
return mergeSubs(subs), nil
}
// pick resolves ids to their registered sub-agents in the given order. It returns
// ErrAgentNotFound wrapped for the first unknown id.
// pick 按给定顺序把 ids 解析为已登记的子代理。遇到第一个未知 id 时返回包装后的
// ErrAgentNotFound。
func (s *Scheduler) pick(ids []string) ([]*SubAgent, error) {
subs := make([]*SubAgent, 0, len(ids))
s.mu.Lock()
@@ -50,8 +48,8 @@ func (s *Scheduler) pick(ids []string) ([]*SubAgent, error) {
return subs, nil
}
// merge aggregates every registered sub-agent into a MergedResult. It is called by
// Join once all sub-agents are terminal.
// merge 把所有已登记的子代理聚合成一个 MergedResult。它由 Join 在全部子代理终结后
// 调用。
func (s *Scheduler) merge() *MergedResult {
s.mu.Lock()
subs := make([]*SubAgent, 0, len(s.agents))
@@ -62,10 +60,9 @@ func (s *Scheduler) merge() *MergedResult {
return mergeSubs(subs)
}
// mergeSubs aggregates the given sub-agents into a MergedResult, counting the
// terminal statuses and appending each snapshot in slice order. A sub-agent that
// is still running contributes its current snapshot and is counted in neither
// Succeeded/Failed/Aborted.
// mergeSubs 把给定子代理聚合成一个 MergedResult:统计终结态数量,并按切片顺序追加
// 各自的快照。仍在运行的子代理贡献其当前快照,且不计入 Succeeded/Failed/Aborted
// 中任何一项。
func mergeSubs(subs []*SubAgent) *MergedResult {
res := &MergedResult{Total: len(subs), Agents: make([]SubAgentResult, 0, len(subs))}
for _, sub := range subs {