package orchestration import ( "context" "fmt" "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. // // 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: 身份识别靠元数据). // 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. 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. func effectiveRole(spec AgentSpec) string { if spec.RoleID == "" { return spec.PersonaID } return spec.RoleID } // checkPersona rejects a role the process cannot run, wrapping ErrUnknownPersona so // a caller can classify it with errors.Is. func (s *Scheduler) checkPersona(personaID string) error { if s.PersonaKnown(personaID) { return nil } 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. func (s *Scheduler) CheckPersonas(specs []AgentSpec) error { for _, spec := range specs { if err := s.checkPersona(spec.PersonaID); err != nil { return err } } 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. func withPersonaEnv(ctx context.Context, personaID string) context.Context { if personaID == "" { return ctx } env := loop.SessionEnvFrom(ctx) env.PersonaID = personaID return loop.WithSessionEnv(ctx, env) }