Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/claudesdk/executor_turn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 26 additions & 2 deletions apps/daemon/internal/agent/claudesdk/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"
Expand All @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions apps/daemon/internal/agent/claudesdk/functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
4 changes: 2 additions & 2 deletions apps/daemon/internal/agent/claudesdk/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 18 additions & 17 deletions apps/daemon/internal/agent/claudesdk/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
136 changes: 122 additions & 14 deletions apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
2 changes: 0 additions & 2 deletions apps/daemon/internal/agent/claudesdk/subagents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/execution-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading