diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index 7841a023b..29f4fc899 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -122,7 +122,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { break } emit(proto.TypeOutputMessage, message) - case "function_call", "function_applied": + case "function_call", "function_withdrawn", "function_applied": if err := s.receiveFunction(event, start, emit); err != nil { failure = err s.invalidate() diff --git a/apps/daemon/internal/agent/claudesdk/functions.go b/apps/daemon/internal/agent/claudesdk/functions.go index a223c6161..6aef261f1 100644 --- a/apps/daemon/internal/agent/claudesdk/functions.go +++ b/apps/daemon/internal/agent/claudesdk/functions.go @@ -41,8 +41,11 @@ func validateFunctions(tools []proto.FunctionTool) error { func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit func(string, any)) error { var call *proto.FunctionCallPayload + var withdrawn *proto.FunctionCallWithdrawnPayload var observation *proto.ToolObservation var receipt chan error + // A Subagent's call is Core's to resolve through the child; the root's tool observation does not describe it. + child := false err := func() error { s.functions.mu.Lock() defer s.functions.mu.Unlock() @@ -61,16 +64,31 @@ func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit fu } } } - if !declared || call.CallID == "" || !json.Valid(call.Arguments) || s.functions.calls[call.CallID] != nil { + child = call != nil && call.NativeID != "" + if !declared || call.CallID == "" || !json.Valid(call.Arguments) || s.functions.calls[call.CallID] != nil || + (call.NativeID == "") != (call.TurnID == "") || child && start.Subagents == nil { return fmt.Errorf("claudesdk: invalid function call") } s.functions.calls[call.CallID] = &pendingFunction{call: *call, receipt: make(chan error, 1)} observation = &proto.ToolObservation{Kind: "function", Status: "in_progress", Name: call.Name, Arguments: call.Arguments} + case "function_withdrawn": + withdrawn = event.Withdrawn + var pending *pendingFunction + if withdrawn != nil { + pending = s.functions.calls[withdrawn.CallID] + } + if pending == nil || pending.applied || pending.call.NativeID == "" || pending.call.NativeID != withdrawn.NativeID || pending.call.TurnID != withdrawn.TurnID { + return fmt.Errorf("claudesdk: invalid function withdrawal") + } + // Native stopped waiting for the child's call, so a result in flight is never applied. + delete(s.functions.calls, withdrawn.CallID) + pending.receipt <- agent.ErrUnknownFunctionCall case "function_applied": pending := s.functions.calls[event.CallID] if pending == nil || pending.result == nil || pending.applied || pending.result.DeliveryID != event.DeliveryID { return fmt.Errorf("claudesdk: invalid native function receipt") } + child = pending.call.NativeID != "" pending.applied = true receipt = pending.receipt status := "completed" @@ -84,11 +102,17 @@ func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit fu if err != nil { return err } + if withdrawn != nil { + emit(proto.TypeFunctionCallWithdrawn, withdrawn) + return nil + } id, stage := event.CallID, "after" if call != nil { id, stage = call.CallID, "before" } - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) + if !child { + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) + } if call != nil { emit(proto.TypeFunctionCall, call) } else { diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go index 7b30bade0..2ccf107d8 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -139,3 +139,26 @@ func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner } emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "done"}) } + +func TestSubagentFunctionCallWithdrawal(t *testing.T) { + s := &session{functions: functionState{calls: map[string]*pendingFunction{}}} + start := startRequest{Functions: []proto.FunctionTool{{Name: "lookup"}}, Subagents: &subagentOptions{}} + var emitted []string + emit := func(kind string, _ any) { emitted = append(emitted, kind) } + call := &proto.FunctionCallPayload{CallID: "a", Name: "lookup", Arguments: json.RawMessage(`{}`), NativeID: "child", TurnID: "child-turn"} + if err := s.receiveFunction(bridgeEvent{Type: "function_call", Call: call}, start, emit); err != nil { + t.Fatal(err) + } + receipt := s.functions.calls["a"].receipt + withdrawn := &proto.FunctionCallWithdrawnPayload{CallID: "a", NativeID: "child", TurnID: "child-turn"} + if err := s.receiveFunction(bridgeEvent{Type: "function_withdrawn", Withdrawn: withdrawn}, start, emit); err != nil { + t.Fatal(err) + } + // A child's call has no root tool observation, and a result in flight learns that it was never applied. + if strings.Join(emitted, ",") != proto.TypeFunctionCall+","+proto.TypeFunctionCallWithdrawn || <-receipt != agent.ErrUnknownFunctionCall { + t.Fatal(emitted) + } + if s.receiveFunction(bridgeEvent{Type: "function_withdrawn", Withdrawn: withdrawn}, start, emit) == nil { + t.Fatal("repeated withdrawal accepted") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index c689423d0..73b798eab 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -92,8 +92,8 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR start.OutputFormat = format } if req.ObserveSubagentIdentities { - if req.DisableSubagents || len(req.FunctionTools) != 0 || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0) { - return fail("subagent execution does not support this tool combination") + if req.DisableSubagents { + return fail("subagent observation conflicts with disabled subagents") } limit := 6 if req.MaxConcurrentSubagents != nil { diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 5872d6f8c..80588beff 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -41,23 +41,24 @@ type bridgeEvent struct { Reason string `json:"reason"` Protocol int `json:"protocol"` - Fact json.RawMessage `json:"fact"` - InputID string `json:"input_id"` - ResultID string `json:"result_id"` - Usage json.RawMessage `json:"usage,omitempty"` - Type string `json:"type"` - Delta string `json:"delta"` - SessionID string `json:"session_id"` - Text string `json:"text"` - Code string `json:"code"` - ItemID string `json:"item_id"` - Message *proto.OutputMessagePayload `json:"message"` - Call *proto.FunctionCallPayload `json:"call"` - CallID string `json:"call_id"` - DeliveryID string `json:"delivery_id"` - ID string `json:"id"` - Stage string `json:"stage"` - Observation *proto.ToolObservation `json:"observation"` + Fact json.RawMessage `json:"fact"` + InputID string `json:"input_id"` + ResultID string `json:"result_id"` + Usage json.RawMessage `json:"usage,omitempty"` + Type string `json:"type"` + Delta string `json:"delta"` + SessionID string `json:"session_id"` + Text string `json:"text"` + Code string `json:"code"` + ItemID string `json:"item_id"` + Message *proto.OutputMessagePayload `json:"message"` + Call *proto.FunctionCallPayload `json:"call"` + Withdrawn *proto.FunctionCallWithdrawnPayload `json:"withdrawn"` + CallID string `json:"call_id"` + DeliveryID string `json:"delivery_id"` + ID string `json:"id"` + Stage string `json:"stage"` + Observation *proto.ToolObservation `json:"observation"` } func launch(ctx context.Context, config Config, start startRequest, env []string) (*session, error) { diff --git a/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go index 86eabf217..902bbdec0 100644 --- a/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go @@ -8,16 +8,16 @@ import ( "encoding/json" "os" "path/filepath" - "strings" "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) -// TestLiveClaudeSDKNativeSubagent runs one Turn in which the model spawns a native general-purpose Subagent with a per-call model. -func TestLiveClaudeSDKNativeSubagent(t *testing.T) { +// liveSubagentConfig reads the private provider settings of the real-provider Subagent tests. +func liveSubagentConfig(t *testing.T) (config Config, key []byte, root string) { entrypoint, keyFile, proofRoot := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE"), os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") endpoint, model := os.Getenv("OAC_TEST_CLAUDE_SDK_BASE_URL"), os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL") if entrypoint == "" || keyFile == "" || proofRoot == "" || endpoint == "" || model == "" { @@ -27,15 +27,33 @@ func TestLiveClaudeSDKNativeSubagent(t *testing.T) { if err != nil { t.Fatal(err) } - root, err := os.MkdirTemp(proofRoot, "claude-subagent-") + key = bytes.TrimSpace(key) + root, err = os.MkdirTemp(proofRoot, "claude-subagent-") if err != nil { t.Fatal(err) } t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ - "ANTHROPIC_BASE_URL=" + endpoint, "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", + return Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ + "ANTHROPIC_BASE_URL=" + endpoint, "ANTHROPIC_AUTH_TOKEN=" + string(key), "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", "ANTHROPIC_DEFAULT_SONNET_MODEL=" + model, "ANTHROPIC_DEFAULT_OPUS_MODEL=" + model, "ANTHROPIC_DEFAULT_HAIKU_MODEL=" + model, - }} + }}, key, root +} + +// saveEvidence stores the observed events of a live run, unless they would contain the provider key. +func saveEvidence(t *testing.T, root string, key []byte, evidence any) { + encoded, _ := json.MarshalIndent(evidence, "", " ") + if bytes.Contains(encoded, key) { + t.Error("evidence contains the provider key") + return + } + _ = os.WriteFile(filepath.Join(root, "evidence.json"), encoded, 0o600) + t.Logf("evidence: %s/evidence.json", root) +} + +// TestLiveClaudeSDKNativeSubagent runs one Turn in which the model spawns a native general-purpose Subagent with a per-call model. +func TestLiveClaudeSDKNativeSubagent(t *testing.T) { + config, key, root := liveSubagentConfig(t) + model := os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL") ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) defer cancel() out := make(chan proto.Envelope, 256) @@ -51,13 +69,7 @@ func TestLiveClaudeSDKNativeSubagent(t *testing.T) { var coordination []map[string]any var events []proto.Envelope defer func() { - encoded, _ := json.MarshalIndent(map[string]any{"counts": counts, "coordination": coordination, "events": events}, "", " ") - if bytes.Contains(encoded, bytes.TrimSpace(key)) { - t.Error("evidence contains the provider key") - return - } - _ = os.WriteFile(filepath.Join(root, "evidence.json"), encoded, 0o600) - t.Logf("evidence: %s/evidence.json counts=%v", root, counts) + saveEvidence(t, root, key, map[string]any{"counts": counts, "coordination": coordination, "events": events}) }() for event := range out { events = append(events, event) @@ -87,3 +99,99 @@ func TestLiveClaudeSDKNativeSubagent(t *testing.T) { t.Fatalf("spawn coordination did not report the requested model: %v", spawn) } } + +// TestLiveClaudeSDKSubagentFunctionCall runs one Turn in which a native Subagent calls the Agent's function. The call reaches Core with the child's +// identity after the child's facts, the submitted result is confirmed by the child's own native tool result, and the Turn reports no root tool call for it. +func TestLiveClaudeSDKSubagentFunctionCall(t *testing.T) { + config, key, root := liveSubagentConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 240*time.Second) + defer cancel() + out := make(chan proto.Envelope, 256) + request := proto.PromptRequestPayload{RunID: uuid.NewString(), ObserveMessages: true, ObserveSubagentIdentities: true, + FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Returns the secret code of a topic.", Parameters: json.RawMessage(`{"type":"object","properties":{"topic":{"type":"string"}},"required":["topic"]}`)}}, + Input: proto.TextInput("Call the Agent tool exactly once with subagent_type \"general-purpose\", name \"helper\" and a prompt telling it to call the mcp__functions__lookup tool with topic \"sky\" and reply with exactly the code it returned. Then report that code."), + AgentOptions: map[string]any{"model": os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL")}} + turn, err := startSingleTurn(ctx, config, request, out) + if err != nil { + t.Fatal(err) + } + defer turn.Cancel(context.Background()) + submitter := turn.(agent.FunctionResultSubmitter) + var events []proto.Envelope + var call proto.FunctionCallPayload + var tools, subagentTools, factsBefore int + submitted := make(chan error, 1) + defer func() { saveEvidence(t, root, key, events) }() + for event := range out { + events = append(events, event) + switch event.Type { + case proto.TypeError: + t.Fatalf("native failure: %s", event.Payload) + case proto.TypeSubagentIdentity, proto.TypeSubagentTurn: + if call.CallID == "" { + factsBefore++ + } + case proto.TypeToolCall: + tools++ + case proto.TypeSubagentItem: + if bytes.Contains(event.Payload, []byte(`"kind":"function"`)) { + subagentTools++ + } + case proto.TypeFunctionCall: + if err := event.DecodePayload(&call); err != nil { + t.Fatal(err) + } + text := "CODE-4821" + result := proto.FunctionResultPayload{DeliveryID: "delivery", CallID: call.CallID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}} + go func() { submitted <- submitter.SubmitFunctionResult(ctx, result) }() + } + } + if _, err := turn.AwaitSettlement(ctx); err != nil { + t.Fatal(err) + } + if call.NativeID == "" || call.TurnID == "" || factsBefore < 2 || tools != 0 || subagentTools == 0 { + t.Fatalf("child call %+v, facts before it %d, root tool calls %d, child function items %d", call, factsBefore, tools, subagentTools) + } + if err := <-submitted; err != nil { + t.Fatalf("child result was not confirmed: %v", err) + } +} + +// TestLiveClaudeSDKSubagentFunctionCallWithdrawn cancels the Turn while a Subagent's function call waits. Native stops the child, and the call is withdrawn. +func TestLiveClaudeSDKSubagentFunctionCallWithdrawn(t *testing.T) { + config, key, root := liveSubagentConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 240*time.Second) + defer cancel() + out := make(chan proto.Envelope, 256) + request := proto.PromptRequestPayload{RunID: uuid.NewString(), ObserveSubagentIdentities: true, + FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Returns the secret code of a topic.", Parameters: json.RawMessage(`{"type":"object","properties":{"topic":{"type":"string"}},"required":["topic"]}`)}}, + Input: proto.TextInput("Call the Agent tool exactly once with subagent_type \"general-purpose\" and a prompt telling it to call the mcp__functions__lookup tool with topic \"sky\" and reply with the code it returned."), + AgentOptions: map[string]any{"model": os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL")}} + turn, err := startSingleTurn(ctx, config, request, out) + if err != nil { + t.Fatal(err) + } + defer turn.Cancel(context.Background()) + var events []proto.Envelope + var call, withdrawn proto.FunctionCallWithdrawnPayload + var pending proto.FunctionCallPayload + defer func() { saveEvidence(t, root, key, events) }() + for event := range out { + events = append(events, event) + switch event.Type { + case proto.TypeFunctionCall: + if err := event.DecodePayload(&pending); err != nil { + t.Fatal(err) + } + go turn.Cancel(ctx) + case proto.TypeFunctionCallWithdrawn: + if err := event.DecodePayload(&withdrawn); err != nil { + t.Fatal(err) + } + } + } + call = proto.FunctionCallWithdrawnPayload{CallID: pending.CallID, NativeID: pending.NativeID, TurnID: pending.TurnID} + if pending.NativeID == "" || withdrawn != call { + t.Fatalf("call %+v was not withdrawn: %+v", pending, withdrawn) + } +} diff --git a/apps/daemon/internal/agent/claudesdk/subagents_test.go b/apps/daemon/internal/agent/claudesdk/subagents_test.go index 6cb3e7467..0acaef1b4 100644 --- a/apps/daemon/internal/agent/claudesdk/subagents_test.go +++ b/apps/daemon/internal/agent/claudesdk/subagents_test.go @@ -32,8 +32,6 @@ func TestSubagentConfigurationRejectsUnqualifiedAuthority(t *testing.T) { for _, change := range []func(*proto.PromptRequestPayload){ func(r *proto.PromptRequestPayload) { r.DisableSubagents = true }, func(r *proto.PromptRequestPayload) { n := 0; r.MaxConcurrentSubagents = &n }, - func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "function"}} }, - func(r *proto.PromptRequestPayload) { v := []proto.MCPHTTPServer{}; r.MCPHTTPServers = &v }, } { req := workspaceRequest() req.DisableSubagents, req.ObserveSubagentIdentities = false, true diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index a2d6635c1..36f4d62b0 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -28,7 +28,7 @@ A function declaration requires `name`, `description` and a JSON Schema in `para [Session input conflicts](./sessions-events.md#input-errors) records the exact messages. Invalid or unsupported content cannot consume a pending call. Admission is separate from application: the adapter confirms a result only when the matching native tool result appears in the live Turn that made the call ([receipt contract](./message-content.md#function-results)). A transport write alone confirms nothing, and a confirmation says nothing about provider consumption or exactly-once external effects. Core never replays a result automatically. -The native Harness can abandon a call before it applies a result, for example when the root Turn closes or interrupts the Codex Subagent that made it, or when MiniMax Code's MCP client times the call out. The call then stops being a required action, the Turn continues and returns to `in_progress` when no required action is left, and a later `function_call_output` for it is rejected like a result after cancellation. An abandoned call has no application receipt. +The native Harness can abandon a call before it applies a result, for example when the root Turn closes or interrupts the Subagent that made it, or when MiniMax Code's MCP client times the call out. The call then stops being a required action, the Turn continues and returns to `in_progress` when no required action is left, and a later `function_call_output` for it is rejected like a result after cancellation. An abandoned call has no application receipt. A function call made by a Subagent is a required action of the root Turn that runs the child. Its `turn_id` is that root Turn, and its `call_id` equals the `call_id` of the child's `function_call` Item on the [Subagent routes](./subagents.md#public-reads). Answer it with `function_call_output` / `tool_result` under that `call_id` and the root Turn ID; the child's Turn ID is not a Session Turn and gets the 400 for a call of another Turn. A Subagent's HTTP MCP calls are `mcp_call` Items in the child's history. diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index b5a7a859d..ad73b3df9 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -46,9 +46,9 @@ Native model parameters and provider protocols per Harness are in [model executi | [HTTP MCP bearer credentials](./execution-tools.md#mcp) | Verified | Verified | Verified | | Required MCP initialization | Verified | Verified | Rejected | | [Subagents](./subagents.md) | Verified: hosted; admitted: `none`, self-hosted | Verified: hosted; admitted: `none`, self-hosted | Verified: hosted; admitted: `none`, self-hosted | -| Subagents together with functions or HTTP MCP | Verified: `none`; admitted: hosted, self-hosted | Rejected | HTTP MCP admitted; functions rejected for every Session | +| Subagents together with functions or HTTP MCP | Verified: `none`; admitted: hosted, self-hosted | Verified: functions on `none`; admitted: HTTP MCP, hosted, self-hosted | Admitted; children get none of the Session's functions or MCP servers | -Claude structured output requires medium verbosity and combines with `multi_agent`, Skills, Plugins, capability directories, MCP and `tool_search`. Claude deferred discovery combines with MCP, Skills, Plugins, capability directories and structured output. Claude Code does not carry function tools into the Subagent profile, so deferred discovery with `multi_agent` stays rejected. Claude Code decides whether `tool_search` takes effect for the configured model, provider and beta settings. Claude on a workspace placement needs the packaged bridge features for each operation (`workspace_functions`, `workspace_structured_output`, `workspace_tool_search`, `workspace_mcp_http`). +Claude structured output requires medium verbosity and combines with `multi_agent`, Skills, Plugins, capability directories, MCP and `tool_search`. Claude deferred discovery combines with MCP, Skills, Plugins, capability directories and structured output. Claude Subagents inherit the parent's tools, including deferred functions. Claude Code decides whether `tool_search` takes effect for the configured model, provider and beta settings. Claude on a workspace placement needs the packaged bridge features for each operation (`workspace_functions`, `workspace_structured_output`, `workspace_tool_search`, `workspace_mcp_http`). ## Environment preparation diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index c1e12fc5a..2db7a58d8 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -86,7 +86,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Input to a `none` Session is admitted synchronously; Core does not emulate the official asynchronous admission window. - A function result that resumes a waiting Turn emits `turn.in_progress`, and cancelling a Turn that waits on a function result emits an interim `agent.session.in_progress`. -- A function call the native Harness abandons, such as a Codex Subagent call dropped when the root closes or interrupts the child or a MiniMax Code call past its 120 s timeout, is withdrawn: it stops being a required action and a later result for it returns 409 `conflict_error`. The official API has no withdrawal. +- A function call the native Harness abandons, such as a Subagent call dropped when the root closes or interrupts the child or a MiniMax Code call past its 120 s timeout, is withdrawn: it stops being a required action and a later result for it returns 409 `conflict_error`. The official API has no withdrawal. - Attaching to the stream mid-Turn sends no catch-up Item snapshots. - The Items list includes in-progress and incomplete output Items, and keeps a failed function result's submitted `output`. - Error messages omit the call and executor IDs that official messages include. @@ -158,7 +158,7 @@ Each item is Core's deliberate or native behavior where the official service beh ### Unverified against the official service - The order of errors when one request has several faults, and error, default and payload-limit parity in general. -- A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. +- A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. - Whether Subagent child Turns and pending Environment file writes block Session deletion. - The official order between the pending-input error and an unknown result target. - Failure reasons for npm, initial-file and Skill installation have no official sample. diff --git a/contracts/agents-api/message-content.md b/contracts/agents-api/message-content.md index f90bd7bb5..aa9206cce 100644 --- a/contracts/agents-api/message-content.md +++ b/contracts/agents-api/message-content.md @@ -57,7 +57,7 @@ An `agent.session.input.tool_result` event carries `success`, an optional nullab Admission (202) does not mean the Harness used the result. The pending call clears when the adapter confirms native application: -- **Claude Code** confirms with a live root native tool result that matches the Session, call ID, success flag, exact text, block count and order, with a native image at every image position. Replayed, synthetic and Subagent records do not confirm it. +- **Claude Code** confirms with a live root native tool result that matches the Session, call ID, success flag, exact text, block count and order, with a native image at every image position. Replayed and synthetic records do not confirm it. A Subagent call is confirmed only by that child's own live result. - **Codex** confirms with the live dynamic-tool `item/completed` observation on the thread and Turn that made the call, which must match the call, function name, status, success flag and the exact ordered content. A write to the Harness alone does not confirm it. - **MiniMax Code** confirms with the live native tool update for the `functions` MCP call whose echoed result carries the call ID. A native failure of the call, such as its 120 s timeout, abandons it. diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index db6f4aaf5..7fb2bd8e4 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -50,12 +50,12 @@ An adapter freezes root output before child settlement, keeps its native owner a ## Native profiles -A Subagent uses the Agent's function and HTTP MCP tools as the Harness would in a local run; [Functions](./execution-tools.md#functions) describes how its calls surface, and [Harness capabilities](./harness-capabilities.md#tools) lists each combination. Codex children inherit the Agent's HTTP MCP servers, and a forked child inherits its functions while a child with fresh history has none. +A Subagent uses the Agent's function and HTTP MCP tools as the Harness would in a local run; [Functions](./execution-tools.md#functions) describes how its calls surface, and [Harness capabilities](./harness-capabilities.md#tools) lists each combination. Codex children inherit the Agent's HTTP MCP servers, and a forked child inherits its functions while a child with fresh history has none. Claude Code children inherit the parent's tools, so they call the Agent's functions as `mcp__functions__` and its HTTP MCP tools natively. **Codex.** The adapter enables the native `multi_agent` feature and maps the concurrency limit to `agents.max_threads`; nesting depth and other native features keep their Codex defaults. It refuses to start if managed requirements force code mode or `multi_agent_v2`, whose collaboration calls leave no persisted receipt. Child Items without a neutral Item type, such as image views and context compaction, are not projected. Close and reopen facts come from direct tool output correlated with the same call's persisted completion, so they need native persisted receipts. Native Turn times have second precision. Child file work can finish under the same owner after the root Turn finishes. Cancellation continues under the same owner after a caller deadline; a later call can confirm settlement without repeating the native interrupt. -**Claude Code.** The pinned SDK's native Agent and SendMessage calls run the native agent types with their native prompts, per-call `model`, `name` and worktree isolation; a child inherits the parent's tools, which on `none` are Agent and SendMessage. The bridge's `subagent_resources` feature gates it. Children use native Bash with the parent's launching-user permissions. Private child records establish parentage, the first own input time and later own Turns; inherited parent context is excluded. The query owner admits children before start and keeps their history through settlement. Confirmed cancellation writes an immutable effect receipt because a native abort can leave no terminal record. There is no close operation: completed or cancelled children stay active. A SendMessage `to` is a child ID or the `name` given at spawn. Messages to running children and remote isolation are rejected, and the native setting `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` removes background work. The [Claude SDK adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md#subagents) documents the details. +**Claude Code.** The pinned SDK's native Agent and SendMessage calls run the native agent types with their native prompts, per-call `model`, `name` and worktree isolation; a child inherits the parent's tools, which on `none` are Agent and SendMessage. The bridge's `subagent_resources` feature gates it. Children use native Bash with the parent's launching-user permissions. Private child records establish parentage, the first own input time and later own Turns; inherited parent context is excluded. The query owner admits children before start and keeps their history through settlement. Before a child's function call, the bridge sends the child's identity and open Turn from its live record, and cancelling the root Turn aborts the child's pending call, which is then withdrawn. Child function calls are `function_call` Items with their `function_call_output`, and MCP calls are `mcp_call` Items. Confirmed cancellation writes an immutable effect receipt because a native abort can leave no terminal record. There is no close operation: completed or cancelled children stay active. A SendMessage `to` is a child ID or the `name` given at spawn. Messages to running children and remote isolation are rejected, and the native setting `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` removes background work. The [Claude SDK adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md#subagents) documents the details. -**MiniMax Code.** Native ACP delegation (`task`, `task_append`, `task_stop`) creates children. Session-private SQLite records supply child identity, accepted inputs, terminal times and own messages. The native task-create transaction enforces the concurrency limit before start, and native preparation must acknowledge that limit and that background Bash is off before any model input. There is no close or reopen, and native workers do not delegate nested work. +**MiniMax Code.** Native ACP delegation (`task`, `task_append`, `task_stop`) creates children. Session-private SQLite records supply child identity, accepted inputs, terminal times and own messages. The native task-create transaction enforces the concurrency limit before start, and native preparation must acknowledge that limit and that background Bash is off before any model input. There is no close or reopen, and native workers do not delegate nested work. Children get none of the Session's functions or MCP servers, because native ACP session servers stay with the root session. Claude and MiniMax publish verified child history at settlement, not as continuous child progress. Root-completion propagation to children, complete child-delta ordering and unbounded background work across root Turns are not established. diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 8acdfaddc..ee74aaa43 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: cf4c1fa4611afd0d238206e9e87f08079f1d1a1bbcef64d08c2174d28668abb5 +source_hash: 6956ecfe568c551e3161eb1fc982d412741846eaa56341ad58ec6511df229df8 --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -30,7 +30,7 @@ Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 [Session 输入冲突](sessions-events.md#input-errors)记录准确消息。无效或不支持的内容不能消耗待处理调用。准入与应用结果分离:只有匹配的原生工具结果出现在发起调用的实时 Turn 中,适配器才确认结果([回执契约](message-content.md#function-results))。传输写入本身不确认任何事实,确认也不代表提供商已消费结果或外部副作用恰好发生一次。Core 不自动重放结果。 -原生 Harness 可能在应用结果之前放弃调用,例如根 Turn 关闭或中断了发起该调用的 Codex Subagent,或 MiniMax Code 的 MCP 客户端使该调用超时。该调用随即不再是待执行操作,Turn 继续运行,并在没有待执行操作时回到 `in_progress`;之后为它提交的 `function_call_output` 会像取消后的结果一样被拒绝。被放弃的调用没有应用回执。 +原生 Harness 可能在应用结果之前放弃调用,例如根 Turn 关闭或中断了发起该调用的 Subagent,或 MiniMax Code 的 MCP 客户端使该调用超时。该调用随即不再是待执行操作,Turn 继续运行,并在没有待执行操作时回到 `in_progress`;之后为它提交的 `function_call_output` 会像取消后的结果一样被拒绝。被放弃的调用没有应用回执。 Subagent 发起的函数调用是运行该子级的根 Turn 的待执行操作:`turn_id` 是该根 Turn,`call_id` 等于 [Subagent 路由](subagents.md#public-reads)上该子级 `function_call` Item 的 `call_id`。使用该 `call_id` 和根 Turn ID,以 `function_call_output` / `tool_result` 作答;子级的 Turn ID 不是 Session Turn,会得到“另一个 Turn 的调用”的 400。Subagent 的 HTTP MCP 调用是该子级历史中的 `mcp_call` Item。 diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index dd8317115..33c002594 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: aa7386a79b936f3415f12e394740fbbe538c6e41c32835a67cb2fbd2694dad9f +source_hash: 8ccea275f1d4bef039b9c4467388374a9bbd987c690c1f435733c6628a834ed4 --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -48,9 +48,9 @@ source_hash: aa7386a79b936f3415f12e394740fbbe538c6e41c32835a67cb2fbd2694dad9f | [HTTP MCP bearer 凭据](execution-tools.md#mcp) | 已验证 | 已验证 | 已验证 | | 必需 MCP 初始化 | 已验证 | 已验证 | 已拒绝 | | [Subagent](subagents.md) | 已验证:托管;已准入:`none`、自托管 | 已验证:托管;已准入:`none`、自托管 | 已验证:托管;已准入:`none`、自托管 | -| Subagent 与函数或 HTTP MCP 同时使用 | 已验证:`none`;已准入:托管、自托管 | 已拒绝 | HTTP MCP 已准入;每个 Session 都拒绝函数 | +| Subagent 与函数或 HTTP MCP 同时使用 | 已验证:`none`;已准入:托管、自托管 | 已验证:`none` 上的函数;已准入:HTTP MCP、托管、自托管 | 已准入;子级不获得 Session 的函数和 MCP 服务器 | -Claude 结构化输出要求 medium verbosity,可与 `multi_agent`、Skill、Plugin、能力目录、MCP 和 `tool_search` 组合。Claude 延迟发现可与 MCP、Skill、Plugin、能力目录和结构化输出组合。Claude Code 不会把函数工具带入 Subagent 配置,因此延迟发现与 `multi_agent` 的组合仍被拒绝。`tool_search` 是否对所配置的模型、提供商和 beta 设置生效,由 Claude Code 决定。Claude 在工作区位置需要每种操作的打包桥接功能(`workspace_functions`、`workspace_structured_output`、`workspace_tool_search`、`workspace_mcp_http`)。 +Claude 结构化输出要求 medium verbosity,可与 `multi_agent`、Skill、Plugin、能力目录、MCP 和 `tool_search` 组合。Claude 延迟发现可与 MCP、Skill、Plugin、能力目录和结构化输出组合。Claude Subagent 继承父级的工具,包括延迟加载的函数。`tool_search` 是否对所配置的模型、提供商和 beta 设置生效,由 Claude Code 决定。Claude 在工作区位置需要每种操作的打包桥接功能(`workspace_functions`、`workspace_structured_output`、`workspace_tool_search`、`workspace_mcp_http`)。 ## Environment 准备 {#environment-preparation} diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 28435b286..d0ecae444 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: 0be7a0fb3f65a4a652a9b7eb0cc02e2be1f563fab68ae34aad833dc877226b0e +source_hash: 1387a015bb94dc885cc6b9b2c9f6a55c9837438a59e86ce9ff371e161ace1499 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -89,7 +89,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 提交给 `none` Session 的输入会同步完成准入;Core 不会模拟官方异步准入窗口。 - 用于恢复等待中 Turn 的函数结果会发出 `turn.in_progress`;取消正在等待函数结果的 Turn 会发出临时 `agent.session.in_progress`。 -- 原生 Harness 放弃的函数调用(例如根关闭或中断子级时被丢弃的 Codex Subagent 调用,或超过 120 秒超时的 MiniMax Code 调用)会被撤回:它不再是待执行操作,之后为它提交结果会返回 409 `conflict_error`。官方 API 没有撤回机制。 +- 原生 Harness 放弃的函数调用(例如根关闭或中断子级时被丢弃的 Subagent 调用,或超过 120 秒超时的 MiniMax Code 调用)会被撤回:它不再是待执行操作,之后为它提交结果会返回 409 `conflict_error`。官方 API 没有撤回机制。 - 在 Turn 中途接入流时,不会发送补发 Item 快照。 - Items 列表会包含进行中和未完成的输出 Items,并保留失败函数结果中已提交的 `output`。 - 错误消息会省略官方消息包含的 call 和 executor ID。 @@ -161,7 +161,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh ### 尚未与官方服务核实的内容 {#unverified-against-the-official-service} - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 -- Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。 +- Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 - Subagent 子级 Turns 和待处理的 Environment file 写入是否会阻止 Session 删除。 - 官方服务在待处理输入错误与未知结果目标之间的先后顺序。 - npm、initial-file 和 Skill 安装的失败原因没有官方样本。 diff --git a/contracts/agents-api/zh/message-content.md b/contracts/agents-api/zh/message-content.md index 00eb76c20..b7140fb1e 100644 --- a/contracts/agents-api/zh/message-content.md +++ b/contracts/agents-api/zh/message-content.md @@ -1,7 +1,7 @@ --- title: "消息内容" source: contracts/agents-api/message-content.md -source_hash: 6652622a46fc054691429d4675d55a3b03beec2ebd54d39529ed8fb5a0dcd783 +source_hash: 743fb8e8ddb2d13fe2dd32f1952e7875b05af241a5dd0ed60c6e3e10a00e8b7a --- 用户消息和函数结果共享同一内容模型:由 `input_text` 与 `input_image` 部分组成的有序列表。Core 按发送形式准确存储消息边界、部分顺序和图像引用,并在用户 Item 中原样返回。不下载、转码或修复媒体。Session 创建的 `input` 与 `events.create` 消息共享验证和准入;[Session、事件与历史](sessions-events.md#send-input)定义准入、请求限制和错误。 @@ -59,7 +59,7 @@ source_hash: 6652622a46fc054691429d4675d55a3b03beec2ebd54d39529ed8fb5a0dcd783 准入(202)不表示 Harness 使用了结果。适配器确认原生应用后,待处理调用清除: -- **Claude Code** 使用实时根原生工具结果确认,要求匹配 Session、调用 ID、成功标志、准确文本、块数和顺序,且每个图像位置都有原生图像。重放、合成和 Subagent 记录不能确认。 +- **Claude Code** 使用实时根原生工具结果确认,要求匹配 Session、调用 ID、成功标志、准确文本、块数和顺序,且每个图像位置都有原生图像。重放和合成记录不能确认。Subagent 的调用只由该子级自己的实时结果确认。 - **Codex** 使用发起调用的线程和 Turn 上的实时 dynamic-tool `item/completed` 观察确认,要求匹配调用、函数名称、状态、成功标志和准确有序内容。仅写入 Harness 不确认。 - **MiniMax Code** 使用 `functions` MCP 调用的实时原生工具更新确认,要求其回显的结果带有该调用 ID。原生使调用失败(例如 120 秒超时)时,该调用被放弃。 diff --git a/contracts/agents-api/zh/subagents.md b/contracts/agents-api/zh/subagents.md index d4aefa46d..c4346f1aa 100644 --- a/contracts/agents-api/zh/subagents.md +++ b/contracts/agents-api/zh/subagents.md @@ -1,7 +1,7 @@ --- title: "子 Agent" source: contracts/agents-api/subagents.md -source_hash: 735f0570a1c9d5feaebd6b38aacb186c9982aa739f7a835ddb2e02a071fc2196 +source_hash: 6c4fe5212d8473ff319fea4a6abf30d8038b75dd663dd9612ddad023adb918c7 --- 启用 `multi_agent.enabled` 后,Harness 可以启动原生子 Agent。Core 通过锁定版本的 SDK(见 [`upstream.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))中的六项 Subagent 读取操作公开它们,并根据适配器观察结果记录它们。当 `multi_agent.enabled=false` 时,Runtime 会移除原生子 Agent 工具。[Harness capabilities](harness-capabilities.md) 列出各 Harness 在哪些组合中支持子 Agent。 @@ -52,12 +52,12 @@ Core 根据已授权的 Session 绑定关系分配合公开 ID 和所有权。 ## 原生配置 {#native-profiles} -Subagent 像本地运行该 Harness 一样使用 Agent 的函数和 HTTP MCP 工具;其调用如何呈现见 [Functions](execution-tools.md#functions),各组合见 [Harness capabilities](harness-capabilities.md#tools)。Codex 子级继承 Agent 的 HTTP MCP 服务器;fork 出的子级继承其函数,使用全新历史的子级没有函数。 +Subagent 像本地运行该 Harness 一样使用 Agent 的函数和 HTTP MCP 工具;其调用如何呈现见 [Functions](execution-tools.md#functions),各组合见 [Harness capabilities](harness-capabilities.md#tools)。Codex 子级继承 Agent 的 HTTP MCP 服务器;fork 出的子级继承其函数,使用全新历史的子级没有函数。Claude Code 子级继承父级的工具,因此原生调用 Agent 的函数(`mcp__functions__`)和 HTTP MCP 工具。 **Codex.** 适配器会启用原生 `multi_agent` 特性,并把并发限制映射到 `agents.max_threads`;嵌套深度和其他原生特性保持 Codex 默认值。如果托管配置要求强制启用代码模式或 `multi_agent_v2`(它们的协作调用不留下持久化回执),则拒绝启动。没有中立 Item 类型的子级 Item(例如图像查看和上下文压缩)不会被投影。关闭和重新打开的事实来自直接工具输出,并与同一次调用的持久化完成记录相关联,因此需要原生持久化回执。原生 Turn 时间具有秒级精度。根级 Turn 完成后,子级文件工作可以在同一所有者下完成。超过调用方截止时间后,取消操作仍会在同一所有者下继续;后续调用可以确认已经收敛,而无需重复执行原生中断。 -**Claude Code.** 锁定版本 SDK 的原生 Agent 和 SendMessage 调用会运行原生的 Agent 类型及其原生提示词,支持按次指定 `model`、`name` 和 worktree 隔离;子级继承父级的工具,在 `none` 上即 Agent 和 SendMessage。bridge 的 `subagent_resources` 特性控制是否启用。子级使用原生 Bash,权限继承自父级的启动用户。私有子级记录用于确定父子关系、首次自身输入时间及后续自身 Turns;继承的父级上下文会被排除。查询所有者会在启动前接纳子级,并将子级历史保留至工作收敛。已确认的取消操作会写入不可变的操作回执,因为原生中止可能不会留下终态记录。不存在关闭操作:已完成或已取消的子级保持 active 状态。SendMessage 的 `to` 可以是子级 ID,也可以是创建时给出的 `name`。向运行中的子级发送消息和 remote 隔离会被拒绝,原生设置 `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` 会移除后台工作。[Claude SDK adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md#subagents) 记录了详细信息。 +**Claude Code.** 锁定版本 SDK 的原生 Agent 和 SendMessage 调用会运行原生的 Agent 类型及其原生提示词,支持按次指定 `model`、`name` 和 worktree 隔离;子级继承父级的工具,在 `none` 上即 Agent 和 SendMessage。bridge 的 `subagent_resources` 特性控制是否启用。子级使用原生 Bash,权限继承自父级的启动用户。私有子级记录用于确定父子关系、首次自身输入时间及后续自身 Turns;继承的父级上下文会被排除。查询所有者会在启动前接纳子级,并将子级历史保留至工作收敛。子级调用函数前,桥接层会根据其实时记录发送子级的身份和未结束的 Turn;取消根 Turn 会中止子级待处理的调用,该调用随后被撤回。子级的函数调用是带有 `function_call_output` 的 `function_call` Item,MCP 调用是 `mcp_call` Item。已确认的取消操作会写入不可变的操作回执,因为原生中止可能不会留下终态记录。不存在关闭操作:已完成或已取消的子级保持 active 状态。SendMessage 的 `to` 可以是子级 ID,也可以是创建时给出的 `name`。向运行中的子级发送消息和 remote 隔离会被拒绝,原生设置 `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` 会移除后台工作。[Claude SDK adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md#subagents) 记录了详细信息。 -**MiniMax Code.** 原生 ACP 委派(`task`、`task_append`、`task_stop`)会创建子级。Session 私有 SQLite 记录提供子级身份、已接受的输入、终态时间和自身消息。原生 task-create 事务会在启动前强制执行并发限制,并且原生准备过程必须在任何模型输入前确认该限制并确认后台 Bash 已关闭。不存在关闭或重新打开操作,原生工作器也不会委派嵌套工作。 +**MiniMax Code.** 原生 ACP 委派(`task`、`task_append`、`task_stop`)会创建子级。Session 私有 SQLite 记录提供子级身份、已接受的输入、终态时间和自身消息。原生 task-create 事务会在启动前强制执行并发限制,并且原生准备过程必须在任何模型输入前确认该限制并确认后台 Bash 已关闭。不存在关闭或重新打开操作,原生工作器也不会委派嵌套工作。子级不获得 Session 的函数和 MCP 服务器,因为原生 ACP 会话服务器只属于根会话。 Claude 和 MiniMax 在子级工作收敛时发布经验证的子级历史,而不是持续发布子级进度。向子级传播根级完成状态、完整的子级增量排序以及跨根级 Turns 的无界后台工作均尚未得到支持。 diff --git a/internal/harnessconfig/builtin/admission_test.go b/internal/harnessconfig/builtin/admission_test.go index 6ec0b5c52..4e9a5eddb 100644 --- a/internal/harnessconfig/builtin/admission_test.go +++ b/internal/harnessconfig/builtin/admission_test.go @@ -46,10 +46,10 @@ func TestSubagentDeclarationsPreserveSupportedBoundaries(t *testing.T) { if err := declaration.ValidateConfiguration(admission); err != nil { t.Fatal(err) } - // Codex admits function and MCP tools with Subagents; Claude Code does not. + // Subagents use the Agent's function and MCP tools. for _, tool := range []string{`{"type":"function","name":"f"}`, `{"type":"mcp","server_label":"s"}`} { admission.Tools = []json.RawMessage{json.RawMessage(tool)} - if err := declaration.ValidateConfiguration(admission); (err != nil) != (kind == "claude_sdk") { + if err := declaration.ValidateConfiguration(admission); err != nil { t.Fatal(kind, tool, err) } } diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index 732d17589..3be9e59f0 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -84,7 +84,7 @@ func validateConfiguration(admission harnessconfig.Admission) error { return err } } - return harnessconfig.RejectSubagentTools(admission, "function", "mcp") + return nil } // validateTools applies execution limits of the packaged adapter. Shared diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index b52259cd7..18d3ecec8 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -347,26 +347,6 @@ func RequireObjectSchemas(functions []proto.FunctionTool) error { return nil } -// RejectSubagentTools rejects the declared tool types that a Harness cannot -// combine with Subagents. -func RejectSubagentTools(admission Admission, unsupported ...string) error { - if !admission.MultiAgent { - return nil - } - for _, raw := range admission.Tools { - var tool struct { - Type string `json:"type"` - } - if json.Unmarshal(raw, &tool) != nil { - return ErrInvalidInput - } - if slices.Contains(unsupported, tool.Type) { - return errors.New("The configured Subagent profile does not support this tool combination.") - } - } - return nil -} - // ExecutionOptions lists the official execution option values a Harness maps to // a native setting. An empty set declares the option unsupported. Absent // options need no declaration: they follow the Harness's native default. Auto diff --git a/internal/harnessconfig/mcode/declaration.go b/internal/harnessconfig/mcode/declaration.go index b9cabb390..ddf661f6a 100644 --- a/internal/harnessconfig/mcode/declaration.go +++ b/internal/harnessconfig/mcode/declaration.go @@ -67,9 +67,6 @@ var Declaration = harnessconfig.Declaration{ Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPOrigins: []string{"service", "environment"}, WhitespaceOnlyText: proto.CapabilityUnsupported, - ValidateConfiguration: func(admission harnessconfig.Admission) error { - return harnessconfig.RejectSubagentTools(admission, "function") - }, ValidateTools: func(functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { if err := harnessconfig.RequireObjectSchemas(functions); err != nil { return err diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index b1209cf81..7c47158d5 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -80,7 +80,7 @@ The `none` profile accepts only text, explicit model/system instructions, manage The internal SDK function-server helper uses the maintained MCP server's public request handlers and standard Tool/CallToolResult types. It snapshots definitions and forwards JSON Schema without a JSON Schema-to-Zod conversion; supplied tools are always loaded. Native call identity comes from the pinned harness's `claudecode/toolUseId` MCP metadata, independently of request IDs, names or arrival order. Missing identities and undeclared tools fail before invoking the host. Return content/error fields unchanged over MCP and forward its per-request abort signal. The private Go factory connects declared functions through this helper and reuses the daemon function-call/result interface and neutral observations. The native function-server registry must contain exactly those functions. SDK allowlisting admits only these host callbacks; the host still owns result decisions and any business permission checks. It grants no runtime-token business authority. -Function results remain pending after stdin/MCP delivery. A matching live, root native user tool_result confirms application only when its Session/call identity, error flag and ordered content match the submission. Text matches exactly; each submitted image position must hold a native image, or the text note native substitutes for an image it cannot process. Native resizing or re-encoding may change image bytes. This acknowledges incorporation into native history, not byte/pixel fidelity or completed provider consumption. Public Items retain the original caller content; real image-dependent model responses separately qualify usability. Ignore replayed, synthetic and subagent messages. Native error text joins the submitted text parts with newlines; neutral observations retain their original order and separate failure status. Missing/mismatched receipts fail the execution; do not replay unknown delivery. Result submission waits at most ten seconds for a receipt and cancels uncertain execution on timeout. Invalid or unsupported image results fail before consuming a pending call. Function state belongs to one live Run and ends with it; the router owns receipt retry/conflict handling. This does not establish crash recovery or exactly-once effects. +Function results remain pending after stdin/MCP delivery. A matching live native user tool_result of the calling agent confirms application only when its Session/call identity, error flag and ordered content match the submission. Text matches exactly; each submitted image position must hold a native image, or the text note native substitutes for an image it cannot process. Native resizing or re-encoding may change image bytes. This acknowledges incorporation into native history, not byte/pixel fidelity or completed provider consumption. Public Items of a root call retain the original caller content, and a child's Items come from its native history; real image-dependent model responses separately qualify usability. Ignore replayed and synthetic messages. Native error text joins the submitted text parts with newlines; neutral observations retain their original order and separate failure status. Missing/mismatched receipts fail the execution; do not replay unknown delivery. Result submission waits at most ten seconds for a receipt and cancels uncertain execution on timeout. Invalid or unsupported image results fail before consuming a pending call. Function state belongs to one live Run and ends with it; the router owns receipt retry/conflict handling. This does not establish crash recovery or exactly-once effects. ### Usage @@ -102,7 +102,7 @@ The first own native user record has a null `parentUuid`. Its timestamp supplies The native query owns child execution and cleanup. PreToolUse admission reserves each native Agent or idle-child SendMessage call before execution. Native `task_started` associates the call and child ID; completion releases that reservation. The frozen limit applies across the child tree, excludes the root, and defaults to six. Unknown call associations fail closed. SendMessage to a running child is rejected; only idle continuation is qualified. A SendMessage `to` is a child ID or the `name` given at spawn. Remote isolation, which needs an account credential Core does not hold, is rejected; native background execution is removed by `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS`. Agent types, per-call `model` and worktree isolation follow the native defaults, and the coordination fact reports the requested `model`. -Workspace children use native Bash with the same launching-user permissions as the parent, and the daemon and adapter add no filesystem, permission or network sandbox. A child's Agent and SendMessage calls are denied until native task admission has verified its identity. Workspace hooks keep their execution and event responsibilities but are not a private-file boundary: tools can access Runtime state that the host user can access. Isolation belongs to the outer Environment. Functions and MCP combined with Subagents are not qualified and are rejected explicitly ([subagents contract](../../contracts/agents-api/subagents.md)); functions and MCP without Subagents are unaffected. Claude on Windows requires Git Bash. +Workspace children use native Bash with the same launching-user permissions as the parent, and the daemon and adapter add no filesystem, permission or network sandbox. A child's Agent and SendMessage calls are denied until native task admission has verified its identity. Workspace hooks keep their execution and event responsibilities but are not a private-file boundary: tools can access Runtime state that the host user can access. Isolation belongs to the outer Environment. A child calls the Agent's functions and MCP tools like the root. The PreToolUse hook admits a child only after native started it and records which agent made each function call, because the MCP request does not name it. Before the call the bridge sends the child's `subagent_identity` and open `subagent_turn` from its live record and then `function_call` with `native_id` and `turn_id`. The child's own live tool result, forwarded with the root session, confirms the result; an aborted child call is sent as `function_withdrawn`. Claude on Windows requires Git Bash. Cancellation uses an adapter-owned effect receipt only after the query owner confirms native process exit, because the fixed native history can end at a tool call without a cancellation result or timestamp. Each receipt is linked into the native history directory atomically, without overwriting an earlier receipt, and records the child, own Turn, spawn call and confirmed effect time. Native records stay unchanged. Replay uses that same timestamp; it never takes a new cancellation time from an unfinished history. Child Items and the cancelled Turn precede the root cancellation event. Missing native exit confirmation or a missing receipt does not imply a terminal child state. diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 8f3729b2b..b57289ba1 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -10,7 +10,7 @@ import { Inputs, type InputEvent } from "./inputs.js"; import { executorResultUsage, resultUsage, type NativeUsage } from "./usage.js"; import { spawnNative } from "./native.js"; import { MessageObserver, type MessageEvent } from "./messages.js"; -import { createFunctionServer } from "./functions.js"; +import { createFunctionServer, type FunctionCall } from "./functions.js"; import { FunctionBridge, type FunctionEvent } from "./function_bridge.js"; import { MCPProfile } from "./mcp.js"; import { MCPObserver, type MCPEvent } from "./mcp_observer.js"; @@ -49,8 +49,8 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: // Environment none runs only the tools Core selects. WebSearch is a provider-side tool; WebFetch would fetch from the Runtime host. const selected = [...(request.tool_search ? ["ToolSearch"] : []), ...(request.web_search === "live" ? ["WebSearch"] : [])]; const declarations = request.workspace?.mcp ?? request.mcp_http_servers; - const profile = declarations === undefined ? undefined : new MCPProfile(declarations, names); - const subagents = request.subagents ? new Subagents(request.cwd, request.subagents.max_concurrent, request.resume) : undefined; + 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 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"; @@ -72,7 +72,15 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: } subagents?.expectSession(request.resume); const mcpServers: Record = Object.create(null); - if (definitions.length) mcpServers.functions = createFunctionServer(definitions, (call,signal) => { const target=functions; return turns ? turns.track(()=>target.invoke(call,signal)) : target.invoke(call,signal); }); + // Core resolves a Subagent's call through the child's identity and open Turn, so those facts precede it. + const invoke = async (target: FunctionBridge, call: FunctionCall, signal: AbortSignal) => { + let child: Awaited>; + // A call Core cannot attribute fails the execution instead of reaching the model as a tool error. + try { child = await subagents?.child(call.id); } catch (error) { failed = true; stream?.close(); throw error; } + for (const fact of child?.facts ?? []) await emit(fact); + return target.invoke(child ? { ...call, child: { native_id: child.native_id, turn_id: child.turn_id } } : call, signal); + }; + if (definitions.length) mcpServers.functions = createFunctionServer(definitions, (call,signal) => { const target=functions; return turns ? turns.track(()=>invoke(target,call,signal)) : invoke(target,call,signal); }); let mcp = profile ? new MCPObserver(profile.identities) : undefined; if (profile) Object.assign(mcpServers, profile.servers); const children: Promise[] = []; @@ -95,6 +103,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: const beforeTool: HookCallback = async (input, id, context) => { if (input.hook_event_name !== "PreToolUse") return {}; if (subagents?.isCoordination(input.tool_name)) return subagents.beforeTool(input, id, context); + await subagents?.noteTool(input, id, context); return profile ? profile.beforeTool(input, id, context) : {}; }; const options: Options = { @@ -202,7 +211,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if (!nativeID || (request.resume && nativeID !== request.resume)) throw new Error("unexpected native session"); // Native decides whether the selected tools and StructuredOutput join the inventory; a missing structured call fails the result. const tools = message.tools.filter(name => name !== "StructuredOutput" && !selected.includes(name)); - if (profile) profile.verify(tools, await stream.mcpServerStatus(), nativeID, !workspace); + if (profile) profile.verify(tools, await stream.mcpServerStatus(), nativeID, !workspace, subagents ? ["Task", "SendMessage"] : []); if (workspace) { // Native tools and settings may add servers and tools, but every tool and Skill Core declared must still be present. const declared = [...names, ...(workspace.skillNames.length ? ["Skill"] : []), ...(subagents ? ["Task", "SendMessage"] : [])]; diff --git a/packages/claude-sdk-adapter/src/function_bridge.ts b/packages/claude-sdk-adapter/src/function_bridge.ts index eaae027d9..354f455ea 100644 --- a/packages/claude-sdk-adapter/src/function_bridge.ts +++ b/packages/claude-sdk-adapter/src/function_bridge.ts @@ -12,7 +12,8 @@ export type FunctionResult = { content: InputContent[]; }; export type FunctionEvent = - | { type: "function_call"; call: { call_id: string; name: string; arguments: Record } } + | { type: "function_call"; call: { call_id: string; name: string; arguments: Record; native_id?: string; turn_id?: string } } + | { type: "function_withdrawn"; withdrawn: { call_id: string; native_id: string; turn_id: string } } | { type: "function_applied"; call_id: string; delivery_id: string }; type Pending = { resolve: (result: CallToolResult) => void; @@ -20,11 +21,15 @@ type Pending = { cleanup: () => void; result?: FunctionResult; aborted?: boolean; + child?: FunctionCall["child"]; }; +// Native stopped waiting for the call, so no result can be applied. +class Abandoned extends Error {} export class FunctionBridge { private readonly pending = new Map(); private readonly seen = new Set(); + private readonly withdrawn = new Set(); constructor(private readonly emit: (event: FunctionEvent) => Promise) {} readonly invoke: FunctionHandler = async (call: FunctionCall, signal: AbortSignal) => { @@ -36,16 +41,24 @@ export class FunctionBridge { const pending = this.pending.get(call.id); if (pending?.result) pending.aborted = true; else this.pending.delete(call.id); - reject(new Error("Native function call aborted.")); + reject(new Abandoned("Native function call aborted.")); }; signal.addEventListener("abort", stop, { once: true }); - this.pending.set(call.id, { resolve, reject, cleanup: () => signal.removeEventListener("abort", stop) }); + this.pending.set(call.id, { resolve, reject, cleanup: () => signal.removeEventListener("abort", stop), child: call.child }); }); - const [, result] = await Promise.all([ - this.emit({ type: "function_call", call: { call_id: call.id, name: call.name, arguments: call.arguments } }), - waiting, - ]); - return result; + try { + const [, result] = await Promise.all([ + this.emit({ type: "function_call", call: { call_id: call.id, name: call.name, arguments: call.arguments, ...call.child } }), + waiting, + ]); + return result; + } catch (error) { + if (call.child && error instanceof Abandoned) { + this.withdrawn.add(call.id); + await this.emit({ type: "function_withdrawn", withdrawn: { call_id: call.id, ...call.child } }); + } + throw error; + } }; submit(line: string): void { @@ -63,6 +76,8 @@ export class FunctionBridge { throw new Error("Native error results cannot retain images."); } const pending = this.pending.get(result.call_id); + // The host may send a result before it reads the withdrawal; native no longer waits for it. + if (!pending && this.withdrawn.has(result.call_id)) return; if (!pending || pending.result) throw new Error("Function result is not pending."); pending.result = result; pending.resolve({ content: result.content.map(part => { @@ -73,12 +88,12 @@ export class FunctionBridge { } async consume(message: SDKMessage, sessionID: string, cancelled = false): Promise { - if (message.type !== "user" || message.parent_tool_use_id !== null || message.session_id !== sessionID || + if (message.type !== "user" || message.session_id !== sessionID || message.isSynthetic || ("isReplay" in message && message.isReplay) || !Array.isArray(message.message.content)) return; for (const block of message.message.content) { if (block.type !== "tool_result") continue; const pending = this.pending.get(block.tool_use_id); - if (!pending || pending.aborted) continue; + if (!pending || pending.aborted || (message.parent_tool_use_id !== null) !== !!pending.child) continue; const result = pending.result; if (!result) { if (!cancelled || !block.is_error) throw new Error("Native response preceded host result."); @@ -104,7 +119,7 @@ export class FunctionBridge { if (pending.result || (callID !== undefined && id !== callID)) continue; pending.cleanup(); this.pending.delete(id); - pending.reject(new Error("Native function call cancelled.")); + pending.reject(new Abandoned("Native function call cancelled.")); } } diff --git a/packages/claude-sdk-adapter/src/functions.ts b/packages/claude-sdk-adapter/src/functions.ts index 791274dff..a0f430f3d 100644 --- a/packages/claude-sdk-adapter/src/functions.ts +++ b/packages/claude-sdk-adapter/src/functions.ts @@ -5,7 +5,8 @@ import { type Tool, type CallToolResult, } from "@modelcontextprotocol/sdk/types.js"; -export type FunctionCall = { id: string; name: string; arguments: Record }; +// A call of a Subagent names the native child and its Turn. +export type FunctionCall = { id: string; name: string; arguments: Record; child?: { native_id: string; turn_id: string } }; export type FunctionHandler = (call: FunctionCall, signal: AbortSignal) => Promise; export function createFunctionServer(definitions: (Tool & { deferLoading?: boolean })[], invoke: FunctionHandler): McpSdkServerConfigWithInstance { diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index d0bb6ed23..ab502581a 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -74,7 +74,7 @@ export class MCPProfile { }); try { if (!signal.aborted && await Promise.race([this.ready, interrupted]) && !signal.aborted && this.admitted && - input.hook_event_name === "PreToolUse" && input.agent_id === undefined && input.session_id === this.sessionID && + input.hook_event_name === "PreToolUse" && this.permitsActor(input.agent_id) && input.session_id === this.sessionID && (id === undefined || id === input.tool_use_id) && !this.unselected(input.tool_name)) return {}; return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Tool is outside the verified execution profile." } }; @@ -83,7 +83,8 @@ export class MCPProfile { } }; - constructor(private readonly declarations: (HTTPServer | StdioServer)[], private readonly functions: string[]) { + // permitsActor tells whether an agent may call tools: the root always, a Subagent once native has started it. + constructor(private readonly declarations: (HTTPServer | StdioServer)[], private readonly functions: string[], private readonly permitsActor: (id?: string) => boolean = id => id === undefined) { this.allowed = [...functions]; for (const server of declarations) { const prefix = `mcp__${server.server_label}__`; @@ -137,7 +138,7 @@ export class MCPProfile { } // With exact inventory the text profile accepts nothing but the declared servers and tools. - verify(inventory: string[], statuses: McpServerStatus[], sessionID: string, exact = true): void { + verify(inventory: string[], statuses: McpServerStatus[], sessionID: string, exact = true, extra: string[] = []): void { // Native status.config can contain expanded headers. Retain only identities; // never publish or persist the private SDK control response. this.admitted = false; @@ -171,8 +172,8 @@ export class MCPProfile { } if (matched !== declared.size + (this.functions.length ? 1 : 0) || !exact && [...expected.keys()].some(name => !inventory.includes(name)) || - exact && (inventory.length !== expected.size + this.functions.length || new Set(inventory).size !== inventory.length || - inventory.some(name => !expected.has(name) && !this.functions.includes(name)))) { + exact && (inventory.length !== expected.size + this.functions.length + extra.length || new Set(inventory).size !== inventory.length || + inventory.some(name => !expected.has(name) && !this.functions.includes(name) && !extra.includes(name)))) { throw new Error("unexpected native MCP inventory"); } this.identities.clear(); diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 122573fdf..dae31d174 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -56,8 +56,7 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { (!!request.tool_search !== deferred)) throw new Error("invalid_request"); if (request.subagents !== undefined) { const value = request.subagents as Record; - if (!value || typeof value !== "object" || Object.keys(value).length !== 1 || !Number.isSafeInteger(value.max_concurrent) || (value.max_concurrent as number) < 1 || - (request.functions as unknown[] | undefined)?.length || request.mcp_http_servers !== undefined) throw new Error("invalid_request"); + if (!value || typeof value !== "object" || Object.keys(value).length !== 1 || !Number.isSafeInteger(value.max_concurrent) || (value.max_concurrent as number) < 1) throw new Error("invalid_request"); } if (request.output_format !== undefined) { const format = request.output_format as Start["output_format"]; @@ -68,7 +67,6 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { parseNativeModelOptions(request.native_model_options); parseHTTPServers(request.mcp_http_servers); const workspace = parseWorkspace(request.workspace, request.cwd); - if (request.subagents && workspace?.mcp?.length) throw new Error("invalid_request"); if (request.require_history && !workspace) throw new Error("invalid_request"); if ((workspace && "mcp_http_servers" in request) || (request.type === "prepare" && !workspace)) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/src/subagent_history.ts b/packages/claude-sdk-adapter/src/subagent_history.ts index f2a45caed..24182a858 100644 --- a/packages/claude-sdk-adapter/src/subagent_history.ts +++ b/packages/claude-sdk-adapter/src/subagent_history.ts @@ -1,17 +1,19 @@ import { readFile, realpath, stat, writeFile, link, unlink } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { dirname, join, relative } from "node:path"; +import type { ToolIdentity } from "./mcp.js"; export type Fact = { type: "subagent_identity" | "subagent_turn" | "subagent_item" | "subagent_coordination"; fact: Record }; export type NativeRow = { type: string; uuid?: string; parentUuid?: string | null; sessionId?: string; agentId?: string; - isSidechain?: boolean; timestamp?: string; toolUseResult?: Record; + isSidechain?: boolean; timestamp?: string; toolUseResult?: Record; mcpMeta?: Record; message?: { id?: string; role?: string; content?: unknown; stop_reason?: string | null }; }; type Call = { id: string; name: string; input: Record; turn: string; result?: Record; error?: boolean; finished?: boolean }; export type Cancellation = { root: string; native_id: string; turn_id: string; source_item_id: string; last_item_id: string; completed_at_ms: number }; type Entry = { id: string; metadata: Record; rows: NativeRow[]; cancellations?: Cancellation[] }; type Child = { id: string; parent: string; call: Call; rows: NativeRow[]; opened: number; cancellations: Cancellation[] }; +const functionPrefix = "mcp__functions__"; const identity = /^[A-Za-z0-9_-]{1,160}$/; function fail(): never { throw new Error("invalid native child history"); } const blocks = (r: NativeRow): Record[] => Array.isArray(r.message?.content) ? r.message.content as Record[] : []; @@ -37,7 +39,10 @@ function calls(rows: NativeRow[]): Map { return result; } -export function projectHistory(root: string, rootRows: NativeRow[], entries: Entry[]): Fact[] { +// 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 function projectHistory(root: string, rootRows: NativeRow[], entries: Entry[], { live, tools = new Map() }: 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)); @@ -54,7 +59,7 @@ export function projectHistory(root: string, rootRows: NativeRow[], entries: Ent if (!own || receipt.root !== root || receipt.native_id !== id || receipt.source_item_id !== call.id || !rows.some(r => r.uuid === receipt.last_item_id) || !Number.isSafeInteger(receipt.completed_at_ms) || receipt.completed_at_ms < time(own)) fail(); } - if (call.result?.agentId !== id && !cancellations.some(r => r.turn_id === first.uuid)) fail(); + if (call.result?.agentId !== id && !(live ? live.has(id) : cancellations.some(r => r.turn_id === first.uuid))) fail(); children.set(id, { id, parent, call, rows, opened: time(first), cancellations }); } const ordered: Child[] = [], pending = new Map(children); @@ -68,8 +73,8 @@ 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(...projectChild(child, children)); - for (const call of byParent.get(root)!.values()) { + for (const child of ordered) facts.push(...(live ? [openTurn(child)] : projectChild(child, children, tools))); + if (!live) for (const call of byParent.get(root)!.values()) { const coordination = coordinationFact(call, "", children); if (coordination) facts.push({ type: "subagent_coordination", fact: coordination }); } @@ -88,7 +93,32 @@ function coordinationFact(call: Call, actor: string, children: Map): Fact[] { +// Core resolves a running child's call through its identity and open Turn. +function openTurn(child: Child): Fact { + const first = child.rows.filter(input).at(-1)!, created = time(first); + return { type: "subagent_turn", fact: { native_id: child.id, turn_id: first.uuid!, created_at_ms: created, started_at_ms: created, status: "in_progress" } }; +} + +// Maps a child's native tool call to its neutral observation; other native tools are not projected. +function observation(call: Call, tools: ReadonlyMap): Record | undefined { + if (call.name === "Bash") { + if (typeof call.input.command !== "string") fail(); + return { kind: "command", command: call.input.command }; + } + if (call.name.startsWith(functionPrefix)) return { kind: "function", name: call.name.slice(functionPrefix.length), arguments: call.input }; + const mcp = tools.get(call.name); + return mcp && { kind: "mcp", server: mcp.server, name: mcp.name, arguments: call.input }; +} + +// A native function result holds a string or text and base64 image blocks. +function functionContent(content: unknown): Record[] { + if (typeof content === "string") return [{ type: "input_text", text: content }]; + if (!Array.isArray(content)) fail(); + return content.map(b => b.type === "text" && typeof b.text === "string" ? { type: "input_text", text: b.text } + : 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[] { const facts: Fact[] = [], turns: NativeRow[][] = []; for (const row of child.rows) { if (input(row)) turns.push([]); @@ -140,17 +170,21 @@ function projectChild(child: Child, children: Map): Fact[] { const call = toolCalls.get(entry.id)!; const coordination = coordinationFact(call, child.id, children); if (coordination) { item(call.id, "subagent_coordination", coordination); continue; } - if (call.name !== "Bash") continue; - if (typeof call.input.command !== "string") fail(); - const results = rows.flatMap(row => blocks(row)).filter(b => b.type === "tool_result" && b.tool_use_id === call.id); + const native = observation(call, tools); + if (!native) continue; + const owners = rows.filter(row => blocks(row).some(b => b.type === "tool_result" && b.tool_use_id === call.id)); + const results = owners.flatMap(row => blocks(row)).filter(b => b.type === "tool_result" && b.tool_use_id === call.id); if (results.length > 1 || (!results.length && !cancellation)) fail(); - if (!results.length) { - item(call.id, "tool_call", { id: call.id, stage: "after", observation: { kind: "command", status: "failed", command: call.input.command, output: "Native execution was cancelled." } }); - continue; - } - const result = results[0], content = result.content; + const result = results[0], content = result?.content, owner = owners[0]; + // The SDK sends a row that answers one call with its native result and MCP metadata, as the root's MCP Items keep it. + const mcpResult = owner?.toolUseResult !== undefined && blocks(owner).filter(b => b.type === "tool_result").length === 1 + ? owner.mcpMeta ? { content: owner.toolUseResult, ...owner.mcpMeta } : owner.toolUseResult : content ?? null; const output = typeof content === "string" ? content : Array.isArray(content) ? content.filter(b => b.type === "text").map(b => b.text).join("\n") : ""; - item(call.id, "tool_call", { id: call.id, stage: "after", observation: { kind: "command", status: result.is_error ? "failed" : "completed", command: call.input.command, output } }); + const status = !result ? native.kind === "command" ? "failed" : "incomplete" : result.is_error ? "failed" : "completed"; + const outcome = native.kind === "command" ? { output: result ? output : "Native execution was cancelled." } + : native.kind === "mcp" && result ? { [result.is_error ? "error" : "output"]: mcpResult } + : result ? { content: functionContent(content) } : {}; + item(call.id, "tool_call", { id: call.id, stage: "after", observation: { ...native, status, ...outcome } }); } if (completed !== undefined && cancellation) fail(); const ended = completed ?? cancellation?.completed_at_ms; @@ -160,7 +194,10 @@ function projectChild(child: Child, children: Map): Fact[] { return facts; } -export async function readHistory(root: string, transcript: string, cwd: string, cancelled?: { at: number; admitted: ReadonlyMap }): Promise<{ facts: Fact[]; ids: string[] }> { +// With live, only that running child and its ancestors are read. +export type Read = Projection & { cancelled?: { at: number; admitted: ReadonlyMap }; child?: string }; + +export async function readHistory(root: string, transcript: string, cwd: string, { cancelled, child, ...projection }: Read = {}): Promise<{ facts: Fact[]; ids: string[] }> { if (!identity.test(root) || !process.env.CLAUDE_CONFIG_DIR) fail(); const state = await realpath(process.env.CLAUDE_CONFIG_DIR!); const path = await realpath(transcript); @@ -178,21 +215,24 @@ export async function readHistory(root: string, transcript: string, cwd: string, return lines.map(line => JSON.parse(line) as NativeRow); } const { getSubagentMessages, listSubagents } = await import("@anthropic-ai/claude-agent-sdk"); - const ids = await listSubagents(root, { dir: cwd }); + const location = (id: string) => join(dirname(path), root, "subagents", "agent-" + (identity.test(id) ? id : fail())); + let ids: string[], at: string; + if (child === undefined) ids = await listSubagents(root, { dir: cwd }); + else for (ids = [], at = child; at !== root && ids.length <= 64; at = JSON.parse(await read(location(at) + ".meta.json")).parentAgentId ?? root) ids.push(at); if (ids.length > 64 || new Set(ids).size !== ids.length) fail(); const entries: Entry[] = []; for (const id of ids) { - if (!identity.test(id)) fail(); - const base = join(dirname(path), root, "subagents", "agent-" + id); - const raw = await rows(base + ".jsonl"), metadata = JSON.parse(await read(base + ".meta.json")); + const base = location(id); // The SDK resolves the persisted conversation chain; raw records retain the - // native ownership and timestamps omitted by its public TypeScript shape. + // native ownership and timestamps omitted by its public TypeScript shape. A + // running child keeps appending, so the raw read follows the SDK read. const messages = await getSubagentMessages(root, id, { dir: cwd }); + const raw = await rows(base + ".jsonl"), metadata = JSON.parse(await read(base + ".meta.json")); const own = new Map(raw.filter(r => r.uuid).map(r => [r.uuid, r])); if (!messages.length || messages.some(m => !own.has(m.uuid))) fail(); const selected = messages.map(m => own.get(m.uuid)!); const cancellations: Cancellation[] = []; - const inputs = selected.filter(input); + const inputs = projection.live ? [] : selected.filter(input); for (let index = 0; index < inputs.length; index++) { const own = inputs[index]; if (!own.uuid || !identity.test(own.uuid)) fail(); @@ -210,7 +250,7 @@ export async function readHistory(root: string, transcript: string, cwd: string, } entries.push({ id, metadata, rows: selected, cancellations }); } - return { ids, facts: projectHistory(root, await rows(path), entries) }; + return { ids, facts: projectHistory(root, await rows(path), entries, projection) }; } // Link publishes a complete receipt without replacing an earlier effect. Native diff --git a/packages/claude-sdk-adapter/src/subagents.ts b/packages/claude-sdk-adapter/src/subagents.ts index e2e58321d..c33ef731d 100644 --- a/packages/claude-sdk-adapter/src/subagents.ts +++ b/packages/claude-sdk-adapter/src/subagents.ts @@ -1,5 +1,6 @@ import type { HookCallback, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { readHistory, type Fact } from "./subagent_history.js"; +import type { ToolIdentity } from "./mcp.js"; const deny = (reason: string) => ({ hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: reason } }); export class Subagents { @@ -10,16 +11,17 @@ export class Subagents { private readonly pendingNames = new Map(); private readonly running = new Map(); private readonly admitted = new Map(); + private readonly actors = new Map(); private session = ""; private transcript = ""; private hydrated = false; - constructor(private readonly cwd: string, private readonly limit: number, private resume: string | undefined) { + constructor(private readonly cwd: string, private readonly limit: number, private resume: string | undefined, private readonly tools: ReadonlyMap = new Map()) { if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("invalid_request"); } get complete(): boolean { return this.pending.size === 0 && this.running.size === 0; } beginTurn(): void { if (!this.complete) throw new Error("native child work is unsettled"); - this.observedCalls.clear(); this.admitted.clear(); + this.observedCalls.clear(); this.admitted.clear(); this.actors.clear(); } expectSession(id: string | undefined): void { this.resume = id; } permitsActor(id?: string): boolean { return id === undefined || this.known.has(id); } @@ -51,6 +53,19 @@ export class Subagents { } return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } }; }; + // A native MCP request names its tool call but not the agent that made it. + readonly noteTool: HookCallback = async input => { + if (input.hook_event_name === "PreToolUse" && input.agent_id !== undefined) this.actors.set(input.tool_use_id, input.agent_id); + return {}; + }; + // The child of a call, with the facts Core needs before it can resolve the call: the child's identity and its open Turn. + async child(call: string): Promise<{ native_id: string; turn_id: string; facts: Fact[] } | undefined> { + 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 }); + return { native_id: id, turn_id: facts[facts.length - 1].fact.turn_id as string, facts }; + } readonly stopped: HookCallback = async input => { if (input.hook_event_name === "Stop" && input.session_id === this.session && input.agent_id === undefined) this.transcript = input.transcript_path; return {}; @@ -92,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, cancelledAt === undefined ? undefined : { at: cancelledAt, admitted: this.admitted }); + const history = await readHistory(this.session, this.transcript, this.cwd, { tools: this.tools, ...(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/function_bridge.test.mjs b/packages/claude-sdk-adapter/tests/function_bridge.test.mjs index 60cdd2bfa..a45c10c80 100644 --- a/packages/claude-sdk-adapter/tests/function_bridge.test.mjs +++ b/packages/claude-sdk-adapter/tests/function_bridge.test.mjs @@ -137,3 +137,30 @@ test("native cancellation preserves a submitted result's missing receipt", async assert.throws(() => bridge.assertComplete(), /unconfirmed/); bridge.close(); }); + +test("a Subagent call carries its child, is confirmed by the child's own result and is withdrawn when native abandons it unanswered", { timeout: 5000 }, async () => { + const events = [], child = { native_id: "child", turn_id: "child-turn" }; + const bridge = new FunctionBridge(async event => { events.push(event); }); + const abort = new AbortController(); + const kept = bridge.invoke({ ...call("a"), child }, new AbortController().signal); + const dropped = bridge.invoke({ ...call("b"), child }, abort.signal); + const droppedCheck = assert.rejects(dropped, /aborted/); + assert.deepEqual(events[0], { type: "function_call", call: { call_id: "a", name: "lookup", arguments: { ids: ["same"] }, ...child } }); + bridge.submit(JSON.stringify(result("a"))); + await bridge.consume(native(result("a")), "native"); + assert.equal(events.length, 2); + await bridge.consume({ ...native(result("a")), parent_tool_use_id: "spawn" }, "native"); + assert.equal(events.length, 3); + assert.deepEqual(events[2], { type: "function_applied", call_id: "a", delivery_id: "delivery-a" }); + await kept; + abort.abort(); + await droppedCheck; + assert.deepEqual(events[3], { type: "function_withdrawn", withdrawn: { call_id: "b", ...child } }); + bridge.submit(JSON.stringify(result("b"))); + bridge.assertComplete(); + const late = new AbortController(), delivered = bridge.invoke({ ...call("c"), child }, late.signal); + bridge.submit(JSON.stringify(result("c"))); + await delivered; + late.abort(); + assert.throws(() => bridge.assertComplete(), /unconfirmed/); +}); diff --git a/packages/claude-sdk-adapter/tests/subagents.test.mjs b/packages/claude-sdk-adapter/tests/subagents.test.mjs index 17f98e7ba..e8fd222ff 100644 --- a/packages/claude-sdk-adapter/tests/subagents.test.mjs +++ b/packages/claude-sdk-adapter/tests/subagents.test.mjs @@ -153,3 +153,24 @@ test("SendMessage addresses a child by its spawned name and the model is reporte assert.equal(facts.find(e => e.fact.kind === "create_subagent_call").fact.model, "sonnet"); assert.deepEqual(facts.find(e => e.fact.kind === "send_subagent_input_call").fact.recipients, ["child"]); }); + +test("a running child projects its open Turn only; function and MCP calls of a finished child become observations", () => { + const { root, children } = fixture(), rows = children[0].rows, tools = new Map([["mcp__docs__find", { server: "docs", name: "find" }]]); + rows.splice(1, 0, + { ...rows[1], uuid: "calls", timestamp: timestamp(15), message: { id: "calls", content: [ + { type: "tool_use", id: "fn", name: "mcp__functions__lookup", input: { topic: "sky" } }, + { type: "tool_use", id: "doc", name: "mcp__docs__find", input: { q: "x" } }] } }, + { ...rows[0], uuid: "answers", parentUuid: "calls", timestamp: timestamp(16), message: { content: [ + { type: "tool_result", tool_use_id: "fn", content: [{ type: "text", text: "CODE" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }] }] } }, + { ...rows[0], uuid: "doc-answer", parentUuid: "answers", timestamp: timestamp(17), toolUseResult: [{ type: "text", text: "7" }], mcpMeta: { structuredContent: { value: 7 } }, + message: { content: [{ type: "tool_result", tool_use_id: "doc", content: [{ type: "text", text: "7" }] }] } }); + const observations = facts => facts.filter(e => e.type === "subagent_item" && e.fact.kind === "tool_call").map(e => e.fact.payload.observation); + assert.deepEqual(observations(projectHistory("root", root, children, { tools })), [ + { kind: "function", name: "lookup", arguments: { topic: "sky" }, status: "completed", content: [{ type: "input_text", text: "CODE" }, { type: "input_image", image_url: "data:image/png;base64,AA==" }] }, + { kind: "mcp", server: "docs", name: "find", arguments: { q: "x" }, status: "completed", output: { content: [{ type: "text", text: "7" }], structuredContent: { value: 7 } } }, + ]); + root.pop(); rows.pop(); rows.pop(); + const live = projectHistory("root", root, children, { live: new Map([["child", "spawn"]]) }); + assert.deepEqual(live.map(e => e.type), ["subagent_identity", "subagent_turn"]); + assert.deepEqual(live[1].fact, { native_id: "child", turn_id: "child-turn", created_at_ms: 1700000000010, started_at_ms: 1700000000010, status: "in_progress" }); +}); diff --git a/services/core/internal/execution/environment_capability_profiles_test.go b/services/core/internal/execution/environment_capability_profiles_test.go index ad8c9fe39..f75082574 100644 --- a/services/core/internal/execution/environment_capability_profiles_test.go +++ b/services/core/internal/execution/environment_capability_profiles_test.go @@ -25,8 +25,7 @@ func TestQualifiedClaudeCapabilitiesDoNotDependOnEnvironmentSource(t *testing.T) } agent["multi_agent"] = map[string]bool{"enabled": true} raw, _ = json.Marshal(map[string]any{"agent": agent, "environment": environment}) - // Structured output is native beside Subagents; function tools are not carried by the Subagent profile. - if err := (Policy{}).ValidateSessionConfiguration("claude_sdk", raw); (err == nil) != (feature == "structured") { + if err := (Policy{}).ValidateSessionConfiguration("claude_sdk", raw); err != nil { t.Fatalf("%s with Subagents: %v", feature, err) } } diff --git a/services/core/internal/execution/mcode_profile_test.go b/services/core/internal/execution/mcode_profile_test.go index 971fa3075..52975c0b0 100644 --- a/services/core/internal/execution/mcode_profile_test.go +++ b/services/core/internal/execution/mcode_profile_test.go @@ -14,7 +14,7 @@ func TestMCodeOperationQualification(t *testing.T) { {"subagents", `{"agent":{"model":"real-model","multi_agent":{"enabled":true}},"environment":{"type":"none"}}`, true}, {"functions", `{"agent":{"model":"real-model","tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]},"environment":{"type":"none"}}`, true}, {"functions hosted", `{"agent":{"model":"real-model","tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]},"environment":{"type":"openai_hosted"}}`, true}, - {"functions with subagents", `{"agent":{"model":"real-model","multi_agent":{"enabled":true},"tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]},"environment":{"type":"none"}}`, false}, + {"functions with subagents", `{"agent":{"model":"real-model","multi_agent":{"enabled":true},"tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]},"environment":{"type":"none"}}`, true}, {"reserved label", `{"agent":{"model":"real-model","tools":[{"type":"function","name":"f","parameters":{"type":"object"}},{"type":"mcp","server_label":"functions","connection_origin":"service","transport":{"type":"http","server_url":"https://example.invalid/mcp"}}]},"environment":{"type":"none"}}`, false}, {"mcp", `{"agent":{"model":"real-model","tools":[{"type":"mcp","server_label":"x","connection_origin":"service","transport":{"type":"http","server_url":"https://example.invalid/mcp"}}]},"environment":{"type":"none"}}`, true}, {"effort", `{"agent":{"model":"real-model","reasoning":{"effort":"high"}},"environment":{"type":"none"}}`, true},