执行方案阶段 2 · P2-1。**02.4.2 是最伤的一条 P0**:`spawntool` 从不填 `AgentSpec.Context`、
`scheduler` 把 nil 交给 loop ⇒ 子代理模型上下文 = 角色 system + 记忆召回 + **0 条消息**,
goal/attach_info/data 一个字都到不了模型(此前测试用 mock adapter 恒答固定文本,测不出)。
- **02.4.2**:新增 `taskContext/taskUserMessage`(`spec.go`)把 **goal 正文 + attach_info + data
渲染成同一条 user 消息**(`[任务目标]/[附加信息]/[任务数据]` 独立段落);`Spawn()` 设
`Context: taskContext(spec)`;**system 只保留角色指令**,任务绝不进 system。
真断言(捕获请求体的 adapter + 真 loop/agent):首请求恰好 **1 条 user 消息**、同时含 goal 与
attach_info,且**所有 system 消息不含 goal 文本**。
- **02.4.1**:`AgentSpec.ToolWhitelist`(nil/空 = 继承全量);schema 增 `tool_whitelist`
(顶层 + 每 task 条目,task 未带时继承批次级);`scheduler.run()` 经 ctx 传递 +
`scopedRunner` 派生;`loop/options.go` **纯增量(+75 / −0)**:`WithToolWhitelist`/
`ToolWhitelistFrom` + `(*Loop).WithToolWhitelist` 派生副本(浅拷贝仅替换 `hooks.CallAdmit`,
链式保留原钩子),名单外调用否决为**软结果**回灌模型。
- **02.4.4**:`SUBAGENT_TIMEOUT_SEC`(默认 600)入 `internal/config` + `.env.example`;
优先级 = 按调用 `timeout_seconds`(1s–24h 校验)> `WithSpawnTimeout`(生产由 cfg 填充)>
调度器 `PerAgentTimeout`(`wire*.go` 仍是 10min 常量,未动);迟到 span 续根由测试锁定。
验证:**全树 `go build`/`go vet`/`go test ./... -count=1` = 58 包 ok / 0 FAIL**;
`GOOS=linux/amd64` + `GOOS=darwin/arm64` 交叉编译绿;前端 `tsc --noEmit` + `npm run build` 绿;
本批文件全部 ≤500 行、函数 ≤80 行。
变异验证(四条,均带还原证明):① `Context` 改回 `cloneMessages(spec.Context)` →
「1 条 user 消息」断言 FAIL(`user messages = 0`,即 P0 症状);② `WithToolWhitelist` 原样返回 →
显式白名单测试 FAIL(`alpha executed 1 times despite not being whitelisted`);
③ `subCtx` 改回全局默认 → 3 个超时测试 FAIL;④ `foldSpanFinish` 丢掉 `SpanFinished` →
迟到 span 状态断言 FAIL。
⚠️ 已知缺口(**登记,未假装已解决**):① 无真实模型端到端(全部捕获请求体 mock);
② 白名单**只挡执行、不过滤工具广告面**(模型仍能看见未列工具,调用即被拒并拿到可读原因;
过滤需改 `loop/request.go`);③ **迟到 span 的"新一轮已开始"窗口未修**(`loop/flow.go` 的
`StartRound` 会整体替换 `graphs[session]` ⇒ 新轮开始后迟到的 span 仍落进新轮图;需按轮键控图,
属后续);④ per-role 超时覆盖未实现(需 persona 旋钮管道)。
243 lines
7.4 KiB
Go
243 lines
7.4 KiB
Go
package orchestration
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
|
"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 is created by (*Scheduler).Spawn; it should not be built directly.
|
|
type SubAgent struct {
|
|
// ID uniquely identifies the sub-agent within its scheduler.
|
|
ID string
|
|
// Goal is the objective the sub-agent pursues.
|
|
Goal string
|
|
// RoleID is the bound role/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 string
|
|
// Mode is the bound behavior mode.
|
|
Mode string
|
|
// AttachInfo is the free-form attachment text supplied at spawn.
|
|
AttachInfo string
|
|
// ParentSession is the session whose flow graph owns this sub-agent's branch
|
|
// (blank when the branch is not visualized).
|
|
ParentSession string
|
|
// Data is the optional structured payload supplied at spawn.
|
|
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 []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 *memory.WorkingMemory
|
|
// timeout 是该子代理生效的运行时限(按调用覆盖优先于调度器默认,
|
|
// design/05 §7 裁决 02.4.4);非正表示除取消外无期限。Spawn 时落定,只读。
|
|
timeout time.Duration
|
|
|
|
mu sync.RWMutex
|
|
status Status
|
|
summary string
|
|
result *loop.Result
|
|
runErr error
|
|
started time.Time
|
|
finished time.Time
|
|
|
|
done chan struct{}
|
|
cancel context.CancelFunc
|
|
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 chan struct{}
|
|
|
|
launchOnce sync.Once
|
|
finishOnce sync.Once
|
|
}
|
|
|
|
// Status returns the sub-agent's current lifecycle 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.
|
|
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.
|
|
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).
|
|
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).
|
|
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).
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
func (s *SubAgent) Snapshot() SubAgentResult {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
snap := SubAgentResult{
|
|
ID: s.ID,
|
|
Goal: s.Goal,
|
|
RoleID: s.RoleID,
|
|
PersonaID: s.PersonaID,
|
|
Mode: s.Mode,
|
|
AttachInfo: s.AttachInfo,
|
|
Status: s.status,
|
|
Summary: s.summary,
|
|
Err: s.runErr,
|
|
StartedAt: s.started,
|
|
FinishedAt: s.finished,
|
|
}
|
|
if s.result != nil {
|
|
snap.Text = s.result.Text
|
|
snap.Steps = s.result.Steps
|
|
}
|
|
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.
|
|
func (s *SubAgent) transitionRunning() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.abortReq || s.status != StatusPending {
|
|
return false
|
|
}
|
|
s.status = StatusRunning
|
|
s.started = time.Now()
|
|
return true
|
|
}
|
|
|
|
// progress returns the current status snapshot for a progress event.
|
|
func (s *SubAgent) progress() Progress {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return Progress{
|
|
AgentID: s.ID,
|
|
Goal: s.Goal,
|
|
RoleID: s.RoleID,
|
|
Mode: s.Mode,
|
|
Status: s.status,
|
|
}
|
|
}
|
|
|
|
// setOutcome records the terminal status, summary and error after the run ends.
|
|
// It is called at most once per sub-agent, guarded by finishOnce.
|
|
func (s *SubAgent) setOutcome(status Status, summary string, err error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.status = status
|
|
s.summary = summary
|
|
s.runErr = err
|
|
s.finished = time.Now()
|
|
}
|
|
|
|
// setResult records the successful loop result.
|
|
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.
|
|
func (s *SubAgent) setCancel(c context.CancelFunc) {
|
|
s.mu.Lock()
|
|
s.cancel = c
|
|
aborted := s.abortReq
|
|
s.mu.Unlock()
|
|
if aborted {
|
|
c()
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func (s *SubAgent) cancelRun() {
|
|
s.mu.Lock()
|
|
s.abortReq = true
|
|
c := s.cancel
|
|
first := !s.doneClosed
|
|
s.doneClosed = true
|
|
s.mu.Unlock()
|
|
if first {
|
|
close(s.done)
|
|
}
|
|
if c != nil {
|
|
c()
|
|
}
|
|
}
|
|
|
|
// aborted reports whether a cancellation was requested for the sub-agent.
|
|
func (s *SubAgent) aborted() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.abortReq
|
|
}
|