From 0fadfaa64dc289a21c19e4f167bb66d37b599209 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 10:57:03 +0000 Subject: [PATCH 1/6] Run MiniMax Code functions through native MCP --- .../internal/agent/mcode/declaration_test.go | 2 +- .../agent/mcode/environment_mcp_test.go | 14 +- apps/daemon/internal/agent/mcode/executor.go | 11 + .../internal/agent/mcode/executor_turn.go | 2 + apps/daemon/internal/agent/mcode/functions.go | 207 ++++++++++++++++++ .../agent/mcode/functions_native_test.go | 78 +++++++ .../internal/agent/mcode/functions_test.go | 158 +++++++++++++ .../internal/agent/mcode/mcp_observations.go | 6 +- apps/daemon/internal/agent/mcode/options.go | 4 +- apps/daemon/internal/agent/mcode/session.go | 3 +- .../internal/agent/mcode/session_test.go | 12 +- .../internal/agent/mcode/tool_observations.go | 8 + .../internal/agent/mcode/unsupported.go | 6 - .../internal/agent/mcode/unsupported_test.go | 3 - .../agent/mcode/workspace_config_test.go | 4 +- contracts/agents-api/execution-tools.md | 2 +- contracts/agents-api/harness-capabilities.md | 2 +- contracts/agents-api/index.md | 3 +- contracts/agents-api/message-content.md | 2 +- contracts/agents-api/zh/execution-tools.md | 2 +- .../agents-api/zh/harness-capabilities.md | 2 +- contracts/agents-api/zh/index.md | 3 +- contracts/agents-api/zh/message-content.md | 2 +- internal/harnessconfig/mcode/declaration.go | 22 +- services/core/deploy/mcode/README.md | 2 +- .../internal/execution/mcode_profile_test.go | 5 +- 26 files changed, 526 insertions(+), 39 deletions(-) create mode 100644 apps/daemon/internal/agent/mcode/functions.go create mode 100644 apps/daemon/internal/agent/mcode/functions_native_test.go create mode 100644 apps/daemon/internal/agent/mcode/functions_test.go diff --git a/apps/daemon/internal/agent/mcode/declaration_test.go b/apps/daemon/internal/agent/mcode/declaration_test.go index 289cae1b8..44d6ffba5 100644 --- a/apps/daemon/internal/agent/mcode/declaration_test.go +++ b/apps/daemon/internal/agent/mcode/declaration_test.go @@ -26,7 +26,7 @@ func TestMCodeExecutionFollowsAvailability(t *testing.T) { if info.Available != available || info.Capabilities.EnvironmentNone.IsSupported() != available || info.Capabilities.DurableInputReceipts.IsSupported() != available || info.Capabilities.SubagentObservations.IsSupported() != available { t.Fatalf("capabilities=%+v", info.Capabilities) } - if info.Capabilities.NativeSessionRecovery.IsSupported() || info.Capabilities.LocalEnvironment.IsSupported() || info.Capabilities.FunctionTools.IsSupported() { + if info.Capabilities.NativeSessionRecovery.IsSupported() || info.Capabilities.LocalEnvironment.IsSupported() { t.Fatal("unqualified capability advertised") } }) diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index cfd062609..e0749054a 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -214,14 +214,12 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { t.Fatal("anonymous MCP inherited credentials") } dataDir := resource.(*executor).opts.DataDir - for _, name := range []string{"config.yaml", "mcp.json"} { - body, err := os.ReadFile(filepath.Join(dataDir, name)) - if err != nil && !os.IsNotExist(err) { - t.Fatal(err) - } - if strings.Contains(string(body), token) { - t.Fatal("MCP credential persisted in native configuration") - } + body, err := os.ReadFile(filepath.Join(dataDir, "config.yaml")) + if err != nil || strings.Contains(string(body), token) { + t.Fatal("MCP credential persisted in native configuration", err) + } + if _, err := os.Stat(filepath.Join(dataDir, "mcp.json")); !os.IsNotExist(err) { + t.Fatal("adapter wrote a native MCP file") } } } diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 47b5fc3f0..71062196a 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -58,6 +58,12 @@ func NewExecutorFactory(runtimeRoot string, config *WorkspaceConfig) agent.Execu return nil, err } opts.MCP = mcp + if opts.Functions, err = serveFunctions(req.FunctionTools); err != nil { + return nil, err + } + if opts.Functions != nil { + opts.MCP = append(opts.MCP, opts.Functions.mcpServer()) + } resource, err := newExecutor(ctx, req, opts, binary) if resource == nil { return nil, err @@ -69,9 +75,13 @@ func NewExecutorFactory(runtimeRoot string, config *WorkspaceConfig) agent.Execu func newExecutor(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*executor, error) { bootstrap, err := launch(ctx, req, opts, binary) if err != nil { + opts.Functions.close() return nil, err } e := &executor{connection: bootstrap.connection, req: req, opts: opts} + if opts.Functions != nil { + opts.Functions.owner.Store(e) + } err = bootstrap.prepareNative() e.connection.setCurrent(nil) close(bootstrap.finished) @@ -164,6 +174,7 @@ func (e *executor) Close(ctx context.Context) error { } e.connection.process.Cancel() e.mu.Unlock() + e.opts.Functions.close() select { case <-e.connection.exited: case <-ctx.Done(): diff --git a/apps/daemon/internal/agent/mcode/executor_turn.go b/apps/daemon/internal/agent/mcode/executor_turn.go index dc24cd17a..a34e18d5a 100644 --- a/apps/daemon/internal/agent/mcode/executor_turn.go +++ b/apps/daemon/internal/agent/mcode/executor_turn.go @@ -34,6 +34,7 @@ func (s *Session) runExecutorTurn(prompt string) { if len(s.tools) > 0 { reusable = false } + s.stopFunctions() s.finishEnvironmentMCP() s.mu.Lock() // ACP and native history cancellation do not prove detached tool cleanup. @@ -121,6 +122,7 @@ func (s *Session) Cancel(ctx context.Context) error { // context. After native settlement, cancellation retires and drains the owner. s.outputCancel() e.mu.Unlock() + s.stopFunctions() var err error if first { raw, _ := json.Marshal(map[string]string{"sessionId": s.sessionID}) diff --git a/apps/daemon/internal/agent/mcode/functions.go b/apps/daemon/internal/agent/mcode/functions.go new file mode 100644 index 000000000..cb2f4f34c --- /dev/null +++ b/apps/daemon/internal/agent/mcode/functions.go @@ -0,0 +1,207 @@ +package mcode + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/json" + "errors" + "net" + "net/http" + "sync" + "sync/atomic" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" +) + +// functionServer serves the Session's public functions as native MCP tools. It +// listens on loopback only and requires a per-Session bearer token, so only the +// native process that received the ACP server entry can call it. +type functionServer struct { + token string + url string + http *http.Server + done chan struct{} + owner atomic.Pointer[executor] + stopOnce sync.Once +} + +func serveFunctions(tools []proto.FunctionTool) (*functionServer, error) { + if len(tools) == 0 { + return nil, nil + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + f := &functionServer{token: rand.Text(), url: "http://" + listener.Addr().String() + "/mcp", done: make(chan struct{})} + server := mcp.NewServer(&mcp.Implementation{Name: declaration.FunctionServerLabel, Version: "1"}, nil) + for _, tool := range tools { + server.AddTool(&mcp.Tool{Name: tool.Name, Description: tool.Description, InputSchema: tool.Parameters}, f.call) + } + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + want := []byte("Bearer " + f.token) + f.http = &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + handler.ServeHTTP(w, r) + })} + go func() { + defer close(f.done) + _ = f.http.Serve(listener) + }() + return f, nil +} + +// mcpServer is the ACP mcpServers entry; the token stays in transient ACP headers. +func (f *functionServer) mcpServer() map[string]any { + return map[string]any{"name": declaration.FunctionServerLabel, "type": "http", "url": f.url, + "headers": []map[string]string{{"name": "Authorization", "value": "Bearer " + f.token}}} +} + +// close stops the listener and every connection, then waits for the serving goroutine. +func (f *functionServer) close() { + if f == nil { + return + } + f.stopOnce.Do(func() { _ = f.http.Close() }) + <-f.done +} + +// call runs on the HTTP handler goroutine; the active Turn owns the call. +func (f *functionServer) call(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + e := f.owner.Load() + if e == nil { + return nil, agent.ErrUnknownFunctionCall + } + e.mu.Lock() + s := e.active + e.mu.Unlock() + if s == nil { + return nil, agent.ErrUnknownFunctionCall + } + return s.callFunction(ctx, req.Params.Name, req.Params.Arguments) +} + +type pendingFunction struct { + call proto.FunctionCallPayload + delivered chan struct{} + stopped chan struct{} + receipt chan error + result *proto.FunctionResultPayload + content []mcp.Content + seq int +} + +// functionCalls is one Turn's pending calls. emit holds the lock so a stopped +// Turn never writes to its closed output. +type functionCalls struct { + mu sync.Mutex + pending map[string]*pendingFunction + next int + closed bool +} + +func (s *Session) callFunction(ctx context.Context, name string, arguments json.RawMessage) (*mcp.CallToolResult, error) { + if len(arguments) == 0 { + arguments = json.RawMessage("{}") + } + id := "call_" + rand.Text() + p := &pendingFunction{call: proto.FunctionCallPayload{CallID: id, Name: name, Arguments: arguments}, delivered: make(chan struct{}), stopped: make(chan struct{}), receipt: make(chan error, 1)} + s.functions.mu.Lock() + if s.functions.closed { + s.functions.mu.Unlock() + return nil, agent.ErrUnknownFunctionCall + } + s.functions.next++ + p.seq = s.functions.next + s.functions.pending[id] = p + s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: name, Stage: "before", Observation: &proto.ToolObservation{Kind: "function", Status: "in_progress", Name: name, Arguments: arguments}}) + s.emit(proto.TypeFunctionCall, p.call) + s.functions.mu.Unlock() + select { + case <-p.delivered: + return &mcp.CallToolResult{Content: p.content, IsError: !p.result.Success}, nil + case <-p.stopped: + return nil, agent.ErrUnknownFunctionCall + case <-ctx.Done(): + s.functions.mu.Lock() + delete(s.functions.pending, id) + p.receipt <- agent.ErrUnknownFunctionCall + s.functions.mu.Unlock() + return nil, ctx.Err() + } +} + +func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { + if err := result.ValidateContent(); err != nil { + return err + } + content := make([]mcp.Content, 0, len(result.Content)) + for _, part := range result.Content { + if part.Text == nil { + return errors.New("mcode: function result images are not supported") + } + content = append(content, &mcp.TextContent{Text: *part.Text}) + } + s.functions.mu.Lock() + p := s.functions.pending[result.CallID] + if s.functions.closed || p == nil || p.result != nil { + s.functions.mu.Unlock() + return agent.ErrUnknownFunctionCall + } + p.result, p.content = &result, content + close(p.delivered) + s.functions.mu.Unlock() + select { + case err := <-p.receipt: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// confirmFunction applies the native result: the oldest delivered, unconfirmed +// call to this function is the one that native reports. +func (s *Session) confirmFunction(update toolUpdate) { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + var match *pendingFunction + for _, p := range s.functions.pending { + if p.result != nil && p.call.Name == update.mcp.tool && (match == nil || p.seq < match.seq) { + match = p + } + } + if match == nil { + return + } + delete(s.functions.pending, match.call.CallID) + status := "completed" + if !match.result.Success { + status = "failed" + } + s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: match.call.CallID, Name: match.call.Name, Stage: "after", Observation: &proto.ToolObservation{Kind: "function", Status: status, Name: match.call.Name, Arguments: match.call.Arguments, Content: &match.result.Content}}) + match.receipt <- nil +} + +// stopFunctions ends the Turn's calls: native gets an error for each pending +// call and each unconfirmed submission fails. +func (s *Session) stopFunctions() { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + if s.functions.closed { + return + } + s.functions.closed = true + for id, p := range s.functions.pending { + close(p.stopped) + p.receipt <- agent.ErrUnknownFunctionCall + delete(s.functions.pending, id) + } +} diff --git a/apps/daemon/internal/agent/mcode/functions_native_test.go b/apps/daemon/internal/agent/mcode/functions_native_test.go new file mode 100644 index 000000000..aa8cbaafb --- /dev/null +++ b/apps/daemon/internal/agent/mcode/functions_native_test.go @@ -0,0 +1,78 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// This opt-in test makes real model calls: the model calls a public function, +// native receives the submitted result, and the Turn finishes with it. +func TestNativeMCodePublicFunction(t *testing.T) { + binary, options := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") + if binary == "" || options == "" { + t.Skip("native executable and private provider options required") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + raw, err := os.ReadFile(options) + if err != nil { + t.Fatal("private provider options unavailable") + } + req := testRequest(t) + req.RunID, req.Input = "", nil + if json.Unmarshal(raw, &req.AgentOptions) != nil { + t.Fatal("invalid private provider options") + } + req.FunctionTools = []proto.FunctionTool{{Name: "lookup_code", Description: "Returns the secret access code. Always call it when asked for the access code.", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}} + e, err := prepareExecutor(t, ctx, req) + if err != nil { + t.Fatal("native Executor preparation failed") + } + out := make(chan proto.Envelope, 256) + turn, err := e.StartTurn(ctx, "turn", proto.TextInput("Call the lookup_code function to get the access code, then reply with only the code."), out) + if err != nil { + t.Fatal("native Turn admission failed") + } + var content string + submitted, confirmed := make(chan error, 1), false + for event := range out { + switch event.Type { + case proto.TypeError: + t.Fatalf("native Turn reported an error: %s", event.Payload) + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + _ = json.Unmarshal(event.Payload, &call) + text := "ACCESS-CODE-4417" + go func() { + submitted <- turn.(*Session).SubmitFunctionResult(ctx, proto.FunctionResultPayload{DeliveryID: "delivery", CallID: call.CallID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}) + }() + case proto.TypeToolCall: + var call proto.ToolCallPayload + confirmed = confirmed || (json.Unmarshal(event.Payload, &call) == nil && call.Stage == "after" && call.Observation != nil && call.Observation.Kind == "function" && call.Observation.Status == "completed") + case proto.TypeDone: + var done proto.DonePayload + _ = json.Unmarshal(event.Payload, &done) + content = done.Content + } + } + if _, err := turn.AwaitSettlement(ctx); err != nil { + t.Fatal("native Turn did not settle") + } + select { + case err := <-submitted: + if err != nil { + t.Fatal("function result was not applied:", err) + } + default: + t.Fatal("the model never called the function") + } + if !confirmed || !strings.Contains(content, "ACCESS-CODE-4417") { + t.Fatalf("confirmed=%v content=%q", confirmed, content) + } +} diff --git a/apps/daemon/internal/agent/mcode/functions_test.go b/apps/daemon/internal/agent/mcode/functions_test.go new file mode 100644 index 000000000..33abfbadd --- /dev/null +++ b/apps/daemon/internal/agent/mcode/functions_test.go @@ -0,0 +1,158 @@ +package mcode + +import ( + "context" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type bearerTransport struct{ header string } + +func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", b.header) + return http.DefaultTransport.RoundTrip(r) +} + +// fakeFunctionTurn is the native side of a public function call: it reads the +// ACP Session's functions server, calls the tool and records what native got. +func fakeFunctionTurn(params json.RawMessage, update func(string, map[string]any), send func(any), promptID json.RawMessage) { + var session struct { + MCP []struct { + Name string `json:"name"` + URL string `json:"url"` + Headers []struct{ Name, Value string } + } `json:"mcpServers"` + } + if json.Unmarshal(params, &session) != nil || len(session.MCP) != 1 || session.MCP[0].Name != "functions" || writeMCPRegistry(os.Getenv("MINIMAX_DATA_DIR"), mcpRegistryEntry("functions", "functions", "lookup", "lookup")) != nil { + os.Exit(20) + } + server := session.MCP[0] + update("tool_call", map[string]any{"toolCallId": "t1", "name": "mcp__functions__lookup", "status": "in_progress", "rawInput": map[string]any{"q": "x"}}) + client := mcp.NewClient(&mcp.Implementation{Name: "fake", Version: "1"}, nil) + connection, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: server.URL, HTTPClient: &http.Client{Transport: bearerTransport{server.Headers[0].Value}}}, nil) + if err != nil { + os.Exit(21) + } + result, err := connection.CallTool(context.Background(), &mcp.CallToolParams{Name: "lookup", Arguments: map[string]any{"q": "x"}}) + text := "error" + if err == nil { + text = result.Content[0].(*mcp.TextContent).Text + } + if os.WriteFile(os.Getenv("OAC_TEST_FN_RESULT"), []byte(text), 0600) != nil { + return + } + update("tool_call_update", map[string]any{"toolCallId": "t1", "status": "completed", "rawOutput": "ignored"}) + raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) +} + +func functionTurn(t *testing.T, scenario string) (*Session, <-chan proto.Envelope, string) { + t.Helper() + req := helperRequest(t, scenario, false) + req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} + record := t.TempDir() + "/result" + t.Setenv("OAC_TEST_FN_RESULT", record) + out := make(chan proto.Envelope, 32) + session, err := startTurn(t, t.Context(), req, out) + if err != nil { + t.Fatal(err) + } + return session, out, record +} +func waitFunctionCall(t *testing.T, out <-chan proto.Envelope) proto.FunctionCallPayload { + t.Helper() + for event := range out { + if event.Type == proto.TypeFunctionCall { + var call proto.FunctionCallPayload + _ = json.Unmarshal(event.Payload, &call) + return call + } + } + t.Fatal("no function call") + return proto.FunctionCallPayload{} +} + +// Native calls the served tool, blocks until the result arrives, receives it, +// and the receipt is confirmed by the native tool result. +func TestFunctionCallRoundTrip(t *testing.T) { + session, out, record := functionTurn(t, "functions") + call := waitFunctionCall(t, out) + if call.Name != "lookup" || string(call.Arguments) != `{"q":"x"}` { + t.Fatalf("call = %+v", call) + } + functions := session.executor.opts.Functions + if response, err := http.Post(functions.url, "application/json", strings.NewReader("{}")); err != nil || response.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated request: %v", err) + } + text := "answer" + if err := session.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{DeliveryID: "d", CallID: call.CallID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(record); string(got) != "answer" { + t.Fatalf("native received %q", got) + } + confirmed := false + for event := range out { + var payload proto.ToolCallPayload + if event.Type == proto.TypeError { + t.Fatalf("error: %s", event.Payload) + } + if event.Type == proto.TypeToolCall && json.Unmarshal(event.Payload, &payload) == nil && payload.Stage == "after" && payload.Observation.Kind == "function" && payload.Observation.Status == "completed" { + confirmed = true + } + } + if !confirmed { + t.Fatal("function receipt was not observed") + } +} + +// A pending call fails when the Turn is cancelled, and closing the Session +// leaves no listener behind. +func TestFunctionCallCancellationReleasesPendingCall(t *testing.T) { + session, out, _ := functionTurn(t, "cancel-wait") + functions := session.executor.opts.Functions + client := mcp.NewClient(&mcp.Implementation{Name: "probe", Version: "1"}, nil) + connection, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: functions.url, HTTPClient: &http.Client{Transport: bearerTransport{"Bearer " + functions.token}}}, nil) + if err != nil { + t.Fatal(err) + } + released := make(chan error, 1) + go func() { + _, err := connection.CallTool(t.Context(), &mcp.CallToolParams{Name: "lookup", Arguments: map[string]any{}}) + released <- err + }() + call := waitFunctionCall(t, out) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := session.Cancel(ctx); err != nil { + t.Fatal(err) + } + select { + case err := <-released: + if err == nil { + t.Fatal("pending native call succeeded") + } + case <-ctx.Done(): + t.Fatal("pending native call was not released") + } + if err := session.SubmitFunctionResult(ctx, proto.FunctionResultPayload{DeliveryID: "d", CallID: call.CallID, Content: []proto.InputContent{}}); err == nil { + t.Fatal("result accepted after cancellation") + } + if err := session.executor.Close(ctx); err != nil { + t.Fatal(err) + } + select { + case <-functions.done: + default: + t.Fatal("function listener outlived the Session") + } +} diff --git a/apps/daemon/internal/agent/mcode/mcp_observations.go b/apps/daemon/internal/agent/mcode/mcp_observations.go index 00e016db1..a026881bd 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) type mcpToolIdentity struct{ server, tool string } @@ -24,7 +25,7 @@ func (s *Session) mcpIdentity(name string) (*mcpToolIdentity, error) { if err != nil { return nil, err } - if len(bindings) == 0 { + if len(bindings) == 0 && len(s.req.FunctionTools) == 0 { return nil, nil } raw, err := os.ReadFile(filepath.Join(s.opts.DataDir, "mcp-runtime-names.json")) @@ -49,6 +50,9 @@ func (s *Session) mcpIdentity(name string) (*mcpToolIdentity, error) { } var key []string declared := 0 + if server.Raw == declaration.FunctionServerLabel && len(s.req.FunctionTools) > 0 { + declared = 1 + } for _, item := range bindings { if item.ServerLabel == server.Raw { declared++ diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 1f8fc7dd3..703fdf0d7 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -16,6 +16,7 @@ type launchOptions struct { Dir, DataDir, Model string Env []string MCP []map[string]any + Functions *functionServer } // workspaceProfile selects the native tools of a Session that has an Environment. @@ -90,9 +91,6 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload, workspac } // Background bash outlives the Turn and escapes the process group; Subagent task output stays. result.Env = append(result.Env, "OAC_RUNTIME_MCODE_BACKGROUND_BASH=off") - if err := os.WriteFile(filepath.Join(result.DataDir, "mcp.json"), []byte(`{"mcpServers":{}}`), 0o600); err != nil { - return result, err - } if !req.DisableSubagents { result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) } else { diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index 4cd5468d1..49e67d1ee 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -48,6 +48,7 @@ type Session struct { previousNativeTurns map[string]bool rootCompletedAtMS *int64 subagentHistoryReady bool + functions *functionCalls } var _ agent.Session = (*Session)(nil) @@ -65,7 +66,7 @@ func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOpti } func newTurnSession(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, c *connection, out chan<- proto.Envelope) *Session { - return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), functions: &functionCalls{pending: map[string]*pendingFunction{}}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} } func (s *Session) prepareNative() error { diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index 4abb9ae5c..6aadc85e9 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "testing" "time" @@ -205,7 +206,10 @@ func TestMCodeProcess(t *testing.T) { return } encoder := json.NewEncoder(os.Stdout) + var sendMu sync.Mutex send := func(value any) { + sendMu.Lock() + defer sendMu.Unlock() if err := encoder.Encode(value); err != nil { os.Exit(2) } @@ -215,7 +219,7 @@ func TestMCodeProcess(t *testing.T) { send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "native-1", "update": fields}}) } scanner := bufio.NewScanner(os.Stdin) - var promptID json.RawMessage + var promptID, sessionParams json.RawMessage prompts, configurations := 0, 0 for scanner.Scan() { var frame rpcFrame @@ -260,6 +264,7 @@ func TestMCodeProcess(t *testing.T) { result = map[string]any{"protocolVersion": 1, "_meta": map[string]any{"oac/subagents": map[string]any{ "version": 2, "maxConcurrent": limit, "backgroundBash": scenario == "background-bash" || os.Getenv("OAC_RUNTIME_MCODE_BACKGROUND_BASH") != "off"}}} case "session/new", "session/load": + sessionParams = frame.Params if scenario == "prepared-mcp-cancel" { if writeMCPRegistry(os.Getenv("MINIMAX_DATA_DIR"), mcpRegistryEntry("proof.server", "proof_server", "read.status", "read_status")) != nil { os.Exit(12) @@ -321,6 +326,11 @@ func TestMCodeProcess(t *testing.T) { } continue } + if scenario == "functions" { + promptID = frame.ID + go fakeFunctionTurn(sessionParams, update, send, promptID) + continue + } if scenario == "prepared-mcp-cancel" { promptID = frame.ID update("tool_call", map[string]any{"toolCallId": "native-call", "name": "mcp__proof_server__read_status", "status": "in_progress", "rawInput": map[string]any{}}) diff --git a/apps/daemon/internal/agent/mcode/tool_observations.go b/apps/daemon/internal/agent/mcode/tool_observations.go index ffa9c6ee8..9267669d2 100644 --- a/apps/daemon/internal/agent/mcode/tool_observations.go +++ b/apps/daemon/internal/agent/mcode/tool_observations.go @@ -6,9 +6,17 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) func (s *Session) emitToolStage(update toolUpdate, stage string) error { + // Public functions report through the function call, not as MCP observations. + if update.mcp != nil && update.mcp.server == declaration.FunctionServerLabel { + if stage == "after" && update.Status != "incomplete" { + s.confirmFunction(update) + } + return nil + } payload := proto.ToolCallPayload{ID: update.ID, Name: update.Name, Stage: stage, Args: update.RawInput} if stage == "after" { payload.Result = map[string]any{"output": update.RawOutput, "status": update.Status} diff --git a/apps/daemon/internal/agent/mcode/unsupported.go b/apps/daemon/internal/agent/mcode/unsupported.go index e221bd595..ac35c4efa 100644 --- a/apps/daemon/internal/agent/mcode/unsupported.go +++ b/apps/daemon/internal/agent/mcode/unsupported.go @@ -2,15 +2,9 @@ package mcode import ( "context" - "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { - return fmt.Errorf("%w: native public function tools are not qualified", agent.ErrUnsupportedOperation) -} - func (s *executor) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported } diff --git a/apps/daemon/internal/agent/mcode/unsupported_test.go b/apps/daemon/internal/agent/mcode/unsupported_test.go index eb0609bb6..05a2afb0d 100644 --- a/apps/daemon/internal/agent/mcode/unsupported_test.go +++ b/apps/daemon/internal/agent/mcode/unsupported_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Nil concrete receivers prove Unsupported needs no native owner, transport, @@ -25,8 +24,6 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { t.Fatal("unsupported error disclosed input") } } - var turn *Session - check(turn.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: secret, DeliveryID: secret})) for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil)} { result, err := owner.ReadWorkspaceFile(ctx, secret, 1) check(err) diff --git a/apps/daemon/internal/agent/mcode/workspace_config_test.go b/apps/daemon/internal/agent/mcode/workspace_config_test.go index ee6aa6bd0..3e1c6b1f5 100644 --- a/apps/daemon/internal/agent/mcode/workspace_config_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_config_test.go @@ -65,8 +65,8 @@ func TestWorkspaceRunsNativeToolsWithoutExtraServers(t *testing.T) { if !slices.Contains(opts.Env, "OAC_RUNTIME_MCODE_BACKGROUND_BASH=off") { t.Fatal("background bash is not disabled") } - if mcp, err := os.ReadFile(filepath.Join(opts.DataDir, "mcp.json")); err != nil || string(mcp) != `{"mcpServers":{}}` { - t.Fatalf("native MCP configuration: %s %v", mcp, err) + if _, err := os.Stat(filepath.Join(opts.DataDir, "mcp.json")); !os.IsNotExist(err) { + t.Fatalf("adapter wrote a native MCP file: %v", err) } }) } diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 6a7a79c3b..6e6338c89 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -97,4 +97,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`, 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. +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. MiniMax Code serves public functions as the MCP server `functions`, so it rejects that label for public servers. Anonymous Claude requests send a blank Authorization header to suppress native OAuth injection. Native OAuth login is not supported. diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index 0b178d03a..302a0b972 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -34,7 +34,7 @@ Native model parameters and provider protocols per Harness are in [model executi | Operation | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | -| [Public functions](./execution-tools.md#functions) with text results | Verified: `none`, hosted; admitted: self-hosted | Verified on all placements; object-root schemas only | Rejected | +| [Public functions](./execution-tools.md#functions) with text results | Verified: `none`, hosted; admitted: self-hosted | Verified on all placements; object-root schemas only | Verified: `none`, hosted; admitted: self-hosted; object-root schemas only; a result must arrive within 120 s ([difference](./index.md#known-gaps)) | | [Function results with images](./message-content.md#function-results) | Verified: `none`, hosted; admitted: self-hosted; remote URLs: rejected | Successful results only; PNG and JPEG data URLs: verified on all placements; GIF and WebP: admitted | Rejected | | [Structured output](./execution-tools.md#structured-output) | Admitted on all placements | Verified on all placements | Rejected | | [Deferred function discovery](./execution-tools.md#deferred-function-discovery) | Rejected | Verified: `none`, self-hosted; admitted: hosted | Rejected | diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index b189df30f..0c5e78696 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -110,7 +110,8 @@ Each item is Core's deliberate or native behavior where the official service beh **Configuration and tools** - A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting or `programmatic_tool_calling` value that the selected Harness does not declare is saved but rejected at Session admission ([Harness capabilities](./harness-capabilities.md#execution-and-input)). -- Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no public functions and no image input. +- Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no image input. +- MiniMax Code serves public functions as a native MCP server, and the native MCP client fails a call after 120 s with no way to change it, so a function result must be submitted within 120 s. - Model-derived reasoning defaults are not resolved. - MCP tools support the `http` transport only, so `stdio` is rejected. An inline `authorization` on a Session MCP transport and an `Authorization` entry in `headers` are rejected too ([HTTP MCP](./execution-tools.md#http-mcp)). diff --git a/contracts/agents-api/message-content.md b/contracts/agents-api/message-content.md index 3ce1632f9..76800bb3e 100644 --- a/contracts/agents-api/message-content.md +++ b/contracts/agents-api/message-content.md @@ -51,7 +51,7 @@ An `agent.session.input.tool_result` event carries `success`, an optional nullab | --- | --- | | Codex | Text and ordered text/image output, passed to the Harness unchanged. An `http` or `https` image URL returns 400, because native Codex replaces a dynamic tool result that carries one with a failure | | Claude Code | Text output. Images only in successful results and only as canonical base64 data URLs of `image/png`, `image/jpeg`, `image/gif` or `image/webp`, because a native tool result carries no URL; an image in a failed result or a remote reference returns 400 before anything is stored, and the pending call stays open. The Harness may resize or re-encode an image, or replace one it cannot process with a text note, in its own history; public Items keep the submitted bytes | -| MiniMax Code | No public functions | +| MiniMax Code | Text output. An image returns 400, because native MCP image content is not qualified to reach the model | ### Application receipts diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 1b653d397..b38ca9254 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -99,4 +99,4 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi - Bearer 认证使用附加的静态或 OAuth Vault 凭据。[Vault 凭据](vaults.md)定义选择规则,[MCP 凭据权限](environments.md#public-mcp-connection-origin)定义冻结的 Runtime 绑定。认证执行要求 `mcp_http_bearer_auth`。 - Runtime 必须声明 `mcp_http_tools`。原生 Harness 管理发现、调用和结果;公开 `mcp_call` Item 使用原始服务器与工具名称,保留观察到的原生结果。 -Codex 以原生方式信任工作区,因此工作区的项目配置在启动和冷恢复时加载,未声明的服务器、apps 和 plugins 保持原生行为。在任何携带 Core 凭据的原生连接之前,它验证每个已声明服务器保持其 URL 或命令、bearer 引用和请求头,否则启动或恢复以错误失败。它拒绝原生保留标签和已存储原生 MCP 凭据。Claude 接受 ASCII 字母、数字、下划线和连字符组成的标签,但不允许 `functions`;接受任意工具名称(原生客户端会将其规范化,适配器拒绝规范化后的冲突),并要求已连接服务器具有静态工具清单。匿名 Claude 请求发送空 Authorization 头以阻止原生 OAuth 注入。不支持原生 OAuth 登录。 +Codex 以原生方式信任工作区,因此工作区的项目配置在启动和冷恢复时加载,未声明的服务器、apps 和 plugins 保持原生行为。在任何携带 Core 凭据的原生连接之前,它验证每个已声明服务器保持其 URL 或命令、bearer 引用和请求头,否则启动或恢复以错误失败。它拒绝原生保留标签和已存储原生 MCP 凭据。Claude 接受 ASCII 字母、数字、下划线和连字符组成的标签,但不允许 `functions`;接受任意工具名称(原生客户端会将其规范化,适配器拒绝规范化后的冲突),并要求已连接服务器具有静态工具清单。MiniMax Code 以 MCP 服务器 `functions` 提供公共函数,因此拒绝公共服务器使用该标签。匿名 Claude 请求发送空 Authorization 头以阻止原生 OAuth 注入。不支持原生 OAuth 登录。 diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index bff761590..b78552451 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -36,7 +36,7 @@ source_hash: 6ae7ae05948fe30ce66d86b0a3987c08456392d6a7ed66e71c09ec8b85044843 | 操作 | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | -| 文本结果的[公开函数](execution-tools.md#functions) | 已验证:`none`、托管;已准入:自托管 | 所有位置已验证;仅对象根 schema | 已拒绝 | +| 文本结果的[公开函数](execution-tools.md#functions) | 已验证:`none`、托管;已准入:自托管 | 所有位置已验证;仅对象根 schema | 已验证:`none`、托管;已准入:自托管;仅对象根 schema;结果须在 120 秒内到达([差异](index.md#known-gaps)) | | [含图像函数结果](message-content.md#function-results) | 已验证:`none`、托管;已准入:自托管;远程 URL:已拒绝 | 仅成功结果;PNG、JPEG data URL:所有位置已验证;GIF、WebP:已准入 | 已拒绝 | | [结构化输出](execution-tools.md#structured-output) | 所有位置已准入 | 所有位置已验证 | 已拒绝 | | [延迟函数发现](execution-tools.md#deferred-function-discovery) | 已拒绝 | 已验证:`none`、自托管;已准入:托管 | 已拒绝 | diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 86b7566c0..4c9815383 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -113,7 +113,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **配置和工具** - 所选 Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值,会被保存,但在 Session 准入时被拒绝([Harness 能力](harness-capabilities.md#execution-and-input))。 -- Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不提供公共 functions,也不支持图像输入。 +- Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不支持图像输入。 +- MiniMax Code 通过原生 MCP 服务器提供公共函数,而原生 MCP 客户端会在 120 秒后使调用失败且无法更改,因此函数结果必须在 120 秒内提交。 - 由模型推导出的推理默认值不会被解析确定。 - MCP 工具仅支持 `http` 传输,`stdio` 会被拒绝,Session MCP 传输中的内联 `authorization` 以及 `headers` 中的 `Authorization` 标头也会被拒绝([HTTP MCP](execution-tools.md#http-mcp))。 diff --git a/contracts/agents-api/zh/message-content.md b/contracts/agents-api/zh/message-content.md index 27b00e6cf..8e910d9b0 100644 --- a/contracts/agents-api/zh/message-content.md +++ b/contracts/agents-api/zh/message-content.md @@ -53,7 +53,7 @@ source_hash: 71a5c1bb8658f1780631c022a5f28f45b4a55fc4996a428e7372a5920ed693c2 | --- | --- | | Codex | 文本与有序文本/图像输出,原样传给 Harness。`http` 或 `https` 图像 URL 返回 400,因为原生 Codex 会把携带此类图像的动态工具结果替换为失败 | | Claude Code | 文本输出。仅成功结果可含图像,且只能是规范 base64 的 `image/png`、`image/jpeg`、`image/gif`、`image/webp` data URL,因为原生工具结果不携带 URL;失败结果图像或远程引用在任何存储前返回 400,待处理调用保持开放。Harness 可在原生历史中调整图像尺寸或重新编码,或将无法处理的图像替换为文本说明;公开 Item 保留提交字节 | -| MiniMax Code | 无公开函数 | +| MiniMax Code | 文本输出。图像返回 400,因为原生 MCP 图像内容尚未验证能到达模型 | ### 应用回执 {#application-receipts} diff --git a/internal/harnessconfig/mcode/declaration.go b/internal/harnessconfig/mcode/declaration.go index 35275d33b..aeb4f374f 100644 --- a/internal/harnessconfig/mcode/declaration.go +++ b/internal/harnessconfig/mcode/declaration.go @@ -2,12 +2,16 @@ package mcode import ( + "encoding/json" "errors" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) +// FunctionServerLabel is the native MCP server that carries public functions. +const FunctionServerLabel = "functions" + // Declaration is the only authored description of the MiniMax Code Harness. The // Runtime may narrow Capabilities for the installed host, never widen them. // Whitespace-only text is unsupported because the native runtime refuses such @@ -43,7 +47,7 @@ var Declaration = harnessconfig.Declaration{ SubagentControl: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, - FunctionTools: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilityUnsupported, MCPHTTPBearerAuth: proto.CapabilitySupported, @@ -63,14 +67,26 @@ 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 len(functions) > 0 { - return errors.New("The configured engine does not support public functions.") + for _, tool := range functions { + var schema struct { + Type string `json:"type"` + } + if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { + return errors.New("The configured engine requires function schemas with root type object.") + } } for _, server := range mcp { if server.AllowedTools != nil || server.Required { return errors.New("The configured engine requires allowed_tools=null and required=false.") } + // The adapter serves public functions under this label. + if server.ServerLabel == FunctionServerLabel { + return errors.New("The configured engine reserves the HTTP MCP label functions.") + } } return nil }, diff --git a/services/core/deploy/mcode/README.md b/services/core/deploy/mcode/README.md index 65f650aa8..a9679b451 100644 --- a/services/core/deploy/mcode/README.md +++ b/services/core/deploy/mcode/README.md @@ -23,7 +23,7 @@ Each Session has its own native data directory, `$OAC_RUNTIME_HOME/runtime/mcode | Text (`environment: none`) | A private working directory inside the Session's data directory | No native file, process or fetch tools, web search, `mcode-tools`, thread goals or user questions. The native task controls (`task_query`, `task_output`, `task_stop`) are always present; with Subagents enabled, the default agent also gets delegation. Permission mode `bypassPermissions`, so MCP tools run without approval prompts | | Workspace | The Environment's workspace directory, for the native process, the ACP Session and the native tools | The native `read`, `write`, `edit`, `bash`, `grep`, `glob`, `todowrite` and `web_fetch` tools, permission mode `bypassPermissions`, the selected workspace Skills and the frozen tool environment in the native process environment | -The text profile rejects workspace and function tools; it accepts service-origin HTTP MCP servers, which the adapter passes in the ACP `mcpServers` of the native Session. Workspace execution requires Linux or macOS ([`workspace.go`](../../../../apps/daemon/internal/agent/mcode/workspace.go)). +The text profile rejects workspace tools; it accepts service-origin HTTP MCP servers, which the adapter passes in the ACP `mcpServers` of the native Session. In both profiles public functions run as native MCP tools: the daemon serves them from a loopback HTTP MCP server named `functions` that exists for the Session, and passes it in `mcpServers` with a per-Session bearer token. A function result must be submitted within the native MCP client's 120 s call timeout. Functions and `multi_agent` cannot be combined. Workspace execution requires Linux or macOS ([`workspace.go`](../../../../apps/daemon/internal/agent/mcode/workspace.go)). In the workspace profile, the frozen installation's Skills are linked into the Session's `skills` directory under their exact names and loaded through the native `skill` tool. Native discovery also reads the workspace's `.minimax`, `.claude` and `.agents` Skills, as a local run does. Public MCP servers use the Environment origin on a workspace and the service origin on the text profile, as the [MCP origin contract](../../../../contracts/agents-api/environments.md#public-mcp-connection-origin) describes. With Subagents enabled, child agents use the same native tools. In both profiles the adapter turns off what needs a MiniMax account or outlives a Turn or asks the user: `askUser`, `beta.mcodeTools`, `beta.threadGoal`, `features.mavis` (its tool carries cron, session send, agent create and MCP create) and `features.webSearch`, and it leaves `website_deploy` out of `tools`. `web_search: disabled` also removes `web_fetch`. Background Bash is off: the patched CLI gates it on `OAC_RUNTIME_MCODE_BACKGROUND_BASH=off`, and the adapter rejects a CLI that does not report it off. diff --git a/services/core/internal/execution/mcode_profile_test.go b/services/core/internal/execution/mcode_profile_test.go index 881b8ea54..9e7384699 100644 --- a/services/core/internal/execution/mcode_profile_test.go +++ b/services/core/internal/execution/mcode_profile_test.go @@ -12,7 +12,10 @@ func TestMCodeOperationQualification(t *testing.T) { {"hosted", `{"agent":{"model":"real-model"},"environment":{"type":"openai_hosted"}}`, true}, {"verbosity", `{"agent":{"model":"real-model","text":{"verbosity":"high"}},"environment":{"type":"none"}}`, false}, {"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"}}`, false}, + {"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}, + {"reserved label", `{"agent":{"model":"real-model","tools":[{"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}, } { From b790442dd764e478e29bf013ce1499aa9d648df1 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 10:58:52 +0000 Subject: [PATCH 2/6] Send each MiniMax Code function receipt once --- apps/daemon/internal/agent/mcode/functions.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/daemon/internal/agent/mcode/functions.go b/apps/daemon/internal/agent/mcode/functions.go index cb2f4f34c..fb07f48ec 100644 --- a/apps/daemon/internal/agent/mcode/functions.go +++ b/apps/daemon/internal/agent/mcode/functions.go @@ -132,8 +132,10 @@ func (s *Session) callFunction(ctx context.Context, name string, arguments json. return nil, agent.ErrUnknownFunctionCall case <-ctx.Done(): s.functions.mu.Lock() - delete(s.functions.pending, id) - p.receipt <- agent.ErrUnknownFunctionCall + if s.functions.pending[id] == p { + delete(s.functions.pending, id) + p.receipt <- agent.ErrUnknownFunctionCall + } s.functions.mu.Unlock() return nil, ctx.Err() } From c6b7f6463f61719893b0ee7cb48d4df96546a6c3 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 16:07:01 +0000 Subject: [PATCH 3/6] Release MiniMax Code function calls that native ends --- apps/daemon/internal/agent/mcode/functions.go | 145 ++++++++++++------ .../internal/agent/mcode/functions_test.go | 95 +++++++++++- apps/daemon/internal/agent/mcode/session.go | 2 +- contracts/agents-api/harness-capabilities.md | 2 +- contracts/agents-api/index.md | 3 +- contracts/agents-api/message-content.md | 3 +- .../agents-api/zh/harness-capabilities.md | 2 +- contracts/agents-api/zh/index.md | 3 +- contracts/agents-api/zh/message-content.md | 3 +- .../harnessconfig/claudesdk/declaration.go | 10 +- internal/harnessconfig/harness.go | 13 ++ internal/harnessconfig/mcode/declaration.go | 38 ++++- .../harnessconfig/mcode/declaration_test.go | 20 +++ services/core/deploy/mcode/README.md | 2 +- 14 files changed, 269 insertions(+), 72 deletions(-) create mode 100644 internal/harnessconfig/mcode/declaration_test.go diff --git a/apps/daemon/internal/agent/mcode/functions.go b/apps/daemon/internal/agent/mcode/functions.go index fb07f48ec..569de3a5c 100644 --- a/apps/daemon/internal/agent/mcode/functions.go +++ b/apps/daemon/internal/agent/mcode/functions.go @@ -10,6 +10,7 @@ import ( "net/http" "sync" "sync/atomic" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -22,27 +23,27 @@ import ( // listens on loopback only and requires a per-Session bearer token, so only the // native process that received the ACP server entry can call it. type functionServer struct { - token string - url string - http *http.Server - done chan struct{} - owner atomic.Pointer[executor] - stopOnce sync.Once + token string + url string + http *http.Server + done chan struct{} + owner atomic.Pointer[executor] } func serveFunctions(tools []proto.FunctionTool) (*functionServer, error) { if len(tools) == 0 { return nil, nil } - listener, err := net.Listen("tcp", "127.0.0.1:0") + f := &functionServer{token: rand.Text(), done: make(chan struct{})} + server, err := f.newServer(tools) if err != nil { return nil, err } - f := &functionServer{token: rand.Text(), url: "http://" + listener.Addr().String() + "/mcp", done: make(chan struct{})} - server := mcp.NewServer(&mcp.Implementation{Name: declaration.FunctionServerLabel, Version: "1"}, nil) - for _, tool := range tools { - server.AddTool(&mcp.Tool{Name: tool.Name, Description: tool.Description, InputSchema: tool.Parameters}, f.call) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err } + f.url = "http://" + listener.Addr().String() + "/mcp" handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) want := []byte("Bearer " + f.token) f.http = &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -59,6 +60,21 @@ func serveFunctions(tools []proto.FunctionTool) (*functionServer, error) { return f, nil } +// newServer registers the tools before any listener exists. AddTool panics on a +// schema it rejects, which becomes an error here. +func (f *functionServer) newServer(tools []proto.FunctionTool) (server *mcp.Server, err error) { + defer func() { + if recover() != nil { + server, err = nil, errors.New("mcode: the MCP server rejected a function schema") + } + }() + server = mcp.NewServer(&mcp.Implementation{Name: declaration.FunctionServerLabel, Version: "1"}, nil) + for _, tool := range tools { + server.AddTool(&mcp.Tool{Name: tool.Name, Description: tool.Description, InputSchema: tool.Parameters}, f.call) + } + return server, nil +} + // mcpServer is the ACP mcpServers entry; the token stays in transient ACP headers. func (f *functionServer) mcpServer() map[string]any { return map[string]any{"name": declaration.FunctionServerLabel, "type": "http", "url": f.url, @@ -70,7 +86,7 @@ func (f *functionServer) close() { if f == nil { return } - f.stopOnce.Do(func() { _ = f.http.Close() }) + _ = f.http.Close() <-f.done } @@ -90,13 +106,12 @@ func (f *functionServer) call(ctx context.Context, req *mcp.CallToolRequest) (*m } type pendingFunction struct { - call proto.FunctionCallPayload - delivered chan struct{} - stopped chan struct{} - receipt chan error - result *proto.FunctionResultPayload - content []mcp.Content - seq int + call proto.FunctionCallPayload + // settled closes when the result is delivered or the call is released; a nil result means released. + settled chan struct{} + receipt chan error + result *proto.FunctionResultPayload + content []mcp.Content } // functionCalls is one Turn's pending calls. emit holds the lock so a stopped @@ -104,43 +119,55 @@ type pendingFunction struct { type functionCalls struct { mu sync.Mutex pending map[string]*pendingFunction - next int + stopped chan struct{} closed bool } +// functionCallMeta is the CallToolResult _meta key that native echoes in its tool update. +const functionCallMeta = "oac_call_id" + func (s *Session) callFunction(ctx context.Context, name string, arguments json.RawMessage) (*mcp.CallToolResult, error) { - if len(arguments) == 0 { - arguments = json.RawMessage("{}") - } id := "call_" + rand.Text() - p := &pendingFunction{call: proto.FunctionCallPayload{CallID: id, Name: name, Arguments: arguments}, delivered: make(chan struct{}), stopped: make(chan struct{}), receipt: make(chan error, 1)} + p := &pendingFunction{call: proto.FunctionCallPayload{CallID: id, Name: name, Arguments: arguments}, settled: make(chan struct{}), receipt: make(chan error, 1)} s.functions.mu.Lock() if s.functions.closed { s.functions.mu.Unlock() return nil, agent.ErrUnknownFunctionCall } - s.functions.next++ - p.seq = s.functions.next s.functions.pending[id] = p s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: name, Stage: "before", Observation: &proto.ToolObservation{Kind: "function", Status: "in_progress", Name: name, Arguments: arguments}}) s.emit(proto.TypeFunctionCall, p.call) s.functions.mu.Unlock() select { - case <-p.delivered: - return &mcp.CallToolResult{Content: p.content, IsError: !p.result.Success}, nil - case <-p.stopped: + case <-p.settled: + if p.result == nil { + return nil, agent.ErrUnknownFunctionCall + } + return &mcp.CallToolResult{Content: p.content, IsError: !p.result.Success, Meta: mcp.Meta{functionCallMeta: id}}, nil + case <-s.functions.stopped: return nil, agent.ErrUnknownFunctionCall case <-ctx.Done(): s.functions.mu.Lock() - if s.functions.pending[id] == p { - delete(s.functions.pending, id) - p.receipt <- agent.ErrUnknownFunctionCall - } + s.release(p) s.functions.mu.Unlock() return nil, ctx.Err() } } +// release drops a call that native ended without confirming its result and +// unblocks its waiter. The caller holds s.functions.mu. +// A later change also withdraws the call from Core here. +func (s *Session) release(p *pendingFunction) { + if s.functions.pending[p.call.CallID] != p { + return + } + delete(s.functions.pending, p.call.CallID) + if p.result == nil { + close(p.settled) + } + p.receipt <- agent.ErrUnknownFunctionCall +} + func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { if err := result.ValidateContent(); err != nil { return err @@ -159,8 +186,11 @@ func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.Functio return agent.ErrUnknownFunctionCall } p.result, p.content = &result, content - close(p.delivered) + close(p.settled) s.functions.mu.Unlock() + // The receive loop calls this inline, so the wait is bounded. + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() select { case err := <-p.receipt: return err @@ -169,27 +199,45 @@ func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.Functio } } -// confirmFunction applies the native result: the oldest delivered, unconfirmed -// call to this function is the one that native reports. +// confirmFunction applies a terminal native update for a function tool. Native +// echoes the returned result, with the call ID in its _meta, when it received +// one; any other end means native gave up on the call, such as its 120 s timeout. +// Native runs MCP tools one at a time, so at most one call is in flight. func (s *Session) confirmFunction(update toolUpdate) { s.functions.mu.Lock() defer s.functions.mu.Unlock() - var match *pendingFunction - for _, p := range s.functions.pending { - if p.result != nil && p.call.Name == update.mcp.tool && (match == nil || p.seq < match.seq) { - match = p + var echo struct { + Details struct { + MCP struct { + Meta map[string]any `json:"_meta"` + } `json:"mcp"` + } `json:"details"` + } + raw, _ := json.Marshal(update.RawOutput) + _ = json.Unmarshal(raw, &echo) + id, _ := echo.Details.MCP.Meta[functionCallMeta].(string) + p := s.functions.pending[id] + if p == nil { + for _, q := range s.functions.pending { + if q.call.Name == update.mcp.tool { + p = q + } } } - if match == nil { + if p == nil { return } - delete(s.functions.pending, match.call.CallID) + if id != p.call.CallID || p.result == nil || update.Status != "completed" && p.result.Success { + s.release(p) + return + } + delete(s.functions.pending, id) status := "completed" - if !match.result.Success { + if !p.result.Success { status = "failed" } - s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: match.call.CallID, Name: match.call.Name, Stage: "after", Observation: &proto.ToolObservation{Kind: "function", Status: status, Name: match.call.Name, Arguments: match.call.Arguments, Content: &match.result.Content}}) - match.receipt <- nil + s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: p.call.Name, Stage: "after", Observation: &proto.ToolObservation{Kind: "function", Status: status, Name: p.call.Name, Arguments: p.call.Arguments, Content: &p.result.Content}}) + p.receipt <- nil } // stopFunctions ends the Turn's calls: native gets an error for each pending @@ -201,9 +249,8 @@ func (s *Session) stopFunctions() { return } s.functions.closed = true - for id, p := range s.functions.pending { - close(p.stopped) - p.receipt <- agent.ErrUnknownFunctionCall - delete(s.functions.pending, id) + close(s.functions.stopped) + for _, p := range s.functions.pending { + s.release(p) } } diff --git a/apps/daemon/internal/agent/mcode/functions_test.go b/apps/daemon/internal/agent/mcode/functions_test.go index 33abfbadd..be2af5842 100644 --- a/apps/daemon/internal/agent/mcode/functions_test.go +++ b/apps/daemon/internal/agent/mcode/functions_test.go @@ -3,6 +3,7 @@ package mcode import ( "context" "encoding/json" + "errors" "net/http" "os" "strings" @@ -11,6 +12,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -50,7 +52,7 @@ func fakeFunctionTurn(params json.RawMessage, update func(string, map[string]any if os.WriteFile(os.Getenv("OAC_TEST_FN_RESULT"), []byte(text), 0600) != nil { return } - update("tool_call_update", map[string]any{"toolCallId": "t1", "status": "completed", "rawOutput": "ignored"}) + update("tool_call_update", map[string]any{"toolCallId": "t1", "status": "completed", "rawOutput": map[string]any{"details": map[string]any{"mcp": result}}}) raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) } @@ -156,3 +158,94 @@ func TestFunctionCallCancellationReleasesPendingCall(t *testing.T) { t.Fatal("function listener outlived the Session") } } + +// probeFunction is the native side of one call: it calls the tool over MCP and +// returns what native received. +func probeFunction(t *testing.T, functions *functionServer) <-chan *mcp.CallToolResult { + t.Helper() + client := mcp.NewClient(&mcp.Implementation{Name: "probe", Version: "1"}, nil) + connection, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: functions.url, HTTPClient: &http.Client{Transport: bearerTransport{"Bearer " + functions.token}}}, nil) + if err != nil { + t.Fatal(err) + } + received := make(chan *mcp.CallToolResult, 1) + go func() { + result, _ := connection.CallTool(t.Context(), &mcp.CallToolParams{Name: "lookup", Arguments: map[string]any{}}) + received <- result + }() + return received +} + +func submitText(session *Session, callID, text string) error { + return session.SubmitFunctionResult(context.Background(), proto.FunctionResultPayload{DeliveryID: "d", CallID: callID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}) +} + +func nativeEnd(session *Session, status string, callID string) { + output := map[string]any{} + if callID != "" { + output["details"] = map[string]any{"mcp": map[string]any{"_meta": map[string]any{functionCallMeta: callID}}} + } + session.confirmFunction(toolUpdate{mcp: &mcpToolIdentity{server: "functions", tool: "lookup"}, Status: status, RawOutput: output}) +} + +// Calls of the same function run one after another; each gets its own result +// and its own receipt. +func TestFunctionCallsInSequence(t *testing.T) { + session, out, _ := functionTurn(t, "cancel-wait") + functions := session.executor.opts.Functions + for _, answer := range []string{"first", "second"} { + received := probeFunction(t, functions) + call := waitFunctionCall(t, out) + submitted := make(chan error, 1) + go func() { submitted <- submitText(session, call.CallID, answer) }() + result := <-received + if got := result.Content[0].(*mcp.TextContent).Text; got != answer { + t.Fatalf("native received %q, want %q", got, answer) + } + nativeEnd(session, "completed", call.CallID) + if err := <-submitted; err != nil { + t.Fatal(err) + } + } +} + +// A call that native fails, such as at its call timeout, is released: a late +// result is refused at once and the next call still works. +func TestFunctionCallFailedByNativeIsReleased(t *testing.T) { + session, out, _ := functionTurn(t, "cancel-wait") + functions := session.executor.opts.Functions + received := probeFunction(t, functions) + late := waitFunctionCall(t, out) + nativeEnd(session, "failed", "") + if result := <-received; result != nil { + t.Fatal("released call returned a result") + } + if err := submitText(session, late.CallID, "late"); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatalf("late result: %v", err) + } + received = probeFunction(t, functions) + next := waitFunctionCall(t, out) + submitted := make(chan error, 1) + go func() { submitted <- submitText(session, next.CallID, "next") }() + if result := <-received; result.Content[0].(*mcp.TextContent).Text != "next" { + t.Fatal("next call did not get its result") + } + nativeEnd(session, "completed", next.CallID) + if err := <-submitted; err != nil { + t.Fatal(err) + } +} + +// A call still pending when the Turn ends is reported to native and refuses a result. +func TestFunctionCallPendingAtTurnEnd(t *testing.T) { + session, out, _ := functionTurn(t, "cancel-wait") + received := probeFunction(t, session.executor.opts.Functions) + call := waitFunctionCall(t, out) + session.stopFunctions() + if result := <-received; result != nil { + t.Fatal("pending call returned a result") + } + if err := submitText(session, call.CallID, "late"); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatalf("result after Turn end: %v", err) + } +} diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index 49e67d1ee..826214f7d 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -66,7 +66,7 @@ func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOpti } func newTurnSession(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, c *connection, out chan<- proto.Envelope) *Session { - return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), functions: &functionCalls{pending: map[string]*pendingFunction{}}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), functions: &functionCalls{pending: map[string]*pendingFunction{}, stopped: make(chan struct{})}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} } func (s *Session) prepareNative() error { diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index 302a0b972..4fffc908b 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -34,7 +34,7 @@ Native model parameters and provider protocols per Harness are in [model executi | Operation | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | -| [Public functions](./execution-tools.md#functions) with text results | Verified: `none`, hosted; admitted: self-hosted | Verified on all placements; object-root schemas only | Verified: `none`, hosted; admitted: self-hosted; object-root schemas only; a result must arrive within 120 s ([difference](./index.md#known-gaps)) | +| [Public functions](./execution-tools.md#functions) with text results | Verified: `none`, hosted; admitted: self-hosted | Verified on all placements; object-root schemas only | Verified: `none`; admitted: hosted, self-hosted; object-root schemas only; a result must arrive within 120 s ([difference](./index.md#differences-from-openai)) | | [Function results with images](./message-content.md#function-results) | Verified: `none`, hosted; admitted: self-hosted; remote URLs: rejected | Successful results only; PNG and JPEG data URLs: verified on all placements; GIF and WebP: admitted | Rejected | | [Structured output](./execution-tools.md#structured-output) | Admitted on all placements | Verified on all placements | Rejected | | [Deferred function discovery](./execution-tools.md#deferred-function-discovery) | Rejected | Verified: `none`, self-hosted; admitted: hosted | Rejected | diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 0c5e78696..8661eb28d 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -99,6 +99,8 @@ Each item is Core's deliberate or native behavior where the official service beh **Vaults and Credentials** ([Vaults and Credentials](./vaults.md)) +- MiniMax Code runs public function calls one at a time, so the application sees one `function_call` at a time. +- MiniMax Code serves public functions as a native MCP server whose client fails a call after 120 s with no way to change it. After 120 s native reports the failure to the model and the Turn continues. Until a later protocol change, Core keeps the call pending, and a result submitted after that is refused by the Runtime and not applied. - Vault and Credential status is stored privately and defaults to `active`; with no archive operation, lists without a filter include both statuses. - An unknown or foreign Vault in `vault_ids` returns 404 "Resource not found."; the official message names the ID. - An explicit `null` for OAuth `access_token`, `refresh` or `token_endpoint_auth` in an update keeps the stored value. @@ -111,7 +113,6 @@ Each item is Core's deliberate or native behavior where the official service beh - A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting or `programmatic_tool_calling` value that the selected Harness does not declare is saved but rejected at Session admission ([Harness capabilities](./harness-capabilities.md#execution-and-input)). - Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no image input. -- MiniMax Code serves public functions as a native MCP server, and the native MCP client fails a call after 120 s with no way to change it, so a function result must be submitted within 120 s. - Model-derived reasoning defaults are not resolved. - MCP tools support the `http` transport only, so `stdio` is rejected. An inline `authorization` on a Session MCP transport and an `Authorization` entry in `headers` are rejected too ([HTTP MCP](./execution-tools.md#http-mcp)). diff --git a/contracts/agents-api/message-content.md b/contracts/agents-api/message-content.md index 76800bb3e..f9f304587 100644 --- a/contracts/agents-api/message-content.md +++ b/contracts/agents-api/message-content.md @@ -59,8 +59,9 @@ Admission (202) does not mean the Harness used the result. The pending call clea - **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. - **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. -Both adapters wait at most 10 seconds for a receipt. A timeout or native release without confirmation leaves application uncertain. Confirmation means the Harness recorded the result, not that the model provider consumed it. Core never replays a result automatically; the submission stays stored for recovery reads. A call the native Harness abandons gets no receipt and is withdrawn ([Functions](./execution-tools.md#functions)). +Each adapter waits at most 10 seconds for a receipt. A timeout or native release without confirmation leaves application uncertain. Confirmation means the Harness recorded the result, not that the model provider consumed it. Core never replays a result automatically; the submission stays stored for recovery reads. A call the native Harness abandons gets no receipt and is withdrawn ([Functions](./execution-tools.md#functions)). ## Runtime boundary diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index b78552451..38300d096 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -36,7 +36,7 @@ source_hash: 6ae7ae05948fe30ce66d86b0a3987c08456392d6a7ed66e71c09ec8b85044843 | 操作 | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | -| 文本结果的[公开函数](execution-tools.md#functions) | 已验证:`none`、托管;已准入:自托管 | 所有位置已验证;仅对象根 schema | 已验证:`none`、托管;已准入:自托管;仅对象根 schema;结果须在 120 秒内到达([差异](index.md#known-gaps)) | +| 文本结果的[公开函数](execution-tools.md#functions) | 已验证:`none`、托管;已准入:自托管 | 所有位置已验证;仅对象根 schema | 已验证:`none`;已准入:托管、自托管;仅对象根 schema;结果须在 120 秒内到达([差异](index.md#differences-from-openai)) | | [含图像函数结果](message-content.md#function-results) | 已验证:`none`、托管;已准入:自托管;远程 URL:已拒绝 | 仅成功结果;PNG、JPEG data URL:所有位置已验证;GIF、WebP:已准入 | 已拒绝 | | [结构化输出](execution-tools.md#structured-output) | 所有位置已准入 | 所有位置已验证 | 已拒绝 | | [延迟函数发现](execution-tools.md#deferred-function-discovery) | 已拒绝 | 已验证:`none`、自托管;已准入:托管 | 已拒绝 | diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 4c9815383..d7a50c7e2 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -102,6 +102,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **Vaults 和 Credentials**([Vaults and Credentials](vaults.md)) +- MiniMax Code 逐个运行公共函数调用,因此应用每次只看到一个 `function_call`。 +- MiniMax Code 通过原生 MCP 服务器提供公共函数,其客户端会在 120 秒后使调用失败且无法更改。120 秒后原生向模型报告失败,Turn 继续。在后续协议变更之前,Core 保持该调用为待处理,此后提交的结果会被 Runtime 拒绝,不会被应用。 - Vault 和 Credential 的状态仅在内部保存,默认值为 `active`;由于没有归档操作,未带过滤器的列表会同时包含两种状态。 - `vault_ids` 中包含未知或属于其他账户的 Vault 时会返回 404 "Resource not found.";官方消息会指出该 ID。 - 更新时显式为 OAuth `access_token`、`refresh` 或 `token_endpoint_auth` 指定 `null`,会保留已存储的值。 @@ -114,7 +116,6 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 所选 Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值,会被保存,但在 Session 准入时被拒绝([Harness 能力](harness-capabilities.md#execution-and-input))。 - Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不支持图像输入。 -- MiniMax Code 通过原生 MCP 服务器提供公共函数,而原生 MCP 客户端会在 120 秒后使调用失败且无法更改,因此函数结果必须在 120 秒内提交。 - 由模型推导出的推理默认值不会被解析确定。 - MCP 工具仅支持 `http` 传输,`stdio` 会被拒绝,Session MCP 传输中的内联 `authorization` 以及 `headers` 中的 `Authorization` 标头也会被拒绝([HTTP MCP](execution-tools.md#http-mcp))。 diff --git a/contracts/agents-api/zh/message-content.md b/contracts/agents-api/zh/message-content.md index 8e910d9b0..495923d7c 100644 --- a/contracts/agents-api/zh/message-content.md +++ b/contracts/agents-api/zh/message-content.md @@ -61,8 +61,9 @@ source_hash: 71a5c1bb8658f1780631c022a5f28f45b4a55fc4996a428e7372a5920ed693c2 - **Claude Code** 使用实时根原生工具结果确认,要求匹配 Session、调用 ID、成功标志、准确文本、块数和顺序,且每个图像位置都有原生图像。重放、合成和 Subagent 记录不能确认。 - **Codex** 使用发起调用的线程和 Turn 上的实时 dynamic-tool `item/completed` 观察确认,要求匹配调用、函数名称、状态、成功标志和准确有序内容。仅写入 Harness 不确认。 +- **MiniMax Code** 使用 `functions` MCP 调用的实时原生工具更新确认,要求其回显的结果带有该调用 ID。原生使调用失败(例如 120 秒超时)时,该调用被放弃。 -两适配器等待回执最多 10 秒。超时或原生释放且未确认时,应用保持不确定。确认表示 Harness 记录了结果,不表示模型提供商消费了结果。Core 不自动重放结果;提交仍保留用于恢复读取。原生 Harness 放弃的调用没有回执,会被撤回([Functions](execution-tools.md#functions))。 +每个适配器等待回执最多 10 秒。超时或原生释放且未确认时,应用保持不确定。确认表示 Harness 记录了结果,不表示模型提供商消费了结果。Core 不自动重放结果;提交仍保留用于恢复读取。原生 Harness 放弃的调用没有回执,会被撤回([Functions](execution-tools.md#functions))。 ## Runtime 边界 {#runtime-boundary} diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index ae25f61e7..94e1110d6 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -109,15 +109,7 @@ func validateTools(tools []proto.FunctionTool, servers []proto.MCPHTTPServer) er return errors.New("The configured engine does not support ${ in MCP URLs or header values.") } } - for _, tool := range tools { - var schema struct { - Type string `json:"type"` - } - if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { - return errors.New("The configured engine currently requires function schemas with root type object.") - } - } - return nil + return harnessconfig.RequireObjectSchemas(tools) } func validateFunctionResult(result proto.FunctionResultPayload) error { diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 677679525..9325a5960 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -332,6 +332,19 @@ func (d Declaration) ValidateMCPOrigins(placement string, servers []proto.MCPHTT return nil } +// RequireObjectSchemas rejects a function whose parameter schema has no root type object. +func RequireObjectSchemas(functions []proto.FunctionTool) error { + for _, tool := range functions { + var schema struct { + Type string `json:"type"` + } + if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { + return errors.New("The configured engine requires function schemas with root type object.") + } + } + return nil +} + // RejectSubagentTools rejects the declared tool types that a Harness cannot // combine with Subagents. func RejectSubagentTools(admission Admission, unsupported ...string) error { diff --git a/internal/harnessconfig/mcode/declaration.go b/internal/harnessconfig/mcode/declaration.go index aeb4f374f..1c13b45cf 100644 --- a/internal/harnessconfig/mcode/declaration.go +++ b/internal/harnessconfig/mcode/declaration.go @@ -71,12 +71,13 @@ var Declaration = harnessconfig.Declaration{ return harnessconfig.RejectSubagentTools(admission, "function") }, ValidateTools: func(functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + if err := harnessconfig.RequireObjectSchemas(functions); err != nil { + return err + } for _, tool := range functions { - var schema struct { - Type string `json:"type"` - } - if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { - return errors.New("The configured engine requires function schemas with root type object.") + // The adapter's MCP server library gives x-mcp-header transport meaning. + if hasKey(tool.Parameters, "x-mcp-header") { + return errors.New("The configured engine does not support the x-mcp-header keyword in function schemas.") } } for _, server := range mcp { @@ -91,3 +92,30 @@ var Declaration = harnessconfig.Declaration{ return nil }, } + +// hasKey reports whether the JSON document has an object member with this name at any depth. +func hasKey(raw json.RawMessage, key string) bool { + var found func(any) bool + found = func(v any) bool { + switch v := v.(type) { + case map[string]any: + if _, ok := v[key]; ok { + return true + } + for _, child := range v { + if found(child) { + return true + } + } + case []any: + for _, child := range v { + if found(child) { + return true + } + } + } + return false + } + var doc any + return json.Unmarshal(raw, &doc) == nil && found(doc) +} diff --git a/internal/harnessconfig/mcode/declaration_test.go b/internal/harnessconfig/mcode/declaration_test.go new file mode 100644 index 000000000..d74a5b773 --- /dev/null +++ b/internal/harnessconfig/mcode/declaration_test.go @@ -0,0 +1,20 @@ +package mcode + +import ( + "encoding/json" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestFunctionSchemaRejectsMCPHeaderKeyword(t *testing.T) { + for schema, ok := range map[string]bool{ + `{"type":"object","properties":{"q":{"type":"string"}}}`: true, + `{"type":"object","properties":{"q":{"type":"string","x-mcp-header":"Region"}}}`: false, + } { + err := Declaration.ValidateTools([]proto.FunctionTool{{Name: "f", Parameters: json.RawMessage(schema)}}, nil) + if (err == nil) != ok { + t.Errorf("%s: %v", schema, err) + } + } +} diff --git a/services/core/deploy/mcode/README.md b/services/core/deploy/mcode/README.md index a9679b451..c8c7f2cba 100644 --- a/services/core/deploy/mcode/README.md +++ b/services/core/deploy/mcode/README.md @@ -23,7 +23,7 @@ Each Session has its own native data directory, `$OAC_RUNTIME_HOME/runtime/mcode | Text (`environment: none`) | A private working directory inside the Session's data directory | No native file, process or fetch tools, web search, `mcode-tools`, thread goals or user questions. The native task controls (`task_query`, `task_output`, `task_stop`) are always present; with Subagents enabled, the default agent also gets delegation. Permission mode `bypassPermissions`, so MCP tools run without approval prompts | | Workspace | The Environment's workspace directory, for the native process, the ACP Session and the native tools | The native `read`, `write`, `edit`, `bash`, `grep`, `glob`, `todowrite` and `web_fetch` tools, permission mode `bypassPermissions`, the selected workspace Skills and the frozen tool environment in the native process environment | -The text profile rejects workspace tools; it accepts service-origin HTTP MCP servers, which the adapter passes in the ACP `mcpServers` of the native Session. In both profiles public functions run as native MCP tools: the daemon serves them from a loopback HTTP MCP server named `functions` that exists for the Session, and passes it in `mcpServers` with a per-Session bearer token. A function result must be submitted within the native MCP client's 120 s call timeout. Functions and `multi_agent` cannot be combined. Workspace execution requires Linux or macOS ([`workspace.go`](../../../../apps/daemon/internal/agent/mcode/workspace.go)). +The text profile rejects workspace tools; it accepts service-origin HTTP MCP servers, which the adapter passes in the ACP `mcpServers` of the native Session. In both profiles public functions run as native MCP tools: the daemon serves them from a loopback HTTP MCP server named `functions` that exists for the Session, and passes it in `mcpServers` with a per-Session bearer token. Native fails a call after 120 s ([difference](../../../../contracts/agents-api/index.md#differences-from-openai)). Functions and `multi_agent` cannot be combined. Workspace execution requires Linux or macOS ([`workspace.go`](../../../../apps/daemon/internal/agent/mcode/workspace.go)). In the workspace profile, the frozen installation's Skills are linked into the Session's `skills` directory under their exact names and loaded through the native `skill` tool. Native discovery also reads the workspace's `.minimax`, `.claude` and `.agents` Skills, as a local run does. Public MCP servers use the Environment origin on a workspace and the service origin on the text profile, as the [MCP origin contract](../../../../contracts/agents-api/environments.md#public-mcp-connection-origin) describes. With Subagents enabled, child agents use the same native tools. In both profiles the adapter turns off what needs a MiniMax account or outlives a Turn or asks the user: `askUser`, `beta.mcodeTools`, `beta.threadGoal`, `features.mavis` (its tool carries cron, session send, agent create and MCP create) and `features.webSearch`, and it leaves `website_deploy` out of `tools`. `web_search: disabled` also removes `web_fetch`. Background Bash is off: the patched CLI gates it on `OAC_RUNTIME_MCODE_BACKGROUND_BASH=off`, and the adapter rejects a CLI that does not report it off. From 2851d8470de9c96404a3f714d7e8668e92c1c0e2 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 16:20:35 +0000 Subject: [PATCH 4/6] Withdraw MiniMax Code calls that native times out --- apps/daemon/internal/agent/mcode/functions.go | 9 ++++++--- apps/daemon/internal/agent/mcode/functions_test.go | 13 +++++++++++-- contracts/agents-api/execution-tools.md | 2 +- contracts/agents-api/index.md | 4 ++-- contracts/agents-api/zh/execution-tools.md | 4 ++-- contracts/agents-api/zh/harness-capabilities.md | 2 +- contracts/agents-api/zh/index.md | 6 +++--- contracts/agents-api/zh/message-content.md | 2 +- 8 files changed, 27 insertions(+), 15 deletions(-) diff --git a/apps/daemon/internal/agent/mcode/functions.go b/apps/daemon/internal/agent/mcode/functions.go index 569de3a5c..5ecd224e6 100644 --- a/apps/daemon/internal/agent/mcode/functions.go +++ b/apps/daemon/internal/agent/mcode/functions.go @@ -154,14 +154,17 @@ func (s *Session) callFunction(ctx context.Context, name string, arguments json. } } -// release drops a call that native ended without confirming its result and -// unblocks its waiter. The caller holds s.functions.mu. -// A later change also withdraws the call from Core here. +// release drops a call that native ended without confirming its result, +// withdraws it from a live Turn and unblocks its waiter. The caller holds +// s.functions.mu. func (s *Session) release(p *pendingFunction) { if s.functions.pending[p.call.CallID] != p { return } delete(s.functions.pending, p.call.CallID) + if !s.functions.closed { + s.emit(proto.TypeFunctionCallWithdrawn, proto.FunctionCallWithdrawnPayload{CallID: p.call.CallID}) + } if p.result == nil { close(p.settled) } diff --git a/apps/daemon/internal/agent/mcode/functions_test.go b/apps/daemon/internal/agent/mcode/functions_test.go index be2af5842..325455cc5 100644 --- a/apps/daemon/internal/agent/mcode/functions_test.go +++ b/apps/daemon/internal/agent/mcode/functions_test.go @@ -209,14 +209,23 @@ func TestFunctionCallsInSequence(t *testing.T) { } } -// A call that native fails, such as at its call timeout, is released: a late +// A call that native fails, such as at its call timeout, is withdrawn: a late // result is refused at once and the next call still works. -func TestFunctionCallFailedByNativeIsReleased(t *testing.T) { +func TestFunctionCallFailedByNativeIsWithdrawn(t *testing.T) { session, out, _ := functionTurn(t, "cancel-wait") functions := session.executor.opts.Functions received := probeFunction(t, functions) late := waitFunctionCall(t, out) nativeEnd(session, "failed", "") + for event := range out { + if event.Type == proto.TypeFunctionCallWithdrawn { + var withdrawn proto.FunctionCallWithdrawnPayload + if json.Unmarshal(event.Payload, &withdrawn) != nil || withdrawn.CallID != late.CallID { + t.Fatalf("withdrawn = %s", event.Payload) + } + break + } + } if result := <-received; result != nil { t.Fatal("released call returned a result") } diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 6e6338c89..e42e98041 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 Subagent that made it. 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 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. 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/index.md b/contracts/agents-api/index.md index 8661eb28d..b2d1a8996 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -81,7 +81,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, 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 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. - 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. @@ -100,7 +100,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Vaults and Credentials** ([Vaults and Credentials](./vaults.md)) - MiniMax Code runs public function calls one at a time, so the application sees one `function_call` at a time. -- MiniMax Code serves public functions as a native MCP server whose client fails a call after 120 s with no way to change it. After 120 s native reports the failure to the model and the Turn continues. Until a later protocol change, Core keeps the call pending, and a result submitted after that is refused by the Runtime and not applied. +- MiniMax Code serves public functions as a native MCP server whose client fails a call after 120 s with no way to change it. After 120 s native reports the failure to the model, the call is withdrawn and the Turn continues. - Vault and Credential status is stored privately and defaults to `active`; with no archive operation, lists without a filter include both statuses. - An unknown or foreign Vault in `vault_ids` returns 404 "Resource not found."; the official message names the ID. - An explicit `null` for OAuth `access_token`, `refresh` or `token_endpoint_auth` in an update keeps the stored value. diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index b38ca9254..808f61a24 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: 5e14ecb8add3ada10039f01ccdb0125c8f545c67f759aad74e973b7ac80cf00e +source_hash: 555e7bbcec87f5d1b9fd62bbb603c61a4df10fd0a245c64bb41398be78d52d67 --- 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 关闭或中断了发起该调用的 Subagent。该调用随即不再是待执行操作,Turn 继续运行,并在没有待执行操作时回到 `in_progress`;之后为它提交的 `function_call_output` 会像取消后的结果一样被拒绝。被放弃的调用没有应用回执。 +原生 Harness 可能在应用结果之前放弃调用,例如根 Turn 关闭或中断了发起该调用的 Codex 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 38300d096..ecf6d16ae 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: 6ae7ae05948fe30ce66d86b0a3987c08456392d6a7ed66e71c09ec8b85044843 +source_hash: 79476f748e558bb88e60c7b6d1ff8684824c43b2891b142009343b1319674bd7 --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index d7a50c7e2..0b33d4e98 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: b6c779b6e8264e04e048394ab2a0f1b470639d0e8fe1f79b2fc0bf5564c2bef1 +source_hash: 7a64fab978905ee684a1b0e9e05f6b27dd5608614e53e47f47c240d7f6c04564 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -84,7 +84,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 提交给 `none` Session 的输入会同步完成准入;Core 不会模拟官方异步准入窗口。 - 用于恢复等待中 Turn 的函数结果会发出 `turn.in_progress`;取消正在等待函数结果的 Turn 会发出临时 `agent.session.in_progress`。 -- 原生 Harness 放弃的函数调用(例如根关闭或中断子级时被丢弃的 Codex Subagent 调用)会被撤回:它不再是待执行操作,之后为它提交结果会返回 409 `conflict_error`。官方 API 没有撤回机制。 +- 原生 Harness 放弃的函数调用(例如根关闭或中断子级时被丢弃的 Codex Subagent 调用,或超过 120 秒超时的 MiniMax Code 调用)会被撤回:它不再是待执行操作,之后为它提交结果会返回 409 `conflict_error`。官方 API 没有撤回机制。 - 在 Turn 中途接入流时,不会发送补发 Item 快照。 - Items 列表会包含进行中和未完成的输出 Items,并保留失败函数结果中已提交的 `output`。 - 错误消息会省略官方消息包含的 call 和 executor ID。 @@ -103,7 +103,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **Vaults 和 Credentials**([Vaults and Credentials](vaults.md)) - MiniMax Code 逐个运行公共函数调用,因此应用每次只看到一个 `function_call`。 -- MiniMax Code 通过原生 MCP 服务器提供公共函数,其客户端会在 120 秒后使调用失败且无法更改。120 秒后原生向模型报告失败,Turn 继续。在后续协议变更之前,Core 保持该调用为待处理,此后提交的结果会被 Runtime 拒绝,不会被应用。 +- MiniMax Code 通过原生 MCP 服务器提供公共函数,其客户端会在 120 秒后使调用失败且无法更改。120 秒后原生向模型报告失败,该调用被撤回,Turn 继续。 - Vault 和 Credential 的状态仅在内部保存,默认值为 `active`;由于没有归档操作,未带过滤器的列表会同时包含两种状态。 - `vault_ids` 中包含未知或属于其他账户的 Vault 时会返回 404 "Resource not found.";官方消息会指出该 ID。 - 更新时显式为 OAuth `access_token`、`refresh` 或 `token_endpoint_auth` 指定 `null`,会保留已存储的值。 diff --git a/contracts/agents-api/zh/message-content.md b/contracts/agents-api/zh/message-content.md index 495923d7c..b3e65bc11 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: 71a5c1bb8658f1780631c022a5f28f45b4a55fc4996a428e7372a5920ed693c2 +source_hash: 2afdb754fd8ece63345af69e06fa15e036d1bf81f047b5f7e457f280dc40db2b --- 用户消息和函数结果共享同一内容模型:由 `input_text` 与 `input_image` 部分组成的有序列表。Core 按发送形式准确存储消息边界、部分顺序和图像引用,并在用户 Item 中原样返回。不下载、转码或修复媒体。Session 创建的 `input` 与 `events.create` 消息共享验证和准入;[Session、事件与历史](sessions-events.md#send-input)定义准入、请求限制和错误。 From 3c53daac5666bb86a7ba4a9d644e551d677a645b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 18:22:14 +0000 Subject: [PATCH 5/6] Fix MiniMax Code function review findings --- apps/daemon/internal/agent/mcode/executor.go | 5 +++++ apps/daemon/internal/agent/mcode/functions.go | 9 +++++---- contracts/agents-api/execution-tools.md | 2 +- contracts/agents-api/zh/execution-tools.md | 4 ++-- internal/harnessconfig/mcode/declaration.go | 2 +- .../core/internal/execution/harness_admission.go | 11 ++++++++--- .../core/internal/execution/message_support_test.go | 13 +++++++++++++ 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 71062196a..9853f9d5a 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" + "slices" "strings" "sync" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) var errTurnCancelled = errors.New("mcode: Turn cancelled before submission") @@ -58,6 +60,9 @@ func NewExecutorFactory(runtimeRoot string, config *WorkspaceConfig) agent.Execu return nil, err } opts.MCP = mcp + if len(req.FunctionTools) > 0 && slices.ContainsFunc(mcp, func(server map[string]any) bool { return server["name"] == declaration.FunctionServerLabel }) { + return nil, fmt.Errorf("mcode: a Plugin MCP server uses the reserved label %s", declaration.FunctionServerLabel) + } if opts.Functions, err = serveFunctions(req.FunctionTools); err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/mcode/functions.go b/apps/daemon/internal/agent/mcode/functions.go index 5ecd224e6..32e88391d 100644 --- a/apps/daemon/internal/agent/mcode/functions.go +++ b/apps/daemon/internal/agent/mcode/functions.go @@ -203,8 +203,9 @@ func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.Functio } // confirmFunction applies a terminal native update for a function tool. Native -// echoes the returned result, with the call ID in its _meta, when it received -// one; any other end means native gave up on the call, such as its 120 s timeout. +// echoes the returned result, with the call ID in its _meta, only when it +// received one; any other end means native gave up on the call, such as its +// 120 s timeout. // Native runs MCP tools one at a time, so at most one call is in flight. func (s *Session) confirmFunction(update toolUpdate) { s.functions.mu.Lock() @@ -230,13 +231,13 @@ func (s *Session) confirmFunction(update toolUpdate) { if p == nil { return } - if id != p.call.CallID || p.result == nil || update.Status != "completed" && p.result.Success { + if id != p.call.CallID || p.result == nil { s.release(p) return } delete(s.functions.pending, id) status := "completed" - if !p.result.Success { + if update.Status != "completed" || !p.result.Success { status = "failed" } s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: p.call.Name, Stage: "after", Observation: &proto.ToolObservation{Kind: "function", Status: status, Name: p.call.Name, Arguments: p.call.Arguments, Content: &p.result.Content}}) diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index e42e98041..7c4d0551c 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -97,4 +97,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`, any tool name (the native client normalizes it, and the adapter rejects normalized collisions), and requires connected servers with static inventories. MiniMax Code serves public functions as the MCP server `functions`, so it rejects that label for public servers. 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. MiniMax Code serves public functions as the MCP server `functions`, so an Agent with functions cannot use that label for a public or Plugin server. Anonymous Claude requests send a blank Authorization header to suppress native OAuth injection. Native OAuth login is not supported. diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 808f61a24..e4c3c6b2e 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: 555e7bbcec87f5d1b9fd62bbb603c61a4df10fd0a245c64bb41398be78d52d67 +source_hash: 0e520d2d39598e8e2879e0a2db45cec5db57bb696094672dfaf9dde8957c7d72 --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -99,4 +99,4 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi - Bearer 认证使用附加的静态或 OAuth Vault 凭据。[Vault 凭据](vaults.md)定义选择规则,[MCP 凭据权限](environments.md#public-mcp-connection-origin)定义冻结的 Runtime 绑定。认证执行要求 `mcp_http_bearer_auth`。 - Runtime 必须声明 `mcp_http_tools`。原生 Harness 管理发现、调用和结果;公开 `mcp_call` Item 使用原始服务器与工具名称,保留观察到的原生结果。 -Codex 以原生方式信任工作区,因此工作区的项目配置在启动和冷恢复时加载,未声明的服务器、apps 和 plugins 保持原生行为。在任何携带 Core 凭据的原生连接之前,它验证每个已声明服务器保持其 URL 或命令、bearer 引用和请求头,否则启动或恢复以错误失败。它拒绝原生保留标签和已存储原生 MCP 凭据。Claude 接受 ASCII 字母、数字、下划线和连字符组成的标签,但不允许 `functions`;接受任意工具名称(原生客户端会将其规范化,适配器拒绝规范化后的冲突),并要求已连接服务器具有静态工具清单。MiniMax Code 以 MCP 服务器 `functions` 提供公共函数,因此拒绝公共服务器使用该标签。匿名 Claude 请求发送空 Authorization 头以阻止原生 OAuth 注入。不支持原生 OAuth 登录。 +Codex 以原生方式信任工作区,因此工作区的项目配置在启动和冷恢复时加载,未声明的服务器、apps 和 plugins 保持原生行为。在任何携带 Core 凭据的原生连接之前,它验证每个已声明服务器保持其 URL 或命令、bearer 引用和请求头,否则启动或恢复以错误失败。它拒绝原生保留标签和已存储原生 MCP 凭据。Claude 接受 ASCII 字母、数字、下划线和连字符组成的标签,但不允许 `functions`;接受任意工具名称(原生客户端会将其规范化,适配器拒绝规范化后的冲突),并要求已连接服务器具有静态工具清单。MiniMax Code 以 MCP 服务器 `functions` 提供公共函数,因此声明了函数的 Agent 不能让公共或 Plugin 服务器使用该标签。匿名 Claude 请求发送空 Authorization 头以阻止原生 OAuth 注入。不支持原生 OAuth 登录。 diff --git a/internal/harnessconfig/mcode/declaration.go b/internal/harnessconfig/mcode/declaration.go index 1c13b45cf..a2c6a3baf 100644 --- a/internal/harnessconfig/mcode/declaration.go +++ b/internal/harnessconfig/mcode/declaration.go @@ -85,7 +85,7 @@ var Declaration = harnessconfig.Declaration{ return errors.New("The configured engine requires allowed_tools=null and required=false.") } // The adapter serves public functions under this label. - if server.ServerLabel == FunctionServerLabel { + if len(functions) > 0 && server.ServerLabel == FunctionServerLabel { return errors.New("The configured engine reserves the HTTP MCP label functions.") } } diff --git a/services/core/internal/execution/harness_admission.go b/services/core/internal/execution/harness_admission.go index d342d9ba6..5ea223c11 100644 --- a/services/core/internal/execution/harness_admission.go +++ b/services/core/internal/execution/harness_admission.go @@ -77,7 +77,7 @@ func validateDeclaredInputs(declaration harnessconfig.Declaration, inputs []sess } continue } - if input.Kind != "tool_result" || declaration.ValidateFunctionResult == nil { + if input.Kind != "tool_result" { continue } var value sessions.FunctionResultInput @@ -88,8 +88,13 @@ func validateDeclaredInputs(declaration harnessconfig.Declaration, inputs []sess if err != nil { return sessions.ErrInvalidInput } - if err := declaration.ValidateFunctionResult(result); err != nil { - return admissionError(err) + if !declaration.Capabilities.FunctionResultImages.IsSupported() && (proto.MessageInput{{Content: result.Content}}).HasImages() { + return sessions.ErrInvalidInput + } + if declaration.ValidateFunctionResult != nil { + if err := declaration.ValidateFunctionResult(result); err != nil { + return admissionError(err) + } } } return nil diff --git a/services/core/internal/execution/message_support_test.go b/services/core/internal/execution/message_support_test.go index 8367a8dc7..866a792cc 100644 --- a/services/core/internal/execution/message_support_test.go +++ b/services/core/internal/execution/message_support_test.go @@ -81,3 +81,16 @@ func TestWhitespaceOnlyTextQualificationUsesEngineProfiles(t *testing.T) { t.Fatal("non-whitespace text or image rejected", err) } } + +// A Harness without function-result images rejects them before anything is stored. +func TestFunctionResultImagesFollowTheDeclaration(t *testing.T) { + image := sessions.Input{Kind: "tool_result", Payload: json.RawMessage(`{"turn_id":"t","call_id":"c","result":{"success":true,"output":[{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXioAAAAASUVORK5CYII="}]}}`)} + mcode, _ := builtin.Registry().Lookup("mcode") + if err := validateDeclaredInputs(mcode, []sessions.Input{image}); !errors.Is(err, sessions.ErrInvalidInput) { + t.Fatal("unsupported function-result image admitted", err) + } + codex, _ := builtin.Registry().Lookup("codex") + if err := validateDeclaredInputs(codex, []sessions.Input{image}); err != nil { + t.Fatal(err) + } +} From d57e8f5c9daabdc8374c36c52d25918ec6da7dcd Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 18:26:39 +0000 Subject: [PATCH 6/6] Reserve the functions label only beside functions in the profile test --- services/core/internal/execution/mcode_profile_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/core/internal/execution/mcode_profile_test.go b/services/core/internal/execution/mcode_profile_test.go index 9e7384699..971fa3075 100644 --- a/services/core/internal/execution/mcode_profile_test.go +++ b/services/core/internal/execution/mcode_profile_test.go @@ -15,7 +15,7 @@ func TestMCodeOperationQualification(t *testing.T) { {"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}, - {"reserved label", `{"agent":{"model":"real-model","tools":[{"type":"mcp","server_label":"functions","connection_origin":"service","transport":{"type":"http","server_url":"https://example.invalid/mcp"}}]},"environment":{"type":"none"}}`, false}, + {"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}, } {