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