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
140 changes: 140 additions & 0 deletions apps/daemon/internal/agent/claudesdk/combination_live_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//go:build linux

package claudesdk

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"github.com/google/uuid"
)

// TestLiveClaudeSDKNativeCombinations proves structured output beside multi_agent,
// a function tool and deferred tool search on the real SDK and provider.
func TestLiveClaudeSDKNativeCombinations(t *testing.T) {
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 == "" || endpoint == "" || model == "" || !filepath.IsAbs(proofRoot) {
t.Skip("real SDK/provider acceptance requires an entrypoint, a private key file, an endpoint, a model and an absolute proof directory")
}
key, err := os.ReadFile(keyFile)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(proofRoot, 0o700); err != nil {
t.Fatal(err)
}
root, err := os.MkdirTemp(proofRoot, "claude-combination-")
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=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=" + model, "ANTHROPIC_DEFAULT_OPUS_MODEL=" + model, "ANTHROPIC_DEFAULT_HAIKU_MODEL=" + model,
}}
schema := json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"],"additionalProperties":false}`)
lookup := func(deferred bool) []proto.FunctionTool {
return []proto.FunctionTool{{Name: "lookup", Description: "Return the synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`), DeferLoading: deferred}}
}
// A stateless JSON-RPC MCP server with one tool.
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var rpc struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &rpc)
var result string
switch rpc.Method {
case "initialize":
result = `{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"proof","version":"0"}}`
case "tools/list":
result = `{"tools":[{"name":"echo","description":"Return the synthetic MCP value.","inputSchema":{"type":"object","properties":{}}}]}`
case "tools/call":
result = `{"content":[{"type":"text","text":"synthetic-mcp-value"}]}`
default:
w.WriteHeader(http.StatusAccepted)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":` + string(rpc.ID) + `,"result":` + result + `}`))
}))
defer mcpServer.Close()
mcp := func(req *proto.PromptRequestPayload) {
req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "proof", ServerURL: mcpServer.URL}}
}
cases := map[string]func(*proto.PromptRequestPayload){
"multi_agent": func(req *proto.PromptRequestPayload) {
req.DisableSubagents, req.ObserveSubagentIdentities = false, true
},
"mcp": mcp,
"mcp_tool_search": func(req *proto.PromptRequestPayload) {
mcp(req)
req.FunctionTools, req.ToolSearch = lookup(true), true
},
"function": func(req *proto.PromptRequestPayload) { req.FunctionTools = lookup(false) },
"tool_search": func(req *proto.PromptRequestPayload) {
req.FunctionTools, req.ToolSearch = lookup(true), true
},
}
for name, configure := range cases {
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
req := proto.PromptRequestPayload{RunID: uuid.NewString(), ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true,
Input: proto.TextInput("Call the lookup tool if one is available and put its value in answer. Otherwise answer with the word ready."),
ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}},
AgentOptions: map[string]any{"model": model, "system_prompt": "Reply only through the structured output."}}
configure(&req)
out := make(chan proto.Envelope, 64)
running, err := startSingleTurn(ctx, config, req, out)
if err != nil {
t.Fatal(err)
}
defer running.Cancel(context.Background())
s := running.(*session)
var done, called bool
var content, failure string
for event := range out {
switch event.Type {
case proto.TypeFunctionCall:
var call proto.FunctionCallPayload
if err := event.DecodePayload(&call); err != nil {
t.Fatal(err)
}
called = true
text := "synthetic-verification-value"
if err := s.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: call.CallID, DeliveryID: uuid.NewString(), Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}); err != nil {
t.Fatal(err)
}
case proto.TypeError:
var payload proto.ErrorPayload
_ = json.Unmarshal(event.Payload, &payload)
failure = payload.Error
case proto.TypeDone:
var payload proto.DonePayload
_ = json.Unmarshal(event.Payload, &payload)
done, content = true, payload.Content
}
}
var answer struct {
Answer string `json:"answer"`
}
if !done || failure != "" || json.Unmarshal([]byte(content), &answer) != nil || answer.Answer == "" {
t.Fatalf("structured output failed: done=%v failure=%q content=%q", done, failure, content)
}
t.Logf("%s: function_called=%v answer=%q", name, called, answer.Answer)
})
}
}
18 changes: 10 additions & 8 deletions apps/daemon/internal/agent/claudesdk/execution_controls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,14 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) {
t.Fatal("lossy schema accepted")
}
request.ExecutionControls.OutputFormat.Schema = schema
request.DisableSubagents = false
if _, _, err := prepare(config, request); err == nil {
t.Fatal("unqualified subagent combination accepted")
request.DisableExecutionEnvironment = true
request.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}}
if start, _, err := prepare(config, request); err != nil || start.OutputFormat == nil {
t.Fatal("native structured output was narrowed by MCP", err)
}
}

func TestToolDiscoveryPreservesFrozenFunctionsAndRejectsOtherProfiles(t *testing.T) {
func TestToolDiscoveryPreservesFrozenFunctionsBesideOtherProfiles(t *testing.T) {
root := t.TempDir()
t.Setenv("OAC_RUNTIME_HOME", root)
config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")}
Expand All @@ -159,11 +160,12 @@ func TestToolDiscoveryPreservesFrozenFunctionsAndRejectsOtherProfiles(t *testing
if err != nil || !start.ToolSearch || !reflect.DeepEqual(start.Functions, request.FunctionTools) {
t.Fatal("function discovery changed native definitions", err)
}
request.DisableSubagents = false
if _, _, err := prepare(config, request); err == nil {
t.Fatal("unqualified combination admitted")
request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(`{"type":"object"}`)}}
request.ObserveMessages = true
if start, _, err := prepare(config, request); err != nil || !start.ToolSearch || start.OutputFormat == nil {
t.Fatal("native tool search was narrowed by structured output", err)
}
request.DisableSubagents = true
request.ExecutionControls = nil
request.ToolSearch = false
if _, _, err := prepare(config, request); err == nil {
t.Fatal("deferred definitions became eager")
Expand Down
8 changes: 0 additions & 8 deletions apps/daemon/internal/agent/claudesdk/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
)

var mcpLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
var mcpTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)

func validateMCP(req proto.PromptRequestPayload) error {
if req.MCPHTTPServers == nil {
Expand All @@ -41,13 +40,6 @@ func validateMCPServers(servers []proto.MCPHTTPServer) error {
return fmt.Errorf("claudesdk: unsupported HTTPS MCP bearer credential")
}
labels[server.ServerLabel] = true
if server.AllowedTools != nil {
for _, name := range *server.AllowedTools {
if !mcpTool.MatchString(name) {
return fmt.Errorf("claudesdk: unsupported MCP tool allowlist")
}
}
}
}
return nil
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/claudesdk/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestHTTPMCPDeclaration(t *testing.T) {
req.DisableExecutionEnvironment = false
}
start, _, err := prepare(config, req)
valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" || mode == "query"
valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" || mode == "query" || mode == "wildcard"
if (err == nil) != valid {
t.Fatalf("unexpected admission: %v", err)
}
Expand Down
11 changes: 3 additions & 8 deletions apps/daemon/internal/agent/claudesdk/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
if err := req.ValidateToolSearch(true); err != nil {
return startRequest{}, nil, err
}
if req.ToolSearch {
if (req.LocalEnvironment != nil && (len(req.LocalEnvironment.Skills) != 0 || len(req.LocalEnvironment.MCP) != 0)) || req.MCPHTTPServers != nil || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) {
return fail("tool discovery requires the single-agent text/function profile")
}
start.ToolSearch = true
}
start.ToolSearch = req.ToolSearch
if err := validateMCP(req); err != nil {
return startRequest{}, nil, err
}
Expand All @@ -91,8 +86,8 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
}
if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil {
format := req.ExecutionControls.OutputFormat
if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && (len(req.LocalEnvironment.MCP) != 0 || len(req.LocalEnvironment.Skills) != 0)) {
return fail("structured output requires the qualified message-observing single-agent function profile")
if format.Type != "json_schema" || !req.ObserveMessages {
return fail("structured output requires message observation")
}
if err := proto.ValidateBinary64Schema(format.Schema); err != nil {
return startRequest{}, nil, err
Expand Down
6 changes: 3 additions & 3 deletions contracts/agents-api/execution-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ The Worker fails previously claimed work without replay after execution loss; qu

Core carries the schema in `ExecutionControls.OutputFormat` and requires structured-output support in the Harness declaration plus the Runtime's `structured_output` and message-observation capabilities, only for requests that use the option. Frozen schemas reach preparation before input and apply to initial and resumed execution; Start cannot replace them.

The Claude adapter passes `outputFormat` to the pinned SDK and allows its native `StructuredOutput` terminal tool, which is internal and never an extra caller function. A matching live root tool result and an attributed successful SDK result confirm the output. The adapter publishes the native `result.result` string unchanged as a completed `final_answer` message with the native tool-use ID; parent assistant prose keeps its own ID. Unvalidated retries and cancelled candidates never become the answer, and the adapter never serializes `structured_output` back to JSON. The stream follows the official message sequence with the whole text in one `output_text.delta`. The bridge advertises the operation only when it reports `structured_output`, and a workspace Runtime also needs `workspace_structured_output`.
The Claude adapter passes `outputFormat` to the pinned SDK and allows its native `StructuredOutput` terminal tool, which is internal and never an extra caller function. Natively the tool is added beside Subagents, Skills, MCP and ToolSearch, and the adapter's exact tool inventory counts it. A matching live root tool result and an attributed successful SDK result confirm the output. The adapter publishes the native `result.result` string unchanged as a completed `final_answer` message with the native tool-use ID; parent assistant prose keeps its own ID. Unvalidated retries and cancelled candidates never become the answer, and the adapter never serializes `structured_output` back to JSON. The stream follows the official message sequence with the whole text in one `output_text.delta`. The bridge advertises the operation only when it reports `structured_output`, and a workspace Runtime also needs `workspace_structured_output`.

The Codex adapter sends the schema unchanged as the native `outputSchema` on every root Turn, including Turns after resume or recovery, and publishes the native final agent message text unchanged. Codex reads JSON numbers losslessly, so Session admission applies no number or root-type limit beyond the common rules. A steered input joins the running Turn under its schema, and Subagent threads do not receive the schema. Codex passes the schema to the model provider as a strict Responses `text.format`; a provider that cannot run it returns its own error unchanged, and a provider that ignores it can return text that does not match the schema.

## Deferred function discovery

A `tool_search` tool has only `type`; Responses-only execution fields are rejected. Function `defer_loading` marks which definitions load lazily. Discovery requires both: `tool_search` without a deferred function, or deferred functions without `tool_search`, is rejected. The saved-Agent tool union keeps `tool_search`; the pinned Session response union omits it, so Session and SSE resources project it out while the frozen configuration keeps it. The pinned Items union has no tool-search Item, and Core invents none.

Core sends `PromptRequestPayload.ToolSearch` and each `FunctionTool.DeferLoading`, and requires `tool_search` support in both the Harness declaration and the Runtime. Native search and lazy schema loading belong to the adapter. The Claude adapter's MCP server marks eager definitions `anthropic/alwaysLoad:true` and deferred ones false, and enables native ToolSearch; the function profile allows only declared callbacks and ToolSearch besides the workspace's native tools. A workspace Runtime derives `tool_search` from the bridge's `workspace_tool_search` feature. The native Harness owns model and provider policy; known conflicting modes and beta settings reject in the adapter, and the SDK gives no reliable pre-input signal that deferral took effect after an opaque policy change.
Core sends `PromptRequestPayload.ToolSearch` and each `FunctionTool.DeferLoading`, and requires `tool_search` support in both the Harness declaration and the Runtime. Native search and lazy schema loading belong to the adapter. The Claude adapter's MCP server marks eager definitions `anthropic/alwaysLoad:true` and deferred ones false, and sets `ENABLE_TOOL_SEARCH=true`; the `none` profile allows declared callbacks, declared MCP tools and ToolSearch besides the workspace's native tools. A workspace Runtime derives `tool_search` from the bridge's `workspace_tool_search` feature. The native Harness owns model, provider and beta-setting policy; the adapter rejects none of them, and the SDK gives no reliable pre-input signal that deferral took effect.

## Web search and programmatic tool calling

Expand Down Expand Up @@ -92,4 +92,4 @@ Execution carries the resolved values to the Harness and validates them against
- Bearer authentication uses an attached static or OAuth Vault credential. [Vault credentials](./vaults.md) owns selection, and [MCP credential authority](./environments.md#public-mcp-connection-origin) owns the frozen Runtime binding. Authenticated execution requires `mcp_http_bearer_auth`.
- The Runtime must advertise `mcp_http_tools`. The native Harness owns discovery, calls and results; public `mcp_call` Items use the original server and tool names and keep the observed native result.

Codex trusts the workspace natively, so its project configuration loads on start and cold resume, and undeclared servers, apps and plugins keep their native behavior. Before any native connection that carries a Core credential, it verifies that each declared server keeps its URL or command, bearer reference and headers, and fails the start or resume with an error otherwise. It rejects reserved native labels and stored native MCP credentials. Claude accepts labels of ASCII letters, digits, underscore and hyphen except `functions`, tool names that may also contain dots, and requires connected servers with static inventories. Anonymous Claude requests send a blank Authorization header to suppress native OAuth injection. Native OAuth login is not supported.
Codex trusts the workspace natively, so its project configuration loads on start and cold resume, and undeclared servers, apps and plugins keep their native behavior. Before any native connection that carries a Core credential, it verifies that each declared server keeps its URL or command, bearer reference and headers, and fails the start or resume with an error otherwise. It rejects reserved native labels and stored native MCP credentials. Claude accepts labels of ASCII letters, digits, underscore and hyphen except `functions`, any tool name (the native client normalizes it, and the adapter rejects normalized collisions), and requires connected servers with static inventories. Anonymous Claude requests send a blank Authorization header to suppress native OAuth injection. Native OAuth login is not supported.
2 changes: 1 addition & 1 deletion contracts/agents-api/harness-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Native model parameters and provider protocols per Harness are in [model executi
| [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 | Rejected | Rejected | Rejected |

Claude structured output requires a single Agent, medium verbosity and no Skills, Plugins, capability directories, MCP or `tool_search`. Claude deferred discovery requires a single Agent, only function tools besides `tool_search` and the disabled controls, no Skills, Plugins or capability directories and no structured output. 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. The Subagent profile does not carry function tools, 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`).

## Environment preparation

Expand Down
Loading
Loading