Files
Cyrene_v2/internal/runtime/orchestration/options.go
T
AskaEth 7e7f53bb41
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(orchestration,loop): 子代理现在真的收到任务(P0)+ 工具白名单 + 超时可配(裁决 02.4.2/02.4.1/02.4.4)
执行方案阶段 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 旋钮管道)。
2026-10-04 11:30:39 +08:00

135 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package orchestration
import (
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"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/orchestration/audit"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
"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 = 4
// defaultAgentID is the comm probe identity used when Options.AgentID is
// blank and a probe must be built from Bus.
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).
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 int
// PerAgentTimeout bounds every sub-agent's run. When it elapses the sub-agent
// is aborted (status aborted). Non-positive means no timeout.
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
// 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 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.Probe
// Memory is the long-term memory store used by SinkMemory. Optional.
Memory *memory.Store
// Tracer emits a per-sub-agent span (kind subagent) around each run. Optional.
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 FlowSpanSink
// Approver records sensitive-operation review-and-approve (代审) triggered by
// Approve. Policy judgement belongs to the upper layer. Optional.
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 func(personaID string) bool
// Logger is the base logger. Nil is replaced with a discard logger tagged with
// source "orchestration".
Logger *slog.Logger
// AgentID is the comm probe identity. When blank and a probe must be built
// from Bus, "orchestrator" is used.
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.
type FlowSpanSink interface {
// SpanStarted registers a running placeholder node for a span that just began.
SpanStarted(sessionID string, sp *trace.Span)
// SpanFinished folds a just-finished span into the session graph.
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.
func wrapLoop(l *loop.Loop) runner {
if l == nil {
return noopRunner{}
}
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.
func (s *Scheduler) scopedRunner(sub *SubAgent) runner {
if len(sub.ToolWhitelist) == 0 {
return s.runner
}
if lp, ok := s.runner.(*loop.Loop); ok {
return lp.WithToolWhitelist(sub.ToolWhitelist)
}
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).
func effectiveTimeout(override, schedulerDefault time.Duration) time.Duration {
if override > 0 {
return override
}
return schedulerDefault
}