package orchestration import ( "encoding/json" "errors" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // Status is the lifecycle state of a spawned sub-agent. // // The lifecycle is pending -> running -> one of the terminal states // (done, failed, aborted). A sub-agent is terminal exactly when Terminal // reports true. type Status string const ( // StatusPending means the sub-agent was created but has not started. StatusPending Status = "pending" // StatusRunning means the sub-agent's loop is executing. StatusRunning Status = "running" // StatusDone means the loop completed and produced a result. StatusDone Status = "done" // StatusFailed means the loop returned an error. StatusFailed Status = "failed" // StatusAborted means the sub-agent was canceled or timed out. 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. 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. type AgentSpec struct { // Goal is the objective the sub-agent works toward. It is required. Goal string // RoleID selects the role/persona the sub-agent is bound to. 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 string // Mode selects the behavior mode (e.g. "engineer") for the role. 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 []model.Message // AttachInfo is free-form attachment/injection text supplied at spawn. 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 string // Data carries an optional structured payload for the caller's use. Data any // ToolWhitelist 限定该子代理可执行的工具集合(design/05 §7 裁决 02.4.1): // 缺省(nil/空)=继承主 agent 的全量工具集;显式列出=只放行所列工具, // 名单外的调用在工具循环层被否决并以软结果回灌。它只收缩能力面,绝不扩展。 ToolWhitelist []string // Timeout 覆盖该子代理的运行时限(design/05 §7 裁决 02.4.4 的按调用覆盖)。 // 非正时回退到调度器的 Options.PerAgentTimeout。 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). const ( // EventDispatched is published when a sub-agent is created. EventDispatched = "subagent.dispatched" // EventStarted is published when a sub-agent begins running. EventStarted = "subagent.started" // EventDone is published when a sub-agent finishes successfully. EventDone = "subagent.done" // EventFailed is published when a sub-agent fails. EventFailed = "subagent.failed" // EventAborted is published when a sub-agent is canceled or times out. EventAborted = "subagent.aborted" ) // Progress is the status snapshot carried by a progress event's Data payload. type Progress struct { // AgentID is the spawned sub-agent id. AgentID string // Goal is the sub-agent's objective. Goal string // RoleID is the bound role/persona. RoleID string // Mode is the bound behavior mode. Mode string // Status is the sub-agent's status at publish time. Status Status } // Sentinel errors returned by the Scheduler. They are wrapped with %w at call // sites so callers can test with errors.Is. var ( // ErrMissingGoal is returned by Spawn when the spec Goal is blank. ErrMissingGoal = errors.New("orchestration: missing goal") // ErrNoRunner is returned when a sub-agent runs with no loop runner // configured (Options.Loop was nil). ErrNoRunner = errors.New("orchestration: no loop runner configured") // ErrNoApprover is returned by Approve when no *audit.Approver was injected. ErrNoApprover = errors.New("orchestration: no approver configured") // ErrNoMemory is returned by SinkMemory when no *memory.Store was injected. ErrNoMemory = errors.New("orchestration: no memory store configured") // ErrAgentNotFound is returned by Abort when the sub-agent id is unknown. 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 = 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 = 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. func cloneMessages(msgs []model.Message) []model.Message { if msgs == nil { return nil } out := make([]model.Message, len(msgs)) for i, m := range msgs { if m.Content != nil { parts := make([]model.Content, len(m.Content)) copy(parts, m.Content) m.Content = parts } out[i] = m } 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. func compactText(s string, n int) string { s = strings.TrimSpace(s) r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" } // taskContext 返回子代理的隔离上下文:调用方显式给出的消息在前(深拷贝), // 任务本身作为最后一条 user 消息注入。裁决 2026-10-04:goal 正文 + // attach_info/data 作为同一条 user 消息内的独立段落;system 只承载角色与 // 安全约束(循环前置的 agent 指令),绝不把任务塞进 system——否则子代理 // 的模型上下文里没有任何一轮用户消息,任务一个字都到不了模型。 func taskContext(spec AgentSpec) []model.Message { return append(cloneMessages(spec.Context), taskUserMessage(spec)) } // taskUserMessage 把任务渲染为一条 user 消息(裁决 2026-10-04): // [任务目标] 段固定存在(Spawn 已保证 goal 非空),[附加信息] 与 // [任务数据] 段仅在对应字段非空时出现;Data 以 JSON 编码,编码失败时 // 整段跳过——宁可少一段噪声,不注入一段坏文本。 func taskUserMessage(spec AgentSpec) model.Message { var b strings.Builder b.WriteString("[任务目标]\n") b.WriteString(spec.Goal) if spec.AttachInfo != "" { b.WriteString("\n\n[附加信息]\n") b.WriteString(spec.AttachInfo) } if spec.Data != nil { if data, err := json.Marshal(spec.Data); err == nil { b.WriteString("\n\n[任务数据]\n") b.Write(data) } } return model.TextMessage(model.RoleUser, b.String()) } // cloneStrings 返回 names 的拷贝,使子代理持有的白名单与调用方的切片 // 不共享内存。nil 进 nil 出。 func cloneStrings(names []string) []string { if names == nil { return nil } return append([]string(nil), names...) } // stringListArg 读取字符串数组参数:容忍缺失/非数组/非字符串元素, // 逐项去空白并丢弃空项,使名单恒为「显式列出的工具名」。缺失返回 nil(全开)。 func stringListArg(m map[string]any, key string) []string { raw, ok := m[key].([]any) if !ok { return nil } out := make([]string, 0, len(raw)) for _, v := range raw { s, _ := v.(string) if s = strings.TrimSpace(s); s != "" { out = append(out, s) } } return out } // timeoutArg 读取 timeout_seconds 按调用覆盖(JSON 解码为 float64): // 缺失/非数/越界(<1s 或 >24h)视为未提供并沿用 dft;越界值不静默夹取—— // 回退到默认而不是替模型猜一个邻近值。 func timeoutArg(m map[string]any, key string, dft time.Duration) time.Duration { f, ok := m[key].(float64) if !ok { return dft } sec := int64(f) if sec < minSpawnTimeoutSeconds || sec > maxSpawnTimeoutSeconds { return dft } return time.Duration(sec) * time.Second }