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