From 98811cad52cf3152bec21c2d580a571e0db0d6ac Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 23 Oct 2026 09:02:17 +0000 Subject: [PATCH 1/2] Stream Harness reasoning as reasoning Items When the Agent sets reasoning.summary, each Harness reports its native reasoning as `reasoning` Items: the native summary when the Harness produces one, otherwise its native reasoning text. The Runtime sends one `reasoning` frame per native reasoning item (id, status, delta), and Core projects it into a `reasoning` Item with a single summary_text part and the official event sequence (item.added, reasoning_summary_part.added, reasoning_summary_text.delta, reasoning_summary_text.done, reasoning_summary_part.done, item.done). An unfinished Item ends incomplete with the Turn. With reasoning.summary null, the default, no reasoning Items or events appear for root or child Turns. Codex maps null to model_reasoning_summary "none", Claude Code declares only the auto summary, and MiniMax Code no longer emits thinking frames. --- .../claudesdk/execution_controls_test.go | 16 ++- .../internal/agent/claudesdk/executor_turn.go | 9 ++ .../internal/agent/claudesdk/options.go | 2 + .../internal/agent/claudesdk/session.go | 1 + .../agent/codex/execution_controls_test.go | 13 ++- apps/daemon/internal/agent/codex/executor.go | 2 +- apps/daemon/internal/agent/codex/options.go | 10 +- .../internal/agent/codex/preparation.go | 1 + apps/daemon/internal/agent/codex/protocol.go | 16 +-- apps/daemon/internal/agent/codex/session.go | 60 ++++++++--- .../internal/agent/codex/session_items.go | 100 +++++------------- .../agent/codex/session_items_test.go | 86 +++++++-------- .../internal/agent/codex/session_messages.go | 6 +- .../agent/codex/session_notifications_test.go | 8 +- .../agent/codex/subagent_profile_test.go | 2 +- .../agent/codex/subagent_snapshots.go | 14 +-- apps/daemon/internal/agent/mcode/events.go | 5 +- contracts/agents-api/core.openapi.yaml | 1 + contracts/agents-api/go-bindings.json | 2 +- contracts/agents-api/harness-capabilities.md | 2 +- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/index.md | 3 +- contracts/agents-api/sessions-events.md | 4 +- contracts/agents-api/v1/official.gen.go | 4 +- .../agents-api/zh/harness-capabilities.md | 4 +- contracts/agents-api/zh/harness-onboarding.md | 4 +- contracts/agents-api/zh/index.md | 5 +- contracts/agents-api/zh/sessions-events.md | 6 +- docs/runtime-protocol.md | 4 +- docs/zh/runtime-protocol.md | 6 +- internal/agentdaemon/proto/inbound.go | 17 +-- .../harnessconfig/claudesdk/declaration.go | 6 +- packages/claude-sdk-adapter/src/adapter.ts | 6 +- packages/claude-sdk-adapter/src/messages.ts | 21 +++- packages/claude-sdk-adapter/src/request.ts | 4 +- .../src/subagent_history.ts | 11 +- packages/claude-sdk-adapter/src/subagents.ts | 6 +- .../tests/messages.test.mjs | 14 +++ .../tests/subagents.test.mjs | 4 +- services/core/internal/execution/journal.go | 2 +- services/core/internal/items/changes.go | 4 + services/core/internal/items/events.go | 30 ++++++ services/core/internal/items/events_test.go | 54 ++++++++++ services/core/internal/items/messages.go | 28 ++++- 44 files changed, 399 insertions(+), 206 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index 21e31d7c4..e35e48068 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -71,7 +71,7 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { "unknown-verbosity": {TextVerbosity: "invalid"}, "no-effort": {ReasoningEffort: "none"}, "minimal-effort": {ReasoningEffort: "minimal"}, - "summary": {ReasoningSummary: "auto"}, + "summary": {ReasoningSummary: "concise"}, "service-tier": {ServiceTier: "priority"}, } for name, controls := range cases { @@ -169,3 +169,17 @@ func TestToolDiscoveryPreservesFrozenFunctionsBesideOtherProfiles(t *testing.T) t.Fatal("deferred definitions became eager") } } + +func TestReasoningSummaryReachesTheBridgeOnlyWhenRequested(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input."), AgentOptions: map[string]any{"model": "native-model"}} + for _, summary := range []string{"", "auto"} { + request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", ReasoningSummary: summary} + start, _, err := prepare(config, request) + if err != nil || start.ReasoningSummary != (summary != "") { + t.Fatalf("summary %q: reasoning flag %v, err %v", summary, start.ReasoningSummary, err) + } + } +} diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index fb8f72b39..d7db8dc7a 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -122,6 +122,15 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { break } emit(proto.TypeOutputMessage, message) + case "reasoning": + reasoning := event.Reasoning + if !start.ReasoningSummary || reasoning == nil || reasoning.ID == "" || + (reasoning.Status != "in_progress" && reasoning.Status != "completed") { + failure = fmt.Errorf("claudesdk: invalid reasoning observation") + s.invalidate() + break + } + emit(proto.TypeReasoning, reasoning) case "function_call", "function_withdrawn", "function_applied": if err := s.receiveFunction(event, start, emit); err != nil { failure = err diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 16ac2ff91..665896f47 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -26,6 +26,7 @@ type startRequest struct { NativeModelOptions *nativeModelOptions `json:"native_model_options,omitempty"` ToolSearch bool `json:"tool_search,omitempty"` WebSearch string `json:"web_search,omitempty"` + ReasoningSummary bool `json:"reasoning_summary,omitempty"` Subagents *subagentOptions `json:"subagents,omitempty"` OutputFormat *proto.OutputFormat `json:"output_format,omitempty"` Type string `json:"type"` @@ -80,6 +81,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if controls := req.ExecutionControls; controls != nil && controls.WebSearch != nil { start.WebSearch = controls.WebSearch.Mode } + start.ReasoningSummary = req.ExecutionControls != nil && req.ExecutionControls.ReasoningSummary != "" if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { format := req.ExecutionControls.OutputFormat if format.Type != "json_schema" { diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 80588beff..b5e55ee34 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -52,6 +52,7 @@ type bridgeEvent struct { Code string `json:"code"` ItemID string `json:"item_id"` Message *proto.OutputMessagePayload `json:"message"` + Reasoning *proto.ReasoningPayload `json:"reasoning"` Call *proto.FunctionCallPayload `json:"call"` Withdrawn *proto.FunctionCallWithdrawnPayload `json:"withdrawn"` CallID string `json:"call_id"` diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index c31b004d4..12d072865 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -17,8 +17,8 @@ func TestExecutionControlsMapToNativeSettings(t *testing.T) { controls *proto.ExecutionControls want [][2]string }{ - {"absent controls send nothing", nil, nil}, - {"empty controls send nothing", &proto.ExecutionControls{}, nil}, + {"absent controls turn summaries off", nil, nil}, + {"empty controls turn summaries off", &proto.ExecutionControls{}, nil}, {"verbosity", &proto.ExecutionControls{TextVerbosity: "high"}, [][2]string{{"model_verbosity", `"high"`}}}, {"search without settings", &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "live", ContextSize: "medium"}}, [][2]string{{"web_search", `"live"`}, {"tools.web_search.context_size", `"medium"`}}}, @@ -37,9 +37,16 @@ func TestExecutionControlsMapToNativeSettings(t *testing.T) { t.Fatal(err) } plan.Cleanup() - if want := append(append([][2]string{}, base...), tc.want...); !reflect.DeepEqual(plan.ExtraConfig, want) { + want := append([][2]string{}, base...) + if tc.controls == nil || tc.controls.ReasoningSummary == "" { + want = append(want, [2]string{"model_reasoning_summary", `"none"`}) + } + if want = append(want, tc.want...); !reflect.DeepEqual(plan.ExtraConfig, want) { t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) } + if plan.ReasoningSummary != (tc.controls != nil && tc.controls.ReasoningSummary != "") { + t.Fatalf("reasoning summary gate = %v", plan.ReasoningSummary) + } }) } } diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go index 7fc01a85c..43f687af9 100644 --- a/apps/daemon/internal/agent/codex/executor.go +++ b/apps/daemon/internal/agent/codex/executor.go @@ -72,7 +72,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess functions: functions, observeSubagentIdentities: base.observeSubagentIdentities, cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, - bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, runID: runID, out: out} + bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, reasoningSummary: base.reasoningSummary, runID: runID, out: out} if previous != nil { s.threadID = previous.currentThreadID() s.retiredTurns = make(map[string]bool, len(previous.retiredTurns)+1) diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index af94a53b5..4e2c4021b 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -55,6 +55,9 @@ type SessionPlan struct { // ModelReasoningEffort is frozen for launch and every native Turn. ModelReasoningEffort string + // ReasoningSummary reports that the Agent requested reasoning summaries. + ReasoningSummary bool + // OutputSchema is the frozen public JSON Schema sent as outputSchema on every // native root Turn. OutputSchema json.RawMessage @@ -160,10 +163,14 @@ func excludeFromShellEnvironment(plan *SessionPlan, pattern string) { // absent control sends nothing, so Codex keeps its native default; Codex itself // ignores a value the selected model does not support. func applyExecutionControls(plan *SessionPlan, controls *proto.ExecutionControls) { + set := func(key, value string) { plan.ExtraConfig = append(plan.ExtraConfig, [2]string{key, value}) } + if controls == nil || controls.ReasoningSummary == "" { + // The official default is no reasoning summary, while Codex defaults to a model-dependent one. + set("model_reasoning_summary", strconv("none")) + } if controls == nil { return } - set := func(key, value string) { plan.ExtraConfig = append(plan.ExtraConfig, [2]string{key, value}) } if search := controls.WebSearch; search != nil { set("web_search", strconv(search.Mode)) set("tools.web_search.context_size", strconv(search.ContextSize)) @@ -193,6 +200,7 @@ func applyExecutionControls(plan *SessionPlan, controls *proto.ExecutionControls set("model_reasoning_effort", strconv(controls.ReasoningEffort)) } if controls.ReasoningSummary != "" { + plan.ReasoningSummary = true set("model_reasoning_summary", strconv(controls.ReasoningSummary)) } if controls.ServiceTier != "" { diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 9334d4fa9..08432c051 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -65,6 +65,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg cleanup: sync.OnceFunc(plan.Cleanup), bufs: NewItemBuffers(), resolvedModel: plan.Model, + reasoningSummary: plan.ReasoningSummary, } plan.Cleanup = s.cleanup p := &Prepared{ diff --git a/apps/daemon/internal/agent/codex/protocol.go b/apps/daemon/internal/agent/codex/protocol.go index 002aa4c92..2e9bd8a89 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -216,11 +216,10 @@ type ThreadItem struct { ID string `json:"id,omitempty"` // agentMessage / reasoning - Phase string `json:"phase,omitempty"` - Text string `json:"text,omitempty"` - Summary []string `json:"summary,omitempty"` - Content []string `json:"content,omitempty"` - SummaryText string `json:"summary_text,omitempty"` + Phase string `json:"phase,omitempty"` + Text string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + Content []string `json:"content,omitempty"` // commandExecution Command string `json:"command,omitempty"` @@ -283,7 +282,12 @@ type AgentMessageDeltaNotification struct { Delta string `json:"delta"` } -type ReasoningDeltaNotification = AgentMessageDeltaNotification +// ReasoningDeltaNotification is a summaryTextDelta (SummaryIndex) or a textDelta (ContentIndex). +type ReasoningDeltaNotification struct { + AgentMessageDeltaNotification + SummaryIndex int `json:"summaryIndex"` + ContentIndex int `json:"contentIndex"` +} type ThreadTokenUsageUpdatedNotification struct { ThreadID string `json:"threadId"` diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index 07557d7e8..b6130cbb2 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -86,8 +86,7 @@ type Session struct { threadID string steering steeringTurn - deltaSeq atomic.Uint64 - thinkingSeq atomic.Uint64 + deltaSeq atomic.Uint64 bufs *ItemBuffers @@ -99,6 +98,8 @@ type Session struct { resumeUsageThreadID string resumeUsageTotal *TurnUsage resolvedModel string + // reasoningSummary reports that the Agent requested reasoning summaries. + reasoningSummary bool finalTextMu sync.Mutex finalText string @@ -125,8 +126,8 @@ func (s *Session) registerHandlers() { rpc.OnNotification("item/completed", s.onItemCompleted) rpc.OnNotification("item/agentMessage/delta", s.onAgentDelta) rpc.OnNotification("item/commandExecution/outputDelta", s.onCommandOutput) - rpc.OnNotification("item/reasoning/textDelta", s.onReasoningDelta) - rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) + rpc.OnNotification("item/reasoning/textDelta", func(raw json.RawMessage) { s.onReasoningDelta("text", raw) }) + rpc.OnNotification("item/reasoning/summaryTextDelta", func(raw json.RawMessage) { s.onReasoningDelta("summary", raw) }) rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) rpc.OnNotification("error", s.onErrorNotif) @@ -143,21 +144,56 @@ func (s *Session) onTurnStarted(raw json.RawMessage) { } } -func (s *Session) onReasoningDelta(raw json.RawMessage) { - var p AgentMessageDeltaNotification +// onReasoningDelta streams the first native channel a reasoning item delivers: +// its summary, or the raw reasoning text of a model that has no summary. A new +// part of the same channel starts a new paragraph. +func (s *Session) onReasoningDelta(channel string, raw json.RawMessage) { + var p ReasoningDeltaNotification if err := json.Unmarshal(raw, &p); err != nil { return } - if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { + if !s.reasoningSummary || !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { return } - _ = FoldDeltaIntoBuffer(s.bufs, "reasoning", p.ItemID, p.Delta) - seq := s.thinkingSeq.Add(1) - env, err := proto.NewEnvelope(proto.TypeThinking, s.runID, proto.ThinkingPayload{Text: p.Delta, Sequence: seq}) - if err != nil { + index := p.ContentIndex + if channel == "summary" { + index = p.SummaryIndex + } + stream, started := s.bufs.Reasoning[p.ItemID] + if started && stream.channel != channel { return } - s.trySend(env) + delta := p.Delta + if started && stream.index != index { + delta = "\n\n" + delta + } + s.bufs.Reasoning[p.ItemID] = reasoningStream{channel: channel, index: index} + s.sendReasoning(p.ItemID, "in_progress", delta) +} + +// completeReasoning ends a reasoning item. An item that streamed nothing is +// reported whole from its completed body; one with no text is not reported. +func (s *Session) completeReasoning(item ThreadItem) { + if !s.reasoningSummary { + return + } + _, streamed := s.bufs.Reasoning[item.ID] + delete(s.bufs.Reasoning, item.ID) + if !streamed { + body := strings.Join(nativeReasoningParts(item), "\n\n") + if body == "" { + return + } + s.sendReasoning(item.ID, "in_progress", body) + } + s.sendReasoning(item.ID, "completed", "") +} + +func (s *Session) sendReasoning(id, status, delta string) { + env, err := proto.NewEnvelope(proto.TypeReasoning, s.runID, proto.ReasoningPayload{ID: id, Status: status, Delta: delta}) + if err == nil { + s.trySend(env) + } } func (s *Session) onTurnCompleted(raw json.RawMessage) { diff --git a/apps/daemon/internal/agent/codex/session_items.go b/apps/daemon/internal/agent/codex/session_items.go index 3cb3db01d..3530c28d7 100644 --- a/apps/daemon/internal/agent/codex/session_items.go +++ b/apps/daemon/internal/agent/codex/session_items.go @@ -3,27 +3,30 @@ package codex import ( "encoding/json" "fmt" - "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// ItemBuffers holds the per-itemId text accumulators used to fold -// delta notifications back into a single chunk on item/completed. -// Codex streams reasoning and agentMessage in two channels — the -// {reasoning,agentMessage}/delta notifications, plus the final item -// body on item/completed. We keep both: deltas drive incremental UI -// (TypeDelta / TypeThinking), and completed-item bodies anchor the -// final text for the done event. +// ItemBuffers holds per-itemId state for streamed notifications. +// agentMessage deltas drive incremental TypeDelta frames while the completed +// item anchors the final text. A reasoning item streams one of its two native +// channels, recorded here from its first delta. type ItemBuffers struct { - Reasoning map[string]string + Reasoning map[string]reasoningStream AgentText map[string]string } +// reasoningStream is the native channel a reasoning item streams and the last +// part index seen on it. +type reasoningStream struct { + channel string + index int +} + // NewItemBuffers returns an empty buffer set. func NewItemBuffers() *ItemBuffers { return &ItemBuffers{ - Reasoning: map[string]string{}, + Reasoning: map[string]reasoningStream{}, AgentText: map[string]string{}, } } @@ -36,7 +39,7 @@ func NewItemBuffers() *ItemBuffers { // item types we silence by intent: // // - reasoning / agentMessage / userMessage: text is streamed via deltas -// and re-stamped in item/completed +// and re-stamped in item/completed; reasoning is reported by the Session // - error: surfaced by turn/completed.status="failed" instead func DispatchStartedItem(runID string, item ThreadItem) ([]proto.Envelope, error) { switch item.Type { @@ -69,34 +72,14 @@ func DispatchStartedItem(runID string, item ThreadItem) ([]proto.Envelope, error } // DispatchCompletedItem folds an item.completed payload into envelopes. -// Reasoning and agentMessage produce a final TypeThinking / TypeDelta -// (delta+sequence=0) so the buffer drained by upstream deltas can be -// flushed; tool-call variants produce an "after" envelope so the UI -// gets a stage transition. -// -// emitFinalDelta=true asks the dispatch to emit a synthetic full-text -// TypeDelta whose Sequence will be set by the caller using a session- -// level monotonic counter; this is only needed when no deltas were -// observed (item.completed arrived without any item/agentMessage/delta). +// An agentMessage returns its text so the session can stamp it into +// DonePayload.Content; tool-call variants produce an "after" envelope so the +// UI gets a stage transition. // // Returns the agent text body for agentMessage items so the session can // stamp it into DonePayload.Content. Empty string for everything else. func DispatchCompletedItem(runID string, item ThreadItem, bufs *ItemBuffers) (envelopes []proto.Envelope, agentText string, err error) { switch item.Type { - case "reasoning": - body := bufs.Reasoning[item.ID] - delete(bufs.Reasoning, item.ID) - if body == "" { - body = fallbackReasoningText(item) - } - if body == "" { - return nil, "", nil - } - env, err := proto.NewEnvelope(proto.TypeThinking, runID, proto.ThinkingPayload{Text: body}) - if err != nil { - return nil, "", fmt.Errorf("codex: thinking envelope: %w", err) - } - return []proto.Envelope{env}, "", nil case "agentMessage": body := bufs.AgentText[item.ID] delete(bufs.AgentText, item.ID) @@ -123,22 +106,14 @@ func DispatchCompletedItem(runID string, item ThreadItem, bufs *ItemBuffers) (en return nil, "", nil } -// FoldDeltaIntoBuffer accumulates a delta string under the matching -// itemId. Returns the running prefix so the caller can emit a -// progressive TypeDelta / TypeThinking envelope for streaming UI. -func FoldDeltaIntoBuffer(bufs *ItemBuffers, kind, itemID, delta string) string { +// FoldDeltaIntoBuffer accumulates an agentMessage delta under its itemId and +// returns the running text. +func FoldDeltaIntoBuffer(bufs *ItemBuffers, itemID, delta string) string { if delta == "" || itemID == "" { return "" } - switch kind { - case "agent": - bufs.AgentText[itemID] += delta - return bufs.AgentText[itemID] - case "reasoning": - bufs.Reasoning[itemID] += delta - return bufs.Reasoning[itemID] - } - return "" + bufs.AgentText[itemID] += delta + return bufs.AgentText[itemID] } func toolNameForItem(item ThreadItem) string { @@ -203,32 +178,11 @@ func argMap(v any) map[string]any { return map[string]any{"value": json.RawMessage(raw)} } -func fallbackReasoningText(item ThreadItem) string { - if item.Text != "" { - return item.Text - } - if item.SummaryText != "" { - return item.SummaryText - } +// nativeReasoningParts is what Codex shows for a completed reasoning item: its +// summary when it has one, otherwise its raw reasoning text. +func nativeReasoningParts(item ThreadItem) []string { if len(item.Summary) > 0 { - var b strings.Builder - for i, s := range item.Summary { - if i > 0 { - b.WriteString("\n\n") - } - b.WriteString(s) - } - return b.String() - } - if len(item.Content) > 0 { - var b strings.Builder - for i, s := range item.Content { - if i > 0 { - b.WriteString("\n\n") - } - b.WriteString(s) - } - return b.String() + return item.Summary } - return "" + return item.Content } diff --git a/apps/daemon/internal/agent/codex/session_items_test.go b/apps/daemon/internal/agent/codex/session_items_test.go index 8ba3334b7..f5bce595f 100644 --- a/apps/daemon/internal/agent/codex/session_items_test.go +++ b/apps/daemon/internal/agent/codex/session_items_test.go @@ -1,7 +1,9 @@ package codex import ( + "context" "encoding/json" + "reflect" "strings" "testing" @@ -66,54 +68,46 @@ func TestDispatchStartedItem_UnknownSilent(t *testing.T) { } } -func TestDispatchCompletedItem_Reasoning_UsesBuffer(t *testing.T) { - bufs := NewItemBuffers() - FoldDeltaIntoBuffer(bufs, "reasoning", "r1", "Hello ") - FoldDeltaIntoBuffer(bufs, "reasoning", "r1", "world") - - envs, text, err := DispatchCompletedItem("run-1", ThreadItem{ - Type: "reasoning", ID: "r1", - }, bufs) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - if text != "" { - t.Fatalf("reasoning must not produce agent text, got %q", text) - } - if len(envs) != 1 || envs[0].Type != proto.TypeThinking { - t.Fatalf("envs = %+v", envs) - } - var p proto.ThinkingPayload - _ = envs[0].DecodePayload(&p) - if p.Text != "Hello world" { - t.Fatalf("thinking text = %q", p.Text) - } - // Buffer must be drained so a re-completion doesn't double-emit. - if got := bufs.Reasoning["r1"]; got != "" { - t.Fatalf("buffer not drained: %q", got) - } -} - -func TestDispatchCompletedItem_Reasoning_FallbackToItemBody(t *testing.T) { - // No deltas observed — must fall back to item.text / summary. - bufs := NewItemBuffers() - envs, _, err := DispatchCompletedItem("run-1", ThreadItem{ - Type: "reasoning", ID: "r1", Text: "fallback body", - }, bufs) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - var p proto.ThinkingPayload - _ = envs[0].DecodePayload(&p) - if p.Text != "fallback body" { - t.Fatalf("fallback path = %q", p.Text) +func TestReasoningIsReportedOnlyWhenRequested(t *testing.T) { + for _, requested := range []bool{true, false} { + out := make(chan proto.Envelope, 16) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(testStateRoot(t)), + rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), reasoningSummary: requested} + s.registerHandlers() + s.setThreadID("root") + scopeNotification(t, s, "turn/started", `{"threadId":"root","turn":{"id":"turn"}}`) + scope := `{"threadId":"root","turnId":"turn",` + scopeNotification(t, s, "item/reasoning/summaryTextDelta", scope+`"itemId":"r1","delta":"plan ","summaryIndex":0}`) + scopeNotification(t, s, "item/reasoning/textDelta", scope+`"itemId":"r1","delta":"raw","contentIndex":0}`) + scopeNotification(t, s, "item/reasoning/summaryTextDelta", scope+`"itemId":"r1","delta":"next","summaryIndex":1}`) + scopeNotification(t, s, "item/completed", scope+`"item":{"type":"reasoning","id":"r1"}}`) + scopeNotification(t, s, "item/completed", scope+`"item":{"type":"reasoning","id":"r2","content":["whole"]}}`) + close(out) + var got []proto.ReasoningPayload + for env := range out { + if env.Type == proto.TypeReasoning { + var p proto.ReasoningPayload + _ = env.DecodePayload(&p) + got = append(got, p) + } + } + want := []proto.ReasoningPayload{ + {ID: "r1", Status: "in_progress", Delta: "plan "}, {ID: "r1", Status: "in_progress", Delta: "\n\nnext"}, {ID: "r1", Status: "completed"}, + {ID: "r2", Status: "in_progress", Delta: "whole"}, {ID: "r2", Status: "completed"}, + } + if !requested { + want = nil + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("requested=%v: reasoning %+v, want %+v", requested, got, want) + } } } func TestDispatchCompletedItem_AgentMessage_BufferIsFinalText(t *testing.T) { bufs := NewItemBuffers() - FoldDeltaIntoBuffer(bufs, "agent", "a1", "Hello ") - FoldDeltaIntoBuffer(bufs, "agent", "a1", "world") + FoldDeltaIntoBuffer(bufs, "a1", "Hello ") + FoldDeltaIntoBuffer(bufs, "a1", "world") envs, text, err := DispatchCompletedItem("run-1", ThreadItem{ Type: "agentMessage", ID: "a1", Text: "fallback", @@ -168,14 +162,14 @@ func TestDispatchCompletedItem_ToolCallEmitsAfterStage(t *testing.T) { func TestFoldDeltaIntoBuffer_AccumulatesPerItem(t *testing.T) { bufs := NewItemBuffers() - if got := FoldDeltaIntoBuffer(bufs, "agent", "a1", "hi"); got != "hi" { + if got := FoldDeltaIntoBuffer(bufs, "a1", "hi"); got != "hi" { t.Fatalf("fold returned %q", got) } - if got := FoldDeltaIntoBuffer(bufs, "agent", "a1", " there"); got != "hi there" { + if got := FoldDeltaIntoBuffer(bufs, "a1", " there"); got != "hi there" { t.Fatalf("fold accumulation = %q", got) } // Different itemId is a different accumulator. - if got := FoldDeltaIntoBuffer(bufs, "agent", "a2", "other"); got != "other" { + if got := FoldDeltaIntoBuffer(bufs, "a2", "other"); got != "other" { t.Fatalf("cross-item bleed: got %q", got) } } diff --git a/apps/daemon/internal/agent/codex/session_messages.go b/apps/daemon/internal/agent/codex/session_messages.go index 5ea3c96e0..f424d3e14 100644 --- a/apps/daemon/internal/agent/codex/session_messages.go +++ b/apps/daemon/internal/agent/codex/session_messages.go @@ -14,7 +14,7 @@ func (s *Session) onAgentDelta(raw json.RawMessage) { if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { return } - _ = FoldDeltaIntoBuffer(s.bufs, "agent", p.ItemID, p.Delta) + _ = FoldDeltaIntoBuffer(s.bufs, p.ItemID, p.Delta) seq := s.deltaSeq.Add(1) env, err := proto.NewEnvelope(proto.TypeDelta, s.runID, proto.DeltaPayload{ItemID: p.ItemID, Delta: p.Delta, Sequence: seq}) if err != nil { @@ -51,6 +51,10 @@ func (s *Session) onItemCompleted(raw json.RawMessage) { if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Item.ID == "" { return } + if p.Item.Type == "reasoning" { + s.completeReasoning(p.Item) + return + } envs, text, err := DispatchCompletedItem(s.runID, p.Item, s.bufs) if err != nil { s.cfg.logger.Warn("codex: dispatch completed item failed", "run_id", s.runID, "err", err) diff --git a/apps/daemon/internal/agent/codex/session_notifications_test.go b/apps/daemon/internal/agent/codex/session_notifications_test.go index deefbfaf5..3e33d7885 100644 --- a/apps/daemon/internal/agent/codex/session_notifications_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_test.go @@ -13,7 +13,7 @@ func TestRootNotificationIsolation(t *testing.T) { t.Run(map[bool]string{false: "child without thread started", true: "child thread started"}[childStarted], func(t *testing.T) { out := make(chan proto.Envelope, 64) s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(testStateRoot(t)), - rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers()} + rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), reasoningSummary: true} s.registerHandlers() s.setThreadID("root") notify := func(method, params string) { t.Helper(); scopeNotification(t, s, method, params) } @@ -57,7 +57,7 @@ func TestRootNotificationIsolation(t *testing.T) { if s.terminal.Load() || s.currentThreadID() != "root" || len(out) != rootEvents { t.Fatalf("foreign notification changed Run ownership/output: terminal=%v thread=%q events=%d want=%d", s.terminal.Load(), s.currentThreadID(), len(out), rootEvents) } - if s.bufs.AgentText["shared"] != "root " || s.bufs.Reasoning["thought"] != "root thought" || s.peekLastErrText() != "root retry" || s.takeFinalText() != "" { + if s.bufs.AgentText["shared"] != "root " || s.bufs.Reasoning["thought"].channel != "text" || s.peekLastErrText() != "root retry" || s.takeFinalText() != "" { t.Fatal("foreign notification changed root buffers") } if s.latestUsage == nil || s.latestUsage.InputTokens != 20 || s.latestUsage.OutputTokens != 5 || s.usageTurnID != "root-turn" { @@ -88,8 +88,8 @@ func TestRootNotificationIsolation(t *testing.T) { } } } - if doneCount != 1 || s.deltaSeq.Load() != 2 || s.thinkingSeq.Load() != 1 { - t.Fatalf("root output duplicated or polluted: done=%d delta=%d thinking=%d", doneCount, s.deltaSeq.Load(), s.thinkingSeq.Load()) + if doneCount != 1 || s.deltaSeq.Load() != 2 { + t.Fatalf("root output duplicated or polluted: done=%d delta=%d", doneCount, s.deltaSeq.Load()) } }) } diff --git a/apps/daemon/internal/agent/codex/subagent_profile_test.go b/apps/daemon/internal/agent/codex/subagent_profile_test.go index b7c964888..6b0611505 100644 --- a/apps/daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/daemon/internal/agent/codex/subagent_profile_test.go @@ -68,7 +68,7 @@ func TestSubagentProfileRejectsRequirementsThatForceUnobservableFeatures(t *test func TestSubagentItemSnapshotSkipsUnmappedItemTypes(t *testing.T) { for _, item := range []string{`{"type":"imageView","id":"i1","path":"/x.png"}`, `{"type":"contextCompaction","id":"c1"}`} { - kind, _, _, err := subagentItemSnapshot(json.RawMessage(item), "completed") + kind, _, _, err := subagentItemSnapshot(json.RawMessage(item), "completed", true) if err != nil || kind != "" { t.Fatal(item, kind, err) } diff --git a/apps/daemon/internal/agent/codex/subagent_snapshots.go b/apps/daemon/internal/agent/codex/subagent_snapshots.go index 19bd8c3fe..4c03b7a2a 100644 --- a/apps/daemon/internal/agent/codex/subagent_snapshots.go +++ b/apps/daemon/internal/agent/codex/subagent_snapshots.go @@ -185,7 +185,7 @@ func (s *Session) publishSubagentHistory(ctx context.Context, h subagentHistory, if turn.CompletedItems[identity.ID] { itemStatus = "completed" } - kind, body, id, err := subagentItemSnapshot(raw, itemStatus) + kind, body, id, err := subagentItemSnapshot(raw, itemStatus, s.reasoningSummary) if kind == proto.TypeSubagentCoordination { value, decodeErr := subagentCoordination(raw, h.ID) if decodeErr != nil { @@ -234,7 +234,7 @@ func nativeMilliseconds(seconds *int64) *int64 { return &ms } -func subagentItemSnapshot(raw json.RawMessage, turnStatus string) (string, json.RawMessage, string, error) { +func subagentItemSnapshot(raw json.RawMessage, turnStatus string, reasoningSummary bool) (string, json.RawMessage, string, error) { var item struct { Type string `json:"type"` ID string `json:"id"` @@ -291,14 +291,16 @@ func subagentItemSnapshot(raw json.RawMessage, turnStatus string) (string, json. body, err := json.Marshal(map[string]any{"input": []any{map[string]any{"type": "message", "role": "user", "content": content}}}) return "message", body, item.ID, err case "reasoning": - var source struct { - Summary []string `json:"summary"` + if !reasoningSummary { + return "", nil, "", nil } + var source ThreadItem if json.Unmarshal(raw, &source) != nil { return "", nil, "", errors.New("codex: invalid child reasoning") } - summary := make([]map[string]string, 0, len(source.Summary)) - for _, text := range source.Summary { + parts := nativeReasoningParts(source) + summary := make([]map[string]string, 0, len(parts)) + for _, text := range parts { summary = append(summary, map[string]string{"type": "summary_text", "text": text}) } status := "in_progress" diff --git a/apps/daemon/internal/agent/mcode/events.go b/apps/daemon/internal/agent/mcode/events.go index edc9d2c28..f9c562e01 100644 --- a/apps/daemon/internal/agent/mcode/events.go +++ b/apps/daemon/internal/agent/mcode/events.go @@ -41,7 +41,7 @@ func (s *Session) handle(frame rpcFrame) error { Type string `json:"type"` Text string `json:"text"` } - if kind := event.Update.Kind; (kind == "agent_message_chunk" || kind == "agent_thought_chunk") && json.Unmarshal(event.Update.Content, &chunk) != nil { + if kind := event.Update.Kind; kind == "agent_message_chunk" && json.Unmarshal(event.Update.Content, &chunk) != nil { return fmt.Errorf("mcode: invalid session update") } switch event.Update.Kind { @@ -53,9 +53,6 @@ func (s *Session) handle(frame rpcFrame) error { s.content.WriteString(text) s.sequence++ s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: text, Sequence: s.sequence}) - case "agent_thought_chunk": - s.sequence++ - s.emit(proto.TypeThinking, proto.ThinkingPayload{Text: chunk.Text, Sequence: s.sequence}) case "tool_call", "tool_call_update": return s.emitTool(event.Update.toolUpdate) } diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 208eb6620..4d5d77ce9 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -2474,6 +2474,7 @@ definitions: - input_image - output_text - encrypted_content + - summary_text type: string required: - type diff --git a/contracts/agents-api/go-bindings.json b/contracts/agents-api/go-bindings.json index 9b4f16d06..edc891904 100644 --- a/contracts/agents-api/go-bindings.json +++ b/contracts/agents-api/go-bindings.json @@ -206,7 +206,7 @@ "order": ["id", "turn_id", "type", "status", "role", "phase", "content", "command", "cwd", "duration_ms", "exit_code", "name", "call_id", "server_label", "arguments", "output", "error", "action", "agent_id", "sender_agent_id", "recipient_agent_id", "recipient_agent_ids", "model", "reasoning_effort", "summary"] }, "ItemContent": { - "sources": ["#/components/schemas/MessageContentResource", "#/components/schemas/EncryptedContentResource"], + "sources": ["#/components/schemas/MessageContentResource", "#/components/schemas/EncryptedContentResource", "#/components/schemas/SummaryTextResource"], "fields": { "image_url": {"type": "string"} } diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index 2cb143ca0..9385052b6 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -23,7 +23,7 @@ Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted | [Whitespace-only message text](./message-content.md) | Admitted; delivered unchanged | Admitted; native drops it, and an all-blank message becomes `(no content)` | Rejected | | [Message images](./message-content.md#images) | PNG and JPEG data URLs: verified on all placements; other data URLs: admitted; remote URLs: rejected | PNG and JPEG data URLs: verified on all placements; remote URLs and GIF and WebP data URLs: admitted | Rejected | | `reasoning.effort` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`: admitted | `low`, `medium`, `high`, `xhigh`, `max`: admitted | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`: admitted | -| `reasoning.summary` | `concise`, `detailed`, `auto`: admitted | Rejected | Rejected | +| `reasoning.summary` | `concise`, `detailed`, `auto`: admitted | `auto`: admitted | Rejected | | `service_tier` other than `auto` | `default`, `flex`, `priority`: admitted | Rejected | Rejected | | `text.verbosity` other than `medium` | Admitted; the native model decides | Rejected | Rejected | | [Public token usage](./sessions-events.md) | Measured counters | Null | Null | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index df391130f..ee9504b77 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -216,7 +216,7 @@ The daemon's `clirunner` starts every native child in its own Unix process group Owned output pipes stay readable after the leader exits. Consumers drain stdout and stderr before calling `Wait`, which joins the cached process result and closes the readers. `Done` reports leader reaping and group cleanup signals; it is not a native execution receipt or proof of persisted history. SDK adapters settle each Turn and drain its observations before publishing completion, and Executor close also closes the query and awaits the native child. Process groups are lifecycle supervision, not isolation or containment of descendants that leave the group. -Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. +Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; the adapter also disables its blocking `request_user_input` tool and sets `model_reasoning_summary` to `none` when the Agent's `reasoning.summary` is null, because the official default is no summary and Codex's own default is model-dependent. Claude runs through the adapter's tool callback, which allows or denies without asking. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. An unreachable model host must not hold a Turn open. The adapter keeps the Harness's own connection retries bounded through native settings, and the Harness's final connection error becomes `connection_failed` when the Harness reports it as structured data ([native failure classification](../../docs/runtime-protocol.md#native-failure-classification)). Core adds no model timeout of its own. Each adapter guide states its Harness's bound. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index cfe8cd77c..19d7adc41 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -123,7 +123,8 @@ Each item is Core's deliberate or native behavior where the official service beh **Execution and history** -- Codex reasoning summaries are requested but not projected as `reasoning` Items, and the stream does not emit reasoning-summary events, Environment `pending` or `ready` events, or every pinned interim tool-output variant. +- A `reasoning` Item's `summary_text` carries the Harness's native reasoning summary when it produces one, otherwise its full native reasoning text, such as raw model reasoning or Claude Code thinking blocks. Claude Code admits only `reasoning.summary` `auto`, and Codex streams the first native channel an item delivers, its summary or its raw reasoning text. +- The stream does not emit Environment `pending` or `ready` events, or every pinned interim tool-output variant. - Native Item variants beyond those listed under [Turns and Items](./sessions-events.md#turns-and-items) are not projected, and Items cannot be modified. - A function result that cancellation prevents from being applied never appears as an Item. - Pinned Codex can lose command output emitted before its stream subscription. diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 03a3a12b6..b1d447bc4 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -119,6 +119,7 @@ A retry with the same `Idempotency-Key` and `stream: true` returns 201 with only - `item.added` and `item.done` always carry `output_index`, null for input Items. Function results emit `item.added` only; `item.done` is for agent output. - An assistant message follows one sequence: `item.added` in progress with empty `content`, `content_part.added` with empty text, `output_text.delta` events, `output_text.done`, `content_part.done` and `item.done`. A message first observed complete, such as structured output, sends its whole text in one `output_text.delta`, byte for byte. The completed text replaces the accumulated deltas. - Codex command output streams as `agent.output.command_execution_output.delta` with the command's Item ID and output index. Native output quotas and text conversion apply, so the deltas are not a byte-exact capture; the completed Item is authoritative. +- A reasoning Item follows one sequence: `item.added` in progress with an empty `summary`, `reasoning_summary_part.added` with empty text when its first text arrives, `reasoning_summary_text.delta` events, `reasoning_summary_text.done`, `reasoning_summary_part.done` and `item.done`. A root Turn's Item has one `summary_text` part, `summary_index` 0. An Item that ends without text has an empty `summary` and gets only `item.added` and `item.done`. - A cancelled or failed Turn marks its unfinished Items `incomplete` and keeps their partial content. - A function call stays in `required_actions` until the Harness applies its result, or cancellation or the Turn's end removes it. A repeated notification emits no new state. @@ -137,7 +138,8 @@ Turn and Item lists take `after`, `limit` and `order` ([list rules](./wire-seman | `mcp_call` | Server and tool identity, arguments, structured result or error | | `function_call`, `function_call_output` | A linked call and its result. The result always carries `output` and `error`, null when the submission omitted them; stored results keep the submitted field presence. Native file changes appear as an `apply_patch` function call with the changes as arguments and no invented result | | `web_search_call` | The supported action fields (`search`, `open_page`, `find_in_page`, `other`); Claude Code reports `search` for `WebSearch` and `open_page` for `WebFetch` | -| `reasoning`, `agent_message` and the Subagent coordination calls | See [Subagents](./subagents.md). `agent_message` has no status; reasoning status can be null | +| `reasoning` | One native reasoning item as a single `summary_text` part, present only when the Agent sets `reasoning.summary`; with it null (the default) no `reasoning` Items or reasoning events appear for any Turn, root or child. The text is the Harness's native summary when it produces one, otherwise its native reasoning text. Status can be null on a child Turn's Item | +| `agent_message` and the Subagent coordination calls | See [Subagents](./subagents.md). `agent_message` has no status | A failed tool does not fail its Turn. Tool output is readable by the Session's Project and can contain the tool's own diagnostic text. diff --git a/contracts/agents-api/v1/official.gen.go b/contracts/agents-api/v1/official.gen.go index 92b49fcb5..f46878b35 100644 --- a/contracts/agents-api/v1/official.gen.go +++ b/contracts/agents-api/v1/official.gen.go @@ -339,9 +339,9 @@ type Item struct { Summary []SummaryText `json:"summary,omitempty"` } -// ItemContent projects MessageContentResource, EncryptedContentResource. +// ItemContent projects MessageContentResource, EncryptedContentResource, SummaryTextResource. type ItemContent struct { - Type string `json:"type" binding:"required" enums:"input_text,input_image,output_text,encrypted_content"` + Type string `json:"type" binding:"required" enums:"input_text,input_image,output_text,encrypted_content,summary_text"` Text *string `json:"text,omitempty"` ImageURL string `json:"image_url,omitempty"` EncryptedContent *string `json:"encrypted_content,omitempty"` diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index e9e5e2d75..2cb49062e 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: a09c99bf3ad9782ffbf3d9d2f4d6313f93f39ee92ea5b919635a96a70b490edd +source_hash: d79690e618d82c6b858dc40342198fc5b091ee56505816f551da80d100ef2c2e --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -25,7 +25,7 @@ source_hash: a09c99bf3ad9782ffbf3d9d2f4d6313f93f39ee92ea5b919635a96a70b490edd | [仅空白消息文本](message-content.md) | 已准入;原样交付 | 已准入;原生会丢弃它,全空白消息变为 `(no content)` | 已拒绝 | | [消息图像](message-content.md#images) | PNG、JPEG data URL:所有位置已验证;其他 data URL:已准入;远程 URL:已拒绝 | PNG、JPEG data URL:所有位置已验证;远程 URL 及 GIF、WebP data URL:已准入 | 已拒绝 | | `reasoning.effort` | `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`:已准入 | `low`、`medium`、`high`、`xhigh`、`max`:已准入 | `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`:已准入 | -| `reasoning.summary` | `concise`、`detailed`、`auto`:已准入 | 已拒绝 | 已拒绝 | +| `reasoning.summary` | `concise`、`detailed`、`auto`:已准入 | `auto`:已准入 | 已拒绝 | | 非 `auto` 的 `service_tier` | `default`、`flex`、`priority`:已准入 | 已拒绝 | 已拒绝 | | 非 `medium` 的 `text.verbosity` | 已准入;由原生模型决定 | 已拒绝 | 已拒绝 | | [公开 token 用量](sessions-events.md) | 测量计数器 | Null | Null | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3d7db2da5..2b3ee661b 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: 8beb6af27e99a4bf25a2e828d1b2c1c51adcd7b597f3cd64a211396a506a09c0 +source_hash: 7e37200557300648fcbd4375df502c6dfb0dc65b7e878833670af0c984081aec --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -218,7 +218,7 @@ daemon 的 `clirunner` 让每个原生子进程在自己的 Unix 进程组中启 所属输出管道在主进程退出后仍可读取。消费者在调用 `Wait` 之前耗尽 stdout 和 stderr;`Wait` 会汇合缓存的进程结果并关闭读取器。`Done` 报告主进程回收和进程组清理信号;它不是原生执行回执,也不是历史已持久化的证据。SDK 适配器会结算每个 Turn,并在发布完成状态前耗尽其观察结果;Executor 关闭还会关闭 Query 并等待原生子进程。进程组用于生命周期监管,而不是隔离或遏制离开进程组的后代进程。 -适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 +适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;适配器还禁用其阻塞式 `request_user_input` 工具,并在 Agent 的 `reasoning.summary` 为 null 时将 `model_reasoning_summary` 设为 `none`,因为官方默认不返回摘要,而 Codex 自己的默认值取决于模型。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 模型主机不可达时不能让 Turn 一直挂起。适配器通过原生设置让 Harness 自身的连接重试保持有界;Harness 以结构化数据报告最终连接错误时,该错误归为 `connection_failed`([原生失败分类](../../../docs/zh/runtime-protocol.md#native-failure-classification))。Core 不另设模型超时。各适配器指南写明其 Harness 的上限。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 009710471..4fa5e7685 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 535fce4a958c4176e98e0b59a6995fd9d72c874cb4a6493b797545652c37321b +source_hash: 90e786b565e819d9c1396e7182c11da1f66f7213398e3c166d2b0713fd072e97 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -126,7 +126,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **执行和历史** -- Codex 的推理摘要会被请求,但不会投影为 `reasoning` Item;流也不会发出 reasoning-summary 事件、Environment 的 `pending` 或 `ready` 事件,也不会覆盖固定版本中的所有临时 tool-output 变体。 +- `reasoning` Item 的 `summary_text` 携带 Harness 产生的原生推理摘要,没有摘要时携带完整的原生推理文本,例如模型原始推理或 Claude Code 的 thinking 块。Claude Code 只接受 `reasoning.summary` 为 `auto`,Codex 流式输出一个 item 先送达的原生通道,即摘要或原始推理文本。 +- 流不会发出 Environment 的 `pending` 或 `ready` 事件,也不会覆盖固定版本中的所有临时 tool-output 变体。 - 除 [Turns and Items](sessions-events.md#turns-and-items) 中列出的变体外,其他原生 Item 变体不会被投影,而且 Items 无法修改。 - 如果取消导致函数结果无法应用,该结果将永远不会作为 Item 出现。 - 固定版本的 Codex 可能会丢失在订阅其流之前发出的命令输出。 diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index 5a401ccf5..e86853e33 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: 581c5a36cd2df2fa35d96da7c0e4d8fcbc7e01f4d97d35a2351b39386b15b380 +source_hash: 99dcffc015fee1be5e83f8be5399c827d4f263f098ec6f173b9153522e9ac26d --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -121,6 +121,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 - `item.added` 和 `item.done` 始终携带 `output_index`;输入 Item 的该值为 null。函数结果只发出 `item.added`;`item.done` 用于代理输出。 - 助手消息遵循以下单一顺序:先发送 `content` 为空的进行中 `item.added`,再发送文本为空的 `content_part.added`,然后发送 `output_text.delta` 事件、`output_text.done`、`content_part.done` 和 `item.done`。首次观察时已完整的消息(例如结构化输出)会在一个 `output_text.delta` 中逐字节发送全部文本。完整文本会替换此前累积的增量。 - Codex 命令输出以 `agent.output.command_execution_output.delta` 流式传输,并携带命令的 Item ID 和输出索引。原生输出配额和文本转换规则适用,因此这些增量并非逐字节捕获的原始结果;完整 Item 才是权威结果。 +- reasoning Item 遵循以下单一顺序:先发送 `summary` 为空的进行中 `item.added`,首段文本到达时发送文本为空的 `reasoning_summary_part.added`,然后发送 `reasoning_summary_text.delta` 事件、`reasoning_summary_text.done`、`reasoning_summary_part.done` 和 `item.done`。根 Turn 的 Item 只有一个 `summary_text` 部分,`summary_index` 为 0。没有文本就结束的 Item `summary` 为空,只发送 `item.added` 和 `item.done`。 - 已取消或失败的 Turn 会将其未完成的 Item 标记为 `incomplete`,并保留其部分内容。 - 函数调用会一直保留在 `required_actions` 中,直到 Harness 应用其结果,或取消操作或 Turn 结束将其移除。重复通知不会发出新状态。 @@ -139,7 +140,8 @@ Turn 和 Item 列表接受 `after`、`limit` 和 `order`([列表规则](wire-s | `mcp_call` | 服务器和工具标识、参数、结构化结果或错误 | | `function_call`、`function_call_output` | 关联的调用及其结果。结果始终携带 `output` 和 `error`;提交时省略这些字段则值为 null。存储的结果会保留提交时字段是否存在的信息。原生文件更改显示为 `apply_patch` 函数调用,更改内容作为参数,不会生成虚构结果 | | `web_search_call` | 支持的操作字段(`search`、`open_page`、`find_in_page`、`other`);Claude Code 以 `search` 报告 `WebSearch`,以 `open_page` 报告 `WebFetch` | -| `reasoning`、`agent_message` 和 Subagent 协调调用 | 见 [Subagents](subagents.md)。`agent_message` 没有状态;reasoning 状态可以为 null | +| `reasoning` | 一个原生推理 item,表示为单个 `summary_text` 部分,仅在 Agent 设置了 `reasoning.summary` 时出现;为 null(默认)时,任何 Turn(根或子)都没有 `reasoning` Item 和 reasoning 事件。文本是 Harness 产生的原生摘要,没有摘要时是其原生推理文本。子 Turn 的 Item 状态可以为 null | +| `agent_message` 和 Subagent 协调调用 | 见 [Subagents](subagents.md)。`agent_message` 没有状态 | 工具失败不会导致其 Turn 失败。Session 的项目可以读取工具输出,其中可能包含该工具自己的诊断文本。 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index e5845c6cf..aa3a71e4c 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -108,7 +108,7 @@ The linked source files define the required fields, validators, limits and finit | --- | --- | --- | | `runtime_prepare` | `runtime_prepare_result` | [Initialization and capability transfer](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/runtime_prepare.go) | | `execution_prepare`, `execution_start`, `execution_release` | `preparation_status` | [Execution admission](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/preparation.go) | -| `prompt_cancel` | `delta`, `thinking`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat`, `interaction_decision_ack` | [Requests](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/outbound.go), [events and capabilities](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/inbound.go) | +| `prompt_cancel` | `delta`, `reasoning`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat`, `interaction_decision_ack` | [Requests](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/outbound.go), [events and capabilities](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/inbound.go) | | `prompt_steer` | `prompt_steer_ack` | [Active input receipts](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/steering.go) | | `function_result` | `function_call`, `function_call_withdrawn`, `interaction_decision_ack` | [Function calls](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/functions.go) | | `workspace_read`, `workspace_write`, `workspace_export` | Matching `*_result` | [Read](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_read.go), [write](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_write.go), [export](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_export.go) | @@ -120,6 +120,8 @@ A `function_call` carries `native_id` and `turn_id` when a Subagent made the cal A `function_call_withdrawn` carries the `call_id`, `native_id` and `turn_id` of a `function_call` and tells that the native Harness will never apply a result for it, for example because a Codex Subagent Turn ended and native dropped its request. Core stops requiring an answer for the call and rejects a later result for it. The adapter acknowledges a `function_result` already in flight with `not_pending`, and Core then waits for the withdrawal or the Turn's end instead of failing the Turn or resending the result. +A `reasoning` frame reports one native reasoning item by its native `id`. A frame with status `in_progress` carries the next text fragment in `delta`, and a frame with status `completed` or `incomplete` ends the item and carries no text. The text is the Harness's native reasoning summary when it produces one, otherwise its native reasoning text. An adapter sends `reasoning` frames only when `execution_controls` sets a reasoning summary, for the Run and for its Subagents alike. + Usage frames and the final usage snapshot each carry the cumulative measurement of the current execution and replace the previous snapshot; never add them. An absent measurement is unknown, not zero. ## Preparation and execution order diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index d9da4fa49..095327142 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 68d35aa375b6c371975c619712f65970dc0055b49e4362c271f835422779eea1 +source_hash: 033ea14b34c596c855004a3ec1ee2e9b675c13ed962bfcc2e5c01d76e5fff4bd --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -110,7 +110,7 @@ Core 对 `usage` 和 `resume` 没有准入规则。 | --- | --- | --- | | `runtime_prepare` | `runtime_prepare_result` | [初始化与能力传输](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/runtime_prepare.go) | | `execution_prepare`, `execution_start`, `execution_release` | `preparation_status` | [执行准入](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/preparation.go) | -| `prompt_cancel` | `delta`, `thinking`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat`, `interaction_decision_ack` | [请求](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/outbound.go)、[事件与能力](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/inbound.go) | +| `prompt_cancel` | `delta`, `reasoning`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat`, `interaction_decision_ack` | [请求](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/outbound.go)、[事件与能力](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/inbound.go) | | `prompt_steer` | `prompt_steer_ack` | [活动输入回执](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/steering.go) | | `function_result` | `function_call`, `function_call_withdrawn`, `interaction_decision_ack` | [Function 调用](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/functions.go) | | `workspace_read`, `workspace_write`, `workspace_export` | 对应的 `*_result` | [读取](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_read.go)、[写入](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_write.go)、[导出](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_export.go) | @@ -122,6 +122,8 @@ Subagent 发起调用时,`function_call` 携带 `native_id` 和 `turn_id`, `function_call_withdrawn` 携带某个 `function_call` 的 `call_id`、`native_id` 和 `turn_id`,表示原生 Harness 永远不会为它应用结果,例如 Codex Subagent 的 Turn 已结束且原生丢弃了其请求。Core 不再要求回答该调用,并拒绝之后为它提交的结果。adapter 以 `not_pending` 确认已在传输中的 `function_result`,Core 随后等待撤回或 Turn 结束,既不让 Turn 失败,也不重发该结果。 +`reasoning` frame 按原生 `id` 报告一个原生推理 item。状态为 `in_progress` 的 frame 在 `delta` 中携带下一段文本,状态为 `completed` 或 `incomplete` 的 frame 结束该 item 且不带文本。文本是 Harness 产生的原生推理摘要,没有摘要时是其原生推理文本。只有 `execution_controls` 设置了推理摘要时,适配器才发送 `reasoning` frame,Run 及其 Subagent 都遵循此规则。 + Usage frame 和最终 usage snapshot 都携带当前执行的累计测量,替换之前的快照;不要相加。缺失的测量表示未知,不是零。 ## 准备与执行顺序 {#preparation-and-execution-order} diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index 18d5bcd94..6a8b8767e 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -19,10 +19,9 @@ const ( // TypeOutputMessage carries opt-in native message boundaries and completion snapshots. TypeOutputMessage = "output_message" - // TypeThinking carries an internal-thinking fragment. Gateway - // forwards as a plain EventDelta so existing renderers keep - // working. - TypeThinking = "thinking" + // TypeReasoning carries one native reasoning item: its text fragments while + // in progress and its terminal status. + TypeReasoning = "reasoning" // TypeToolCall carries a tool invocation. Stage=="before" runs // before the call, "after" runs after. @@ -65,10 +64,12 @@ type OutputMessagePayload struct { Text *string `json:"text,omitempty"` } -// ThinkingPayload carries an internal-thinking fragment. -type ThinkingPayload struct { - Text string `json:"text"` - Sequence uint64 `json:"sequence,omitempty"` +// ReasoningPayload reports one native reasoning item. Delta is the next text +// fragment, empty on a frame that only changes Status. +type ReasoningPayload struct { + ID string `json:"id"` + Status string `json:"status"` + Delta string `json:"delta,omitempty"` } // ToolCallPayload carries a tool invocation event. Stage is "before" diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index 7fadf82ea..9c52923e9 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -52,8 +52,9 @@ var Declaration = harnessconfig.Declaration{ MCPHTTPRequired: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported, }, - // The SDK takes effort low to max. It has no verbosity, summary or service - // tier. Native REPL runs tool calls inside model-written code, outside the root + // The SDK takes effort low to max. Its thinking blocks are the reasoning + // text, so a summary request takes only auto; there is no concise or + // detailed setting. It has no verbosity or service tier. Native REPL runs tool calls inside model-written code, outside the root // tool_use and tool_result records that function receipts and tool observations // read, so enabling programmatic tool calling is rejected. Native WebSearch and // WebFetch are live and have no cached mode or settings: disabled removes them, @@ -63,6 +64,7 @@ var Declaration = harnessconfig.Declaration{ Execution: harnessconfig.ExecutionOptions{ WebSearchModes: []string{"disabled", "live"}, ReasoningEfforts: []string{"low", "medium", "high", "xhigh", "max"}, + ReasoningSummaries: []string{"auto"}, ProgrammaticToolCalling: []bool{false}, TextVerbosities: []string{"medium"}, }, diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index a495f1392..d96dc02b1 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -50,7 +50,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: const selected = [...(request.tool_search ? ["ToolSearch"] : []), ...(request.web_search === "live" ? ["WebSearch"] : [])]; const declarations = request.workspace?.mcp ?? request.mcp_http_servers; const profile: MCPProfile | undefined = declarations === undefined ? undefined : new MCPProfile(declarations, names, id => subagents?.permitsActor(id) ?? id === undefined); - const subagents = request.subagents ? new Subagents(request.cwd, request.subagents.max_concurrent, request.resume, profile?.identities) : undefined; + const subagents = request.subagents ? new Subagents(request.cwd, request.subagents.max_concurrent, request.resume, profile?.identities, request.reasoning_summary) : undefined; const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, profile, subagents, request.web_search === "disabled"); let commands = workspace ? new CommandObserver() : undefined; const observeSearch = workspace !== undefined || request.web_search === "live"; @@ -90,7 +90,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: let failed = false; let cancellationFactsFailed = false; let structured = request.output_format ? new StructuredOutput() : undefined; - let messages = new MessageObserver(); + let messages = new MessageObserver(request.reasoning_summary); let stream: ReturnType | undefined; let warm: WarmQuery | undefined; let nativeAlive = false; @@ -170,7 +170,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: turns.configure(stream,(input,output)=>{ inputs=new Inputs(input); functions=new FunctionBridge(output); - messages=new MessageObserver(); + messages=new MessageObserver(request.reasoning_summary); structured=request.output_format ? new StructuredOutput() : undefined; commands=workspace ? new CommandObserver() : undefined; searches=observeSearch ? new SearchObserver() : undefined; diff --git a/packages/claude-sdk-adapter/src/messages.ts b/packages/claude-sdk-adapter/src/messages.ts index 7a47fc8bd..fdfec9d06 100644 --- a/packages/claude-sdk-adapter/src/messages.ts +++ b/packages/claude-sdk-adapter/src/messages.ts @@ -2,7 +2,8 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; export type MessageEvent = | { type: "delta"; delta: string; item_id: string } - | { type: "output_message"; message: { id: string; status: "in_progress" | "completed"; phase?: "final_answer"; text?: string } }; + | { type: "output_message"; message: { id: string; status: "in_progress" | "completed"; phase?: "final_answer"; text?: string } } + | { type: "reasoning"; reasoning: { id: string; status: "in_progress" | "completed"; delta?: string } }; type ActiveMessage = { id: string; @@ -10,10 +11,13 @@ type ActiveMessage = { block?: number; observed: boolean; failed: boolean; + thinking: Set; }; export class MessageObserver { private active?: ActiveMessage; + // Native thinking blocks are reported as reasoning only when the Agent requested summaries. + constructor(private readonly reasoning = false) {} consume(message: SDKMessage): MessageEvent[] { if ((message.type !== "stream_event" && message.type !== "assistant") || @@ -35,13 +39,18 @@ export class MessageObserver { const event = message.event; if (event.type === "message_start") { if (!event.message.id) throw new Error("missing native message identity"); - this.active = { id: event.message.id, blocks: new Map(), observed: false, failed: false }; + this.active = { id: event.message.id, blocks: new Map(), observed: false, failed: false, thinking: new Set() }; return []; } const active = this.active; if (!active) throw new Error("native message start is missing"); if (event.type === "content_block_start") { active.block = event.index; + if (event.content_block.type === "thinking" && this.reasoning) { + active.thinking.add(event.index); + const id = `${active.id}:${event.index}`; + return [{ type: "reasoning", reasoning: { id, status: "in_progress", ...(event.content_block.thinking ? { delta: event.content_block.thinking } : {}) } }]; + } if (event.content_block.type !== "text") return []; active.blocks.set(event.index, event.content_block.text); const events: MessageEvent[] = []; @@ -58,7 +67,13 @@ export class MessageObserver { active.blocks.set(event.index, previous + event.delta.text); return event.delta.text ? [{ type: "delta", item_id: active.id, delta: event.delta.text }] : []; } - if (event.type === "content_block_stop") active.block = undefined; + if (event.type === "content_block_delta" && event.delta.type === "thinking_delta" && active.thinking.has(event.index)) { + return event.delta.thinking ? [{ type: "reasoning", reasoning: { id: `${active.id}:${event.index}`, status: "in_progress", delta: event.delta.thinking } }] : []; + } + if (event.type === "content_block_stop") { + active.block = undefined; + if (active.thinking.delete(event.index)) return [{ type: "reasoning", reasoning: { id: `${active.id}:${event.index}`, status: "completed" } }]; + } if (event.type === "message_stop") { this.active = undefined; if (active.observed && !active.failed) { diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 94879af3f..1b00a08e0 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -18,6 +18,7 @@ export type Start = { require_history?: boolean; subagents?: { max_concurrent: number }; tool_search?: boolean; + reasoning_summary?: boolean; web_search?: "disabled" | "live"; functions?: { name: string; description: string; parameters: Tool["inputSchema"]; defer_loading?: boolean }[]; mcp_http_servers?: HTTPServer[]; @@ -35,7 +36,7 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const request = value as Record; - const allowed = new Set(["native_model_options", "type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "output_format", "subagents", "functions", "tool_search", "web_search", "mcp_http_servers", "workspace"]); + const allowed = new Set(["native_model_options", "type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "output_format", "subagents", "functions", "tool_search", "reasoning_summary", "web_search", "mcp_http_servers", "workspace"]); if (Object.keys(request).some(key => !allowed.has(key)) || (request.type !== "start" && request.type !== "prepare" && request.type !== "executor_prepare") || (request.type === "start" ? !Array.isArray(request.input) : "input" in request) || @@ -44,6 +45,7 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { typeof request.cwd !== "string" || !isAbsolute(request.cwd) || (request.web_search !== undefined && request.web_search !== "disabled" && request.web_search !== "live") || (request.require_history !== undefined && typeof request.require_history !== "boolean") || + (request.reasoning_summary !== undefined && typeof request.reasoning_summary !== "boolean") || (request.resume !== undefined && (typeof request.resume !== "string" || !request.resume))) throw new Error("invalid_request"); if (request.functions !== undefined && (!Array.isArray(request.functions) || request.functions.some(tool => !tool || typeof tool.name !== "string" || !tool.name || typeof tool.description !== "string" || diff --git a/packages/claude-sdk-adapter/src/subagent_history.ts b/packages/claude-sdk-adapter/src/subagent_history.ts index 24182a858..38ce8adc2 100644 --- a/packages/claude-sdk-adapter/src/subagent_history.ts +++ b/packages/claude-sdk-adapter/src/subagent_history.ts @@ -40,9 +40,9 @@ function calls(rows: NativeRow[]): Map { } // With live, the children are still running: admitted names the ones native started in this Turn, and only their open Turn is projected. -export type Projection = { live?: ReadonlyMap; tools?: ReadonlyMap }; +export type Projection = { live?: ReadonlyMap; tools?: ReadonlyMap; reasoning?: boolean }; -export function projectHistory(root: string, rootRows: NativeRow[], entries: Entry[], { live, tools = new Map() }: Projection = {}): Fact[] { +export function projectHistory(root: string, rootRows: NativeRow[], entries: Entry[], { live, tools = new Map(), reasoning = false }: Projection = {}): Fact[] { const facts: Fact[] = [], children = new Map(); const byParent = new Map>([[root, calls(rootRows)]]); for (const entry of entries) byParent.set(entry.id, calls(entry.rows)); @@ -73,7 +73,7 @@ export function projectHistory(root: string, rootRows: NativeRow[], entries: Ent parent_turn_id: child.call.turn, source_item_id: child.call.id, name: typeof child.call.input.name === "string" ? child.call.input.name : null, instructions: null, } }); - for (const child of ordered) facts.push(...(live ? [openTurn(child)] : projectChild(child, children, tools))); + for (const child of ordered) facts.push(...(live ? [openTurn(child)] : projectChild(child, children, tools, reasoning))); if (!live) for (const call of byParent.get(root)!.values()) { const coordination = coordinationFact(call, "", children); if (coordination) facts.push({ type: "subagent_coordination", fact: coordination }); @@ -118,7 +118,8 @@ function functionContent(content: unknown): Record[] { : b.type === "image" && b.source?.type === "base64" ? { type: "input_image", image_url: `data:${b.source.media_type};base64,${b.source.data}` } : fail()); } -function projectChild(child: Child, children: Map, tools: ReadonlyMap): Fact[] { +// Thinking blocks project as reasoning only when the Agent requested summaries. +function projectChild(child: Child, children: Map, tools: ReadonlyMap, reasoning: boolean): Fact[] { const facts: Fact[] = [], turns: NativeRow[][] = []; for (const row of child.rows) { if (input(row)) turns.push([]); @@ -152,7 +153,7 @@ function projectChild(child: Child, children: Map, tools: Readonl for (const block of blocks(row)) { if (block.type === "text" && typeof block.text === "string") message.text += block.text; else if (block.type === "thinking" && typeof block.thinking === "string") message.thinking += block.thinking; - const kind = block.type === "text" ? "text" : block.type === "thinking" ? "thinking" : block.type === "tool_use" ? "tool" : undefined; + const kind = block.type === "text" ? "text" : block.type === "thinking" && reasoning ? "thinking" : block.type === "tool_use" ? "tool" : undefined; if (kind) { const key = kind === "tool" ? block.id as string : id; if (!seen.has(kind + ":" + key)) { seen.add(kind + ":" + key); order.push({ id: key, kind }); } diff --git a/packages/claude-sdk-adapter/src/subagents.ts b/packages/claude-sdk-adapter/src/subagents.ts index c33ef731d..5a2b1e06e 100644 --- a/packages/claude-sdk-adapter/src/subagents.ts +++ b/packages/claude-sdk-adapter/src/subagents.ts @@ -15,7 +15,7 @@ export class Subagents { private session = ""; private transcript = ""; private hydrated = false; - constructor(private readonly cwd: string, private readonly limit: number, private resume: string | undefined, private readonly tools: ReadonlyMap = new Map()) { + constructor(private readonly cwd: string, private readonly limit: number, private resume: string | undefined, private readonly tools: ReadonlyMap = new Map(), private readonly reasoning = false) { if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("invalid_request"); } get complete(): boolean { return this.pending.size === 0 && this.running.size === 0; } @@ -63,7 +63,7 @@ export class Subagents { const id = this.actors.get(call); if (id === undefined) return; if (!this.transcript || !this.known.has(id)) throw new Error("native child history is missing"); - const { facts } = await readHistory(this.session, this.transcript, this.cwd, { child: id, live: this.admitted, tools: this.tools }); + const { facts } = await readHistory(this.session, this.transcript, this.cwd, { child: id, live: this.admitted, tools: this.tools, reasoning: this.reasoning }); return { native_id: id, turn_id: facts[facts.length - 1].fact.turn_id as string, facts }; } readonly stopped: HookCallback = async input => { @@ -107,7 +107,7 @@ export class Subagents { async facts(cancelledAt?: number): Promise { if (!this.session || !this.transcript) { if (!this.known.size) return []; throw new Error("native child history is missing"); } if (cancelledAt === undefined && (this.pending.size || this.running.size)) throw new Error("native child work is unsettled"); - const history = await readHistory(this.session, this.transcript, this.cwd, { tools: this.tools, ...(cancelledAt === undefined ? {} : { cancelled: { at: cancelledAt, admitted: this.admitted } }) }); + const history = await readHistory(this.session, this.transcript, this.cwd, { tools: this.tools, reasoning: this.reasoning, ...(cancelledAt === undefined ? {} : { cancelled: { at: cancelledAt, admitted: this.admitted } }) }); if ([...this.known].some(id => !history.ids.includes(id))) throw new Error("native child history is missing"); return history.facts.filter(event => event.type !== "subagent_coordination" || this.observedCalls.has(event.fact.id as string)); } diff --git a/packages/claude-sdk-adapter/tests/messages.test.mjs b/packages/claude-sdk-adapter/tests/messages.test.mjs index 7a9328c2f..23ec445d6 100644 --- a/packages/claude-sdk-adapter/tests/messages.test.mjs +++ b/packages/claude-sdk-adapter/tests/messages.test.mjs @@ -90,3 +90,17 @@ test("unmatched text cannot acquire an invented identity", () => { observer.consume(block(0)); assert.throws(() => observer.consume(snapshot("other", "wrong")), /unmatched/); }); + +test("thinking blocks are reasoning only when summaries are requested", () => { + const think = [start("native"), stream({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }), + stream({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hmm " } }), + stream({ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig" } }), blockStop(0), stop()]; + const off = new MessageObserver(); + assert.deepEqual(think.flatMap(message => off.consume(message)), []); + const observer = new MessageObserver(true); + assert.deepEqual(think.flatMap(message => observer.consume(message)), [ + { type: "reasoning", reasoning: { id: "native:0", status: "in_progress" } }, + { type: "reasoning", reasoning: { id: "native:0", status: "in_progress", delta: "hmm " } }, + { type: "reasoning", reasoning: { id: "native:0", status: "completed" } }, + ]); +}); diff --git a/packages/claude-sdk-adapter/tests/subagents.test.mjs b/packages/claude-sdk-adapter/tests/subagents.test.mjs index e8fd222ff..b34d45dba 100644 --- a/packages/claude-sdk-adapter/tests/subagents.test.mjs +++ b/packages/claude-sdk-adapter/tests/subagents.test.mjs @@ -22,7 +22,9 @@ test("native child input owns its Turn and stable creation time; completion is n assert.equal(facts[0].fact.native_created_at, 1700000000); assert.equal(facts[0].fact.parent_turn_id, "root-turn"); assert.equal(facts.filter(e => e.type === "subagent_turn").length, 2); - assert.deepEqual(facts.filter(e => e.type === "subagent_item").map(e => e.fact.kind), ["message", "reasoning", "output_message"]); + const kinds = history => history.filter(e => e.type === "subagent_item").map(e => e.fact.kind); + assert.deepEqual(kinds(projectHistory("root", root, children, { reasoning: true })), ["message", "reasoning", "output_message"]); + assert.deepEqual(kinds(facts), ["message", "output_message"]); assert.equal(facts.find(e => e.type === "subagent_turn" && e.fact.status === "completed").fact.completed_at_ms, 1700000000030); assert(!facts.some(e => e.type === "subagent_lifecycle")); assert.deepEqual(facts, projectHistory("root", structuredClone(root), structuredClone(children))); diff --git a/services/core/internal/execution/journal.go b/services/core/internal/execution/journal.go index b9fc7901c..1da198cfe 100644 --- a/services/core/internal/execution/journal.go +++ b/services/core/internal/execution/journal.go @@ -59,7 +59,7 @@ func (j *journal) enqueue(env proto.Envelope) error { return sessions.ErrInvalidInput } switch env.Type { - case proto.TypeDelta, proto.TypeOutputMessage, proto.TypeThinking, proto.TypeToolCall, proto.TypeCommandOutput, proto.TypeUsage, + case proto.TypeDelta, proto.TypeOutputMessage, proto.TypeReasoning, proto.TypeToolCall, proto.TypeCommandOutput, proto.TypeUsage, proto.TypeError, proto.TypeDone, proto.TypePromptSteerAck, proto.TypeSubagentIdentity, proto.TypeSubagentLifecycle, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination, "cancel_receipt": default: return nil diff --git a/services/core/internal/items/changes.go b/services/core/internal/items/changes.go index f4b5c8e13..bcea9a85f 100644 --- a/services/core/internal/items/changes.go +++ b/services/core/internal/items/changes.go @@ -73,6 +73,10 @@ func Observe(kind string, update Update, stored Stored) (Change, bool, error) { switch kind { case proto.TypeDelta: change.Delta = update.Item.Content[0].Text + case proto.TypeReasoning: + if text := summaryText(update.Item); text != "" { + change.Delta = &text + } case proto.TypeCommandOutput: change.Delta = update.CommandOutputDelta } diff --git a/services/core/internal/items/events.go b/services/core/internal/items/events.go index db27391e0..f68fd19c8 100644 --- a/services/core/internal/items/events.go +++ b/services/core/internal/items/events.go @@ -31,6 +31,10 @@ func Events(change Change, outputIndex *int32) []v1.SessionEvent { textMessage := item.Type == "message" && item.Role == "assistant" && len(item.Content) == 1 && item.Content[0].Text != nil if previous.ID == "" { initial := item + if item.Type == "reasoning" { + // Reasoning text arrives only through summary events. + initial.Status, initial.Summary = "in_progress", []v1.SummaryText{} + } if textMessage { // Assistant text is added empty and in progress; its text arrives // only through deltas, as in official streams (EVT-10). @@ -55,6 +59,32 @@ func Events(change Change, outputIndex *int32) []v1.SessionEvent { if outputIndex == nil { return events } + if item.Type == "reasoning" { + // The summary is one part, framed like assistant text. + text, zero := summaryText(item), int64(0) + event := base + event.ItemID, event.SummaryIndex = item.ID, &zero + if text != "" && summaryText(previous) == "" { + empty := "" + event.Part = &v1.ItemContent{Type: "summary_text", Text: &empty} + emit("reasoning_summary_part.added", event) + event.Part = nil + if delta == nil { + delta = &text + } + } + if delta != nil && *delta != "" { + event.Delta = delta + emit("reasoning_summary_text.delta", event) + event.Delta = nil + } + if item.Status != "in_progress" && text != "" { + event.Text = &text + emit("reasoning_summary_text.done", event) + event.Text, event.Part = nil, &v1.ItemContent{Type: "summary_text", Text: &text} + emit("reasoning_summary_part.done", event) + } + } if item.Type == "command_execution" && delta != nil { event := base event.Type = "agent.output.command_execution_output.delta" diff --git a/services/core/internal/items/events_test.go b/services/core/internal/items/events_test.go index 2053d739e..1f91b3ba8 100644 --- a/services/core/internal/items/events_test.go +++ b/services/core/internal/items/events_test.go @@ -6,6 +6,7 @@ import ( "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func assistantText(status, text string) v1.Item { @@ -113,3 +114,56 @@ func TestEventsAddAssistantTextEmptyAndInProgress(t *testing.T) { t.Fatalf("added %+v, part %+v", added, events[1].Part) } } + +func TestReasoningObservationsBecomeOneItemWithOfficialEvents(t *testing.T) { + index := int32(0) + var stored v1.Item + var kinds, deltas []string + for _, frame := range []proto.ReasoningPayload{ + {ID: "r1", Status: "in_progress", Delta: "think "}, + {ID: "r1", Status: "in_progress", Delta: "hard"}, + {ID: "r1", Status: "completed"}, + } { + raw, _ := json.Marshal(frame) + updates, err := Project(testTurn, proto.TypeReasoning, 1, raw) + if err != nil || len(updates) != 1 { + t.Fatalf("project %+v: %v %v", frame, updates, err) + } + change, ok, err := Observe(proto.TypeReasoning, updates[0], Stored{Item: stored}) + if err != nil || !ok { + t.Fatalf("observe %+v: %v %v", frame, ok, err) + } + for _, event := range Events(change, &index) { + kinds = append(kinds, event.Type[len("agent.session.turn."):]) + if event.Delta != nil { + deltas = append(deltas, *event.Delta) + } + if event.Type == "agent.session.turn.reasoning_summary_text.done" && *event.Text != "think hard" { + t.Fatalf("done text %q", *event.Text) + } + } + stored = change.Item + } + wantKinds := []string{"item.added", "reasoning_summary_part.added", "reasoning_summary_text.delta", "reasoning_summary_text.delta", "reasoning_summary_text.done", "reasoning_summary_part.done", "item.done"} + if !reflect.DeepEqual(kinds, wantKinds) || !reflect.DeepEqual(deltas, []string{"think ", "hard"}) { + t.Fatalf("events %q deltas %q", kinds, deltas) + } + if stored.Type != "reasoning" || stored.Status != "completed" || len(stored.Summary) != 1 || stored.Summary[0].Text != "think hard" { + t.Fatalf("item %+v", stored) + } +} + +func TestOpenReasoningEndsIncompleteWithItsText(t *testing.T) { + text := "partial" + open := v1.Item{ID: "item", TurnID: testTurn, Type: "reasoning", Status: "in_progress", Summary: []v1.SummaryText{{Type: "summary_text", Text: text}}} + ended := open + ended.Status = "incomplete" + index := int32(0) + var kinds []string + for _, event := range Events(Change{Previous: open, Item: ended}, &index) { + kinds = append(kinds, event.Type[len("agent.session.turn."):]) + } + if want := []string{"reasoning_summary_text.done", "reasoning_summary_part.done", "item.done"}; !reflect.DeepEqual(kinds, want) { + t.Fatalf("events %q, want %q", kinds, want) + } +} diff --git a/services/core/internal/items/messages.go b/services/core/internal/items/messages.go index 2c4d0cd5f..624e9adba 100644 --- a/services/core/internal/items/messages.go +++ b/services/core/internal/items/messages.go @@ -62,6 +62,19 @@ func Project(turn, kind string, sequence int64, raw json.RawMessage) ([]Update, item.Phase = p.Phase } return []Update{{Item: item, AppendText: p.Text == nil}}, nil + case proto.TypeReasoning: + var p proto.ReasoningPayload + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + if p.ID == "" || (p.Status != "in_progress" && p.Status != "completed" && p.Status != "incomplete") { + return nil, errors.New("invalid reasoning observation") + } + item := v1.Item{ID: Identity(turn, "reasoning:"+p.ID), TurnID: turn, Type: "reasoning", Status: p.Status} + if p.Delta != "" { + item.Summary = []v1.SummaryText{{Type: "summary_text", Text: p.Delta}} + } + return []Update{{Item: item, AppendText: true}}, nil case proto.TypeDone, "execution_failed": // Done may contain adapter diagnostics. Only a successful Turn // confirms aggregate answer text; failures retain observed message deltas. @@ -99,6 +112,14 @@ func Project(turn, kind string, sequence int64, raw json.RawMessage) ([]Update, } } +// summaryText is the text of a reasoning Item's single summary part. +func summaryText(item v1.Item) string { + if len(item.Summary) == 0 { + return "" + } + return item.Summary[0].Text +} + func Merge(update Update, previous v1.Item) (v1.Item, error) { if update.CommandOutputDelta != nil { return mergeCommandOutput(update, previous) @@ -117,8 +138,11 @@ func Merge(update Update, previous v1.Item) (v1.Item, error) { item.Output = previous.Output } if update.AppendText { - if previous.Status != "in_progress" { - return previous, nil + if item.Type == "reasoning" { + if text := summaryText(previous) + summaryText(item); text != "" { + item.Summary = []v1.SummaryText{{Type: "summary_text", Text: text}} + } + return item, nil } text := *previous.Content[0].Text + *item.Content[0].Text item.Content = slices.Clone(item.Content) From 552bf16573ef78a58d65cadce3811df8d1460fcb Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 23 Oct 2026 09:25:37 +0000 Subject: [PATCH 2/2] Keep native reasoning parts and the part status Each native reasoning part becomes its own summary_text part, reasoning_summary_part.done always carries its nullable status, native reasoning without text is not reported, MiniMax Code child Turns report none, and one validator defines a reasoning frame. --- .../internal/agent/claudesdk/executor_turn.go | 3 +- apps/daemon/internal/agent/codex/session.go | 35 ++++++----- .../internal/agent/codex/session_items.go | 8 +-- .../agent/codex/session_items_test.go | 2 +- .../agent/codex/subagent_profile_test.go | 17 ++++++ .../agent/codex/subagent_snapshots.go | 13 ++-- .../internal/agent/mcode/subagent_messages.go | 5 -- apps/daemon/internal/agent/mcode/subagents.go | 5 +- .../internal/agent/mcode/subagents_test.go | 5 +- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/index.md | 2 +- contracts/agents-api/sessions-events.md | 4 +- contracts/agents-api/v1/events.go | 11 +++- contracts/agents-api/v1/events_test.go | 2 + contracts/agents-api/zh/harness-onboarding.md | 4 +- contracts/agents-api/zh/index.md | 4 +- contracts/agents-api/zh/sessions-events.md | 6 +- docs/runtime-protocol.md | 2 +- docs/zh/runtime-protocol.md | 4 +- internal/agentdaemon/proto/inbound.go | 11 +++- .../harnessconfig/claudesdk/declaration.go | 5 +- packages/claude-sdk-adapter/src/messages.ts | 20 ++++--- .../tests/messages.test.mjs | 7 ++- services/core/deploy/codex/README.md | 2 +- services/core/internal/items/changes.go | 4 -- services/core/internal/items/events.go | 59 ++++++++++++------- services/core/internal/items/events_test.go | 28 +++++---- services/core/internal/items/messages.go | 31 ++++++---- 28 files changed, 189 insertions(+), 112 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index d7db8dc7a..418de6b14 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -124,8 +124,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { emit(proto.TypeOutputMessage, message) case "reasoning": reasoning := event.Reasoning - if !start.ReasoningSummary || reasoning == nil || reasoning.ID == "" || - (reasoning.Status != "in_progress" && reasoning.Status != "completed") { + if !start.ReasoningSummary || reasoning == nil || !reasoning.Valid() { failure = fmt.Errorf("claudesdk: invalid reasoning observation") s.invalidate() break diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index b6130cbb2..e844581f0 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -145,8 +145,8 @@ func (s *Session) onTurnStarted(raw json.RawMessage) { } // onReasoningDelta streams the first native channel a reasoning item delivers: -// its summary, or the raw reasoning text of a model that has no summary. A new -// part of the same channel starts a new paragraph. +// its summary, or the raw reasoning text of a model that has no summary. Each +// native part is reported as its own part. func (s *Session) onReasoningDelta(channel string, raw json.RawMessage) { var p ReasoningDeltaNotification if err := json.Unmarshal(raw, &p); err != nil { @@ -155,20 +155,20 @@ func (s *Session) onReasoningDelta(channel string, raw json.RawMessage) { if !s.reasoningSummary || !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { return } - index := p.ContentIndex + native := p.ContentIndex if channel == "summary" { - index = p.SummaryIndex + native = p.SummaryIndex } stream, started := s.bufs.Reasoning[p.ItemID] if started && stream.channel != channel { return } - delta := p.Delta - if started && stream.index != index { - delta = "\n\n" + delta + if started && stream.native != native { + stream.part++ } - s.bufs.Reasoning[p.ItemID] = reasoningStream{channel: channel, index: index} - s.sendReasoning(p.ItemID, "in_progress", delta) + stream.channel, stream.native = channel, native + s.bufs.Reasoning[p.ItemID] = stream + s.sendReasoning(p.ItemID, "in_progress", stream.part, p.Delta) } // completeReasoning ends a reasoning item. An item that streamed nothing is @@ -180,17 +180,22 @@ func (s *Session) completeReasoning(item ThreadItem) { _, streamed := s.bufs.Reasoning[item.ID] delete(s.bufs.Reasoning, item.ID) if !streamed { - body := strings.Join(nativeReasoningParts(item), "\n\n") - if body == "" { + part := 0 + for _, text := range nativeReasoningParts(item) { + if text != "" { + s.sendReasoning(item.ID, "in_progress", part, text) + part++ + } + } + if part == 0 { return } - s.sendReasoning(item.ID, "in_progress", body) } - s.sendReasoning(item.ID, "completed", "") + s.sendReasoning(item.ID, "completed", 0, "") } -func (s *Session) sendReasoning(id, status, delta string) { - env, err := proto.NewEnvelope(proto.TypeReasoning, s.runID, proto.ReasoningPayload{ID: id, Status: status, Delta: delta}) +func (s *Session) sendReasoning(id, status string, part int, delta string) { + env, err := proto.NewEnvelope(proto.TypeReasoning, s.runID, proto.ReasoningPayload{ID: id, Status: status, Index: part, Delta: delta}) if err == nil { s.trySend(env) } diff --git a/apps/daemon/internal/agent/codex/session_items.go b/apps/daemon/internal/agent/codex/session_items.go index 3530c28d7..c1cd6a3cf 100644 --- a/apps/daemon/internal/agent/codex/session_items.go +++ b/apps/daemon/internal/agent/codex/session_items.go @@ -16,11 +16,11 @@ type ItemBuffers struct { AgentText map[string]string } -// reasoningStream is the native channel a reasoning item streams and the last -// part index seen on it. +// reasoningStream is the native channel a reasoning item streams, the last +// native part index seen on it and the reported part it maps to. type reasoningStream struct { - channel string - index int + channel string + native, part int } // NewItemBuffers returns an empty buffer set. diff --git a/apps/daemon/internal/agent/codex/session_items_test.go b/apps/daemon/internal/agent/codex/session_items_test.go index f5bce595f..95ce60d56 100644 --- a/apps/daemon/internal/agent/codex/session_items_test.go +++ b/apps/daemon/internal/agent/codex/session_items_test.go @@ -92,7 +92,7 @@ func TestReasoningIsReportedOnlyWhenRequested(t *testing.T) { } } want := []proto.ReasoningPayload{ - {ID: "r1", Status: "in_progress", Delta: "plan "}, {ID: "r1", Status: "in_progress", Delta: "\n\nnext"}, {ID: "r1", Status: "completed"}, + {ID: "r1", Status: "in_progress", Delta: "plan "}, {ID: "r1", Status: "in_progress", Index: 1, Delta: "next"}, {ID: "r1", Status: "completed"}, {ID: "r2", Status: "in_progress", Delta: "whole"}, {ID: "r2", Status: "completed"}, } if !requested { diff --git a/apps/daemon/internal/agent/codex/subagent_profile_test.go b/apps/daemon/internal/agent/codex/subagent_profile_test.go index 6b0611505..ae96346b0 100644 --- a/apps/daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/daemon/internal/agent/codex/subagent_profile_test.go @@ -74,3 +74,20 @@ func TestSubagentItemSnapshotSkipsUnmappedItemTypes(t *testing.T) { } } } + +func TestSubagentReasoningIsReportedOnlyWhenRequestedWithText(t *testing.T) { + for _, test := range []struct { + item string + requested bool + want string + }{ + {`{"type":"reasoning","id":"r1","summary":["plan","next"]}`, true, `{"status":"completed","summary":[{"text":"plan","type":"summary_text"},{"text":"next","type":"summary_text"}]}`}, + {`{"type":"reasoning","id":"r1","summary":["plan"]}`, false, ""}, + {`{"type":"reasoning","id":"r1"}`, true, ""}, + } { + kind, body, _, err := subagentItemSnapshot(json.RawMessage(test.item), "completed", test.requested) + if err != nil || string(body) != test.want || (kind == "") != (test.want == "") { + t.Fatal(test.item, test.requested, kind, string(body), err) + } + } +} diff --git a/apps/daemon/internal/agent/codex/subagent_snapshots.go b/apps/daemon/internal/agent/codex/subagent_snapshots.go index 4c03b7a2a..2dcca64ad 100644 --- a/apps/daemon/internal/agent/codex/subagent_snapshots.go +++ b/apps/daemon/internal/agent/codex/subagent_snapshots.go @@ -298,10 +298,15 @@ func subagentItemSnapshot(raw json.RawMessage, turnStatus string, reasoningSumma if json.Unmarshal(raw, &source) != nil { return "", nil, "", errors.New("codex: invalid child reasoning") } - parts := nativeReasoningParts(source) - summary := make([]map[string]string, 0, len(parts)) - for _, text := range parts { - summary = append(summary, map[string]string{"type": "summary_text", "text": text}) + var summary []map[string]string + for _, text := range nativeReasoningParts(source) { + if text != "" { + summary = append(summary, map[string]string{"type": "summary_text", "text": text}) + } + } + if len(summary) == 0 { + // A reasoning item with no text is not reported, as in the root projection. + return "", nil, "", nil } status := "in_progress" if turnStatus == "completed" { diff --git a/apps/daemon/internal/agent/mcode/subagent_messages.go b/apps/daemon/internal/agent/mcode/subagent_messages.go index d906e5bf7..a4c9cbabb 100644 --- a/apps/daemon/internal/agent/mcode/subagent_messages.go +++ b/apps/daemon/internal/agent/mcode/subagent_messages.go @@ -34,11 +34,6 @@ func (s *Session) projectSubagentMessages(session nativeSubagentSession, turn na if message.Role != "assistant" { continue } - if message.Data.Thinking != "" { - if err := emit(message.ID+":reasoning", "reasoning", map[string]any{"status": "completed", "summary": []map[string]string{{"type": "summary_text", "text": message.Data.Thinking}}}); err != nil { - return err - } - } if message.Data.Text != "" { text := message.Data.Text if err := emit(message.ID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: message.ID, Status: "completed", Text: &text}); err != nil { diff --git a/apps/daemon/internal/agent/mcode/subagents.go b/apps/daemon/internal/agent/mcode/subagents.go index 56ea16303..57c5a5749 100644 --- a/apps/daemon/internal/agent/mcode/subagents.go +++ b/apps/daemon/internal/agent/mcode/subagents.go @@ -35,9 +35,8 @@ type nativeSubagentMessage struct { ID, TurnID, Role string CreatedAt int64 Data struct { - Text string `json:"msg_content"` - Thinking string `json:"thinking_content"` - Tools []nativeSubagentTool `json:"tool_calls"` + Text string `json:"msg_content"` + Tools []nativeSubagentTool `json:"tool_calls"` } } type nativeSubagentTool struct { diff --git a/apps/daemon/internal/agent/mcode/subagents_test.go b/apps/daemon/internal/agent/mcode/subagents_test.go index 7761d5024..e82e4f3b9 100644 --- a/apps/daemon/internal/agent/mcode/subagents_test.go +++ b/apps/daemon/internal/agent/mcode/subagents_test.go @@ -40,7 +40,8 @@ func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { } first := project(childSnapshot(t)) second := project(childSnapshot(t)) - if len(first) != 6 { + // Native thinking is not reported: MiniMax Code admits no reasoning summary. + if len(first) != 5 { t.Fatalf("got %d events", len(first)) } for i, event := range first { @@ -48,7 +49,7 @@ func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { t.Fatal("cold snapshot identities changed") } } - if first[0].Type != proto.TypeSubagentIdentity || first[1].Type != proto.TypeSubagentTurn || first[5].Type != proto.TypeSubagentTurn { + if first[0].Type != proto.TypeSubagentIdentity || first[1].Type != proto.TypeSubagentTurn || first[4].Type != proto.TypeSubagentTurn { t.Fatal("identity/turn/item ordering changed") } var input proto.SubagentItemPayload diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index ee9504b77..df391130f 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -216,7 +216,7 @@ The daemon's `clirunner` starts every native child in its own Unix process group Owned output pipes stay readable after the leader exits. Consumers drain stdout and stderr before calling `Wait`, which joins the cached process result and closes the readers. `Done` reports leader reaping and group cleanup signals; it is not a native execution receipt or proof of persisted history. SDK adapters settle each Turn and drain its observations before publishing completion, and Executor close also closes the query and awaits the native child. Process groups are lifecycle supervision, not isolation or containment of descendants that leave the group. -Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; the adapter also disables its blocking `request_user_input` tool and sets `model_reasoning_summary` to `none` when the Agent's `reasoning.summary` is null, because the official default is no summary and Codex's own default is model-dependent. Claude runs through the adapter's tool callback, which allows or denies without asking. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. +Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. An unreachable model host must not hold a Turn open. The adapter keeps the Harness's own connection retries bounded through native settings, and the Harness's final connection error becomes `connection_failed` when the Harness reports it as structured data ([native failure classification](../../docs/runtime-protocol.md#native-failure-classification)). Core adds no model timeout of its own. Each adapter guide states its Harness's bound. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 19d7adc41..69e35d493 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -94,6 +94,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Empty input returns 400 `invalid_request` with a generic message and a null param; the official response is `invalid_request_error` with param `input`. - Session usage is available as soon as every root Turn has settled; official reads lag by seconds. - U+0000 in Harness output, such as command output, reads as U+FFFD; the official service keeps it. +- A `reasoning` Item carries what the Harness shows of its native reasoning: its native summary when it produces one, otherwise its native reasoning text, such as Codex raw reasoning or the thinking text the model API returns to Claude Code. Each native part is one `summary_text` part, and native reasoning without text is not reported, so Claude Code with the model provider's `thinking.display` `omitted` reports none. Codex streams the first native channel an item delivers, its summary or its raw reasoning text. **Files, Skills, Environment files and Artifacts** ([Files and Skills](./source-files.md), [Environment files and Artifacts](./environment-files.md)) @@ -123,7 +124,6 @@ Each item is Core's deliberate or native behavior where the official service beh **Execution and history** -- A `reasoning` Item's `summary_text` carries the Harness's native reasoning summary when it produces one, otherwise its full native reasoning text, such as raw model reasoning or Claude Code thinking blocks. Claude Code admits only `reasoning.summary` `auto`, and Codex streams the first native channel an item delivers, its summary or its raw reasoning text. - The stream does not emit Environment `pending` or `ready` events, or every pinned interim tool-output variant. - Native Item variants beyond those listed under [Turns and Items](./sessions-events.md#turns-and-items) are not projected, and Items cannot be modified. - A function result that cancellation prevents from being applied never appears as an Item. diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index b1d447bc4..9c5ccd37c 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -119,7 +119,7 @@ A retry with the same `Idempotency-Key` and `stream: true` returns 201 with only - `item.added` and `item.done` always carry `output_index`, null for input Items. Function results emit `item.added` only; `item.done` is for agent output. - An assistant message follows one sequence: `item.added` in progress with empty `content`, `content_part.added` with empty text, `output_text.delta` events, `output_text.done`, `content_part.done` and `item.done`. A message first observed complete, such as structured output, sends its whole text in one `output_text.delta`, byte for byte. The completed text replaces the accumulated deltas. - Codex command output streams as `agent.output.command_execution_output.delta` with the command's Item ID and output index. Native output quotas and text conversion apply, so the deltas are not a byte-exact capture; the completed Item is authoritative. -- A reasoning Item follows one sequence: `item.added` in progress with an empty `summary`, `reasoning_summary_part.added` with empty text when its first text arrives, `reasoning_summary_text.delta` events, `reasoning_summary_text.done`, `reasoning_summary_part.done` and `item.done`. A root Turn's Item has one `summary_text` part, `summary_index` 0. An Item that ends without text has an empty `summary` and gets only `item.added` and `item.done`. +- A reasoning Item follows one sequence: `item.added` in progress with an empty `summary`; for each `summary_text` part in order, `reasoning_summary_part.added` with empty text, `reasoning_summary_text.delta` events, then `reasoning_summary_text.done` and `reasoning_summary_part.done` once the next part starts or the Item ends; then `item.done`. `reasoning_summary_part.done` has `status` `incomplete` for a part a cancelled or failed Turn cut off, otherwise null. A child Turn snapshot that rewrites earlier text reports it only in the done events. - A cancelled or failed Turn marks its unfinished Items `incomplete` and keeps their partial content. - A function call stays in `required_actions` until the Harness applies its result, or cancellation or the Turn's end removes it. A repeated notification emits no new state. @@ -138,7 +138,7 @@ Turn and Item lists take `after`, `limit` and `order` ([list rules](./wire-seman | `mcp_call` | Server and tool identity, arguments, structured result or error | | `function_call`, `function_call_output` | A linked call and its result. The result always carries `output` and `error`, null when the submission omitted them; stored results keep the submitted field presence. Native file changes appear as an `apply_patch` function call with the changes as arguments and no invented result | | `web_search_call` | The supported action fields (`search`, `open_page`, `find_in_page`, `other`); Claude Code reports `search` for `WebSearch` and `open_page` for `WebFetch` | -| `reasoning` | One native reasoning item as a single `summary_text` part, present only when the Agent sets `reasoning.summary`; with it null (the default) no `reasoning` Items or reasoning events appear for any Turn, root or child. The text is the Harness's native summary when it produces one, otherwise its native reasoning text. Status can be null on a child Turn's Item | +| `reasoning` | One native reasoning item, each native part one `summary_text` part, present only when the Agent sets `reasoning.summary`; with it null (the default) no `reasoning` Items or reasoning events appear for any Turn, root or child. [OpenAI compatibility](./index.md#differences-from-openai) describes the text. Status can be null on a child Turn's Item | | `agent_message` and the Subagent coordination calls | See [Subagents](./subagents.md). `agent_message` has no status | A failed tool does not fail its Turn. Tool output is readable by the Session's Project and can contain the tool's own diagnostic text. diff --git a/contracts/agents-api/v1/events.go b/contracts/agents-api/v1/events.go index 03205874d..b51d75375 100644 --- a/contracts/agents-api/v1/events.go +++ b/contracts/agents-api/v1/events.go @@ -21,8 +21,9 @@ func itemEvent(eventType string) bool { // MarshalJSON keeps the nullable top-level usage on terminal Turn events only, // a nullable output_index on every Item event (EVT-09), a nullable error param -// on error events and a nullable turn_id on Environment events. Subagent events -// carry no session_id; their Subagent names the Session. +// on error events, a nullable status on reasoning summary part completions and +// a nullable turn_id on Environment events. Subagent events carry no +// session_id; their Subagent names the Session. func (e SessionEvent) MarshalJSON() ([]byte, error) { type wire SessionEvent switch { @@ -43,6 +44,12 @@ func (e SessionEvent) MarshalJSON() ([]byte, error) { wire OutputIndex *int32 `json:"output_index"` }{wire(e), e.OutputIndex}) + case e.Type == "agent.session.turn.reasoning_summary_part.done": + e.Usage = nil + return json.Marshal(struct { + wire + Status *string `json:"status"` + }{wire(e), e.Status}) case strings.HasPrefix(e.Type, "agent.session.environment."): e.Usage = nil var turn *string diff --git a/contracts/agents-api/v1/events_test.go b/contracts/agents-api/v1/events_test.go index 5ede7e496..fa6508730 100644 --- a/contracts/agents-api/v1/events_test.go +++ b/contracts/agents-api/v1/events_test.go @@ -60,6 +60,8 @@ func TestSessionEventWireFields(t *testing.T) { {SessionEvent{Type: "agent.session.environment.failed", EventID: "event", Environment: &SessionEnvironmentState{ID: "environment", Type: "openai_hosted", Status: "failed", Error: &StreamError{Type: "environment_error", Code: "environment_connection_failed", Message: "The environment failed to connect."}}}, `{"type":"agent.session.environment.failed","event_id":"event","environment":{"id":"environment","type":"openai_hosted","status":"failed","error":{"code":"environment_connection_failed","type":"environment_error","message":"The environment failed to connect."}},"turn_id":null}`}, + {SessionEvent{Type: "agent.session.turn.reasoning_summary_part.done", EventID: "event", SessionID: "session", TurnID: "turn", ItemID: "item", OutputIndex: new(int32), SummaryIndex: new(int64), Part: &ItemContent{Type: "summary_text", Text: new(string)}}, + `{"type":"agent.session.turn.reasoning_summary_part.done","event_id":"event","session_id":"session","turn_id":"turn","item_id":"item","output_index":0,"part":{"type":"summary_text","text":""},"summary_index":0,"status":null}`}, {SessionEvent{Type: "agent.session.subagent.closed", EventID: "event", SessionID: "session", Subagent: &Subagent{ID: "subagent", Object: "agent.session.subagent", SessionID: "session", ParentAgentID: "root", Status: "closed"}}, `{"subagent":{"id":"subagent","object":"agent.session.subagent","session_id":"session","parent_agent_id":"root","opened_at":0,"closed_at":null,"name":null,"instructions":null,"status":"closed"},"type":"agent.session.subagent.closed","event_id":"event"}`}, } { diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 2b3ee661b..3d7db2da5 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: 7e37200557300648fcbd4375df502c6dfb0dc65b7e878833670af0c984081aec +source_hash: 8beb6af27e99a4bf25a2e828d1b2c1c51adcd7b597f3cd64a211396a506a09c0 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -218,7 +218,7 @@ daemon 的 `clirunner` 让每个原生子进程在自己的 Unix 进程组中启 所属输出管道在主进程退出后仍可读取。消费者在调用 `Wait` 之前耗尽 stdout 和 stderr;`Wait` 会汇合缓存的进程结果并关闭读取器。`Done` 报告主进程回收和进程组清理信号;它不是原生执行回执,也不是历史已持久化的证据。SDK 适配器会结算每个 Turn,并在发布完成状态前耗尽其观察结果;Executor 关闭还会关闭 Query 并等待原生子进程。进程组用于生命周期监管,而不是隔离或遏制离开进程组的后代进程。 -适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;适配器还禁用其阻塞式 `request_user_input` 工具,并在 Agent 的 `reasoning.summary` 为 null 时将 `model_reasoning_summary` 设为 `none`,因为官方默认不返回摘要,而 Codex 自己的默认值取决于模型。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 +适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 模型主机不可达时不能让 Turn 一直挂起。适配器通过原生设置让 Harness 自身的连接重试保持有界;Harness 以结构化数据报告最终连接错误时,该错误归为 `connection_failed`([原生失败分类](../../../docs/zh/runtime-protocol.md#native-failure-classification))。Core 不另设模型超时。各适配器指南写明其 Harness 的上限。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 4fa5e7685..a5017f184 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 90e786b565e819d9c1396e7182c11da1f66f7213398e3c166d2b0713fd072e97 +source_hash: 2936067de4c8baa0ff06ff5175d23d3c92a65de758bd656f4bbffd60529f41b6 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -97,6 +97,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 空输入会返回 400 `invalid_request`,附带通用消息和值为 null 的 param;官方响应为 `invalid_request_error`,param 为 `input`。 - 一旦所有根级 Turn 均已进入终态,Session 用量即可用;官方读取会滞后数秒。 - Harness 输出(例如命令输出)中的 U+0000 读取时为 U+FFFD;官方服务会保留该字符。 +- `reasoning` Item 携带 Harness 对其原生推理所展示的内容:Harness 产生原生摘要时为该摘要,否则为其原生推理文本,例如 Codex 的原始推理,或模型 API 返回给 Claude Code 的 thinking 文本。每个原生部分对应一个 `summary_text` 部分,没有文本的原生推理不会上报,因此模型提供方的 `thinking.display` 为 `omitted` 时,Claude Code 不上报推理。Codex 流式输出一个 item 先送达的原生通道,即摘要或原始推理文本。 **Files、Skills、Environment files 和 Artifacts**([Files and Skills](source-files.md)、[Environment files and Artifacts](environment-files.md)) @@ -126,7 +127,6 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **执行和历史** -- `reasoning` Item 的 `summary_text` 携带 Harness 产生的原生推理摘要,没有摘要时携带完整的原生推理文本,例如模型原始推理或 Claude Code 的 thinking 块。Claude Code 只接受 `reasoning.summary` 为 `auto`,Codex 流式输出一个 item 先送达的原生通道,即摘要或原始推理文本。 - 流不会发出 Environment 的 `pending` 或 `ready` 事件,也不会覆盖固定版本中的所有临时 tool-output 变体。 - 除 [Turns and Items](sessions-events.md#turns-and-items) 中列出的变体外,其他原生 Item 变体不会被投影,而且 Items 无法修改。 - 如果取消导致函数结果无法应用,该结果将永远不会作为 Item 出现。 diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index e86853e33..c1314349a 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: 99dcffc015fee1be5e83f8be5399c827d4f263f098ec6f173b9153522e9ac26d +source_hash: 9e02f5fc7ace233f2a17cd7d8fa53447794b95430bf7bf219b66ece33f96ca35 --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -121,7 +121,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 - `item.added` 和 `item.done` 始终携带 `output_index`;输入 Item 的该值为 null。函数结果只发出 `item.added`;`item.done` 用于代理输出。 - 助手消息遵循以下单一顺序:先发送 `content` 为空的进行中 `item.added`,再发送文本为空的 `content_part.added`,然后发送 `output_text.delta` 事件、`output_text.done`、`content_part.done` 和 `item.done`。首次观察时已完整的消息(例如结构化输出)会在一个 `output_text.delta` 中逐字节发送全部文本。完整文本会替换此前累积的增量。 - Codex 命令输出以 `agent.output.command_execution_output.delta` 流式传输,并携带命令的 Item ID 和输出索引。原生输出配额和文本转换规则适用,因此这些增量并非逐字节捕获的原始结果;完整 Item 才是权威结果。 -- reasoning Item 遵循以下单一顺序:先发送 `summary` 为空的进行中 `item.added`,首段文本到达时发送文本为空的 `reasoning_summary_part.added`,然后发送 `reasoning_summary_text.delta` 事件、`reasoning_summary_text.done`、`reasoning_summary_part.done` 和 `item.done`。根 Turn 的 Item 只有一个 `summary_text` 部分,`summary_index` 为 0。没有文本就结束的 Item `summary` 为空,只发送 `item.added` 和 `item.done`。 +- reasoning Item 遵循以下单一顺序:先发送 `summary` 为空的进行中 `item.added`;然后按顺序对每个 `summary_text` 部分发送文本为空的 `reasoning_summary_part.added` 和 `reasoning_summary_text.delta` 事件,并在下一个部分开始或 Item 结束时发送 `reasoning_summary_text.done` 和 `reasoning_summary_part.done`;最后发送 `item.done`。被取消或失败的 Turn 截断的部分,其 `reasoning_summary_part.done` 的 `status` 为 `incomplete`,否则为 null。子 Turn 快照改写了先前文本时,新文本只出现在 done 事件中。 - 已取消或失败的 Turn 会将其未完成的 Item 标记为 `incomplete`,并保留其部分内容。 - 函数调用会一直保留在 `required_actions` 中,直到 Harness 应用其结果,或取消操作或 Turn 结束将其移除。重复通知不会发出新状态。 @@ -140,7 +140,7 @@ Turn 和 Item 列表接受 `after`、`limit` 和 `order`([列表规则](wire-s | `mcp_call` | 服务器和工具标识、参数、结构化结果或错误 | | `function_call`、`function_call_output` | 关联的调用及其结果。结果始终携带 `output` 和 `error`;提交时省略这些字段则值为 null。存储的结果会保留提交时字段是否存在的信息。原生文件更改显示为 `apply_patch` 函数调用,更改内容作为参数,不会生成虚构结果 | | `web_search_call` | 支持的操作字段(`search`、`open_page`、`find_in_page`、`other`);Claude Code 以 `search` 报告 `WebSearch`,以 `open_page` 报告 `WebFetch` | -| `reasoning` | 一个原生推理 item,表示为单个 `summary_text` 部分,仅在 Agent 设置了 `reasoning.summary` 时出现;为 null(默认)时,任何 Turn(根或子)都没有 `reasoning` Item 和 reasoning 事件。文本是 Harness 产生的原生摘要,没有摘要时是其原生推理文本。子 Turn 的 Item 状态可以为 null | +| `reasoning` | 一个原生推理 item,每个原生部分对应一个 `summary_text` 部分,仅在 Agent 设置了 `reasoning.summary` 时出现;为 null(默认)时,任何 Turn(根或子)都没有 `reasoning` Item 和 reasoning 事件。文本内容见 [OpenAI 兼容性](index.md#differences-from-openai)。子 Turn 的 Item 状态可以为 null | | `agent_message` 和 Subagent 协调调用 | 见 [Subagents](subagents.md)。`agent_message` 没有状态 | 工具失败不会导致其 Turn 失败。Session 的项目可以读取工具输出,其中可能包含该工具自己的诊断文本。 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index aa3a71e4c..0b4a4effe 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -120,7 +120,7 @@ A `function_call` carries `native_id` and `turn_id` when a Subagent made the cal A `function_call_withdrawn` carries the `call_id`, `native_id` and `turn_id` of a `function_call` and tells that the native Harness will never apply a result for it, for example because a Codex Subagent Turn ended and native dropped its request. Core stops requiring an answer for the call and rejects a later result for it. The adapter acknowledges a `function_result` already in flight with `not_pending`, and Core then waits for the withdrawal or the Turn's end instead of failing the Turn or resending the result. -A `reasoning` frame reports one native reasoning item by its native `id`. A frame with status `in_progress` carries the next text fragment in `delta`, and a frame with status `completed` or `incomplete` ends the item and carries no text. The text is the Harness's native reasoning summary when it produces one, otherwise its native reasoning text. An adapter sends `reasoning` frames only when `execution_controls` sets a reasoning summary, for the Run and for its Subagents alike. +A `reasoning` frame reports one native reasoning item of the Run by its native `id`. A frame with status `in_progress` carries the next text fragment in `delta` for native part `index`, which counts from 0 and is the current part or the next one; the frame with status `completed` ends the item and carries no text. An adapter reports no reasoning without text, and sends `reasoning` frames, or `reasoning` Subagent Item snapshots, only when `execution_controls` sets a reasoning summary. [OpenAI compatibility](../contracts/agents-api/index.md#differences-from-openai) describes the text. Usage frames and the final usage snapshot each carry the cumulative measurement of the current execution and replace the previous snapshot; never add them. An absent measurement is unknown, not zero. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 095327142..dea8a70f8 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 033ea14b34c596c855004a3ec1ee2e9b675c13ed962bfcc2e5c01d76e5fff4bd +source_hash: f9a66794bab8aca97b88712a67451271ea96d825f35b20288f94c98d42523f1a --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -122,7 +122,7 @@ Subagent 发起调用时,`function_call` 携带 `native_id` 和 `turn_id`, `function_call_withdrawn` 携带某个 `function_call` 的 `call_id`、`native_id` 和 `turn_id`,表示原生 Harness 永远不会为它应用结果,例如 Codex Subagent 的 Turn 已结束且原生丢弃了其请求。Core 不再要求回答该调用,并拒绝之后为它提交的结果。adapter 以 `not_pending` 确认已在传输中的 `function_result`,Core 随后等待撤回或 Turn 结束,既不让 Turn 失败,也不重发该结果。 -`reasoning` frame 按原生 `id` 报告一个原生推理 item。状态为 `in_progress` 的 frame 在 `delta` 中携带下一段文本,状态为 `completed` 或 `incomplete` 的 frame 结束该 item 且不带文本。文本是 Harness 产生的原生推理摘要,没有摘要时是其原生推理文本。只有 `execution_controls` 设置了推理摘要时,适配器才发送 `reasoning` frame,Run 及其 Subagent 都遵循此规则。 +`reasoning` frame 按原生 `id` 报告 Run 的一个原生推理 item。状态为 `in_progress` 的 frame 在 `delta` 中携带原生部分 `index` 的下一段文本,`index` 从 0 开始,取当前部分或下一个部分;状态为 `completed` 的 frame 结束该 item 且不带文本。适配器不上报没有文本的推理,并且只有 `execution_controls` 设置了推理摘要时,才发送 `reasoning` frame 或 `reasoning` Subagent Item 快照。文本内容见 [OpenAI 兼容性](../../contracts/agents-api/zh/index.md#differences-from-openai)。 Usage frame 和最终 usage snapshot 都携带当前执行的累计测量,替换之前的快照;不要相加。缺失的测量表示未知,不是零。 diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index 6a8b8767e..7ce4d2942 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -64,14 +64,21 @@ type OutputMessagePayload struct { Text *string `json:"text,omitempty"` } -// ReasoningPayload reports one native reasoning item. Delta is the next text -// fragment, empty on a frame that only changes Status. +// ReasoningPayload reports one native reasoning item. An in_progress frame +// carries the next text fragment of native part Index, which is the current +// part or the next one; the completed frame carries no text. type ReasoningPayload struct { ID string `json:"id"` Status string `json:"status"` + Index int `json:"index,omitempty"` Delta string `json:"delta,omitempty"` } +// Valid reports whether p is a well-formed reasoning frame. +func (p ReasoningPayload) Valid() bool { + return p.ID != "" && p.Index >= 0 && (p.Status == "in_progress" || (p.Status == "completed" && p.Delta == "")) +} + // ToolCallPayload carries a tool invocation event. Stage is "before" // when the agent is about to call the tool, "after" when the result // is back. diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index 9c52923e9..dbbb5ba0c 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -54,8 +54,9 @@ var Declaration = harnessconfig.Declaration{ }, // The SDK takes effort low to max. Its thinking blocks are the reasoning // text, so a summary request takes only auto; there is no concise or - // detailed setting. It has no verbosity or service tier. Native REPL runs tool calls inside model-written code, outside the root - // tool_use and tool_result records that function receipts and tool observations + // detailed setting. It has no verbosity or service tier. Native REPL runs + // tool calls inside model-written code, outside the root tool_use and + // tool_result records that function receipts and tool observations // read, so enabling programmatic tool calling is rejected. Native WebSearch and // WebFetch are live and have no cached mode or settings: disabled removes them, // and live and an absent control keep them on workspace placements. On none, diff --git a/packages/claude-sdk-adapter/src/messages.ts b/packages/claude-sdk-adapter/src/messages.ts index fdfec9d06..b9a98faa2 100644 --- a/packages/claude-sdk-adapter/src/messages.ts +++ b/packages/claude-sdk-adapter/src/messages.ts @@ -11,7 +11,8 @@ type ActiveMessage = { block?: number; observed: boolean; failed: boolean; - thinking: Set; + // Thinking blocks in progress, and whether each has reported text. + thinking: Map; }; export class MessageObserver { @@ -39,7 +40,7 @@ export class MessageObserver { const event = message.event; if (event.type === "message_start") { if (!event.message.id) throw new Error("missing native message identity"); - this.active = { id: event.message.id, blocks: new Map(), observed: false, failed: false, thinking: new Set() }; + this.active = { id: event.message.id, blocks: new Map(), observed: false, failed: false, thinking: new Map() }; return []; } const active = this.active; @@ -47,9 +48,10 @@ export class MessageObserver { if (event.type === "content_block_start") { active.block = event.index; if (event.content_block.type === "thinking" && this.reasoning) { - active.thinking.add(event.index); - const id = `${active.id}:${event.index}`; - return [{ type: "reasoning", reasoning: { id, status: "in_progress", ...(event.content_block.thinking ? { delta: event.content_block.thinking } : {}) } }]; + // A block reports nothing until its first text, so one without text is not reported. + const delta = event.content_block.thinking; + active.thinking.set(event.index, !!delta); + return delta ? [{ type: "reasoning", reasoning: { id: `${active.id}:${event.index}`, status: "in_progress", delta } }] : []; } if (event.content_block.type !== "text") return []; active.blocks.set(event.index, event.content_block.text); @@ -68,11 +70,15 @@ export class MessageObserver { return event.delta.text ? [{ type: "delta", item_id: active.id, delta: event.delta.text }] : []; } if (event.type === "content_block_delta" && event.delta.type === "thinking_delta" && active.thinking.has(event.index)) { - return event.delta.thinking ? [{ type: "reasoning", reasoning: { id: `${active.id}:${event.index}`, status: "in_progress", delta: event.delta.thinking } }] : []; + if (!event.delta.thinking) return []; + active.thinking.set(event.index, true); + return [{ type: "reasoning", reasoning: { id: `${active.id}:${event.index}`, status: "in_progress", delta: event.delta.thinking } }]; } if (event.type === "content_block_stop") { active.block = undefined; - if (active.thinking.delete(event.index)) return [{ type: "reasoning", reasoning: { id: `${active.id}:${event.index}`, status: "completed" } }]; + const reported = active.thinking.get(event.index); + active.thinking.delete(event.index); + if (reported) return [{ type: "reasoning", reasoning: { id: `${active.id}:${event.index}`, status: "completed" } }]; } if (event.type === "message_stop") { this.active = undefined; diff --git a/packages/claude-sdk-adapter/tests/messages.test.mjs b/packages/claude-sdk-adapter/tests/messages.test.mjs index 23ec445d6..a0cef6fb1 100644 --- a/packages/claude-sdk-adapter/tests/messages.test.mjs +++ b/packages/claude-sdk-adapter/tests/messages.test.mjs @@ -91,15 +91,16 @@ test("unmatched text cannot acquire an invented identity", () => { assert.throws(() => observer.consume(snapshot("other", "wrong")), /unmatched/); }); -test("thinking blocks are reasoning only when summaries are requested", () => { +test("thinking blocks with text are reasoning only when summaries are requested", () => { const think = [start("native"), stream({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }), stream({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hmm " } }), - stream({ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig" } }), blockStop(0), stop()]; + stream({ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig" } }), blockStop(0), + stream({ type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "" } }), + stream({ type: "content_block_delta", index: 1, delta: { type: "signature_delta", signature: "sig" } }), blockStop(1), stop()]; const off = new MessageObserver(); assert.deepEqual(think.flatMap(message => off.consume(message)), []); const observer = new MessageObserver(true); assert.deepEqual(think.flatMap(message => observer.consume(message)), [ - { type: "reasoning", reasoning: { id: "native:0", status: "in_progress" } }, { type: "reasoning", reasoning: { id: "native:0", status: "in_progress", delta: "hmm " } }, { type: "reasoning", reasoning: { id: "native:0", status: "completed" } }, ]); diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index a1056a111..72c7d2af6 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -23,7 +23,7 @@ The adapter applies Core's typed controls and opt-ins, described in the [Runtime - **`environment: none`.** The daemon forces `CODEX_EXEC_SERVER_URL=none` and confirms that Codex reports its `local` and `remote` environments as unknown before it starts or resumes a thread; a binary that cannot do this fails closed. The engine process runs on the bound device, which is not a user execution environment. - **Web search.** The control becomes Codex's `web_search` option (`disabled`, `cached` or `live`) with `tools.web_search` `context_size`, `allowed_domains` and `location`. - **Programmatic tool calling.** `enabled: true` turns on the native `code_mode` feature. An explicit disable turns off the native `code_mode`, `code_mode_only` and `code_mode_prewarm` features and checks managed requirements before a thread starts or resumes, rejecting a conflicting requirement. -- **Reasoning, service tier and verbosity.** `reasoning.effort` and `reasoning.summary` become `model_reasoning_effort` and `model_reasoning_summary`, a non-`auto` service tier becomes `service_tier` and the text verbosity becomes `model_verbosity`. +- **Reasoning, service tier and verbosity.** `reasoning.effort` and `reasoning.summary` become `model_reasoning_effort` and `model_reasoning_summary`, a null `reasoning.summary`, the official default, becomes `none` because Codex's own default depends on the model, a non-`auto` service tier becomes `service_tier` and the text verbosity becomes `model_verbosity`. - **Subagents.** Disabling Subagents turns off both native multi-agent feature generations, `multi_agent` and `multi_agent_v2`, overriding the operator's feature preferences. ## MCP servers diff --git a/services/core/internal/items/changes.go b/services/core/internal/items/changes.go index bcea9a85f..f4b5c8e13 100644 --- a/services/core/internal/items/changes.go +++ b/services/core/internal/items/changes.go @@ -73,10 +73,6 @@ func Observe(kind string, update Update, stored Stored) (Change, bool, error) { switch kind { case proto.TypeDelta: change.Delta = update.Item.Content[0].Text - case proto.TypeReasoning: - if text := summaryText(update.Item); text != "" { - change.Delta = &text - } case proto.TypeCommandOutput: change.Delta = update.CommandOutputDelta } diff --git a/services/core/internal/items/events.go b/services/core/internal/items/events.go index f68fd19c8..463dd6a6e 100644 --- a/services/core/internal/items/events.go +++ b/services/core/internal/items/events.go @@ -2,6 +2,7 @@ package items import ( "reflect" + "strings" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) @@ -60,29 +61,47 @@ func Events(change Change, outputIndex *int32) []v1.SessionEvent { return events } if item.Type == "reasoning" { - // The summary is one part, framed like assistant text. - text, zero := summaryText(item), int64(0) - event := base - event.ItemID, event.SummaryIndex = item.ID, &zero - if text != "" && summaryText(previous) == "" { - empty := "" - event.Part = &v1.ItemContent{Type: "summary_text", Text: &empty} - emit("reasoning_summary_part.added", event) - event.Part = nil - if delta == nil { - delta = &text + // Each summary part is framed like assistant text. A part is done once + // a later part starts or the Item ends. + done := func(summary []v1.SummaryText, status string) int { + if status == "in_progress" && len(summary) > 0 { + return len(summary) - 1 } + return len(summary) } - if delta != nil && *delta != "" { - event.Delta = delta - emit("reasoning_summary_text.delta", event) - event.Delta = nil + closed := 0 + if previous.ID != "" { + closed = done(previous.Summary, previous.Status) } - if item.Status != "in_progress" && text != "" { - event.Text = &text - emit("reasoning_summary_text.done", event) - event.Text, event.Part = nil, &v1.ItemContent{Type: "summary_text", Text: &text} - emit("reasoning_summary_part.done", event) + for i, part := range item.Summary { + index, before := int64(i), "" + event := base + event.ItemID, event.SummaryIndex = item.ID, &index + if i < len(previous.Summary) { + before = previous.Summary[i].Text + } else { + empty := "" + event.Part = &v1.ItemContent{Type: "summary_text", Text: &empty} + emit("reasoning_summary_part.added", event) + event.Part = nil + } + // A child snapshot that rewrote earlier text reports it only when done. + if fragment, ok := strings.CutPrefix(part.Text, before); ok && fragment != "" { + event.Delta = &fragment + emit("reasoning_summary_text.delta", event) + event.Delta = nil + } + if i >= closed && i < done(item.Summary, item.Status) { + text := part.Text + event.Text = &text + emit("reasoning_summary_text.done", event) + event.Text, event.Part = nil, &v1.ItemContent{Type: "summary_text", Text: &text} + if item.Status == "incomplete" { + status := "incomplete" + event.Status = &status + } + emit("reasoning_summary_part.done", event) + } } } if item.Type == "command_execution" && delta != nil { diff --git a/services/core/internal/items/events_test.go b/services/core/internal/items/events_test.go index 1f91b3ba8..9232ba6ab 100644 --- a/services/core/internal/items/events_test.go +++ b/services/core/internal/items/events_test.go @@ -2,6 +2,7 @@ package items import ( "encoding/json" + "fmt" "reflect" "testing" @@ -115,13 +116,14 @@ func TestEventsAddAssistantTextEmptyAndInProgress(t *testing.T) { } } -func TestReasoningObservationsBecomeOneItemWithOfficialEvents(t *testing.T) { +func TestReasoningPartsBecomeOneItemWithOfficialEvents(t *testing.T) { index := int32(0) var stored v1.Item var kinds, deltas []string for _, frame := range []proto.ReasoningPayload{ {ID: "r1", Status: "in_progress", Delta: "think "}, {ID: "r1", Status: "in_progress", Delta: "hard"}, + {ID: "r1", Status: "in_progress", Index: 1, Delta: "then act"}, {ID: "r1", Status: "completed"}, } { raw, _ := json.Marshal(frame) @@ -134,34 +136,40 @@ func TestReasoningObservationsBecomeOneItemWithOfficialEvents(t *testing.T) { t.Fatalf("observe %+v: %v %v", frame, ok, err) } for _, event := range Events(change, &index) { - kinds = append(kinds, event.Type[len("agent.session.turn."):]) + kind := event.Type[len("agent.session.turn."):] + if event.SummaryIndex != nil { + kind += fmt.Sprint(*event.SummaryIndex) + } + kinds = append(kinds, kind) if event.Delta != nil { deltas = append(deltas, *event.Delta) } - if event.Type == "agent.session.turn.reasoning_summary_text.done" && *event.Text != "think hard" { - t.Fatalf("done text %q", *event.Text) - } } stored = change.Item } - wantKinds := []string{"item.added", "reasoning_summary_part.added", "reasoning_summary_text.delta", "reasoning_summary_text.delta", "reasoning_summary_text.done", "reasoning_summary_part.done", "item.done"} - if !reflect.DeepEqual(kinds, wantKinds) || !reflect.DeepEqual(deltas, []string{"think ", "hard"}) { + wantKinds := []string{"item.added", "reasoning_summary_part.added0", "reasoning_summary_text.delta0", "reasoning_summary_text.delta0", + "reasoning_summary_text.done0", "reasoning_summary_part.done0", "reasoning_summary_part.added1", "reasoning_summary_text.delta1", + "reasoning_summary_text.done1", "reasoning_summary_part.done1", "item.done"} + if !reflect.DeepEqual(kinds, wantKinds) || !reflect.DeepEqual(deltas, []string{"think ", "hard", "then act"}) { t.Fatalf("events %q deltas %q", kinds, deltas) } - if stored.Type != "reasoning" || stored.Status != "completed" || len(stored.Summary) != 1 || stored.Summary[0].Text != "think hard" { + want := []v1.SummaryText{{Type: "summary_text", Text: "think hard"}, {Type: "summary_text", Text: "then act"}} + if stored.Type != "reasoning" || stored.Status != "completed" || !reflect.DeepEqual(stored.Summary, want) { t.Fatalf("item %+v", stored) } } func TestOpenReasoningEndsIncompleteWithItsText(t *testing.T) { - text := "partial" - open := v1.Item{ID: "item", TurnID: testTurn, Type: "reasoning", Status: "in_progress", Summary: []v1.SummaryText{{Type: "summary_text", Text: text}}} + open := v1.Item{ID: "item", TurnID: testTurn, Type: "reasoning", Status: "in_progress", Summary: []v1.SummaryText{{Type: "summary_text", Text: "partial"}}} ended := open ended.Status = "incomplete" index := int32(0) var kinds []string for _, event := range Events(Change{Previous: open, Item: ended}, &index) { kinds = append(kinds, event.Type[len("agent.session.turn."):]) + if event.Type == "agent.session.turn.reasoning_summary_part.done" && (event.Status == nil || *event.Status != "incomplete" || *event.Part.Text != "partial") { + t.Fatalf("part done %+v", event) + } } if want := []string{"reasoning_summary_text.done", "reasoning_summary_part.done", "item.done"}; !reflect.DeepEqual(kinds, want) { t.Fatalf("events %q, want %q", kinds, want) diff --git a/services/core/internal/items/messages.go b/services/core/internal/items/messages.go index 624e9adba..586c9b228 100644 --- a/services/core/internal/items/messages.go +++ b/services/core/internal/items/messages.go @@ -14,6 +14,8 @@ type Update struct { Item v1.Item AppendText bool CommandOutputDelta *string + // SummaryIndex is the reasoning summary part an appended fragment extends. + SummaryIndex int // An aggregate answer is used only when no native message identity was recorded. AggregateFinal bool } @@ -67,14 +69,14 @@ func Project(turn, kind string, sequence int64, raw json.RawMessage) ([]Update, if err := json.Unmarshal(raw, &p); err != nil { return nil, err } - if p.ID == "" || (p.Status != "in_progress" && p.Status != "completed" && p.Status != "incomplete") { + if !p.Valid() { return nil, errors.New("invalid reasoning observation") } item := v1.Item{ID: Identity(turn, "reasoning:"+p.ID), TurnID: turn, Type: "reasoning", Status: p.Status} if p.Delta != "" { item.Summary = []v1.SummaryText{{Type: "summary_text", Text: p.Delta}} } - return []Update{{Item: item, AppendText: true}}, nil + return []Update{{Item: item, AppendText: true, SummaryIndex: p.Index}}, nil case proto.TypeDone, "execution_failed": // Done may contain adapter diagnostics. Only a successful Turn // confirms aggregate answer text; failures retain observed message deltas. @@ -112,13 +114,9 @@ func Project(turn, kind string, sequence int64, raw json.RawMessage) ([]Update, } } -// summaryText is the text of a reasoning Item's single summary part. -func summaryText(item v1.Item) string { - if len(item.Summary) == 0 { - return "" - } - return item.Summary[0].Text -} +// Reasoning parts advance in order: a fragment extends the current part or +// starts the next one. +var errReasoningPart = errors.New("reasoning part out of order") func Merge(update Update, previous v1.Item) (v1.Item, error) { if update.CommandOutputDelta != nil { @@ -126,6 +124,9 @@ func Merge(update Update, previous v1.Item) (v1.Item, error) { } item := update.Item if previous.ID == "" { + if update.SummaryIndex != 0 { + return previous, errReasoningPart + } return item, nil } if previous.Status != "in_progress" && !(update.AggregateFinal && previous.Status == "incomplete") { @@ -139,9 +140,17 @@ func Merge(update Update, previous v1.Item) (v1.Item, error) { } if update.AppendText { if item.Type == "reasoning" { - if text := summaryText(previous) + summaryText(item); text != "" { - item.Summary = []v1.SummaryText{{Type: "summary_text", Text: text}} + parts, index := slices.Clone(previous.Summary), update.SummaryIndex + switch { + case len(item.Summary) == 0: + case index == len(parts): + parts = append(parts, item.Summary[0]) + case index == len(parts)-1: + parts[index].Text += item.Summary[0].Text + default: + return previous, errReasoningPart } + item.Summary = parts return item, nil } text := *previous.Content[0].Text + *item.Content[0].Text