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
12 changes: 11 additions & 1 deletion apps/daemon/internal/agent/claudesdk/execution_controls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,26 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"}
request.ExecutionControls = &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "disabled", ContextSize: "medium"}, TextVerbosity: "medium"}
before, _ := json.Marshal(request)
controlled, _, err := prepare(config, request)
if err != nil {
t.Fatal(err)
}
after, _ := json.Marshal(request)
if !controlled.DisableWebSearch || ordinary.DisableWebSearch {
t.Fatal("disabled web search did not reach the bridge")
}
controlled.DisableWebSearch = false
if !reflect.DeepEqual(ordinary, controlled) || string(before) != string(after) {
t.Fatal("default controls changed native input, instructions, continuation or caller options")
}
// Without a search control Claude Code keeps its native web tools.
request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"}
absent, _, err := prepare(config, request)
if err != nil || !reflect.DeepEqual(ordinary, absent) {
t.Fatal("absent search control changed the native configuration", err)
}
}

func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) {
Expand Down
11 changes: 9 additions & 2 deletions apps/daemon/internal/agent/claudesdk/executor_turn.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) {
terminal := false
mcp := mcpState{calls: map[string]proto.ToolObservation{}}
commands := commandState{calls: map[string]proto.ToolObservation{}}
searches := searchState{calls: map[string]proto.ToolObservation{}}
settlementReceived := false
settlementConfirmed := false
cancelled := false
Expand Down Expand Up @@ -92,6 +93,11 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) {
failure = err
s.invalidate()
}
case "search_observation":
if err := searches.receive(event, start, s.inputSessionID(), emit); err != nil {
failure = err
s.invalidate()
}
case "mcp_observation":
if err := mcp.receive(event, start, emit); err != nil {
failure = err
Expand Down Expand Up @@ -159,7 +165,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) {
}
emit(proto.TypeUsage, proto.UsagePayload{Usage: usage})
case "result":
if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() {
if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() || !searches.complete() {
failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result")
s.settlementErr = failure
s.invalidate()
Expand Down Expand Up @@ -194,11 +200,12 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) {
}
// Close result admission before deciding which unanswered calls cancellation settled.
s.stopFunctions()
if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() {
if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() || !searches.complete() {
settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations"
}
mcp.close(emit)
commands.close(emit)
searches.close(emit)
s.stopSteering()
metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"}
if id := s.inputSessionID(); id != "" {
Expand Down
7 changes: 6 additions & 1 deletion apps/daemon/internal/agent/claudesdk/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type subagentOptions struct {
type startRequest struct {
NativeModelOptions *nativeModelOptions `json:"native_model_options,omitempty"`
ToolSearch bool `json:"tool_search,omitempty"`
DisableWebSearch bool `json:"disable_web_search,omitempty"`
Subagents *subagentOptions `json:"subagents,omitempty"`
OutputFormat *proto.OutputFormat `json:"output_format,omitempty"`
Type string `json:"type"`
Expand Down Expand Up @@ -81,6 +82,10 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
if err := validateMCP(req); err != nil {
return startRequest{}, nil, err
}
// Without a search control Claude Code keeps its native web tools.
if controls := req.ExecutionControls; controls != nil && controls.WebSearch != nil {
start.DisableWebSearch = controls.WebSearch.Mode == "disabled"
}
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)) {
Expand Down Expand Up @@ -163,7 +168,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
}
}
env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider)
env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1")
env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"))
projectedMCP, mcpEnv, err := prepareRuntimeMCP(req)
if err != nil {
return startRequest{}, nil, err
Expand Down
52 changes: 52 additions & 0 deletions apps/daemon/internal/agent/claudesdk/searches.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package claudesdk

import (
"fmt"

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

type searchState struct {
calls map[string]proto.ToolObservation
}

func (c *searchState) receive(event bridgeEvent, start startRequest, sessionID string, emit func(string, any)) error {
n := event.Observation
if start.Workspace == nil || sessionID == "" || event.SessionID != sessionID || event.ID == "" || n == nil ||
n.Kind != "web_search" || n.Action == nil || (n.Action.Type != "search" && n.Action.Type != "open_page") ||
(n.Action.Type == "search") != (n.Action.Query != nil) || (n.Action.Type == "open_page") != (n.Action.URL != nil) ||
n.Name != "" || n.Command != "" || n.Cwd != nil || n.ExitCode != nil || n.DurationMS != nil || n.Server != "" ||
len(n.Arguments) != 0 || len(n.Output) != 0 || len(n.Error) != 0 || n.Content != nil {
return fmt.Errorf("claudesdk: invalid web search observation")
}
previous, exists := c.calls[event.ID]
if (event.Stage == "before" && (exists || n.Status != "in_progress")) ||
(event.Stage == "after" && (!exists || previous.Status != "in_progress" ||
(n.Status != "completed" && n.Status != "failed" && n.Status != "incomplete"))) ||
(event.Stage != "before" && event.Stage != "after") {
return fmt.Errorf("claudesdk: inconsistent web search observation")
}
c.calls[event.ID] = *n
emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: "web_search", Stage: event.Stage, Observation: n})
return nil
}

func (c *searchState) complete() bool {
for _, call := range c.calls {
if call.Status == "in_progress" {
return false
}
}
return true
}

func (c *searchState) close(emit func(string, any)) {
for id, call := range c.calls {
if call.Status != "in_progress" {
continue
}
call.Status = "incomplete"
c.calls[id] = call
emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: "web_search", Stage: "after", Observation: &call})
}
}
47 changes: 47 additions & 0 deletions apps/daemon/internal/agent/claudesdk/searches_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package claudesdk

import (
"testing"

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

func searchEvent(id, stage, status string, action *proto.ToolWebSearchAction) bridgeEvent {
return bridgeEvent{Type: "search_observation", SessionID: "native-session", ID: id, Stage: stage,
Observation: &proto.ToolObservation{Kind: "web_search", Status: status, Action: action}}
}

func TestSearchObservationLifecycle(t *testing.T) {
state := searchState{calls: map[string]proto.ToolObservation{}}
start := startRequest{Workspace: &workspaceProfile{}}
var events []proto.ToolCallPayload
emit := func(kind string, payload any) { events = append(events, payload.(proto.ToolCallPayload)) }
query, url := "agents api", "https://example.com"
search := &proto.ToolWebSearchAction{Type: "search", Query: &query}
if err := state.receive(searchEvent("one", "before", "in_progress", search), start, "native-session", emit); err != nil || state.complete() {
t.Fatal("search was not pending", err)
}
if err := state.receive(searchEvent("one", "before", "in_progress", search), start, "native-session", emit); err == nil {
t.Fatal("duplicate search accepted")
}
if err := state.receive(searchEvent("one", "after", "completed", search), start, "native-session", emit); err != nil || !state.complete() {
t.Fatal("search did not complete", err)
}
open := &proto.ToolWebSearchAction{Type: "open_page", URL: &url}
if err := state.receive(searchEvent("two", "before", "in_progress", open), start, "native-session", emit); err != nil {
t.Fatal(err)
}
state.close(emit)
if state.calls["two"].Status != "incomplete" || len(events) != 4 || events[3].Observation.Action.URL == nil {
t.Fatal("unfinished page open was not closed", events)
}
for name, event := range map[string]bridgeEvent{
"action": searchEvent("bad", "before", "in_progress", &proto.ToolWebSearchAction{Type: "find_in_page", Pattern: &query}),
"missing": searchEvent("bad", "before", "in_progress", &proto.ToolWebSearchAction{Type: "search"}),
"session": {Type: "search_observation", SessionID: "other", ID: "bad", Stage: "before", Observation: &proto.ToolObservation{Kind: "web_search", Status: "in_progress", Action: search}},
} {
if err := state.receive(event, start, "native-session", emit); err == nil {
t.Fatal("invalid observation accepted", name)
}
}
}
4 changes: 3 additions & 1 deletion apps/daemon/internal/agent/claudesdk/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ func launch(ctx context.Context, config Config, start startRequest, env []string
if binary == "" {
binary = "node"
}
process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true})
// Node's fetch honours the operator's proxy only when asked; unlike NODE_USE_ENV_PROXY, the flag stays with
// the bridge, so native tools run with the environment they would have locally.
process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"--use-env-proxy", config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true})
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/claudesdk/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) {
env = append(env, entry)
}
}
env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1")
env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir)
seen := map[string]bool{}
for _, entry := range config.Env {
name, _, ok := strings.Cut(entry, "=")
Expand Down
1 change: 0 additions & 1 deletion apps/daemon/internal/agent/mcode/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload) (launchO
"features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false},
}}
}
config["permissionMode"] = "auto"
data, err := json.Marshal(config)
if err != nil {
return result, err
Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/internal/agent/mcode/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ func TestOptionsRefreshManagedState(t *testing.T) {
if json.Unmarshal(data, &cfg) != nil {
t.Fatal("invalid config")
}
if cfg["permissionMode"] != "auto" {
t.Fatal("native permission mode changed")
if _, set := cfg["permissionMode"]; set {
t.Fatal("text profile restated the native permission mode")
}
if cfg["skills"].(map[string]any)["external"].(map[string]any)["enabled"] != false {
t.Fatal("external discovery enabled")
Expand Down
1 change: 0 additions & 1 deletion apps/daemon/internal/agent/mcode/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ func prepareWorkspaceOptions(runtimeRoot string, c WorkspaceConfig, req proto.Pr
return opts, err
}
config["permissionMode"] = "bypassPermissions"
config["sandbox"] = map[string]bool{"enabled": false}
if len(req.LocalEnvironment.Skills) > 0 {
selected := config["agents"].(map[string]any)["default"].(map[string]any)
names := make([]string, 0, len(req.LocalEnvironment.Skills))
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ Skills and their immutable versions are Project resources, independent of Sessio
- Bundles are encrypted with the service cipher, bound to Project, Skill and version. Metadata reads never load or decrypt bundles. Deleting a Skill reclaims its versions without affecting frozen Sessions.
- References resolve inside the Session creation transaction, after the upsert establishes ownership. Referenced resources are locked in a stable order, and the version, metadata and bytes freeze together. Public reference metadata, unresolved Template intent and the resolved Runtime bundle stay distinct; a reference is never reported as inline.

Inline and referenced Skills use the same confidential snapshot and installer. The Runtime installs them under `skills/<name>` in its capability root before setup and native execution. Adapters register only the selected Skill roots: Codex as explicit extra roots, MiniMax Code through its native catalog, and Claude as one controlled envelope per package with real directories and immutable hard links. Automatic native MCP discovery stays disabled. Codex nested `SKILL.md` discovery, `agents/openai.yaml` dependency configuration and Claude inline shell preprocessing fail adapter preparation. Native Harness configuration is never passed through wholesale.
Inline and referenced Skills use the same confidential snapshot and installer. The Runtime installs them under `skills/<name>` in its capability root before setup and native execution. Adapters register only the selected Skill roots: Codex as explicit extra roots, MiniMax Code through its native catalog, and Claude as one controlled envelope per package with real directories and immutable hard links. Skill roots activate no MCP servers; only [Plugin MCP](#plugin-mcp) does. Codex nested `SKILL.md` discovery, `agents/openai.yaml` dependency configuration and Claude inline shell preprocessing fail adapter preparation. Native Harness configuration is never passed through wholesale.

### Plugin MCP

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 @@ -47,7 +47,7 @@ The Claude adapter passes `outputFormat` to the pinned SDK and allows its native

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 selected workspace 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 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.

## Web search and programmatic tool calling

Expand All @@ -60,14 +60,14 @@ Core sends `PromptRequestPayload.ToolSearch` and each `FunctionTool.DeferLoading

Saved Agents keep every pinned `web_search` mode: omitted or null is saved as `live`, and `cached` and `live` as sent ([saved modes](./wire-semantics.md#saved-configuration)). Search settings are resource data: omitted or null `context_size` resolves to `medium`; omitted domains and location resolve to null; an empty domain list stays empty; a supplied location, including `{}`, has `city`, `country`, `region` and `timezone`, null where omitted.

Execution carries the resolved values to the Harness and validates them against its declaration in `internal/harnessconfig` (`Execution`): the supported web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. A value outside the declaration is rejected at Session admission with a message naming the field, unless the Session replaces the saved tools. Core sends an option only when the Agent sets it or the pinned default applies, so an omitted `web_search` or `programmatic_tool_calling` keeps each Harness's native behavior. For programmatic calling this differs from the official default-on behavior. Unrelated native utility tools are not removed.
Execution carries the resolved values to the Harness and validates them against its declaration in `internal/harnessconfig` (`Execution`): the supported web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. A value outside the declaration is rejected at Session admission with a message naming the field, unless the Session replaces the saved tools. Core sends an option only when the Agent sets it or the pinned default applies, so an omitted `web_search` or `programmatic_tool_calling` keeps each Harness's native behavior. For programmatic calling this differs from the official default-on behavior. Unrelated native utility tools are not removed. Claude Code on a workspace placement also keeps `WebSearch` and `WebFetch` when the control is omitted; [Turns and Items](./sessions-events.md#turns-and-items) gives their `web_search_call` actions.

`ExecutionControls` carries the options ([Runtime protocol](../../docs/runtime-protocol.md#capability-declarations)). The Runtime needs only the `execution_controls` capability.

| Harness | Web search | Programmatic tool calling |
| --- | --- | --- |
| Codex | `disabled`, `cached` and `live`, with `context_size`, `allowed_domains` and `location`, as native `web_search` and `tools.web_search` settings | `enabled: true` turns on native `code_mode`; `false` turns off `code_mode`, `code_mode_only` and `code_mode_prewarm` and checks native managed requirements before a thread starts or resumes |
| Claude Code | `disabled` only, with the restricted built-in inventory verified at native initialization | `false` only |
| Claude Code | `disabled` only. On a workspace placement the native `claude_code` tool set runs without ask-user, scheduling, workflow, background and `ListAgents` tools, and `disabled` removes `WebSearch` and `WebFetch`; `environment: none` keeps no built-in tools | `false` only |
| MiniMax Code | `disabled` only, through the native `features.webSearch=false` because the restricted profile has no search tool | `false` only |

## HTTP MCP
Expand Down
Loading
Loading