diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index 26b10990..90ede54c 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -88,10 +88,8 @@ func (s *Server) codexBackend(w http.ResponseWriter, r *http.Request) { writeError(w, provider.Responses, 400, "/responses/compact is not supported for Magpie models; use a compaction_trigger on /responses") return } - if hasSealedAgentMessage(body) { - writeError(w, provider.Responses, 400, "An OpenAI lead sent a sealed subagent task that a Magpie-served model cannot read. Use a Magpie-served model for the lead, or choose an OpenAI subagent.") - return - } + // a sealed subagent task goes to a ChatGPT account the model + // or group has, or is turned away (serve, sealedReader) body, compact := codexInput(body, true) if compact { s.codexCompact(w, r, body) @@ -118,6 +116,69 @@ func (s *Server) codexBackend(w http.ResponseWriter, r *http.Request) { s.codexUpstream(w, r, rest, body) } +// sealedTaskError is what a subagent is told whose task its lead sealed +// when nothing model names can read it. +func sealedTaskError(model string) string { + return fmt.Sprintf("An OpenAI lead sent a sealed subagent task that only a ChatGPT account can read, and %s has none. Use a Magpie-served model for the lead, or choose an OpenAI subagent (or a group with a Codex account in it).", model) +} + +// sealedReader is who can read a subagent's task its lead sealed: a +// ChatGPT account (#619). The ChatGPT backend seals spawn_agent's message +// for a lead it answers as it came (passthrough, a Codex account a group +// has as well), and only it opens it again. +func sealedReader(p provider.Provider) bool { + return p.Account != nil && p.Account.Agent == "codex" +} + +// sealedReaders keeps of cands those that can read a sealed subagent task, +// pl's order with them. +func sealedReaders(cands []candidate, pl planned) ([]candidate, planned) { + var kept []candidate + var order []Weighed + for i, c := range cands { + if sealedReader(c.p) { + kept = append(kept, c) + order = append(order, pl.order[i]) + } + } + cands, pl.order = kept, order + return cands, pl +} + +// leadFirst puts first the account that answered the lead, the thread +// parent names, in scope: the one that sealed its subagent's task, which +// another account may not open, as it doesn't another's reasoning. +func leadFirst(scope, parent string, cands []candidate, pl planned) ([]candidate, planned) { + parent = strings.TrimSpace(parent) + if parent == "" { + return cands, pl + } + sticks.Lock() + st, had := stickOf(scope + "|" + parent) + if !had { + // a group with rules keeps the lead's conversation under its + // first words too + for k, v := range sticks.m { + if strings.HasPrefix(k, scope+"|") && strings.HasSuffix(k, "|"+parent) { + st, had = v, true + break + } + } + } + sticks.Unlock() + if !had || time.Since(st.at) > stickKeep { + return cands, pl + } + for i, c := range cands { + if i > 0 && c.who() == st.who { + cands = append(append([]candidate{c}, cands[:i]...), cands[i+1:]...) + pl.order = append(append([]Weighed{pl.order[i]}, pl.order[:i]...), pl.order[i+1:]...) + break + } + } + return cands, pl +} + // Only native sealed agent tasks need this guidance. Other encrypted_content // fields (including ordinary model history) keep their existing handling. func hasSealedAgentMessage(body []byte) bool { diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 4945482f..0986c47e 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1050,6 +1050,17 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro // before any image is taken out of the request: one may be for images g, ms, isGroup := provider.FindGroup(asked) g = g.Live() // a manual group's rules wait + // a Codex subagent's task its lead sealed — the lead answered by a + // ChatGPT account, the group's own or Codex's — goes only to a ChatGPT + // account, the lead's first (#619); with none, it is turned away + // before anyone is asked + sealedTask := from == provider.Responses && hasSealedAgentMessage(body) + if sealedTask && !(isGroup && slices.ContainsFunc(ms, func(m provider.Member) bool { return sealedReader(m.Provider) }) || !isGroup && sealedReader(p)) { + call.Status, call.Error = 400, "sealed subagent task" + writeError(w, from, 400, sealedTaskError(call.Model)) + turnedAway() + return + } var hit *RuleHit var ruled []provider.Member var ruleAt, words string @@ -1133,11 +1144,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro // own, and an agent's side requests (a title, a summary) to a smaller // model leave the conversation where it is scope, mode, rotate := p.ID+"/"+model, p.Affinity, p.Routing == provider.Rotate + leadScope := scope // where the lead of a subagent is kept if isGroup { // a routing group: every member's keys or accounts weighed together cands, pl = s.planGroup(g, ms, from) group = groupRef(g, ms) scope, mode, rotate = provider.GroupPrefix+g.ID, g.Affinity, g.Routing == provider.Rotate + leadScope = scope if words != "" { // a subagent a rule sends elsewhere mustn't take its agent's // conversation with it: each keeps where it is on its own @@ -1146,6 +1159,9 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } else { cands, pl = s.plan(p, model, from) } + if sealedTask { + cands, pl = sealedReaders(cands, pl) + } if len(cands) == 0 && len(pl.left) > 0 && !slices.ContainsFunc(pl.left, func(w Weighed) bool { return !w.Barred }) { // every account or key there is was set not to serve the model call.Status, call.Error = 403, "every account barred" @@ -1166,6 +1182,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro // (#63): its reasoning, sealed by the account that wrote it, is refused // by another. cands, pl, aff, stuck := affine(scope, mode, rotate, r.Header, from, body, cands, pl) + if sealedTask && !aff.Kept { + parent := metadata.Parent + if parent == "" { + parent = r.Header.Get("x-codex-parent-thread-id") + } + cands, pl = leadFirst(leadScope, parent, cands, pl) + } if hit != nil && hit.Use != "" && !(hit.Held && aff.Kept) { // the rule's member first, over whoever answered last, as a turn // begins; within it, whoever answered last stays — the rule's diff --git a/internal/gateway/sealed_agent_handoff_test.go b/internal/gateway/sealed_agent_handoff_test.go index ffb30f54..09157efe 100644 --- a/internal/gateway/sealed_agent_handoff_test.go +++ b/internal/gateway/sealed_agent_handoff_test.go @@ -82,3 +82,54 @@ func TestCodexSealedAgentHandoffNativePassthrough(t *testing.T) { t.Fatalf("native request: status=%d forwarded=%v response=%s", code, len(got) > 0, response) } } + +// A Codex subagent on a routing group whose lead a ChatGPT account of the +// group answered (Koohoko, #619): the task the lead's spawn_agent sealed +// goes to the group's ChatGPT account that answered the lead, not to the +// member routing puts first, which can't read it, nor is it turned away. +func TestCodexSealedAgentHandoffGoesToGroupsChatGPTAccount(t *testing.T) { + var tried []string + var got []byte + chatgpt(t, func(w http.ResponseWriter, r *http.Request) { + got, _ = io.ReadAll(r.Body) + tried = append(tried, r.Header.Get("chatgpt-account-id")) + io.WriteString(w, sse(`data: {"type":"response.output_text.delta","delta":"on it"}`, + `data: {"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`)) + }) + codexSignedIn(t, "spare@example.com") + sticks.Lock() + sticks.m = map[string]stick{} + sticks.Unlock() + grok := &scripted{replies: []reply{{200, "text/event-stream", grokAnswer}}} + scriptedOn(t, "xai", provider.Chat, grok) + refusalGroup(t, "xai/m", "codex/gpt-5.5") + s := New() + ask := func(session, parent, pin, input string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"group/g","stream":true,"input":[`+input+`]}`)) + req.Header.Set("Authorization", "Bearer chatgpt-token") + req.Header.Set("chatgpt-account-id", "acct-1") + req.Header.Set("session_id", session) + if parent != "" { + req.Header.Set("x-codex-parent-thread-id", parent) + } + if pin != "" { + req.Header.Set(AccountHeader, pin) + } + s.Handler().ServeHTTP(rec, req) + return rec + } + // the lead, answered by the spare account + lead := `{"type":"message","role":"user","content":[{"type":"input_text","text":"spawn a worker"}]}` + if rec := ask("lead-1", "", "spare@example.com", lead); rec.Code != 200 || strings.Join(tried, ",") != "acct-2" { + t.Fatalf("lead: %d %s (tried %v)", rec.Code, rec.Body.String(), tried) + } + tried = nil + rec := ask("worker-1", "lead-1", "", sealedHandoff) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "on it") { + t.Fatalf("subagent: %d %s", rec.Code, rec.Body.String()) + } + if grok.n != 0 || strings.Join(tried, ",") != "acct-2" || !strings.Contains(string(got), "gAAAAATest_ciphertext==") { + t.Fatalf("sealed task went to grok %d, accounts %v, forwarded sealed=%v", grok.n, tried, strings.Contains(string(got), "gAAAAATest_ciphertext==")) + } +}