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